Unified · Split
| @@ -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 | |
| @@ -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). | |
| @@ -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 | |
| @@ -14,7 +14,7 @@ import * as Effect from "effect/Effect"; · 13 unmodified lines | ||
| 14 | 14 | import * as Schema from "effect/Schema"; |
| 15 | 15 | |
| 16 | 16 | 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"; | |
| 18 | 18 | import { |
| 19 | 19 | ControlPlaneStorageError, |
| 20 | 20 | createControlPlaneClient, |
| @@ -22,9 +22,12 @@ import { · 1 unmodified line | ||
| 22 | 22 | type ResolveExecutionEnvironment, |
| 23 | 23 | } from "@executor-v3/control-plane"; |
| 24 | 24 | 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"; | |
| 25 | 27 | import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; |
| 26 | 28 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 27 | 29 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; |
| 30 | +import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; | |
| 28 | 31 | import { z } from "zod/v4"; |
| 29 | 32 | |
| 30 | 33 | import { |
| @@ -58,6 +61,79 @@ const makeServer = createLocalExecutorServer({ · 27 unmodified lines | ||
| 58 | 61 | executionResolver, |
| 59 | 62 | }); |
| 60 | 63 | |
| 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 | + | |
| 61 | 137 | const ownerParam = HttpApiSchema.param("owner", Schema.String); |
| 62 | 138 | const 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 | 746 | it.scoped("loads MCP sources from control-plane state and resumes elicitation", () => |
| 551 | 747 | Effect.gen(function* () { |
| 552 | 748 | const demoServer = yield* Effect.acquireRelease( |
| @@ -12,7 +12,7 @@ · 11 unmodified lines | ||
| 12 | 12 | }, |
| 13 | 13 | "apps/executor": { |
| 14 | 14 | "name": "executor", |
| 15 | − "version": "0.0.0-local", | |
| 15 | + "version": "1.1.0", | |
| 16 | 16 | "bin": { |
| 17 | 17 | "executor": "./bin/executor", |
| 18 | 18 | }, |
| @@ -146,7 +146,7 @@ · 127 unmodified lines | ||
| 146 | 146 | "@executor-v3/codemode-core": "workspace:*", |
| 147 | 147 | "@executor-v3/codemode-mcp": "workspace:*", |
| 148 | 148 | "@executor-v3/codemode-openapi": "workspace:*", |
| 149 | − "@executor-v3/runtime-local-inproc": "workspace:*", | |
| 149 | + "@executor-v3/runtime-deno-subprocess": "workspace:*", | |
| 150 | 150 | "drizzle-orm": "1.0.0-beta.15-859cf75", |
| 151 | 151 | "effect": "catalog:", |
| 152 | 152 | "graphql": "^16.12.0", |
| @@ -164,6 +164,21 @@ · 11 unmodified lines | ||
| 164 | 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 | 182 | "packages/kitchen-sink": { |
| 168 | 183 | "name": "@executor-v3/kitchen-sink", |
| 169 | 184 | "dependencies": { |
| @@ -207,6 +222,19 @@ · 37 unmodified lines | ||
| 207 | 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 | 238 | "packages/runtime-local-inproc": { |
| 211 | 239 | "name": "@executor-v3/runtime-local-inproc", |
| 212 | 240 | "dependencies": { |
| @@ -223,6 +251,7 @@ · 10 unmodified lines | ||
| 223 | 251 | "dependencies": { |
| 224 | 252 | "@effect/platform": "catalog:", |
| 225 | 253 | "@executor-v3/control-plane": "workspace:*", |
| 254 | + "@executor-v3/executor-mcp": "workspace:*", | |
| 226 | 255 | "effect": "catalog:", |
| 227 | 256 | }, |
| 228 | 257 | "devDependencies": { |
| @@ -437,12 +466,16 @@ · 208 unmodified lines | ||
| 437 | 466 | |
| 438 | 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 | 471 | "@executor-v3/kitchen-sink": ["@executor-v3/kitchen-sink@workspace:packages/kitchen-sink"], |
| 441 | 472 | |
| 442 | 473 | "@executor-v3/mcp-elicitation-demo": ["@executor-v3/mcp-elicitation-demo@workspace:apps/mcp-elicitation-demo"], |
| 443 | 474 | |
| 444 | 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 | 479 | "@executor-v3/runtime-local-inproc": ["@executor-v3/runtime-local-inproc@workspace:packages/runtime-local-inproc"], |
| 447 | 480 | |
| 448 | 481 | "@executor-v3/server": ["@executor-v3/server@workspace:packages/server"], |
| @@ -28,7 +28,7 @@ · 27 unmodified lines | ||
| 28 | 28 | "@executor-v3/codemode-core": "workspace:*", |
| 29 | 29 | "@executor-v3/codemode-mcp": "workspace:*", |
| 30 | 30 | "@executor-v3/codemode-openapi": "workspace:*", |
| 31 | − "@executor-v3/runtime-local-inproc": "workspace:*", | |
| 31 | + "@executor-v3/runtime-deno-subprocess": "workspace:*", | |
| 32 | 32 | "drizzle-orm": "1.0.0-beta.15-859cf75", |
| 33 | 33 | "effect": "catalog:", |
| 34 | 34 | "graphql": "^16.12.0", |
| @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; · 2 unmodified lines | ||
| 3 | 3 | import * as Schema from "effect/Schema"; |
| 4 | 4 | |
| 5 | 5 | 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"; | |
| 7 | 7 | |
| 8 | 8 | import { |
| 9 | 9 | createSqlControlPlaneRuntime, |
| @@ -31,7 +31,7 @@ const makeExecutionResolver = () => { · 21 unmodified lines | ||
| 31 | 31 | |
| 32 | 32 | return () => |
| 33 | 33 | Effect.succeed({ |
| 34 | − executor: makeInProcessExecutor(), | |
| 34 | + executor: makeDenoSubprocessExecutor(), | |
| 35 | 35 | toolInvoker, |
| 36 | 36 | }); |
| 37 | 37 | }; |
| @@ -12,7 +12,7 @@ import { z } from "zod/v4"; · 11 unmodified lines | ||
| 12 | 12 | |
| 13 | 13 | import { makeToolInvokerFromTools } from "@executor-v3/codemode-core"; |
| 14 | 14 | 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"; | |
| 16 | 16 | |
| 17 | 17 | import { |
| 18 | 18 | createSqlControlPlaneRuntime, |
| @@ -262,7 +262,7 @@ const makeMcpExecutionResolver = ( · 243 unmodified lines | ||
| 262 | 262 | }); |
| 263 | 263 | |
| 264 | 264 | return { |
| 265 | − executor: makeInProcessExecutor(), | |
| 265 | + executor: makeDenoSubprocessExecutor(), | |
| 266 | 266 | toolInvoker: makeToolInvokerFromTools({ |
| 267 | 267 | tools: discovered.tools, |
| 268 | 268 | onElicitation, |
| @@ -270,7 +270,7 @@ const makeMcpExecutionResolver = ( · 1 unmodified line | ||
| 270 | 270 | }; |
| 271 | 271 | }) as Effect.Effect< |
| 272 | 272 | { |
| 273 | − executor: ReturnType<typeof makeInProcessExecutor>; | |
| 273 | + executor: ReturnType<typeof makeDenoSubprocessExecutor>; | |
| 274 | 274 | toolInvoker: ReturnType<typeof makeToolInvokerFromTools>; |
| 275 | 275 | }, |
| 276 | 276 | unknown |
| @@ -61,6 +61,7 @@ export * from "./store"; · 60 unmodified lines | ||
| 61 | 61 | export * from "./workspace-execution-environment"; |
| 62 | 62 | export * from "./source-inspection"; |
| 63 | 63 | export * from "./source-discovery"; |
| 64 | +export * from "./execution-service"; | |
| 64 | 65 | |
| 65 | 66 | export type RuntimeControlPlaneOptions = { |
| 66 | 67 | actorResolver?: ControlPlaneActorResolverShape; |
| @@ -23,7 +23,7 @@ import { · 22 unmodified lines | ||
| 23 | 23 | type OpenApiToolDefinition, |
| 24 | 24 | type OpenApiToolManifest, |
| 25 | 25 | } from "@executor-v3/codemode-openapi"; |
| 26 | −import { makeInProcessExecutor } from "@executor-v3/runtime-local-inproc"; | |
| 26 | +import { makeDenoSubprocessExecutor } from "@executor-v3/runtime-deno-subprocess"; | |
| 27 | 27 | import { |
| 28 | 28 | SqlControlPlaneRowsService, |
| 29 | 29 | type SqlControlPlaneRows, |
| @@ -1215,7 +1215,7 @@ export const createWorkspaceExecutionEnvironmentResolver = (input: { · 1185 unmodified lines | ||
| 1215 | 1215 | }); |
| 1216 | 1216 | |
| 1217 | 1217 | return { |
| 1218 | − executor: makeInProcessExecutor(), | |
| 1218 | + executor: makeDenoSubprocessExecutor(), | |
| 1219 | 1219 | toolInvoker, |
| 1220 | 1220 | catalog, |
| 1221 | 1221 | } satisfies ExecutionEnvironment; |
| @@ -3,5 +3,6 @@ import { defineConfig } from "vitest/config"; · 2 unmodified lines | ||
| 3 | 3 | export default defineConfig({ |
| 4 | 4 | test: { |
| 5 | 5 | include: ["src/**/*.test.ts"], |
| 6 | + testTimeout: 30_000, | |
| 6 | 7 | }, |
| 7 | 8 | }); |
| @@ -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 | +} | |
| @@ -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 | +}; | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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(); | |
| @@ -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 | +}; | |
| @@ -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 | +}); | |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -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 | +}); | |
| @@ -11,6 +11,7 @@ · 10 unmodified lines | ||
| 11 | 11 | "dependencies": { |
| 12 | 12 | "@effect/platform": "catalog:", |
| 13 | 13 | "@executor-v3/control-plane": "workspace:*", |
| 14 | + "@executor-v3/executor-mcp": "workspace:*", | |
| 14 | 15 | "effect": "catalog:" |
| 15 | 16 | }, |
| 16 | 17 | "devDependencies": { |
| @@ -16,6 +16,7 @@ import { · 15 unmodified lines | ||
| 16 | 16 | type ResolveSecretMaterial, |
| 17 | 17 | type SqlControlPlaneRuntime, |
| 18 | 18 | } from "@executor-v3/control-plane"; |
| 19 | +import { createExecutorMcpRequestHandler } from "@executor-v3/executor-mcp"; | |
| 19 | 20 | import * as Effect from "effect/Effect"; |
| 20 | 21 | import * as Layer from "effect/Layer"; |
| 21 | 22 | import type * as Scope from "effect/Scope"; |
| @@ -243,7 +244,7 @@ const serveUiAsset = async (request: Request, ui: StaticUiOptions): Promise<Resp · 221 unmodified lines | ||
| 243 | 244 | |
| 244 | 245 | const isApiRequest = (request: Request): boolean => { |
| 245 | 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 | |
| 249 | 250 | export const createLocalExecutorRequestHandler = ( |
| @@ -277,10 +278,19 @@ export const createLocalExecutorRequestHandler = ( · 27 unmodified lines | ||
| 277 | 278 | ); |
| 278 | 279 | |
| 279 | 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 | 286 | return { |
| 282 | 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 | 294 | getBaseUrl: () => baseUrlRef, |
| 285 | 295 | setBaseUrl: (baseUrl) => { |
| 286 | 296 | baseUrlRef = baseUrl; |