History

deno

Rhys Sullivan committed 8 months ago

Commit 4ae53a41 parent 6a7950823 files · +2579 −45Browse files

Unified · Split

23 files
  1. ARCHITECTURE.md +407 −0
  2. README.md +314 −0
  3. apps/executor/README.md +0 −32
  4. apps/executor/src/server/server.test.ts +197 −1
  5. bun.lock +35 −2
  6. packages/control-plane/package.json +1 −1
  7. packages/control-plane/src/runtime/execution-http.test.ts +2 −2
  8. packages/control-plane/src/runtime/execution-mcp-resume.test.ts +3 −3
  9. packages/control-plane/src/runtime/index.ts +1 −0
  10. packages/control-plane/src/runtime/workspace-execution-environment.ts +2 −2
  11. packages/control-plane/vitest.config.ts +1 −0
  12. packages/executor-mcp/package.json +25 −0
  13. packages/executor-mcp/src/index.ts +569 −0
  14. packages/executor-mcp/tsconfig.json +15 −0
  15. packages/runtime-deno-subprocess/package.json +23 −0
  16. packages/runtime-deno-subprocess/src/deno-subprocess-worker.mjs +205 −0
  17. packages/runtime-deno-subprocess/src/deno-worker-process.ts +166 −0
  18. packages/runtime-deno-subprocess/src/index.test.ts +225 −0
  19. packages/runtime-deno-subprocess/src/index.ts +352 −0
  20. packages/runtime-deno-subprocess/tsconfig.json +15 −0
  21. packages/runtime-deno-subprocess/vitest.config.ts +8 −0
  22. packages/server/package.json +1 −0
  23. packages/server/src/index.ts +12 −2
