History

wip api in cli

Rhys Sullivan committed 8 months ago

Commit 508c8d41 parent be4adee8 files · +856 −22Browse files

Unified · Split

8 files
  1. apps/cli/README.md +2 −0
  2. apps/cli/src/interaction-prototype.ts +516 −0
  3. apps/cli/src/main.ts +109 −2
  4. apps/web/app/api/control-plane/[...path]/route.ts +8 −1
  5. apps/web/lib/control-plane/server.ts +171 −17
  6. packages/api-http/src/index.ts +1 −0
  7. packages/api-http/src/router.ts +46 −1
  8. packages/sdk/src/index.ts +3 −1
apps/cli/README.md+2−0
@@ -26,6 +26,7 @@ Supported commands: · 25 unmodified lines
26- `executor workspace use <workspace-id>`26- `executor workspace use <workspace-id>`
27- `executor run execute --code ...`27- `executor run execute --code ...`
28- `executor run execute --file ...`28- `executor run execute --file ...`
29- `executor run describe`
29 30
30Control-plane management from CLI should happen through `run execute` and discovered31Control-plane management from CLI should happen through `run execute` and discovered
31source tool paths (for example, add Executor's own OpenAPI source, then call its32source tool paths (for example, add Executor's own OpenAPI source, then call its
@@ -35,6 +36,7 @@ Run locally: · 3 unmodified lines
35 36
36- `bun run --cwd apps/cli start -- target show`37- `bun run --cwd apps/cli start -- target show`
37- `bun run --cwd apps/cli start -- run execute --target local --code "return 1"`38- `bun run --cwd apps/cli start -- run execute --target local --code "return 1"`
39- `bun run --cwd apps/cli start -- run describe --target local --workspace ws_local`
38- `bun run --cwd apps/cli start -- --help`40- `bun run --cwd apps/cli start -- --help`
39- `bun run --cwd apps/cli start -- server start --port 8788`41- `bun run --cwd apps/cli start -- server start --port 8788`
40 42
apps/cli/src/interaction-prototype.ts+516−0
@@ -0,0 +1,516 @@
1import { createInterface } from "node:readline/promises";
2import { spawnSync } from "node:child_process";
3import { stdin as input, stdout as output } from "node:process";
4
5type RunStatus =
6 | "queued"
7 | "running"
8 | "waiting_for_interaction"
9 | "completed"
10 | "failed";
11
12type InteractionKind = "source_oauth_signin" | "approval";
13type InteractionStatus = "pending" | "resolved" | "denied";
14
15type Interaction = {
16 id: string;
17 runId: string;
18 kind: InteractionKind;
19 status: InteractionStatus;
20 blocking: boolean;
21 title: string;
22 request: Record<string, unknown>;
23};
24
25type RunRecord = {
26 id: string;
27 status: RunStatus;
28 result: unknown;
29 error: string | null;
30};
31
32type Step =
33 | {
34 type: "interaction";
35 create: (runId: string) => Interaction;
36 }
37 | {
38 type: "complete";
39 result: unknown;
40 };
41
42const AXIOM_MCP_URL = "https://mcp.axiom.co/mcp";
43const DEFAULT_WEB_ORIGIN = "http://127.0.0.1:3000";
44
45type PrototypeWebServer = {
46 stop: () => void;
47 origin: string;
48};
49
50const normalizeOrigin = (raw: string): string =>
51 raw.endsWith("/") ? raw.slice(0, -1) : raw;
52
53const escapeHtml = (value: string): string =>
54 value
55 .replaceAll("&", "&amp;")
56 .replaceAll("<", "&lt;")
57 .replaceAll(">", "&gt;")
58 .replaceAll('"', "&quot;")
59 .replaceAll("'", "&#39;");
60
61const buildOAuthStartUrl = (input: {
62 sourceUrl: string;
63 webOrigin: string;
64}): string =>
65 `${normalizeOrigin(input.webOrigin)}/mcp/oauth/start?sourceUrl=${encodeURIComponent(input.sourceUrl)}`;
66
67const readFlagValue = (name: string): string | undefined => {
68 const prefix = `${name}=`;
69 for (let index = 0; index < process.argv.length; index += 1) {
70 const arg = process.argv[index];
71 if (arg === name) {
72 const next = process.argv[index + 1];
73 if (next && !next.startsWith("--")) {
74 const trimmed = next.trim();
75 return trimmed.length > 0 ? trimmed : undefined;
76 }
77 }
78
79 if (arg.startsWith(prefix)) {
80 const value = arg.slice(prefix.length).trim();
81 return value.length > 0 ? value : undefined;
82 }
83 }
84
85 return undefined;
86};
87
88const startPrototypeWebServer = (webOrigin: string): PrototypeWebServer => {
89 const origin = normalizeOrigin(webOrigin);
90 const parsed = new URL(origin);
91
92 if (parsed.protocol !== "http:") {
93 throw new Error(`Mock OAuth server expects http origin, received: ${origin}`);
94 }
95
96 const bunRuntime = (globalThis as { Bun?: unknown }).Bun;
97 if (!bunRuntime || typeof bunRuntime !== "object" || !("serve" in bunRuntime)) {
98 throw new Error("Bun runtime is required for the mock OAuth server.");
99 }
100
101 const serve = (bunRuntime as {
102 serve: (input: {
103 hostname?: string;
104 port?: number;
105 fetch: (request: Request) => Response;
106 }) => { stop: (closeActiveConnections?: boolean) => void };
107 }).serve;
108
109 const port = parsed.port.length > 0 ? Number(parsed.port) : 80;
110 if (!Number.isInteger(port) || port <= 0) {
111 throw new Error(`Invalid port in web origin: ${origin}`);
112 }
113
114 const server = serve({
115 hostname: parsed.hostname,
116 port,
117 fetch: (request) => {
118 const url = new URL(request.url);
119
120 if (url.pathname === "/healthz") {
121 return Response.json({
122 ok: true,
123 service: "executor-interaction-prototype-oauth",
124 });
125 }
126
127 if (url.pathname === "/mcp/oauth/start") {
128 const sourceUrl = url.searchParams.get("sourceUrl") ?? AXIOM_MCP_URL;
129 const completeHref = `/mcp/oauth/complete?sourceUrl=${encodeURIComponent(sourceUrl)}`;
130
131 return new Response(
132 `<!doctype html><html><head><meta charset="utf-8"><title>Mock Axiom OAuth</title></head><body style="font-family: sans-serif; max-width: 760px; margin: 40px auto; line-height: 1.5;"><h1>Mock Axiom MCP Sign-In</h1><p>This is a local mock OAuth page served by the CLI prototype.</p><p><strong>Source URL:</strong> <code>${escapeHtml(sourceUrl)}</code></p><p><a href="${escapeHtml(completeHref)}" style="display:inline-block;padding:10px 14px;border:1px solid #222;text-decoration:none;border-radius:6px;">Approve and Continue</a></p></body></html>`,
133 {
134 headers: {
135 "content-type": "text/html; charset=utf-8",
136 "cache-control": "no-store",
137 },
138 },
139 );
140 }
141
142 if (url.pathname === "/mcp/oauth/complete") {
143 const sourceUrl = url.searchParams.get("sourceUrl") ?? AXIOM_MCP_URL;
144 const payload = {
145 type: "executor-v2:mcp-oauth-result",
146 ok: true,
147 sourceUrl,
148 payload: {
149 accessToken: "mock_access_token",
150 },
151 };
152
153 return new Response(
154 `<!doctype html><html><head><meta charset="utf-8"><title>OAuth Complete</title></head><body style="font-family: sans-serif; max-width: 760px; margin: 40px auto; line-height: 1.5;"><h1>Mock OAuth Complete</h1><p>You can return to the terminal now.</p><script>try { if (window.opener) { window.opener.postMessage(${JSON.stringify(payload)}, window.location.origin); } } catch {} setTimeout(() => window.close(), 300);</script></body></html>`,
155 {
156 headers: {
157 "content-type": "text/html; charset=utf-8",
158 "cache-control": "no-store",
159 },
160 },
161 );
162 }
163
164 return new Response("Not found", { status: 404 });
165 },
166 });
167
168 output.write(`[web] Mock OAuth server listening on ${origin}\n`);
169
170 return {
171 origin,
172 stop: () => {
173 server.stop(true);
174 },
175 };
176};
177
178const sleep = (ms: number): Promise<void> =>
179 new Promise((resolve) => setTimeout(resolve, ms));
180
181class MockInteractionBackend {
182 #runs = new Map<string, RunRecord>();
183 #interactions = new Map<string, Interaction>();
184 #stepsByRun = new Map<string, Array<Step>>();
185
186 readonly #webOrigin: string;
187
188 constructor(webOrigin: string) {
189 this.#webOrigin = normalizeOrigin(webOrigin);
190 }
191
192 createRun(): { runId: string } {
193 const runId = `run_${crypto.randomUUID()}`;
194 this.#runs.set(runId, {
195 id: runId,
196 status: "queued",
197 result: null,
198 error: null,
199 });
200
201 this.#stepsByRun.set(runId, [
202 {
203 type: "interaction",
204 create: (id) => ({
205 id: `int_${crypto.randomUUID()}`,
206 runId: id,
207 kind: "source_oauth_signin",
208 status: "pending",
209 blocking: true,
210 title: "Sign in to Axiom MCP",
211 request: {
212 sourceName: "axiom-mcp",
213 endpoint: AXIOM_MCP_URL,
214 authorizationUrl: buildOAuthStartUrl({
215 sourceUrl: AXIOM_MCP_URL,
216 webOrigin: this.#webOrigin,
217 }),
218 },
219 }),
220 },
221 {
222 type: "interaction",
223 create: (id) => ({
224 id: `int_${crypto.randomUUID()}`,
225 runId: id,
226 kind: "approval",
227 status: "pending",
228 blocking: true,
229 title: "Approve tool call",
230 request: {
231 toolPath: "source.axiom-mcp.datasets/query",
232 reason: "Run wants to read issue telemetry for the selected repository.",
233 inputPreview: {
234 query: "['errors'] | where repo == 'vercel/next.js' | limit 5",
235 },
236 },
237 }),
238 },
239 {
240 type: "complete",
241 result: {
242 ok: true,
243 rows: [
244 { issue: 90837, count: 16 },
245 { issue: 90829, count: 9 },
246 { issue: 90798, count: 5 },
247 ],
248 },
249 },
250 ]);
251
252 void this.#advance(runId);
253 return { runId };
254 }
255
256 getRun(runId: string): RunRecord {
257 const run = this.#runs.get(runId);
258 if (!run) {
259 throw new Error(`Run not found: ${runId}`);
260 }
261
262 return run;
263 }
264
265 listPendingInteractions(runId: string): Array<Interaction> {
266 const items: Array<Interaction> = [];
267
268 for (const interaction of this.#interactions.values()) {
269 if (interaction.runId === runId && interaction.status === "pending") {
270 items.push(interaction);
271 }
272 }
273
274 return items;
275 }
276
277 async respondToInteraction(input: {
278 interactionId: string;
279 decision: "approved" | "denied";
280 payload?: Record<string, unknown>;
281 }): Promise<void> {
282 const interaction = this.#interactions.get(input.interactionId);
283 if (!interaction) {
284 throw new Error(`Interaction not found: ${input.interactionId}`);
285 }
286
287 if (interaction.status !== "pending") {
288 return;
289 }
290
291 if (input.decision === "denied") {
292 interaction.status = "denied";
293 this.#interactions.set(interaction.id, interaction);
294
295 const run = this.getRun(interaction.runId);
296 run.status = "failed";
297 run.error = `Interaction denied: ${interaction.kind}`;
298 this.#runs.set(run.id, run);
299 return;
300 }
301
302 interaction.status = "resolved";
303 this.#interactions.set(interaction.id, {
304 ...interaction,
305 request: {
306 ...interaction.request,
307 response: input.payload ?? null,
308 },
309 });
310
311 const run = this.getRun(interaction.runId);
312 run.status = "running";
313 this.#runs.set(run.id, run);
314 await this.#advance(run.id);
315 }
316
317 async #advance(runId: string): Promise<void> {
318 const run = this.getRun(runId);
319 if (run.status === "failed" || run.status === "completed") {
320 return;
321 }
322
323 run.status = "running";
324 this.#runs.set(run.id, run);
325 await sleep(350);
326
327 const remaining = this.#stepsByRun.get(runId) ?? [];
328 const next = remaining.shift();
329 this.#stepsByRun.set(runId, remaining);
330
331 if (!next) {
332 run.status = "completed";
333 run.result = { ok: true };
334 this.#runs.set(run.id, run);
335 return;
336 }
337
338 if (next.type === "complete") {
339 run.status = "completed";
340 run.result = next.result;
341 this.#runs.set(run.id, run);
342 return;
343 }
344
345 const interaction = next.create(runId);
346 this.#interactions.set(interaction.id, interaction);
347 run.status = "waiting_for_interaction";
348 this.#runs.set(run.id, run);
349 }
350}
351
352const tryOpen = (command: string, args: Array<string>): boolean => {
353 const result = spawnSync(command, args, {
354 stdio: "ignore",
355 windowsHide: true,
356 });
357
358 if (result.error) {
359 return false;
360 }
361
362 return typeof result.status === "number" ? result.status === 0 : true;
363};
364
365const openBrowser = (url: string): boolean => {
366 output.write(`\n[cli] Opening browser: ${url}\n`);
367
368 if (process.platform === "darwin") {
369 return tryOpen("open", [url]);
370 }
371
372 if (process.platform === "win32") {
373 return tryOpen("cmd", ["/c", "start", "", url]);
374 }
375
376 return tryOpen("xdg-open", [url]) || tryOpen("gio", ["open", url]);
377};
378
379const promptYesNo = async (
380 rl: ReturnType<typeof createInterface>,
381 prompt: string,
382 defaultYes = true,
383): Promise<boolean> => {
384 const suffix = defaultYes ? "[Y/n]" : "[y/N]";
385 const answer = (await rl.question(`${prompt} ${suffix} `)).trim().toLowerCase();
386
387 if (answer.length === 0) {
388 return defaultYes;
389 }
390
391 return answer === "y" || answer === "yes";
392};
393
394const handleInteraction = async (input: {
395 interaction: Interaction;
396 backend: MockInteractionBackend;
397 rl: ReturnType<typeof createInterface>;
398 auto: boolean;
399}): Promise<void> => {
400 const { interaction, backend, rl, auto } = input;
401
402 if (interaction.kind === "source_oauth_signin") {
403 const authorizationUrl = String(interaction.request.authorizationUrl ?? "");
404 const sourceName = String(interaction.request.sourceName ?? "source");
405 output.write(`\n[interaction] ${interaction.title}\n`);
406 output.write(`[interaction] Source: ${sourceName}\n`);
407 output.write(`[interaction] Using OAuth bootstrap: ${authorizationUrl}\n`);
408
409 if (auto) {
410 output.write("[demo] Auto mode: skipping browser launch and simulating callback...\n");
411 await sleep(700);
412 } else {
413 const opened = openBrowser(authorizationUrl);
414 if (!opened) {
415 output.write("[cli] Could not auto-open browser. Please open this URL manually.\n");
416 output.write(`[cli] ${authorizationUrl}\n`);
417 }
418
419 await rl.question("Complete sign-in in the browser, then press Enter to continue... ");
420 }
421
422 await backend.respondToInteraction({
423 interactionId: interaction.id,
424 decision: "approved",
425 payload: {
426 completedAt: Date.now(),
427 },
428 });
429
430 output.write("[interaction] OAuth sign-in completed.\n");
431 return;
432 }
433
434 if (interaction.kind === "approval") {
435 const toolPath = String(interaction.request.toolPath ?? "unknown.tool");
436 output.write(`\n[interaction] ${interaction.title}\n`);
437 output.write(`[interaction] Tool: ${toolPath}\n`);
438
439 const approved = auto
440 ? true
441 : await promptYesNo(rl, "Approve this tool call?", true);
442
443 await backend.respondToInteraction({
444 interactionId: interaction.id,
445 decision: approved ? "approved" : "denied",
446 payload: {
447 approved,
448 decidedAt: Date.now(),
449 },
450 });
451
452 output.write(approved
453 ? "[interaction] Tool call approved.\n"
454 : "[interaction] Tool call denied.\n");
455 }
456};
457
458const main = async (): Promise<void> => {
459 const auto = process.argv.includes("--auto");
460 const webOrigin =
461 readFlagValue("--web-origin")
462 ?? process.env.EXECUTOR_WEB_ORIGIN?.trim()
463 ?? DEFAULT_WEB_ORIGIN;
464 const rl = createInterface({ input, output });
465 const backend = new MockInteractionBackend(webOrigin);
466 const webServer = startPrototypeWebServer(webOrigin);
467
468 try {
469 output.write("Executor Interaction Prototype\n");
470 output.write("- single run\n");
471 output.write("- pauses on interactions\n");
472 output.write("- resumes when resolved\n");
473 output.write(`- Axiom MCP URL: ${AXIOM_MCP_URL}\n`);
474 output.write(`- OAuth start origin: ${webServer.origin}\n`);
475
476 const { runId } = backend.createRun();
477 output.write(`\n[run] started: ${runId}\n`);
478
479 while (true) {
480 const run = backend.getRun(runId);
481
482 if (run.status === "completed") {
483 output.write(`\n[run] completed\n`);
484 output.write(`[run] result: ${JSON.stringify(run.result, null, 2)}\n`);
485 break;
486 }
487
488 if (run.status === "failed") {
489 output.write(`\n[run] failed: ${run.error ?? "unknown error"}\n`);
490 break;
491 }
492
493 if (run.status === "waiting_for_interaction") {
494 const pending = backend.listPendingInteractions(runId);
495 if (pending.length === 0) {
496 await sleep(250);
497 continue;
498 }
499
500 for (const interaction of pending) {
501 await handleInteraction({ interaction, backend, rl, auto });
502 }
503
504 continue;
505 }
506
507 output.write("[run] running...\n");
508 await sleep(250);
509 }
510 } finally {
511 webServer.stop();
512 rl.close();
513 }
514};
515
516void main();
apps/cli/src/main.ts+109−2
@@ -8,6 +8,7 @@ import { FetchHttpClient } from "@effect/platform"; · 7 unmodified lines
8import { BunContext, BunRuntime } from "@effect/platform-bun";8import { BunContext, BunRuntime } from "@effect/platform-bun";
9import {9import {
10 createExecutorApiFetchHandler,10 createExecutorApiFetchHandler,
11 ExecutorControlPlaneBaseUrlHeader,
11 type ExecutorApiPrincipal,12 type ExecutorApiPrincipal,
12} from "@executor-v2/api-http";13} from "@executor-v2/api-http";
13import { makeControlPlaneClient } from "@executor-v2/management-api/client";14import { makeControlPlaneClient } from "@executor-v2/management-api/client";
@@ -30,12 +31,27 @@ type RequestOptions = { · 16 unmodified lines
30 method: "GET" | "POST" | "DELETE";31 method: "GET" | "POST" | "DELETE";
31 path: string;32 path: string;
32 body?: unknown;33 body?: unknown;
34 headers?: Record<string, string>;
33};35};
34 36
35type WorkspaceRecord = {37type WorkspaceRecord = {
36 id: string;38 id: string;
37};39};
38 40
41type McpToolsListTool = {
42 name?: string;
43 description?: string;
44};
45
46type McpToolsListResponse = {
47 result?: {
48 tools?: Array<McpToolsListTool>;
49 };
50 error?: {
51 message?: string;
52 };
53};
54
39type DeviceAuthorizationResponse = {55type DeviceAuthorizationResponse = {
40 device_code: string;56 device_code: string;
41 user_code: string;57 user_code: string;
@@ -302,6 +318,9 @@ class ExecutorServerClient { · 260 unmodified lines
302 const baseUrl = await this.#resolveBaseUrl();318 const baseUrl = await this.#resolveBaseUrl();
303 const url = `${baseUrl}${options.path}`;319 const url = `${baseUrl}${options.path}`;
304 const headers = await this.#buildHeaders();320 const headers = await this.#buildHeaders();
321 if (options.headers) {
322 Object.assign(headers, options.headers);
323 }
305 if (options.body !== undefined) {324 if (options.body !== undefined) {
306 headers["Content-Type"] = "application/json";325 headers["Content-Type"] = "application/json";
307 }326 }
@@ -342,6 +361,10 @@ class ExecutorServerClient { · 34 unmodified lines
342 return Effect.runPromise(program.pipe(Effect.provide(FetchHttpClient.layer)));361 return Effect.runPromise(program.pipe(Effect.provide(FetchHttpClient.layer)));
343 }362 }
344 363
364 async resolveBaseUrl(): Promise<string> {
365 return this.#resolveBaseUrl();
366 }
367
345 async #buildHeaders(): Promise<Record<string, string>> {368 async #buildHeaders(): Promise<Record<string, string>> {
346 const headers: Record<string, string> = {369 const headers: Record<string, string> = {
347 Accept: "application/json",370 Accept: "application/json",
@@ -1133,6 +1156,7 @@ const runExecuteCommand = Command.make( · 785 unmodified lines
1133 };1156 };
1134 1157
1135 await withClient(common, async ({ client, config, workspaceOverride, asJson }) => {1158 await withClient(common, async ({ client, config, workspaceOverride, asJson }) => {
1159 const controlPlaneBaseUrl = await client.resolveBaseUrl();
1136 const workspaceId = workspaceOverride?.trim().length1160 const workspaceId = workspaceOverride?.trim().length
1137 ? workspaceOverride.trim()1161 ? workspaceOverride.trim()
1138 : (config.workspaceId?.trim().length ? config.workspaceId.trim() : undefined);1162 : (config.workspaceId?.trim().length ? config.workspaceId.trim() : undefined);
@@ -1141,6 +1165,9 @@ const runExecuteCommand = Command.make( · 2 unmodified lines
1141 const result = await client.request<unknown>({1165 const result = await client.request<unknown>({
1142 method: "POST",1166 method: "POST",
1143 path: `/execute${query}`,1167 path: `/execute${query}`,
1168 headers: {
1169 [ExecutorControlPlaneBaseUrlHeader]: controlPlaneBaseUrl,
1170 },
1144 body: {1171 body: {
1145 code,1172 code,
1146 ...(Option.isSome(input.timeoutMs)1173 ...(Option.isSome(input.timeoutMs)
@@ -1155,11 +1182,91 @@ const runExecuteCommand = Command.make( · 8 unmodified lines
1155 catch: toErrorMessage,1182 catch: toErrorMessage,
1156 }),1183 }),
1157).pipe(1184).pipe(
1158 Command.withDescription("Execute TypeScript code through Executor runtime"),1185 Command.withDescription("Execute JavaScript against configured runtime"),
1186);
1187
1188const runDescribeCommand = Command.make(
1189 "describe",
1190 {
1191 ...commonTargetOptions(),
1192 toolExposureMode: Options.choice("tool-exposure-mode", ["sources_only", "all_tools"]).pipe(
1193 Options.optional,
1194 ),
1195 },
1196 (input) =>
1197 Effect.tryPromise({
1198 try: async () => {
1199 const common: CommonTargetOptions = {
1200 target: input.target,
1201 workspace: input.workspace,
1202 baseUrl: input.baseUrl,
1203 json: input.json,
1204 };
1205
1206 await withClient(common, async ({ client, config, workspaceOverride, asJson }) => {
1207 const workspaceId = await ensureWorkspaceId(client, config, workspaceOverride);
1208 const controlPlaneBaseUrl = await client.resolveBaseUrl();
1209 const toolExposureMode = optionToUndefined(input.toolExposureMode);
1210
1211 const queryParts = [
1212 `workspaceId=${encodeURIComponent(workspaceId)}`,
1213 ...(toolExposureMode
1214 ? [`toolExposureMode=${encodeURIComponent(toolExposureMode)}`]
1215 : []),
1216 ];
1217
1218 const response = await client.request<McpToolsListResponse>({
1219 method: "POST",
1220 path: `/v1/mcp?${queryParts.join("&")}`,
1221 headers: {
1222 Accept: "application/json, text/event-stream",
1223 [ExecutorControlPlaneBaseUrlHeader]: controlPlaneBaseUrl,
1224 },
1225 body: {
1226 jsonrpc: "2.0",
1227 id: "executor-cli-tools-list",
1228 method: "tools/list",
1229 params: {},
1230 },
1231 });
1232
1233 if (response.error?.message) {
1234 throw new Error(response.error.message);
1235 }
1236
1237 const executeTool = response.result?.tools?.find((tool) => tool.name === "executor.execute");
1238 const description =
1239 typeof executeTool?.description === "string"
1240 ? executeTool.description.trim()
1241 : "";
1242
1243 if (description.length === 0) {
1244 throw new Error("Executor execute tool description is unavailable.");
1245 }
1246
1247 if (asJson) {
1248 printOutput(
1249 {
1250 workspaceId,
1251 toolExposureMode: toolExposureMode ?? "sources_only",
1252 description,
1253 },
1254 true,
1255 );
1256 return;
1257 }
1258
1259 stdout.write(`${description}\n`);
1260 });
1261 },
1262 catch: toErrorMessage,
1263 }),
1264).pipe(
1265 Command.withDescription("Show execute tool description with namespace/tool context"),
1159);1266);
1160 1267
1161const runCommand = Command.make("run").pipe(1268const runCommand = Command.make("run").pipe(
1162 Command.withSubcommands([runExecuteCommand] as any),1269 Command.withSubcommands([runExecuteCommand, runDescribeCommand] as any),
1163 Command.withDescription("Code execution commands"),1270 Command.withDescription("Code execution commands"),
1164);1271);
1165 1272
apps/web/app/api/control-plane/[...path]/route.ts+8−1
@@ -2,6 +2,7 @@ import { withAuth } from "@workos-inc/authkit-nextjs"; · 1 unmodified line
2import type { NextRequest } from "next/server";2import type { NextRequest } from "next/server";
3 3
4import {4import {
5 ExecutorControlPlaneBaseUrlHeader,
5 createExecutorApiFetchHandler,6 createExecutorApiFetchHandler,
6 type ExecutorApiPrincipal,7 type ExecutorApiPrincipal,
7} from "@executor-v2/api-http";8} from "@executor-v2/api-http";
@@ -35,7 +36,13 @@ const rewriteControlPlaneRequest = ( · 27 unmodified lines
35): Request => {36): Request => {
36 const rewrittenUrl = new URL(request.url);37 const rewrittenUrl = new URL(request.url);
37 rewrittenUrl.pathname = `/${path.join("/")}`;38 rewrittenUrl.pathname = `/${path.join("/")}`;
38 return new Request(rewrittenUrl, request);39 const rewrittenRequest = new Request(rewrittenUrl, request);
40 const headers = new Headers(request.headers);
41 headers.set(
42 ExecutorControlPlaneBaseUrlHeader,
43 `${request.nextUrl.origin}/api/control-plane`,
44 );
45 return new Request(rewrittenRequest, { headers });
39};46};
40 47
41const storageErrorResponse = (operation: string, cause: unknown): Response => {48const storageErrorResponse = (operation: string, cause: unknown): Response => {
apps/web/lib/control-plane/server.ts+171−17
@@ -1,6 +1,7 @@
1import {1import {
2 ControlPlaneAuthHeaders,2 ControlPlaneAuthHeaders,
3 ControlPlaneService,3 ControlPlaneService,
4 controlPlaneOpenApiSpec,
4 fetchOpenApiDocument,5 fetchOpenApiDocument,
5 makeControlPlaneService,6 makeControlPlaneService,
6 makeControlPlaneSourcesService,7 makeControlPlaneSourcesService,
@@ -58,6 +59,10 @@ import { · 51 unmodified lines
58 type Profile,59 type Profile,
59 type Organization,60 type Organization,
60 type OrganizationMembership,61 type OrganizationMembership,
62 type Source,
63 type SourceId,
64 type ToolArtifact,
65 type WorkspaceId,
61 type Workspace,66 type Workspace,
62} from "@executor-v2/schema";67} from "@executor-v2/schema";
63import {68import {
@@ -69,6 +74,7 @@ import { makeDenoSubprocessRuntimeAdapter } from "@executor-v2/runtime-deno-subp · 5 unmodified lines
69import { makeLocalInProcessRuntimeAdapter } from "@executor-v2/runtime-local-inproc";74import { makeLocalInProcessRuntimeAdapter } from "@executor-v2/runtime-local-inproc";
70import * as Effect from "effect/Effect";75import * as Effect from "effect/Effect";
71import * as Layer from "effect/Layer";76import * as Layer from "effect/Layer";
77import * as Option from "effect/Option";
72import { webServerEnvironment } from "../env/server";78import { webServerEnvironment } from "../env/server";
73 79
74const isPlanetScalePostgresUrl = (value: string): boolean => {80const isPlanetScalePostgresUrl = (value: string): boolean => {
@@ -194,10 +200,55 @@ const resolveStateRootDir = (): string => · 119 unmodified lines
194 200
195const openApiSyncRetryDelayMs = 300;201const openApiSyncRetryDelayMs = 300;
196const runtimeToolCallRetentionMs = 15 * 60 * 1000;202const runtimeToolCallRetentionMs = 15 * 60 * 1000;
197const defaultGithubOpenApiSpecUrl =203const runtimeExecutorSourceId = "src_executor_control_plane" as SourceId;
198 "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json";204
199const defaultGithubApiBaseUrl = "https://api.github.com";205const trimTrailingSlash = (value: string): string =>
200const defaultGithubSourceId = "src_github_openapi";206 value.replace(/\/+$/, "");
207
208const normalizeHttpBaseUrl = (value: string | undefined): string | null => {
209 if (!value) {
210 return null;
211 }
212
213 const trimmed = value.trim();
214 if (trimmed.length === 0) {
215 return null;
216 }
217
218 try {
219 const parsed = new URL(trimmed);
220 if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
221 return null;
222 }
223
224 return trimTrailingSlash(parsed.toString());
225 } catch {
226 return null;
227 }
228};
229
230const buildRuntimeExecutorSource = (
231 workspaceId: string,
232 controlPlaneBaseUrl: string,
233): Source => {
234 const now = Date.now();
235 const baseUrl = trimTrailingSlash(controlPlaneBaseUrl);
236
237 return {
238 id: runtimeExecutorSourceId,
239 workspaceId: workspaceId as WorkspaceId,
240 name: "executor",
241 kind: "openapi",
242 endpoint: `${baseUrl}/v1/openapi.json`,
243 status: "connected",
244 enabled: true,
245 configJson: JSON.stringify({ baseUrl }),
246 sourceHash: null,
247 lastError: null,
248 createdAt: now,
249 updatedAt: now,
250 };
251};
201 252
202const formatCause = (cause: unknown): string => {253const formatCause = (cause: unknown): string => {
203 if (cause && typeof cause === "object") {254 if (cause && typeof cause === "object") {
@@ -754,6 +805,25 @@ const createControlPlaneRuntime = async (): Promise<ControlPlaneRuntime> => { · 550 unmodified lines
754 runCleanupTimers.set(runId, timer);805 runCleanupTimers.set(runId, timer);
755 };806 };
756 807
808 const createWorkspaceRunExecutor = (
809 workspaceId: string,
810 toolRegistry: ReturnType<typeof createSourceToolRegistry>,
811 ) => {
812 const executeRuntimeRun = createRuntimeHostExecuteRuntimeRun({
813 defaultRuntimeKind,
814 runtimeAdapters,
815 toolRegistry,
816 });
817
818 return createRunExecutor(executeRuntimeRun, {
819 makeRunId: () => {
820 const runId = `run_${crypto.randomUUID()}`;
821 rememberRunWorkspace(runId, workspaceId);
822 return runId;
823 },
824 });
825 };
826
757 const resolveMcpSession = (workspaceId: string): {827 const resolveMcpSession = (workspaceId: string): {
758 handler: (request: Request) => Promise<Response>;828 handler: (request: Request) => Promise<Response>;
759 executeRun: (input: ExecuteRunInput) => Effect.Effect<ExecuteRunResult>;829 executeRun: (input: ExecuteRunInput) => Effect.Effect<ExecuteRunResult>;
@@ -772,18 +842,7 @@ const createControlPlaneRuntime = async (): Promise<ControlPlaneRuntime> => { · 12 unmodified lines
772 toolProviderRegistry,842 toolProviderRegistry,
773 approvalPolicy: persistentApprovalPolicy,843 approvalPolicy: persistentApprovalPolicy,
774 });844 });
775 const executeRuntimeRun = createRuntimeHostExecuteRuntimeRun({845 const runExecutor = createWorkspaceRunExecutor(workspaceId, toolRegistry);
776 defaultRuntimeKind,
777 runtimeAdapters,
778 toolRegistry,
779 });
780 const runExecutor = createRunExecutor(executeRuntimeRun, {
781 makeRunId: () => {
782 const runId = `run_${crypto.randomUUID()}`;
783 rememberRunWorkspace(runId, workspaceId);
784 return runId;
785 },
786 });
787 const next = createRuntimeHostMcpHandler(runExecutor.executeRun, {846 const next = createRuntimeHostMcpHandler(runExecutor.executeRun, {
788 toolRegistry,847 toolRegistry,
789 defaultToolExposureMode,848 defaultToolExposureMode,
@@ -828,6 +887,90 @@ const createControlPlaneRuntime = async (): Promise<ControlPlaneRuntime> => { · 38 unmodified lines
828 return session;887 return session;
829 };888 };
830 889
890 const resolveExecuteToolRegistry = async (
891 workspaceId: string,
892 input: ExecuteRunInput,
893 fallback: ReturnType<typeof createSourceToolRegistry>,
894 ): Promise<ReturnType<typeof createSourceToolRegistry>> => {
895 const controlPlaneBaseUrl = normalizeHttpBaseUrl(input.context?.controlPlaneBaseUrl);
896 if (!controlPlaneBaseUrl) {
897 return fallback;
898 }
899
900 const runtimeSource = buildRuntimeExecutorSource(workspaceId, controlPlaneBaseUrl);
901 const artifactKey = `${runtimeSource.workspaceId}:${runtimeSource.id}`;
902 const runtimeArtifacts = new Map<string, ToolArtifact>();
903
904 const overlaySourceStore: SourceStore = {
905 getById: (workspaceIdValue, sourceIdValue) => {
906 if (workspaceIdValue === runtimeSource.workspaceId && sourceIdValue === runtimeSource.id) {
907 return Effect.succeed(Option.some(runtimeSource));
908 }
909
910 return sourceStore.getById(workspaceIdValue, sourceIdValue);
911 },
912 listByWorkspace: (workspaceIdValue) =>
913 sourceStore.listByWorkspace(workspaceIdValue).pipe(
914 Effect.map((sources) => {
915 if (workspaceIdValue !== runtimeSource.workspaceId) {
916 return sources;
917 }
918
919 const withoutRuntimeSource = sources.filter((source) => source.id !== runtimeSource.id);
920 return [...withoutRuntimeSource, runtimeSource];
921 }),
922 ),
923 upsert: (source) => sourceStore.upsert(source),
924 removeById: (workspaceIdValue, sourceIdValue) =>
925 sourceStore.removeById(workspaceIdValue, sourceIdValue),
926 };
927
928 const overlayToolArtifactStore: ToolArtifactStore = {
929 getBySource: (workspaceIdValue, sourceIdValue) => {
930 if (workspaceIdValue === runtimeSource.workspaceId && sourceIdValue === runtimeSource.id) {
931 const artifact = runtimeArtifacts.get(artifactKey);
932 return Effect.succeed(artifact ? Option.some(artifact) : Option.none());
933 }
934
935 return toolArtifactStore.getBySource(workspaceIdValue, sourceIdValue);
936 },
937 upsert: (artifact) => {
938 if (artifact.workspaceId === runtimeSource.workspaceId && artifact.sourceId === runtimeSource.id) {
939 return Effect.sync(() => {
940 runtimeArtifacts.set(artifactKey, artifact);
941 });
942 }
943
944 return toolArtifactStore.upsert(artifact);
945 },
946 };
947
948 const overlaySourceManager = makeSourceManagerService(overlayToolArtifactStore);
949 const refreshResult = await Effect.runPromise(
950 overlaySourceManager.refreshOpenApiArtifact({
951 source: runtimeSource,
952 openApiSpec: controlPlaneOpenApiSpec,
953 }).pipe(Effect.either),
954 );
955
956 if (refreshResult._tag === "Left") {
957 console.error("[control-plane] runtime executor source refresh failed", {
958 workspaceId,
959 controlPlaneBaseUrl,
960 details: formatCause(refreshResult.left),
961 });
962 return fallback;
963 }
964
965 return createSourceToolRegistry({
966 workspaceId,
967 sourceStore: overlaySourceStore,
968 toolArtifactStore: overlayToolArtifactStore,
969 toolProviderRegistry,
970 approvalPolicy: persistentApprovalPolicy,
971 });
972 };
973
831 return {974 return {
832 persistence,975 persistence,
833 sourceStore,976 sourceStore,
@@ -840,7 +983,18 @@ const createControlPlaneRuntime = async (): Promise<ControlPlaneRuntime> => { · 6 unmodified lines
840 },983 },
841 executeRun: async (input, workspaceId) => {984 executeRun: async (input, workspaceId) => {
842 const session = resolveMcpSession(workspaceId);985 const session = resolveMcpSession(workspaceId);
843 return Effect.runPromise(session.executeRun(input));986 const toolRegistry = await resolveExecuteToolRegistry(
987 workspaceId,
988 input,
989 session.toolRegistry,
990 );
991
992 if (toolRegistry === session.toolRegistry) {
993 return Effect.runPromise(session.executeRun(input));
994 }
995
996 const runExecutor = createWorkspaceRunExecutor(workspaceId, toolRegistry);
997 return Effect.runPromise(runExecutor.executeRun(input));
844 },998 },
845 handleRuntimeToolCall: async (request) => {999 handleRuntimeToolCall: async (request) => {
846 if (request.method.toUpperCase() !== "POST") {1000 if (request.method.toUpperCase() !== "POST") {
packages/api-http/src/index.ts+1−0
@@ -1,4 +1,5 @@
1export {1export {
2 ExecutorControlPlaneBaseUrlHeader,
2 type ExecutorApiHandlers,3 type ExecutorApiHandlers,
3 type ExecutorApiPrincipal,4 type ExecutorApiPrincipal,
4 createExecutorApiFetchHandler,5 createExecutorApiFetchHandler,
packages/api-http/src/router.ts+46−1
@@ -14,6 +14,9 @@ export type ExecutorApiPrincipal = { · 13 unmodified lines
14 workspaceId: string;14 workspaceId: string;
15};15};
16 16
17export const ExecutorControlPlaneBaseUrlHeader =
18 "x-executor-control-plane-base-url" as const;
19
17export type ExecutorApiHandlers = {20export type ExecutorApiHandlers = {
18 handleControlPlane: (request: Request) => Promise<Response>;21 handleControlPlane: (request: Request) => Promise<Response>;
19 handleMcp: (request: Request, workspaceId: string) => Promise<Response>;22 handleMcp: (request: Request, workspaceId: string) => Promise<Response>;
@@ -112,6 +115,37 @@ const parseExecuteRequest = async (request: Request): Promise<ExecuteRunInput | · 92 unmodified lines
112 };115 };
113};116};
114 117
118const normalizeControlPlaneBaseUrl = (value: string): string | undefined => {
119 const trimmed = value.trim();
120 if (trimmed.length === 0) {
121 return undefined;
122 }
123
124 try {
125 const url = new URL(trimmed);
126 if (url.protocol !== "http:" && url.protocol !== "https:") {
127 return undefined;
128 }
129
130 return url.toString().replace(/\/+$/, "");
131 } catch {
132 return undefined;
133 }
134};
135
136const resolveControlPlaneBaseUrl = (request: Request): string | undefined => {
137 const headerValue = request.headers.get(ExecutorControlPlaneBaseUrlHeader);
138 if (headerValue) {
139 const normalized = normalizeControlPlaneBaseUrl(headerValue);
140 if (normalized) {
141 return normalized;
142 }
143 }
144
145 const url = new URL(request.url);
146 return `${url.origin}`;
147};
148
115const resolveExecutionWorkspaceId = (149const resolveExecutionWorkspaceId = (
116 request: Request,150 request: Request,
117 principal: ExecutorApiPrincipal,151 principal: ExecutorApiPrincipal,
@@ -221,8 +255,19 @@ export const createExecutorApiFetchHandler = ( · 103 unmodified lines
221 );255 );
222 }256 }
223 257
258 const controlPlaneBaseUrl = resolveControlPlaneBaseUrl(request);
259
224 const result = await options.handlers.executeRun(260 const result = await options.handlers.executeRun(
225 input,261 {
262 ...input,
263 ...(controlPlaneBaseUrl
264 ? {
265 context: {
266 controlPlaneBaseUrl,
267 },
268 }
269 : {}),
270 },
226 resolveExecutionWorkspaceId(request, principalResult, options.defaultMcpWorkspaceId),271 resolveExecutionWorkspaceId(request, principalResult, options.defaultMcpWorkspaceId),
227 );272 );
228 273
packages/sdk/src/index.ts+3−1
@@ -7,6 +7,9 @@ export type RunTerminalStatus = · 6 unmodified lines
7export type ExecuteRunInput = {7export type ExecuteRunInput = {
8 code: string;8 code: string;
9 timeoutMs?: number;9 timeoutMs?: number;
10 context?: {
11 controlPlaneBaseUrl?: string;
12 };
10};13};
11 14
12export type ExecuteRunResult = {15export type ExecuteRunResult = {
@@ -75,4 +78,3 @@ export const createExecutorRunClient = ( · 62 unmodified lines
75 execute,78 execute,
76});79});
77 80
78