Skip to content

Commit 661ed29

Browse files
committed
Enhance runtime configuration options in executor. Added support for specifying execution runtime in .executor/executor.jsonc with options: "quickjs", "ses", and "deno". Updated documentation and tests to reflect these changes. Implemented runtime selection logic in the control plane.
1 parent e4deb68 commit 661ed29

11 files changed

Lines changed: 260 additions & 7 deletions

File tree

‎.changeset/bumpy-cloths-pay.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"executor": patch
3+
---
4+
5+
Support selecting runtime

‎ARCHITECTURE.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ and runs user-authored code against that environment.
117117

118118
The default runtime executes code inside a QuickJS WebAssembly sandbox so tool calls stay proxied through the control plane.
119119

120+
The workspace can override that in `.executor/executor.jsonc` with `runtime: "quickjs" | "ses" | "deno"`.
121+
120122
### Adapter packages
121123

122124
Several packages exist to turn external systems into callable tools:

‎README.md‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,11 +228,20 @@ At a high level, every execution follows the same loop:
228228

229229
1. `executor` resolves the current local installation and workspace.
230230
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"`.
232232
4. Tool calls are dispatched through `executor` rather than directly from your code.
233233
5. If a tool needs interaction, the run pauses and records a pending interaction.
234234
6. Once the interaction is resolved, the execution continues and eventually completes or fails.
235235

236+
Example:
237+
238+
```jsonc
239+
{
240+
"runtime": "ses",
241+
"sources": {}
242+
}
243+
```
244+
236245
This gives you a stable surface for agent automation:
237246

238247
- the agent sees a coherent catalog
@@ -317,6 +326,7 @@ If you are exploring the repo, these are the directories that matter most:
317326
- `apps/web`: local React web UI
318327
- `packages/server`: local HTTP server that serves API, MCP, and UI
319328
- `packages/control-plane`: source management, secrets, persistence, execution, and inspection
329+
- `packages/runtime-deno-subprocess`: optional Deno subprocess runtime for TypeScript execution
320330
- `packages/runtime-quickjs`: default QuickJS sandbox runtime for TypeScript execution
321331
- `packages/runtime-ses`: optional SES sandbox runtime for TypeScript execution
322332
- `packages/executor-mcp`: MCP bridge for `execute` and `resume`

‎apps/docs/developer/tool-catalog-and-execution.mdx‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ Executor turns connected integrations into a workspace tool catalog.
77

88
That catalog is the thing the runtime actually executes against.
99

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+
1020
## How tools are produced
1121

1222
At a high level:

‎apps/executor/src/distribution/artifact.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,18 @@ export const buildDistributionPackage = async (
225225
join(repoRoot, "packages/runtime-deno-subprocess/src/deno-subprocess-worker.mjs"),
226226
join(binDir, "deno-subprocess-worker.mjs"),
227227
);
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+
});
228240
await writeFile(join(packageDir, "package.json"), createPackageJson({
229241
packageName,
230242
packageVersion,

‎apps/executor/src/distribution/distribution.test.ts‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,16 @@ describe("distribution flow", () => {
1010
const verifyInstallFlow = <R>(
1111
runCommand: (
1212
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>,
1417
) =>
1518
Effect.gen(function* () {
1619
const harness = yield* DistributionHarness;
1720

1821
yield* harness.writeProjectConfig(`{
22+
"runtime": "ses",
1923
// local workspace config
2024
"sources": {},
2125
}
@@ -65,6 +69,17 @@ describe("distribution flow", () => {
6569
accountId: string;
6670
};
6771

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+
6883
yield* runCommand(["down", "--base-url", harness.baseUrl]);
6984
yield* runCommand(["up", "--base-url", harness.baseUrl]);
7085

@@ -84,11 +99,13 @@ describe("distribution flow", () => {
8499
});
85100

86101
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+
)
88105
.pipe(Effect.provide(LocalDistributionHarnessLive)), 240_000);
89106

90107
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))
93110
).pipe(Effect.provide(LocalDistributionHarnessLive)), 240_000);
94111
});
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

‎packages/control-plane/src/runtime/local-config.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as Effect from "effect/Effect";
88

99
import {
1010
loadLocalExecutorConfig,
11+
mergeLocalExecutorConfigs,
1112
resolveDefaultHomeConfigCandidates,
1213
resolveDefaultHomeStateDirectory,
1314
resolveHomeConfigPath,
@@ -27,6 +28,7 @@ describe("local-config", () => {
2728
writeFile(
2829
join(configDirectory, "executor.jsonc"),
2930
`{
31+
"runtime": "ses",
3032
// local workspace config
3133
"sources": {
3234
"github": {
@@ -52,6 +54,7 @@ describe("local-config", () => {
5254
expect(loaded.config?.sources?.github?.connection.endpoint).toBe(
5355
"https://api.github.com",
5456
);
57+
expect(loaded.config?.runtime).toBe("ses");
5558
expect(context.homeStateDirectory).toContain("executor");
5659
}).pipe(Effect.provide(NodeFileSystem.layer)),
5760
);
@@ -146,4 +149,18 @@ describe("local-config", () => {
146149
expect(resolvedPath).toBe(join(legacyConfigDirectory, "executor.jsonc"));
147150
}).pipe(Effect.provide(NodeFileSystem.layer)),
148151
);
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+
});
149166
});

‎packages/control-plane/src/runtime/local-config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@ export const mergeLocalExecutorConfigs = (
258258
}
259259

260260
return decodeLocalExecutorConfig({
261+
runtime: extra?.runtime ?? base?.runtime,
261262
workspace: {
262263
...(base?.workspace ?? {}),
263264
...(extra?.workspace ?? {}),

‎packages/control-plane/src/runtime/workspace-execution-environment.ts‎

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
type CodeExecutor,
23
createSystemToolMap,
34
createToolCatalogFromEntries,
45
createToolCatalogFromTools,
@@ -9,8 +10,15 @@ import {
910
type ToolInvoker,
1011
type ToolPath,
1112
} from "@executor/codemode-core";
13+
import { makeDenoSubprocessExecutor } from "@executor/runtime-deno-subprocess";
1214
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";
1422
import * as Context from "effect/Context";
1523
import * as Either from "effect/Either";
1624
import * as Effect from "effect/Effect";
@@ -67,6 +75,26 @@ import {
6775

6876
const asToolPath = (value: string): ToolPath => value as ToolPath;
6977

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+
7098
const tokenize = (value: string): string[] =>
7199
value
72100
.trim()
@@ -652,6 +680,10 @@ export const createWorkspaceExecutionEnvironmentResolver = (input: {
652680
({ workspaceId, accountId, onElicitation }) =>
653681
Effect.gen(function* () {
654682
const runtimeLocalWorkspace = yield* getRuntimeLocalWorkspaceOption();
683+
const loadedConfig =
684+
runtimeLocalWorkspace === null
685+
? null
686+
: yield* input.workspaceConfigStore.load(runtimeLocalWorkspace.context);
655687
const localToolRuntime =
656688
runtimeLocalWorkspace === null
657689
? {
@@ -675,7 +707,9 @@ export const createWorkspaceExecutionEnvironmentResolver = (input: {
675707
onElicitation,
676708
});
677709

678-
const executor = makeQuickJsExecutor();
710+
const executor = createCodeExecutorForRuntime(
711+
resolveConfiguredExecutionRuntime(loadedConfig?.config),
712+
);
679713

680714
return {
681715
executor,

0 commit comments

Comments
 (0)