Unified · Split
| @@ -0,0 +1,5 @@ | ||
| 1 | +--- | |
| 2 | +"executor": patch | |
| 3 | +--- | |
| 4 | + | |
| 5 | +Support selecting runtime | |
| @@ -117,6 +117,8 @@ and runs user-authored code against that environment. · 116 unmodified lines | ||
| 117 | 117 | |
| 118 | 118 | The default runtime executes code inside a QuickJS WebAssembly sandbox so tool calls stay proxied through the control plane. |
| 119 | 119 | |
| 120 | +The workspace can override that in `.executor/executor.jsonc` with `runtime: "quickjs" | "ses" | "deno"`. | |
| 121 | + | |
| 120 | 122 | ### Adapter packages |
| 121 | 123 | |
| 122 | 124 | Several packages exist to turn external systems into callable tools: |
| @@ -228,11 +228,20 @@ At a high level, every execution follows the same loop: · 227 unmodified lines | ||
| 228 | 228 | |
| 229 | 229 | 1. `executor` resolves the current local installation and workspace. |
| 230 | 230 | 2. It builds a tool catalog from built-in tools plus all connected workspace sources. |
| 231 | −3. It runs your TypeScript inside the QuickJS sandbox runtime by default. | |
| 231 | +3. It runs your TypeScript inside the configured sandbox runtime. QuickJS is the default, and `.executor/executor.jsonc` can set `"runtime": "quickjs" | "ses" | "deno"`. | |
| 232 | 232 | 4. Tool calls are dispatched through `executor` rather than directly from your code. |
| 233 | 233 | 5. If a tool needs interaction, the run pauses and records a pending interaction. |
| 234 | 234 | 6. Once the interaction is resolved, the execution continues and eventually completes or fails. |
| 235 | 235 | |
| 236 | +Example: | |
| 237 | + | |
| 238 | +```jsonc | |
| 239 | +{ | |
| 240 | + "runtime": "ses", | |
| 241 | + "sources": {} | |
| 242 | +} | |
| 243 | +``` | |
| 244 | + | |
| 236 | 245 | This gives you a stable surface for agent automation: |
| 237 | 246 | |
| 238 | 247 | - the agent sees a coherent catalog |
| @@ -317,6 +326,7 @@ If you are exploring the repo, these are the directories that matter most: · 78 unmodified lines | ||
| 317 | 326 | - `apps/web`: local React web UI |
| 318 | 327 | - `packages/server`: local HTTP server that serves API, MCP, and UI |
| 319 | 328 | - `packages/control-plane`: source management, secrets, persistence, execution, and inspection |
| 329 | +- `packages/runtime-deno-subprocess`: optional Deno subprocess runtime for TypeScript execution | |
| 320 | 330 | - `packages/runtime-quickjs`: default QuickJS sandbox runtime for TypeScript execution |
| 321 | 331 | - `packages/runtime-ses`: optional SES sandbox runtime for TypeScript execution |
| 322 | 332 | - `packages/executor-mcp`: MCP bridge for `execute` and `resume` |
| @@ -7,6 +7,16 @@ Executor turns connected integrations into a workspace tool catalog. · 6 unmodified lines | ||
| 7 | 7 | |
| 8 | 8 | That catalog is the thing the runtime actually executes against. |
| 9 | 9 | |
| 10 | +The execution sandbox is selected from `.executor/executor.jsonc`: | |
| 11 | + | |
| 12 | +```jsonc | |
| 13 | +{ | |
| 14 | + "runtime": "quickjs" | |
| 15 | +} | |
| 16 | +``` | |
| 17 | + | |
| 18 | +Supported values are `quickjs` (default), `ses`, and `deno`. | |
| 19 | + | |
| 10 | 20 | ## How tools are produced |
| 11 | 21 | |
| 12 | 22 | At a high level: |
| @@ -225,6 +225,18 @@ export const buildDistributionPackage = async ( · 224 unmodified lines | ||
| 225 | 225 | join(repoRoot, "packages/runtime-deno-subprocess/src/deno-subprocess-worker.mjs"), |
| 226 | 226 | join(binDir, "deno-subprocess-worker.mjs"), |
| 227 | 227 | ); |
| 228 | + await runCommand({ | |
| 229 | + command: "bun", | |
| 230 | + args: [ | |
| 231 | + "build", | |
| 232 | + "./packages/runtime-ses/src/sandbox-worker.mjs", | |
| 233 | + "--target", | |
| 234 | + "node", | |
| 235 | + "--outfile", | |
| 236 | + join(binDir, "sandbox-worker.mjs"), | |
| 237 | + ], | |
| 238 | + cwd: repoRoot, | |
| 239 | + }); | |
| 228 | 240 | await writeFile(join(packageDir, "package.json"), createPackageJson({ |
| 229 | 241 | packageName, |
| 230 | 242 | packageVersion, |
| @@ -10,12 +10,16 @@ describe("distribution flow", () => { · 9 unmodified lines | ||
| 10 | 10 | const verifyInstallFlow = <R>( |
| 11 | 11 | runCommand: ( |
| 12 | 12 | args: ReadonlyArray<string>, |
| 13 | − ) => Effect.Effect<{ stdout: string }, Error, R>, | |
| 13 | + options?: { | |
| 14 | + readonly okExitCodes?: ReadonlyArray<number>; | |
| 15 | + }, | |
| 16 | + ) => Effect.Effect<{ stdout: string; stderr: string }, Error, R>, | |
| 14 | 17 | ) => |
| 15 | 18 | Effect.gen(function* () { |
| 16 | 19 | const harness = yield* DistributionHarness; |
| 17 | 20 | |
| 18 | 21 | yield* harness.writeProjectConfig(`{ |
| 22 | + "runtime": "ses", | |
| 19 | 23 | // local workspace config |
| 20 | 24 | "sources": {}, |
| 21 | 25 | } |
| @@ -65,6 +69,17 @@ describe("distribution flow", () => { · 43 unmodified lines | ||
| 65 | 69 | accountId: string; |
| 66 | 70 | }; |
| 67 | 71 | |
| 72 | + const sesCall = yield* runCommand( | |
| 73 | + [ | |
| 74 | + "call", | |
| 75 | + 'await fetch("https://example.com"); return 1;', | |
| 76 | + "--base-url", | |
| 77 | + harness.baseUrl, | |
| 78 | + ], | |
| 79 | + { okExitCodes: [1] }, | |
| 80 | + ); | |
| 81 | + expect(sesCall.stderr).toContain("fetch is disabled in SES executor"); | |
| 82 | + | |
| 68 | 83 | yield* runCommand(["down", "--base-url", harness.baseUrl]); |
| 69 | 84 | yield* runCommand(["up", "--base-url", harness.baseUrl]); |
| 70 | 85 | |
| @@ -84,11 +99,13 @@ describe("distribution flow", () => { · 13 unmodified lines | ||
| 84 | 99 | }); |
| 85 | 100 | |
| 86 | 101 | it.live("boots a staged package artifact in a fresh home", () => |
| 87 | − verifyInstallFlow((args) => Effect.flatMap(DistributionHarness, (harness) => harness.run(args))) | |
| 102 | + verifyInstallFlow((args, options) => | |
| 103 | + Effect.flatMap(DistributionHarness, (harness) => harness.run(args, options)) | |
| 104 | + ) | |
| 88 | 105 | .pipe(Effect.provide(LocalDistributionHarnessLive)), 240_000); |
| 89 | 106 | |
| 90 | 107 | it.live("boots an npm-installed package in a fresh home", () => |
| 91 | − verifyInstallFlow((args) => | |
| 92 | − Effect.flatMap(DistributionHarness, (harness) => harness.runInstalled(args)) | |
| 108 | + verifyInstallFlow((args, options) => | |
| 109 | + Effect.flatMap(DistributionHarness, (harness) => harness.runInstalled(args, options)) | |
| 93 | 110 | ).pipe(Effect.provide(LocalDistributionHarnessLive)), 240_000); |
| 94 | 111 | }); |
| @@ -0,0 +1,137 @@ | ||
| 1 | +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; | |
| 2 | +import { tmpdir } from "node:os"; | |
| 3 | +import { join } from "node:path"; | |
| 4 | +import { describe, expect, it } from "@effect/vitest"; | |
| 5 | +import * as Effect from "effect/Effect"; | |
| 6 | + | |
| 7 | +import { | |
| 8 | + createControlPlaneRuntime, | |
| 9 | +} from "./index"; | |
| 10 | +import { withControlPlaneClient } from "./test-http-client"; | |
| 11 | + | |
| 12 | +const writeProjectConfig = ( | |
| 13 | + workspaceRoot: string, | |
| 14 | + config: Record<string, unknown>, | |
| 15 | +) => { | |
| 16 | + const configDirectory = join(workspaceRoot, ".executor"); | |
| 17 | + mkdirSync(configDirectory, { recursive: true }); | |
| 18 | + writeFileSync( | |
| 19 | + join(configDirectory, "executor.jsonc"), | |
| 20 | + `${JSON.stringify(config, null, 2)}\n`, | |
| 21 | + "utf8", | |
| 22 | + ); | |
| 23 | +}; | |
| 24 | + | |
| 25 | +const makeRuntime = (config: Record<string, unknown>) => { | |
| 26 | + const workspaceRoot = mkdtempSync(join(tmpdir(), "executor-runtime-config-")); | |
| 27 | + writeProjectConfig(workspaceRoot, config); | |
| 28 | + | |
| 29 | + return Effect.acquireRelease( | |
| 30 | + createControlPlaneRuntime({ | |
| 31 | + workspaceRoot, | |
| 32 | + homeConfigPath: join(workspaceRoot, ".executor-home.jsonc"), | |
| 33 | + homeStateDirectory: join(workspaceRoot, ".executor-home-state"), | |
| 34 | + }), | |
| 35 | + (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie), | |
| 36 | + ); | |
| 37 | +}; | |
| 38 | + | |
| 39 | +describe("execution runtime config", () => { | |
| 40 | + it.scoped("defaults to QuickJS when no runtime is configured", () => | |
| 41 | + Effect.gen(function* () { | |
| 42 | + const runtime = yield* makeRuntime({ | |
| 43 | + sources: {}, | |
| 44 | + }); | |
| 45 | + const installation = runtime.localInstallation; | |
| 46 | + | |
| 47 | + const execution = yield* withControlPlaneClient( | |
| 48 | + { | |
| 49 | + runtime, | |
| 50 | + accountId: installation.accountId, | |
| 51 | + }, | |
| 52 | + (client) => | |
| 53 | + client.executions.create({ | |
| 54 | + path: { | |
| 55 | + workspaceId: installation.workspaceId, | |
| 56 | + }, | |
| 57 | + payload: { | |
| 58 | + code: 'await fetch("https://example.com"); return 1;', | |
| 59 | + }, | |
| 60 | + }), | |
| 61 | + ); | |
| 62 | + | |
| 63 | + expect(execution.execution.status).toBe("failed"); | |
| 64 | + expect(execution.execution.errorText).toContain( | |
| 65 | + "fetch is disabled in QuickJS executor", | |
| 66 | + ); | |
| 67 | + }), | |
| 68 | + 60_000, | |
| 69 | + ); | |
| 70 | + | |
| 71 | + it.scoped("uses the SES runtime when configured in executor.jsonc", () => | |
| 72 | + Effect.gen(function* () { | |
| 73 | + const runtime = yield* makeRuntime({ | |
| 74 | + runtime: "ses", | |
| 75 | + sources: {}, | |
| 76 | + }); | |
| 77 | + const installation = runtime.localInstallation; | |
| 78 | + | |
| 79 | + const execution = yield* withControlPlaneClient( | |
| 80 | + { | |
| 81 | + runtime, | |
| 82 | + accountId: installation.accountId, | |
| 83 | + }, | |
| 84 | + (client) => | |
| 85 | + client.executions.create({ | |
| 86 | + path: { | |
| 87 | + workspaceId: installation.workspaceId, | |
| 88 | + }, | |
| 89 | + payload: { | |
| 90 | + code: 'await fetch("https://example.com"); return 1;', | |
| 91 | + }, | |
| 92 | + }), | |
| 93 | + ); | |
| 94 | + | |
| 95 | + expect(execution.execution.status).toBe("failed"); | |
| 96 | + expect(execution.execution.errorText).toContain( | |
| 97 | + "fetch is disabled in SES executor", | |
| 98 | + ); | |
| 99 | + }), | |
| 100 | + 60_000, | |
| 101 | + ); | |
| 102 | + | |
| 103 | + it.scoped("uses the Deno runtime when configured in executor.jsonc", () => | |
| 104 | + Effect.gen(function* () { | |
| 105 | + const runtime = yield* makeRuntime({ | |
| 106 | + runtime: "deno", | |
| 107 | + sources: {}, | |
| 108 | + }); | |
| 109 | + const installation = runtime.localInstallation; | |
| 110 | + | |
| 111 | + const execution = yield* withControlPlaneClient( | |
| 112 | + { | |
| 113 | + runtime, | |
| 114 | + accountId: installation.accountId, | |
| 115 | + }, | |
| 116 | + (client) => | |
| 117 | + client.executions.create({ | |
| 118 | + path: { | |
| 119 | + workspaceId: installation.workspaceId, | |
| 120 | + }, | |
| 121 | + payload: { | |
| 122 | + code: 'return typeof Deno !== "undefined";', | |
| 123 | + }, | |
| 124 | + }), | |
| 125 | + ); | |
| 126 | + | |
| 127 | + if (execution.execution.status === "completed") { | |
| 128 | + expect(execution.execution.resultJson).toBe("true"); | |
| 129 | + return; | |
| 130 | + } | |
| 131 | + | |
| 132 | + expect(execution.execution.status).toBe("failed"); | |
| 133 | + expect(execution.execution.errorText).toContain("Install Deno or set DENO_BIN"); | |
| 134 | + }), | |
| 135 | + 60_000, | |
| 136 | + ); | |
| 137 | +}); | |
| @@ -8,6 +8,7 @@ import * as Effect from "effect/Effect"; · 7 unmodified lines | ||
| 8 | 8 | |
| 9 | 9 | import { |
| 10 | 10 | loadLocalExecutorConfig, |
| 11 | + mergeLocalExecutorConfigs, | |
| 11 | 12 | resolveDefaultHomeConfigCandidates, |
| 12 | 13 | resolveDefaultHomeStateDirectory, |
| 13 | 14 | resolveHomeConfigPath, |
| @@ -27,6 +28,7 @@ describe("local-config", () => { · 13 unmodified lines | ||
| 27 | 28 | writeFile( |
| 28 | 29 | join(configDirectory, "executor.jsonc"), |
| 29 | 30 | `{ |
| 31 | + "runtime": "ses", | |
| 30 | 32 | // local workspace config |
| 31 | 33 | "sources": { |
| 32 | 34 | "github": { |
| @@ -52,6 +54,7 @@ describe("local-config", () => { · 19 unmodified lines | ||
| 52 | 54 | expect(loaded.config?.sources?.github?.connection.endpoint).toBe( |
| 53 | 55 | "https://api.github.com", |
| 54 | 56 | ); |
| 57 | + expect(loaded.config?.runtime).toBe("ses"); | |
| 55 | 58 | expect(context.homeStateDirectory).toContain("executor"); |
| 56 | 59 | }).pipe(Effect.provide(NodeFileSystem.layer)), |
| 57 | 60 | ); |
| @@ -146,4 +149,18 @@ describe("local-config", () => { · 88 unmodified lines | ||
| 146 | 149 | expect(resolvedPath).toBe(join(legacyConfigDirectory, "executor.jsonc")); |
| 147 | 150 | }).pipe(Effect.provide(NodeFileSystem.layer)), |
| 148 | 151 | ); |
| 152 | + | |
| 153 | + it("lets project config override the merged runtime", () => { | |
| 154 | + const merged = mergeLocalExecutorConfigs( | |
| 155 | + { | |
| 156 | + runtime: "quickjs", | |
| 157 | + sources: {}, | |
| 158 | + }, | |
| 159 | + { | |
| 160 | + runtime: "deno", | |
| 161 | + }, | |
| 162 | + ); | |
| 163 | + | |
| 164 | + expect(merged?.runtime).toBe("deno"); | |
| 165 | + }); | |
| 149 | 166 | }); |
| @@ -258,6 +258,7 @@ export const mergeLocalExecutorConfigs = ( · 257 unmodified lines | ||
| 258 | 258 | } |
| 259 | 259 | |
| 260 | 260 | return decodeLocalExecutorConfig({ |
| 261 | + runtime: extra?.runtime ?? base?.runtime, | |
| 261 | 262 | workspace: { |
| 262 | 263 | ...(base?.workspace ?? {}), |
| 263 | 264 | ...(extra?.workspace ?? {}), |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { |
| 2 | + type CodeExecutor, | |
| 2 | 3 | createSystemToolMap, |
| 3 | 4 | createToolCatalogFromEntries, |
| 4 | 5 | createToolCatalogFromTools, |
| @@ -9,8 +10,15 @@ import { · 4 unmodified lines | ||
| 9 | 10 | type ToolInvoker, |
| 10 | 11 | type ToolPath, |
| 11 | 12 | } from "@executor/codemode-core"; |
| 13 | +import { makeDenoSubprocessExecutor } from "@executor/runtime-deno-subprocess"; | |
| 12 | 14 | import { makeQuickJsExecutor } from "@executor/runtime-quickjs"; |
| 13 | −import type { AccountId, Source } from "#schema"; | |
| 15 | +import { makeSesExecutor } from "@executor/runtime-ses"; | |
| 16 | +import type { | |
| 17 | + AccountId, | |
| 18 | + LocalExecutorConfig, | |
| 19 | + LocalExecutorRuntime, | |
| 20 | + Source, | |
| 21 | +} from "#schema"; | |
| 14 | 22 | import * as Context from "effect/Context"; |
| 15 | 23 | import * as Either from "effect/Either"; |
| 16 | 24 | import * as Effect from "effect/Effect"; |
| @@ -67,6 +75,26 @@ import { · 50 unmodified lines | ||
| 67 | 75 | |
| 68 | 76 | const asToolPath = (value: string): ToolPath => value as ToolPath; |
| 69 | 77 | |
| 78 | +const DEFAULT_EXECUTION_RUNTIME: LocalExecutorRuntime = "quickjs"; | |
| 79 | + | |
| 80 | +export const resolveConfiguredExecutionRuntime = ( | |
| 81 | + config: LocalExecutorConfig | null | undefined, | |
| 82 | +): LocalExecutorRuntime => config?.runtime ?? DEFAULT_EXECUTION_RUNTIME; | |
| 83 | + | |
| 84 | +export const createCodeExecutorForRuntime = ( | |
| 85 | + runtime: LocalExecutorRuntime, | |
| 86 | +): CodeExecutor => { | |
| 87 | + switch (runtime) { | |
| 88 | + case "deno": | |
| 89 | + return makeDenoSubprocessExecutor(); | |
| 90 | + case "ses": | |
| 91 | + return makeSesExecutor(); | |
| 92 | + case "quickjs": | |
| 93 | + default: | |
| 94 | + return makeQuickJsExecutor(); | |
| 95 | + } | |
| 96 | +}; | |
| 97 | + | |
| 70 | 98 | const tokenize = (value: string): string[] => |
| 71 | 99 | value |
| 72 | 100 | .trim() |
| @@ -652,6 +680,10 @@ export const createWorkspaceExecutionEnvironmentResolver = (input: { · 579 unmodified lines | ||
| 652 | 680 | ({ workspaceId, accountId, onElicitation }) => |
| 653 | 681 | Effect.gen(function* () { |
| 654 | 682 | const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption(); |
| 683 | + const loadedConfig = | |
| 684 | + runtimeLocalWorkspace === null | |
| 685 | + ? null | |
| 686 | + : yield* input.workspaceConfigStore.load(runtimeLocalWorkspace.context); | |
| 655 | 687 | const localToolRuntime = |
| 656 | 688 | runtimeLocalWorkspace === null |
| 657 | 689 | ? { |
| @@ -675,7 +707,9 @@ export const createWorkspaceExecutionEnvironmentResolver = (input: { · 17 unmodified lines | ||
| 675 | 707 | onElicitation, |
| 676 | 708 | }); |
| 677 | 709 | |
| 678 | − const executor = makeQuickJsExecutor(); | |
| 710 | + const executor = createCodeExecutorForRuntime( | |
| 711 | + resolveConfiguredExecutionRuntime(loadedConfig?.config), | |
| 712 | + ); | |
| 679 | 713 | |
| 680 | 714 | return { |
| 681 | 715 | executor, |
| @@ -2,6 +2,12 @@ import { Schema } from "effect"; · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import { SourceTransportSchema, StringMapSchema } from "./source"; |
| 4 | 4 | |
| 5 | +export const LocalExecutorRuntimeSchema = Schema.Literal( | |
| 6 | + "quickjs", | |
| 7 | + "ses", | |
| 8 | + "deno", | |
| 9 | +); | |
| 10 | + | |
| 5 | 11 | export const LocalConfigSecretProviderSourceSchema = Schema.Literal( |
| 6 | 12 | "env", |
| 7 | 13 | "file", |
| @@ -156,6 +162,7 @@ export const LocalConfigWorkspaceSchema = Schema.Struct({ · 148 unmodified lines | ||
| 156 | 162 | }); |
| 157 | 163 | |
| 158 | 164 | export const LocalExecutorConfigSchema = Schema.Struct({ |
| 165 | + runtime: Schema.optional(LocalExecutorRuntimeSchema), | |
| 159 | 166 | workspace: Schema.optional(LocalConfigWorkspaceSchema), |
| 160 | 167 | sources: Schema.optional( |
| 161 | 168 | Schema.Record({ |
| @@ -182,4 +189,5 @@ export type LocalConfigSecretInput = typeof LocalConfigSecretInputSchema.Type; · 20 unmodified lines | ||
| 182 | 189 | export type LocalConfigSource = typeof LocalConfigSourceSchema.Type; |
| 183 | 190 | export type LocalConfigPolicy = typeof LocalConfigPolicySchema.Type; |
| 184 | 191 | export type LocalConfigSecrets = typeof LocalConfigSecretsSchema.Type; |
| 192 | +export type LocalExecutorRuntime = typeof LocalExecutorRuntimeSchema.Type; | |
| 185 | 193 | export type LocalExecutorConfig = typeof LocalExecutorConfigSchema.Type; |