ARCHITECTURE.md+407−0
@@ -0,0 +1,407 @@
1# Architecture
2
3This document explains the active v3 architecture at a high level.
4
5If `README.md` answers "what is this product and how do I use it?", this file answers "what are the major moving parts and how do they fit together?"
6
7## One-line view
8
9`executor` is a local daemonized control plane that turns connected sources into a workspace tool catalog and then runs TypeScript against that catalog, pausing for user interaction whenever a tool or auth flow needs it.
10
11## System shape
12
13```text
14 +-----------------------+
15 | CLI / executor |
16 | apps/executor |
17 +-----------+-----------+
18 |
19 | HTTP
20 v
21+-------------+ +---------+---------+ +----------------+
22| Browser UI |-->| local server |<--| MCP clients |
23| apps/web | | packages/server | | via /mcp |
24+-------------+ +---------+---------+ +----------------+
25 |
26 | provides runtime layer
27 v
28 +---------+---------+
29 | control plane |
30 | packages/control- |
31 | plane |
32 +----+-----+----+----+
33 | | |
34 persistence --+ | +-- source auth / discovery / inspection
35 |
36 +-- execution environment resolver
37 |
38 +-- live execution manager
39 v
40 +---------+---------+
41 | in-process TS |
42 | executor runtime |
43 | runtime-local- |
44 | inproc |
45 +--------------------+
46```
47
48## Design goals reflected in the code
49
50The current architecture optimizes for a few specific ideas:
51
52- local-first operation with one daemon instead of many disconnected tools
53- one shared runtime for CLI, browser UI, and MCP access
54- schema-rich tool usage instead of raw HTTP from prompts
55- reusable source connections that become workspace-scoped tools
56- human-in-the-loop execution that can pause and resume cleanly
57- adapters for multiple source kinds without hardwiring product logic to one protocol
58
59## Major components
60
61### `apps/executor`: installed CLI and daemon manager
62
63This is the main user entrypoint.
64
65Responsibilities:
66
67- exposes commands such as `up`, `down`, `status`, `doctor`, `call`, and `resume`
68- ensures the local daemon is running before execution
69- submits TypeScript executions to the local control plane
70- handles paused interaction flows, including opening browser URLs when needed
71- includes a few dev-only seed helpers for demo sources
72
73Conceptually, the CLI is not the business logic. It is a thin user-facing shell over the local runtime.
74
75### `packages/server`: one local process for API, MCP, and UI
76
77This package hosts the actual local server.
78
79Responsibilities:
80
81- creates the control-plane runtime
82- mounts the control-plane HTTP API at `/v1`
83- mounts the `executor` MCP endpoint at `/mcp`
84- serves the web UI assets for normal browser routes
85- writes PID metadata for daemon lifecycle management
86
87This is an important architectural choice: the API and UI are served by the same local process, so the product behaves like one install rather than a pile of separate services.
88
89### `packages/control-plane`: product core
90
91This is the center of the system.
92
93It contains the runtime layer, persistence integration, and the business logic for:
94
95- local installation bootstrap
96- accounts, organizations, and workspaces
97- source discovery and connection
98- source auth and credential flows
99- source inspection and tool indexing
100- secret and credential handling
101- execution creation, resumption, and state tracking
102- policy-aware tool invocation
103
104If you want to understand the behavior of the product, this is the most important package.
105
106### `packages/runtime-local-inproc`: code execution runtime
107
108This package provides the TypeScript execution environment used by the local product.
109
110At a high level it receives:
111
112- an executor implementation
113- a tool catalog
114- a tool invoker
115
116and runs user-authored code against that environment.
117
118The current runtime is in-process, which keeps the local architecture simple and fast.
119
120### Adapter packages
121
122Several packages exist to turn external systems into callable tools:
123
124- `packages/codemode-core`: shared tool abstractions, discovery, schemas, and system tools
125- `packages/codemode-mcp`: MCP tool loading and invocation
126- `packages/codemode-openapi`: OpenAPI extraction, manifests, and tool generation
127- `packages/executor-mcp`: exposes the local runtime itself as an MCP server
128- `packages/react`: React hooks and client state wrappers for the local UI
129
130These packages are what let the control plane treat multiple source kinds as one logical tool catalog.
131
132## Runtime model
133
134### Local installation bootstrap
135
136On first startup, the control plane provisions a local installation automatically.
137
138That bootstrap creates:
139
140- one local account
141- one personal organization
142- one default workspace
143- one local installation record that points at them
144
145This means the product can work out of the box as a local single-user system without requiring an external identity or tenant setup step.
146
147### Single daemon, shared state
148
149All main entrypoints talk to the same local daemon:
150
151- CLI commands call the local HTTP API
152- the web app calls the same local HTTP API
153- MCP hosts talk to the `/mcp` handler exposed by that same process
154
155Because of that, these surfaces share:
156
157- the same workspace
158- the same connected sources
159- the same secrets and credentials
160- the same execution history and interaction state
161
162## Persistence model
163
164The persistence layer is SQL-backed.
165
166Default behavior:
167
168- embedded PGlite for local use
169
170Optional behavior:
171
172- Postgres when a Postgres URL is supplied
173
174At a high level, the database stores these domains:
175
176- installation identity: local installation, accounts, organizations, memberships, workspaces
177- source state: sources, auth sessions, source credential bindings
178- tool model: tool artifacts and related metadata extracted from sources
179- secret state: credentials and secret materials
180- execution state: executions and execution interactions
181- governance state: policies
182
183This is why `executor` can reconnect sources once, inspect them later, and run multiple executions over time without rebuilding everything from scratch on every prompt.
184
185## Source lifecycle
186
187The source lifecycle is one of the defining architectural paths in the system.
188
189### 1. Discovery
190
191The Add Source flow starts with URL discovery.
192
193The discovery service probes a URL and tries, in order, to determine whether it looks like:
194
195- an OpenAPI source
196- a GraphQL source
197- an MCP source
198- or an unknown endpoint
199
200Discovery also produces metadata such as:
201
202- inferred source kind
203- confidence level
204- probable auth style
205- optional spec URL
206- warnings
207- potential namespace and transport hints
208
209### 2. Connection and auth
210
211Once a source is identified, the auth service drives the connection flow.
212
213Depending on the source, that may involve:
214
215- connecting immediately with no auth
216- asking for a bearer token or other credential material
217- creating an OAuth session and handing the user into a browser flow
218- persisting auth bindings back to the source
219
220The important architecture point is that source connection is not just a CRUD write. It is a runtime workflow that can branch into interactive auth.
221
222### 3. Tool indexing
223
224After a source is connected, `executor` materializes a workspace-visible tool model.
225
226The exact extraction path depends on the source kind:
227
228- OpenAPI sources are transformed from the OpenAPI document into tool manifests and typed operations
229- GraphQL sources are introspected into callable query and mutation tools
230- MCP sources are represented through persisted tool artifacts and runtime invocation metadata
231
232This indexed representation is what powers search, inspection, and execution without re-parsing everything in the UI.
233
234### 4. Inspection
235
236The source inspection service reconstructs a rich inspection bundle for the UI.
237
238That bundle includes:
239
240- source metadata
241- namespace and pipeline kind
242- tool summaries
243- tool detail payloads
244- raw document text when available
245- manifest and definitions JSON when available
246
247The UI uses this to show both a tree view of tools and a search-oriented discovery view.
248
249## Execution architecture
250
251Execution is the other defining path in the system.
252
253### 1. Create execution
254
255An execution begins when the CLI, API, or MCP bridge submits TypeScript code.
256
257The control plane creates an execution record with status such as:
258
259- `pending`
260- `running`
261- `waiting_for_interaction`
262- `completed`
263- `failed`
264
265### 2. Resolve the workspace execution environment
266
267Before code runs, the runtime builds the execution environment for the current workspace.
268
269That environment combines:
270
271- built-in system tools from `codemode-core`
272- `executor` internal tools such as `executor.sources.add`
273- persisted tools generated from connected OpenAPI, GraphQL, and MCP sources
274
275The resolver returns three things:
276
277- the in-process code executor
278- the workspace tool catalog
279- the tool invoker that actually dispatches calls
280
281This resolver is the composition point where the whole product becomes one callable tool surface.
282
283### 3. Run TypeScript
284
285The code then runs inside the local in-process runtime.
286
287The intended calling pattern is:
288
2891. discover a tool by intent
2902. inspect its schema if needed
2913. call the selected `tools.*` path
292
293The runtime is deliberately opinionated here: the product is built around tool calls, not ad hoc `fetch` requests from user code.
294
295### 4. Invoke tools through the control plane
296
297When code calls a tool, the control plane decides how to handle it.
298
299Possible paths include:
300
301- built-in system tool invocation
302- internal `executor` tool invocation
303- OpenAPI tool invocation with resolved auth headers
304- GraphQL tool invocation with resolved auth headers
305- MCP tool invocation through the MCP connector
306
307This is also the point where policy checks and source auth resolution happen.
308
309### 5. Persist results and surface interactions
310
311The execution service updates persistent execution state as the run progresses.
312
313If a tool call or auth flow needs user interaction, the live execution manager:
314
315- creates an execution interaction record
316- marks the execution as `waiting_for_interaction`
317- stores enough payload to resume later
318- waits for a structured elicitation response
319
320Once the response arrives, the manager moves the execution back to `running` and the code continues.
321
322## Human interaction model
323
324A major architectural feature of `executor` is that interactions are first-class runtime state.
325
326This is handled by the live execution manager and the execution interaction tables.
327
328That gives the product a clean pause/resume loop instead of forcing every host to improvise its own half-finished approval flow.
329
330Interaction types include:
331
332- URL-based flows, such as opening a secure credential page or OAuth URL
333- form-like elicitation where a host can provide a structured response
334
335This same idea is used both for direct CLI execution and for the MCP-facing `execute` and `resume` tools.
336
337## API and MCP surfaces
338
339### HTTP API
340
341The control-plane HTTP API is mounted under `/v1`.
342
343Major groups include:
344
345- local installation and secret management
346- OAuth
347- organizations and memberships
348- workspaces
349- sources
350- policies
351- executions
352
353The web UI is a client of this API.
354
355### MCP bridge
356
357`packages/executor-mcp` exposes the local runtime as an MCP server.
358
359The bridge registers two main tools:
360
361- `execute`: run TypeScript against the local runtime
362- `resume`: continue a paused execution
363
364When the MCP host supports managed elicitation, the bridge can drive the interaction loop directly through MCP instead of requiring the caller to implement custom resume handling.
365
366## Frontend architecture
367
368The web app lives in `apps/web` and is a React app built with Vite.
369
370At a high level:
371
372- routes are managed with TanStack Router
373- data access is wrapped by `@executor-v3/react`
374- the UI is focused on sources, source inspection, and secrets
375- production assets are served by the local server
376- development runs Vite separately while still embedding the same backend behavior
377
378The frontend is intentionally thin. It is mostly a presentation layer over the control-plane API.
379
380## Why the architecture is shaped this way
381
382The system is trying to solve a specific product problem:
383
384- give agents a better way to use tools than pasting giant manifests into prompts
385- keep auth, credentials, and user interactions in a durable runtime
386- let many protocols look like one workspace tool surface
387- make CLI, UI, and MCP hosts all operate on the same local state
388
389That is why the architecture keeps converging on the same central idea:
390
391`executor` is not just a CLI and not just an MCP server. It is a local runtime that owns source connection, tool indexing, execution, and interaction state.
392
393## Current boundaries
394
395A few practical boundaries are worth calling out:
396
397- the active implementation is local-first and single-daemon
398- the current web app is React/Vite, not the older architectures mentioned in planning notes
399- `legacy/` and `legacy2/` are historical context, not the active runtime
400- policy infrastructure exists in the core, but the central product loop today is source connection plus execution
401
402## Read next
403
404- `README.md` for the product view and usage guidance
405- `apps/executor/src/cli/main.ts` for the CLI surface
406- `packages/server/src/index.ts` for how the local server is assembled
407- `packages/control-plane/src/runtime/` for the core runtime flows
README.md+314−0
@@ -0,0 +1,314 @@
1# executor
2
3`executor` is a local-first execution environment for AI agents.
4
5It gives an agent a TypeScript runtime, a discoverable tool catalog, and a single local place to connect external systems such as MCP servers, OpenAPI APIs, and GraphQL APIs. Instead of pasting large MCP manifests into every chat or giving an agent broad shell access, you run code inside `executor` and let it call typed `tools.*` functions.
6
7At runtime, `executor` behaves like one local product:
8
9- a CLI for starting the runtime and executing code
10- a local API server
11- a local web UI for connecting sources, inspecting tools, and managing secrets
12- an MCP endpoint for hosts that want to drive `executor` through MCP
13
14The current codebase is the v3 rewrite. Older experiments live in `legacy/` and `legacy2/`, but the active architecture is the one in `apps/` and `packages/`.
15
16## Why this exists
17
18`executor` is built around a simple idea: agents should work against a structured tool environment instead of guessing at raw HTTP calls, carrying huge MCP definitions in context, or running arbitrary local commands with broad permissions.
19
20In practice that means:
21
22- sources are connected once and turned into a reusable workspace tool catalog
23- the agent discovers tools by intent, inspects schemas, and then calls typed functions
24- secrets and OAuth flows stay in the local runtime and web UI instead of being pasted into chat
25- human interaction can pause an execution and resume it cleanly
26
27## Mental model
28
29Think of `executor` as a local control plane for agent tool use.
30
311. You start a local `executor` daemon.
322. You connect sources such as an MCP server, an OpenAPI document, or a GraphQL endpoint.
333. `executor` indexes those sources into a workspace tool catalog.
344. An agent runs TypeScript against that catalog through `executor call` or through the MCP bridge.
355. If a tool needs credentials or user input, execution pauses, opens a local flow, and then resumes.
36
37## What it does today
38
39### Connect external tool sources
40
41`executor` currently supports these source types:
42
43- `mcp`: remote MCP servers, including transport selection for streamable HTTP or SSE
44- `openapi`: REST APIs described by an OpenAPI document
45- `graphql`: GraphQL endpoints that can be introspected into callable tools
46
47The add-source flow can:
48
49- probe a URL and infer what kind of source it is
50- infer likely authentication requirements
51- prompt for credentials when discovery or connection needs them
52- start OAuth when a source requires it
53- persist the source and its indexed tool metadata in the local workspace
54
55The web app also includes templates for common providers so you can start from real examples instead of filling every field by hand.
56
57### Run agent code against tools
58
59The main CLI workflow is `executor call`.
60
61The runtime expects the agent to use the built-in discovery workflow:
62
63```ts
64const matches = await tools.discover({ query: "github issues", limit: 5 });
65const path = matches.bestPath;
66const detail = await tools.describe.tool({ path, includeSchemas: true });
67
68return await tools.github.issues.list({
69 owner: "vercel",
70 repo: "next.js",
71});
72```
73
74A few important rules shape the execution model:
75
76- write TypeScript, not raw shell pipelines
77- use `tools.*`, not direct `fetch`
78- discover first when the exact tool path is not known
79- inspect schemas before calling complex tools
80
81### Handle credentials and user interaction
82
83When a source or tool needs human input, `executor` can pause the execution and create an interaction record.
84
85That interaction may ask you to:
86
87- open a secure local credential page
88- complete an OAuth flow in the browser
89- respond to a structured elicitation from a tool host
90- resume a paused execution from the CLI
91
92This is the core human-in-the-loop behavior that lets `executor` keep secrets and approvals outside the agent's raw context.
93
94### Inspect the connected tool model
95
96The web UI is not just a setup surface. It is also where you can inspect what `executor` learned from a source.
97
98For each source you can:
99
100- browse its tool tree
101- search for tools by intent
102- inspect input and output schemas
103- view generated manifests, definitions, and raw source documents when available
104- edit source settings and authentication details
105
106## Quick start
107
108If you are working from this repository, the easiest path is:
109
110```bash
111bun install
112bun run executor doctor --json
113bun run executor up
114```
115
116That starts the local runtime. The default base URL is:
117
118```text
119http://127.0.0.1:8788
120```
121
122From there:
123
1241. Open the web UI in your browser.
1252. Add a source from `/sources/add`.
1263. If needed, store credentials in `/secrets`.
1274. Run TypeScript with `bun run executor call ...`.
128
129If you are using a packaged distribution, the command name is simply `executor` instead of `bun run executor`.
130
131## Core CLI commands
132
133```bash
134executor up
135executor down
136executor status --json
137executor doctor --json
138executor call --file script.ts
139executor resume --execution-id exec_123
140```
141
142`executor call` accepts code in three ways:
143
144- inline as a positional argument
145- from `--file`
146- from standard input with `--stdin`
147
148Examples:
149
150```bash
151executor call 'const matches = await tools.discover({ query: "repo details", limit: 1 }); return matches;'
152executor call --file script.ts
153cat script.ts | executor call --stdin
154executor call --no-open --file script.ts
155```
156
157If an execution pauses, resume it with:
158
159```bash
160executor resume --execution-id exec_123
161```
162
163## Adding a source
164
165There are two main ways to add a source.
166
167### In the web UI
168
169Use the Add Source flow to:
170
171- paste a URL
172- run discovery
173- review the inferred kind, namespace, transport, and auth
174- connect the source
175- complete credential or OAuth setup if required
176
177This is the easiest path for most users.
178
179### From inside an execution
180
181The runtime also exposes `tools.executor.sources.add(...)`, which lets an agent add a source from code.
182
183Examples:
184
185```ts
186return await tools.executor.sources.add({
187 endpoint: "https://example.com/mcp",
188 name: "Example",
189 namespace: "example",
190});
191```
192
193```ts
194return await tools.executor.sources.add({
195 kind: "openapi",
196 endpoint: "https://api.github.com",
197 specUrl: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json",
198 name: "GitHub",
199 namespace: "github",
200});
201```
202
203For HTTP-style sources, `executor` can drive the credential flow for you.
204
205## How execution works
206
207At a high level, every execution follows the same loop:
208
2091. `executor` resolves the current local installation and workspace.
2102. It builds a tool catalog from built-in tools plus all connected workspace sources.
2113. It runs your TypeScript inside the in-process execution runtime.
2124. Tool calls are dispatched through `executor` rather than directly from your code.
2135. If a tool needs interaction, the run pauses and records a pending interaction.
2146. Once the interaction is resolved, the execution continues and eventually completes or fails.
215
216This gives you a stable surface for agent automation:
217
218- the agent sees a coherent catalog
219- connected sources become reusable namespace-based tools
220- auth stays attached to sources and secret material
221- the runtime can track execution state instead of losing it inside a one-shot prompt
222
223## Web UI overview
224
225The React web app is served from the same local server as the API.
226
227Main screens:
228
229- `/`: list connected sources in the current local workspace
230- `/sources/add`: discover and connect new sources
231- `/sources/:sourceId`: inspect tools, search tools, and browse source artifacts
232- `/sources/:sourceId/edit`: edit source settings and auth
233- `/secrets`: create, update, and delete locally stored secrets
234
235The UI uses the same control-plane API as the CLI, so both surfaces are operating on the same local runtime state.
236
237## Local-first runtime behavior
238
239By default `executor` runs as a single local daemon process.
240
241It serves:
242
243- `/v1` for the local control-plane API
244- `/mcp` for the `executor` MCP endpoint
245- the web UI for normal browser routes
246
247Default network location:
248
249- host: `127.0.0.1`
250- port: `8788`
251
252Default data locations are OS-aware:
253
254- Linux data: `~/.local/share/executor`
255- Linux runtime state: `~/.local/state/executor/run`
256- macOS: `~/Library/Application Support/Executor`
257- Windows: `%LOCALAPPDATA%\Executor`
258
259The server also maintains local PID and log files in its runtime directory.
260
261## Persistence and data
262
263`executor` persists the local control plane to SQL.
264
265By default it uses embedded PGlite for a local install, which keeps the product self-contained. If you provide a Postgres URL, the persistence layer can target real Postgres instead.
266
267Persisted concepts include:
268
269- the local installation record
270- organizations and workspaces provisioned for that install
271- connected sources
272- indexed tool artifacts and related metadata
273- credentials and secret material bindings
274- source auth sessions
275- executions and execution interactions
276- policies
277
278On first start, `executor` provisions a local account, a personal organization, and a default workspace automatically.
279
280## Security and trust model
281
282`executor` is designed to narrow how agents interact with external systems.
283
284Compared with direct shell or raw API usage, the model is intentionally more structured:
285
286- tool calls are routed through a controlled runtime
287- secrets are stored separately from prompt text
288- OAuth and credential capture happen through local flows
289- executions can pause for interaction instead of guessing or failing silently
290- source auth and tool metadata live with the workspace rather than inside each prompt
291
292This does not make the system magically risk-free, but it gives the runtime places to enforce policy, collect approvals, and keep sensitive material out of the agent's immediate context.
293
294## Repository layout
295
296If you are exploring the repo, these are the directories that matter most:
297
298- `apps/executor`: packaged CLI entrypoint and daemon lifecycle commands
299- `apps/web`: local React web UI
300- `packages/server`: local HTTP server that serves API, MCP, and UI
301- `packages/control-plane`: source management, secrets, persistence, execution, and inspection
302- `packages/runtime-local-inproc`: in-process TypeScript execution runtime
303- `packages/executor-mcp`: MCP bridge for `execute` and `resume`
304- `packages/codemode-*`: core tool abstractions plus MCP and OpenAPI adapters
305
306## Project status
307
308This repository is explicitly on its third major architecture iteration.
309
310- `apps/` and `packages/` are the active implementation
311- `legacy/` is the original codebase
312- `legacy2/` is the second generation
313
314If you want the system view instead of the product view, read [`ARCHITECTURE.md`](./ARCHITECTURE.md).
apps/executor/README.md+0−32
@@ -1,32 +0,0 @@
1# executor
2
3`executor` is a local AI executor with a CLI, a local API server, and a web UI.
4
5It installs as a user command, starts a single local background server, and serves both the API and frontend from that local process.
6
7## Install
8
9```bash
10npm install -g executor
11```
12
13## Use
14
15```bash
16executor doctor --json
17executor up
18executor status --json
19executor down
20```
21
22## What it does
23
24- installs a local `executor` command
25- stores local app data in standard user directories
26- starts one local daemon process
27- serves the local API and UI together
28
29## Links
30
31- Repository: https://github.com/RhysSullivan/executor
32- Issues: https://github.com/RhysSullivan/executor/issues
apps/executor/src/server/server.test.ts+197−1
@@ -14,7 +14,7 @@ import * as Effect from "effect/Effect"; · 13 unmodified lines
14import * as Schema from "effect/Schema";14import * as Schema from "effect/Schema";
15 15
16import { startMcpElicitationDemoServer } from "@executor-v3/mcp-elicitation-demo";16import { startMcpElicitationDemoServer } from "@executor-v3/mcp-elicitation-demo";
17import { makeToolInvokerFromTools } from "@executor-v3/codemode-core";17import { makeToolInvokerFromTools, toTool } from "@executor-v3/codemode-core";
18import {18import {
19 ControlPlaneStorageError,19 ControlPlaneStorageError,
20 createControlPlaneClient,20 createControlPlaneClient,
@@ -22,9 +22,12 @@ import { · 1 unmodified line
22 type ResolveExecutionEnvironment,22 type ResolveExecutionEnvironment,
23} from "@executor-v3/control-plane";23} from "@executor-v3/control-plane";
24import { makeInProcessExecutor } from "@executor-v3/runtime-local-inproc";24import { makeInProcessExecutor } from "@executor-v3/runtime-local-inproc";
25import { Client } from "@modelcontextprotocol/sdk/client/index.js";
26import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
25import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";27import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
26import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";28import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
27import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";29import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
30import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
28import { z } from "zod/v4";31import { z } from "zod/v4";
29 32
30import {33import {
@@ -58,6 +61,79 @@ const makeServer = createLocalExecutorServer({ · 27 unmodified lines
58 executionResolver,61 executionResolver,
59});62});
60 63
64const gatedExecutionResolver: ResolveExecutionEnvironment = ({ onElicitation }) =>
65 Effect.succeed({
66 executor: makeInProcessExecutor(),
67 toolInvoker: makeToolInvokerFromTools({
68 tools: {
69 "demo.gated_echo": toTool({
70 tool: {
71 description: "Asks for approval before echoing a value",
72 inputSchema: Schema.standardSchemaV1(
73 Schema.Struct({
74 value: Schema.String,
75 approve: Schema.optional(Schema.Boolean),
76 }),
77 ),
78 execute: ({ value, approve }: { value: string; approve?: boolean }) =>
79 approve === true ? `approved:${value}` : `denied:${value}`,
80 },
81 metadata: {
82 sourceKey: "demo",
83 elicitation: {
84 mode: "form",
85 message: "Approve gated echo?",
86 requestedSchema: {
87 type: "object",
88 properties: {
89 approve: {
90 type: "boolean",
91 title: "Approve",
92 },
93 },
94 required: ["approve"],
95 },
96 },
97 },
98 }),
99 },
100 onElicitation,
101 }),
102 });
103
104type ExecutorMcpClient = {
105 client: Client;
106 close: () => Promise<void>;
107};
108
109const makeExecutorMcpClient = (input: {
110 baseUrl: string;
111 capabilities?: Record<string, unknown>;
112 onElicitation?: (request: { params: { message?: string } }) => Promise<unknown>;
113}) =>
114 Effect.acquireRelease(
115 Effect.promise<ExecutorMcpClient>(async () => {
116 const client = new Client(
117 { name: "executor-mcp-test-client", version: "1.0.0" },
118 { capabilities: input.capabilities ?? {} },
119 );
120 if (input.onElicitation) {
121 client.setRequestHandler(ElicitRequestSchema, input.onElicitation);
122 }
123
124 const transport = new StreamableHTTPClientTransport(new URL(`${input.baseUrl}/mcp`));
125 await client.connect(transport);
126
127 return {
128 client,
129 close: async () => {
130 await client.close();
131 },
132 };
133 }),
134 ({ close }) => Effect.promise(() => close()).pipe(Effect.orDie),
135 );
136
61const ownerParam = HttpApiSchema.param("owner", Schema.String);137const ownerParam = HttpApiSchema.param("owner", Schema.String);
62const repoParam = HttpApiSchema.param("repo", Schema.String);138const repoParam = HttpApiSchema.param("repo", Schema.String);
63 139
@@ -547,6 +623,126 @@ describe("local-executor-server", () => { · 483 unmodified lines
547 }),623 }),
548 );624 );
549 625
626 it.scoped("serves only execute over MCP when elicitation is supported", () =>
627 Effect.gen(function* () {
628 const server = yield* createLocalExecutorServer({
629 port: 0,
630 localDataDir: ":memory:",
631 executionResolver: gatedExecutionResolver,
632 });
633 const mcp = yield* makeExecutorMcpClient({
634 baseUrl: server.baseUrl,
635 capabilities: {
636 elicitation: {
637 form: {},
638 url: {},
639 },
640 },
641 onElicitation: async () => ({
642 action: "accept",
643 content: {
644 approve: true,
645 },
646 }),
647 });
648
649 const listed = yield* Effect.promise(
650 () => mcp.client.listTools() as Promise<{ tools: Array<{ name: string }> }>,
651 );
652 expect(listed.tools.map((tool) => tool.name)).toEqual(["execute"]);
653 expect(mcp.client.getInstructions()).toContain(
654 `npx add-mcp \"${server.baseUrl}/mcp\" --transport http --name \"executor\"`,
655 );
656
657 const executed = yield* Effect.promise(
658 () =>
659 mcp.client.callTool({
660 name: "execute",
661 arguments: {
662 code: 'return await tools.demo.gated_echo({ value: "from-mcp" });',
663 },
664 }) as Promise<{
665 structuredContent?: {
666 status?: string;
667 result?: unknown;
668 };
669 }>,
670 );
671
672 expect(executed.structuredContent?.status).toBe("completed");
673 expect(executed.structuredContent?.result).toBe("approved:from-mcp");
674 }),
675 );
676
677 it.scoped("serves execute and resume over MCP when elicitation is unavailable", () =>
678 Effect.gen(function* () {
679 const server = yield* createLocalExecutorServer({
680 port: 0,
681 localDataDir: ":memory:",
682 executionResolver: gatedExecutionResolver,
683 });
684 const mcp = yield* makeExecutorMcpClient({
685 baseUrl: server.baseUrl,
686 });
687
688 const listed = yield* Effect.promise(
689 () => mcp.client.listTools() as Promise<{ tools: Array<{ name: string }> }>,
690 );
691 expect(listed.tools.map((tool) => tool.name).sort()).toEqual(["execute", "resume"]);
692 expect(mcp.client.getInstructions()).toContain(
693 `npx add-mcp \"${server.baseUrl}/mcp\" --transport http --name \"executor\"`,
694 );
695
696 const executed = yield* Effect.promise(
697 () =>
698 mcp.client.callTool({
699 name: "execute",
700 arguments: {
701 code: 'return await tools.demo.gated_echo({ value: "manual-mcp" });',
702 },
703 }) as Promise<{
704 structuredContent?: {
705 status?: string;
706 interaction?: {
707 message?: string;
708 };
709 resumePayload?: {
710 executionId?: string;
711 };
712 };
713 }>,
714 );
715
716 expect(executed.structuredContent?.status).toBe("waiting_for_interaction");
717 expect(executed.structuredContent?.interaction?.message).toContain("Approve gated echo");
718 expect(executed.structuredContent?.resumePayload?.executionId).toBeTruthy();
719
720 const resumed = yield* Effect.promise(
721 () =>
722 mcp.client.callTool({
723 name: "resume",
724 arguments: {
725 resumePayload: executed.structuredContent?.resumePayload,
726 response: {
727 action: "accept",
728 content: {
729 approve: true,
730 },
731 },
732 },
733 }) as Promise<{
734 structuredContent?: {
735 status?: string;
736 result?: unknown;
737 };
738 }>,
739 );
740
741 expect(resumed.structuredContent?.status).toBe("completed");
742 expect(resumed.structuredContent?.result).toBe("approved:manual-mcp");
743 }),
744 );
745
550 it.scoped("loads MCP sources from control-plane state and resumes elicitation", () =>746 it.scoped("loads MCP sources from control-plane state and resumes elicitation", () =>
551 Effect.gen(function* () {747 Effect.gen(function* () {
552 const demoServer = yield* Effect.acquireRelease(748 const demoServer = yield* Effect.acquireRelease(
bun.lock+35−2
@@ -12,7 +12,7 @@ · 11 unmodified lines
12 },12 },
13 "apps/executor": {13 "apps/executor": {
14 "name": "executor",14 "name": "executor",
15 "version": "0.0.0-local",15 "version": "1.1.0",
16 "bin": {16 "bin": {
17 "executor": "./bin/executor",17 "executor": "./bin/executor",
18 },18 },
@@ -146,7 +146,7 @@ · 127 unmodified lines
146 "@executor-v3/codemode-core": "workspace:*",146 "@executor-v3/codemode-core": "workspace:*",
147 "@executor-v3/codemode-mcp": "workspace:*",147 "@executor-v3/codemode-mcp": "workspace:*",
148 "@executor-v3/codemode-openapi": "workspace:*",148 "@executor-v3/codemode-openapi": "workspace:*",
149 "@executor-v3/runtime-local-inproc": "workspace:*",149 "@executor-v3/runtime-deno-subprocess": "workspace:*",
150 "drizzle-orm": "1.0.0-beta.15-859cf75",150 "drizzle-orm": "1.0.0-beta.15-859cf75",
151 "effect": "catalog:",151 "effect": "catalog:",
152 "graphql": "^16.12.0",152 "graphql": "^16.12.0",
@@ -164,6 +164,21 @@ · 11 unmodified lines
164 "zod": "catalog:",164 "zod": "catalog:",
165 },165 },
166 },166 },
167 "packages/executor-mcp": {
168 "name": "@executor-v3/executor-mcp",
169 "dependencies": {
170 "@executor-v3/control-plane": "workspace:*",
171 "@modelcontextprotocol/sdk": "^1.26.0",
172 "effect": "catalog:",
173 "zod": "catalog:",
174 },
175 "devDependencies": {
176 "@effect/vitest": "catalog:",
177 "@types/node": "catalog:",
178 "bun-types": "catalog:",
179 "vitest": "catalog:",
180 },
181 },
167 "packages/kitchen-sink": {182 "packages/kitchen-sink": {
168 "name": "@executor-v3/kitchen-sink",183 "name": "@executor-v3/kitchen-sink",
169 "dependencies": {184 "dependencies": {
@@ -207,6 +222,19 @@ · 37 unmodified lines
207 "react": ">=18 <20",222 "react": ">=18 <20",
208 },223 },
209 },224 },
225 "packages/runtime-deno-subprocess": {
226 "name": "@executor-v3/runtime-deno-subprocess",
227 "dependencies": {
228 "@executor-v3/codemode-core": "workspace:*",
229 "effect": "catalog:",
230 },
231 "devDependencies": {
232 "@effect/vitest": "catalog:",
233 "@types/node": "catalog:",
234 "bun-types": "catalog:",
235 "vitest": "catalog:",
236 },
237 },
210 "packages/runtime-local-inproc": {238 "packages/runtime-local-inproc": {
211 "name": "@executor-v3/runtime-local-inproc",239 "name": "@executor-v3/runtime-local-inproc",
212 "dependencies": {240 "dependencies": {
@@ -223,6 +251,7 @@ · 10 unmodified lines
223 "dependencies": {251 "dependencies": {
224 "@effect/platform": "catalog:",252 "@effect/platform": "catalog:",
225 "@executor-v3/control-plane": "workspace:*",253 "@executor-v3/control-plane": "workspace:*",
254 "@executor-v3/executor-mcp": "workspace:*",
226 "effect": "catalog:",255 "effect": "catalog:",
227 },256 },
228 "devDependencies": {257 "devDependencies": {
@@ -437,12 +466,16 @@ · 208 unmodified lines
437 466
438 "@executor-v3/control-plane": ["@executor-v3/control-plane@workspace:packages/control-plane"],467 "@executor-v3/control-plane": ["@executor-v3/control-plane@workspace:packages/control-plane"],
439 468
469 "@executor-v3/executor-mcp": ["@executor-v3/executor-mcp@workspace:packages/executor-mcp"],
470
440 "@executor-v3/kitchen-sink": ["@executor-v3/kitchen-sink@workspace:packages/kitchen-sink"],471 "@executor-v3/kitchen-sink": ["@executor-v3/kitchen-sink@workspace:packages/kitchen-sink"],
441 472
442 "@executor-v3/mcp-elicitation-demo": ["@executor-v3/mcp-elicitation-demo@workspace:apps/mcp-elicitation-demo"],473 "@executor-v3/mcp-elicitation-demo": ["@executor-v3/mcp-elicitation-demo@workspace:apps/mcp-elicitation-demo"],
443 474
444 "@executor-v3/react": ["@executor-v3/react@workspace:packages/react"],475 "@executor-v3/react": ["@executor-v3/react@workspace:packages/react"],
445 476
477 "@executor-v3/runtime-deno-subprocess": ["@executor-v3/runtime-deno-subprocess@workspace:packages/runtime-deno-subprocess"],
478
446 "@executor-v3/runtime-local-inproc": ["@executor-v3/runtime-local-inproc@workspace:packages/runtime-local-inproc"],479 "@executor-v3/runtime-local-inproc": ["@executor-v3/runtime-local-inproc@workspace:packages/runtime-local-inproc"],
447 480
448 "@executor-v3/server": ["@executor-v3/server@workspace:packages/server"],481 "@executor-v3/server": ["@executor-v3/server@workspace:packages/server"],
packages/control-plane/package.json+1−1
@@ -28,7 +28,7 @@ · 27 unmodified lines
28 "@executor-v3/codemode-core": "workspace:*",28 "@executor-v3/codemode-core": "workspace:*",
29 "@executor-v3/codemode-mcp": "workspace:*",29 "@executor-v3/codemode-mcp": "workspace:*",
30 "@executor-v3/codemode-openapi": "workspace:*",30 "@executor-v3/codemode-openapi": "workspace:*",
31 "@executor-v3/runtime-local-inproc": "workspace:*",31 "@executor-v3/runtime-deno-subprocess": "workspace:*",
32 "drizzle-orm": "1.0.0-beta.15-859cf75",32 "drizzle-orm": "1.0.0-beta.15-859cf75",
33 "effect": "catalog:",33 "effect": "catalog:",
34 "graphql": "^16.12.0",34 "graphql": "^16.12.0",
packages/control-plane/src/runtime/execution-http.test.ts+2−2
@@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; · 2 unmodified lines
3import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
4 4
5import { makeToolInvokerFromTools } from "@executor-v3/codemode-core";5import { makeToolInvokerFromTools } from "@executor-v3/codemode-core";
6import { makeInProcessExecutor } from "@executor-v3/runtime-local-inproc";6import { makeDenoSubprocessExecutor } from "@executor-v3/runtime-deno-subprocess";
7 7
8import {8import {
9 createSqlControlPlaneRuntime,9 createSqlControlPlaneRuntime,
@@ -31,7 +31,7 @@ const makeExecutionResolver = () => { · 21 unmodified lines
31 31
32 return () =>32 return () =>
33 Effect.succeed({33 Effect.succeed({
34 executor: makeInProcessExecutor(),34 executor: makeDenoSubprocessExecutor(),
35 toolInvoker,35 toolInvoker,
36 });36 });
37};37};
packages/control-plane/src/runtime/execution-mcp-resume.test.ts+3−3
@@ -12,7 +12,7 @@ import { z } from "zod/v4"; · 11 unmodified lines
12 12
13import { makeToolInvokerFromTools } from "@executor-v3/codemode-core";13import { makeToolInvokerFromTools } from "@executor-v3/codemode-core";
14import { discoverMcpToolsFromConnector } from "@executor-v3/codemode-mcp";14import { discoverMcpToolsFromConnector } from "@executor-v3/codemode-mcp";
15import { makeInProcessExecutor } from "@executor-v3/runtime-local-inproc";15import { makeDenoSubprocessExecutor } from "@executor-v3/runtime-deno-subprocess";
16 16
17import {17import {
18 createSqlControlPlaneRuntime,18 createSqlControlPlaneRuntime,
@@ -262,7 +262,7 @@ const makeMcpExecutionResolver = ( · 243 unmodified lines
262 });262 });
263 263
264 return {264 return {
265 executor: makeInProcessExecutor(),265 executor: makeDenoSubprocessExecutor(),
266 toolInvoker: makeToolInvokerFromTools({266 toolInvoker: makeToolInvokerFromTools({
267 tools: discovered.tools,267 tools: discovered.tools,
268 onElicitation,268 onElicitation,
@@ -270,7 +270,7 @@ const makeMcpExecutionResolver = ( · 1 unmodified line
270 };270 };
271 }) as Effect.Effect<271 }) as Effect.Effect<
272 {272 {
273 executor: ReturnType<typeof makeInProcessExecutor>;273 executor: ReturnType<typeof makeDenoSubprocessExecutor>;
274 toolInvoker: ReturnType<typeof makeToolInvokerFromTools>;274 toolInvoker: ReturnType<typeof makeToolInvokerFromTools>;
275 },275 },
276 unknown276 unknown
packages/control-plane/src/runtime/index.ts+1−0
@@ -61,6 +61,7 @@ export * from "./store"; · 60 unmodified lines
61export * from "./workspace-execution-environment";61export * from "./workspace-execution-environment";
62export * from "./source-inspection";62export * from "./source-inspection";
63export * from "./source-discovery";63export * from "./source-discovery";
64export * from "./execution-service";
64 65
65export type RuntimeControlPlaneOptions = {66export type RuntimeControlPlaneOptions = {
66 actorResolver?: ControlPlaneActorResolverShape;67 actorResolver?: ControlPlaneActorResolverShape;
packages/control-plane/src/runtime/workspace-execution-environment.ts+2−2
@@ -23,7 +23,7 @@ import { · 22 unmodified lines
23 type OpenApiToolDefinition,23 type OpenApiToolDefinition,
24 type OpenApiToolManifest,24 type OpenApiToolManifest,
25} from "@executor-v3/codemode-openapi";25} from "@executor-v3/codemode-openapi";
26import { makeInProcessExecutor } from "@executor-v3/runtime-local-inproc";26import { makeDenoSubprocessExecutor } from "@executor-v3/runtime-deno-subprocess";
27import {27import {
28 SqlControlPlaneRowsService,28 SqlControlPlaneRowsService,
29 type SqlControlPlaneRows,29 type SqlControlPlaneRows,
@@ -1215,7 +1215,7 @@ export const createWorkspaceExecutionEnvironmentResolver = (input: { · 1185 unmodified lines
1215 });1215 });
1216 1216
1217 return {1217 return {
1218 executor: makeInProcessExecutor(),1218 executor: makeDenoSubprocessExecutor(),
1219 toolInvoker,1219 toolInvoker,
1220 catalog,1220 catalog,
1221 } satisfies ExecutionEnvironment;1221 } satisfies ExecutionEnvironment;
packages/control-plane/vitest.config.ts+1−0
@@ -3,5 +3,6 @@ import { defineConfig } from "vitest/config"; · 2 unmodified lines
3export default defineConfig({3export default defineConfig({
4 test: {4 test: {
5 include: ["src/**/*.test.ts"],5 include: ["src/**/*.test.ts"],
6 testTimeout: 30_000,
6 },7 },
7});8});
packages/executor-mcp/package.json+25−0
@@ -0,0 +1,25 @@
1{
2 "name": "@executor-v3/executor-mcp",
3 "private": true,
4 "type": "module",
5 "exports": {
6 ".": "./src/index.ts"
7 },
8 "scripts": {
9 "typecheck": "bunx tsc --noEmit -p tsconfig.json",
10 "test": "vitest run",
11 "test:watch": "vitest"
12 },
13 "dependencies": {
14 "@executor-v3/control-plane": "workspace:*",
15 "@modelcontextprotocol/sdk": "^1.26.0",
16 "effect": "catalog:",
17 "zod": "catalog:"
18 },
19 "devDependencies": {
20 "@effect/vitest": "catalog:",
21 "@types/node": "catalog:",
22 "bun-types": "catalog:",
23 "vitest": "catalog:"
24 }
25}
packages/executor-mcp/src/index.ts+569−0
@@ -0,0 +1,569 @@
1import type {
2 ExecutionEnvelope,
3 ExecutionInteraction,
4 SqlControlPlaneRuntime,
5} from "@executor-v3/control-plane";
6import {
7 createExecution,
8 getExecution,
9 resumeExecution,
10} from "@executor-v3/control-plane";
11import * as Effect from "effect/Effect";
12import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
14import { z } from "zod/v4";
15
16const pollingIntervalMs = 200;
17
18const executeInputSchema = {
19 code: z.string().trim().min(1),
20};
21
22const resumeInputSchema = {
23 resumePayload: z.object({
24 executionId: z.string().trim().min(1),
25 }),
26 response: z.object({
27 action: z.enum(["accept", "decline", "cancel"]),
28 content: z.record(z.string(), z.unknown()).optional(),
29 }).optional(),
30};
31
32type ParsedInteractionPayload = {
33 mode: "form" | "url";
34 message: string;
35 url?: string;
36 requestedSchema?: Record<string, unknown>;
37 elicitationId?: string;
38};
39
40type ResumePayload = {
41 executionId: string;
42};
43
44type ResumeResponseInput = {
45 action: "accept" | "decline" | "cancel";
46 content?: Record<string, unknown>;
47};
48
49type ExecutorMcpToolResult = {
50 content: Array<{ type: "text"; text: string }>;
51 structuredContent: Record<string, unknown>;
52 isError?: boolean;
53};
54
55export type ExecutorMcpRequestHandler = {
56 handleRequest: (request: Request) => Promise<Response>;
57 close: () => Promise<void>;
58};
59
60const isRecord = (value: unknown): value is Record<string, unknown> =>
61 typeof value === "object" && value !== null && !Array.isArray(value);
62
63const parseJsonValue = (value: string | null): unknown => {
64 if (value === null) {
65 return null;
66 }
67
68 try {
69 return JSON.parse(value);
70 } catch {
71 return value;
72 }
73};
74
75const parseInteractionPayload = (
76 interaction: ExecutionInteraction,
77): ParsedInteractionPayload | null => {
78 try {
79 const parsed = JSON.parse(interaction.payloadJson) as {
80 elicitation?: {
81 mode?: "form" | "url";
82 message?: string;
83 url?: string;
84 requestedSchema?: Record<string, unknown>;
85 elicitationId?: string;
86 id?: string;
87 };
88 };
89
90 if (!parsed.elicitation || typeof parsed.elicitation.message !== "string") {
91 return null;
92 }
93
94 return {
95 mode: parsed.elicitation.mode === "url" ? "url" : "form",
96 message: parsed.elicitation.message,
97 url: parsed.elicitation.url,
98 requestedSchema: isRecord(parsed.elicitation.requestedSchema)
99 ? parsed.elicitation.requestedSchema
100 : undefined,
101 elicitationId:
102 typeof parsed.elicitation.elicitationId === "string"
103 ? parsed.elicitation.elicitationId
104 : typeof parsed.elicitation.id === "string"
105 ? parsed.elicitation.id
106 : undefined,
107 };
108 } catch {
109 return null;
110 }
111};
112
113const runControlPlane = <A, E, R>(
114 runtime: SqlControlPlaneRuntime,
115 effect: Effect.Effect<A, E, R>,
116): Promise<A> =>
117 Effect.runPromise(
118 effect.pipe(Effect.provide(runtime.runtimeLayer)) as Effect.Effect<A, E, never>,
119 );
120
121const sleep = (ms: number) =>
122 new Promise<void>((resolve) => {
123 setTimeout(resolve, ms);
124 });
125
126const supportsManagedElicitation = (server: McpServer): boolean => {
127 const capabilities = server.server.getClientCapabilities();
128 return Boolean(capabilities?.elicitation?.form) && Boolean(capabilities?.elicitation?.url);
129};
130
131const summarizeExecution = (execution: ExecutionEnvelope["execution"]): string => {
132 switch (execution.status) {
133 case "completed":
134 return `Execution ${execution.id} completed.`;
135 case "failed":
136 return execution.errorText
137 ? `Execution ${execution.id} failed: ${execution.errorText}`
138 : `Execution ${execution.id} failed.`;
139 case "waiting_for_interaction":
140 return `Execution ${execution.id} is waiting for interaction.`;
141 default:
142 return `Execution ${execution.id} is ${execution.status}.`;
143 }
144};
145
146const executionStructuredContent = (envelope: ExecutionEnvelope): Record<string, unknown> => ({
147 executionId: envelope.execution.id,
148 status: envelope.execution.status,
149 result: parseJsonValue(envelope.execution.resultJson),
150 errorText: envelope.execution.errorText,
151 logs: parseJsonValue(envelope.execution.logsJson),
152});
153
154const buildFinalResult = (
155 envelope: ExecutionEnvelope,
156 options: { isError?: boolean } = {},
157): ExecutorMcpToolResult => ({
158 content: [{ type: "text", text: summarizeExecution(envelope.execution) }],
159 structuredContent: executionStructuredContent(envelope),
160 ...(options.isError ? { isError: true } : {}),
161});
162
163const buildPausedResult = (envelope: ExecutionEnvelope): ExecutorMcpToolResult => {
164 const interaction = envelope.pendingInteraction;
165 const parsed = interaction ? parseInteractionPayload(interaction) : null;
166
167 return {
168 content: [{
169 type: "text",
170 text: interaction
171 ? `Execution ${envelope.execution.id} paused: ${parsed?.message ?? "Interaction required."}`
172 : `Execution ${envelope.execution.id} is waiting for interaction.`,
173 }],
174 structuredContent: {
175 executionId: envelope.execution.id,
176 status: "waiting_for_interaction",
177 interaction: interaction
178 ? {
179 id: interaction.id,
180 purpose: interaction.purpose,
181 kind: interaction.kind,
182 message: parsed?.message ?? "Interaction required",
183 mode: parsed?.mode ?? (interaction.kind === "url" ? "url" : "form"),
184 url: parsed?.url ?? null,
185 requestedSchema: parsed?.requestedSchema ?? null,
186 }
187 : null,
188 resumePayload: {
189 executionId: envelope.execution.id,
190 } satisfies ResumePayload,
191 },
192 };
193};
194
195const buildToolResult = (envelope: ExecutionEnvelope): ExecutorMcpToolResult => {
196 switch (envelope.execution.status) {
197 case "completed":
198 return buildFinalResult(envelope);
199 case "failed":
200 case "cancelled":
201 return buildFinalResult(envelope, { isError: true });
202 case "waiting_for_interaction":
203 return buildPausedResult(envelope);
204 default:
205 return buildFinalResult(envelope);
206 }
207};
208
209const waitForInteractionProgress = async (input: {
210 runtime: SqlControlPlaneRuntime;
211 workspaceId: string;
212 executionId: string;
213 pendingInteractionId: string;
214}): Promise<ExecutionEnvelope> => {
215 while (true) {
216 const next = await runControlPlane(
217 input.runtime,
218 getExecution({
219 workspaceId: input.workspaceId as never,
220 executionId: input.executionId as never,
221 }),
222 );
223
224 if (
225 next.execution.status !== "waiting_for_interaction"
226 || next.pendingInteraction?.id !== input.pendingInteractionId
227 ) {
228 return next;
229 }
230
231 await sleep(pollingIntervalMs);
232 }
233};
234
235const driveExecutionWithElicitation = async (input: {
236 runtime: SqlControlPlaneRuntime;
237 workspaceId: string;
238 accountId: string;
239 server: McpServer;
240 envelope: ExecutionEnvelope;
241}): Promise<ExecutionEnvelope> => {
242 let current = input.envelope;
243
244 while (current.execution.status === "waiting_for_interaction") {
245 const pending = current.pendingInteraction;
246 if (pending === null) {
247 return current;
248 }
249
250 const parsed = parseInteractionPayload(pending);
251 if (!parsed) {
252 return current;
253 }
254
255 if (parsed.mode === "form") {
256 const response = await input.server.server.elicitInput({
257 mode: "form",
258 message: parsed.message,
259 requestedSchema: (parsed.requestedSchema ?? {
260 type: "object",
261 properties: {},
262 }) as never,
263 });
264
265 current = await runControlPlane(
266 input.runtime,
267 resumeExecution({
268 workspaceId: input.workspaceId as never,
269 executionId: current.execution.id as never,
270 payload: {
271 responseJson: JSON.stringify(response),
272 },
273 resumedByAccountId: input.accountId as never,
274 }),
275 );
276 continue;
277 }
278
279 const response = await input.server.server.elicitInput({
280 mode: "url",
281 message: parsed.message,
282 url: parsed.url ?? "",
283 elicitationId: parsed.elicitationId ?? pending.id,
284 });
285
286 if (response.action !== "accept") {
287 current = await runControlPlane(
288 input.runtime,
289 resumeExecution({
290 workspaceId: input.workspaceId as never,
291 executionId: current.execution.id as never,
292 payload: {
293 responseJson: JSON.stringify(response),
294 },
295 resumedByAccountId: input.accountId as never,
296 }),
297 );
298 continue;
299 }
300
301 current = await waitForInteractionProgress({
302 runtime: input.runtime,
303 workspaceId: input.workspaceId,
304 executionId: current.execution.id,
305 pendingInteractionId: pending.id,
306 });
307 }
308
309 return current;
310};
311
312const driveExecutionWithoutElicitation = async (input: {
313 runtime: SqlControlPlaneRuntime;
314 workspaceId: string;
315 accountId: string;
316 executionId: string;
317 initialResponse?: ResumeResponseInput;
318}): Promise<ExecutionEnvelope> => {
319 let current = await runControlPlane(
320 input.runtime,
321 getExecution({
322 workspaceId: input.workspaceId as never,
323 executionId: input.executionId as never,
324 }),
325 );
326 let response = input.initialResponse;
327
328 while (current.execution.status === "waiting_for_interaction") {
329 const pending = current.pendingInteraction;
330 if (pending === null || response === undefined) {
331 return current;
332 }
333
334 current = await runControlPlane(
335 input.runtime,
336 resumeExecution({
337 workspaceId: input.workspaceId as never,
338 executionId: current.execution.id as never,
339 payload: {
340 responseJson: JSON.stringify(response),
341 },
342 resumedByAccountId: input.accountId as never,
343 }),
344 );
345 response = undefined;
346 }
347
348 return current;
349};
350
351const buildInstallInstructions = (baseUrl: string): string => {
352 const mcpUrl = new URL("/mcp", baseUrl).toString();
353
354 return [
355 "Run once to install for supported MCP clients (via add-mcp):",
356 `npx add-mcp \"${mcpUrl}\" --transport http --name \"executor\"`,
357 ].join("\n");
358};
359
360const createExecutorMcpServer = (config: {
361 runtime: SqlControlPlaneRuntime;
362 baseUrl: string;
363}): McpServer => {
364 const server = new McpServer(
365 { name: "executor", version: "1.0.0" },
366 {
367 capabilities: {
368 tools: {},
369 },
370 instructions: buildInstallInstructions(config.baseUrl),
371 },
372 );
373
374 const workspaceId = config.runtime.localInstallation.workspaceId;
375 const accountId = config.runtime.localInstallation.accountId;
376
377 const executeTool = server.registerTool(
378 "execute",
379 {
380 description: [
381 "Execute TypeScript against the local executor runtime.",
382 "Use the in-sandbox tool catalog via tools.* rather than direct fetch calls.",
383 ].join("\n"),
384 inputSchema: executeInputSchema,
385 },
386 async ({ code }: { code: string }) => {
387 let created = await runControlPlane(
388 config.runtime,
389 createExecution({
390 workspaceId,
391 payload: { code },
392 createdByAccountId: accountId,
393 }),
394 );
395
396 if (supportsManagedElicitation(server)) {
397 created = await driveExecutionWithElicitation({
398 runtime: config.runtime,
399 workspaceId,
400 accountId,
401 server,
402 envelope: created,
403 });
404 }
405
406 return buildToolResult(created);
407 },
408 );
409
410 const resumeTool = server.registerTool(
411 "resume",
412 {
413 description: "Resume a paused executor execution using the resumePayload returned by execute.",
414 inputSchema: resumeInputSchema,
415 },
416 async (
417 input: {
418 resumePayload: ResumePayload;
419 response?: ResumeResponseInput;
420 },
421 ) => {
422 const resumed = await driveExecutionWithoutElicitation({
423 runtime: config.runtime,
424 workspaceId,
425 accountId,
426 executionId: input.resumePayload.executionId,
427 initialResponse: input.response,
428 });
429
430 return buildToolResult(resumed);
431 },
432 );
433
434
435 const syncToolAvailability = () => {
436 if (supportsManagedElicitation(server)) {
437 executeTool.enable();
438 resumeTool.disable();
439 return;
440 }
441
442 executeTool.enable();
443 resumeTool.enable();
444 };
445
446 syncToolAvailability();
447 server.server.oninitialized = syncToolAvailability;
448
449 return server;
450};
451
452const jsonErrorResponse = (status: number, code: number, message: string) =>
453 new Response(JSON.stringify({
454 jsonrpc: "2.0",
455 error: {
456 code,
457 message,
458 },
459 id: null,
460 }), {
461 status,
462 headers: {
463 "content-type": "application/json",
464 },
465 });
466
467export const createExecutorMcpRequestHandler = (
468 runtime: SqlControlPlaneRuntime,
469): ExecutorMcpRequestHandler => {
470 const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
471 const servers = new Map<string, McpServer>();
472
473 const disposeSession = async (
474 sessionId: string,
475 options: { closeTransport?: boolean; closeServer?: boolean } = {},
476 ) => {
477 const transport = transports.get(sessionId);
478 const server = servers.get(sessionId);
479
480 transports.delete(sessionId);
481 servers.delete(sessionId);
482
483 if (options.closeTransport) {
484 await transport?.close().catch(() => undefined);
485 }
486
487 if (options.closeServer) {
488 await server?.close().catch(() => undefined);
489 }
490 };
491
492 return {
493 handleRequest: async (request) => {
494 const sessionId = request.headers.get("mcp-session-id");
495 if (sessionId) {
496 const transport = transports.get(sessionId);
497 if (!transport) {
498 return jsonErrorResponse(404, -32001, "Session not found");
499 }
500
501 return transport.handleRequest(request);
502 }
503
504 let createdServer: McpServer | undefined;
505 const transport = new WebStandardStreamableHTTPServerTransport({
506 sessionIdGenerator: () => crypto.randomUUID(),
507 enableJsonResponse: true,
508 onsessioninitialized: (newSessionId) => {
509 transports.set(newSessionId, transport);
510 if (createdServer) {
511 servers.set(newSessionId, createdServer);
512 }
513 },
514 onsessionclosed: (closedSessionId) => {
515 void disposeSession(closedSessionId, { closeServer: true });
516 },
517 });
518
519 transport.onclose = () => {
520 const closedSessionId = transport.sessionId;
521 if (closedSessionId) {
522 void disposeSession(closedSessionId, { closeServer: true });
523 }
524 };
525
526 try {
527 createdServer = createExecutorMcpServer({
528 runtime,
529 baseUrl: new URL(request.url).origin,
530 });
531 await createdServer.connect(transport);
532 const response = await transport.handleRequest(request);
533
534 if (!transport.sessionId) {
535 await transport.close().catch(() => undefined);
536 await createdServer.close().catch(() => undefined);
537 }
538
539 return response;
540 } catch (error) {
541 if (!transport.sessionId) {
542 await transport.close().catch(() => undefined);
543 await createdServer?.close().catch(() => undefined);
544 }
545
546 return jsonErrorResponse(
547 500,
548 -32603,
549 error instanceof Error ? error.message : "Internal server error",
550 );
551 }
552 },
553 close: async () => {
554 const sessionIds = new Set<string>([
555 ...transports.keys(),
556 ...servers.keys(),
557 ]);
558
559 await Promise.all(
560 [...sessionIds].map((sessionId) =>
561 disposeSession(sessionId, {
562 closeTransport: true,
563 closeServer: true,
564 }),
565 ),
566 );
567 },
568 };
569};
packages/executor-mcp/tsconfig.json+15−0
@@ -0,0 +1,15 @@
1{
2 "compilerOptions": {
3 "target": "ES2022",
4 "module": "ESNext",
5 "moduleResolution": "Bundler",
6 "strict": true,
7 "skipLibCheck": true,
8 "noEmit": true,
9 "lib": ["ES2022", "DOM"],
10 "types": ["bun-types", "node"]
11 },
12 "include": [
13 "src/**/*.ts"
14 ]
15}
packages/runtime-deno-subprocess/package.json+23−0
@@ -0,0 +1,23 @@
1{
2 "name": "@executor-v3/runtime-deno-subprocess",
3 "private": true,
4 "type": "module",
5 "exports": {
6 ".": "./src/index.ts"
7 },
8 "scripts": {
9 "typecheck": "bunx tsc --noEmit -p tsconfig.json",
10 "test": "vitest run",
11 "test:watch": "vitest"
12 },
13 "dependencies": {
14 "@executor-v3/codemode-core": "workspace:*",
15 "effect": "catalog:"
16 },
17 "devDependencies": {
18 "@effect/vitest": "catalog:",
19 "@types/node": "catalog:",
20 "bun-types": "catalog:",
21 "vitest": "catalog:"
22 }
23}
packages/runtime-deno-subprocess/src/deno-subprocess-worker.mjs+205−0
@@ -0,0 +1,205 @@
1// @ts-nocheck
2// This script runs inside the Deno subprocess.
3// It communicates with the host process via line-delimited JSON over stdin/stdout.
4// All IPC messages are prefixed with @@executor-ipc@@ to distinguish from user output.
5
6const encoder = new TextEncoder();
7const IPC_PREFIX = "@@executor-ipc@@";
8
9const pendingToolCalls = new Map();
10let started = false;
11
12/** @type {string[]} */
13const logs = [];
14
15const writeIpcMessage = (message) => {
16 const payload = `${IPC_PREFIX}${JSON.stringify(message)}\n`;
17 Deno.stdout.writeSync(encoder.encode(payload));
18};
19
20const toErrorMessage = (error) => {
21 if (error instanceof Error) {
22 return error.stack ?? error.message;
23 }
24
25 return String(error);
26};
27
28const createToolCaller = (toolPath) => (args) =>
29 new Promise((resolve, reject) => {
30 const requestId = crypto.randomUUID();
31 pendingToolCalls.set(requestId, { resolve, reject });
32
33 writeIpcMessage({
34 type: "tool_call",
35 requestId,
36 toolPath,
37 args: args === undefined ? {} : args,
38 });
39 });
40
41const createToolsProxy = (path = []) => {
42 const callable = () => undefined;
43
44 return new Proxy(callable, {
45 get(_target, prop) {
46 if (prop === "then") return undefined;
47 if (typeof prop !== "string") return undefined;
48 return createToolsProxy([...path, prop]);
49 },
50 apply(_target, _thisArg, args) {
51 const toolPath = path.join(".");
52 if (!toolPath) {
53 throw new Error("Tool path missing in invocation");
54 }
55
56 return createToolCaller(toolPath)(args.length > 0 ? args[0] : undefined);
57 },
58 });
59};
60
61const formatLogArg = (value) => {
62 if (typeof value === "string") {
63 return value;
64 }
65
66 try {
67 return JSON.stringify(value);
68 } catch {
69 return String(value);
70 }
71};
72
73const formatLogLine = (args) => args.map(formatLogArg).join(" ");
74
75const sandboxConsole = {
76 log: (...args) => {
77 logs.push(`[log] ${formatLogLine(args)}`);
78 },
79 warn: (...args) => {
80 logs.push(`[warn] ${formatLogLine(args)}`);
81 },
82 error: (...args) => {
83 logs.push(`[error] ${formatLogLine(args)}`);
84 },
85 info: (...args) => {
86 logs.push(`[info] ${formatLogLine(args)}`);
87 },
88 debug: (...args) => {
89 logs.push(`[debug] ${formatLogLine(args)}`);
90 },
91};
92
93const runUserCode = async (code) => {
94 const tools = createToolsProxy();
95
96 const execute = new Function(
97 "tools",
98 "console",
99 `"use strict"; return (async () => {\n${code}\n})();`,
100 );
101
102 return await execute(tools, sandboxConsole);
103};
104
105const handleStart = (message) => {
106 if (started) {
107 writeIpcMessage({
108 type: "failed",
109 error: "start message already received",
110 logs,
111 });
112 return;
113 }
114
115 started = true;
116
117 runUserCode(message.code)
118 .then((result) => {
119 writeIpcMessage({
120 type: "completed",
121 result,
122 logs,
123 });
124 })
125 .catch((error) => {
126 writeIpcMessage({
127 type: "failed",
128 error: toErrorMessage(error),
129 logs,
130 });
131 });
132};
133
134const handleToolResult = (message) => {
135 const pending = pendingToolCalls.get(message.requestId);
136 if (!pending) {
137 return;
138 }
139
140 pendingToolCalls.delete(message.requestId);
141
142 if (message.ok) {
143 pending.resolve(message.value);
144 return;
145 }
146
147 pending.reject(new Error(message.error));
148};
149
150const handleHostMessage = (message) => {
151 if (!message || typeof message !== "object") {
152 return;
153 }
154
155 if (message.type === "start") {
156 handleStart(message);
157 return;
158 }
159
160 if (message.type === "tool_result") {
161 handleToolResult(message);
162 }
163};
164
165const decodeLines = async () => {
166 const reader = Deno.stdin.readable.getReader();
167 const decoder = new TextDecoder();
168 let buffer = "";
169
170 while (true) {
171 const { value, done } = await reader.read();
172 if (done) {
173 break;
174 }
175
176 buffer += decoder.decode(value, { stream: true });
177
178 while (true) {
179 const lineEnd = buffer.indexOf("\n");
180 if (lineEnd === -1) {
181 break;
182 }
183
184 const line = buffer.slice(0, lineEnd).trim();
185 buffer = buffer.slice(lineEnd + 1);
186
187 if (line.length === 0) {
188 continue;
189 }
190
191 try {
192 const message = JSON.parse(line);
193 handleHostMessage(message);
194 } catch (error) {
195 writeIpcMessage({
196 type: "failed",
197 error: `invalid host message: ${toErrorMessage(error)}`,
198 logs,
199 });
200 }
201 }
202 }
203};
204
205await decodeLines();
packages/runtime-deno-subprocess/src/deno-worker-process.ts+166−0
@@ -0,0 +1,166 @@
1import { spawn } from "node:child_process";
2
3export type DenoPermissions = {
4 /** Allow network access. Pass `true` for all, or an array of allowed hosts. */
5 allowNet?: boolean | string[];
6 /** Allow file read access. Pass `true` for all, or an array of allowed paths. */
7 allowRead?: boolean | string[];
8 /** Allow file write access. Pass `true` for all, or an array of allowed paths. */
9 allowWrite?: boolean | string[];
10 /** Allow environment variable access. Pass `true` for all, or an array of allowed vars. */
11 allowEnv?: boolean | string[];
12 /** Allow running subprocesses. Pass `true` for all, or an array of allowed commands. */
13 allowRun?: boolean | string[];
14 /** Allow FFI (foreign function interface). */
15 allowFfi?: boolean | string[];
16};
17
18export type SpawnDenoWorkerProcessInput = {
19 executable: string;
20 scriptPath: string;
21 permissions?: DenoPermissions;
22};
23
24export type DenoWorkerProcessCallbacks = {
25 onStdoutLine: (line: string) => void;
26 onStderr: (chunk: string) => void;
27 onError: (error: Error) => void;
28 onExit: (code: number | null, signal: NodeJS.Signals | null) => void;
29};
30
31export type DenoWorkerProcess = {
32 stdin: NodeJS.WritableStream;
33 dispose: () => void;
34};
35
36const normalizeError = (cause: unknown): Error =>
37 cause instanceof Error ? cause : new Error(String(cause));
38
39const buildPermissionArgs = (permissions?: DenoPermissions): string[] => {
40 if (!permissions) {
41 // Default: deny everything dangerous for maximum sandboxing
42 return [
43 "--deny-net",
44 "--deny-read",
45 "--deny-write",
46 "--deny-env",
47 "--deny-run",
48 "--deny-ffi",
49 ];
50 }
51
52 const args: string[] = [];
53
54 const addPermission = (
55 flag: string,
56 value: boolean | string[] | undefined,
57 ) => {
58 if (value === true) {
59 args.push(`--allow-${flag}`);
60 } else if (Array.isArray(value) && value.length > 0) {
61 args.push(`--allow-${flag}=${value.join(",")}`);
62 } else {
63 args.push(`--deny-${flag}`);
64 }
65 };
66
67 addPermission("net", permissions.allowNet);
68 addPermission("read", permissions.allowRead);
69 addPermission("write", permissions.allowWrite);
70 addPermission("env", permissions.allowEnv);
71 addPermission("run", permissions.allowRun);
72 addPermission("ffi", permissions.allowFfi);
73
74 return args;
75};
76
77export const spawnDenoWorkerProcess = (
78 input: SpawnDenoWorkerProcessInput,
79 callbacks: DenoWorkerProcessCallbacks,
80): DenoWorkerProcess => {
81 const permissionArgs = buildPermissionArgs(input.permissions);
82
83 const child = spawn(
84 input.executable,
85 [
86 "run",
87 "--quiet",
88 "--no-prompt",
89 "--no-check",
90 ...permissionArgs,
91 input.scriptPath,
92 ],
93 {
94 stdio: ["pipe", "pipe", "pipe"],
95 },
96 );
97
98 if (!child.stdin || !child.stdout || !child.stderr) {
99 throw new Error(
100 "Failed to create piped stdio for Deno worker subprocess",
101 );
102 }
103
104 child.stdout.setEncoding("utf8");
105 child.stderr.setEncoding("utf8");
106
107 let stdoutBuffer = "";
108
109 const onStdoutData = (chunk: string) => {
110 stdoutBuffer += chunk;
111
112 while (true) {
113 const newlineIndex = stdoutBuffer.indexOf("\n");
114 if (newlineIndex === -1) {
115 break;
116 }
117
118 const line = stdoutBuffer.slice(0, newlineIndex);
119 stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
120 callbacks.onStdoutLine(line);
121 }
122 };
123
124 const onStderrData = (chunk: string) => {
125 callbacks.onStderr(chunk);
126 };
127
128 const onError = (cause: unknown) => {
129 callbacks.onError(normalizeError(cause));
130 };
131
132 const onExit = (
133 code: number | null,
134 signal: NodeJS.Signals | null,
135 ) => {
136 callbacks.onExit(code, signal);
137 };
138
139 child.stdout.on("data", onStdoutData);
140 child.stderr.on("data", onStderrData);
141 child.on("error", onError);
142 child.on("exit", onExit);
143
144 let disposed = false;
145
146 const dispose = () => {
147 if (disposed) {
148 return;
149 }
150
151 disposed = true;
152 child.stdout!.removeListener("data", onStdoutData);
153 child.stderr!.removeListener("data", onStderrData);
154 child.removeListener("error", onError);
155 child.removeListener("exit", onExit);
156
157 if (!child.killed) {
158 child.kill("SIGKILL");
159 }
160 };
161
162 return {
163 stdin: child.stdin,
164 dispose,
165 };
166};
packages/runtime-deno-subprocess/src/index.test.ts+225−0
@@ -0,0 +1,225 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";
4
5import {
6 allowAllToolInteractions,
7 makeToolInvokerFromTools,
8 toExecutorTool,
9} from "@executor-v3/codemode-core";
10import { isDenoAvailable, makeDenoSubprocessExecutor } from "./index";
11
12const numberPairInputSchema = Schema.standardSchemaV1(
13 Schema.Struct({
14 a: Schema.Number,
15 b: Schema.Number,
16 }),
17);
18
19const messageInputSchema = Schema.standardSchemaV1(
20 Schema.Struct({
21 message: Schema.String,
22 }),
23);
24
25const tools = {
26 "math.add": {
27 description: "Add two numbers",
28 inputSchema: numberPairInputSchema,
29 execute: ({ a, b }: { a: number; b: number }) => ({ sum: a + b }),
30 },
31 "notifications.send": toExecutorTool({
32 tool: {
33 description: "Send a message",
34 inputSchema: messageInputSchema,
35 execute: ({ message }: { message: string }) => ({
36 delivered: true,
37 message,
38 }),
39 },
40 metadata: {
41 interaction: "required",
42 },
43 }),
44};
45
46const skipUnlessDeno = isDenoAvailable()
47 ? describe
48 : describe.skip;
49
50skipUnlessDeno("runtime-deno-subprocess", () => {
51 it.effect("executes simple code and returns result", () =>
52 Effect.gen(function* () {
53 const executor = makeDenoSubprocessExecutor();
54 const toolInvoker = makeToolInvokerFromTools({ tools });
55
56 const output = yield* executor.execute(
57 "return 1 + 2;",
58 toolInvoker,
59 );
60
61 expect(output.result).toBe(3);
62 expect(output.error).toBeUndefined();
63 }),
64 );
65
66 it.effect("executes code with tool calls", () =>
67 Effect.gen(function* () {
68 const executor = makeDenoSubprocessExecutor();
69 const toolInvoker = makeToolInvokerFromTools({
70 tools,
71 onToolInteraction: allowAllToolInteractions,
72 });
73
74 const output = yield* executor.execute(
75 [
76 "const math = await tools.math.add({ a: 19, b: 23 });",
77 "await tools.notifications.send({ message: `sum is ${math.sum}` });",
78 "return math;",
79 ].join("\n"),
80 toolInvoker,
81 );
82
83 expect(output.result).toEqual({ sum: 42 });
84 expect(output.error).toBeUndefined();
85 }),
86 );
87
88 it.effect("captures console.log output in logs", () =>
89 Effect.gen(function* () {
90 const executor = makeDenoSubprocessExecutor();
91 const toolInvoker = makeToolInvokerFromTools({ tools });
92
93 const output = yield* executor.execute(
94 [
95 'console.log("hello from sandbox");',
96 'console.warn("a warning");',
97 'console.error("an error");',
98 "return 42;",
99 ].join("\n"),
100 toolInvoker,
101 );
102
103 expect(output.result).toBe(42);
104 expect(output.logs).toContain("[log] hello from sandbox");
105 expect(output.logs).toContain("[warn] a warning");
106 expect(output.logs).toContain("[error] an error");
107 }),
108 );
109
110 it.effect("reports execution errors without crashing", () =>
111 Effect.gen(function* () {
112 const executor = makeDenoSubprocessExecutor();
113 const toolInvoker = makeToolInvokerFromTools({ tools });
114
115 const output = yield* executor.execute(
116 'throw new Error("boom");',
117 toolInvoker,
118 );
119
120 expect(output.result).toBeNull();
121 expect(output.error).toContain("boom");
122 }),
123 );
124
125 it.effect("handles tool call errors gracefully", () =>
126 Effect.gen(function* () {
127 const failingTools = {
128 "broken.thing": {
129 description: "Always fails",
130 inputSchema: Schema.standardSchemaV1(Schema.Struct({})),
131 execute: () => {
132 throw new Error("tool is broken");
133 },
134 },
135 };
136
137 const executor = makeDenoSubprocessExecutor();
138 const toolInvoker = makeToolInvokerFromTools({
139 tools: failingTools,
140 });
141
142 const output = yield* executor.execute(
143 "return await tools.broken.thing({});",
144 toolInvoker,
145 );
146
147 expect(output.result).toBeNull();
148 expect(output.error).toContain("tool is broken");
149 }),
150 );
151
152 it.effect("respects timeout", () =>
153 Effect.gen(function* () {
154 const executor = makeDenoSubprocessExecutor({
155 timeoutMs: 500,
156 });
157 const toolInvoker = makeToolInvokerFromTools({ tools });
158
159 const output = yield* executor.execute(
160 "await new Promise(() => {}); return 1;",
161 toolInvoker,
162 );
163
164 expect(output.result).toBeNull();
165 expect(output.error).toContain("timed out");
166 }),
167 );
168
169 it.effect("network access is denied by default", () =>
170 Effect.gen(function* () {
171 const executor = makeDenoSubprocessExecutor();
172 const toolInvoker = makeToolInvokerFromTools({ tools });
173
174 const output = yield* executor.execute(
175 'await fetch("https://example.com"); return 1;',
176 toolInvoker,
177 );
178
179 expect(output.result).toBeNull();
180 expect(output.error).toBeDefined();
181 }),
182 );
183
184 it.effect("network access can be allowed via permissions", () =>
185 Effect.gen(function* () {
186 const executor = makeDenoSubprocessExecutor({
187 permissions: {
188 allowNet: true,
189 },
190 });
191 const toolInvoker = makeToolInvokerFromTools({ tools });
192
193 const output = yield* executor.execute(
194 [
195 'const res = await fetch("https://example.com");',
196 "return res.status;",
197 ].join("\n"),
198 toolInvoker,
199 );
200
201 expect(output.result).toBe(200);
202 expect(output.error).toBeUndefined();
203 }),
204 );
205
206 it.effect("multiple sequential tool calls work correctly", () =>
207 Effect.gen(function* () {
208 const executor = makeDenoSubprocessExecutor();
209 const toolInvoker = makeToolInvokerFromTools({ tools });
210
211 const output = yield* executor.execute(
212 [
213 "const r1 = await tools.math.add({ a: 1, b: 2 });",
214 "const r2 = await tools.math.add({ a: r1.sum, b: 10 });",
215 "const r3 = await tools.math.add({ a: r2.sum, b: 100 });",
216 "return r3;",
217 ].join("\n"),
218 toolInvoker,
219 );
220
221 expect(output.result).toEqual({ sum: 113 });
222 expect(output.error).toBeUndefined();
223 }),
224 );
225});
packages/runtime-deno-subprocess/src/index.ts+352−0
@@ -0,0 +1,352 @@
1import { existsSync } from "node:fs";
2import { fileURLToPath } from "node:url";
3
4import type {
5 CodeExecutor,
6 ExecuteResult,
7 ToolInvoker,
8} from "@executor-v3/codemode-core";
9import * as Effect from "effect/Effect";
10
11import {
12 type DenoPermissions,
13 spawnDenoWorkerProcess,
14} from "./deno-worker-process";
15
16export type { DenoPermissions };
17
18export type DenoSubprocessExecutorOptions = {
19 /** Path to the deno binary. Falls back to DENO_BIN env, ~/.deno/bin/deno, then "deno". */
20 denoExecutable?: string;
21 /** Maximum execution time in milliseconds. Defaults to 5 minutes. */
22 timeoutMs?: number;
23 /** Deno permission flags. Defaults to denying everything (full sandbox). */
24 permissions?: DenoPermissions;
25};
26
27const IPC_PREFIX = "@@executor-ipc@@";
28const DEFAULT_TIMEOUT_MS = 5 * 60_000;
29
30// --------------------------------------------------------------------------
31// IPC message types (host <-> worker)
32// --------------------------------------------------------------------------
33
34type HostStartMessage = {
35 type: "start";
36 code: string;
37};
38
39type HostToolResultMessage = {
40 type: "tool_result";
41 requestId: string;
42 ok: boolean;
43 value?: unknown;
44 error?: string;
45};
46
47type HostToWorkerMessage = HostStartMessage | HostToolResultMessage;
48
49type WorkerToolCallMessage = {
50 type: "tool_call";
51 requestId: string;
52 toolPath: string;
53 args: unknown;
54};
55
56type WorkerCompletedMessage = {
57 type: "completed";
58 result: unknown;
59 logs?: string[];
60};
61
62type WorkerFailedMessage = {
63 type: "failed";
64 error: string;
65 logs?: string[];
66};
67
68type WorkerToHostMessage =
69 | WorkerToolCallMessage
70 | WorkerCompletedMessage
71 | WorkerFailedMessage;
72
73// --------------------------------------------------------------------------
74// Deno binary resolution
75// --------------------------------------------------------------------------
76
77const defaultDenoExecutable = (): string => {
78 const configured = process.env.DENO_BIN?.trim();
79 if (configured) {
80 return configured;
81 }
82
83 const home = process.env.HOME?.trim();
84 if (home) {
85 const installedPath = `${home}/.deno/bin/deno`;
86 if (existsSync(installedPath)) {
87 return installedPath;
88 }
89 }
90
91 return "deno";
92};
93
94// --------------------------------------------------------------------------
95// Worker script resolution
96// --------------------------------------------------------------------------
97
98const resolveWorkerScriptPath = (): string => {
99 const moduleUrl = String(import.meta.url);
100
101 if (moduleUrl.startsWith("/")) {
102 return moduleUrl;
103 }
104
105 try {
106 const workerUrl = new URL(
107 "./deno-subprocess-worker.mjs",
108 moduleUrl,
109 );
110 if (workerUrl.protocol === "file:") {
111 return fileURLToPath(workerUrl);
112 }
113
114 return workerUrl.pathname.length > 0
115 ? workerUrl.pathname
116 : workerUrl.toString();
117 } catch {
118 return moduleUrl;
119 }
120};
121
122let cachedWorkerScriptPath: string | undefined;
123
124const workerScriptPath = (): string => {
125 if (!cachedWorkerScriptPath) {
126 cachedWorkerScriptPath = resolveWorkerScriptPath();
127 }
128
129 return cachedWorkerScriptPath;
130};
131
132// --------------------------------------------------------------------------
133// Helpers
134// --------------------------------------------------------------------------
135
136const writeMessage = (
137 stdin: NodeJS.WritableStream,
138 message: HostToWorkerMessage,
139): void => {
140 stdin.write(`${JSON.stringify(message)}\n`);
141};
142
143const isWorkerMessage = (value: unknown): value is WorkerToHostMessage =>
144 typeof value === "object"
145 && value !== null
146 && "type" in value
147 && typeof (value as Record<string, unknown>).type === "string";
148
149// --------------------------------------------------------------------------
150// Core execution
151// --------------------------------------------------------------------------
152
153const executeInDeno = (
154 code: string,
155 toolInvoker: ToolInvoker,
156 options: DenoSubprocessExecutorOptions,
157): Effect.Effect<ExecuteResult, never> =>
158 Effect.gen(function* () {
159 const denoExecutable =
160 options.denoExecutable ?? defaultDenoExecutable();
161 const timeoutMs = Math.max(100, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
162
163 const result = yield* Effect.async<ExecuteResult>((resume) => {
164 let settled = false;
165 let stderrBuffer = "";
166 let worker: ReturnType<typeof spawnDenoWorkerProcess> | null =
167 null;
168
169 const finish = (executeResult: ExecuteResult) => {
170 if (settled) {
171 return;
172 }
173
174 settled = true;
175 clearTimeout(timeout);
176 worker?.dispose();
177 resume(Effect.succeed(executeResult));
178 };
179
180 const fail = (
181 error: string,
182 logs?: string[],
183 ) => {
184 finish({
185 result: null,
186 error,
187 logs,
188 });
189 };
190
191 const timeout = setTimeout(() => {
192 fail(
193 `Deno subprocess execution timed out after ${timeoutMs}ms`,
194 );
195 }, timeoutMs);
196
197 const handleStdoutLine = (rawLine: string) => {
198 const line = rawLine.trim();
199 if (line.length === 0 || !line.startsWith(IPC_PREFIX)) {
200 return;
201 }
202
203 const payload = line.slice(IPC_PREFIX.length);
204 let message: WorkerToHostMessage;
205 try {
206 const parsed: unknown = JSON.parse(payload);
207 if (!isWorkerMessage(parsed)) {
208 fail(`Invalid worker message: ${payload}`);
209 return;
210 }
211 message = parsed;
212 } catch (cause) {
213 fail(
214 `Failed to decode worker message: ${payload}\n${String(cause)}`,
215 );
216 return;
217 }
218
219 if (message.type === "tool_call") {
220 if (!worker) {
221 fail(
222 "Deno subprocess unavailable while handling worker tool_call",
223 );
224 return;
225 }
226
227 const currentWorker = worker;
228
229 Effect.runPromise(
230 Effect.match(
231 Effect.tryPromise({
232 try: () =>
233 Effect.runPromise(
234 toolInvoker.invoke({
235 path: message.toolPath,
236 args: message.args,
237 }),
238 ),
239 catch: (cause) =>
240 cause instanceof Error
241 ? cause
242 : new Error(String(cause)),
243 }),
244 {
245 onSuccess: (value) => {
246 writeMessage(currentWorker.stdin, {
247 type: "tool_result",
248 requestId: message.requestId,
249 ok: true,
250 value,
251 });
252 },
253 onFailure: (error) => {
254 writeMessage(currentWorker.stdin, {
255 type: "tool_result",
256 requestId: message.requestId,
257 ok: false,
258 error: error.message,
259 });
260 },
261 },
262 ),
263 ).catch((cause) => {
264 fail(
265 `Failed handling worker tool_call: ${String(cause)}`,
266 );
267 });
268
269 return;
270 }
271
272 if (message.type === "completed") {
273 finish({
274 result: message.result,
275 logs: message.logs,
276 });
277 return;
278 }
279
280 // message.type === "failed"
281 fail(message.error, message.logs);
282 };
283
284 try {
285 worker = spawnDenoWorkerProcess(
286 {
287 executable: denoExecutable,
288 scriptPath: workerScriptPath(),
289 permissions: options.permissions,
290 },
291 {
292 onStdoutLine: handleStdoutLine,
293 onStderr: (chunk) => {
294 stderrBuffer += chunk;
295 },
296 onError: (cause) => {
297 fail(
298 `Failed to spawn Deno subprocess: ${cause.message}`,
299 );
300 },
301 onExit: (exitCode, signal) => {
302 if (settled) {
303 return;
304 }
305
306 fail(
307 `Deno subprocess exited before returning terminal message (code=${String(exitCode)} signal=${String(signal)} stderr=${stderrBuffer})`,
308 );
309 },
310 },
311 );
312 } catch (cause) {
313 fail(
314 `Failed to spawn Deno subprocess: ${cause instanceof Error ? cause.message : String(cause)}`,
315 );
316 return;
317 }
318
319 writeMessage(worker.stdin, {
320 type: "start",
321 code,
322 });
323 });
324
325 return result;
326 });
327
328// --------------------------------------------------------------------------
329// Public API
330// --------------------------------------------------------------------------
331
332/**
333 * Check whether the configured (or default) Deno binary is available on the system.
334 */
335export const isDenoAvailable = (
336 executable: string = defaultDenoExecutable(),
337): boolean =>
338 executable.includes("/") ? existsSync(executable) : true;
339
340/**
341 * Create a `CodeExecutor` that runs code in a sandboxed Deno subprocess.
342 *
343 * The subprocess is spawned with `--deny-net --deny-read --deny-write --deny-env --deny-run --deny-ffi`
344 * by default, providing strong process-level isolation. Tool calls are proxied back to the host
345 * via stdin/stdout IPC and resolved through the provided `ToolInvoker`.
346 */
347export const makeDenoSubprocessExecutor = (
348 options: DenoSubprocessExecutorOptions = {},
349): CodeExecutor => ({
350 execute: (code: string, toolInvoker: ToolInvoker) =>
351 executeInDeno(code, toolInvoker, options),
352});
packages/runtime-deno-subprocess/tsconfig.json+15−0
@@ -0,0 +1,15 @@
1{
2 "compilerOptions": {
3 "target": "ES2022",
4 "module": "ESNext",
5 "moduleResolution": "Bundler",
6 "strict": true,
7 "skipLibCheck": true,
8 "noEmit": true,
9 "lib": ["ES2022"],
10 "types": ["bun-types", "node"]
11 },
12 "include": [
13 "src/**/*.ts"
14 ]
15}
packages/runtime-deno-subprocess/vitest.config.ts+8−0
@@ -0,0 +1,8 @@
1import { defineConfig } from "vitest/config";
2
3export default defineConfig({
4 test: {
5 include: ["src/**/*.test.ts"],
6 testTimeout: 30_000,
7 },
8});
packages/server/package.json+1−0
@@ -11,6 +11,7 @@ · 10 unmodified lines
11 "dependencies": {11 "dependencies": {
12 "@effect/platform": "catalog:",12 "@effect/platform": "catalog:",
13 "@executor-v3/control-plane": "workspace:*",13 "@executor-v3/control-plane": "workspace:*",
14 "@executor-v3/executor-mcp": "workspace:*",
14 "effect": "catalog:"15 "effect": "catalog:"
15 },16 },
16 "devDependencies": {17 "devDependencies": {
packages/server/src/index.ts+12−2
@@ -16,6 +16,7 @@ import { · 15 unmodified lines
16 type ResolveSecretMaterial,16 type ResolveSecretMaterial,
17 type SqlControlPlaneRuntime,17 type SqlControlPlaneRuntime,
18} from "@executor-v3/control-plane";18} from "@executor-v3/control-plane";
19import { createExecutorMcpRequestHandler } from "@executor-v3/executor-mcp";
19import * as Effect from "effect/Effect";20import * as Effect from "effect/Effect";
20import * as Layer from "effect/Layer";21import * as Layer from "effect/Layer";
21import type * as Scope from "effect/Scope";22import type * as Scope from "effect/Scope";
@@ -243,7 +244,7 @@ const serveUiAsset = async (request: Request, ui: StaticUiOptions): Promise<Resp · 221 unmodified lines
243 244
244const isApiRequest = (request: Request): boolean => {245const isApiRequest = (request: Request): boolean => {
245 const pathname = new URL(request.url).pathname;246 const pathname = new URL(request.url).pathname;
246 return pathname === "/v1" || pathname.startsWith("/v1/");247 return pathname === "/mcp" || pathname === "/v1" || pathname.startsWith("/v1/");
247};248};
248 249
249export const createLocalExecutorRequestHandler = (250export const createLocalExecutorRequestHandler = (
@@ -277,10 +278,19 @@ export const createLocalExecutorRequestHandler = ( · 27 unmodified lines
277 );278 );
278 279
279 const apiHandler = yield* createControlPlaneWebHandler(runtime);280 const apiHandler = yield* createControlPlaneWebHandler(runtime);
281 const mcpHandler = yield* Effect.acquireRelease(
282 Effect.sync(() => createExecutorMcpRequestHandler(runtime)),
283 (handler) => Effect.promise(() => handler.close()).pipe(Effect.orDie),
284 );
280 285
281 return {286 return {
282 runtime,287 runtime,
283 handleApiRequest: (request) => apiHandler.handler(request),288 handleApiRequest: (request) => {
289 const pathname = new URL(request.url).pathname;
290 return pathname === "/mcp"
291 ? mcpHandler.handleRequest(request)
292 : apiHandler.handler(request);
293 },
284 getBaseUrl: () => baseUrlRef,294 getBaseUrl: () => baseUrlRef,
285 setBaseUrl: (baseUrl) => {295 setBaseUrl: (baseUrl) => {
286 baseUrlRef = baseUrl;296 baseUrlRef = baseUrl;