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
2626 - `executor workspace use <workspace-id>`
2727 - `executor run execute --code ...`
2828 - `executor run execute --file ...`
29+- `executor run describe`
2930
3031 Control-plane management from CLI should happen through `run execute` and discovered
3132 source tool paths (for example, add Executor's own OpenAPI source, then call its
@@ -35,6 +36,7 @@ Run locally: · 3 unmodified lines
3536
3637 - `bun run --cwd apps/cli start -- target show`
3738 - `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`
3840 - `bun run --cwd apps/cli start -- --help`
3941 - `bun run --cwd apps/cli start -- server start --port 8788`
4042
apps/cli/src/interaction-prototype.ts+516−0
@@ -0,0 +1,516 @@
1+import { createInterface } from "node:readline/promises";
2+import { spawnSync } from "node:child_process";
3+import { stdin as input, stdout as output } from "node:process";
4+
5+type RunStatus =
6+ | "queued"
7+ | "running"
8+ | "waiting_for_interaction"
9+ | "completed"
10+ | "failed";
11+
12+type InteractionKind = "source_oauth_signin" | "approval";
13+type InteractionStatus = "pending" | "resolved" | "denied";
14+
15+type 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+
25+type RunRecord = {
26+ id: string;
27+ status: RunStatus;
28+ result: unknown;
29+ error: string | null;
30+};
31+
32+type Step =
33+ | {
34+ type: "interaction";
35+ create: (runId: string) => Interaction;
36+ }
37+ | {
38+ type: "complete";
39+ result: unknown;
40+ };
41+
42+const AXIOM_MCP_URL = "https://mcp.axiom.co/mcp";
43+const DEFAULT_WEB_ORIGIN = "http://127.0.0.1:3000";
44+
45+type PrototypeWebServer = {
46+ stop: () => void;
47+ origin: string;
48+};
49+
50+const normalizeOrigin = (raw: string): string =>
51+ raw.endsWith("/") ? raw.slice(0, -1) : raw;
52+
53+const escapeHtml = (value: string): string =>
54+ value
55+ .replaceAll("&", "&amp;")
56+ .replaceAll("<", "&lt;")
57+ .replaceAll(">", "&gt;")
58+ .replaceAll('"', "&quot;")
59+ .replaceAll("'", "&#39;");
60+
61+const buildOAuthStartUrl = (input: {
62+ sourceUrl: string;
63+ webOrigin: string;
64+}): string =>
65+ `${normalizeOrigin(input.webOrigin)}/mcp/oauth/start?sourceUrl=${encodeURIComponent(input.sourceUrl)}`;
66+
67+const 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+
88+const 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+
178+const sleep = (ms: number): Promise<void> =>
179+ new Promise((resolve) => setTimeout(resolve, ms));
180+
181+class 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+
352+const 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+
365+const 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+
379+const 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+
394+const 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+
458+const 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+
516+void main();
apps/cli/src/main.ts+109−2
@@ -8,6 +8,7 @@ import { FetchHttpClient } from "@effect/platform"; · 7 unmodified lines
88 import { BunContext, BunRuntime } from "@effect/platform-bun";
99 import {
1010 createExecutorApiFetchHandler,
11+ ExecutorControlPlaneBaseUrlHeader,
1112 type ExecutorApiPrincipal,
1213 } from "@executor-v2/api-http";
1314 import { makeControlPlaneClient } from "@executor-v2/management-api/client";
@@ -30,12 +31,27 @@ type RequestOptions = { · 16 unmodified lines
3031 method: "GET" | "POST" | "DELETE";
3132 path: string;
3233 body?: unknown;
34+ headers?: Record<string, string>;
3335 };
3436
3537 type WorkspaceRecord = {
3638 id: string;
3739 };
3840
41+type McpToolsListTool = {
42+ name?: string;
43+ description?: string;
44+};
45+
46+type McpToolsListResponse = {
47+ result?: {
48+ tools?: Array<McpToolsListTool>;
49+ };
50+ error?: {
51+ message?: string;
52+ };
53+};
54+
3955 type DeviceAuthorizationResponse = {
4056 device_code: string;
4157 user_code: string;
@@ -302,6 +318,9 @@ class ExecutorServerClient { · 260 unmodified lines
302318 const baseUrl = await this.#resolveBaseUrl();
303319 const url = `${baseUrl}${options.path}`;
304320 const headers = await this.#buildHeaders();
321+ if (options.headers) {
322+ Object.assign(headers, options.headers);
323+ }
305324 if (options.body !== undefined) {
306325 headers["Content-Type"] = "application/json";
307326 }
@@ -342,6 +361,10 @@ class ExecutorServerClient { · 34 unmodified lines
342361 return Effect.runPromise(program.pipe(Effect.provide(FetchHttpClient.layer)));
343362 }
344363
364+ async resolveBaseUrl(): Promise<string> {
365+ return this.#resolveBaseUrl();
366+ }
367+
345368 async #buildHeaders(): Promise<Record<string, string>> {
346369 const headers: Record<string, string> = {
347370 Accept: "application/json",
@@ -1133,6 +1156,7 @@ const runExecuteCommand = Command.make( · 785 unmodified lines
11331156 };
11341157
11351158 await withClient(common, async ({ client, config, workspaceOverride, asJson }) => {
1159+ const controlPlaneBaseUrl = await client.resolveBaseUrl();
11361160 const workspaceId = workspaceOverride?.trim().length
11371161 ? workspaceOverride.trim()
11381162 : (config.workspaceId?.trim().length ? config.workspaceId.trim() : undefined);
@@ -1141,6 +1165,9 @@ const runExecuteCommand = Command.make( · 2 unmodified lines
11411165 const result = await client.request<unknown>({
11421166 method: "POST",
11431167 path: `/execute${query}`,
1168+ headers: {
1169+ [ExecutorControlPlaneBaseUrlHeader]: controlPlaneBaseUrl,
1170+ },
11441171 body: {
11451172 code,
11461173 ...(Option.isSome(input.timeoutMs)
@@ -1155,11 +1182,91 @@ const runExecuteCommand = Command.make( · 8 unmodified lines
11551182 catch: toErrorMessage,
11561183 }),
11571184 ).pipe(
1158− Command.withDescription("Execute TypeScript code through Executor runtime"),
1185+ Command.withDescription("Execute JavaScript against configured runtime"),
1186+);
1187+
1188+const 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"),
11591266 );
11601267
11611268 const runCommand = Command.make("run").pipe(
1162− Command.withSubcommands([runExecuteCommand] as any),
1269+ Command.withSubcommands([runExecuteCommand, runDescribeCommand] as any),
11631270 Command.withDescription("Code execution commands"),
11641271 );
11651272
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
22 import type { NextRequest } from "next/server";
33
44 import {
5+ ExecutorControlPlaneBaseUrlHeader,
56 createExecutorApiFetchHandler,
67 type ExecutorApiPrincipal,
78 } from "@executor-v2/api-http";
@@ -35,7 +36,13 @@ const rewriteControlPlaneRequest = ( · 27 unmodified lines
3536 ): Request => {
3637 const rewrittenUrl = new URL(request.url);
3738 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 });
3946 };
4047
4148 const storageErrorResponse = (operation: string, cause: unknown): Response => {
apps/web/lib/control-plane/server.ts+171−17
@@ -1,6 +1,7 @@
11 import {
22 ControlPlaneAuthHeaders,
33 ControlPlaneService,
4+ controlPlaneOpenApiSpec,
45 fetchOpenApiDocument,
56 makeControlPlaneService,
67 makeControlPlaneSourcesService,
@@ -58,6 +59,10 @@ import { · 51 unmodified lines
5859 type Profile,
5960 type Organization,
6061 type OrganizationMembership,
62+ type Source,
63+ type SourceId,
64+ type ToolArtifact,
65+ type WorkspaceId,
6166 type Workspace,
6267 } from "@executor-v2/schema";
6368 import {
@@ -69,6 +74,7 @@ import { makeDenoSubprocessRuntimeAdapter } from "@executor-v2/runtime-deno-subp · 5 unmodified lines
6974 import { makeLocalInProcessRuntimeAdapter } from "@executor-v2/runtime-local-inproc";
7075 import * as Effect from "effect/Effect";
7176 import * as Layer from "effect/Layer";
77+import * as Option from "effect/Option";
7278 import { webServerEnvironment } from "../env/server";
7379
7480 const isPlanetScalePostgresUrl = (value: string): boolean => {
@@ -194,10 +200,55 @@ const resolveStateRootDir = (): string => · 119 unmodified lines
194200
195201 const openApiSyncRetryDelayMs = 300;
196202 const runtimeToolCallRetentionMs = 15 * 60 * 1000;
197−const defaultGithubOpenApiSpecUrl =
198− "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json";
199−const defaultGithubApiBaseUrl = "https://api.github.com";
200−const defaultGithubSourceId = "src_github_openapi";
203+const runtimeExecutorSourceId = "src_executor_control_plane" as SourceId;
204+
205+const trimTrailingSlash = (value: string): string =>
206+ value.replace(/\/+$/, "");
207+
208+const 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+
230+const 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+};
201252
202253 const formatCause = (cause: unknown): string => {
203254 if (cause && typeof cause === "object") {
@@ -754,6 +805,25 @@ const createControlPlaneRuntime = async (): Promise<ControlPlaneRuntime> => { · 550 unmodified lines
754805 runCleanupTimers.set(runId, timer);
755806 };
756807
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+
757827 const resolveMcpSession = (workspaceId: string): {
758828 handler: (request: Request) => Promise<Response>;
759829 executeRun: (input: ExecuteRunInput) => Effect.Effect<ExecuteRunResult>;
@@ -772,18 +842,7 @@ const createControlPlaneRuntime = async (): Promise<ControlPlaneRuntime> => { · 12 unmodified lines
772842 toolProviderRegistry,
773843 approvalPolicy: persistentApprovalPolicy,
774844 });
775− const executeRuntimeRun = createRuntimeHostExecuteRuntimeRun({
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− });
845+ const runExecutor = createWorkspaceRunExecutor(workspaceId, toolRegistry);
787846 const next = createRuntimeHostMcpHandler(runExecutor.executeRun, {
788847 toolRegistry,
789848 defaultToolExposureMode,
@@ -828,6 +887,90 @@ const createControlPlaneRuntime = async (): Promise<ControlPlaneRuntime> => { · 38 unmodified lines
828887 return session;
829888 };
830889
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+
831974 return {
832975 persistence,
833976 sourceStore,
@@ -840,7 +983,18 @@ const createControlPlaneRuntime = async (): Promise<ControlPlaneRuntime> => { · 6 unmodified lines
840983 },
841984 executeRun: async (input, workspaceId) => {
842985 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));
844998 },
845999 handleRuntimeToolCall: async (request) => {
8461000 if (request.method.toUpperCase() !== "POST") {
packages/api-http/src/index.ts+1−0
@@ -1,4 +1,5 @@
11 export {
2+ ExecutorControlPlaneBaseUrlHeader,
23 type ExecutorApiHandlers,
34 type ExecutorApiPrincipal,
45 createExecutorApiFetchHandler,
packages/api-http/src/router.ts+46−1
@@ -14,6 +14,9 @@ export type ExecutorApiPrincipal = { · 13 unmodified lines
1414 workspaceId: string;
1515 };
1616
17+export const ExecutorControlPlaneBaseUrlHeader =
18+ "x-executor-control-plane-base-url" as const;
19+
1720 export type ExecutorApiHandlers = {
1821 handleControlPlane: (request: Request) => Promise<Response>;
1922 handleMcp: (request: Request, workspaceId: string) => Promise<Response>;
@@ -112,6 +115,37 @@ const parseExecuteRequest = async (request: Request): Promise<ExecuteRunInput | · 92 unmodified lines
112115 };
113116 };
114117
118+const 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+
136+const 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+
115149 const resolveExecutionWorkspaceId = (
116150 request: Request,
117151 principal: ExecutorApiPrincipal,
@@ -221,8 +255,19 @@ export const createExecutorApiFetchHandler = ( · 103 unmodified lines
221255 );
222256 }
223257
258+ const controlPlaneBaseUrl = resolveControlPlaneBaseUrl(request);
259+
224260 const result = await options.handlers.executeRun(
225− input,
261+ {
262+ ...input,
263+ ...(controlPlaneBaseUrl
264+ ? {
265+ context: {
266+ controlPlaneBaseUrl,
267+ },
268+ }
269+ : {}),
270+ },
226271 resolveExecutionWorkspaceId(request, principalResult, options.defaultMcpWorkspaceId),
227272 );
228273
packages/sdk/src/index.ts+3−1
@@ -7,6 +7,9 @@ export type RunTerminalStatus = · 6 unmodified lines
77 export type ExecuteRunInput = {
88 code: string;
99 timeoutMs?: number;
10+ context?: {
11+ controlPlaneBaseUrl?: string;
12+ };
1013 };
1114
1215 export type ExecuteRunResult = {
@@ -75,4 +78,3 @@ export const createExecutorRunClient = ( · 62 unmodified lines
7578 execute,
7679 });
7780
78−