Unified · Split
| @@ -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 | ||
| 30 | Control-plane management from CLI should happen through `run execute` and discovered | 31 | Control-plane management from CLI should happen through `run execute` and discovered |
| 31 | source tool paths (for example, add Executor's own OpenAPI source, then call its | 32 | source 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 | ||
| @@ -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("&", "&") | ||
| 56 | .replaceAll("<", "<") | ||
| 57 | .replaceAll(">", ">") | ||
| 58 | .replaceAll('"', """) | ||
| 59 | .replaceAll("'", "'"); | ||
| 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(); | ||
| @@ -8,6 +8,7 @@ import { FetchHttpClient } from "@effect/platform"; · 7 unmodified lines | |||
| 8 | import { BunContext, BunRuntime } from "@effect/platform-bun"; | 8 | import { BunContext, BunRuntime } from "@effect/platform-bun"; |
| 9 | import { | 9 | import { |
| 10 | createExecutorApiFetchHandler, | 10 | createExecutorApiFetchHandler, |
| 11 | ExecutorControlPlaneBaseUrlHeader, | ||
| 11 | type ExecutorApiPrincipal, | 12 | type ExecutorApiPrincipal, |
| 12 | } from "@executor-v2/api-http"; | 13 | } from "@executor-v2/api-http"; |
| 13 | import { makeControlPlaneClient } from "@executor-v2/management-api/client"; | 14 | import { 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 | ||
| 35 | type WorkspaceRecord = { | 37 | type WorkspaceRecord = { |
| 36 | id: string; | 38 | id: string; |
| 37 | }; | 39 | }; |
| 38 | 40 | ||
| 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 | |||
| 39 | type DeviceAuthorizationResponse = { | 55 | type 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().length | 1160 | 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 | |||
| 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"), | ||
| 1159 | ); | 1266 | ); |
| 1160 | 1267 | ||
| 1161 | const runCommand = Command.make("run").pipe( | 1268 | const 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 | ||
| @@ -2,6 +2,7 @@ import { withAuth } from "@workos-inc/authkit-nextjs"; · 1 unmodified line | |||
| 2 | import type { NextRequest } from "next/server"; | 2 | import type { NextRequest } from "next/server"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 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 | ||
| 41 | const storageErrorResponse = (operation: string, cause: unknown): Response => { | 48 | const storageErrorResponse = (operation: string, cause: unknown): Response => { |
| @@ -1,6 +1,7 @@ | |||
| 1 | import { | 1 | import { |
| 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"; |
| 63 | import { | 68 | import { |
| @@ -69,6 +74,7 @@ import { makeDenoSubprocessRuntimeAdapter } from "@executor-v2/runtime-deno-subp · 5 unmodified lines | |||
| 69 | import { makeLocalInProcessRuntimeAdapter } from "@executor-v2/runtime-local-inproc"; | 74 | import { makeLocalInProcessRuntimeAdapter } from "@executor-v2/runtime-local-inproc"; |
| 70 | import * as Effect from "effect/Effect"; | 75 | import * as Effect from "effect/Effect"; |
| 71 | import * as Layer from "effect/Layer"; | 76 | import * as Layer from "effect/Layer"; |
| 77 | import * as Option from "effect/Option"; | ||
| 72 | import { webServerEnvironment } from "../env/server"; | 78 | import { webServerEnvironment } from "../env/server"; |
| 73 | 79 | ||
| 74 | const isPlanetScalePostgresUrl = (value: string): boolean => { | 80 | const isPlanetScalePostgresUrl = (value: string): boolean => { |
| @@ -194,10 +200,55 @@ const resolveStateRootDir = (): string => · 119 unmodified lines | |||
| 194 | 200 | ||
| 195 | const openApiSyncRetryDelayMs = 300; | 201 | const openApiSyncRetryDelayMs = 300; |
| 196 | const runtimeToolCallRetentionMs = 15 * 60 * 1000; | 202 | const runtimeToolCallRetentionMs = 15 * 60 * 1000; |
| 197 | const defaultGithubOpenApiSpecUrl = | 203 | const 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 | |
| 199 | const defaultGithubApiBaseUrl = "https://api.github.com"; | 205 | const trimTrailingSlash = (value: string): string => |
| 200 | const defaultGithubSourceId = "src_github_openapi"; | 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 | }; | ||
| 201 | 252 | ||
| 202 | const formatCause = (cause: unknown): string => { | 253 | const 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") { |
| @@ -1,4 +1,5 @@ | |||
| 1 | export { | 1 | export { |
| 2 | ExecutorControlPlaneBaseUrlHeader, | ||
| 2 | type ExecutorApiHandlers, | 3 | type ExecutorApiHandlers, |
| 3 | type ExecutorApiPrincipal, | 4 | type ExecutorApiPrincipal, |
| 4 | createExecutorApiFetchHandler, | 5 | createExecutorApiFetchHandler, |
| @@ -14,6 +14,9 @@ export type ExecutorApiPrincipal = { · 13 unmodified lines | |||
| 14 | workspaceId: string; | 14 | workspaceId: string; |
| 15 | }; | 15 | }; |
| 16 | 16 | ||
| 17 | export const ExecutorControlPlaneBaseUrlHeader = | ||
| 18 | "x-executor-control-plane-base-url" as const; | ||
| 19 | |||
| 17 | export type ExecutorApiHandlers = { | 20 | export 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 | ||
| 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 | |||
| 115 | const resolveExecutionWorkspaceId = ( | 149 | const 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 | ||
| @@ -7,6 +7,9 @@ export type RunTerminalStatus = · 6 unmodified lines | |||
| 7 | export type ExecuteRunInput = { | 7 | export type ExecuteRunInput = { |
| 8 | code: string; | 8 | code: string; |
| 9 | timeoutMs?: number; | 9 | timeoutMs?: number; |
| 10 | context?: { | ||
| 11 | controlPlaneBaseUrl?: string; | ||
| 12 | }; | ||
| 10 | }; | 13 | }; |
| 11 | 14 | ||
| 12 | export type ExecuteRunResult = { | 15 | export type ExecuteRunResult = { |
| @@ -75,4 +78,3 @@ export const createExecutorRunClient = ( · 62 unmodified lines | |||
| 75 | execute, | 78 | execute, |
| 76 | }); | 79 | }); |
| 77 | 80 | ||
| 78 | |||