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+
3+This document explains the active v3 architecture at a high level.
4+
5+If `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+
50+The 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+
63+This is the main user entrypoint.
64+
65+Responsibilities:
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+
73+Conceptually, 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+
77+This package hosts the actual local server.
78+
79+Responsibilities:
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+
87+This 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+
91+This is the center of the system.
92+
93+It 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+
104+If 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+
108+This package provides the TypeScript execution environment used by the local product.
109+
110+At a high level it receives:
111+
112+- an executor implementation
113+- a tool catalog
114+- a tool invoker
115+
116+and runs user-authored code against that environment.
117+
118+The current runtime is in-process, which keeps the local architecture simple and fast.
119+
120+### Adapter packages
121+
122+Several 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+
130+These 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+
136+On first startup, the control plane provisions a local installation automatically.
137+
138+That 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+
145+This 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+
149+All 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+
155+Because 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+
164+The persistence layer is SQL-backed.
165+
166+Default behavior:
167+
168+- embedded PGlite for local use
169+
170+Optional behavior:
171+
172+- Postgres when a Postgres URL is supplied
173+
174+At 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+
183+This 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+
187+The source lifecycle is one of the defining architectural paths in the system.
188+
189+### 1. Discovery
190+
191+The Add Source flow starts with URL discovery.
192+
193+The 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+
200+Discovery 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+
211+Once a source is identified, the auth service drives the connection flow.
212+
213+Depending 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+
220+The 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+
224+After a source is connected, `executor` materializes a workspace-visible tool model.
225+
226+The 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+
232+This indexed representation is what powers search, inspection, and execution without re-parsing everything in the UI.
233+
234+### 4. Inspection
235+
236+The source inspection service reconstructs a rich inspection bundle for the UI.
237+
238+That 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+
247+The UI uses this to show both a tree view of tools and a search-oriented discovery view.
248+
249+## Execution architecture
250+
251+Execution is the other defining path in the system.
252+
253+### 1. Create execution
254+
255+An execution begins when the CLI, API, or MCP bridge submits TypeScript code.
256+
257+The 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+
267+Before code runs, the runtime builds the execution environment for the current workspace.
268+
269+That 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+
275+The 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+
281+This resolver is the composition point where the whole product becomes one callable tool surface.
282+
283+### 3. Run TypeScript
284+
285+The code then runs inside the local in-process runtime.
286+
287+The intended calling pattern is:
288+
289+1. discover a tool by intent
290+2. inspect its schema if needed
291+3. call the selected `tools.*` path
292+
293+The 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+
297+When code calls a tool, the control plane decides how to handle it.
298+
299+Possible 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+
307+This is also the point where policy checks and source auth resolution happen.
308+
309+### 5. Persist results and surface interactions
310+
311+The execution service updates persistent execution state as the run progresses.
312+
313+If 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+
320+Once the response arrives, the manager moves the execution back to `running` and the code continues.
321+
322+## Human interaction model
323+
324+A major architectural feature of `executor` is that interactions are first-class runtime state.
325+
326+This is handled by the live execution manager and the execution interaction tables.
327+
328+That gives the product a clean pause/resume loop instead of forcing every host to improvise its own half-finished approval flow.
329+
330+Interaction 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+
335+This 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+
341+The control-plane HTTP API is mounted under `/v1`.
342+
343+Major groups include:
344+
345+- local installation and secret management
346+- OAuth
347+- organizations and memberships
348+- workspaces
349+- sources
350+- policies
351+- executions
352+
353+The 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+
359+The bridge registers two main tools:
360+
361+- `execute`: run TypeScript against the local runtime
362+- `resume`: continue a paused execution
363+
364+When 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+
368+The web app lives in `apps/web` and is a React app built with Vite.
369+
370+At 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+
378+The 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+
382+The 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+
389+That 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+
395+A 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+
5+It 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+
7+At 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+
14+The 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+
20+In 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+
29+Think of `executor` as a local control plane for agent tool use.
30+
31+1. You start a local `executor` daemon.
32+2. You connect sources such as an MCP server, an OpenAPI document, or a GraphQL endpoint.
33+3. `executor` indexes those sources into a workspace tool catalog.
34+4. An agent runs TypeScript against that catalog through `executor call` or through the MCP bridge.
35+5. 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+
47+The 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+
55+The 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+
59+The main CLI workflow is `executor call`.
60+
61+The runtime expects the agent to use the built-in discovery workflow:
62+
63+```ts
64+const matches = await tools.discover({ query: "github issues", limit: 5 });
65+const path = matches.bestPath;
66+const detail = await tools.describe.tool({ path, includeSchemas: true });
67+
68+return await tools.github.issues.list({
69+ owner: "vercel",
70+ repo: "next.js",
71+});
72+```
73+
74+A 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+
83+When a source or tool needs human input, `executor` can pause the execution and create an interaction record.
84+
85+That 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+
92+This 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+
96+The web UI is not just a setup surface. It is also where you can inspect what `executor` learned from a source.
97+
98+For 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+
108+If you are working from this repository, the easiest path is:
109+
110+```bash
111+bun install
112+bun run executor doctor --json
113+bun run executor up
114+```
115+
116+That starts the local runtime. The default base URL is:
117+
118+```text
119+http://127.0.0.1:8788
120+```
121+
122+From there:
123+
124+1. Open the web UI in your browser.
125+2. Add a source from `/sources/add`.
126+3. If needed, store credentials in `/secrets`.
127+4. Run TypeScript with `bun run executor call ...`.
128+
129+If 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
134+executor up
135+executor down
136+executor status --json
137+executor doctor --json
138+executor call --file script.ts
139+executor 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+
148+Examples:
149+
150+```bash
151+executor call 'const matches = await tools.discover({ query: "repo details", limit: 1 }); return matches;'
152+executor call --file script.ts
153+cat script.ts | executor call --stdin
154+executor call --no-open --file script.ts
155+```
156+
157+If an execution pauses, resume it with:
158+
159+```bash
160+executor resume --execution-id exec_123
161+```
162+
163+## Adding a source
164+
165+There are two main ways to add a source.
166+
167+### In the web UI
168+
169+Use 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+
177+This is the easiest path for most users.
178+
179+### From inside an execution
180+
181+The runtime also exposes `tools.executor.sources.add(...)`, which lets an agent add a source from code.
182+
183+Examples:
184+
185+```ts
186+return await tools.executor.sources.add({
187+ endpoint: "https://example.com/mcp",
188+ name: "Example",
189+ namespace: "example",
190+});
191+```
192+
193+```ts
194+return 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+
203+For HTTP-style sources, `executor` can drive the credential flow for you.
204+
205+## How execution works
206+
207+At a high level, every execution follows the same loop:
208+
209+1. `executor` resolves the current local installation and workspace.
210+2. It builds a tool catalog from built-in tools plus all connected workspace sources.
211+3. It runs your TypeScript inside the in-process execution runtime.
212+4. Tool calls are dispatched through `executor` rather than directly from your code.
213+5. If a tool needs interaction, the run pauses and records a pending interaction.
214+6. Once the interaction is resolved, the execution continues and eventually completes or fails.
215+
216+This 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+
225+The React web app is served from the same local server as the API.
226+
227+Main 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+
235+The 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+
239+By default `executor` runs as a single local daemon process.
240+
241+It 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+
247+Default network location:
248+
249+- host: `127.0.0.1`
250+- port: `8788`
251+
252+Default 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+
259+The 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+
265+By 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+
267+Persisted 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+
278+On 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+
284+Compared 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+
292+This 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+
296+If 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+
308+This 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+
314+If 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−
5−It 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
10−npm install -g executor
11−```
12−
13−## Use
14−
15−```bash
16−executor doctor --json
17−executor up
18−executor status --json
19−executor 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
1414 import * as Schema from "effect/Schema";
1515
1616 import { startMcpElicitationDemoServer } from "@executor-v3/mcp-elicitation-demo";
17−import { makeToolInvokerFromTools } from "@executor-v3/codemode-core";
17+import { makeToolInvokerFromTools, toTool } from "@executor-v3/codemode-core";
1818 import {
1919 ControlPlaneStorageError,
2020 createControlPlaneClient,
@@ -22,9 +22,12 @@ import { · 1 unmodified line
2222 type ResolveExecutionEnvironment,
2323 } from "@executor-v3/control-plane";
2424 import { makeInProcessExecutor } from "@executor-v3/runtime-local-inproc";
25+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
26+import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
2527 import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
2628 import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2729 import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
30+import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
2831 import { z } from "zod/v4";
2932
3033 import {
@@ -58,6 +61,79 @@ const makeServer = createLocalExecutorServer({ · 27 unmodified lines
5861 executionResolver,
5962 });
6063
64+const 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+
104+type ExecutorMcpClient = {
105+ client: Client;
106+ close: () => Promise<void>;
107+};
108+
109+const 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+
61137 const ownerParam = HttpApiSchema.param("owner", Schema.String);
62138 const repoParam = HttpApiSchema.param("repo", Schema.String);
63139
@@ -547,6 +623,126 @@ describe("local-executor-server", () => { · 483 unmodified lines
547623 }),
548624 );
549625
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+
550746 it.scoped("loads MCP sources from control-plane state and resumes elicitation", () =>
551747 Effect.gen(function* () {
552748 const demoServer = yield* Effect.acquireRelease(
bun.lock+35−2
@@ -12,7 +12,7 @@ · 11 unmodified lines
1212 },
1313 "apps/executor": {
1414 "name": "executor",
15− "version": "0.0.0-local",
15+ "version": "1.1.0",
1616 "bin": {
1717 "executor": "./bin/executor",
1818 },
@@ -146,7 +146,7 @@ · 127 unmodified lines
146146 "@executor-v3/codemode-core": "workspace:*",
147147 "@executor-v3/codemode-mcp": "workspace:*",
148148 "@executor-v3/codemode-openapi": "workspace:*",
149− "@executor-v3/runtime-local-inproc": "workspace:*",
149+ "@executor-v3/runtime-deno-subprocess": "workspace:*",
150150 "drizzle-orm": "1.0.0-beta.15-859cf75",
151151 "effect": "catalog:",
152152 "graphql": "^16.12.0",
@@ -164,6 +164,21 @@ · 11 unmodified lines
164164 "zod": "catalog:",
165165 },
166166 },
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+ },
167182 "packages/kitchen-sink": {
168183 "name": "@executor-v3/kitchen-sink",
169184 "dependencies": {
@@ -207,6 +222,19 @@ · 37 unmodified lines
207222 "react": ">=18 <20",
208223 },
209224 },
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+ },
210238 "packages/runtime-local-inproc": {
211239 "name": "@executor-v3/runtime-local-inproc",
212240 "dependencies": {
@@ -223,6 +251,7 @@ · 10 unmodified lines
223251 "dependencies": {
224252 "@effect/platform": "catalog:",
225253 "@executor-v3/control-plane": "workspace:*",
254+ "@executor-v3/executor-mcp": "workspace:*",
226255 "effect": "catalog:",
227256 },
228257 "devDependencies": {
@@ -437,12 +466,16 @@ · 208 unmodified lines
437466
438467 "@executor-v3/control-plane": ["@executor-v3/control-plane@workspace:packages/control-plane"],
439468
469+ "@executor-v3/executor-mcp": ["@executor-v3/executor-mcp@workspace:packages/executor-mcp"],
470+
440471 "@executor-v3/kitchen-sink": ["@executor-v3/kitchen-sink@workspace:packages/kitchen-sink"],
441472
442473 "@executor-v3/mcp-elicitation-demo": ["@executor-v3/mcp-elicitation-demo@workspace:apps/mcp-elicitation-demo"],
443474
444475 "@executor-v3/react": ["@executor-v3/react@workspace:packages/react"],
445476
477+ "@executor-v3/runtime-deno-subprocess": ["@executor-v3/runtime-deno-subprocess@workspace:packages/runtime-deno-subprocess"],
478+
446479 "@executor-v3/runtime-local-inproc": ["@executor-v3/runtime-local-inproc@workspace:packages/runtime-local-inproc"],
447480
448481 "@executor-v3/server": ["@executor-v3/server@workspace:packages/server"],
packages/control-plane/package.json+1−1
@@ -28,7 +28,7 @@ · 27 unmodified lines
2828 "@executor-v3/codemode-core": "workspace:*",
2929 "@executor-v3/codemode-mcp": "workspace:*",
3030 "@executor-v3/codemode-openapi": "workspace:*",
31− "@executor-v3/runtime-local-inproc": "workspace:*",
31+ "@executor-v3/runtime-deno-subprocess": "workspace:*",
3232 "drizzle-orm": "1.0.0-beta.15-859cf75",
3333 "effect": "catalog:",
3434 "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
33 import * as Schema from "effect/Schema";
44
55 import { makeToolInvokerFromTools } from "@executor-v3/codemode-core";
6−import { makeInProcessExecutor } from "@executor-v3/runtime-local-inproc";
6+import { makeDenoSubprocessExecutor } from "@executor-v3/runtime-deno-subprocess";
77
88 import {
99 createSqlControlPlaneRuntime,
@@ -31,7 +31,7 @@ const makeExecutionResolver = () => { · 21 unmodified lines
3131
3232 return () =>
3333 Effect.succeed({
34− executor: makeInProcessExecutor(),
34+ executor: makeDenoSubprocessExecutor(),
3535 toolInvoker,
3636 });
3737 };
packages/control-plane/src/runtime/execution-mcp-resume.test.ts+3−3
@@ -12,7 +12,7 @@ import { z } from "zod/v4"; · 11 unmodified lines
1212
1313 import { makeToolInvokerFromTools } from "@executor-v3/codemode-core";
1414 import { discoverMcpToolsFromConnector } from "@executor-v3/codemode-mcp";
15−import { makeInProcessExecutor } from "@executor-v3/runtime-local-inproc";
15+import { makeDenoSubprocessExecutor } from "@executor-v3/runtime-deno-subprocess";
1616
1717 import {
1818 createSqlControlPlaneRuntime,
@@ -262,7 +262,7 @@ const makeMcpExecutionResolver = ( · 243 unmodified lines
262262 });
263263
264264 return {
265− executor: makeInProcessExecutor(),
265+ executor: makeDenoSubprocessExecutor(),
266266 toolInvoker: makeToolInvokerFromTools({
267267 tools: discovered.tools,
268268 onElicitation,
@@ -270,7 +270,7 @@ const makeMcpExecutionResolver = ( · 1 unmodified line
270270 };
271271 }) as Effect.Effect<
272272 {
273− executor: ReturnType<typeof makeInProcessExecutor>;
273+ executor: ReturnType<typeof makeDenoSubprocessExecutor>;
274274 toolInvoker: ReturnType<typeof makeToolInvokerFromTools>;
275275 },
276276 unknown
packages/control-plane/src/runtime/index.ts+1−0
@@ -61,6 +61,7 @@ export * from "./store"; · 60 unmodified lines
6161 export * from "./workspace-execution-environment";
6262 export * from "./source-inspection";
6363 export * from "./source-discovery";
64+export * from "./execution-service";
6465
6566 export type RuntimeControlPlaneOptions = {
6667 actorResolver?: ControlPlaneActorResolverShape;
packages/control-plane/src/runtime/workspace-execution-environment.ts+2−2
@@ -23,7 +23,7 @@ import { · 22 unmodified lines
2323 type OpenApiToolDefinition,
2424 type OpenApiToolManifest,
2525 } from "@executor-v3/codemode-openapi";
26−import { makeInProcessExecutor } from "@executor-v3/runtime-local-inproc";
26+import { makeDenoSubprocessExecutor } from "@executor-v3/runtime-deno-subprocess";
2727 import {
2828 SqlControlPlaneRowsService,
2929 type SqlControlPlaneRows,
@@ -1215,7 +1215,7 @@ export const createWorkspaceExecutionEnvironmentResolver = (input: { · 1185 unmodified lines
12151215 });
12161216
12171217 return {
1218− executor: makeInProcessExecutor(),
1218+ executor: makeDenoSubprocessExecutor(),
12191219 toolInvoker,
12201220 catalog,
12211221 } satisfies ExecutionEnvironment;
packages/control-plane/vitest.config.ts+1−0
@@ -3,5 +3,6 @@ import { defineConfig } from "vitest/config"; · 2 unmodified lines
33 export default defineConfig({
44 test: {
55 include: ["src/**/*.test.ts"],
6+ testTimeout: 30_000,
67 },
78 });
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 @@
1+import type {
2+ ExecutionEnvelope,
3+ ExecutionInteraction,
4+ SqlControlPlaneRuntime,
5+} from "@executor-v3/control-plane";
6+import {
7+ createExecution,
8+ getExecution,
9+ resumeExecution,
10+} from "@executor-v3/control-plane";
11+import * as Effect from "effect/Effect";
12+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13+import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
14+import { z } from "zod/v4";
15+
16+const pollingIntervalMs = 200;
17+
18+const executeInputSchema = {
19+ code: z.string().trim().min(1),
20+};
21+
22+const 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+
32+type ParsedInteractionPayload = {
33+ mode: "form" | "url";
34+ message: string;
35+ url?: string;
36+ requestedSchema?: Record<string, unknown>;
37+ elicitationId?: string;
38+};
39+
40+type ResumePayload = {
41+ executionId: string;
42+};
43+
44+type ResumeResponseInput = {
45+ action: "accept" | "decline" | "cancel";
46+ content?: Record<string, unknown>;
47+};
48+
49+type ExecutorMcpToolResult = {
50+ content: Array<{ type: "text"; text: string }>;
51+ structuredContent: Record<string, unknown>;
52+ isError?: boolean;
53+};
54+
55+export type ExecutorMcpRequestHandler = {
56+ handleRequest: (request: Request) => Promise<Response>;
57+ close: () => Promise<void>;
58+};
59+
60+const isRecord = (value: unknown): value is Record<string, unknown> =>
61+ typeof value === "object" && value !== null && !Array.isArray(value);
62+
63+const 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+
75+const 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+
113+const 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+
121+const sleep = (ms: number) =>
122+ new Promise<void>((resolve) => {
123+ setTimeout(resolve, ms);
124+ });
125+
126+const supportsManagedElicitation = (server: McpServer): boolean => {
127+ const capabilities = server.server.getClientCapabilities();
128+ return Boolean(capabilities?.elicitation?.form) && Boolean(capabilities?.elicitation?.url);
129+};
130+
131+const 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+
146+const 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+
154+const 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+
163+const 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+
195+const 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+
209+const 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+
235+const 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+
312+const 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+
351+const 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+
360+const 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+
452+const 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+
467+export 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+
6+const encoder = new TextEncoder();
7+const IPC_PREFIX = "@@executor-ipc@@";
8+
9+const pendingToolCalls = new Map();
10+let started = false;
11+
12+/** @type {string[]} */
13+const logs = [];
14+
15+const writeIpcMessage = (message) => {
16+ const payload = `${IPC_PREFIX}${JSON.stringify(message)}\n`;
17+ Deno.stdout.writeSync(encoder.encode(payload));
18+};
19+
20+const toErrorMessage = (error) => {
21+ if (error instanceof Error) {
22+ return error.stack ?? error.message;
23+ }
24+
25+ return String(error);
26+};
27+
28+const 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+
41+const 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+
61+const 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+
73+const formatLogLine = (args) => args.map(formatLogArg).join(" ");
74+
75+const 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+
93+const 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+
105+const 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+
134+const 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+
150+const 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+
165+const 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+
205+await decodeLines();
packages/runtime-deno-subprocess/src/deno-worker-process.ts+166−0
@@ -0,0 +1,166 @@
1+import { spawn } from "node:child_process";
2+
3+export 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+
18+export type SpawnDenoWorkerProcessInput = {
19+ executable: string;
20+ scriptPath: string;
21+ permissions?: DenoPermissions;
22+};
23+
24+export 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+
31+export type DenoWorkerProcess = {
32+ stdin: NodeJS.WritableStream;
33+ dispose: () => void;
34+};
35+
36+const normalizeError = (cause: unknown): Error =>
37+ cause instanceof Error ? cause : new Error(String(cause));
38+
39+const 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+
77+export 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 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import * as Effect from "effect/Effect";
3+import * as Schema from "effect/Schema";
4+
5+import {
6+ allowAllToolInteractions,
7+ makeToolInvokerFromTools,
8+ toExecutorTool,
9+} from "@executor-v3/codemode-core";
10+import { isDenoAvailable, makeDenoSubprocessExecutor } from "./index";
11+
12+const numberPairInputSchema = Schema.standardSchemaV1(
13+ Schema.Struct({
14+ a: Schema.Number,
15+ b: Schema.Number,
16+ }),
17+);
18+
19+const messageInputSchema = Schema.standardSchemaV1(
20+ Schema.Struct({
21+ message: Schema.String,
22+ }),
23+);
24+
25+const 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+
46+const skipUnlessDeno = isDenoAvailable()
47+ ? describe
48+ : describe.skip;
49+
50+skipUnlessDeno("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 @@
1+import { existsSync } from "node:fs";
2+import { fileURLToPath } from "node:url";
3+
4+import type {
5+ CodeExecutor,
6+ ExecuteResult,
7+ ToolInvoker,
8+} from "@executor-v3/codemode-core";
9+import * as Effect from "effect/Effect";
10+
11+import {
12+ type DenoPermissions,
13+ spawnDenoWorkerProcess,
14+} from "./deno-worker-process";
15+
16+export type { DenoPermissions };
17+
18+export 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+
27+const IPC_PREFIX = "@@executor-ipc@@";
28+const DEFAULT_TIMEOUT_MS = 5 * 60_000;
29+
30+// --------------------------------------------------------------------------
31+// IPC message types (host <-> worker)
32+// --------------------------------------------------------------------------
33+
34+type HostStartMessage = {
35+ type: "start";
36+ code: string;
37+};
38+
39+type HostToolResultMessage = {
40+ type: "tool_result";
41+ requestId: string;
42+ ok: boolean;
43+ value?: unknown;
44+ error?: string;
45+};
46+
47+type HostToWorkerMessage = HostStartMessage | HostToolResultMessage;
48+
49+type WorkerToolCallMessage = {
50+ type: "tool_call";
51+ requestId: string;
52+ toolPath: string;
53+ args: unknown;
54+};
55+
56+type WorkerCompletedMessage = {
57+ type: "completed";
58+ result: unknown;
59+ logs?: string[];
60+};
61+
62+type WorkerFailedMessage = {
63+ type: "failed";
64+ error: string;
65+ logs?: string[];
66+};
67+
68+type WorkerToHostMessage =
69+ | WorkerToolCallMessage
70+ | WorkerCompletedMessage
71+ | WorkerFailedMessage;
72+
73+// --------------------------------------------------------------------------
74+// Deno binary resolution
75+// --------------------------------------------------------------------------
76+
77+const 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+
98+const 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+
122+let cachedWorkerScriptPath: string | undefined;
123+
124+const workerScriptPath = (): string => {
125+ if (!cachedWorkerScriptPath) {
126+ cachedWorkerScriptPath = resolveWorkerScriptPath();
127+ }
128+
129+ return cachedWorkerScriptPath;
130+};
131+
132+// --------------------------------------------------------------------------
133+// Helpers
134+// --------------------------------------------------------------------------
135+
136+const writeMessage = (
137+ stdin: NodeJS.WritableStream,
138+ message: HostToWorkerMessage,
139+): void => {
140+ stdin.write(`${JSON.stringify(message)}\n`);
141+};
142+
143+const 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+
153+const 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+ */
335+export 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+ */
347+export 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 @@
1+import { defineConfig } from "vitest/config";
2+
3+export 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
1111 "dependencies": {
1212 "@effect/platform": "catalog:",
1313 "@executor-v3/control-plane": "workspace:*",
14+ "@executor-v3/executor-mcp": "workspace:*",
1415 "effect": "catalog:"
1516 },
1617 "devDependencies": {
packages/server/src/index.ts+12−2
@@ -16,6 +16,7 @@ import { · 15 unmodified lines
1616 type ResolveSecretMaterial,
1717 type SqlControlPlaneRuntime,
1818 } from "@executor-v3/control-plane";
19+import { createExecutorMcpRequestHandler } from "@executor-v3/executor-mcp";
1920 import * as Effect from "effect/Effect";
2021 import * as Layer from "effect/Layer";
2122 import type * as Scope from "effect/Scope";
@@ -243,7 +244,7 @@ const serveUiAsset = async (request: Request, ui: StaticUiOptions): Promise<Resp · 221 unmodified lines
243244
244245 const isApiRequest = (request: Request): boolean => {
245246 const pathname = new URL(request.url).pathname;
246− return pathname === "/v1" || pathname.startsWith("/v1/");
247+ return pathname === "/mcp" || pathname === "/v1" || pathname.startsWith("/v1/");
247248 };
248249
249250 export const createLocalExecutorRequestHandler = (
@@ -277,10 +278,19 @@ export const createLocalExecutorRequestHandler = ( · 27 unmodified lines
277278 );
278279
279280 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+ );
280285
281286 return {
282287 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+ },
284294 getBaseUrl: () => baseUrlRef,
285295 setBaseUrl: (baseUrl) => {
286296 baseUrlRef = baseUrl;