From fbe53162996c2ba01d5e8daaddc7c075cd8c2dc6 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 11 Feb 2026 08:21:40 -0800 Subject: [PATCH 1/6] Add Cloudflare Workers sandbox runtime for isolated code execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a second code execution runtime (cloudflare-worker-loader) alongside the existing local-bun (node:vm) runtime. Agent-generated code runs in Cloudflare Workers V8 isolates with full network blocking (globalOutbound: null), preventing sandbox escape and data exfiltration. Architecture: - Host Worker (executor/packages/sandbox-host) receives code via POST /v1/runs - Spawns a dynamic V8 isolate per task using the Worker Loader API - User code runs in a separate ES module from the harness, preventing IIFE escape and Response.json hijacking - Tool calls route through a ToolBridge RPC entrypoint back to Convex - Console output is buffered and streamed back in real-time Security hardening: - User code in separate module (user-code.js) — cannot access req/env/ctx - Response.json captured in globals.js before user module evaluation - Timing-safe auth token comparison - All network blocked from isolate; communication only via TOOL_BRIDGE binding Also extracts transpileForRuntime() into a shared module so both runtimes transpile TypeScript before execution. --- .gitignore | 9 + executor/convex/executor.ts | 3 +- executor/convex/executorNode.ts | 87 +++- .../cloudflare-worker-loader-runtime.test.ts | 329 ++++++++++++++ .../cloudflare_worker_loader_runtime.ts | 141 ++++++ executor/lib/runtimes/runtime_catalog.ts | 82 ++++ executor/lib/runtimes/runtime_core.ts | 29 +- executor/lib/runtimes/transpile.ts | 38 ++ .../packages/sandbox-host/.dev.vars.example | 2 + executor/packages/sandbox-host/package.json | 16 + executor/packages/sandbox-host/src/index.ts | 401 ++++++++++++++++++ executor/packages/sandbox-host/tsconfig.json | 17 + executor/packages/sandbox-host/wrangler.jsonc | 20 + 13 files changed, 1123 insertions(+), 51 deletions(-) create mode 100644 executor/lib/runtimes/cloudflare-worker-loader-runtime.test.ts create mode 100644 executor/lib/runtimes/cloudflare_worker_loader_runtime.ts create mode 100644 executor/lib/runtimes/runtime_catalog.ts create mode 100644 executor/lib/runtimes/transpile.ts create mode 100644 executor/packages/sandbox-host/.dev.vars.example create mode 100644 executor/packages/sandbox-host/package.json create mode 100644 executor/packages/sandbox-host/src/index.ts create mode 100644 executor/packages/sandbox-host/tsconfig.json create mode 100644 executor/packages/sandbox-host/wrangler.jsonc diff --git a/.gitignore b/.gitignore index 9f935b5be..f070affca 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,15 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json convex_local_backend.sqlite3 convex_local_storage/ +# Local OpenAPI sources service state +sources/data/*.sqlite +sources/data/*.sqlite-shm +sources/data/*.sqlite-wal + +# Cloudflare Workers local state +.wrangler/ +.dev.vars + # Generated push env file executor/.env.executor-push diff --git a/executor/convex/executor.ts b/executor/convex/executor.ts index c0e4dafd5..8c2bdf211 100644 --- a/executor/convex/executor.ts +++ b/executor/convex/executor.ts @@ -5,6 +5,7 @@ import type { MutationCtx } from "./_generated/server"; import { internalMutation } from "./_generated/server"; import { workspaceMutation } from "../lib/functionBuilders"; import { actorIdForAccount } from "../lib/identity"; +import { isKnownRuntimeId } from "../lib/runtimes/runtime_catalog"; import type { ApprovalRecord, TaskRecord } from "../lib/types"; const DEFAULT_TIMEOUT_MS = 300_000; @@ -38,7 +39,7 @@ async function createTaskRecord( } const runtimeId = args.runtimeId ?? "local-bun"; - if (runtimeId !== "local-bun") { + if (!isKnownRuntimeId(runtimeId)) { throw new Error(`Unsupported runtime: ${runtimeId}`); } diff --git a/executor/convex/executorNode.ts b/executor/convex/executorNode.ts index e0df904ae..52f29760f 100644 --- a/executor/convex/executorNode.ts +++ b/executor/convex/executorNode.ts @@ -9,6 +9,13 @@ import { InProcessExecutionAdapter } from "../lib/adapters/in_process_execution_ import { resolveCredentialPayload } from "../lib/credential_providers"; import { APPROVAL_DENIED_PREFIX } from "../lib/execution_constants"; import { actorIdForAccount } from "../lib/identity"; +import { runCodeWithCloudflareWorkerLoader } from "../lib/runtimes/cloudflare_worker_loader_runtime"; +import { + CLOUDFLARE_WORKER_LOADER_RUNTIME_ID, + isCloudflareWorkerLoaderConfigured, + isKnownRuntimeId, + LOCAL_BUN_RUNTIME_ID, +} from "../lib/runtimes/runtime_catalog"; import { runCodeWithAdapter } from "../lib/runtimes/runtime_core"; import { createDiscoverTool } from "../lib/tool_discovery"; import { @@ -1089,7 +1096,7 @@ export const runTask = internalAction({ return null; } - if (task.runtimeId !== "local-bun") { + if (!isKnownRuntimeId(task.runtimeId)) { const failed = await ctx.runMutation(internal.database.markTaskFinished, { taskId: args.taskId, status: "failed", @@ -1108,6 +1115,25 @@ export const runTask = internalAction({ return null; } + if (task.runtimeId === CLOUDFLARE_WORKER_LOADER_RUNTIME_ID && !isCloudflareWorkerLoaderConfigured()) { + const failed = await ctx.runMutation(internal.database.markTaskFinished, { + taskId: args.taskId, + status: "failed", + stdout: "", + stderr: "", + error: `Runtime is not configured: ${task.runtimeId}`, + }); + + if (failed) { + await publish(ctx, args.taskId, "task", "task.failed", { + taskId: args.taskId, + status: failed.status, + error: failed.error, + }); + } + return null; + } + try { const running = (await ctx.runMutation(internal.database.markTaskRunning, { taskId: args.taskId, @@ -1122,27 +1148,44 @@ export const runTask = internalAction({ startedAt: running.startedAt, }); - const adapter = new InProcessExecutionAdapter({ - runId: args.taskId, - invokeTool: async (call) => await invokeTool(ctx, running, call), - emitOutput: async (event) => { - await ctx.runMutation(internal.executor.appendRuntimeOutput, { - runId: event.runId, - stream: event.stream, - line: event.line, - timestamp: event.timestamp, - }); - }, - }); - - const runtimeResult = await runCodeWithAdapter( - { - taskId: args.taskId, - code: running.code, - timeoutMs: running.timeoutMs, - }, - adapter, - ); + const runtimeResult = + running.runtimeId === LOCAL_BUN_RUNTIME_ID + ? await (async () => { + const adapter = new InProcessExecutionAdapter({ + runId: args.taskId, + invokeTool: async (call) => await invokeTool(ctx, running, call), + emitOutput: async (event) => { + await ctx.runMutation(internal.executor.appendRuntimeOutput, { + runId: event.runId, + stream: event.stream, + line: event.line, + timestamp: event.timestamp, + }); + }, + }); + + return await runCodeWithAdapter( + { + taskId: args.taskId, + code: running.code, + timeoutMs: running.timeoutMs, + }, + adapter, + ); + })() + : running.runtimeId === CLOUDFLARE_WORKER_LOADER_RUNTIME_ID + ? await runCodeWithCloudflareWorkerLoader({ + taskId: args.taskId, + code: running.code, + timeoutMs: running.timeoutMs, + }) + : { + status: "failed" as const, + stdout: "", + stderr: "", + error: `Runtime not found: ${running.runtimeId}`, + durationMs: 0, + }; const finished = await ctx.runMutation(internal.database.markTaskFinished, { taskId: args.taskId, diff --git a/executor/lib/runtimes/cloudflare-worker-loader-runtime.test.ts b/executor/lib/runtimes/cloudflare-worker-loader-runtime.test.ts new file mode 100644 index 000000000..c9d6ac54c --- /dev/null +++ b/executor/lib/runtimes/cloudflare-worker-loader-runtime.test.ts @@ -0,0 +1,329 @@ +import { expect, test, describe, beforeAll, afterAll } from "bun:test"; +import { runCodeWithCloudflareWorkerLoader } from "./cloudflare_worker_loader_runtime"; + +// ── Fake host worker ───────────────────────────────────────────────────────── +// +// This test spins up a local HTTP server that mimics the CF host worker's +// /v1/runs endpoint. It validates the request shape and returns a result. +// It also acts as the callback server for /internal/runs/:id/tool-call. + +let fakeHostServer: ReturnType; +let fakeCallbackServer: ReturnType; + +const AUTH_TOKEN = "test-sandbox-token"; +const CALLBACK_TOKEN = "test-callback-token"; + +const toolResponses = new Map(); +const capturedOutputs: Array<{ stream: string; line: string }> = []; + +beforeAll(() => { + // Callback server — mimics the Convex /internal/runs/:id/tool-call endpoint + fakeCallbackServer = Bun.serve({ + port: 0, + fetch: async (req) => { + const url = new URL(req.url); + + // Verify auth + const auth = req.headers.get("authorization"); + if (auth !== `Bearer ${CALLBACK_TOKEN}`) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Tool call + if (url.pathname.endsWith("/tool-call")) { + const body = (await req.json()) as { toolPath: string; input: unknown }; + const response = toolResponses.get(body.toolPath); + if (response !== undefined) { + return Response.json({ ok: true, value: response }); + } + return Response.json({ ok: false, error: `Unknown tool: ${body.toolPath}` }); + } + + // Output + if (url.pathname.endsWith("/output")) { + const body = (await req.json()) as { stream: string; line: string }; + capturedOutputs.push({ stream: body.stream, line: body.line }); + return Response.json({ ok: true }); + } + + return Response.json({ error: "Not found" }, { status: 404 }); + }, + }); + + // Host server — mimics the CF sandbox host worker's /v1/runs endpoint + fakeHostServer = Bun.serve({ + port: 0, + fetch: async (req) => { + const url = new URL(req.url); + + if (url.pathname === "/health") { + return Response.json({ ok: true }); + } + + if (url.pathname !== "/v1/runs" || req.method !== "POST") { + return Response.json({ error: "Not found" }, { status: 404 }); + } + + const auth = req.headers.get("authorization"); + if (auth !== `Bearer ${AUTH_TOKEN}`) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = (await req.json()) as { + taskId: string; + code: string; + timeoutMs: number; + callback: { baseUrl: string; authToken: string }; + }; + + // Simulate the sandbox running the code in a very simplified way. + // In production, the CF Worker Loader would spawn an isolate. + // Here we just use eval-like logic to test the protocol. + const stdout: string[] = []; + const stderr: string[] = []; + + try { + // Simulate console and tools + const consoleProxy = { + log: (...args: unknown[]) => stdout.push(args.map(String).join(" ")), + info: (...args: unknown[]) => stdout.push(args.map(String).join(" ")), + warn: (...args: unknown[]) => stderr.push(args.map(String).join(" ")), + error: (...args: unknown[]) => stderr.push(args.map(String).join(" ")), + }; + + // For tool calls, call back to the callback server + const callTool = async (toolPath: string, input: unknown) => { + const callbackUrl = `${body.callback.baseUrl}/internal/runs/${body.taskId}/tool-call`; + const resp = await fetch(callbackUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${body.callback.authToken}`, + }, + body: JSON.stringify({ callId: `call_${crypto.randomUUID()}`, toolPath, input }), + }); + const result = (await resp.json()) as { ok: boolean; value?: unknown; error?: string }; + if (!result.ok) throw new Error(result.error ?? "Tool call failed"); + return result.value; + }; + + // Emit output back to callback server + const emitLine = async (stream: string, line: string) => { + const outputUrl = `${body.callback.baseUrl}/internal/runs/${body.taskId}/output`; + await fetch(outputUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${body.callback.authToken}`, + }, + body: JSON.stringify({ stream, line, timestamp: Date.now() }), + }).catch(() => {}); + }; + + // Create a minimal tools proxy + const createProxy = (path: string[] = []): unknown => { + const callable = () => {}; + return new Proxy(callable, { + get(_target, prop) { + if (prop === "then") return undefined; + if (typeof prop !== "string") return undefined; + return createProxy([...path, prop]); + }, + async apply(_target, _thisArg, args) { + return callTool(path.join("."), args[0]); + }, + }); + }; + + const tools = createProxy(); + const fn = new Function( + "tools", "console", "setTimeout", "clearTimeout", + `"use strict"; return (async () => {\n${body.code}\n})();`, + ); + const value = await fn(tools, consoleProxy, setTimeout, clearTimeout); + + if (value !== undefined) { + stdout.push(`result: ${JSON.stringify(value)}`); + } + + // Stream output to callback + for (const line of stdout) await emitLine("stdout", line); + for (const line of stderr) await emitLine("stderr", line); + + return Response.json({ + status: "completed", + stdout: stdout.join("\n"), + stderr: stderr.join("\n"), + exitCode: 0, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return Response.json({ + status: "failed", + stdout: stdout.join("\n"), + stderr: stderr.join("\n"), + error: message, + }); + } + }, + }); + + // Set environment variables for the runtime + process.env.CLOUDFLARE_SANDBOX_RUN_URL = `http://127.0.0.1:${fakeHostServer.port}/v1/runs`; + process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN = AUTH_TOKEN; + process.env.CONVEX_SITE_URL = `http://127.0.0.1:${fakeCallbackServer.port}`; + process.env.EXECUTOR_INTERNAL_TOKEN = CALLBACK_TOKEN; + process.env.CLOUDFLARE_SANDBOX_REQUEST_TIMEOUT_MS = "10000"; +}); + +afterAll(() => { + fakeHostServer?.stop(true); + fakeCallbackServer?.stop(true); + delete process.env.CLOUDFLARE_SANDBOX_RUN_URL; + delete process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN; + delete process.env.CONVEX_SITE_URL; + delete process.env.EXECUTOR_INTERNAL_TOKEN; + delete process.env.CLOUDFLARE_SANDBOX_REQUEST_TIMEOUT_MS; +}); + +describe("cloudflare worker loader runtime", () => { + test("executes simple code and returns stdout", async () => { + const result = await runCodeWithCloudflareWorkerLoader({ + taskId: `task_${crypto.randomUUID()}`, + code: `console.log("hello from cf sandbox");`, + timeoutMs: 5_000, + }); + + expect(result.status).toBe("completed"); + expect(result.stdout).toContain("hello from cf sandbox"); + expect(result.durationMs).toBeGreaterThan(0); + }); + + test("returns a value", async () => { + const result = await runCodeWithCloudflareWorkerLoader({ + taskId: `task_${crypto.randomUUID()}`, + code: `return 42;`, + timeoutMs: 5_000, + }); + + expect(result.status).toBe("completed"); + expect(result.stdout).toContain("42"); + }); + + test("captures errors as failed status", async () => { + const result = await runCodeWithCloudflareWorkerLoader({ + taskId: `task_${crypto.randomUUID()}`, + code: `throw new Error("boom");`, + timeoutMs: 5_000, + }); + + expect(result.status).toBe("failed"); + expect(result.error).toContain("boom"); + }); + + test("calls tools via callback server", async () => { + toolResponses.set("math.add", { sum: 7 }); + + const result = await runCodeWithCloudflareWorkerLoader({ + taskId: `task_${crypto.randomUUID()}`, + code: ` + const out = await tools.math.add({ a: 3, b: 4 }); + console.log("sum:", out.sum); + `, + timeoutMs: 5_000, + }); + + expect(result.status).toBe("completed"); + expect(result.stdout).toContain("sum: 7"); + + toolResponses.delete("math.add"); + }); + + test("streams output to callback server", async () => { + capturedOutputs.length = 0; + + const result = await runCodeWithCloudflareWorkerLoader({ + taskId: `task_${crypto.randomUUID()}`, + code: `console.log("streamed line");`, + timeoutMs: 5_000, + }); + + expect(result.status).toBe("completed"); + expect(capturedOutputs.some((o) => o.line === "streamed line" && o.stream === "stdout")).toBe( + true, + ); + }); + + test("transpiles TypeScript code before sending to sandbox", async () => { + const result = await runCodeWithCloudflareWorkerLoader({ + taskId: `task_${crypto.randomUUID()}`, + code: ` + interface User { + name: string; + age: number; + } + const user: User = { name: "Alice", age: 30 }; + const greet = (u: User): string => \`Hello \${u.name}, age \${u.age}\`; + console.log(greet(user)); + `, + timeoutMs: 5_000, + }); + + expect(result.status).toBe("completed"); + expect(result.stdout).toContain("Hello Alice, age 30"); + }); + + test("reports TypeScript syntax errors clearly", async () => { + const result = await runCodeWithCloudflareWorkerLoader({ + taskId: `task_${crypto.randomUUID()}`, + // Unterminated type syntax that TS transpiler will reject + code: `const x: = 5;`, + timeoutMs: 5_000, + }); + + expect(result.status).toBe("failed"); + expect(result.error).toContain("TypeScript transpile error"); + }); + + test("handles unknown tool gracefully", async () => { + const result = await runCodeWithCloudflareWorkerLoader({ + taskId: `task_${crypto.randomUUID()}`, + code: `await tools.nonexistent.thing({});`, + timeoutMs: 5_000, + }); + + expect(result.status).toBe("failed"); + expect(result.error).toContain("nonexistent.thing"); + }); +}); + +describe("runtime catalog", () => { + test("isKnownRuntimeId recognizes both runtimes", async () => { + const { + isKnownRuntimeId, + LOCAL_BUN_RUNTIME_ID, + CLOUDFLARE_WORKER_LOADER_RUNTIME_ID, + } = await import("./runtime_catalog"); + + expect(isKnownRuntimeId(LOCAL_BUN_RUNTIME_ID)).toBe(true); + expect(isKnownRuntimeId(CLOUDFLARE_WORKER_LOADER_RUNTIME_ID)).toBe(true); + expect(isKnownRuntimeId("unknown-runtime")).toBe(false); + }); + + test("isCloudflareWorkerLoaderConfigured checks env vars", async () => { + const { isCloudflareWorkerLoaderConfigured } = await import("./runtime_catalog"); + // Env vars are set in beforeAll + expect(isCloudflareWorkerLoaderConfigured()).toBe(true); + }); + + test("getCloudflareWorkerLoaderConfig reads env vars", async () => { + const { getCloudflareWorkerLoaderConfig } = await import("./runtime_catalog"); + const config = getCloudflareWorkerLoaderConfig(); + + expect(config.runUrl).toContain("/v1/runs"); + expect(config.authToken).toBe(AUTH_TOKEN); + expect(config.callbackBaseUrl).toContain(String(fakeCallbackServer.port)); + expect(config.callbackAuthToken).toBe(CALLBACK_TOKEN); + expect(config.requestTimeoutMs).toBe(10_000); + }); +}); diff --git a/executor/lib/runtimes/cloudflare_worker_loader_runtime.ts b/executor/lib/runtimes/cloudflare_worker_loader_runtime.ts new file mode 100644 index 000000000..5c70954f6 --- /dev/null +++ b/executor/lib/runtimes/cloudflare_worker_loader_runtime.ts @@ -0,0 +1,141 @@ +"use node"; + +import type { SandboxExecutionRequest, SandboxExecutionResult } from "../types"; +import { getCloudflareWorkerLoaderConfig } from "./runtime_catalog"; +import { transpileForRuntime } from "./transpile"; + +/** + * Run agent-generated code via a Cloudflare Worker that uses the Dynamic + * Worker Loader API to spawn a sandboxed isolate. + * + * ## Architecture + * + * 1. This function (running inside a Convex action) POSTs the code + config to + * a **host Worker** deployed on Cloudflare. + * + * 2. The host Worker uses `env.LOADER.get(id, callback)` to create a dynamic + * isolate containing the user code. + * + * 3. The dynamic isolate's `tools` proxy calls are intercepted by a + * `ToolBridge` entrypoint in the host Worker (passed via `env` bindings), + * which in turn calls back to the Convex `/internal/runs/{runId}/tool-call` + * HTTP endpoint to resolve the tool. + * + * 4. Console output from the isolate is similarly relayed back to + * `/internal/runs/{runId}/output`. + * + * 5. When execution completes, the host Worker returns the result as JSON and + * this function maps it to a `SandboxExecutionResult`. + * + * ## Callback authentication + * + * The host Worker authenticates its callbacks using the same + * `EXECUTOR_INTERNAL_TOKEN` bearer token that the Convex HTTP API expects. + */ +export async function runCodeWithCloudflareWorkerLoader( + request: SandboxExecutionRequest, +): Promise { + const config = getCloudflareWorkerLoaderConfig(); + const startedAt = Date.now(); + + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + config.requestTimeoutMs, + ); + + try { + // Transpile TS → JS on the Convex side before sending to the CF isolate. + // The dynamic isolate runs the code as plain JS (harness.js), so any + // TypeScript syntax must be stripped first. + const code = await transpileForRuntime(request.code); + + const response = await fetch(config.runUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${config.authToken}`, + }, + body: JSON.stringify({ + taskId: request.taskId, + code, + timeoutMs: request.timeoutMs, + // The host Worker needs these to call back to Convex for tool + // invocations and output streaming. + callback: { + baseUrl: config.callbackBaseUrl, + authToken: config.callbackAuthToken, + }, + }), + signal: controller.signal, + }); + + if (!response.ok) { + const text = await response.text().catch(() => response.statusText); + return { + status: "failed", + stdout: "", + stderr: text, + error: `Cloudflare sandbox returned ${response.status}: ${text}`, + durationMs: Date.now() - startedAt, + }; + } + + const result = (await response.json()) as { + status?: string; + stdout?: string; + stderr?: string; + error?: string; + exitCode?: number; + }; + + const status = mapStatus(result.status); + + return { + status, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + exitCode: result.exitCode, + error: result.error, + durationMs: Date.now() - startedAt, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const isAbort = error instanceof DOMException && error.name === "AbortError"; + + if (isAbort) { + return { + status: "timed_out", + stdout: "", + stderr: "", + error: `Cloudflare sandbox request timed out after ${config.requestTimeoutMs}ms`, + durationMs: Date.now() - startedAt, + }; + } + + return { + status: "failed", + stdout: "", + stderr: "", + error: `Cloudflare sandbox request failed: ${message}`, + durationMs: Date.now() - startedAt, + }; + } finally { + clearTimeout(timeout); + } +} + +function mapStatus( + raw: string | undefined, +): SandboxExecutionResult["status"] { + switch (raw) { + case "completed": + return "completed"; + case "timed_out": + return "timed_out"; + case "denied": + return "denied"; + default: + return "failed"; + } +} diff --git a/executor/lib/runtimes/runtime_catalog.ts b/executor/lib/runtimes/runtime_catalog.ts new file mode 100644 index 000000000..fbe64d6cf --- /dev/null +++ b/executor/lib/runtimes/runtime_catalog.ts @@ -0,0 +1,82 @@ +// ── Runtime ID constants ────────────────────────────────────────────────────── + +export const LOCAL_BUN_RUNTIME_ID = "local-bun"; +export const CLOUDFLARE_WORKER_LOADER_RUNTIME_ID = "cloudflare-worker-loader"; + +const KNOWN_RUNTIME_IDS = new Set([ + LOCAL_BUN_RUNTIME_ID, + CLOUDFLARE_WORKER_LOADER_RUNTIME_ID, +]); + +export function isKnownRuntimeId(runtimeId: string): boolean { + return KNOWN_RUNTIME_IDS.has(runtimeId); +} + +// ── Cloudflare Worker Loader config ────────────────────────────────────────── + +export interface CloudflareWorkerLoaderConfig { + /** The URL of the CF host worker's /v1/runs endpoint. */ + runUrl: string; + /** Shared-secret bearer token for authenticating with the host worker. */ + authToken: string; + /** HTTP request timeout in ms (how long we wait for the host worker to respond). */ + requestTimeoutMs: number; + /** The Convex site URL that the CF host worker calls back to for tool invocations. */ + callbackBaseUrl: string; + /** Internal auth token that the CF host worker uses when calling back. */ + callbackAuthToken: string; +} + +/** + * Returns true if all required env vars for the Cloudflare Worker Loader + * runtime are present. + */ +export function isCloudflareWorkerLoaderConfigured(): boolean { + return Boolean( + process.env.CLOUDFLARE_SANDBOX_RUN_URL + && process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN, + ); +} + +/** + * Reads Cloudflare Worker Loader config from environment variables. + * Throws if required vars are missing. + */ +export function getCloudflareWorkerLoaderConfig(): CloudflareWorkerLoaderConfig { + const runUrl = process.env.CLOUDFLARE_SANDBOX_RUN_URL; + const authToken = process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN; + + if (!runUrl || !authToken) { + throw new Error( + "Cloudflare Worker Loader runtime requires CLOUDFLARE_SANDBOX_RUN_URL and CLOUDFLARE_SANDBOX_AUTH_TOKEN", + ); + } + + // The callback URL is the Convex site URL where the internal runs API lives. + // The CF host worker will POST to {callbackBaseUrl}/internal/runs/{runId}/tool-call + const callbackBaseUrl = process.env.CONVEX_SITE_URL ?? process.env.CONVEX_URL; + if (!callbackBaseUrl) { + throw new Error( + "Cloudflare Worker Loader runtime requires CONVEX_SITE_URL or CONVEX_URL for tool-call callbacks", + ); + } + + const callbackAuthToken = process.env.EXECUTOR_INTERNAL_TOKEN; + if (!callbackAuthToken) { + throw new Error( + "Cloudflare Worker Loader runtime requires EXECUTOR_INTERNAL_TOKEN for authenticated tool-call callbacks", + ); + } + + const requestTimeoutMs = Number( + process.env.CLOUDFLARE_SANDBOX_REQUEST_TIMEOUT_MS ?? "90000", + ); + + return { + runUrl, + authToken, + requestTimeoutMs, + callbackBaseUrl, + callbackAuthToken, + }; +} diff --git a/executor/lib/runtimes/runtime_core.ts b/executor/lib/runtimes/runtime_core.ts index ed5b13178..ce478820a 100644 --- a/executor/lib/runtimes/runtime_core.ts +++ b/executor/lib/runtimes/runtime_core.ts @@ -11,34 +11,7 @@ import type { SandboxExecutionRequest, SandboxExecutionResult, } from "../types"; - -async function transpileForRuntime(code: string): Promise { - let ts: typeof import("typescript"); - try { - ts = require("typescript"); - } catch { - return code; - } - - const target = ts.ScriptTarget?.ES2022 ?? ts.ScriptTarget?.ESNext; - const moduleKind = ts.ModuleKind?.ESNext; - - const result = ts.transpileModule(code, { - compilerOptions: { - ...(target !== undefined ? { target } : {}), - ...(moduleKind !== undefined ? { module: moduleKind } : {}), - }, - reportDiagnostics: true, - }); - - if (result.diagnostics && result.diagnostics.length > 0) { - const first = result.diagnostics[0]; - const message = ts.flattenDiagnosticMessageText(first.messageText, "\n"); - throw new Error(`TypeScript transpile error: ${message}`); - } - - return result.outputText || code; -} +import { transpileForRuntime } from "./transpile"; function formatArgs(args: unknown[]): string { return args diff --git a/executor/lib/runtimes/transpile.ts b/executor/lib/runtimes/transpile.ts new file mode 100644 index 000000000..0c8432393 --- /dev/null +++ b/executor/lib/runtimes/transpile.ts @@ -0,0 +1,38 @@ +"use node"; + +/** + * Transpile TypeScript code to JavaScript using the `typescript` module. + * + * If the `typescript` module is not available, the code is returned as-is + * (graceful fallback for environments where TS isn't installed). + * + * Targets ES2022/ESNext so the output can run in modern runtimes (node:vm, + * Cloudflare Workers isolates, etc.) without further downlevelling. + */ +export async function transpileForRuntime(code: string): Promise { + let ts: typeof import("typescript"); + try { + ts = require("typescript"); + } catch { + return code; + } + + const target = ts.ScriptTarget?.ES2022 ?? ts.ScriptTarget?.ESNext; + const moduleKind = ts.ModuleKind?.ESNext; + + const result = ts.transpileModule(code, { + compilerOptions: { + ...(target !== undefined ? { target } : {}), + ...(moduleKind !== undefined ? { module: moduleKind } : {}), + }, + reportDiagnostics: true, + }); + + if (result.diagnostics && result.diagnostics.length > 0) { + const first = result.diagnostics[0]; + const message = ts.flattenDiagnosticMessageText(first.messageText, "\n"); + throw new Error(`TypeScript transpile error: ${message}`); + } + + return result.outputText || code; +} diff --git a/executor/packages/sandbox-host/.dev.vars.example b/executor/packages/sandbox-host/.dev.vars.example new file mode 100644 index 000000000..ef0b00c33 --- /dev/null +++ b/executor/packages/sandbox-host/.dev.vars.example @@ -0,0 +1,2 @@ +# Shared-secret bearer token — must match CLOUDFLARE_SANDBOX_AUTH_TOKEN on the executor side. +AUTH_TOKEN=dev-sandbox-token diff --git a/executor/packages/sandbox-host/package.json b/executor/packages/sandbox-host/package.json new file mode 100644 index 000000000..f16908e2f --- /dev/null +++ b/executor/packages/sandbox-host/package.json @@ -0,0 +1,16 @@ +{ + "name": "@executor/sandbox-host", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev --port 8787", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250214.0", + "typescript": "^5.9.3", + "wrangler": "^4.0.0" + } +} diff --git a/executor/packages/sandbox-host/src/index.ts b/executor/packages/sandbox-host/src/index.ts new file mode 100644 index 000000000..067bc9155 --- /dev/null +++ b/executor/packages/sandbox-host/src/index.ts @@ -0,0 +1,401 @@ +/** + * Executor Sandbox Host Worker + * + * This Cloudflare Worker uses the Dynamic Worker Loader API to run + * agent-generated code in sandboxed isolates. It exposes a single HTTP + * endpoint (`POST /v1/runs`) that the executor's Convex action calls. + * + * ## How it works + * + * 1. Receives a run request with `{ taskId, code, timeoutMs, callback }`. + * + * 2. Uses `env.LOADER.get(id, () => WorkerCode)` to spawn a dynamic isolate + * containing the user's code. + * + * 3. The isolate's network access is fully blocked (`globalOutbound: null`). + * Instead, tool calls are routed through a `ToolBridge` entrypoint class + * (passed as a loopback service binding via `ctx.exports`) which calls back + * to the Convex HTTP API to resolve them. + * + * 4. Console output is buffered in the harness and returned in the response. + * Output lines are also streamed back to Convex in real-time via the + * ToolBridge binding. + * + * 5. The result (status, stdout, stderr, error) is returned as JSON. + * + * ## Code isolation + * + * User code is placed in a **separate JS module** (`user-code.js`) that + * exports a single `run(tools, console)` async function. The harness module + * (`harness.js`) imports and calls this function, passing controlled `tools` + * and `console` proxies. Because the user code is in a different module, it + * cannot access the harness's `fetch` handler scope, `req`, `env`, `ctx`, + * or `Response` — preventing IIFE escape attacks and response forgery. + */ + +import { WorkerEntrypoint } from "cloudflare:workers"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +interface Env { + LOADER: WorkerLoader; + AUTH_TOKEN: string; +} + +/** Dynamic Worker Loader binding — provided by the `worker_loaders` config. */ +interface WorkerLoader { + get(id: string, getCode: () => Promise): WorkerStub; +} + +interface WorkerCode { + compatibilityDate: string; + compatibilityFlags?: string[]; + mainModule: string; + modules: Record; + env?: Record; + globalOutbound?: unknown | null; +} + +interface WorkerStub { + getEntrypoint(name?: string, options?: { props?: unknown }): EntrypointStub; +} + +interface EntrypointStub { + fetch(input: string | Request, init?: RequestInit): Promise; +} + +interface RunRequest { + taskId: string; + code: string; + timeoutMs: number; + callback: { + baseUrl: string; + authToken: string; + }; +} + +interface RunResult { + status: "completed" | "failed" | "timed_out" | "denied"; + stdout: string; + stderr: string; + error?: string; + exitCode?: number; +} + +interface ToolCallResult { + ok: boolean; + value?: unknown; + error?: string; + denied?: boolean; +} + +interface BridgeProps { + callbackBaseUrl: string; + callbackAuthToken: string; + taskId: string; +} + +// ── Auth ────────────────────────────────────────────────────────────────────── + +/** Constant-time string comparison to prevent timing side-channels. */ +function timingSafeEqual(a: string, b: string): boolean { + const encoder = new TextEncoder(); + const bufA = encoder.encode(a); + const bufB = encoder.encode(b); + + if (bufA.length !== bufB.length) { + // Compare against self to keep timing consistent, then return false. + let result = 0; + for (let i = 0; i < bufA.length; i++) { + result |= (bufA[i] ?? 0) ^ (bufA[i] ?? 0); + } + return false; + } + + let result = 0; + for (let i = 0; i < bufA.length; i++) { + result |= (bufA[i] ?? 0) ^ (bufB[i] ?? 0); + } + return result === 0; +} + +// ── Tool Bridge Entrypoint ─────────────────────────────────────────────────── +// +// This class is exposed as a named entrypoint on the host Worker. A loopback +// service binding (via `ctx.exports.ToolBridge({props: ...})`) is passed into +// the dynamic isolate's `env`. When the isolate calls +// `env.TOOL_BRIDGE.callTool(...)`, the RPC call lands here. +// +// `this.ctx.props` carries the callback URL and auth token for the specific task. + +export class ToolBridge extends WorkerEntrypoint { + private get props(): BridgeProps { + return (this.ctx as unknown as { props: BridgeProps }).props; + } + + /** Forward a tool call to the Convex internal HTTP API. */ + async callTool(toolPath: string, input: unknown): Promise { + const { callbackBaseUrl, callbackAuthToken, taskId } = this.props; + const url = `${callbackBaseUrl}/internal/runs/${taskId}/tool-call`; + const callId = `call_${crypto.randomUUID()}`; + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${callbackAuthToken}`, + }, + body: JSON.stringify({ callId, toolPath, input }), + }); + + if (!response.ok) { + const text = await response.text().catch(() => response.statusText); + return { ok: false, error: `Tool callback failed (${response.status}): ${text}` }; + } + + return (await response.json()) as ToolCallResult; + } + + /** Stream a console output line back to Convex (best-effort). */ + async emitOutput(stream: "stdout" | "stderr", line: string): Promise { + const { callbackBaseUrl, callbackAuthToken, taskId } = this.props; + const url = `${callbackBaseUrl}/internal/runs/${taskId}/output`; + + try { + await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${callbackAuthToken}`, + }, + body: JSON.stringify({ stream, line, timestamp: Date.now() }), + }); + } catch { + // Swallow — output streaming is best-effort. + } + } +} + +// ── Sandbox Harness ────────────────────────────────────────────────────────── +// +// The harness is a static ES module loaded as the main module of the dynamic +// isolate. User code lives in a **separate** module (`user-code.js`) and is +// imported by the harness. This prevents user code from accessing or +// manipulating the harness's fetch handler, `req`, `env`, `ctx`, or `Response`. + +const GLOBALS_MODULE = ` +// Captured before user code module is evaluated (this module is imported first). +export const ResponseJson = Response.json.bind(Response); +`; + +const HARNESS_CODE = ` +import { ResponseJson as _ResponseJson } from "./globals.js"; +import { run } from "./user-code.js"; + +const APPROVAL_DENIED_PREFIX = "APPROVAL_DENIED:"; + +function formatArgs(args) { + return args.map((v) => { + if (typeof v === "string") return v; + try { return JSON.stringify(v); } + catch { return String(v); } + }).join(" "); +} + +function createToolsProxy(bridge, path = []) { + const callable = () => {}; + return new Proxy(callable, { + get(_target, prop) { + if (prop === "then") return undefined; + if (typeof prop !== "string") return undefined; + return createToolsProxy(bridge, [...path, prop]); + }, + async apply(_target, _thisArg, args) { + const toolPath = path.join("."); + if (!toolPath) throw new Error("Tool path missing"); + const input = args.length > 0 ? args[0] : {}; + const result = await bridge.callTool(toolPath, input); + if (result.ok) return result.value; + if (result.denied) throw new Error(APPROVAL_DENIED_PREFIX + result.error); + throw new Error(result.error); + }, + }); +} + +export default { + async fetch(req, env, ctx) { + const stdoutLines = []; + const stderrLines = []; + + const appendStdout = (line) => { + stdoutLines.push(line); + ctx.waitUntil(env.TOOL_BRIDGE.emitOutput("stdout", line)); + }; + const appendStderr = (line) => { + stderrLines.push(line); + ctx.waitUntil(env.TOOL_BRIDGE.emitOutput("stderr", line)); + }; + + const tools = createToolsProxy(env.TOOL_BRIDGE); + const console = { + log: (...args) => appendStdout(formatArgs(args)), + info: (...args) => appendStdout(formatArgs(args)), + warn: (...args) => appendStderr(formatArgs(args)), + error: (...args) => appendStderr(formatArgs(args)), + }; + + try { + const value = await run(tools, console); + + if (value !== undefined) { + appendStdout("result: " + formatArgs([value])); + } + + return _ResponseJson({ + status: "completed", + stdout: stdoutLines.join("\\n"), + stderr: stderrLines.join("\\n"), + exitCode: 0, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.startsWith(APPROVAL_DENIED_PREFIX)) { + const denied = message.replace(APPROVAL_DENIED_PREFIX, "").trim(); + appendStderr(denied); + return _ResponseJson({ + status: "denied", + stdout: stdoutLines.join("\\n"), + stderr: stderrLines.join("\\n"), + error: denied, + }); + } + appendStderr(message); + return _ResponseJson({ + status: "failed", + stdout: stdoutLines.join("\\n"), + stderr: stderrLines.join("\\n"), + error: message, + }); + } + }, +}; +`; + +/** + * Build the user code module. The code is wrapped in an exported async + * function `run(tools, console)` so the harness can call it with controlled + * scope bindings. The user code runs in a separate module from the harness + * and cannot access `req`, `env`, `ctx`, or `Response`. + */ +function buildUserModule(userCode: string): string { + return `export async function run(tools, console) {\n"use strict";\n${userCode}\n}\n`; +} + +// ── Main Handler ───────────────────────────────────────────────────────────── + +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + const url = new URL(request.url); + + if (request.method === "GET" && url.pathname === "/health") { + return Response.json({ ok: true }); + } + + if (request.method !== "POST" || url.pathname !== "/v1/runs") { + return Response.json({ error: "Not found" }, { status: 404 }); + } + + // ── Auth ────────────────────────────────────────────────────────────── + const authHeader = request.headers.get("authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + const token = authHeader.slice("Bearer ".length); + if (!timingSafeEqual(token, env.AUTH_TOKEN)) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + // ── Parse body ──────────────────────────────────────────────────────── + let body: RunRequest; + try { + body = (await request.json()) as RunRequest; + } catch { + return Response.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + if (!body.taskId || !body.code || !body.callback?.baseUrl || !body.callback?.authToken) { + return Response.json( + { error: "Missing required fields: taskId, code, callback.baseUrl, callback.authToken" }, + { status: 400 }, + ); + } + + const timeoutMs = body.timeoutMs ?? 300_000; + const isolateId = body.taskId; + + try { + const ctxExports = (ctx as unknown as { + exports: Record unknown>; + }).exports; + + const toolBridgeBinding = ctxExports.ToolBridge({ + props: { + callbackBaseUrl: body.callback.baseUrl, + callbackAuthToken: body.callback.authToken, + taskId: body.taskId, + }, + }); + + const worker = env.LOADER.get(isolateId, async () => ({ + compatibilityDate: "2025-06-01", + mainModule: "harness.js", + modules: { + "harness.js": HARNESS_CODE, + // Globals captured before user code is evaluated. + "globals.js": GLOBALS_MODULE, + // User code is in a separate module — it exports run(tools, console) + // and cannot access the harness's fetch handler scope. + "user-code.js": buildUserModule(body.code), + }, + env: { + TOOL_BRIDGE: toolBridgeBinding, + }, + globalOutbound: null, + })); + + const entrypoint = worker.getEntrypoint(); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await entrypoint.fetch("http://sandbox.internal/run", { + method: "POST", + signal: controller.signal, + }); + const result = (await response.json()) as RunResult; + return Response.json(result); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + return Response.json({ + status: "timed_out", + stdout: "", + stderr: "", + error: `Execution timed out after ${timeoutMs}ms`, + } satisfies RunResult); + } + throw error; + } finally { + clearTimeout(timer); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return Response.json({ + status: "failed", + stdout: "", + stderr: "", + error: `Sandbox host error: ${message}`, + } satisfies RunResult); + } + }, +}; diff --git a/executor/packages/sandbox-host/tsconfig.json b/executor/packages/sandbox-host/tsconfig.json new file mode 100644 index 000000000..f083d583d --- /dev/null +++ b/executor/packages/sandbox-host/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx" + }, + "include": ["src/**/*.ts"] +} diff --git a/executor/packages/sandbox-host/wrangler.jsonc b/executor/packages/sandbox-host/wrangler.jsonc new file mode 100644 index 000000000..e46e4cacf --- /dev/null +++ b/executor/packages/sandbox-host/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "name": "executor-sandbox-host", + "main": "src/index.ts", + "compatibility_date": "2025-06-01", + "compatibility_flags": ["enable_ctx_exports"], + + // The Worker Loader binding — this is the core of the Dynamic Worker Loader API. + // It provides `env.LOADER.get(id, callback)` to spawn sandboxed isolates. + "worker_loaders": [ + { + "binding": "LOADER" + } + ] + + // Environment secrets (set via `wrangler secret put AUTH_TOKEN`): + // AUTH_TOKEN: shared-secret bearer token that the Convex executor uses + // to authenticate run requests. + // + // See .dev.vars.example for local development values. +} From a8f2a00fd9105a1ade54b4d82a1d8c4e2ecfe0f5 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 11 Feb 2026 09:02:13 -0800 Subject: [PATCH 2/6] Extract isolate harness/globals from string constants into real JS files Move HARNESS_CODE and GLOBALS_MODULE from inline template literals in index.ts into separate *.isolate.js files under src/isolate/. Wrangler's rules config treats the *.isolate.js glob as Text modules, so they're bundled as string constants at build time while being authored as proper JS files with syntax highlighting and linting. --- executor/packages/sandbox-host/src/index.ts | 111 ++---------------- .../src/isolate/globals.isolate.js | 2 + .../src/isolate/harness.isolate.js | 96 +++++++++++++++ executor/packages/sandbox-host/wrangler.jsonc | 8 ++ 4 files changed, 119 insertions(+), 98 deletions(-) create mode 100644 executor/packages/sandbox-host/src/isolate/globals.isolate.js create mode 100644 executor/packages/sandbox-host/src/isolate/harness.isolate.js diff --git a/executor/packages/sandbox-host/src/index.ts b/executor/packages/sandbox-host/src/index.ts index 067bc9155..a73e1ae7e 100644 --- a/executor/packages/sandbox-host/src/index.ts +++ b/executor/packages/sandbox-host/src/index.ts @@ -35,6 +35,15 @@ import { WorkerEntrypoint } from "cloudflare:workers"; +// Import isolate modules as raw text — these are loaded as JS modules inside +// the dynamic isolate, NOT executed in the host worker. The *.isolate.js +// extension is mapped to Text type in wrangler.jsonc rules, so wrangler +// bundles them as string constants instead of trying to execute them. +// @ts-expect-error — wrangler Text module import (no TS declarations) +import GLOBALS_MODULE from "./isolate/globals.isolate.js"; +// @ts-expect-error — wrangler Text module import (no TS declarations) +import HARNESS_CODE from "./isolate/harness.isolate.js"; + // ── Types ──────────────────────────────────────────────────────────────────── interface Env { @@ -182,104 +191,10 @@ export class ToolBridge extends WorkerEntrypoint { // isolate. User code lives in a **separate** module (`user-code.js`) and is // imported by the harness. This prevents user code from accessing or // manipulating the harness's fetch handler, `req`, `env`, `ctx`, or `Response`. - -const GLOBALS_MODULE = ` -// Captured before user code module is evaluated (this module is imported first). -export const ResponseJson = Response.json.bind(Response); -`; - -const HARNESS_CODE = ` -import { ResponseJson as _ResponseJson } from "./globals.js"; -import { run } from "./user-code.js"; - -const APPROVAL_DENIED_PREFIX = "APPROVAL_DENIED:"; - -function formatArgs(args) { - return args.map((v) => { - if (typeof v === "string") return v; - try { return JSON.stringify(v); } - catch { return String(v); } - }).join(" "); -} - -function createToolsProxy(bridge, path = []) { - const callable = () => {}; - return new Proxy(callable, { - get(_target, prop) { - if (prop === "then") return undefined; - if (typeof prop !== "string") return undefined; - return createToolsProxy(bridge, [...path, prop]); - }, - async apply(_target, _thisArg, args) { - const toolPath = path.join("."); - if (!toolPath) throw new Error("Tool path missing"); - const input = args.length > 0 ? args[0] : {}; - const result = await bridge.callTool(toolPath, input); - if (result.ok) return result.value; - if (result.denied) throw new Error(APPROVAL_DENIED_PREFIX + result.error); - throw new Error(result.error); - }, - }); -} - -export default { - async fetch(req, env, ctx) { - const stdoutLines = []; - const stderrLines = []; - - const appendStdout = (line) => { - stdoutLines.push(line); - ctx.waitUntil(env.TOOL_BRIDGE.emitOutput("stdout", line)); - }; - const appendStderr = (line) => { - stderrLines.push(line); - ctx.waitUntil(env.TOOL_BRIDGE.emitOutput("stderr", line)); - }; - - const tools = createToolsProxy(env.TOOL_BRIDGE); - const console = { - log: (...args) => appendStdout(formatArgs(args)), - info: (...args) => appendStdout(formatArgs(args)), - warn: (...args) => appendStderr(formatArgs(args)), - error: (...args) => appendStderr(formatArgs(args)), - }; - - try { - const value = await run(tools, console); - - if (value !== undefined) { - appendStdout("result: " + formatArgs([value])); - } - - return _ResponseJson({ - status: "completed", - stdout: stdoutLines.join("\\n"), - stderr: stderrLines.join("\\n"), - exitCode: 0, - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.startsWith(APPROVAL_DENIED_PREFIX)) { - const denied = message.replace(APPROVAL_DENIED_PREFIX, "").trim(); - appendStderr(denied); - return _ResponseJson({ - status: "denied", - stdout: stdoutLines.join("\\n"), - stderr: stderrLines.join("\\n"), - error: denied, - }); - } - appendStderr(message); - return _ResponseJson({ - status: "failed", - stdout: stdoutLines.join("\\n"), - stderr: stderrLines.join("\\n"), - error: message, - }); - } - }, -}; -`; +// +// Both HARNESS_CODE and GLOBALS_MODULE are imported as raw text from +// `./isolate/harness.js` and `./isolate/globals.js` respectively, so they +// can be authored as real JS files with proper syntax highlighting and linting. /** * Build the user code module. The code is wrapped in an exported async diff --git a/executor/packages/sandbox-host/src/isolate/globals.isolate.js b/executor/packages/sandbox-host/src/isolate/globals.isolate.js new file mode 100644 index 000000000..57df8aadc --- /dev/null +++ b/executor/packages/sandbox-host/src/isolate/globals.isolate.js @@ -0,0 +1,2 @@ +// Captured before user code module is evaluated (this module is imported first). +export const ResponseJson = Response.json.bind(Response); diff --git a/executor/packages/sandbox-host/src/isolate/harness.isolate.js b/executor/packages/sandbox-host/src/isolate/harness.isolate.js new file mode 100644 index 000000000..4499002d9 --- /dev/null +++ b/executor/packages/sandbox-host/src/isolate/harness.isolate.js @@ -0,0 +1,96 @@ +import { ResponseJson as _ResponseJson } from "./globals.js"; +import { run } from "./user-code.js"; + +const APPROVAL_DENIED_PREFIX = "APPROVAL_DENIED:"; + +function formatArgs(args) { + return args + .map((v) => { + if (typeof v === "string") return v; + try { + return JSON.stringify(v); + } catch { + return String(v); + } + }) + .join(" "); +} + +function createToolsProxy(bridge, path = []) { + const callable = () => {}; + return new Proxy(callable, { + get(_target, prop) { + if (prop === "then") return undefined; + if (typeof prop !== "string") return undefined; + return createToolsProxy(bridge, [...path, prop]); + }, + async apply(_target, _thisArg, args) { + const toolPath = path.join("."); + if (!toolPath) throw new Error("Tool path missing"); + const input = args.length > 0 ? args[0] : {}; + const result = await bridge.callTool(toolPath, input); + if (result.ok) return result.value; + if (result.denied) throw new Error(APPROVAL_DENIED_PREFIX + result.error); + throw new Error(result.error); + }, + }); +} + +export default { + async fetch(req, env, ctx) { + const stdoutLines = []; + const stderrLines = []; + + const appendStdout = (line) => { + stdoutLines.push(line); + ctx.waitUntil(env.TOOL_BRIDGE.emitOutput("stdout", line)); + }; + const appendStderr = (line) => { + stderrLines.push(line); + ctx.waitUntil(env.TOOL_BRIDGE.emitOutput("stderr", line)); + }; + + const tools = createToolsProxy(env.TOOL_BRIDGE); + const console = { + log: (...args) => appendStdout(formatArgs(args)), + info: (...args) => appendStdout(formatArgs(args)), + warn: (...args) => appendStderr(formatArgs(args)), + error: (...args) => appendStderr(formatArgs(args)), + }; + + try { + const value = await run(tools, console); + + if (value !== undefined) { + appendStdout("result: " + formatArgs([value])); + } + + return _ResponseJson({ + status: "completed", + stdout: stdoutLines.join("\n"), + stderr: stderrLines.join("\n"), + exitCode: 0, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + if (message.startsWith(APPROVAL_DENIED_PREFIX)) { + const denied = message.replace(APPROVAL_DENIED_PREFIX, "").trim(); + appendStderr(denied); + return _ResponseJson({ + status: "denied", + stdout: stdoutLines.join("\n"), + stderr: stderrLines.join("\n"), + error: denied, + }); + } + appendStderr(message); + return _ResponseJson({ + status: "failed", + stdout: stdoutLines.join("\n"), + stderr: stderrLines.join("\n"), + error: message, + }); + } + }, +}; diff --git a/executor/packages/sandbox-host/wrangler.jsonc b/executor/packages/sandbox-host/wrangler.jsonc index e46e4cacf..231928a90 100644 --- a/executor/packages/sandbox-host/wrangler.jsonc +++ b/executor/packages/sandbox-host/wrangler.jsonc @@ -4,6 +4,14 @@ "compatibility_date": "2025-06-01", "compatibility_flags": ["enable_ctx_exports"], + // Treat *.isolate.js files as raw text strings so they can be passed to the + // Dynamic Worker Loader API as module source code. This lets us author the + // isolate harness and globals as real JS files (with syntax highlighting) + // while importing them as text in the host worker. + "rules": [ + { "type": "Text", "globs": ["**/*.isolate.js"], "fallthrough": true } + ], + // The Worker Loader binding — this is the core of the Dynamic Worker Loader API. // It provides `env.LOADER.get(id, callback)` to spawn sandboxed isolates. "worker_loaders": [ From f3db48448400c761dac6bbf82fb80836c79f3679 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 11 Feb 2026 09:08:54 -0800 Subject: [PATCH 3/6] Refactor runtime and typechecker to use better-result instead of try/catch Replace nested try/catch patterns with Result types from better-result: - transpileForRuntime() returns Result (sync) - runtime_core uses Result.tryPromise() for VM execution - CF worker loader uses Result.tryPromise() for fetch + response parsing - typechecker uses Result.try() for TS module loading and semantic checks - Add TranspileError as a TaggedError for typed error discrimination --- executor/bun.lock | 11 + .../cloudflare_worker_loader_runtime.ts | 125 ++++----- executor/lib/runtimes/runtime_core.ts | 132 +++++----- executor/lib/runtimes/transpile.ts | 82 ++++-- executor/lib/typechecker.ts | 237 ++++++++++-------- executor/package.json | 1 + 6 files changed, 350 insertions(+), 238 deletions(-) diff --git a/executor/bun.lock b/executor/bun.lock index 41f6593a0..15ae16648 100644 --- a/executor/bun.lock +++ b/executor/bun.lock @@ -5,12 +5,17 @@ "": { "name": "executor", "dependencies": { + "better-result": "^2.7.0", "convex": "^1.31.7", "graphql": "^16.12.0", }, }, }, "packages": { + "@clack/core": ["@clack/core@0.5.0", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow=="], + + "@clack/prompts": ["@clack/prompts@0.11.0", "", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.0", "", { "os": "android", "cpu": "arm" }, "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ=="], @@ -63,12 +68,18 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="], + "better-result": ["better-result@2.7.0", "", { "dependencies": { "@clack/prompts": "^0.11.0" }, "bin": { "better-result": "bin/cli.mjs" } }, "sha512-7zrmXjAK8u8Z6SOe4R65XObOR5X+Y2I/VVku3t5cPOGQ8/WsBcfFmfnIPiEl5EBMDOzPHRwbiPbMtQBKYdw7RA=="], + "convex": ["convex@1.31.7", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-PtNMe1mAIOvA8Yz100QTOaIdgt2rIuWqencVXrb4McdhxBHZ8IJ1eXTnrgCC9HydyilGT1pOn+KNqT14mqn9fQ=="], "esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="], "graphql": ["graphql@16.12.0", "", {}, "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], + + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], } } diff --git a/executor/lib/runtimes/cloudflare_worker_loader_runtime.ts b/executor/lib/runtimes/cloudflare_worker_loader_runtime.ts index 5c70954f6..7ec9c09ea 100644 --- a/executor/lib/runtimes/cloudflare_worker_loader_runtime.ts +++ b/executor/lib/runtimes/cloudflare_worker_loader_runtime.ts @@ -1,5 +1,6 @@ "use node"; +import { Result } from "better-result"; import type { SandboxExecutionRequest, SandboxExecutionResult } from "../types"; import { getCloudflareWorkerLoaderConfig } from "./runtime_catalog"; import { transpileForRuntime } from "./transpile"; @@ -38,19 +39,30 @@ export async function runCodeWithCloudflareWorkerLoader( const config = getCloudflareWorkerLoaderConfig(); const startedAt = Date.now(); - const controller = new AbortController(); - const timeout = setTimeout( - () => controller.abort(), - config.requestTimeoutMs, - ); + const mkResult = ( + status: SandboxExecutionResult["status"], + opts?: { stdout?: string; stderr?: string; error?: string; exitCode?: number }, + ): SandboxExecutionResult => ({ + status, + stdout: opts?.stdout ?? "", + stderr: opts?.stderr ?? "", + exitCode: opts?.exitCode, + error: opts?.error, + durationMs: Date.now() - startedAt, + }); - try { - // Transpile TS → JS on the Convex side before sending to the CF isolate. - // The dynamic isolate runs the code as plain JS (harness.js), so any - // TypeScript syntax must be stripped first. - const code = await transpileForRuntime(request.code); + // ── Transpile TS → JS on the Convex side ───────────────────────────── + const transpiled = transpileForRuntime(request.code); + if (transpiled.isErr()) { + return mkResult("failed", { error: transpiled.error.message }); + } - const response = await fetch(config.runUrl, { + // ── POST to CF host worker ──────────────────────────────────────────── + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs); + + const response = await Result.tryPromise(() => + fetch(config.runUrl, { method: "POST", headers: { "Content-Type": "application/json", @@ -58,71 +70,66 @@ export async function runCodeWithCloudflareWorkerLoader( }, body: JSON.stringify({ taskId: request.taskId, - code, + code: transpiled.value, timeoutMs: request.timeoutMs, - // The host Worker needs these to call back to Convex for tool - // invocations and output streaming. callback: { baseUrl: config.callbackBaseUrl, authToken: config.callbackAuthToken, }, }), signal: controller.signal, - }); + }), + ); - if (!response.ok) { - const text = await response.text().catch(() => response.statusText); - return { - status: "failed", - stdout: "", - stderr: text, - error: `Cloudflare sandbox returned ${response.status}: ${text}`, - durationMs: Date.now() - startedAt, - }; + clearTimeout(timeout); + + if (response.isErr()) { + const cause = response.error.cause; + const isAbort = cause instanceof DOMException && cause.name === "AbortError"; + if (isAbort) { + return mkResult("timed_out", { + error: `Cloudflare sandbox request timed out after ${config.requestTimeoutMs}ms`, + }); } + const message = cause instanceof Error ? cause.message : String(cause); + return mkResult("failed", { + error: `Cloudflare sandbox request failed: ${message}`, + }); + } - const result = (await response.json()) as { + // ── Handle non-OK HTTP status ───────────────────────────────────────── + if (!response.value.ok) { + const text = await Result.tryPromise(() => response.value.text()); + const body = text.unwrapOr(response.value.statusText); + return mkResult("failed", { + stderr: body, + error: `Cloudflare sandbox returned ${response.value.status}: ${body}`, + }); + } + + // ── Parse JSON response ─────────────────────────────────────────────── + const body = await Result.tryPromise(() => + response.value.json() as Promise<{ status?: string; stdout?: string; stderr?: string; error?: string; exitCode?: number; - }; - - const status = mapStatus(result.status); - - return { - status, - stdout: result.stdout ?? "", - stderr: result.stderr ?? "", - exitCode: result.exitCode, - error: result.error, - durationMs: Date.now() - startedAt, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const isAbort = error instanceof DOMException && error.name === "AbortError"; - - if (isAbort) { - return { - status: "timed_out", - stdout: "", - stderr: "", - error: `Cloudflare sandbox request timed out after ${config.requestTimeoutMs}ms`, - durationMs: Date.now() - startedAt, - }; - } + }>, + ); - return { - status: "failed", - stdout: "", - stderr: "", - error: `Cloudflare sandbox request failed: ${message}`, - durationMs: Date.now() - startedAt, - }; - } finally { - clearTimeout(timeout); + if (body.isErr()) { + return mkResult("failed", { + error: `Cloudflare sandbox returned invalid JSON`, + }); } + + return mkResult(mapStatus(body.value.status), { + stdout: body.value.stdout, + stderr: body.value.stderr, + exitCode: body.value.exitCode, + error: body.value.error, + }); } function mapStatus( diff --git a/executor/lib/runtimes/runtime_core.ts b/executor/lib/runtimes/runtime_core.ts index ce478820a..cb70ec8a1 100644 --- a/executor/lib/runtimes/runtime_core.ts +++ b/executor/lib/runtimes/runtime_core.ts @@ -5,6 +5,7 @@ // the core helpers here (formatArgs, createToolsProxy, console proxy, execution // loop, result mapping) should be mirrored there. import { APPROVAL_DENIED_PREFIX, TASK_TIMEOUT_MARKER } from "../execution_constants"; +import { Result } from "better-result"; import { Script, createContext } from "node:vm"; import type { ExecutionAdapter, @@ -17,11 +18,7 @@ function formatArgs(args: unknown[]): string { return args .map((value) => { if (typeof value === "string") return value; - try { - return JSON.stringify(value); - } catch { - return String(value); - } + return Result.try(() => JSON.stringify(value)).unwrapOr(String(value)); }) .join(" "); } @@ -71,6 +68,30 @@ function createToolsProxy( }); } +/** Classify a caught error from VM execution into a result status. */ +function classifyExecutionError( + error: unknown, + request: SandboxExecutionRequest, +): { status: SandboxExecutionResult["status"]; message: string } { + const message = error instanceof Error ? error.message : String(error); + + if (message === TASK_TIMEOUT_MARKER || message.includes("Script execution timed out")) { + return { + status: "timed_out", + message: `Execution timed out after ${request.timeoutMs}ms`, + }; + } + + if (message.startsWith(APPROVAL_DENIED_PREFIX)) { + return { + status: "denied", + message: message.replace(APPROVAL_DENIED_PREFIX, "").trim(), + }; + } + + return { status: "failed", message }; +} + export async function runCodeWithAdapter( request: SandboxExecutionRequest, adapter: ExecutionAdapter, @@ -103,6 +124,26 @@ export async function runCodeWithAdapter( ); }; + const mkResult = ( + status: SandboxExecutionResult["status"], + opts?: { error?: string; exitCode?: number }, + ): SandboxExecutionResult => ({ + status, + stdout: stdoutLines.join("\n"), + stderr: stderrLines.join("\n"), + exitCode: opts?.exitCode, + error: opts?.error, + durationMs: Date.now() - startedAt, + }); + + // ── Transpile ────────────────────────────────────────────────────────── + const transpiled = transpileForRuntime(request.code); + if (transpiled.isErr()) { + appendStderr(transpiled.error.message); + return mkResult("failed", { error: transpiled.error.message }); + } + + // ── Sandbox setup ────────────────────────────────────────────────────── const tools = createToolsProxy(adapter, request.taskId); const consoleProxy = { log: (...args: unknown[]) => appendStdout(formatArgs(args)), @@ -118,15 +159,14 @@ export async function runCodeWithAdapter( clearTimeout, }); const context = createContext(sandbox, { - codeGeneration: { - strings: false, - wasm: false, - }, + codeGeneration: { strings: false, wasm: false }, }); - const executableCode = await transpileForRuntime(request.code); - const runnerScript = new Script(`(async () => {\n"use strict";\n${executableCode}\n})()`); + const runnerScript = new Script( + `(async () => {\n"use strict";\n${transpiled.value}\n})()`, + ); + // ── Execute with timeout ─────────────────────────────────────────────── let timeoutHandle: ReturnType | undefined; const timeoutPromise = new Promise((_resolve, reject) => { timeoutHandle = setTimeout(() => { @@ -134,60 +174,32 @@ export async function runCodeWithAdapter( }, request.timeoutMs); }); - try { + const execution = await Result.tryPromise(async () => { const value = await Promise.race([ - Promise.resolve(runnerScript.runInContext(context, { timeout: Math.max(1, request.timeoutMs) })), + Promise.resolve( + runnerScript.runInContext(context, { + timeout: Math.max(1, request.timeoutMs), + }), + ), timeoutPromise, ]); + return value; + }); - if (value !== undefined) { - appendStdout(`result: ${formatArgs([value])}`); - } - - return { - status: "completed", - stdout: stdoutLines.join("\n"), - stderr: stderrLines.join("\n"), - exitCode: 0, - durationMs: Date.now() - startedAt, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message === TASK_TIMEOUT_MARKER || message.includes("Script execution timed out")) { - const timeoutMessage = `Execution timed out after ${request.timeoutMs}ms`; - appendStderr(timeoutMessage); - return { - status: "timed_out", - stdout: stdoutLines.join("\n"), - stderr: stderrLines.join("\n"), - error: timeoutMessage, - durationMs: Date.now() - startedAt, - }; - } - - if (message.startsWith(APPROVAL_DENIED_PREFIX)) { - const deniedMessage = message.replace(APPROVAL_DENIED_PREFIX, "").trim(); - appendStderr(deniedMessage); - return { - status: "denied", - stdout: stdoutLines.join("\n"), - stderr: stderrLines.join("\n"), - error: deniedMessage, - durationMs: Date.now() - startedAt, - }; - } + if (timeoutHandle) clearTimeout(timeoutHandle); + if (execution.isErr()) { + const { status, message } = classifyExecutionError( + execution.error.cause, + request, + ); appendStderr(message); - return { - status: "failed", - stdout: stdoutLines.join("\n"), - stderr: stderrLines.join("\n"), - error: message, - durationMs: Date.now() - startedAt, - }; - } finally { - if (timeoutHandle) { - clearTimeout(timeoutHandle); - } + return mkResult(status, { error: message }); } + + if (execution.value !== undefined) { + appendStdout(`result: ${formatArgs([execution.value])}`); + } + + return mkResult("completed", { exitCode: 0 }); } diff --git a/executor/lib/runtimes/transpile.ts b/executor/lib/runtimes/transpile.ts index 0c8432393..f58c44ea5 100644 --- a/executor/lib/runtimes/transpile.ts +++ b/executor/lib/runtimes/transpile.ts @@ -1,38 +1,78 @@ "use node"; +import { Result, TaggedError } from "better-result"; + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +export class TranspileError extends TaggedError("TranspileError")<{ + message: string; +}>() {} + +// --------------------------------------------------------------------------- +// TypeScript module loader +// --------------------------------------------------------------------------- + +let cachedTypeScript: typeof import("typescript") | null | undefined; + +function getTypeScriptModule(): typeof import("typescript") | null { + if (cachedTypeScript === undefined) { + const loaded = Result.try(() => require("typescript") as typeof import("typescript")); + cachedTypeScript = loaded.isOk() ? loaded.value : null; + } + return cachedTypeScript ?? null; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + /** * Transpile TypeScript code to JavaScript using the `typescript` module. * - * If the `typescript` module is not available, the code is returned as-is + * Returns a Result — either the transpiled JS string or a TranspileError. + * If the TypeScript module is not available, the code is returned as-is * (graceful fallback for environments where TS isn't installed). * * Targets ES2022/ESNext so the output can run in modern runtimes (node:vm, * Cloudflare Workers isolates, etc.) without further downlevelling. */ -export async function transpileForRuntime(code: string): Promise { - let ts: typeof import("typescript"); - try { - ts = require("typescript"); - } catch { - return code; - } +export function transpileForRuntime( + code: string, +): Result { + const ts = getTypeScriptModule(); + if (!ts) return Result.ok(code); const target = ts.ScriptTarget?.ES2022 ?? ts.ScriptTarget?.ESNext; const moduleKind = ts.ModuleKind?.ESNext; - const result = ts.transpileModule(code, { - compilerOptions: { - ...(target !== undefined ? { target } : {}), - ...(moduleKind !== undefined ? { module: moduleKind } : {}), - }, - reportDiagnostics: true, - }); + return Result.try({ + try: () => { + const result = ts.transpileModule(code, { + compilerOptions: { + ...(target !== undefined ? { target } : {}), + ...(moduleKind !== undefined ? { module: moduleKind } : {}), + }, + reportDiagnostics: true, + }); - if (result.diagnostics && result.diagnostics.length > 0) { - const first = result.diagnostics[0]; - const message = ts.flattenDiagnosticMessageText(first.messageText, "\n"); - throw new Error(`TypeScript transpile error: ${message}`); - } + if (result.diagnostics && result.diagnostics.length > 0) { + const first = result.diagnostics[0]; + const message = ts.flattenDiagnosticMessageText( + first.messageText, + "\n", + ); + throw new TranspileError({ message: `TypeScript transpile error: ${message}` }); + } - return result.outputText || code; + return result.outputText || code; + }, + catch: (e) => + e instanceof TranspileError + ? e + : new TranspileError({ + message: e instanceof Error ? e.message : String(e), + }), + }); } diff --git a/executor/lib/typechecker.ts b/executor/lib/typechecker.ts index e5363b720..64eeb3a19 100644 --- a/executor/lib/typechecker.ts +++ b/executor/lib/typechecker.ts @@ -13,6 +13,7 @@ * without needing their own TypeScript setup. */ +import { Result } from "better-result"; import type { ToolDescriptor } from "./types"; // --------------------------------------------------------------------------- @@ -23,11 +24,8 @@ let cachedTypeScript: typeof import("typescript") | null | undefined; function getTypeScriptModule(): typeof import("typescript") | null { if (cachedTypeScript === undefined) { - try { - cachedTypeScript = require("typescript"); - } catch { - cachedTypeScript = null; - } + const loaded = Result.try(() => require("typescript") as typeof import("typescript")); + cachedTypeScript = loaded.isOk() ? loaded.value : null; } return cachedTypeScript ?? null; } @@ -39,7 +37,7 @@ function isValidTypeExpression(typeExpression: string): boolean { return !/[\r\n`]/.test(typeExpression); } - try { + return Result.try(() => { const sourceFile = ts.createSourceFile( "_type_expr_check.ts", `type __T = ${typeExpression};`, @@ -51,9 +49,7 @@ function isValidTypeExpression(typeExpression: string): boolean { sourceFile as unknown as { parseDiagnostics?: import("typescript").Diagnostic[] } ).parseDiagnostics ?? []; return diagnostics.length === 0; - } catch { - return false; - } + }).unwrapOr(false); } function safeTypeExpression(raw: string | undefined, fallback: string): string { @@ -63,20 +59,62 @@ function safeTypeExpression(raw: string | undefined, fallback: string): string { } const OPENAPI_HELPER_TYPES = ` -type _OrEmpty = [T] extends [never] ? {} : T; +type _Normalize = Exclude; +type _OrEmpty = [_Normalize] extends [never] ? {} : _Normalize; type _Simplify = { [K in keyof T]: T[K] } & {}; +type _ParamsOf = + Op extends { parameters: infer P } ? P : + Op extends { parameters?: infer P } ? P : + never; +type _ParamAt = + _ParamsOf extends { [P in K]?: infer V } ? V : never; +type _BodyOf = + Op extends { requestBody?: infer B } ? B : + Op extends { requestBody: infer B } ? B : + never; +type _BodyContent = + B extends { content: infer C } + ? C extends Record ? V : never + : never; type ToolInput = _Simplify< - _OrEmpty & - _OrEmpty & - _OrEmpty + _OrEmpty<_ParamAt> & + _OrEmpty<_ParamAt> & + _OrEmpty<_ParamAt> & + _OrEmpty<_ParamAt> & + _OrEmpty<_BodyContent<_BodyOf>> >; -type ToolOutput = - Op extends { responses: { 200: { content: { "application/json": infer R } } } } ? R : - Op extends { responses: { 201: { content: { "application/json": infer R } } } } ? R : - Op extends { responses: { 202: { content: { "application/json": infer R } } } } ? R : - Op extends { responses: { 204: unknown } } ? void : - Op extends { responses: { 205: unknown } } ? void : - unknown; +type _ResponsesOf = Op extends { responses: infer R } ? R : never; +type _RespAt = + _ResponsesOf extends { [K in Code]?: infer R } ? R : never; +type _ResponsePayload = + [R] extends [never] ? never : + R extends { content: infer C } + ? C extends Record ? V : unknown + : R extends { schema: infer S } ? S : unknown; +type _HasStatus = + [_RespAt] extends [never] ? false : true; +type _PayloadAt = + Code extends 204 | 205 + ? (_HasStatus extends true ? void : never) + : _ResponsePayload<_RespAt>; +type _FirstKnown = + T extends readonly [infer H, ...infer Rest] + ? [H] extends [never] ? _FirstKnown : H + : unknown; +type ToolOutput = _FirstKnown<[ + _PayloadAt, + _PayloadAt, + _PayloadAt, + _PayloadAt, + _PayloadAt, + _PayloadAt, + _PayloadAt, + _PayloadAt, + _PayloadAt, + _PayloadAt, + _PayloadAt, + unknown +]>; `; function stripExportKeywordsForTypechecker(dts: string): string { @@ -233,6 +271,8 @@ export interface TypecheckResult { readonly errors: readonly string[]; } +const TYPECHECK_OK: TypecheckResult = { ok: true, errors: [] }; + let warnedMissingCompilerHostSupport = false; let warnedSemanticFallback = false; @@ -245,7 +285,7 @@ function runSyntaxOnlyTypecheck( headerLineCount: number, ) => string, ): TypecheckResult { - try { + return Result.try(() => { const sourceFile = ts.createSourceFile( "generated.ts", wrappedCode, @@ -256,16 +296,72 @@ function runSyntaxOnlyTypecheck( const diagnostics = ( sourceFile as unknown as { parseDiagnostics?: import("typescript").Diagnostic[] } ).parseDiagnostics ?? []; - if (diagnostics.length === 0) { - return { ok: true, errors: [] }; - } + if (diagnostics.length === 0) return TYPECHECK_OK; return { - ok: false, - errors: diagnostics.map((d: import("typescript").Diagnostic) => formatError(d, headerLineCount)), + ok: false as const, + errors: diagnostics.map((d) => formatError(d, headerLineCount)), }; - } catch { - return { ok: true, errors: [] }; - } + }).unwrapOr(TYPECHECK_OK); +} + +function runSemanticTypecheck( + ts: typeof import("typescript"), + wrappedCode: string, + headerLineCount: number, + formatError: ( + diagnostic: import("typescript").Diagnostic, + headerLineCount: number, + ) => string, +): Result { + return Result.try({ + try: () => { + const sourceFile = ts.createSourceFile( + "generated.ts", + wrappedCode, + ts.ScriptTarget.ESNext, + true, + ts.ScriptKind.TS, + ); + + const compilerOptions: import("typescript").CompilerOptions = { + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + strict: true, + noEmit: true, + lib: ["lib.es2022.d.ts"], + types: [], // prevent automatic @types/* from conflicting with sandbox declarations + }; + + const host = ts.createCompilerHost(compilerOptions); + const originalGetSourceFile = host.getSourceFile.bind(host); + host.getSourceFile = (fileName, languageVersion) => { + if (fileName === "generated.ts") return sourceFile; + return originalGetSourceFile(fileName, languageVersion); + }; + + const program = ts.createProgram(["generated.ts"], compilerOptions, host); + const diagnostics = program.getSemanticDiagnostics(sourceFile); + + if (diagnostics.length === 0) return TYPECHECK_OK; + + // Filter out errors from the .d.ts header — only report user code errors + const userErrors = diagnostics.filter((d) => { + if (d.start !== undefined && d.file) { + const { line } = d.file.getLineAndCharacterOfPosition(d.start); + return line + 1 > headerLineCount; + } + return false; + }); + + if (userErrors.length === 0) return TYPECHECK_OK; + + return { + ok: false as const, + errors: userErrors.map((d) => formatError(d, headerLineCount)), + }; + }, + catch: (e) => (e instanceof Error ? e : new Error(String(e))), + }); } /** @@ -278,13 +374,8 @@ export function typecheckCode( code: string, toolDeclarations: string, ): TypecheckResult { - let ts: typeof import("typescript"); - try { - ts = require("typescript"); - } catch { - // TypeScript not available — skip typechecking - return { ok: true, errors: [] }; - } + const ts = getTypeScriptModule(); + if (!ts) return TYPECHECK_OK; // Wrap the code in an async function with the tools declaration. // We declare sandbox globals (console, setTimeout, etc.) ourselves since @@ -301,12 +392,12 @@ export function typecheckCode( const formatError = ( diagnostic: import("typescript").Diagnostic, - headerLineCount: number, + hdrLineCount: number, ): string => { const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"); if (diagnostic.start !== undefined && diagnostic.file) { const { line } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start); - const adjustedLine = line + 1 - headerLineCount; + const adjustedLine = line + 1 - hdrLineCount; if (adjustedLine > 0) { return `Line ${adjustedLine}: ${message}`; } @@ -328,66 +419,16 @@ export function typecheckCode( return runSyntaxOnlyTypecheck(ts, wrappedCode, headerLineCount, formatError); } - try { - const sourceFile = ts.createSourceFile( - "generated.ts", - wrappedCode, - ts.ScriptTarget.ESNext, - true, - ts.ScriptKind.TS, - ); - - const compilerOptions: import("typescript").CompilerOptions = { - target: ts.ScriptTarget.ESNext, - module: ts.ModuleKind.ESNext, - strict: true, - noEmit: true, - lib: ["lib.es2022.d.ts"], - types: [], // prevent automatic @types/* (e.g. @types/node) from conflicting with our sandbox declarations - }; - - const host = ts.createCompilerHost(compilerOptions); - const originalGetSourceFile = host.getSourceFile.bind(host); - host.getSourceFile = (fileName, languageVersion) => { - if (fileName === "generated.ts") return sourceFile; - return originalGetSourceFile(fileName, languageVersion); - }; - - const program = ts.createProgram(["generated.ts"], compilerOptions, host); - const diagnostics = program.getSemanticDiagnostics(sourceFile); - - if (diagnostics.length === 0) { - return { ok: true, errors: [] }; - } - - // Filter out errors from the .d.ts header (circular refs, etc.) — only report user code errors - const userErrors = diagnostics.filter((d) => { - if (d.start !== undefined && d.file) { - const { line } = d.file.getLineAndCharacterOfPosition(d.start); - return line + 1 > headerLineCount; - } - return false; - }); + const semantic = runSemanticTypecheck(ts, wrappedCode, headerLineCount, formatError); + if (semantic.isOk()) return semantic.value; - if (userErrors.length === 0) { - return { ok: true, errors: [] }; - } - - return { - ok: false, - errors: userErrors.map((d) => formatError(d, headerLineCount)), - }; - } catch (error) { - // Some runtimes (e.g. Convex action isolates) can lack the full Node-backed - // TypeScript host environment. If semantic typechecking cannot initialize, - // fall back to syntax-only parsing instead of failing the MCP call. - if (!warnedSemanticFallback) { - warnedSemanticFallback = true; - console.warn( - `[executor] TypeScript semantic typecheck unavailable, falling back to syntax-only checks: ${error instanceof Error ? error.message : String(error)}`, - ); - } - - return runSyntaxOnlyTypecheck(ts, wrappedCode, headerLineCount, formatError); + // Semantic typechecking failed to initialize — fall back to syntax-only. + if (!warnedSemanticFallback) { + warnedSemanticFallback = true; + console.warn( + `[executor] TypeScript semantic typecheck unavailable, falling back to syntax-only checks: ${semantic.error.message}`, + ); } + + return runSyntaxOnlyTypecheck(ts, wrappedCode, headerLineCount, formatError); } diff --git a/executor/package.json b/executor/package.json index 73aa7be12..ef73e895f 100644 --- a/executor/package.json +++ b/executor/package.json @@ -14,6 +14,7 @@ "build:release": "bun run scripts/build-release.ts" }, "dependencies": { + "better-result": "^2.7.0", "convex": "^1.31.7", "graphql": "^16.12.0" } From e81257d97a62ba593dd7fcccda41b96d01f5df66 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 11 Feb 2026 09:30:22 -0800 Subject: [PATCH 4/6] Refactor sandbox host worker to use better-result Replace try/catch patterns in the CF host worker with Result types: - ToolBridge.callTool uses Result.tryPromise for fetch + response handling - ToolBridge.emitOutput uses Result.tryPromise (best-effort, discard err) - Request body parsing uses Result.tryPromise instead of try/catch - Isolate execution uses nested Result.tryPromise, eliminates try/finally --- executor/packages/sandbox-host/package.json | 3 + executor/packages/sandbox-host/src/index.ts | 107 ++++++++++++-------- 2 files changed, 66 insertions(+), 44 deletions(-) diff --git a/executor/packages/sandbox-host/package.json b/executor/packages/sandbox-host/package.json index f16908e2f..5e8af81fe 100644 --- a/executor/packages/sandbox-host/package.json +++ b/executor/packages/sandbox-host/package.json @@ -12,5 +12,8 @@ "@cloudflare/workers-types": "^4.20250214.0", "typescript": "^5.9.3", "wrangler": "^4.0.0" + }, + "dependencies": { + "better-result": "^2.7.0" } } diff --git a/executor/packages/sandbox-host/src/index.ts b/executor/packages/sandbox-host/src/index.ts index a73e1ae7e..78e13385b 100644 --- a/executor/packages/sandbox-host/src/index.ts +++ b/executor/packages/sandbox-host/src/index.ts @@ -33,6 +33,7 @@ * or `Response` — preventing IIFE escape attacks and response forgery. */ +import { Result } from "better-result"; import { WorkerEntrypoint } from "cloudflare:workers"; // Import isolate modules as raw text — these are loaded as JS modules inside @@ -128,6 +129,15 @@ function timingSafeEqual(a: string, b: string): boolean { return result === 0; } +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const failedResult = (error: string): RunResult => ({ + status: "failed", + stdout: "", + stderr: "", + error, +}); + // ── Tool Bridge Entrypoint ─────────────────────────────────────────────────── // // This class is exposed as a named entrypoint on the host Worker. A loopback @@ -148,21 +158,30 @@ export class ToolBridge extends WorkerEntrypoint { const url = `${callbackBaseUrl}/internal/runs/${taskId}/tool-call`; const callId = `call_${crypto.randomUUID()}`; - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${callbackAuthToken}`, - }, - body: JSON.stringify({ callId, toolPath, input }), - }); + const response = await Result.tryPromise(() => + fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${callbackAuthToken}`, + }, + body: JSON.stringify({ callId, toolPath, input }), + }), + ); + + if (response.isErr()) { + const cause = response.error.cause; + const message = cause instanceof Error ? cause.message : String(cause); + return { ok: false, error: `Tool callback failed: ${message}` }; + } - if (!response.ok) { - const text = await response.text().catch(() => response.statusText); - return { ok: false, error: `Tool callback failed (${response.status}): ${text}` }; + if (!response.value.ok) { + const text = await Result.tryPromise(() => response.value.text()); + const body = text.unwrapOr(response.value.statusText); + return { ok: false, error: `Tool callback failed (${response.value.status}): ${body}` }; } - return (await response.json()) as ToolCallResult; + return (await response.value.json()) as ToolCallResult; } /** Stream a console output line back to Convex (best-effort). */ @@ -170,18 +189,17 @@ export class ToolBridge extends WorkerEntrypoint { const { callbackBaseUrl, callbackAuthToken, taskId } = this.props; const url = `${callbackBaseUrl}/internal/runs/${taskId}/output`; - try { - await fetch(url, { + // Best-effort — swallow errors. + await Result.tryPromise(() => + fetch(url, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${callbackAuthToken}`, }, body: JSON.stringify({ stream, line, timestamp: Date.now() }), - }); - } catch { - // Swallow — output streaming is best-effort. - } + }), + ); } } @@ -231,12 +249,11 @@ export default { } // ── Parse body ──────────────────────────────────────────────────────── - let body: RunRequest; - try { - body = (await request.json()) as RunRequest; - } catch { + const parsed = await Result.tryPromise(() => request.json() as Promise); + if (parsed.isErr()) { return Response.json({ error: "Invalid JSON body" }, { status: 400 }); } + const body = parsed.value; if (!body.taskId || !body.code || !body.callback?.baseUrl || !body.callback?.authToken) { return Response.json( @@ -248,7 +265,8 @@ export default { const timeoutMs = body.timeoutMs ?? 300_000; const isolateId = body.taskId; - try { + // ── Spawn isolate and execute ───────────────────────────────────────── + const execution = await Result.tryPromise(async () => { const ctxExports = (ctx as unknown as { exports: Record unknown>; }).exports; @@ -266,10 +284,7 @@ export default { mainModule: "harness.js", modules: { "harness.js": HARNESS_CODE, - // Globals captured before user code is evaluated. "globals.js": GLOBALS_MODULE, - // User code is in a separate module — it exports run(tools, console) - // and cannot access the harness's fetch handler scope. "user-code.js": buildUserModule(body.code), }, env: { @@ -283,15 +298,18 @@ export default { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - const response = await entrypoint.fetch("http://sandbox.internal/run", { + const response = await Result.tryPromise(() => + entrypoint.fetch("http://sandbox.internal/run", { method: "POST", signal: controller.signal, - }); - const result = (await response.json()) as RunResult; - return Response.json(result); - } catch (error) { - if (error instanceof DOMException && error.name === "AbortError") { + }), + ); + + clearTimeout(timer); + + if (response.isErr()) { + const cause = response.error.cause; + if (cause instanceof DOMException && cause.name === "AbortError") { return Response.json({ status: "timed_out", stdout: "", @@ -299,18 +317,19 @@ export default { error: `Execution timed out after ${timeoutMs}ms`, } satisfies RunResult); } - throw error; - } finally { - clearTimeout(timer); + throw cause; } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return Response.json({ - status: "failed", - stdout: "", - stderr: "", - error: `Sandbox host error: ${message}`, - } satisfies RunResult); + + const result = (await response.value.json()) as RunResult; + return Response.json(result); + }); + + if (execution.isErr()) { + const cause = execution.error.cause; + const message = cause instanceof Error ? cause.message : String(cause); + return Response.json(failedResult(`Sandbox host error: ${message}`)); } + + return execution.value; }, }; From e10dba08ce21faf23c5261ae23959cd107d68acb Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:14:14 -0800 Subject: [PATCH 5/6] Add CF sandbox host worker to dev runner Start wrangler dev for the sandbox-host package alongside other services in dev.ts. Runs on port 8787 (configurable via SANDBOX_PORT env var). Also cleans up stale processes on that port. --- dev.ts | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/dev.ts b/dev.ts index eb6406cda..08cf58d98 100644 --- a/dev.ts +++ b/dev.ts @@ -6,10 +6,12 @@ * Reads all configuration from the root .env file (auto-loaded by Bun). * * Starts: - * 1. Convex cloud dev function watcher ─┐ - * 2. Executor web UI (port 4312) ├─ all started concurrently - * 3. Assistant server (port 3000) │ - * 4. Discord bot ─┘ + * 1. Sources catalog API (port 4343) ─┐ + * 2. Convex cloud dev function watcher │ + * 3. CF sandbox host worker (port 8787) ├─ all started concurrently + * 4. Executor web UI (port 4312) │ + * 5. Assistant server (port 3000) │ + * 6. Discord bot ─┘ * * All processes are killed when this script exits (Ctrl+C). * PIDs are written to .dev.pids for use with `bun run kill:all`. @@ -21,8 +23,10 @@ import { unlinkSync } from "node:fs"; const PID_FILE = join(import.meta.dir, ".dev.pids"); const colors = { + sources: "\x1b[33m", // yellow convex: "\x1b[36m", // cyan web: "\x1b[34m", // blue + sandbox: "\x1b[91m", // bright red assistant: "\x1b[32m", // green bot: "\x1b[35m", // magenta reset: "\x1b[0m", @@ -117,7 +121,8 @@ process.on("SIGTERM", shutdown); // ── Kill stale processes from a previous run ── const EXECUTOR_WEB_PORT = Number(Bun.env.EXECUTOR_WEB_PORT ?? 4312); -const DEV_PORTS = [3000, EXECUTOR_WEB_PORT]; +const SANDBOX_PORT = Number(Bun.env.SANDBOX_PORT ?? 8787); +const DEV_PORTS = [3000, EXECUTOR_WEB_PORT, SANDBOX_PORT]; async function killStaleProcesses() { let killed = 0; @@ -164,6 +169,11 @@ const urls = resolveExecutorUrls(); console.log(prefix("convex", `Using Convex URL: ${urls.convexUrl}`)); console.log(prefix("convex", `Using executor HTTP URL: ${urls.executorUrl}`)); +// 1. Sources catalog API +spawnService("sources", ["bun", "--hot", "server.ts"], { + cwd: "./sources", +}); + // 2. Start Convex file watcher (non-blocking — repushes on changes) spawnService("convex", [ "bunx", "convex", "dev", @@ -172,7 +182,15 @@ spawnService("convex", [ cwd: "./executor", }); -// 3. Everything else in parallel +// 3. CF sandbox host worker (local wrangler dev for iterating on the worker) +spawnService("sandbox", [ + "bunx", "wrangler", "dev", + "--port", String(SANDBOX_PORT), +], { + cwd: "./executor/packages/sandbox-host", +}); + +// 4. Everything else in parallel spawnService("web", ["bun", "run", "dev"], { cwd: "./executor/apps/web", }); From cd555fab6a731e2b6720e7ef9e854d02cfef5523 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:05:48 -0800 Subject: [PATCH 6/6] Implement tool call management and approval workflow - Introduce new database schema for tool calls, including status tracking and approval handling. - Add mutations for creating, updating, and retrieving tool calls, with support for pending approvals. - Enhance the executor to manage tool call lifecycle, including status updates and error handling. - Update internal API to support new tool call functionalities, ensuring proper event publishing for task states. - Refactor existing code to integrate new tool call logic, improving overall execution flow and error management. --- bun.lock | 153 +++- .../apps/web/src/components/code-editor.tsx | 64 +- .../apps/web/src/components/tools-view.tsx | 833 ++++++++++-------- executor/convex/_generated/api.d.ts | 97 ++ executor/convex/database.ts | 168 ++++ executor/convex/executor.ts | 59 ++ executor/convex/executorNode.ts | 276 ++++-- executor/convex/http.ts | 85 -- executor/convex/runtimeCallbacks.ts | 88 ++ executor/convex/schema.ts | 26 + .../in-process-execution-adapter.test.ts | 2 +- .../adapters/in_process_execution_adapter.ts | 16 +- executor/lib/execution_constants.ts | 1 + executor/lib/openapi-cache.test.ts | 3 +- .../cloudflare-worker-loader-runtime.test.ts | 301 ++----- .../cloudflare_worker_loader_runtime.ts | 110 +-- executor/lib/runtimes/runtime-core.test.ts | 4 +- executor/lib/runtimes/runtime_catalog.ts | 28 +- executor/lib/runtimes/runtime_core.ts | 43 +- executor/lib/tool-sources.test.ts | 84 ++ executor/lib/tool_sources.ts | 427 ++++++++- executor/lib/typechecker.test.ts | 42 +- executor/lib/types.ts | 26 +- executor/packages/sandbox-host/package.json | 3 +- executor/packages/sandbox-host/src/index.ts | 298 ++++--- .../src/isolate/harness.isolate.js | 20 +- package.json | 3 + sources/package.json | 16 + sources/scraper.ts | 384 ++++++++ sources/server.ts | 192 ++++ sources/supplements.json | 171 ++++ sources/tsconfig.json | 20 + 32 files changed, 3063 insertions(+), 980 deletions(-) create mode 100644 executor/convex/runtimeCallbacks.ts create mode 100644 sources/package.json create mode 100644 sources/scraper.ts create mode 100644 sources/server.ts create mode 100644 sources/supplements.json create mode 100644 sources/tsconfig.json diff --git a/bun.lock b/bun.lock index 7cdb3138f..e5f4547ec 100644 --- a/bun.lock +++ b/bun.lock @@ -130,6 +130,25 @@ "name": "@executor/convex", "version": "0.0.1", }, + "executor/packages/sandbox-host": { + "name": "@executor/sandbox-host", + "version": "0.1.0", + "dependencies": { + "better-result": "^2.7.0", + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250214.0", + "typescript": "^5.9.3", + "wrangler": "^4.0.0", + }, + }, + "sources": { + "name": "@proto/sources", + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.9.3", + }, + }, }, "trustedDependencies": [ "sharp", @@ -284,6 +303,26 @@ "@borewit/text-codec": ["@borewit/text-codec@0.2.1", "", {}, "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw=="], + "@clack/core": ["@clack/core@0.5.0", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow=="], + + "@clack/prompts": ["@clack/prompts@0.11.0", "", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="], + + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], + + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.12.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "^1.20260115.0" }, "optionalPeers": ["workerd"] }, "sha512-tP/Wi+40aBJovonSNJSsS7aFJY0xjuckKplmzDs2Xat06BJ68B6iG7YDUWXJL8gNn0gqW7YC5WhlYhO3QbugQA=="], + + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260210.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-e3vMgzr8ZM6VjpJVFrnMBhjvFhlMIkhT+BLpBk3pKaWsrXao+azDlmzzxB3Zf4CZ8LmCEtaP7n5d2mNGL6Dqww=="], + + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260210.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ng2uLJVMrI5VrcAS26gDGM+qxCuWD4ZA8VR4i88RdyM8TLn+AqPFisrvn7AMA+QSv0+ck+ZdFtXek7qNp2gNuA=="], + + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260210.0", "", { "os": "linux", "cpu": "x64" }, "sha512-frn2/+6DV59h13JbGSk9ATvJw3uORWssFIKZ/G/to+WRrIDQgCpSrjLtGbFSSn5eBEhYOvwxPKc7IrppkmIj/w=="], + + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260210.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0fmxEHaDcAF+7gcqnBcQdBCOzNvGz3mTMwqxEYJc5xZgFwQf65/dYK5fnV8z56GVNqu88NEnLMG3DD2G7Ey1vw=="], + + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260210.0", "", { "os": "win32", "cpu": "x64" }, "sha512-G/Apjk/QLNnwbu8B0JO9FuAJKHNr+gl8X3G/7qaUrpwIkPx5JFQElVE6LKk4teSrycvAy5AzLFAL0lOB1xsUIQ=="], + + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260210.0", "", {}, "sha512-zHaF0RZVYUQwNCJCECnNAJdMur72Lk3FMiD6wU78Dx3Bv7DQRcuXNmPNuJmsGnosVZCcWintHlPTQ/4BEiDG5w=="], + "@convex-dev/migrations": ["@convex-dev/migrations@0.3.1", "", { "peerDependencies": { "convex": "^1.24.8" } }, "sha512-dGiN82vWMN415/AGMcVooDOJk8iTousZahBBs27uA03pL74Uo3ksucUW3CiCfPx75rv+x9UnenEakimQYYNIrA=="], "@convex-dev/stripe": ["@convex-dev/stripe@0.1.3", "", { "dependencies": { "stripe": "^20.0.0" }, "peerDependencies": { "convex": "^1.29.3", "react": "^18.3.1 || ^19.0.0" } }, "sha512-yL0ygrWrjto9f7rprCb4KYp+p3Oka7KG5e3HBoJ9qSzbncpavZI+JTkGZnLCYZjPOo2ByCrBJvtGAxfP169Nzw=="], @@ -294,6 +333,8 @@ "@convex-dev/workpool": ["@convex-dev/workpool@0.2.19", "", { "peerDependencies": { "convex": ">=1.25.0 <1.35.0", "convex-helpers": "^0.1.94" } }, "sha512-U2KwYnsKILyxW1baWEhDv+ZtnL5FZbYFxBT5owQ0Lw/kseiudMZraA4clH+/6gowHSahWpkq4wndhcOfpfhuOA=="], + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + "@discordjs/builders": ["@discordjs/builders@1.13.1", "", { "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.33", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w=="], "@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="], @@ -400,6 +441,8 @@ "@executor/convex": ["@executor/convex@workspace:executor/convex"], + "@executor/sandbox-host": ["@executor/sandbox-host@workspace:executor/packages/sandbox-host"], + "@executor/web": ["@executor/web@workspace:executor/apps/web"], "@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="], @@ -496,7 +539,7 @@ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], "@mariozechner/pi-ai": ["@mariozechner/pi-ai@0.52.8", "", { "dependencies": { "@anthropic-ai/sdk": "^0.73.0", "@aws-sdk/client-bedrock-runtime": "^3.983.0", "@google/genai": "^1.40.0", "@mistralai/mistralai": "1.10.0", "@sinclair/typebox": "^0.34.41", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "chalk": "^5.6.2", "openai": "6.10.0", "partial-json": "^0.1.7", "proxy-agent": "^6.5.0", "undici": "^7.19.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-+aFCUbKJcskDJhr9wPcMBTy0x/xWio5v1dkxRYXUBPWp+Zt9DSdT5Kmd/IIQ+a0TOZDF4ajt4GY/oAw37X7XTw=="], @@ -576,6 +619,14 @@ "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], + + "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], + + "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + + "@proto/sources": ["@proto/sources@workspace:sources"], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], @@ -828,6 +879,8 @@ "@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="], + "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], "@smithy/abort-controller": ["@smithy/abort-controller@4.2.8", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw=="], @@ -920,6 +973,8 @@ "@smithy/uuid": ["@smithy/uuid@1.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw=="], + "@speed-highlight/core": ["@speed-highlight/core@1.2.14", "", {}, "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@streamdown/code": ["@streamdown/code@1.0.1", "", { "dependencies": { "shiki": "^3.19.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-U9LITfQ28tZYAoY922jdtw1ryg4kgRBdURopqK9hph7G2fBUwPeHthjH7SvaV0fvFv7EqjqCzARJuWUljLe9Ag=="], @@ -1208,10 +1263,14 @@ "basic-ftp": ["basic-ftp@5.1.0", "", {}, "sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw=="], + "better-result": ["better-result@2.7.0", "", { "dependencies": { "@clack/prompts": "^0.11.0" }, "bin": { "better-result": "bin/cli.mjs" } }, "sha512-7zrmXjAK8u8Z6SOe4R65XObOR5X+Y2I/VVku3t5cPOGQ8/WsBcfFmfnIPiEl5EBMDOzPHRwbiPbMtQBKYdw7RA=="], + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], "birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="], + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "bowser": ["bowser@2.13.1", "", {}, "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw=="], @@ -1394,7 +1453,7 @@ "electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="], - "elysia": ["elysia@1.4.23", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-mFIT/hEnNfrfbjGRUqunLNcURJfSXpKY4j+EWr4vP6Eoulf7feqs0WQLZwlgFZCxhdyfu0mrypIZ4mNJcEVVlQ=="], + "elysia": ["elysia@1.4.24", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-cgMUPXzZYZGafj2VePFnckVPZ8xJKbZNOgv4hlM2nW96cPSG1BL3y2KNRrKmmA2hKEuUw0tvHVwG//pMqSZ5Jw=="], "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], @@ -1410,6 +1469,8 @@ "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], + "es-abstract": ["es-abstract@1.24.1", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], @@ -1994,6 +2055,8 @@ "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + "miniflare": ["miniflare@4.20260210.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.18.2", "workerd": "1.20260210.0", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-HXR6m53IOqEzq52DuGF1x7I1K6lSIqzhbCbQXv/cTmPnPJmNkr7EBtLDm4nfSkOvlDtnwDCLUjWII5fyGJI5Tw=="], + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -2474,6 +2537,8 @@ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], + "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], @@ -2546,6 +2611,10 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "workerd": ["workerd@1.20260210.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260210.0", "@cloudflare/workerd-darwin-arm64": "1.20260210.0", "@cloudflare/workerd-linux-64": "1.20260210.0", "@cloudflare/workerd-linux-arm64": "1.20260210.0", "@cloudflare/workerd-windows-64": "1.20260210.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-Sb0WXhrvf+XHQigP2trAxQnXo7wxZFC4PWnn6I7LhFxiTvzxvOAqMEiLkIz58wggRCb54T/KAA8hdjkTniR5FA=="], + + "wrangler": ["wrangler@4.64.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.12.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260210.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260210.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260210.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-0PBiVEbshQT4Av/KLHbOAks4ioIKp/eAO7Xr2BgAX5v7cFYYgeOvudBrbtZa/hDDIA6858QuJnTQ8mI+cm8Vqw=="], + "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -2572,6 +2641,10 @@ "yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="], + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], + + "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], @@ -2580,10 +2653,16 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@assistant/bot/@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + + "@assistant/server/@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + "@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "@convex-dev/workos/@workos-inc/authkit-react": ["@workos-inc/authkit-react@0.11.0", "", { "dependencies": { "@workos-inc/authkit-js": "0.13.0" }, "peerDependencies": { "react": ">=17" } }, "sha512-67HFSxP4wXC8ECGyvc1yGMwuD5NGkwT2OPt8DavHoKAlO+hRaAlu9wwzqUx1EJrHht0Dcx+l20Byq8Ab0bEhlg=="], @@ -2612,12 +2691,18 @@ "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "@jridgewell/gen-mapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@jridgewell/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@mariozechner/pi-ai/undici": ["undici@7.21.0", "", {}, "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg=="], "@mistralai/mistralai/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@next/eslint-plugin-next/fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], + "@proto/sources/@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + "@redocly/openapi-core/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], "@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], @@ -2690,6 +2775,10 @@ "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "miniflare/undici": ["undici@7.18.2", "", {}, "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw=="], + + "miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], @@ -2728,6 +2817,8 @@ "vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + "wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + "wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -2738,6 +2829,10 @@ "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "@assistant/bot/@types/bun/bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + + "@assistant/server/@types/bun/bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], @@ -2768,6 +2863,8 @@ "@next/eslint-plugin-next/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "@proto/sources/@types/bun/bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + "@redocly/openapi-core/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], @@ -2790,6 +2887,58 @@ "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], diff --git a/executor/apps/web/src/components/code-editor.tsx b/executor/apps/web/src/components/code-editor.tsx index c747ca8c4..844c4976f 100644 --- a/executor/apps/web/src/components/code-editor.tsx +++ b/executor/apps/web/src/components/code-editor.tsx @@ -116,20 +116,62 @@ function countAllTools(node: NamespaceNode): number { /** TS helper types for OpenAPI indexed access (same as typechecker.ts) */ const OPENAPI_HELPER_TYPES = ` -type _OrEmpty = [T] extends [never] ? {} : T; +type _Normalize = Exclude; +type _OrEmpty = [_Normalize] extends [never] ? {} : _Normalize; type _Simplify = { [K in keyof T]: T[K] } & {}; +type _ParamsOf = + Op extends { parameters: infer P } ? P : + Op extends { parameters?: infer P } ? P : + never; +type _ParamAt = + _ParamsOf extends { [P in K]?: infer V } ? V : never; +type _BodyOf = + Op extends { requestBody?: infer B } ? B : + Op extends { requestBody: infer B } ? B : + never; +type _BodyContent = + B extends { content: infer C } + ? C extends Record ? V : never + : never; type ToolInput = _Simplify< - _OrEmpty & - _OrEmpty & - _OrEmpty + _OrEmpty<_ParamAt> & + _OrEmpty<_ParamAt> & + _OrEmpty<_ParamAt> & + _OrEmpty<_ParamAt> & + _OrEmpty<_BodyContent<_BodyOf>> >; -type ToolOutput = - Op extends { responses: { 200: { content: { "application/json": infer R } } } } ? R : - Op extends { responses: { 201: { content: { "application/json": infer R } } } } ? R : - Op extends { responses: { 202: { content: { "application/json": infer R } } } } ? R : - Op extends { responses: { 204: unknown } } ? void : - Op extends { responses: { 205: unknown } } ? void : - unknown; +type _ResponsesOf = Op extends { responses: infer R } ? R : never; +type _RespAt = + _ResponsesOf extends { [K in Code]?: infer R } ? R : never; +type _ResponsePayload = + [R] extends [never] ? never : + R extends { content: infer C } + ? C extends Record ? V : unknown + : R extends { schema: infer S } ? S : unknown; +type _HasStatus = + [_RespAt] extends [never] ? false : true; +type _PayloadAt = + Code extends 204 | 205 + ? (_HasStatus extends true ? void : never) + : _ResponsePayload<_RespAt>; +type _FirstKnown = + T extends readonly [infer H, ...infer Rest] + ? [H] extends [never] ? _FirstKnown : H + : unknown; +type ToolOutput = _FirstKnown<[ + _PayloadAt, + _PayloadAt, + _PayloadAt, + _PayloadAt, + _PayloadAt, + _PayloadAt, + _PayloadAt, + _PayloadAt, + _PayloadAt, + _PayloadAt, + _PayloadAt, + unknown +]>; `; function generateToolsDts(tools: ToolDescriptor[], dtsSources: Set): string { diff --git a/executor/apps/web/src/components/tools-view.tsx b/executor/apps/web/src/components/tools-view.tsx index 5800469b3..df51a3bc9 100644 --- a/executor/apps/web/src/components/tools-view.tsx +++ b/executor/apps/web/src/components/tools-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { Wrench, Plus, @@ -11,6 +11,7 @@ import { AlertTriangle, KeyRound, Pencil, + Loader2, } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -56,158 +57,27 @@ import type { import { parse as parseDomain } from "tldts"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; +import { Streamdown } from "streamdown"; -// ── API Presets ── - -interface ApiPreset { +interface CatalogCollectionItem { + id: string; name: string; - label: string; - description: string; - type: "openapi" | "mcp" | "graphql"; - spec?: string; - url?: string; - endpoint?: string; - baseUrl?: string; - authNote?: string; + summary: string; + specUrl: string; + originUrl?: string; + providerName: string; + logoUrl?: string; + categories?: string; + version?: string; } -const API_PRESETS: ApiPreset[] = [ - { - name: "github", - label: "GitHub", - description: "Repos, issues, PRs, actions, users, orgs", - type: "openapi", - spec: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml", - baseUrl: "https://api.github.com", - authNote: "Add a bearer credential with a PAT for authenticated access", - }, - { - name: "vercel", - label: "Vercel", - description: "Deployments, projects, domains, env vars, teams", - type: "openapi", - spec: "https://openapi.vercel.sh", - baseUrl: "https://api.vercel.com", - authNote: "Requires API token as bearer credential", - }, - { - name: "slack", - label: "Slack", - description: "Messages, channels, users, reactions, files", - type: "openapi", - spec: "https://api.slack.com/specs/openapi/v2/slack_web.json", - baseUrl: "https://slack.com/api", - authNote: "Requires a bot token as bearer credential", - }, - { - name: "discord", - label: "Discord", - description: "Guilds, channels, messages, interactions, webhooks", - type: "openapi", - spec: "https://raw.githubusercontent.com/discord/discord-api-spec/main/specs/openapi.json", - baseUrl: "https://discord.com/api/v10", - authNote: "Requires a bot token as bearer credential", - }, - { - name: "stripe", - label: "Stripe", - description: "Payments, customers, subscriptions, invoices", - type: "openapi", - spec: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json", - baseUrl: "https://api.stripe.com", - authNote: "Requires API key as bearer credential", - }, - - { - name: "openai", - label: "OpenAI", - description: "Chat completions, embeddings, images, files", - type: "openapi", - spec: "https://app.stainless.com/api/spec/documented/openai/openapi.documented.yml", - baseUrl: "https://api.openai.com", - authNote: "Requires API key as bearer credential", - }, - { - name: "cloudflare", - label: "Cloudflare", - description: "DNS, zones, workers, KV, R2, firewall", - type: "openapi", - spec: "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml", - baseUrl: "https://api.cloudflare.com/client/v4", - authNote: "Requires API token as bearer credential", - }, - { - name: "sentry", - label: "Sentry", - description: "Issues, events, projects, releases, alerts", - type: "openapi", - spec: "https://raw.githubusercontent.com/getsentry/sentry-api-schema/refs/heads/main/openapi-derefed.json", - baseUrl: "https://sentry.io/api/0", - authNote: "Requires auth token as bearer credential", - }, - { - name: "jira", - label: "Jira", - description: "Issues, projects, boards, sprints, users", - type: "openapi", - spec: "https://developer.atlassian.com/cloud/jira/platform/swagger-v3.v3.json", - baseUrl: "https://your-domain.atlassian.net/rest/api/3", - authNote: "Requires API token with basic auth (email:token)", - }, - { - name: "pagerduty", - label: "PagerDuty", - description: "Incidents, services, schedules, escalations", - type: "openapi", - spec: "https://raw.githubusercontent.com/PagerDuty/api-schema/main/reference/REST/openapiv3.json", - baseUrl: "https://api.pagerduty.com", - authNote: "Requires API key as bearer credential", - }, - { - name: "digitalocean", - label: "DigitalOcean", - description: "Droplets, databases, domains, apps, spaces", - type: "openapi", - spec: "https://api-engineering.nyc3.cdn.digitaloceanspaces.com/spec-ci/DigitalOcean-public.v2.yaml", - baseUrl: "https://api.digitalocean.com", - authNote: "Requires API token as bearer credential", - }, - { - name: "twilio", - label: "Twilio", - description: "SMS, calls, conversations, verify, phone numbers", - type: "openapi", - spec: "https://raw.githubusercontent.com/twilio/twilio-oai/main/spec/json/twilio_api_v2010.json", - baseUrl: "https://api.twilio.com", - authNote: "Requires Account SID + Auth Token as basic auth", - }, - { - name: "notion", - label: "Notion", - description: "Pages, databases, blocks, search, users", - type: "openapi", - spec: "https://developers.notion.com/openapi.json", - baseUrl: "https://api.notion.com", - authNote: "Requires integration token as bearer credential", - }, - - { - name: "resend", - label: "Resend", - description: "Send emails, manage domains, API keys", - type: "openapi", - spec: "https://raw.githubusercontent.com/resend/resend-openapi/main/resend.yaml", - baseUrl: "https://api.resend.com", - }, - { - name: "linear", - label: "Linear", - description: "Issues, projects, teams, cycles, labels", - type: "graphql", - endpoint: "https://api.linear.app/graphql", - authNote: "Requires API key as bearer credential", - }, -]; +interface CatalogCollectionsResponse { + items?: CatalogCollectionItem[]; + totalCount?: number; + hasMore?: boolean; + error?: string; + detail?: string; +} /** Derive a favicon URL from any URL string via Google's favicon service. */ function faviconForUrl(url: string | undefined | null): string | null { @@ -220,16 +90,6 @@ function faviconForUrl(url: string | undefined | null): string | null { } } -function getFaviconUrl(preset: ApiPreset): string | null { - if (preset.type === "openapi") { - return faviconForUrl(preset.baseUrl ?? null); - } - if (preset.type === "graphql") { - return faviconForUrl(preset.endpoint ?? null); - } - return faviconForUrl(preset.url ?? null); -} - function getSourceFavicon(source: ToolSourceRecord): string | null { if (source.type === "mcp") { return faviconForUrl((source.config.url as string) ?? null); @@ -237,7 +97,30 @@ function getSourceFavicon(source: ToolSourceRecord): string | null { if (source.type === "graphql") { return faviconForUrl((source.config.endpoint as string) ?? null); } - return faviconForUrl((source.config.baseUrl as string) ?? null); + const spec = source.config.spec as string | undefined; + if (typeof spec === "string" && spec.startsWith("postman:")) { + return null; + } + const baseUrl = source.config.baseUrl as string | undefined; + const collectionUrl = source.config.collectionUrl as string | undefined; + const specUrl = typeof spec === "string" && spec.startsWith("http") ? spec : null; + return faviconForUrl(baseUrl ?? collectionUrl ?? specUrl); +} + +function sourceEndpointLabel(source: ToolSourceRecord): string { + if (source.type === "mcp") return (source.config.url as string) ?? ""; + if (source.type === "graphql") return (source.config.endpoint as string) ?? ""; + + const spec = source.config.spec; + if (typeof spec === "string" && spec.startsWith("postman:")) { + const uid = spec.slice("postman:".length).trim(); + if (uid.length > 0) { + return `catalog:${uid}`; + } + return "catalog:collection"; + } + + return (source.config.spec as string) ?? ""; } function sourceKeyForSource(source: ToolSourceRecord): string | null { @@ -366,6 +249,39 @@ function inferNameFromUrl(url: string): string { } } +function sanitizeSourceName(value: string): string { + const slug = value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 48); + return slug || "source"; +} + +function withUniqueSourceName(baseName: string, takenNames: Set): string { + const loweredTaken = new Set([...takenNames].map((name) => name.toLowerCase())); + const candidate = sanitizeSourceName(baseName); + if (!loweredTaken.has(candidate.toLowerCase())) { + return candidate; + } + + let suffix = 2; + while (true) { + const next = `${candidate}-${suffix}`; + if (!loweredTaken.has(next.toLowerCase())) { + return next; + } + suffix += 1; + } +} + +function catalogSourceName(item: CatalogCollectionItem): string { + const owner = sanitizeSourceName(item.providerName || "catalog"); + const title = sanitizeSourceName(item.name); + return sanitizeSourceName(`${owner}-${title}`); +} + function AddSourceDialog({ existingSourceNames, }: { @@ -374,7 +290,7 @@ function AddSourceDialog({ const { context } = useSession(); const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource); const [open, setOpen] = useState(false); - const [presetsOpen, setPresetsOpen] = useState(false); + const [view, setView] = useState<"catalog" | "custom">("catalog"); const [type, setType] = useState<"mcp" | "openapi" | "graphql">("mcp"); const [name, setName] = useState(""); const [nameManuallyEdited, setNameManuallyEdited] = useState(false); @@ -383,7 +299,35 @@ function AddSourceDialog({ const [mcpTransport, setMcpTransport] = useState<"auto" | "streamable-http" | "sse">("auto"); const [mcpActorQueryParamKey, setMcpActorQueryParamKey] = useState("userId"); const [submitting, setSubmitting] = useState(false); - const [addingPreset, setAddingPreset] = useState(null); + const [locallyReservedNames, setLocallyReservedNames] = useState([]); + const [catalogQuery, setCatalogQuery] = useState(""); + const [catalogSort, setCatalogSort] = useState<"popular" | "recent">("popular"); + const [catalogItems, setCatalogItems] = useState([]); + const [catalogOffset, setCatalogOffset] = useState(0); + const [catalogHasMore, setCatalogHasMore] = useState(true); + const [catalogTotalCount, setCatalogTotalCount] = useState(null); + const [catalogLoading, setCatalogLoading] = useState(false); + const [catalogLoadingMore, setCatalogLoadingMore] = useState(false); + const [catalogError, setCatalogError] = useState(null); + const [addingCatalogId, setAddingCatalogId] = useState(null); + const catalogRequestIdRef = useRef(0); + const catalogInFlightRef = useRef(false); + + const CATALOG_PAGE_SIZE = 20; + + const getTakenSourceNames = () => new Set([...existingSourceNames, ...locallyReservedNames]); + + const reserveSourceName = (sourceName: string) => { + setLocallyReservedNames((current) => + current.includes(sourceName) + ? current + : [...current, sourceName] + ); + }; + + const getUniqueAutoSourceName = (candidate: string) => { + return withUniqueSourceName(candidate, getTakenSourceNames()); + }; const handleEndpointChange = (value: string) => { setEndpoint(value); @@ -398,20 +342,131 @@ function AddSourceDialog({ setNameManuallyEdited(true); }; - const resetForm = () => { + const resetDialogState = () => { + catalogRequestIdRef.current += 1; + catalogInFlightRef.current = false; + setView("catalog"); + setType("mcp"); setName(""); setEndpoint(""); setBaseUrl(""); setMcpTransport("auto"); setMcpActorQueryParamKey("userId"); setNameManuallyEdited(false); - setPresetsOpen(false); - setAddingPreset(null); + setLocallyReservedNames([]); + setCatalogQuery(""); + setCatalogSort("popular"); + setCatalogItems([]); + setCatalogOffset(0); + setCatalogHasMore(true); + setCatalogTotalCount(null); + setCatalogLoading(false); + setCatalogLoadingMore(false); + setCatalogError(null); + setAddingCatalogId(null); + }; + + const loadCatalogPage = async ({ + mode, + query, + sort, + }: { + mode: "reset" | "next"; + query?: string; + sort?: "popular" | "recent"; + }) => { + const resolvedQuery = (query ?? catalogQuery).trim(); + const resolvedSort = sort ?? catalogSort; + const nextOffset = mode === "reset" ? 0 : catalogOffset; + + if (mode === "next") { + if (catalogLoading || catalogLoadingMore || catalogInFlightRef.current || !catalogHasMore) { + return; + } + setCatalogLoadingMore(true); + } else { + setCatalogLoading(true); + setCatalogLoadingMore(false); + setCatalogError(null); + } + + const requestId = catalogRequestIdRef.current + 1; + catalogRequestIdRef.current = requestId; + catalogInFlightRef.current = true; + + try { + const params = new URLSearchParams({ + sort: resolvedSort, + limit: String(CATALOG_PAGE_SIZE), + offset: String(nextOffset), + }); + if (resolvedQuery.length > 0) { + params.set("q", resolvedQuery); + } + + const catalogBase = process.env.NEXT_PUBLIC_SOURCES_URL ?? "http://127.0.0.1:4343"; + const response = await fetch(`${catalogBase}/collections?${params.toString()}`, { + cache: "no-store", + }); + const body = await response.json() as CatalogCollectionsResponse; + + if (requestId !== catalogRequestIdRef.current) { + return; + } + + if (!response.ok) { + throw new Error(body.error || "Failed to load API catalog"); + } + + const nextItems = Array.isArray(body.items) ? body.items : []; + setCatalogItems((current) => { + if (mode === "reset") { + return nextItems; + } + + const merged = [...current]; + const seen = new Set(current.map((entry) => entry.id)); + for (const item of nextItems) { + if (seen.has(item.id)) continue; + seen.add(item.id); + merged.push(item); + } + return merged; + }); + setCatalogOffset(nextOffset + nextItems.length); + setCatalogHasMore(typeof body.hasMore === "boolean" ? body.hasMore : nextItems.length >= CATALOG_PAGE_SIZE); + setCatalogTotalCount(typeof body.totalCount === "number" ? body.totalCount : null); + setCatalogError(null); + } catch (error) { + if (requestId !== catalogRequestIdRef.current) { + return; + } + + if (mode === "reset") { + setCatalogItems([]); + setCatalogOffset(0); + } + setCatalogHasMore(false); + setCatalogTotalCount(null); + setCatalogError(error instanceof Error ? error.message : "Failed to load API catalog"); + } finally { + if (requestId === catalogRequestIdRef.current) { + setCatalogLoading(false); + setCatalogLoadingMore(false); + catalogInFlightRef.current = false; + } + } }; const handleOpenChange = (isOpen: boolean) => { setOpen(isOpen); - if (!isOpen) resetForm(); + if (!isOpen) { + resetDialogState(); + return; + } + + resetDialogState(); + void loadCatalogPage({ mode: "reset", query: "", sort: "popular" }); }; const addSource = async ( @@ -430,31 +485,35 @@ function AddSourceDialog({ toast.success(`Source "${sourceName}" added — loading tools…`); }; - const handlePresetAdd = async (preset: ApiPreset) => { - setAddingPreset(preset.name); + const handleCatalogAdd = async (item: CatalogCollectionItem) => { + if (!item.specUrl.trim()) { + toast.error("Missing OpenAPI spec URL for this API source"); + return; + } + + setAddingCatalogId(item.id); try { - const config: Record = - preset.type === "mcp" - ? { url: preset.url } - : preset.type === "graphql" - ? { endpoint: preset.endpoint } - : { - spec: preset.spec, - ...(preset.baseUrl ? { baseUrl: preset.baseUrl } : {}), - }; - await addSource(preset.name, preset.type, config); - if (preset.authNote) { - toast.info(preset.authNote, { duration: 6000 }); - } - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to add source"); + const sourceName = getUniqueAutoSourceName(catalogSourceName(item)); + await addSource(sourceName, "openapi", { + spec: item.specUrl, + }); + reserveSourceName(sourceName); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to add API source"); } finally { - setAddingPreset(null); + setAddingCatalogId(null); } }; const handleCustomSubmit = async () => { if (!context || !name.trim() || !endpoint.trim()) return; + + const takenNames = [...getTakenSourceNames()].map((entry) => entry.toLowerCase()); + if (takenNames.includes(name.trim().toLowerCase())) { + toast.error(`Source name "${name.trim()}" already exists`); + return; + } + setSubmitting(true); try { const config: Record = @@ -470,7 +529,8 @@ function AddSourceDialog({ ? { endpoint: endpoint } : { spec: endpoint, ...(baseUrl ? { baseUrl } : {}) }; await addSource(name.trim(), type, config); - resetForm(); + reserveSourceName(name.trim()); + resetDialogState(); setOpen(false); } catch (err) { toast.error(err instanceof Error ? err.message : "Failed to add source"); @@ -495,180 +555,261 @@ function AddSourceDialog({
- {/* Custom source form — always visible */} -
-
- - setCatalogQuery(event.target.value)} + placeholder="Search APIs" + className="h-8 text-xs font-mono bg-background flex-1 min-w-[150px]" + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void loadCatalogPage({ mode: "reset" }); + } + }} + /> + + +
+ +

+ Browse API collections and add them as tool sources. + {catalogTotalCount !== null ? ` Found ${catalogTotalCount.toLocaleString()} total.` : ""} +

+ + + +
{ + const target = event.currentTarget; + const nearBottom = target.scrollHeight - target.scrollTop - target.clientHeight < 120; + if (nearBottom) { + void loadCatalogPage({ mode: "next" }); + } + }} > - - - - - - MCP Server - - - OpenAPI Spec - - - GraphQL - - - -
-
- - handleEndpointChange(e.target.value)} - placeholder={ - type === "mcp" - ? "https://mcp-server.example.com/sse" - : type === "graphql" - ? "https://api.example.com/graphql" - : "https://api.example.com/openapi.json" - } - className="h-8 text-xs font-mono bg-background" - /> -
-
- - handleNameChange(e.target.value)} - placeholder="e.g. my-service" - className="h-8 text-xs font-mono bg-background" - /> + + + {catalogItems.map((item) => ( +
+ {item.logoUrl && ( + + )} +
+

{item.name}

+

+ {item.providerName} + {item.version ? ` · v${item.version}` : ""} +

+ {item.summary && ( +
+ {item.summary} +
+ )} +
+
+ +
+
+ ))} + + {catalogLoadingMore && ( +
+ + Loading more... +
+ )} + + {!catalogLoading && !catalogLoadingMore && catalogItems.length === 0 && !catalogError && ( +

+ No collections found for this query. +

+ )} + + {catalogError && ( +

+ {catalogError} +

+ )} + + {!catalogHasMore && catalogItems.length > 0 && ( +

+ End of results. +

+ )} +
- {type === "openapi" && ( + ) : ( +
+ + +
+ + +
+
setBaseUrl(e.target.value)} - placeholder="https://api.example.com" + value={endpoint} + onChange={(event) => handleEndpointChange(event.target.value)} + placeholder={ + type === "mcp" + ? "https://mcp-server.example.com/sse" + : type === "graphql" + ? "https://api.example.com/graphql" + : "https://api.example.com/openapi.json" + } className="h-8 text-xs font-mono bg-background" />
- )} - {type === "mcp" && ( - <> -
- - -
+ +
+ + handleNameChange(event.target.value)} + placeholder="e.g. my-service" + className="h-8 text-xs font-mono bg-background" + /> +
+ + {type === "openapi" && (
- + setMcpActorQueryParamKey(e.target.value)} - placeholder="userId" + value={baseUrl} + onChange={(event) => setBaseUrl(event.target.value)} + placeholder="https://api.example.com" className="h-8 text-xs font-mono bg-background" />
- - )} - -
+ )} - {/* Collapsible presets */} - - - - - Quick add from catalog - - {API_PRESETS.length} - - - -
- {API_PRESETS.map((preset) => { - const alreadyAdded = existingSourceNames.has(preset.name); - return ( - +
+ )}
@@ -918,11 +1059,7 @@ function SourceCard({ )} - {source.type === "mcp" - ? (source.config.url as string) - : source.type === "graphql" - ? (source.config.endpoint as string) - : (source.config.spec as string)} + {sourceEndpointLabel(source)} {source.type === "openapi" && quality && ( diff --git a/executor/convex/_generated/api.d.ts b/executor/convex/_generated/api.d.ts index be68d10b5..2931e33b3 100644 --- a/executor/convex/_generated/api.d.ts +++ b/executor/convex/_generated/api.d.ts @@ -276,6 +276,47 @@ export declare const api: { any >; }; + runtimeCallbacks: { + appendOutput: FunctionReference< + "mutation", + "public", + { + internalSecret: string; + line: string; + runId: string; + stream: "stdout" | "stderr"; + timestamp?: number; + }, + any + >; + completeRun: FunctionReference< + "mutation", + "public", + { + durationMs?: number; + error?: string; + exitCode?: number; + internalSecret: string; + runId: string; + status: "completed" | "failed" | "timed_out" | "denied"; + stderr?: string; + stdout?: string; + }, + any + >; + handleToolCall: FunctionReference< + "action", + "public", + { + callId: string; + input?: any; + internalSecret: string; + runId: string; + toolPath: string; + }, + any + >; + }; workspace: { bootstrapAnonymousSession: FunctionReference< "mutation", @@ -520,6 +561,18 @@ export declare const internal: { { sourceId: string; workspaceId: Id<"workspaces"> }, any >; + finishToolCall: FunctionReference< + "mutation", + "internal", + { + callId: string; + error?: string; + output?: any; + status: "completed" | "failed" | "denied"; + taskId: string; + }, + any + >; getActiveAnonymousOauthSigningKey: FunctionReference< "query", "internal", @@ -551,6 +604,12 @@ export declare const internal: { { taskId: string; workspaceId: Id<"workspaces"> }, any >; + getToolCall: FunctionReference< + "query", + "internal", + { callId: string; taskId: string }, + any + >; listAccessPolicies: FunctionReference< "query", "internal", @@ -598,6 +657,12 @@ export declare const internal: { { workspaceId: Id<"workspaces"> }, any >; + listToolCalls: FunctionReference< + "query", + "internal", + { taskId: string }, + any + >; listToolSources: FunctionReference< "query", "internal", @@ -651,6 +716,12 @@ export declare const internal: { }, any >; + setToolCallPendingApproval: FunctionReference< + "mutation", + "internal", + { approvalId: string; callId: string; taskId: string }, + any + >; storeAnonymousOauthSigningKey: FunctionReference< "mutation", "internal", @@ -690,6 +761,18 @@ export declare const internal: { }, any >; + upsertToolCallRequested: FunctionReference< + "mutation", + "internal", + { + callId: string; + input?: any; + taskId: string; + toolPath: string; + workspaceId: Id<"workspaces">; + }, + any + >; upsertToolSource: FunctionReference< "mutation", "internal", @@ -716,6 +799,20 @@ export declare const internal: { }, any >; + completeRuntimeRun: FunctionReference< + "mutation", + "internal", + { + durationMs?: number; + error?: string; + exitCode?: number; + runId: string; + status: "completed" | "failed" | "timed_out" | "denied"; + stderr?: string; + stdout?: string; + }, + any + >; createTaskInternal: FunctionReference< "mutation", "internal", diff --git a/executor/convex/database.ts b/executor/convex/database.ts index 2a1aab6ad..83596fcdb 100644 --- a/executor/convex/database.ts +++ b/executor/convex/database.ts @@ -16,6 +16,11 @@ const completedTaskStatusValidator = v.union( v.literal("denied"), ); const approvalStatusValidator = v.union(v.literal("pending"), v.literal("approved"), v.literal("denied")); +const terminalToolCallStatusValidator = v.union( + v.literal("completed"), + v.literal("failed"), + v.literal("denied"), +); const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny")); const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor")); const credentialProviderValidator = v.union( @@ -127,6 +132,23 @@ function mapApproval(doc: Doc<"approvals">) { }; } +function mapToolCall(doc: Doc<"toolCalls">) { + return { + taskId: doc.taskId, + callId: doc.callId, + workspaceId: doc.workspaceId, + toolPath: doc.toolPath, + input: doc.input, + status: doc.status, + approvalId: doc.approvalId, + output: doc.output, + error: doc.error, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + completedAt: doc.completedAt, + }; +} + function mapPolicy(doc: Doc<"accessPolicies">) { return { id: doc.policyId, @@ -309,6 +331,17 @@ async function getApprovalDoc(ctx: { db: QueryCtx["db"] }, approvalId: string) { .unique(); } +async function getToolCallDoc( + ctx: { db: QueryCtx["db"] }, + taskId: string, + callId: string, +) { + return await ctx.db + .query("toolCalls") + .withIndex("by_task_call", (q) => q.eq("taskId", taskId).eq("callId", callId)) + .unique(); +} + export const createTask = internalMutation({ args: { id: v.string(), @@ -391,6 +424,11 @@ export const listRuntimeTargets = internalQuery({ label: "Local JS Runtime", description: "Runs generated code in-process using Bun", }, + { + id: "cloudflare-worker-loader", + label: "Cloudflare Worker Loader", + description: "Runs generated code in a Cloudflare Worker", + } ]; }, }); @@ -441,6 +479,10 @@ export const markTaskFinished = internalMutation({ return null; } + if (doc.status === "completed" || doc.status === "failed" || doc.status === "timed_out" || doc.status === "denied") { + return mapTask(doc); + } + const now = Date.now(); await ctx.db.patch(doc._id, { status: args.status, @@ -607,6 +649,132 @@ export const getApprovalInWorkspace = internalQuery({ }, }); +export const upsertToolCallRequested = internalMutation({ + args: { + taskId: v.string(), + callId: v.string(), + workspaceId: v.id("workspaces"), + toolPath: v.string(), + input: v.optional(v.any()), + }, + handler: async (ctx, args) => { + const existing = await getToolCallDoc(ctx, args.taskId, args.callId); + if (existing) { + return mapToolCall(existing); + } + + const now = Date.now(); + await ctx.db.insert("toolCalls", { + taskId: args.taskId, + callId: args.callId, + workspaceId: args.workspaceId, + toolPath: args.toolPath, + input: args.input ?? {}, + status: "requested", + createdAt: now, + updatedAt: now, + }); + + const created = await getToolCallDoc(ctx, args.taskId, args.callId); + if (!created) { + throw new Error(`Failed to create tool call ${args.taskId}/${args.callId}`); + } + return mapToolCall(created); + }, +}); + +export const getToolCall = internalQuery({ + args: { + taskId: v.string(), + callId: v.string(), + }, + handler: async (ctx, args) => { + const doc = await getToolCallDoc(ctx, args.taskId, args.callId); + return doc ? mapToolCall(doc) : null; + }, +}); + +export const setToolCallPendingApproval = internalMutation({ + args: { + taskId: v.string(), + callId: v.string(), + approvalId: v.string(), + }, + handler: async (ctx, args) => { + const doc = await getToolCallDoc(ctx, args.taskId, args.callId); + if (!doc) { + throw new Error(`Tool call not found: ${args.taskId}/${args.callId}`); + } + + if (doc.status === "completed" || doc.status === "failed" || doc.status === "denied") { + return mapToolCall(doc); + } + + const now = Date.now(); + await ctx.db.patch(doc._id, { + status: "pending_approval", + approvalId: args.approvalId, + updatedAt: now, + }); + + const updated = await getToolCallDoc(ctx, args.taskId, args.callId); + if (!updated) { + throw new Error(`Failed to read tool call ${args.taskId}/${args.callId}`); + } + return mapToolCall(updated); + }, +}); + +export const finishToolCall = internalMutation({ + args: { + taskId: v.string(), + callId: v.string(), + status: terminalToolCallStatusValidator, + output: v.optional(v.any()), + error: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const doc = await getToolCallDoc(ctx, args.taskId, args.callId); + if (!doc) { + throw new Error(`Tool call not found: ${args.taskId}/${args.callId}`); + } + + if (doc.status === "completed" || doc.status === "failed" || doc.status === "denied") { + return mapToolCall(doc); + } + + const now = Date.now(); + await ctx.db.patch(doc._id, { + status: args.status, + output: args.output, + error: args.error, + updatedAt: now, + completedAt: now, + }); + + const updated = await getToolCallDoc(ctx, args.taskId, args.callId); + if (!updated) { + throw new Error(`Failed to read tool call ${args.taskId}/${args.callId}`); + } + return mapToolCall(updated); + }, +}); + +export const listToolCalls = internalQuery({ + args: { + taskId: v.string(), + }, + handler: async (ctx, args) => { + const docs = await ctx.db + .query("toolCalls") + .withIndex("by_task_created", (q) => q.eq("taskId", args.taskId)) + .order("asc") + .collect(); + + return docs.map(mapToolCall); + }, +}); + export const bootstrapAnonymousSession = internalMutation({ args: { sessionId: v.optional(v.string()) }, handler: async (ctx, args) => { diff --git a/executor/convex/executor.ts b/executor/convex/executor.ts index 8c2bdf211..9225df7f4 100644 --- a/executor/convex/executor.ts +++ b/executor/convex/executor.ts @@ -22,6 +22,14 @@ async function publishTaskEvent( await ctx.runMutation(internal.database.createTaskEvent, input); } +function terminalEventForStatus(status: "completed" | "failed" | "timed_out" | "denied"): + "task.completed" | "task.failed" | "task.timed_out" | "task.denied" { + if (status === "completed") return "task.completed"; + if (status === "timed_out") return "task.timed_out"; + if (status === "denied") return "task.denied"; + return "task.failed"; +} + async function createTaskRecord( ctx: MutationCtx, args: { @@ -236,3 +244,54 @@ export const appendRuntimeOutput = internalMutation({ return { ok: true as const }; }, }); + +export const completeRuntimeRun = internalMutation({ + args: { + runId: v.string(), + status: v.union(v.literal("completed"), v.literal("failed"), v.literal("timed_out"), v.literal("denied")), + stdout: v.optional(v.string()), + stderr: v.optional(v.string()), + exitCode: v.optional(v.number()), + error: v.optional(v.string()), + durationMs: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const task = (await ctx.runQuery(internal.database.getTask, { taskId: args.runId })) as TaskRecord | null; + if (!task) { + return { ok: false as const, error: `Run not found: ${args.runId}` }; + } + + if (task.status === "completed" || task.status === "failed" || task.status === "timed_out" || task.status === "denied") { + return { ok: true as const, alreadyFinal: true as const, task }; + } + + const finished = await ctx.runMutation(internal.database.markTaskFinished, { + taskId: args.runId, + status: args.status, + stdout: args.stdout ?? "", + stderr: args.stderr ?? "", + exitCode: args.exitCode, + error: args.error, + }); + + if (!finished) { + return { ok: false as const, error: `Failed to mark run finished: ${args.runId}` }; + } + + await publishTaskEvent(ctx, { + taskId: args.runId, + eventName: "task", + type: terminalEventForStatus(args.status), + payload: { + taskId: args.runId, + status: finished.status, + exitCode: finished.exitCode, + durationMs: args.durationMs, + error: finished.error, + completedAt: finished.completedAt, + }, + }); + + return { ok: true as const, alreadyFinal: false as const, task: finished }; + }, +}); diff --git a/executor/convex/executorNode.ts b/executor/convex/executorNode.ts index 52f29760f..24707a224 100644 --- a/executor/convex/executorNode.ts +++ b/executor/convex/executorNode.ts @@ -7,9 +7,9 @@ import { action, internalAction } from "./_generated/server"; import type { ActionCtx } from "./_generated/server"; import { InProcessExecutionAdapter } from "../lib/adapters/in_process_execution_adapter"; import { resolveCredentialPayload } from "../lib/credential_providers"; -import { APPROVAL_DENIED_PREFIX } from "../lib/execution_constants"; +import { APPROVAL_DENIED_PREFIX, APPROVAL_PENDING_PREFIX } from "../lib/execution_constants"; import { actorIdForAccount } from "../lib/identity"; -import { runCodeWithCloudflareWorkerLoader } from "../lib/runtimes/cloudflare_worker_loader_runtime"; +import { dispatchCodeWithCloudflareWorkerLoader } from "../lib/runtimes/cloudflare_worker_loader_runtime"; import { CLOUDFLARE_WORKER_LOADER_RUNTIME_ID, isCloudflareWorkerLoaderConfigured, @@ -41,6 +41,7 @@ import type { ResolvedToolCredential, TaskRecord, ToolCallRequest, + ToolCallRecord, ToolCallResult, ToolCredentialSpec, ToolDefinition, @@ -270,10 +271,6 @@ function normalizeExternalToolSource(raw: { return result; } -async function sleep(ms: number): Promise { - await new Promise((resolve) => setTimeout(resolve, ms)); -} - const baseTools = new Map(DEFAULT_TOOLS.map((tool) => [tool.path, tool])); interface DtsStorageEntry { sourceKey: string; @@ -300,21 +297,6 @@ async function publish( }); } -async function waitForApproval(ctx: ActionCtx, approvalId: string): Promise<"approved" | "denied"> { - while (true) { - const approval = await ctx.runQuery(internal.database.getApproval, { approvalId }); - if (!approval) { - throw new Error(`Approval ${approvalId} not found`); - } - - if (approval.status !== "pending") { - return approval.status as "approved" | "denied"; - } - - await sleep(500); - } -} - /** * Load a prepared OpenAPI spec, using Convex file storage as a persistent cache. * @@ -847,6 +829,26 @@ function getGraphqlDecision( async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallRequest): Promise { const { toolPath, input, callId } = call; + const persistedCall = (await ctx.runMutation(internal.database.upsertToolCallRequested, { + taskId: task.id, + callId, + workspaceId: task.workspaceId, + toolPath, + input, + })) as ToolCallRecord; + + if (persistedCall.status === "completed") { + return persistedCall.output; + } + + if (persistedCall.status === "failed") { + throw new Error(persistedCall.error ?? `Tool call failed: ${callId}`); + } + + if (persistedCall.status === "denied") { + throw new Error(`${APPROVAL_DENIED_PREFIX}${persistedCall.error ?? persistedCall.toolPath}`); + } + const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: task.workspaceId }); const typedPolicies = policies as AccessPolicyRecord[]; @@ -892,14 +894,23 @@ async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallReques decision = getToolDecision(task, tool, typedPolicies); } + const publishToolStarted = persistedCall.status === "requested"; + if (decision === "deny") { + const deniedMessage = `${effectiveToolPath} (policy denied)`; + await ctx.runMutation(internal.database.finishToolCall, { + taskId: task.id, + callId, + status: "denied", + error: deniedMessage, + }); await publish(ctx, task.id, "task", "tool.call.denied", { taskId: task.id, callId, toolPath: effectiveToolPath, reason: "policy_deny", }); - throw new Error(`${APPROVAL_DENIED_PREFIX}${effectiveToolPath} (policy denied)`); + throw new Error(`${APPROVAL_DENIED_PREFIX}${deniedMessage}`); } let credential: ResolvedToolCredential | undefined; @@ -911,40 +922,98 @@ async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallReques credential = resolved; } - await publish(ctx, task.id, "task", "tool.call.started", { - taskId: task.id, - callId, - toolPath: effectiveToolPath, - approval: decision === "require_approval" ? "required" : "auto", - input: asPayload(input), - }); - - if (decision === "require_approval") { - const approval = await ctx.runMutation(internal.database.createApproval, { - id: createApprovalId(), + if (publishToolStarted) { + await publish(ctx, task.id, "task", "tool.call.started", { taskId: task.id, + callId, toolPath: effectiveToolPath, - input, + approval: decision === "require_approval" ? "required" : "auto", + input: asPayload(input), + }); + } + + let approvalSatisfied = false; + if (persistedCall.approvalId) { + const existingApproval = await ctx.runQuery(internal.database.getApproval, { + approvalId: persistedCall.approvalId, }); + if (!existingApproval) { + throw new Error(`Approval ${persistedCall.approvalId} not found for call ${callId}`); + } - await publish(ctx, task.id, "approval", "approval.requested", { - approvalId: approval.id, + if (existingApproval.status === "pending") { + throw new Error(`${APPROVAL_PENDING_PREFIX}${existingApproval.id}`); + } + + if (existingApproval.status === "denied") { + const deniedMessage = `${effectiveToolPath} (${existingApproval.id})`; + await ctx.runMutation(internal.database.finishToolCall, { + taskId: task.id, + callId, + status: "denied", + error: deniedMessage, + }); + await publish(ctx, task.id, "task", "tool.call.denied", { + taskId: task.id, + callId, + toolPath: effectiveToolPath, + approvalId: existingApproval.id, + }); + throw new Error(`${APPROVAL_DENIED_PREFIX}${deniedMessage}`); + } + + approvalSatisfied = existingApproval.status === "approved"; + } + + if (decision === "require_approval" && !approvalSatisfied) { + const approvalId = persistedCall.approvalId ?? createApprovalId(); + let approval = await ctx.runQuery(internal.database.getApproval, { + approvalId, + }); + + if (!approval) { + approval = await ctx.runMutation(internal.database.createApproval, { + id: approvalId, + taskId: task.id, + toolPath: effectiveToolPath, + input, + }); + + await publish(ctx, task.id, "approval", "approval.requested", { + approvalId: approval.id, + taskId: task.id, + callId, + toolPath: approval.toolPath, + input: asPayload(approval.input), + createdAt: approval.createdAt, + }); + } + + await ctx.runMutation(internal.database.setToolCallPendingApproval, { taskId: task.id, callId, - toolPath: approval.toolPath, - input: asPayload(approval.input), - createdAt: approval.createdAt, + approvalId: approval.id, }); - const approvalDecision = await waitForApproval(ctx, approval.id); - if (approvalDecision === "denied") { + if (approval.status === "pending") { + throw new Error(`${APPROVAL_PENDING_PREFIX}${approval.id}`); + } + + if (approval.status === "denied") { + const deniedMessage = `${effectiveToolPath} (${approval.id})`; + await ctx.runMutation(internal.database.finishToolCall, { + taskId: task.id, + callId, + status: "denied", + error: deniedMessage, + }); await publish(ctx, task.id, "task", "tool.call.denied", { taskId: task.id, callId, toolPath: effectiveToolPath, approvalId: approval.id, }); - throw new Error(`${APPROVAL_DENIED_PREFIX}${effectiveToolPath} (${approval.id})`); + throw new Error(`${APPROVAL_DENIED_PREFIX}${deniedMessage}`); } } @@ -958,6 +1027,12 @@ async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallReques isToolAllowed: (path) => isToolAllowedForTask(task, path, workspaceTools ?? baseTools, typedPolicies), }; const value = await tool.run(input, context); + await ctx.runMutation(internal.database.finishToolCall, { + taskId: task.id, + callId, + status: "completed", + output: value, + }); await publish(ctx, task.id, "task", "tool.call.completed", { taskId: task.id, callId, @@ -967,6 +1042,12 @@ async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallReques return value; } catch (error) { const message = describeError(error); + await ctx.runMutation(internal.database.finishToolCall, { + taskId: task.id, + callId, + status: "failed", + error: message, + }); await publish(ctx, task.id, "task", "tool.call.failed", { taskId: task.id, callId, @@ -1058,6 +1139,7 @@ export const handleExternalToolCall = internalAction({ if (!task) { return { ok: false, + kind: "failed", error: `Run not found: ${args.runId}`, }; } @@ -1072,16 +1154,27 @@ export const handleExternalToolCall = internalAction({ return { ok: true, value }; } catch (error) { const message = describeError(error); + if (message.startsWith(APPROVAL_PENDING_PREFIX)) { + const approvalId = message.replace(APPROVAL_PENDING_PREFIX, "").trim(); + return { + ok: false, + kind: "pending", + approvalId, + retryAfterMs: 500, + error: "Approval pending", + }; + } if (message.startsWith(APPROVAL_DENIED_PREFIX)) { return { ok: false, - denied: true, + kind: "denied", error: message.replace(APPROVAL_DENIED_PREFIX, "").trim(), }; } return { ok: false, + kind: "failed", error: message, }; } @@ -1148,44 +1241,65 @@ export const runTask = internalAction({ startedAt: running.startedAt, }); - const runtimeResult = - running.runtimeId === LOCAL_BUN_RUNTIME_ID - ? await (async () => { - const adapter = new InProcessExecutionAdapter({ - runId: args.taskId, - invokeTool: async (call) => await invokeTool(ctx, running, call), - emitOutput: async (event) => { - await ctx.runMutation(internal.executor.appendRuntimeOutput, { - runId: event.runId, - stream: event.stream, - line: event.line, - timestamp: event.timestamp, - }); - }, - }); - - return await runCodeWithAdapter( - { - taskId: args.taskId, - code: running.code, - timeoutMs: running.timeoutMs, - }, - adapter, - ); - })() - : running.runtimeId === CLOUDFLARE_WORKER_LOADER_RUNTIME_ID - ? await runCodeWithCloudflareWorkerLoader({ - taskId: args.taskId, - code: running.code, - timeoutMs: running.timeoutMs, - }) - : { - status: "failed" as const, - stdout: "", - stderr: "", - error: `Runtime not found: ${running.runtimeId}`, - durationMs: 0, - }; + if (running.runtimeId === CLOUDFLARE_WORKER_LOADER_RUNTIME_ID) { + const dispatchResult = await dispatchCodeWithCloudflareWorkerLoader({ + taskId: args.taskId, + code: running.code, + timeoutMs: running.timeoutMs, + }); + + if (!dispatchResult.ok) { + const failed = await ctx.runMutation(internal.database.markTaskFinished, { + taskId: args.taskId, + status: "failed", + stdout: "", + stderr: "", + error: dispatchResult.error, + }); + + if (failed) { + await publish(ctx, args.taskId, "task", "task.failed", { + taskId: args.taskId, + status: failed.status, + error: failed.error, + completedAt: failed.completedAt, + }); + } + return null; + } + + await publish(ctx, args.taskId, "task", "task.dispatched", { + taskId: args.taskId, + runtimeId: running.runtimeId, + dispatchId: dispatchResult.dispatchId, + durationMs: dispatchResult.durationMs, + }); + return null; + } + + const runtimeResult = await (async () => { + const adapter = new InProcessExecutionAdapter({ + runId: args.taskId, + invokeTool: async (call) => await invokeTool(ctx, running, call), + emitOutput: async (event) => { + await ctx.runMutation(internal.executor.appendRuntimeOutput, { + runId: event.runId, + stream: event.stream, + line: event.line, + timestamp: event.timestamp, + }); + }, + }); + + return await runCodeWithAdapter( + { + taskId: args.taskId, + code: running.code, + timeoutMs: running.timeoutMs, + }, + adapter, + ); + })(); const finished = await ctx.runMutation(internal.database.markTaskFinished, { taskId: args.taskId, diff --git a/executor/convex/http.ts b/executor/convex/http.ts index ea86fb4ef..b6165479d 100644 --- a/executor/convex/http.ts +++ b/executor/convex/http.ts @@ -9,7 +9,6 @@ import type { AnonymousContext, PendingApprovalRecord, TaskRecord, ToolDescripto import type { Id } from "./_generated/dataModel"; const http = httpRouter(); -const internalToken = process.env.EXECUTOR_INTERNAL_TOKEN ?? null; function getMcpAuthorizationServer(): string | null { return process.env.MCP_AUTHORIZATION_SERVER @@ -121,28 +120,6 @@ function parseMcpContext(url: URL): { return { workspaceId, clientId, sessionId }; } -function isInternalAuthorized(request: Request): boolean { - if (!internalToken) return false; - const header = request.headers.get("authorization"); - if (!header || !header.startsWith("Bearer ")) return false; - return header.slice("Bearer ".length) === internalToken; -} - -function parseInternalRunPath(pathname: string): { runId: string; endpoint: "tool-call" | "output" } | null { - const parts = pathname.split("/").filter(Boolean); - if (parts.length !== 4 || parts[0] !== "internal" || parts[1] !== "runs") { - return null; - } - - const runId = parts[2]; - const endpoint = parts[3]; - if (!runId || (endpoint !== "tool-call" && endpoint !== "output")) { - return null; - } - - return { runId, endpoint }; -} - const mcpHandler = httpAction(async (ctx, request) => { const url = new URL(request.url); const mcpAuthConfig = getMcpAuthConfig(); @@ -313,62 +290,6 @@ const oauthAuthorizationServerProxyHandler = httpAction(async (_ctx, request) => }); }); -const internalRunsHandler = httpAction(async (ctx, request) => { - if (!isInternalAuthorized(request)) { - return Response.json({ error: "Unauthorized internal call" }, { status: 401 }); - } - - const url = new URL(request.url); - const parsed = parseInternalRunPath(url.pathname); - if (!parsed) { - return Response.json({ error: "Invalid internal route" }, { status: 404 }); - } - - let body: unknown; - try { - body = await request.json(); - } catch { - body = {}; - } - const payload = body && typeof body === "object" ? (body as Record) : {}; - - if (parsed.endpoint === "tool-call") { - const callId = payload.callId; - const toolPath = payload.toolPath; - if (typeof callId !== "string" || typeof toolPath !== "string") { - return Response.json({ error: "callId and toolPath are required" }, { status: 400 }); - } - - const result = await ctx.runAction(internal.executorNode.handleExternalToolCall, { - runId: parsed.runId, - callId, - toolPath, - input: payload.input, - }); - return Response.json(result, { status: 200 }); - } - - const stream = payload.stream; - const line = payload.line; - if ((stream !== "stdout" && stream !== "stderr") || typeof line !== "string") { - return Response.json({ error: "stream and line are required" }, { status: 400 }); - } - - const task = await ctx.runQuery(internal.database.getTask, { taskId: parsed.runId }); - if (!task) { - return Response.json({ error: `Run not found: ${parsed.runId}` }, { status: 404 }); - } - - await ctx.runMutation(internal.executor.appendRuntimeOutput, { - runId: parsed.runId, - stream, - line, - timestamp: typeof payload.timestamp === "number" ? payload.timestamp : Date.now(), - }); - - return Response.json({ ok: true }, { status: 200 }); -}); - authKit.registerRoutes(http); registerStripeRoutes(http, components.stripe, { webhookPath: "/stripe/webhook", @@ -380,10 +301,4 @@ http.route({ path: "/mcp", method: "DELETE", handler: mcpHandler }); http.route({ path: "/.well-known/oauth-protected-resource", method: "GET", handler: oauthProtectedResourceHandler }); http.route({ path: "/.well-known/oauth-authorization-server", method: "GET", handler: oauthAuthorizationServerProxyHandler }); -http.route({ - pathPrefix: "/internal/runs/", - method: "POST", - handler: internalRunsHandler, -}); - export default http; diff --git a/executor/convex/runtimeCallbacks.ts b/executor/convex/runtimeCallbacks.ts new file mode 100644 index 000000000..2f0e7e4be --- /dev/null +++ b/executor/convex/runtimeCallbacks.ts @@ -0,0 +1,88 @@ +import { v } from "convex/values"; +import { internal } from "./_generated/api"; +import { action, mutation } from "./_generated/server"; +import type { ToolCallResult } from "../lib/types"; + +function requireInternalSecret(secret: string): void { + const expected = process.env.EXECUTOR_INTERNAL_TOKEN; + if (!expected) { + throw new Error("EXECUTOR_INTERNAL_TOKEN is not configured"); + } + if (secret !== expected) { + throw new Error("Unauthorized: invalid internal secret"); + } +} + +export const handleToolCall = action({ + args: { + internalSecret: v.string(), + runId: v.string(), + callId: v.string(), + toolPath: v.string(), + input: v.optional(v.any()), + }, + handler: async (ctx, args): Promise => { + requireInternalSecret(args.internalSecret); + return await ctx.runAction(internal.executorNode.handleExternalToolCall, { + runId: args.runId, + callId: args.callId, + toolPath: args.toolPath, + input: args.input, + }); + }, +}); + +export const appendOutput = mutation({ + args: { + internalSecret: v.string(), + runId: v.string(), + stream: v.union(v.literal("stdout"), v.literal("stderr")), + line: v.string(), + timestamp: v.optional(v.number()), + }, + handler: async (ctx, args) => { + requireInternalSecret(args.internalSecret); + + const task = await ctx.runQuery(internal.database.getTask, { + taskId: args.runId, + }); + if (!task) { + return { ok: false as const, error: `Run not found: ${args.runId}` }; + } + + await ctx.runMutation(internal.executor.appendRuntimeOutput, { + runId: args.runId, + stream: args.stream, + line: args.line, + timestamp: args.timestamp, + }); + + return { ok: true as const }; + }, +}); + +export const completeRun = mutation({ + args: { + internalSecret: v.string(), + runId: v.string(), + status: v.union(v.literal("completed"), v.literal("failed"), v.literal("timed_out"), v.literal("denied")), + stdout: v.optional(v.string()), + stderr: v.optional(v.string()), + exitCode: v.optional(v.number()), + error: v.optional(v.string()), + durationMs: v.optional(v.number()), + }, + handler: async (ctx, args) => { + requireInternalSecret(args.internalSecret); + + return await ctx.runMutation(internal.executor.completeRuntimeRun, { + runId: args.runId, + status: args.status, + stdout: args.stdout, + stderr: args.stderr, + exitCode: args.exitCode, + error: args.error, + durationMs: args.durationMs, + }); + }, +}); diff --git a/executor/convex/schema.ts b/executor/convex/schema.ts index f60e78d9c..812ee660a 100644 --- a/executor/convex/schema.ts +++ b/executor/convex/schema.ts @@ -34,6 +34,13 @@ const taskStatus = v.union( v.literal("denied"), ); const approvalStatus = v.union(v.literal("pending"), v.literal("approved"), v.literal("denied")); +const toolCallStatus = v.union( + v.literal("requested"), + v.literal("pending_approval"), + v.literal("completed"), + v.literal("failed"), + v.literal("denied"), +); const policyDecision = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny")); const credentialScope = v.union(v.literal("workspace"), v.literal("actor")); const credentialProvider = v.union( @@ -207,6 +214,25 @@ export default defineSchema({ .index("by_workspace_created", ["workspaceId", "createdAt"]) .index("by_workspace_status_created", ["workspaceId", "status", "createdAt"]), + toolCalls: defineTable({ + taskId: v.string(), + callId: v.string(), + workspaceId: v.id("workspaces"), + toolPath: v.string(), + input: v.any(), + status: toolCallStatus, + approvalId: v.optional(v.string()), + output: v.optional(v.any()), + error: v.optional(v.string()), + createdAt: v.number(), + updatedAt: v.number(), + completedAt: v.optional(v.number()), + }) + .index("by_task_call", ["taskId", "callId"]) + .index("by_task_created", ["taskId", "createdAt"]) + .index("by_workspace_created", ["workspaceId", "createdAt"]) + .index("by_approval_id", ["approvalId"]), + taskEvents: defineTable({ sequence: v.number(), taskId: v.string(), // references tasks.taskId (not tasks._id) diff --git a/executor/lib/adapters/in-process-execution-adapter.test.ts b/executor/lib/adapters/in-process-execution-adapter.test.ts index 925ca661f..76019f407 100644 --- a/executor/lib/adapters/in-process-execution-adapter.test.ts +++ b/executor/lib/adapters/in-process-execution-adapter.test.ts @@ -45,7 +45,7 @@ test("maps approval denied errors to denied result", async () => { expect(result).toEqual({ ok: false, - denied: true, + kind: "denied", error: "approval required", }); }); diff --git a/executor/lib/adapters/in_process_execution_adapter.ts b/executor/lib/adapters/in_process_execution_adapter.ts index 2e82701fe..583dc96d2 100644 --- a/executor/lib/adapters/in_process_execution_adapter.ts +++ b/executor/lib/adapters/in_process_execution_adapter.ts @@ -1,4 +1,4 @@ -import { APPROVAL_DENIED_PREFIX } from "../execution_constants"; +import { APPROVAL_DENIED_PREFIX, APPROVAL_PENDING_PREFIX } from "../execution_constants"; import type { ExecutionAdapter, RuntimeOutputEvent, @@ -20,6 +20,7 @@ export class InProcessExecutionAdapter implements ExecutionAdapter { if (call.runId !== this.options.runId) { return { ok: false, + kind: "failed", error: `Run mismatch for call ${call.callId}`, }; } @@ -32,13 +33,24 @@ export class InProcessExecutionAdapter implements ExecutionAdapter { if (message.startsWith(APPROVAL_DENIED_PREFIX)) { return { ok: false, - denied: true, + kind: "denied", error: message.replace(APPROVAL_DENIED_PREFIX, "").trim(), }; } + if (message.startsWith(APPROVAL_PENDING_PREFIX)) { + return { + ok: false, + kind: "pending", + approvalId: message.replace(APPROVAL_PENDING_PREFIX, "").trim(), + retryAfterMs: 500, + error: "Approval pending", + }; + } + return { ok: false, + kind: "failed", error: message, }; } diff --git a/executor/lib/execution_constants.ts b/executor/lib/execution_constants.ts index fd98c5782..37f8ed305 100644 --- a/executor/lib/execution_constants.ts +++ b/executor/lib/execution_constants.ts @@ -1,2 +1,3 @@ export const APPROVAL_DENIED_PREFIX = "APPROVAL_DENIED:"; +export const APPROVAL_PENDING_PREFIX = "APPROVAL_PENDING:"; export const TASK_TIMEOUT_MARKER = "TASK_TIMEOUT"; diff --git a/executor/lib/openapi-cache.test.ts b/executor/lib/openapi-cache.test.ts index c9d773f94..46bb8a99f 100644 --- a/executor/lib/openapi-cache.test.ts +++ b/executor/lib/openapi-cache.test.ts @@ -191,7 +191,8 @@ describe("prepareOpenApiSpec with large specs", () => { expect(tools.length).toBeGreaterThan(700); const deleteTool = tools.find((tool) => tool.metadata?.operationId?.startsWith("delete_")); expect(deleteTool).toBeDefined(); - expect(deleteTool?.metadata?.displayReturnsType).toContain("ToolOutput; let fakeCallbackServer: ReturnType; @@ -13,53 +7,27 @@ let fakeCallbackServer: ReturnType; const AUTH_TOKEN = "test-sandbox-token"; const CALLBACK_TOKEN = "test-callback-token"; -const toolResponses = new Map(); -const capturedOutputs: Array<{ stream: string; line: string }> = []; +let hostResponseStatus = 202; +let hostResponseBody: Record = { accepted: true, dispatchId: "dispatch_test" }; +type HostRequestBody = { + taskId: string; + code: string; + timeoutMs: number; + callback: { convexUrl: string; internalSecret: string }; +}; + +let lastHostRequestBody: HostRequestBody | null = null; beforeAll(() => { - // Callback server — mimics the Convex /internal/runs/:id/tool-call endpoint fakeCallbackServer = Bun.serve({ port: 0, - fetch: async (req) => { - const url = new URL(req.url); - - // Verify auth - const auth = req.headers.get("authorization"); - if (auth !== `Bearer ${CALLBACK_TOKEN}`) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - - // Tool call - if (url.pathname.endsWith("/tool-call")) { - const body = (await req.json()) as { toolPath: string; input: unknown }; - const response = toolResponses.get(body.toolPath); - if (response !== undefined) { - return Response.json({ ok: true, value: response }); - } - return Response.json({ ok: false, error: `Unknown tool: ${body.toolPath}` }); - } - - // Output - if (url.pathname.endsWith("/output")) { - const body = (await req.json()) as { stream: string; line: string }; - capturedOutputs.push({ stream: body.stream, line: body.line }); - return Response.json({ ok: true }); - } - - return Response.json({ error: "Not found" }, { status: 404 }); - }, + fetch: () => Response.json({ ok: true }), }); - // Host server — mimics the CF sandbox host worker's /v1/runs endpoint fakeHostServer = Bun.serve({ port: 0, fetch: async (req) => { const url = new URL(req.url); - - if (url.pathname === "/health") { - return Response.json({ ok: true }); - } - if (url.pathname !== "/v1/runs" || req.method !== "POST") { return Response.json({ error: "Not found" }, { status: 404 }); } @@ -69,106 +37,11 @@ beforeAll(() => { return Response.json({ error: "Unauthorized" }, { status: 401 }); } - const body = (await req.json()) as { - taskId: string; - code: string; - timeoutMs: number; - callback: { baseUrl: string; authToken: string }; - }; - - // Simulate the sandbox running the code in a very simplified way. - // In production, the CF Worker Loader would spawn an isolate. - // Here we just use eval-like logic to test the protocol. - const stdout: string[] = []; - const stderr: string[] = []; - - try { - // Simulate console and tools - const consoleProxy = { - log: (...args: unknown[]) => stdout.push(args.map(String).join(" ")), - info: (...args: unknown[]) => stdout.push(args.map(String).join(" ")), - warn: (...args: unknown[]) => stderr.push(args.map(String).join(" ")), - error: (...args: unknown[]) => stderr.push(args.map(String).join(" ")), - }; - - // For tool calls, call back to the callback server - const callTool = async (toolPath: string, input: unknown) => { - const callbackUrl = `${body.callback.baseUrl}/internal/runs/${body.taskId}/tool-call`; - const resp = await fetch(callbackUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${body.callback.authToken}`, - }, - body: JSON.stringify({ callId: `call_${crypto.randomUUID()}`, toolPath, input }), - }); - const result = (await resp.json()) as { ok: boolean; value?: unknown; error?: string }; - if (!result.ok) throw new Error(result.error ?? "Tool call failed"); - return result.value; - }; - - // Emit output back to callback server - const emitLine = async (stream: string, line: string) => { - const outputUrl = `${body.callback.baseUrl}/internal/runs/${body.taskId}/output`; - await fetch(outputUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${body.callback.authToken}`, - }, - body: JSON.stringify({ stream, line, timestamp: Date.now() }), - }).catch(() => {}); - }; - - // Create a minimal tools proxy - const createProxy = (path: string[] = []): unknown => { - const callable = () => {}; - return new Proxy(callable, { - get(_target, prop) { - if (prop === "then") return undefined; - if (typeof prop !== "string") return undefined; - return createProxy([...path, prop]); - }, - async apply(_target, _thisArg, args) { - return callTool(path.join("."), args[0]); - }, - }); - }; - - const tools = createProxy(); - const fn = new Function( - "tools", "console", "setTimeout", "clearTimeout", - `"use strict"; return (async () => {\n${body.code}\n})();`, - ); - const value = await fn(tools, consoleProxy, setTimeout, clearTimeout); - - if (value !== undefined) { - stdout.push(`result: ${JSON.stringify(value)}`); - } - - // Stream output to callback - for (const line of stdout) await emitLine("stdout", line); - for (const line of stderr) await emitLine("stderr", line); - - return Response.json({ - status: "completed", - stdout: stdout.join("\n"), - stderr: stderr.join("\n"), - exitCode: 0, - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return Response.json({ - status: "failed", - stdout: stdout.join("\n"), - stderr: stderr.join("\n"), - error: message, - }); - } + lastHostRequestBody = await req.json(); + return Response.json(hostResponseBody, { status: hostResponseStatus }); }, }); - // Set environment variables for the runtime process.env.CLOUDFLARE_SANDBOX_RUN_URL = `http://127.0.0.1:${fakeHostServer.port}/v1/runs`; process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN = AUTH_TOKEN; process.env.CONVEX_SITE_URL = `http://127.0.0.1:${fakeCallbackServer.port}`; @@ -186,114 +59,79 @@ afterAll(() => { delete process.env.CLOUDFLARE_SANDBOX_REQUEST_TIMEOUT_MS; }); -describe("cloudflare worker loader runtime", () => { - test("executes simple code and returns stdout", async () => { - const result = await runCodeWithCloudflareWorkerLoader({ - taskId: `task_${crypto.randomUUID()}`, - code: `console.log("hello from cf sandbox");`, - timeoutMs: 5_000, - }); - - expect(result.status).toBe("completed"); - expect(result.stdout).toContain("hello from cf sandbox"); - expect(result.durationMs).toBeGreaterThan(0); - }); +describe("cloudflare worker loader dispatch", () => { + test("dispatches run request and returns accepted response", async () => { + hostResponseStatus = 202; + hostResponseBody = { accepted: true, dispatchId: "dispatch_abc" }; + lastHostRequestBody = null; - test("returns a value", async () => { - const result = await runCodeWithCloudflareWorkerLoader({ + const result = await dispatchCodeWithCloudflareWorkerLoader({ taskId: `task_${crypto.randomUUID()}`, - code: `return 42;`, + code: `console.log("hello");`, timeoutMs: 5_000, }); - expect(result.status).toBe("completed"); - expect(result.stdout).toContain("42"); - }); - - test("captures errors as failed status", async () => { - const result = await runCodeWithCloudflareWorkerLoader({ - taskId: `task_${crypto.randomUUID()}`, - code: `throw new Error("boom");`, - timeoutMs: 5_000, - }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.accepted).toBe(true); + expect(result.dispatchId).toBe("dispatch_abc"); + } - expect(result.status).toBe("failed"); - expect(result.error).toContain("boom"); + expect(lastHostRequestBody).not.toBeNull(); + const requestBody = lastHostRequestBody as unknown as HostRequestBody; + expect(requestBody.callback.convexUrl).toContain(String(fakeCallbackServer.port)); + expect(requestBody.callback.internalSecret).toBe(CALLBACK_TOKEN); }); - test("calls tools via callback server", async () => { - toolResponses.set("math.add", { sum: 7 }); + test("transpiles TypeScript before dispatching", async () => { + hostResponseStatus = 202; + hostResponseBody = { accepted: true, dispatchId: "dispatch_ts" }; + lastHostRequestBody = null; - const result = await runCodeWithCloudflareWorkerLoader({ + const result = await dispatchCodeWithCloudflareWorkerLoader({ taskId: `task_${crypto.randomUUID()}`, code: ` - const out = await tools.math.add({ a: 3, b: 4 }); - console.log("sum:", out.sum); + interface User { name: string } + const user: User = { name: "Ada" }; + console.log(user.name); `, timeoutMs: 5_000, }); - expect(result.status).toBe("completed"); - expect(result.stdout).toContain("sum: 7"); - - toolResponses.delete("math.add"); + expect(result.ok).toBe(true); + expect(lastHostRequestBody).not.toBeNull(); + const requestBody = lastHostRequestBody as unknown as HostRequestBody; + expect(requestBody.code.includes("interface User")).toBe(false); + expect(requestBody.code.includes("const user")).toBe(true); }); - test("streams output to callback server", async () => { - capturedOutputs.length = 0; + test("fails when host does not accept dispatch", async () => { + hostResponseStatus = 500; + hostResponseBody = { error: "host failure" }; - const result = await runCodeWithCloudflareWorkerLoader({ + const result = await dispatchCodeWithCloudflareWorkerLoader({ taskId: `task_${crypto.randomUUID()}`, - code: `console.log("streamed line");`, + code: `console.log("x");`, timeoutMs: 5_000, }); - expect(result.status).toBe("completed"); - expect(capturedOutputs.some((o) => o.line === "streamed line" && o.stream === "stdout")).toBe( - true, - ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("500"); + } }); - test("transpiles TypeScript code before sending to sandbox", async () => { - const result = await runCodeWithCloudflareWorkerLoader({ + test("reports TypeScript transpile errors", async () => { + const result = await dispatchCodeWithCloudflareWorkerLoader({ taskId: `task_${crypto.randomUUID()}`, - code: ` - interface User { - name: string; - age: number; - } - const user: User = { name: "Alice", age: 30 }; - const greet = (u: User): string => \`Hello \${u.name}, age \${u.age}\`; - console.log(greet(user)); - `, - timeoutMs: 5_000, - }); - - expect(result.status).toBe("completed"); - expect(result.stdout).toContain("Hello Alice, age 30"); - }); - - test("reports TypeScript syntax errors clearly", async () => { - const result = await runCodeWithCloudflareWorkerLoader({ - taskId: `task_${crypto.randomUUID()}`, - // Unterminated type syntax that TS transpiler will reject code: `const x: = 5;`, timeoutMs: 5_000, }); - expect(result.status).toBe("failed"); - expect(result.error).toContain("TypeScript transpile error"); - }); - - test("handles unknown tool gracefully", async () => { - const result = await runCodeWithCloudflareWorkerLoader({ - taskId: `task_${crypto.randomUUID()}`, - code: `await tools.nonexistent.thing({});`, - timeoutMs: 5_000, - }); - - expect(result.status).toBe("failed"); - expect(result.error).toContain("nonexistent.thing"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("TypeScript transpile error"); + } }); }); @@ -312,7 +150,6 @@ describe("runtime catalog", () => { test("isCloudflareWorkerLoaderConfigured checks env vars", async () => { const { isCloudflareWorkerLoaderConfigured } = await import("./runtime_catalog"); - // Env vars are set in beforeAll expect(isCloudflareWorkerLoaderConfigured()).toBe(true); }); @@ -322,8 +159,24 @@ describe("runtime catalog", () => { expect(config.runUrl).toContain("/v1/runs"); expect(config.authToken).toBe(AUTH_TOKEN); - expect(config.callbackBaseUrl).toContain(String(fakeCallbackServer.port)); - expect(config.callbackAuthToken).toBe(CALLBACK_TOKEN); + expect(config.callbackConvexUrl).toContain(String(fakeCallbackServer.port)); + expect(config.callbackInternalSecret).toBe(CALLBACK_TOKEN); expect(config.requestTimeoutMs).toBe(10_000); }); + + test("prefers CONVEX_URL over CONVEX_SITE_URL for callback RPC", async () => { + const { getCloudflareWorkerLoaderConfig } = await import("./runtime_catalog"); + const previousConvexUrl = process.env.CONVEX_URL; + const previousSiteUrl = process.env.CONVEX_SITE_URL; + try { + process.env.CONVEX_URL = "https://example-convex-cloud-url.test"; + process.env.CONVEX_SITE_URL = "https://example-convex-site-url.test"; + + const config = getCloudflareWorkerLoaderConfig(); + expect(config.callbackConvexUrl).toBe("https://example-convex-cloud-url.test"); + } finally { + process.env.CONVEX_URL = previousConvexUrl; + process.env.CONVEX_SITE_URL = previousSiteUrl; + } + }); }); diff --git a/executor/lib/runtimes/cloudflare_worker_loader_runtime.ts b/executor/lib/runtimes/cloudflare_worker_loader_runtime.ts index 7ec9c09ea..c7fce7c31 100644 --- a/executor/lib/runtimes/cloudflare_worker_loader_runtime.ts +++ b/executor/lib/runtimes/cloudflare_worker_loader_runtime.ts @@ -1,7 +1,7 @@ "use node"; import { Result } from "better-result"; -import type { SandboxExecutionRequest, SandboxExecutionResult } from "../types"; +import type { SandboxExecutionRequest } from "../types"; import { getCloudflareWorkerLoaderConfig } from "./runtime_catalog"; import { transpileForRuntime } from "./transpile"; @@ -19,42 +19,47 @@ import { transpileForRuntime } from "./transpile"; * * 3. The dynamic isolate's `tools` proxy calls are intercepted by a * `ToolBridge` entrypoint in the host Worker (passed via `env` bindings), - * which in turn calls back to the Convex `/internal/runs/{runId}/tool-call` - * HTTP endpoint to resolve the tool. + * which in turn calls Convex callback RPCs to resolve tools. * - * 4. Console output from the isolate is similarly relayed back to - * `/internal/runs/{runId}/output`. + * 4. Console output from the isolate is similarly relayed back via callback + * RPC mutation. * - * 5. When execution completes, the host Worker returns the result as JSON and - * this function maps it to a `SandboxExecutionResult`. + * 5. The host Worker accepts the run immediately and finishes execution + * asynchronously, reporting terminal results back through callback RPC. * * ## Callback authentication * - * The host Worker authenticates its callbacks using the same - * `EXECUTOR_INTERNAL_TOKEN` bearer token that the Convex HTTP API expects. + * The host Worker authenticates callback RPCs using `EXECUTOR_INTERNAL_TOKEN`. */ -export async function runCodeWithCloudflareWorkerLoader( +export interface CloudflareDispatchResult { + ok: true; + accepted: true; + dispatchId: string; + durationMs: number; +} + +export interface CloudflareDispatchError { + ok: false; + error: string; + durationMs: number; +} + +export async function dispatchCodeWithCloudflareWorkerLoader( request: SandboxExecutionRequest, -): Promise { +): Promise { const config = getCloudflareWorkerLoaderConfig(); const startedAt = Date.now(); - const mkResult = ( - status: SandboxExecutionResult["status"], - opts?: { stdout?: string; stderr?: string; error?: string; exitCode?: number }, - ): SandboxExecutionResult => ({ - status, - stdout: opts?.stdout ?? "", - stderr: opts?.stderr ?? "", - exitCode: opts?.exitCode, - error: opts?.error, + const mkError = (error: string): CloudflareDispatchError => ({ + ok: false, + error, durationMs: Date.now() - startedAt, }); // ── Transpile TS → JS on the Convex side ───────────────────────────── const transpiled = transpileForRuntime(request.code); if (transpiled.isErr()) { - return mkResult("failed", { error: transpiled.error.message }); + return mkError(transpiled.error.message); } // ── POST to CF host worker ──────────────────────────────────────────── @@ -73,8 +78,8 @@ export async function runCodeWithCloudflareWorkerLoader( code: transpiled.value, timeoutMs: request.timeoutMs, callback: { - baseUrl: config.callbackBaseUrl, - authToken: config.callbackAuthToken, + convexUrl: config.callbackConvexUrl, + internalSecret: config.callbackInternalSecret, }, }), signal: controller.signal, @@ -87,62 +92,39 @@ export async function runCodeWithCloudflareWorkerLoader( const cause = response.error.cause; const isAbort = cause instanceof DOMException && cause.name === "AbortError"; if (isAbort) { - return mkResult("timed_out", { - error: `Cloudflare sandbox request timed out after ${config.requestTimeoutMs}ms`, - }); + return mkError(`Cloudflare sandbox dispatch timed out after ${config.requestTimeoutMs}ms`); } const message = cause instanceof Error ? cause.message : String(cause); - return mkResult("failed", { - error: `Cloudflare sandbox request failed: ${message}`, - }); + return mkError(`Cloudflare sandbox dispatch failed: ${message}`); } - // ── Handle non-OK HTTP status ───────────────────────────────────────── - if (!response.value.ok) { + // ── Handle non-accepted HTTP status ─────────────────────────────────── + if (response.value.status !== 202) { const text = await Result.tryPromise(() => response.value.text()); const body = text.unwrapOr(response.value.statusText); - return mkResult("failed", { - stderr: body, - error: `Cloudflare sandbox returned ${response.value.status}: ${body}`, - }); + return mkError(`Cloudflare sandbox dispatch returned ${response.value.status}: ${body}`); } - // ── Parse JSON response ─────────────────────────────────────────────── + // ── Parse accepted response JSON ────────────────────────────────────── const body = await Result.tryPromise(() => response.value.json() as Promise<{ - status?: string; - stdout?: string; - stderr?: string; - error?: string; - exitCode?: number; + accepted?: boolean; + dispatchId?: string; }>, ); if (body.isErr()) { - return mkResult("failed", { - error: `Cloudflare sandbox returned invalid JSON`, - }); + return mkError("Cloudflare sandbox dispatch returned invalid JSON"); } - return mkResult(mapStatus(body.value.status), { - stdout: body.value.stdout, - stderr: body.value.stderr, - exitCode: body.value.exitCode, - error: body.value.error, - }); -} - -function mapStatus( - raw: string | undefined, -): SandboxExecutionResult["status"] { - switch (raw) { - case "completed": - return "completed"; - case "timed_out": - return "timed_out"; - case "denied": - return "denied"; - default: - return "failed"; + if (!body.value.accepted || !body.value.dispatchId) { + return mkError("Cloudflare sandbox dispatch response missing accepted/dispatchId"); } + + return { + ok: true, + accepted: true, + dispatchId: body.value.dispatchId, + durationMs: Date.now() - startedAt, + }; } diff --git a/executor/lib/runtimes/runtime-core.test.ts b/executor/lib/runtimes/runtime-core.test.ts index d61ce6422..2a6ba4341 100644 --- a/executor/lib/runtimes/runtime-core.test.ts +++ b/executor/lib/runtimes/runtime-core.test.ts @@ -28,6 +28,7 @@ function createRuntimeAdapter( if (!tool) { return { ok: false, + kind: "failed", error: `Tool not found: ${call.toolPath}`, }; } @@ -45,6 +46,7 @@ function createRuntimeAdapter( } catch (error) { return { ok: false, + kind: "failed", error: error instanceof Error ? error.message : String(error), }; } @@ -89,7 +91,7 @@ test("returns denied when adapter marks tool call denied", async () => { async invokeTool() { return { ok: false, - denied: true, + kind: "denied", error: "policy denied", }; }, diff --git a/executor/lib/runtimes/runtime_catalog.ts b/executor/lib/runtimes/runtime_catalog.ts index fbe64d6cf..2448a0fbd 100644 --- a/executor/lib/runtimes/runtime_catalog.ts +++ b/executor/lib/runtimes/runtime_catalog.ts @@ -19,12 +19,12 @@ export interface CloudflareWorkerLoaderConfig { runUrl: string; /** Shared-secret bearer token for authenticating with the host worker. */ authToken: string; - /** HTTP request timeout in ms (how long we wait for the host worker to respond). */ + /** Dispatch request timeout in ms (how long we wait for /v1/runs accepted response). */ requestTimeoutMs: number; - /** The Convex site URL that the CF host worker calls back to for tool invocations. */ - callbackBaseUrl: string; - /** Internal auth token that the CF host worker uses when calling back. */ - callbackAuthToken: string; + /** Convex deployment URL used for runtime callback RPC invocations. */ + callbackConvexUrl: string; + /** Internal auth secret used for runtime callback RPC auth. */ + callbackInternalSecret: string; } /** @@ -52,19 +52,17 @@ export function getCloudflareWorkerLoaderConfig(): CloudflareWorkerLoaderConfig ); } - // The callback URL is the Convex site URL where the internal runs API lives. - // The CF host worker will POST to {callbackBaseUrl}/internal/runs/{runId}/tool-call - const callbackBaseUrl = process.env.CONVEX_SITE_URL ?? process.env.CONVEX_URL; - if (!callbackBaseUrl) { + const callbackConvexUrl = process.env.CONVEX_URL ?? process.env.CONVEX_SITE_URL; + if (!callbackConvexUrl) { throw new Error( - "Cloudflare Worker Loader runtime requires CONVEX_SITE_URL or CONVEX_URL for tool-call callbacks", + "Cloudflare Worker Loader runtime requires CONVEX_SITE_URL or CONVEX_URL for callback RPC", ); } - const callbackAuthToken = process.env.EXECUTOR_INTERNAL_TOKEN; - if (!callbackAuthToken) { + const callbackInternalSecret = process.env.EXECUTOR_INTERNAL_TOKEN; + if (!callbackInternalSecret) { throw new Error( - "Cloudflare Worker Loader runtime requires EXECUTOR_INTERNAL_TOKEN for authenticated tool-call callbacks", + "Cloudflare Worker Loader runtime requires EXECUTOR_INTERNAL_TOKEN for authenticated callback RPC", ); } @@ -76,7 +74,7 @@ export function getCloudflareWorkerLoaderConfig(): CloudflareWorkerLoaderConfig runUrl, authToken, requestTimeoutMs, - callbackBaseUrl, - callbackAuthToken, + callbackConvexUrl, + callbackInternalSecret, }; } diff --git a/executor/lib/runtimes/runtime_core.ts b/executor/lib/runtimes/runtime_core.ts index cb70ec8a1..4c5b08234 100644 --- a/executor/lib/runtimes/runtime_core.ts +++ b/executor/lib/runtimes/runtime_core.ts @@ -29,6 +29,10 @@ function fireAndForget(promise: void | Promise): void { } } +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + function createToolsProxy( adapter: ExecutionAdapter, runId: string, @@ -48,22 +52,31 @@ function createToolsProxy( } const input = args.length > 0 ? args[0] : {}; - const result = await adapter.invokeTool({ - runId, - callId: `call_${crypto.randomUUID()}`, - toolPath, - input, - }); - - if (result.ok) { - return result.value; + const callId = `call_${crypto.randomUUID()}`; + + while (true) { + const result = await adapter.invokeTool({ + runId, + callId, + toolPath, + input, + }); + + if (result.ok) { + return result.value; + } + + switch (result.kind) { + case "pending": + await sleep(Math.max(50, result.retryAfterMs ?? 500)); + continue; + case "denied": + throw new Error(`${APPROVAL_DENIED_PREFIX}${result.error}`); + case "failed": + default: + throw new Error(result.error); + } } - - if (result.denied) { - throw new Error(`${APPROVAL_DENIED_PREFIX}${result.error}`); - } - - throw new Error(result.error); }, }); } diff --git a/executor/lib/tool-sources.test.ts b/executor/lib/tool-sources.test.ts index 5bfd5142d..65550266d 100644 --- a/executor/lib/tool-sources.test.ts +++ b/executor/lib/tool-sources.test.ts @@ -321,6 +321,90 @@ test("compiled source artifacts can be materialized and executed", async () => { } }); +test("postman collection specs load and execute request tools", async () => { + const upstream = Bun.serve({ + port: 0, + fetch: (req) => { + const url = new URL(req.url); + if (req.method === "GET" && url.pathname.startsWith("/items/")) { + const id = url.pathname.split("/").filter(Boolean)[1] ?? ""; + return Response.json({ + ok: true, + id, + expand: url.searchParams.get("expand"), + limit: url.searchParams.get("limit"), + token: req.headers.get("x-token"), + }); + } + return new Response("not found", { status: 404 }); + }, + }); + + const proxy = Bun.serve({ + port: 0, + fetch: async (req) => { + const body = (await req.json()) as { service?: string; method?: string; path?: string }; + if (body.service === "sync" && body.method === "GET" && body.path === "/collection/123-demo?populate=true") { + return Response.json({ + data: { + name: "Demo collection", + variables: [{ key: "token", value: "Bearer demo-token" }], + folders: [{ id: "folder_items", name: "Items" }], + requests: [ + { + id: "request_get_item", + name: "Get item", + method: "GET", + url: `http://127.0.0.1:${upstream.port}/items/{{itemId}}`, + folder: "folder_items", + headerData: [{ key: "x-token", value: "{{token}}" }], + queryParams: [{ key: "expand", value: "details" }], + pathVariableData: [{ key: "itemId", value: "default-id" }], + }, + ], + }, + }); + } + return Response.json({ error: "unknown request" }, { status: 400 }); + }, + }); + + try { + const { tools, warnings } = await loadExternalTools([ + { + type: "openapi", + name: "postman-demo", + spec: "postman:123-demo", + postmanProxyUrl: `http://127.0.0.1:${proxy.port}`, + }, + ]); + + expect(warnings).toHaveLength(0); + expect(tools).toHaveLength(1); + expect(tools[0]?.source).toBe("catalog:postman-demo"); + expect(tools[0]?.path).toContain("items"); + + const result = await tools[0]!.run( + { + variables: { itemId: "abc-123" }, + query: { limit: 10 }, + }, + { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true }, + ); + + expect(result).toEqual({ + ok: true, + id: "abc-123", + expand: "details", + limit: "10", + token: "Bearer demo-token", + }); + } finally { + proxy.stop(true); + upstream.stop(true); + } +}); + test("parseGraphqlOperationPaths handles aliases and fragments", async () => { const parsed = parseGraphqlOperationPaths( "linear", diff --git a/executor/lib/tool_sources.ts b/executor/lib/tool_sources.ts index cc346e8ec..bad0d602e 100644 --- a/executor/lib/tool_sources.ts +++ b/executor/lib/tool_sources.ts @@ -36,6 +36,10 @@ export interface OpenApiToolSourceConfig { sourceId?: string; sourceKey?: string; spec: string | Record; + /** Optional canonical Postman collection URL for display/debugging. */ + collectionUrl?: string; + /** Optional override for Postman ws/proxy endpoint (primarily for tests). */ + postmanProxyUrl?: string; baseUrl?: string; auth?: OpenApiAuth; defaultReadApproval?: ToolApprovalMode; @@ -62,6 +66,23 @@ export type ExternalToolSourceConfig = | OpenApiToolSourceConfig | GraphqlToolSourceConfig; +interface PostmanSerializedRunSpec { + kind: "postman"; + method: string; + url: string; + headers: Record; + queryParams: Array<{ key: string; value: string }>; + body?: + | { kind: "urlencoded"; entries: Array<{ key: string; value: string }> } + | { kind: "raw"; text: string }; + variables: Record; + authHeaders: Record; +} + +const POSTMAN_SPEC_PREFIX = "postman:"; +const DEFAULT_POSTMAN_PROXY_URL = "https://www.postman.com/_api/ws/proxy"; +const POSTMAN_TEMPLATE_PATTERN = /\{\{([^{}]+)\}\}/g; + function sanitizeSegment(value: string): string { const cleanedBase = value .toLowerCase() @@ -665,6 +686,161 @@ function getCredentialSourceKey(config: { return config.sourceKey ?? `${config.type}:${config.name}`; } +export function parsePostmanCollectionUid(spec: string): string | null { + if (!spec.startsWith(POSTMAN_SPEC_PREFIX)) { + return null; + } + + const uid = spec.slice(POSTMAN_SPEC_PREFIX.length).trim(); + if (!uid) { + return null; + } + + return uid; +} + +function stringifyTemplateValue(value: unknown): string { + if (value === null || value === undefined) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + return JSON.stringify(value); +} + +function interpolatePostmanTemplate(value: string, variables: Record): string { + return value.replace(POSTMAN_TEMPLATE_PATTERN, (_, rawKey: string) => { + const key = rawKey.trim(); + return Object.prototype.hasOwnProperty.call(variables, key) + ? variables[key]! + : `{{${key}}}`; + }); +} + +function findUnresolvedPostmanTemplateKeys(value: string): string[] { + const keys = new Set(); + let match: RegExpExecArray | null; + const pattern = new RegExp(POSTMAN_TEMPLATE_PATTERN.source, "g"); + while ((match = pattern.exec(value)) !== null) { + const key = String(match[1] ?? "").trim(); + if (key) keys.add(key); + } + return [...keys]; +} + +function asStringRecord(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const result: Record = {}; + for (const [key, entry] of Object.entries(value as Record)) { + if (!key) continue; + result[key] = stringifyTemplateValue(entry); + } + return result; +} + +function detectJsonContentType(headers: Record): boolean { + const contentType = Object.entries(headers).find(([name]) => name.toLowerCase() === "content-type")?.[1] ?? ""; + return contentType.toLowerCase().includes("json"); +} + +async function executePostmanRequest( + runSpec: PostmanSerializedRunSpec, + payload: Record, + credentialHeaders?: Record, +): Promise { + const variables = { + ...runSpec.variables, + ...asStringRecord(payload.variables), + }; + + const interpolatedUrl = interpolatePostmanTemplate(runSpec.url, variables); + const unresolvedUrlKeys = findUnresolvedPostmanTemplateKeys(interpolatedUrl); + if (unresolvedUrlKeys.length > 0) { + throw new Error(`Missing required URL variables: ${unresolvedUrlKeys.join(", ")}`); + } + + let url: URL; + try { + url = new URL(interpolatedUrl); + } catch { + throw new Error(`Invalid request URL: ${interpolatedUrl}`); + } + + for (const entry of runSpec.queryParams) { + if (!entry.key) continue; + const value = interpolatePostmanTemplate(entry.value, variables); + if (value.length > 0) { + url.searchParams.set(entry.key, value); + } + } + + const queryOverrides = asRecord(payload.query); + for (const [key, value] of Object.entries(queryOverrides)) { + if (!key || value === undefined || value === null) continue; + url.searchParams.set(key, stringifyTemplateValue(value)); + } + + const headers: Record = {}; + for (const [name, value] of Object.entries(runSpec.headers)) { + if (!name) continue; + headers[name] = interpolatePostmanTemplate(value, variables); + } + Object.assign(headers, runSpec.authHeaders); + Object.assign(headers, credentialHeaders ?? {}); + + const headerOverrides = asRecord(payload.headers); + for (const [name, value] of Object.entries(headerOverrides)) { + if (!name || value === undefined || value === null) continue; + headers[name] = stringifyTemplateValue(value); + } + + const method = runSpec.method.toUpperCase(); + const readMethods = new Set(["GET", "HEAD", "OPTIONS"]); + let body: string | undefined; + + if (!readMethods.has(method)) { + const hasExplicitBody = Object.prototype.hasOwnProperty.call(payload, "body"); + if (hasExplicitBody) { + const bodyValue = payload.body; + if (typeof bodyValue === "string") { + body = bodyValue; + } else if (bodyValue !== undefined) { + body = JSON.stringify(bodyValue); + if (!Object.keys(headers).some((name) => name.toLowerCase() === "content-type")) { + headers["content-type"] = "application/json"; + } + } + } else if (runSpec.body?.kind === "urlencoded") { + const params = new URLSearchParams(); + for (const entry of runSpec.body.entries) { + if (!entry.key) continue; + params.set(entry.key, interpolatePostmanTemplate(entry.value, variables)); + } + body = params.toString(); + if (!Object.keys(headers).some((name) => name.toLowerCase() === "content-type")) { + headers["content-type"] = "application/x-www-form-urlencoded"; + } + } else if (runSpec.body?.kind === "raw") { + body = interpolatePostmanTemplate(runSpec.body.text, variables); + } + } + + const response = await fetch(url.toString(), { + method, + headers, + body, + }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`HTTP ${response.status} ${response.statusText}: ${text.slice(0, 500)}`); + } + + if (detectJsonContentType(headers) || (response.headers.get("content-type") ?? "").includes("json")) { + return await response.json(); + } + + return await response.text(); +} + function buildOpenApiUrl( baseUrl: string, pathTemplate: string, @@ -828,7 +1004,6 @@ function compactOpenApiPaths( ? jsonSchemaTypeHintFallback(combinedSchema, 0, compSchemas) : "{}"; compactOperation._returnsTypeHint = responseTypeHintFromSchema(responseSchema, responseStatus, compSchemas); - compactOperation._usesGeneratedTypes = true; const previewKeys = [ ...mergedParameters.map((param) => String(param.name ?? "")).filter((name) => name.length > 0), ...Object.keys(asRecord(requestBodySchema.properties)), @@ -1105,16 +1280,10 @@ export function buildOpenApiToolsFromPrepared( } } - const usesGeneratedTypes = Boolean(operation._usesGeneratedTypes); - const operationIdKey = JSON.stringify(operationIdRaw); const displayArgsType = argPreviewKeys.length > 0 ? compactArgKeysHint(argPreviewKeys) - : usesGeneratedTypes - ? `ToolInput` - : compactArgTypeHint(argsType); - const displayReturnsType = usesGeneratedTypes - ? `ToolOutput` - : compactReturnTypeHint(returnsType); + : compactArgTypeHint(argsType); + const displayReturnsType = compactReturnTypeHint(returnsType); const approval = config.overrides?.[operationIdRaw]?.approval ?? (readMethods.has(method) @@ -1185,7 +1354,234 @@ export function buildOpenApiToolsFromPrepared( return tools; } +function buildPostmanToolPath( + sourceName: string, + requestName: string, + folderPath: string[], + usedPaths: Set, +): string { + const source = sanitizeSegment(sourceName); + const segments = [ + source, + ...folderPath.map((segment) => sanitizeSegment(segment)).filter((segment) => segment.length > 0), + sanitizeSnakeSegment(requestName), + ]; + const basePath = segments.join("."); + + let path = basePath; + let suffix = 2; + while (usedPaths.has(path)) { + path = `${basePath}_${suffix}`; + suffix += 1; + } + usedPaths.add(path); + return path; +} + +function resolvePostmanFolderPath( + folderId: string | undefined, + folderById: Map, +): string[] { + const path: string[] = []; + let cursor = folderId; + let safety = 0; + while (cursor && safety < 100) { + safety += 1; + const folder = folderById.get(cursor); + if (!folder) break; + path.unshift(folder.name); + cursor = folder.parentId; + } + return path; +} + +function extractPostmanVariableMap(value: unknown): Record { + if (!Array.isArray(value)) return {}; + const result: Record = {}; + for (const entry of value) { + if (!entry || typeof entry !== "object") continue; + const record = entry as Record; + const key = typeof record.key === "string" ? record.key.trim() : ""; + if (!key) continue; + if (record.disabled === true) continue; + result[key] = stringifyTemplateValue(record.value); + } + return result; +} + +function extractPostmanHeaderMap(value: unknown): Record { + if (!Array.isArray(value)) return {}; + const result: Record = {}; + for (const entry of value) { + if (!entry || typeof entry !== "object") continue; + const record = entry as Record; + const key = typeof record.key === "string" ? record.key.trim() : ""; + if (!key || record.disabled === true) continue; + result[key] = stringifyTemplateValue(record.value); + } + return result; +} + +function extractPostmanQueryEntries(value: unknown): Array<{ key: string; value: string }> { + if (!Array.isArray(value)) return []; + const entries: Array<{ key: string; value: string }> = []; + for (const entry of value) { + if (!entry || typeof entry !== "object") continue; + const record = entry as Record; + const key = typeof record.key === "string" ? record.key.trim() : ""; + if (!key || record.disabled === true) continue; + entries.push({ key, value: stringifyTemplateValue(record.value) }); + } + return entries; +} + +function extractPostmanBody(record: Record): PostmanSerializedRunSpec["body"] { + const dataMode = typeof record.dataMode === "string" ? record.dataMode.toLowerCase() : ""; + if (dataMode === "urlencoded" && Array.isArray(record.data)) { + const entries: Array<{ key: string; value: string }> = []; + for (const item of record.data) { + if (!item || typeof item !== "object") continue; + const entry = item as Record; + const key = typeof entry.key === "string" ? entry.key.trim() : ""; + if (!key || entry.disabled === true) continue; + entries.push({ key, value: stringifyTemplateValue(entry.value) }); + } + return entries.length > 0 ? { kind: "urlencoded", entries } : undefined; + } + + if (typeof record.rawModeData === "string" && record.rawModeData.length > 0) { + return { kind: "raw", text: record.rawModeData }; + } + + return undefined; +} + +async function loadPostmanCollectionTools( + config: OpenApiToolSourceConfig, + collectionUid: string, +): Promise { + const proxyUrl = config.postmanProxyUrl ?? DEFAULT_POSTMAN_PROXY_URL; + const payload = { + service: "sync", + method: "GET", + path: `/collection/${collectionUid}?populate=true`, + }; + + const response = await fetch(proxyUrl, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Failed to fetch API collection ${collectionUid}: HTTP ${response.status} ${text.slice(0, 300)}`); + } + + const raw = await response.json() as Record; + const collection = asRecord(raw.data); + const requests = Array.isArray(collection.requests) + ? collection.requests.filter((entry): entry is Record => Boolean(entry && typeof entry === "object")) + : []; + + const folders = Array.isArray(collection.folders) + ? collection.folders.filter((entry): entry is Record => Boolean(entry && typeof entry === "object")) + : []; + + const folderById = new Map(); + for (const folder of folders) { + const id = typeof folder.id === "string" ? folder.id : ""; + if (!id) continue; + const name = typeof folder.name === "string" && folder.name.trim().length > 0 ? folder.name : "folder"; + const parentId = typeof folder.folder === "string" ? folder.folder : undefined; + folderById.set(id, { name, parentId }); + } + + const sourceLabel = `catalog:${config.name}`; + const authHeaders = buildStaticAuthHeaders(config.auth); + const credentialSourceKey = getCredentialSourceKey(config); + const credentialSpec = buildCredentialSpec(credentialSourceKey, config.auth); + const readMethods = new Set(["get", "head", "options"]); + const usedPaths = new Set(); + const collectionVariables = extractPostmanVariableMap(collection.variables); + const argsType = "{ variables?: Record; query?: Record; headers?: Record; body?: unknown }"; + const returnsType = "unknown"; + + const tools: ToolDefinition[] = []; + + for (const request of requests) { + const methodRaw = typeof request.method === "string" ? request.method.toLowerCase() : "get"; + const method = methodRaw.length > 0 ? methodRaw : "get"; + const url = typeof request.url === "string" ? request.url : ""; + if (!url) continue; + + const requestId = typeof request.id === "string" ? request.id : ""; + const requestName = typeof request.name === "string" && request.name.trim().length > 0 + ? request.name.trim() + : requestId || `${method.toUpperCase()} request`; + const folderId = typeof request.folder === "string" ? request.folder : undefined; + const folderPath = resolvePostmanFolderPath(folderId, folderById); + const requestVariables = { + ...collectionVariables, + ...extractPostmanVariableMap(request.pathVariableData), + }; + + const runSpec: PostmanSerializedRunSpec = { + kind: "postman", + method, + url, + headers: extractPostmanHeaderMap(request.headerData), + queryParams: extractPostmanQueryEntries(request.queryParams), + body: extractPostmanBody(request), + variables: requestVariables, + authHeaders, + }; + + const approval = config.overrides?.[requestId]?.approval + ?? config.overrides?.[requestName]?.approval + ?? (readMethods.has(method) + ? config.defaultReadApproval ?? "auto" + : config.defaultWriteApproval ?? "required"); + + const tool: ToolDefinition & { _runSpec: SerializedTool["runSpec"] } = { + path: buildPostmanToolPath(config.name, requestName, folderPath, usedPaths), + source: sourceLabel, + approval, + description: typeof request.description === "string" && request.description.trim().length > 0 + ? request.description + : `${method.toUpperCase()} ${url}`, + metadata: { + argsType, + returnsType, + displayArgsType: compactArgTypeHint(argsType), + displayReturnsType: compactReturnTypeHint(returnsType), + argPreviewKeys: ["variables", "query", "headers", "body"], + operationId: requestId || requestName, + }, + credential: credentialSpec, + _runSpec: runSpec, + run: async (input: unknown, context) => { + const payloadRecord = asRecord(input); + return await executePostmanRequest(runSpec, payloadRecord, context.credential?.headers); + }, + }; + + tools.push(tool); + } + + return tools; +} + async function loadOpenApiTools(config: OpenApiToolSourceConfig): Promise { + if (typeof config.spec === "string") { + const collectionUid = parsePostmanCollectionUid(config.spec); + if (collectionUid) { + return await loadPostmanCollectionTools(config, collectionUid); + } + } + const prepared = await prepareOpenApiSpec(config.spec, config.name); return buildOpenApiToolsFromPrepared(config, prepared); } @@ -1895,6 +2291,7 @@ export interface SerializedTool { * Data needed to reconstruct `run()`. Shape depends on source type. * OpenAPI: { kind: "openapi", baseUrl, method, pathTemplate, parameters, authHeaders } * MCP: { kind: "mcp", url, transport?, queryParams?, toolName } + * Postman: { kind: "postman", method, url, headers, queryParams, body?, variables, authHeaders } * GraphQL raw: { kind: "graphql_raw", endpoint, authHeaders } * GraphQL field: { kind: "graphql_field", endpoint, operationName, operationType, queryTemplate, argNames?, authHeaders } * Builtin: { kind: "builtin" } — run comes from DEFAULT_TOOLS @@ -1915,6 +2312,7 @@ export interface SerializedTool { queryParams?: Record; toolName: string; } + | PostmanSerializedRunSpec | { kind: "graphql_raw"; endpoint: string; @@ -2062,6 +2460,17 @@ export function rehydrateTools( }; } + if (st.runSpec.kind === "postman") { + const runSpec = st.runSpec; + return { + ...base, + run: async (input: unknown, context) => { + const payload = asRecord(input); + return await executePostmanRequest(runSpec, payload, context.credential?.headers); + }, + }; + } + if (st.runSpec.kind === "mcp") { const { url, transport, queryParams, toolName } = st.runSpec; return { diff --git a/executor/lib/typechecker.test.ts b/executor/lib/typechecker.test.ts index 131fd09b9..e862508c7 100644 --- a/executor/lib/typechecker.test.ts +++ b/executor/lib/typechecker.test.ts @@ -307,6 +307,47 @@ export interface operations { expect(bad.errors.length).toBeGreaterThan(0); }); + test("OpenAPI helper types infer vendor JSON response payloads", () => { + const tools: ToolDescriptor[] = [ + { + path: "github.activity.get_feeds", + description: "Get feeds", + approval: "auto", + source: "openapi:github", + operationId: "activity/get-feeds", + }, + ]; + + const sourceDts = ` +export interface operations { + "activity/get-feeds": { + parameters: { query?: never; path?: never; header?: never; cookie?: never }; + responses: { + 200: { content: { "application/vnd.github+json": { current_user_url: string; timeline_url: string } } }; + }; + }; +} +`; + + const declarations = generateToolDeclarations(tools, { + sourceDtsBySource: { + "openapi:github": sourceDts, + }, + }); + + const ok = typecheckCode( + "const feed = await tools.github.activity.get_feeds({}); return feed.current_user_url;", + declarations, + ); + expect(ok.ok).toBe(true); + + const bad = typecheckCode( + "const feed = await tools.github.activity.get_feeds({}); return feed.missing_field;", + declarations, + ); + expect(bad.ok).toBe(false); + }); + test("optional parameter can be provided", () => { const result = typecheckCode( 'const r = await tools.admin.send_announcement({ message: "hi", channel: "general" }); return r.sent;', @@ -424,4 +465,3 @@ export interface operations { }); }); - diff --git a/executor/lib/types.ts b/executor/lib/types.ts index 3992834fd..e53633791 100644 --- a/executor/lib/types.ts +++ b/executor/lib/types.ts @@ -4,6 +4,7 @@ import type { Id } from "../convex/_generated/dataModel"; export type TaskStatus = "queued" | "running" | "completed" | "failed" | "timed_out" | "denied"; export type ApprovalStatus = "pending" | "approved" | "denied"; +export type ToolCallStatus = "requested" | "pending_approval" | "completed" | "failed" | "denied"; export type PolicyDecision = "allow" | "require_approval" | "deny"; export type CredentialScope = "workspace" | "actor"; export type CredentialProvider = "managed" | "workos-vault"; @@ -55,6 +56,21 @@ export interface TaskEventRecord { createdAt: number; } +export interface ToolCallRecord { + taskId: string; + callId: string; + workspaceId: Id<"workspaces">; + toolPath: string; + input: unknown; + status: ToolCallStatus; + approvalId?: string; + output?: unknown; + error?: string; + createdAt: number; + updatedAt: number; + completedAt?: number; +} + export interface AccessPolicyRecord { id: string; workspaceId: Id<"workspaces">; @@ -167,7 +183,15 @@ export interface ToolCallRequest { export type ToolCallResult = | { ok: true; value: unknown } - | { ok: false; error: string; denied?: boolean }; + | { + ok: false; + kind: "pending"; + approvalId: string; + retryAfterMs?: number; + error?: string; + } + | { ok: false; kind: "denied"; error: string } + | { ok: false; kind: "failed"; error: string }; export type RuntimeOutputStream = "stdout" | "stderr"; diff --git a/executor/packages/sandbox-host/package.json b/executor/packages/sandbox-host/package.json index 5e8af81fe..0a3466092 100644 --- a/executor/packages/sandbox-host/package.json +++ b/executor/packages/sandbox-host/package.json @@ -14,6 +14,7 @@ "wrangler": "^4.0.0" }, "dependencies": { - "better-result": "^2.7.0" + "better-result": "^2.7.0", + "convex": "^1.31.7" } } diff --git a/executor/packages/sandbox-host/src/index.ts b/executor/packages/sandbox-host/src/index.ts index 78e13385b..dc41a3692 100644 --- a/executor/packages/sandbox-host/src/index.ts +++ b/executor/packages/sandbox-host/src/index.ts @@ -14,14 +14,15 @@ * * 3. The isolate's network access is fully blocked (`globalOutbound: null`). * Instead, tool calls are routed through a `ToolBridge` entrypoint class - * (passed as a loopback service binding via `ctx.exports`) which calls back - * to the Convex HTTP API to resolve them. + * (passed as a loopback service binding via `ctx.exports`) which invokes + * Convex callback RPC functions to resolve them. * * 4. Console output is buffered in the harness and returned in the response. * Output lines are also streamed back to Convex in real-time via the * ToolBridge binding. * - * 5. The result (status, stdout, stderr, error) is returned as JSON. + * 5. `/v1/runs` returns an accepted dispatch response immediately. Terminal + * result status is reported back to Convex through callback RPC. * * ## Code isolation * @@ -35,6 +36,13 @@ import { Result } from "better-result"; import { WorkerEntrypoint } from "cloudflare:workers"; +import { ConvexHttpClient } from "convex/browser"; + +const RUNTIME_CALLBACKS = { + handleToolCall: "runtimeCallbacks:handleToolCall", + appendOutput: "runtimeCallbacks:appendOutput", + completeRun: "runtimeCallbacks:completeRun", +} as const; // Import isolate modules as raw text — these are loaded as JS modules inside // the dynamic isolate, NOT executed in the host worker. The *.isolate.js @@ -79,8 +87,8 @@ interface RunRequest { code: string; timeoutMs: number; callback: { - baseUrl: string; - authToken: string; + convexUrl: string; + internalSecret: string; }; } @@ -92,16 +100,23 @@ interface RunResult { exitCode?: number; } +interface RunDispatchResponse { + accepted: true; + dispatchId: string; +} + interface ToolCallResult { - ok: boolean; + ok: true | false; value?: unknown; error?: string; - denied?: boolean; + kind?: "pending" | "denied" | "failed"; + approvalId?: string; + retryAfterMs?: number; } interface BridgeProps { - callbackBaseUrl: string; - callbackAuthToken: string; + callbackConvexUrl: string; + callbackInternalSecret: string; taskId: string; } @@ -138,6 +153,10 @@ const failedResult = (error: string): RunResult => ({ error, }); +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + // ── Tool Bridge Entrypoint ─────────────────────────────────────────────────── // // This class is exposed as a named entrypoint on the host Worker. A loopback @@ -152,54 +171,52 @@ export class ToolBridge extends WorkerEntrypoint { return (this.ctx as unknown as { props: BridgeProps }).props; } - /** Forward a tool call to the Convex internal HTTP API. */ - async callTool(toolPath: string, input: unknown): Promise { - const { callbackBaseUrl, callbackAuthToken, taskId } = this.props; - const url = `${callbackBaseUrl}/internal/runs/${taskId}/tool-call`; - const callId = `call_${crypto.randomUUID()}`; - - const response = await Result.tryPromise(() => - fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${callbackAuthToken}`, - }, - body: JSON.stringify({ callId, toolPath, input }), - }), - ); + private createConvexClient(): ConvexHttpClient { + return new ConvexHttpClient(this.props.callbackConvexUrl, { + skipConvexDeploymentUrlCheck: true, + }); + } + + /** Forward a tool call to the Convex callback RPC action. */ + async callTool(toolPath: string, input: unknown, callId?: string): Promise { + const { callbackInternalSecret, taskId } = this.props; + const effectiveCallId = callId && callId.trim().length > 0 + ? callId + : `call_${crypto.randomUUID()}`; + + const response = await Result.tryPromise(async () => { + const convex = this.createConvexClient(); + return await convex.action(RUNTIME_CALLBACKS.handleToolCall as any, { + internalSecret: callbackInternalSecret, + runId: taskId, + callId: effectiveCallId, + toolPath, + input, + }); + }); if (response.isErr()) { const cause = response.error.cause; const message = cause instanceof Error ? cause.message : String(cause); - return { ok: false, error: `Tool callback failed: ${message}` }; - } - - if (!response.value.ok) { - const text = await Result.tryPromise(() => response.value.text()); - const body = text.unwrapOr(response.value.statusText); - return { ok: false, error: `Tool callback failed (${response.value.status}): ${body}` }; + return { ok: false, kind: "failed", error: `Tool callback failed: ${message}` }; } - return (await response.value.json()) as ToolCallResult; + return response.value as ToolCallResult; } /** Stream a console output line back to Convex (best-effort). */ async emitOutput(stream: "stdout" | "stderr", line: string): Promise { - const { callbackBaseUrl, callbackAuthToken, taskId } = this.props; - const url = `${callbackBaseUrl}/internal/runs/${taskId}/output`; - - // Best-effort — swallow errors. - await Result.tryPromise(() => - fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${callbackAuthToken}`, - }, - body: JSON.stringify({ stream, line, timestamp: Date.now() }), - }), - ); + const { callbackInternalSecret, taskId } = this.props; + await Result.tryPromise(async () => { + const convex = this.createConvexClient(); + await convex.mutation(RUNTIME_CALLBACKS.appendOutput as any, { + internalSecret: callbackInternalSecret, + runId: taskId, + stream, + line, + timestamp: Date.now(), + }); + }); } } @@ -224,6 +241,106 @@ function buildUserModule(userCode: string): string { return `export async function run(tools, console) {\n"use strict";\n${userCode}\n}\n`; } +async function executeSandboxRun(request: RunRequest, ctx: ExecutionContext, env: Env): Promise { + const timeoutMs = request.timeoutMs ?? 300_000; + const isolateId = request.taskId; + + const ctxExports = (ctx as unknown as { + exports: Record unknown>; + }).exports; + + const toolBridgeBinding = ctxExports.ToolBridge({ + props: { + callbackConvexUrl: request.callback.convexUrl, + callbackInternalSecret: request.callback.internalSecret, + taskId: request.taskId, + }, + }); + + const worker = env.LOADER.get(isolateId, async () => ({ + compatibilityDate: "2025-06-01", + mainModule: "harness.js", + modules: { + "harness.js": HARNESS_CODE, + "globals.js": GLOBALS_MODULE, + "user-code.js": buildUserModule(request.code), + }, + env: { + TOOL_BRIDGE: toolBridgeBinding, + }, + globalOutbound: null, + })); + + const entrypoint = worker.getEntrypoint(); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + const response = await Result.tryPromise(() => + entrypoint.fetch("http://sandbox.internal/run", { + method: "POST", + signal: controller.signal, + }), + ); + + clearTimeout(timer); + + if (response.isErr()) { + const cause = response.error.cause; + if (cause instanceof DOMException && cause.name === "AbortError") { + return { + status: "timed_out", + stdout: "", + stderr: "", + error: `Execution timed out after ${timeoutMs}ms`, + }; + } + throw cause; + } + + const body = await Result.tryPromise(() => response.value.json() as Promise); + if (body.isErr()) { + return failedResult("Sandbox isolate returned invalid JSON"); + } + return body.value; +} + +async function reportRunCompletion(request: RunRequest, result: RunResult, durationMs: number): Promise { + const convex = new ConvexHttpClient(request.callback.convexUrl, { + skipConvexDeploymentUrlCheck: true, + }); + let lastError: unknown = null; + + for (let attempt = 1; attempt <= 3; attempt += 1) { + const response = await Result.tryPromise(async () => { + return await convex.mutation(RUNTIME_CALLBACKS.completeRun as any, { + internalSecret: request.callback.internalSecret, + runId: request.taskId, + status: result.status, + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + error: result.error, + durationMs, + }); + }); + + if (response.isOk()) { + return; + } + + lastError = response.error.cause; + if (attempt < 3) { + await sleep(200 * attempt); + } + } + + console.error("Failed to report run completion", { + taskId: request.taskId, + error: lastError instanceof Error ? lastError.message : String(lastError), + }); +} + // ── Main Handler ───────────────────────────────────────────────────────────── export default { @@ -255,81 +372,34 @@ export default { } const body = parsed.value; - if (!body.taskId || !body.code || !body.callback?.baseUrl || !body.callback?.authToken) { + if (!body.taskId || !body.code || !body.callback?.convexUrl || !body.callback?.internalSecret) { return Response.json( - { error: "Missing required fields: taskId, code, callback.baseUrl, callback.authToken" }, + { error: "Missing required fields: taskId, code, callback.convexUrl, callback.internalSecret" }, { status: 400 }, ); } - const timeoutMs = body.timeoutMs ?? 300_000; - const isolateId = body.taskId; - - // ── Spawn isolate and execute ───────────────────────────────────────── - const execution = await Result.tryPromise(async () => { - const ctxExports = (ctx as unknown as { - exports: Record unknown>; - }).exports; - - const toolBridgeBinding = ctxExports.ToolBridge({ - props: { - callbackBaseUrl: body.callback.baseUrl, - callbackAuthToken: body.callback.authToken, - taskId: body.taskId, - }, - }); + const startedAt = Date.now(); + const dispatchId = `dispatch_${body.taskId}_${startedAt}`; - const worker = env.LOADER.get(isolateId, async () => ({ - compatibilityDate: "2025-06-01", - mainModule: "harness.js", - modules: { - "harness.js": HARNESS_CODE, - "globals.js": GLOBALS_MODULE, - "user-code.js": buildUserModule(body.code), - }, - env: { - TOOL_BRIDGE: toolBridgeBinding, - }, - globalOutbound: null, - })); - - const entrypoint = worker.getEntrypoint(); - - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - - const response = await Result.tryPromise(() => - entrypoint.fetch("http://sandbox.internal/run", { - method: "POST", - signal: controller.signal, - }), - ); + ctx.waitUntil((async () => { + const runResult = await Result.tryPromise(() => executeSandboxRun(body, ctx, env)); + const finalResult = runResult.isOk() + ? runResult.value + : failedResult( + `Sandbox host error: ${runResult.error.cause instanceof Error + ? runResult.error.cause.message + : String(runResult.error.cause)}`, + ); - clearTimeout(timer); - - if (response.isErr()) { - const cause = response.error.cause; - if (cause instanceof DOMException && cause.name === "AbortError") { - return Response.json({ - status: "timed_out", - stdout: "", - stderr: "", - error: `Execution timed out after ${timeoutMs}ms`, - } satisfies RunResult); - } - throw cause; - } - - const result = (await response.value.json()) as RunResult; - return Response.json(result); - }); + await reportRunCompletion(body, finalResult, Date.now() - startedAt); + })()); - if (execution.isErr()) { - const cause = execution.error.cause; - const message = cause instanceof Error ? cause.message : String(cause); - return Response.json(failedResult(`Sandbox host error: ${message}`)); - } + const response: RunDispatchResponse = { + accepted: true, + dispatchId, + }; - return execution.value; + return Response.json(response, { status: 202 }); }, }; diff --git a/executor/packages/sandbox-host/src/isolate/harness.isolate.js b/executor/packages/sandbox-host/src/isolate/harness.isolate.js index 4499002d9..ec40ed2cc 100644 --- a/executor/packages/sandbox-host/src/isolate/harness.isolate.js +++ b/executor/packages/sandbox-host/src/isolate/harness.isolate.js @@ -16,6 +16,10 @@ function formatArgs(args) { .join(" "); } +async function sleep(ms) { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + function createToolsProxy(bridge, path = []) { const callable = () => {}; return new Proxy(callable, { @@ -28,10 +32,18 @@ function createToolsProxy(bridge, path = []) { const toolPath = path.join("."); if (!toolPath) throw new Error("Tool path missing"); const input = args.length > 0 ? args[0] : {}; - const result = await bridge.callTool(toolPath, input); - if (result.ok) return result.value; - if (result.denied) throw new Error(APPROVAL_DENIED_PREFIX + result.error); - throw new Error(result.error); + const callId = "call_" + crypto.randomUUID(); + + while (true) { + const result = await bridge.callTool(toolPath, input, callId); + if (result.ok) return result.value; + if (result.kind === "pending") { + await sleep(Math.max(50, result.retryAfterMs ?? 500)); + continue; + } + if (result.kind === "denied") throw new Error(APPROVAL_DENIED_PREFIX + result.error); + throw new Error(result.error); + } }, }); } diff --git a/package.json b/package.json index 40cfd120c..fd2ad110b 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "executor/convex", "executor/apps/*", "executor/packages/*", + "sources", "assistant", "assistant/packages/*", "assistant/packages/reacord" @@ -14,6 +15,8 @@ "with-env": "bun run --env-file=.env --", "dev": "bun run dev.ts", "kill:all": "bun run kill-all.ts", + "dev:sources": "bun run --cwd sources dev", + "sources:sync": "bun run --cwd sources sync", "dev:executor:convex": "bunx --cwd executor convex dev", "dev:executor:web": "bun run --cwd executor/apps/web dev", "dev:executor:all": "bun run dev:executor:convex & bun run dev:executor:web", diff --git a/sources/package.json b/sources/package.json new file mode 100644 index 000000000..decf61d1b --- /dev/null +++ b/sources/package.json @@ -0,0 +1,16 @@ +{ + "name": "@proto/sources", + "private": true, + "type": "module", + "scripts": { + "dev": "bun --hot server.ts", + "start": "bun server.ts", + "sync": "bun server.ts --sync-only", + "typecheck": "bunx tsc --noEmit -p tsconfig.json" + }, + "dependencies": {}, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.9.3" + } +} diff --git a/sources/scraper.ts b/sources/scraper.ts new file mode 100644 index 000000000..4199c2320 --- /dev/null +++ b/sources/scraper.ts @@ -0,0 +1,384 @@ +import { Database } from "bun:sqlite"; +import { fileURLToPath } from "node:url"; + +// --------------------------------------------------------------------------- +// List.json shape (from APIs.guru / our fork) +// --------------------------------------------------------------------------- + +interface ListOrigin { + format: string; + url: string; + version: string; +} + +interface ListVersionInfo { + contact?: { email?: string; name?: string; url?: string }; + description?: string; + title?: string; + version?: string; + "x-apisguru-categories"?: string[]; + "x-logo"?: { url?: string }; + "x-origin"?: ListOrigin[]; + "x-providerName"?: string; +} + +interface ListVersion { + added?: string; + updated?: string; + info: ListVersionInfo; + swaggerUrl: string; + swaggerYamlUrl?: string; + openapiVer?: string; + link?: string; +} + +interface ListEntry { + added?: string; + preferred: string; + versions: Record; +} + +type ListJson = Record; + +// --------------------------------------------------------------------------- +// Exported types +// --------------------------------------------------------------------------- + +export interface CatalogItem { + id: string; + name: string; + summary: string; + specUrl: string; + originUrl: string; + providerName: string; + logoUrl: string; + categories: string; + version: string; +} + +export interface SyncState { + inFlight: Promise | null; + lastSyncedAt: number | null; + lastError: string | null; + lastCount: number; +} + +export interface SyncResult { + count: number; + syncedAt: number; + trigger: string; +} + +export interface SyncConfig { + listUrl: string; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DEFAULT_LIST_URL = "https://api.apis.guru/v2/list.json"; +const SUPPLEMENTS_PATH = fileURLToPath(new URL("./supplements.json", import.meta.url)); + +// --------------------------------------------------------------------------- +// Text helpers +// --------------------------------------------------------------------------- + +function normalizeText(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); +} + +function compactText(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]+/g, ""); +} + +function parseTimestamp(value: string | undefined): number { + if (!value) return 0; + const parsed = Date.parse(value.trim()); + return Number.isFinite(parsed) ? parsed : 0; +} + +// --------------------------------------------------------------------------- +// Mapping – list.json entry → DB row +// --------------------------------------------------------------------------- + +interface ApiRow { + id: string; + name: string; + summary: string; + spec_url: string; + origin_url: string; + provider_name: string; + logo_url: string; + categories: string; + version: string; + updated_at: number; + search_text: string; + search_compact: string; +} + +function mapEntry(providerKey: string, entry: ListEntry): ApiRow | null { + const ver = entry.versions[entry.preferred]; + if (!ver) return null; + + const info = ver.info; + const name = (info.title ?? providerKey).trim(); + if (!name) return null; + + const summary = (info.description ?? "").trim(); + const specUrl = ver.swaggerUrl; + if (!specUrl) return null; + + const originUrl = info["x-origin"]?.[0]?.url ?? ""; + const providerName = (info["x-providerName"] ?? providerKey).trim(); + const logoUrl = info["x-logo"]?.url ?? ""; + const categories = (info["x-apisguru-categories"] ?? []).join(","); + const version = (info.version ?? entry.preferred).trim(); + const updatedAt = parseTimestamp(ver.updated) || parseTimestamp(entry.added) || 0; + + const searchText = normalizeText( + `${name} ${providerName} ${providerKey} ${summary.slice(0, 300)} ${categories.replace(/,/g, " ")}`, + ); + + return { + id: providerKey, + name, + summary, + spec_url: specUrl, + origin_url: originUrl, + provider_name: providerName, + logo_url: logoUrl, + categories, + version, + updated_at: updatedAt, + search_text: searchText, + search_compact: compactText(searchText), + }; +} + +// --------------------------------------------------------------------------- +// SQLite +// --------------------------------------------------------------------------- + +export function createDb(dbPath: string): Database { + const db = new Database(dbPath); + db.exec(` + PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + + CREATE TABLE IF NOT EXISTS api_catalog ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + summary TEXT NOT NULL, + spec_url TEXT NOT NULL, + origin_url TEXT NOT NULL DEFAULT '', + provider_name TEXT NOT NULL, + logo_url TEXT NOT NULL DEFAULT '', + categories TEXT NOT NULL DEFAULT '', + version TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL DEFAULT 0, + search_text TEXT NOT NULL, + search_compact TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_catalog_name ON api_catalog(name); + CREATE INDEX IF NOT EXISTS idx_catalog_updated ON api_catalog(updated_at DESC); + CREATE INDEX IF NOT EXISTS idx_catalog_provider ON api_catalog(provider_name); + CREATE INDEX IF NOT EXISTS idx_catalog_search ON api_catalog(search_compact); + `); + return db; +} + +export function rowCount(db: Database): number { + const row = db.query("SELECT COUNT(*) AS count FROM api_catalog").get() as { count: number } | null; + return row?.count ?? 0; +} + +function upsertRows(db: Database, rows: ApiRow[]): void { + if (rows.length === 0) return; + + const upsert = db.query(` + INSERT INTO api_catalog ( + id, name, summary, spec_url, origin_url, + provider_name, logo_url, categories, version, + updated_at, search_text, search_compact + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + summary = excluded.summary, + spec_url = excluded.spec_url, + origin_url = excluded.origin_url, + provider_name = excluded.provider_name, + logo_url = excluded.logo_url, + categories = excluded.categories, + version = excluded.version, + updated_at = excluded.updated_at, + search_text = excluded.search_text, + search_compact = excluded.search_compact + `); + + db.exec("BEGIN IMMEDIATE"); + try { + for (const r of rows) { + upsert.run( + r.id, r.name, r.summary, r.spec_url, r.origin_url, + r.provider_name, r.logo_url, r.categories, r.version, + r.updated_at, r.search_text, r.search_compact, + ); + } + db.exec("COMMIT"); + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } +} + +// --------------------------------------------------------------------------- +// Sync +// --------------------------------------------------------------------------- + +async function loadSupplements(): Promise { + try { + const file = Bun.file(SUPPLEMENTS_PATH); + if (!(await file.exists())) return {}; + const data = (await file.json()) as ListJson & { $comment?: string }; + delete data.$comment; + return data; + } catch { + return {}; + } +} + +async function fetchAndSync(db: Database, config: SyncConfig): Promise { + const url = config.listUrl || DEFAULT_LIST_URL; + console.log(`[scraper] fetching ${url}`); + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch list.json: HTTP ${response.status}`); + } + + const data = (await response.json()) as ListJson; + + // Merge local supplements (overrides upstream if same key) + const supplements = await loadSupplements(); + const supplementCount = Object.keys(supplements).length; + if (supplementCount > 0) { + Object.assign(data, supplements); + console.log(`[scraper] merged ${supplementCount} supplemental entries`); + } + + const entries = Object.entries(data); + console.log(`[scraper] parsing ${entries.length} entries`); + + const rows: ApiRow[] = []; + for (const [key, entry] of entries) { + const row = mapEntry(key, entry); + if (row) rows.push(row); + } + + // Batch upsert in chunks of 500 + const CHUNK = 500; + for (let i = 0; i < rows.length; i += CHUNK) { + upsertRows(db, rows.slice(i, i + CHUNK)); + } + + const count = rowCount(db); + console.log(`[scraper] synced ${rows.length} rows (${count} total in db)`); + return count; +} + +export function triggerSync( + db: Database, + state: SyncState, + config: SyncConfig, + trigger: string, +): Promise { + if (state.inFlight) return state.inFlight; + + const promise = fetchAndSync(db, config) + .then((count) => { + const syncedAt = Date.now(); + state.lastSyncedAt = syncedAt; + state.lastError = null; + state.lastCount = count; + return { count, syncedAt, trigger } satisfies SyncResult; + }) + .catch((error) => { + state.lastError = error instanceof Error ? error.message : String(error); + throw error; + }) + .finally(() => { + state.inFlight = null; + }); + + state.inFlight = promise; + return promise; +} + +// --------------------------------------------------------------------------- +// Query +// --------------------------------------------------------------------------- + +function buildSearchFilter(q: string): { sql: string; params: string[] } { + const trimmed = q.trim(); + if (!trimmed) return { sql: "", params: [] }; + + const normalized = normalizeText(trimmed); + const compact = compactText(normalized); + const tokens = normalized.split(/\s+/).filter(Boolean); + + const clauses: string[] = []; + const params: string[] = []; + + if (compact.length > 0) { + clauses.push("search_compact LIKE ?"); + params.push(`%${compact}%`); + } + + for (const token of tokens) { + clauses.push("search_text LIKE ?"); + params.push(`%${token}%`); + } + + return { + sql: `WHERE (${clauses.join(" OR ")})`, + params, + }; +} + +export function queryCollections(db: Database, input: { + q: string; + sort: "popular" | "recent"; + limit: number; + offset: number; +}): { items: CatalogItem[]; totalCount: number; hasMore: boolean } { + const { sql, params } = buildSearchFilter(input.q); + + const countRow = db.query( + `SELECT COUNT(*) AS count FROM api_catalog ${sql}`, + ).get(...params) as { count: number } | null; + const totalCount = countRow?.count ?? 0; + + // "popular" → alphabetical by name (no popularity metric from APIs.guru) + // "recent" → by updated_at desc + const orderBy = input.sort === "recent" + ? "updated_at DESC, name COLLATE NOCASE ASC" + : "name COLLATE NOCASE ASC"; + + const rows = db.query(` + SELECT + id, name, summary, spec_url AS specUrl, + origin_url AS originUrl, provider_name AS providerName, + logo_url AS logoUrl, categories, version + FROM api_catalog ${sql} + ORDER BY ${orderBy} + LIMIT ? OFFSET ? + `).all(...params, input.limit, input.offset) as CatalogItem[]; + + return { + items: rows, + totalCount, + hasMore: input.offset + rows.length < totalCount, + }; +} diff --git a/sources/server.ts b/sources/server.ts new file mode 100644 index 000000000..036e2d973 --- /dev/null +++ b/sources/server.ts @@ -0,0 +1,192 @@ +#!/usr/bin/env bun + +import { mkdir } from "node:fs/promises"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + createDb, + queryCollections, + rowCount, + triggerSync, + type SyncState, + type SyncConfig, +} from "./scraper"; + +// --------------------------------------------------------------------------- +// Config (env with defaults) +// --------------------------------------------------------------------------- + +const PORT = Math.max(1, Math.min(65535, Number(process.env.SOURCES_PORT) || 4343)); +const DB_PATH = process.env.SOURCES_DB_PATH ?? fileURLToPath(new URL("./data/catalog.sqlite", import.meta.url)); +const SYNC_INTERVAL_MS = Math.max(60_000, Number(process.env.SOURCES_SYNC_INTERVAL_MS) || 6 * 60 * 60 * 1000); +const LIST_URL = process.env.SOURCES_LIST_URL ?? "https://api.apis.guru/v2/list.json"; +const ALLOWED_ORIGINS = process.env.SOURCES_ALLOWED_ORIGINS?.split(",").map((s) => s.trim()).filter(Boolean) ?? []; +const DEFAULT_LIMIT = 25; +const MAX_LIMIT = 100; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function parseInteger(input: string | null, fallback: number): number { + if (!input) return fallback; + const parsed = Number(input); + if (!Number.isFinite(parsed)) return fallback; + return Math.floor(parsed); +} + +function parseSort(value: string | null): "popular" | "recent" { + return value === "recent" ? "recent" : "popular"; +} + +function json(data: unknown, status = 200, corsHeaders?: Record): Response { + return Response.json(data, { + status, + headers: { "cache-control": "no-store", ...corsHeaders }, + }); +} + +function corsHeaders(request: Request): Record { + const origin = request.headers.get("origin") ?? ""; + // In dev, allow any localhost origin. In prod, check allowlist. + const allowed = + origin.match(/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/) || + ALLOWED_ORIGINS.includes(origin); + + if (!allowed && origin) return {}; + + return { + "access-control-allow-origin": origin || "*", + "access-control-allow-methods": "GET, POST, OPTIONS", + "access-control-allow-headers": "content-type", + "access-control-max-age": "86400", + }; +} + +// --------------------------------------------------------------------------- +// Init +// --------------------------------------------------------------------------- + +await mkdir(dirname(DB_PATH), { recursive: true }); +const db = createDb(DB_PATH); +const syncConfig: SyncConfig = { listUrl: LIST_URL }; +const state: SyncState = { + inFlight: null, + lastSyncedAt: null, + lastError: null, + lastCount: rowCount(db), +}; + +// --------------------------------------------------------------------------- +// CLI: --sync-only +// --------------------------------------------------------------------------- + +if (Bun.argv.includes("--sync-only")) { + try { + const result = await triggerSync(db, state, syncConfig, "cli"); + console.log(`synced ${result.count} sources into ${DB_PATH}`); + } finally { + db.close(); + } + process.exit(0); +} + +// --------------------------------------------------------------------------- +// Server +// --------------------------------------------------------------------------- + +const server = Bun.serve({ + port: PORT, + fetch: async (request) => { + const cors = corsHeaders(request); + + // Preflight + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: cors }); + } + + const url = new URL(request.url); + + // GET /health + if (request.method === "GET" && url.pathname === "/health") { + return json({ + ok: true, + count: rowCount(db), + lastSyncedAt: state.lastSyncedAt, + lastError: state.lastError, + lastCount: state.lastCount, + }, 200, cors); + } + + // POST|GET /admin/sync + if ((request.method === "POST" || request.method === "GET") && url.pathname === "/admin/sync") { + try { + const result = await triggerSync(db, state, syncConfig, "manual"); + return json({ ok: true, ...result }, 200, cors); + } catch (error) { + return json({ ok: false, error: error instanceof Error ? error.message : String(error) }, 500, cors); + } + } + + // GET /collections + if (request.method === "GET" && url.pathname === "/collections") { + try { + // Auto-seed on first request if DB is empty + if (rowCount(db) === 0) { + await triggerSync(db, state, syncConfig, "seed"); + } + + const q = url.searchParams.get("q")?.trim() ?? ""; + const sort = parseSort(url.searchParams.get("sort")); + const limit = Math.max(1, Math.min(MAX_LIMIT, parseInteger(url.searchParams.get("limit"), DEFAULT_LIMIT))); + const offset = Math.max(0, parseInteger(url.searchParams.get("offset"), 0)); + + const result = queryCollections(db, { q, sort, limit, offset }); + return json({ + items: result.items, + totalCount: result.totalCount, + hasMore: result.hasMore, + limit, + offset, + }, 200, cors); + } catch (error) { + return json({ + error: "Failed to load API catalog", + detail: error instanceof Error ? error.message : String(error), + }, 500, cors); + } + } + + return json({ error: "Not found" }, 404, cors); + }, +}); + +console.log(`[sources] running on http://127.0.0.1:${server.port}`); +console.log(`[sources] db: ${DB_PATH}`); +console.log(`[sources] endpoints: GET /collections, GET /health, POST /admin/sync`); + +// Non-blocking startup sync +void triggerSync(db, state, syncConfig, "startup").catch((error) => { + console.error(`[sources] initial sync failed: ${error instanceof Error ? error.message : String(error)}`); +}); + +// Periodic sync +const syncInterval = setInterval(() => { + void triggerSync(db, state, syncConfig, "interval").catch((error) => { + console.error(`[sources] scheduled sync failed: ${error instanceof Error ? error.message : String(error)}`); + }); +}, SYNC_INTERVAL_MS); + +// Graceful shutdown +let shuttingDown = false; +const shutdown = () => { + if (shuttingDown) return; + shuttingDown = true; + clearInterval(syncInterval); + server.stop(); + db.close(); + process.exit(0); +}; + +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); diff --git a/sources/supplements.json b/sources/supplements.json new file mode 100644 index 000000000..fc4ec42f1 --- /dev/null +++ b/sources/supplements.json @@ -0,0 +1,171 @@ +{ + "$comment": "Additional OpenAPI specs not in APIs.guru. Same shape as list.json entries.", + "cloudflare.com": { + "added": "2026-02-11T00:00:00.000Z", + "preferred": "4", + "versions": { + "4": { + "added": "2026-02-11T00:00:00.000Z", + "updated": "2026-02-11T00:00:00.000Z", + "info": { + "title": "Cloudflare API", + "description": "Interact with Cloudflare's products and services via the Cloudflare API.", + "version": "4", + "x-apisguru-categories": ["cloud", "hosting"], + "x-logo": { "url": "https://www.cloudflare.com/favicon.ico" }, + "x-origin": [{ "format": "openapi", "url": "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.json", "version": "3.0" }], + "x-providerName": "cloudflare.com" + }, + "swaggerUrl": "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.json", + "openapiVer": "3.0.3" + } + } + }, + "discord.com": { + "added": "2026-02-11T00:00:00.000Z", + "preferred": "10", + "versions": { + "10": { + "added": "2026-02-11T00:00:00.000Z", + "updated": "2026-02-11T00:00:00.000Z", + "info": { + "title": "Discord HTTP API", + "description": "HTTP API for Discord. Build bots, integrations, and applications that interact with Discord.", + "version": "10", + "x-apisguru-categories": ["messaging", "social"], + "x-logo": { "url": "https://discord.com/assets/favicon.ico" }, + "x-origin": [{ "format": "openapi", "url": "https://raw.githubusercontent.com/discord/discord-api-spec/main/specs/openapi.json", "version": "3.1" }], + "x-providerName": "discord.com" + }, + "swaggerUrl": "https://raw.githubusercontent.com/discord/discord-api-spec/main/specs/openapi.json", + "openapiVer": "3.1.0" + } + } + }, + "paypal.com": { + "added": "2026-02-11T00:00:00.000Z", + "preferred": "2", + "versions": { + "2": { + "added": "2026-02-11T00:00:00.000Z", + "updated": "2026-02-11T00:00:00.000Z", + "info": { + "title": "PayPal Checkout Orders API", + "description": "Use the Orders API to create, update, retrieve, authorize, and capture orders (payments).", + "version": "2", + "x-apisguru-categories": ["financial", "payment"], + "x-logo": { "url": "https://www.paypal.com/favicon.ico" }, + "x-origin": [{ "format": "openapi", "url": "https://raw.githubusercontent.com/paypal/paypal-rest-api-specifications/main/openapi/checkout_orders_v2.json", "version": "3.0" }], + "x-providerName": "paypal.com" + }, + "swaggerUrl": "https://raw.githubusercontent.com/paypal/paypal-rest-api-specifications/main/openapi/checkout_orders_v2.json", + "openapiVer": "3.0.0" + } + } + }, + "resend.com": { + "added": "2026-02-11T00:00:00.000Z", + "preferred": "1.0.0", + "versions": { + "1.0.0": { + "added": "2026-02-11T00:00:00.000Z", + "updated": "2026-02-11T00:00:00.000Z", + "info": { + "title": "Resend API", + "description": "Email for developers. Send transactional and marketing emails with a simple API.", + "version": "1.0.0", + "x-apisguru-categories": ["email", "messaging"], + "x-logo": { "url": "https://resend.com/favicon.ico" }, + "x-origin": [{ "format": "openapi", "url": "https://raw.githubusercontent.com/resend/resend-openapi/main/resend.yaml", "version": "3.0" }], + "x-providerName": "resend.com" + }, + "swaggerUrl": "https://raw.githubusercontent.com/resend/resend-openapi/main/resend.yaml", + "openapiVer": "3.0.3" + } + } + }, + "figma.com": { + "added": "2026-02-11T00:00:00.000Z", + "preferred": "0.36.0", + "versions": { + "0.36.0": { + "added": "2026-02-11T00:00:00.000Z", + "updated": "2026-02-11T00:00:00.000Z", + "info": { + "title": "Figma REST API", + "description": "The Figma REST API. Access files, components, styles, comments, users, projects, and more.", + "version": "0.36.0", + "x-apisguru-categories": ["design", "collaboration"], + "x-logo": { "url": "https://static.figma.com/app/icon/1/favicon.ico" }, + "x-origin": [{ "format": "openapi", "url": "https://raw.githubusercontent.com/figma/rest-api-spec/main/openapi/openapi.yaml", "version": "3.1" }], + "x-providerName": "figma.com" + }, + "swaggerUrl": "https://raw.githubusercontent.com/figma/rest-api-spec/main/openapi/openapi.yaml", + "openapiVer": "3.1.0" + } + } + }, + "sentry.io": { + "added": "2026-02-11T00:00:00.000Z", + "preferred": "v0", + "versions": { + "v0": { + "added": "2026-02-11T00:00:00.000Z", + "updated": "2026-02-11T00:00:00.000Z", + "info": { + "title": "Sentry API", + "description": "Sentry's API for error tracking, performance monitoring, and debugging.", + "version": "v0", + "x-apisguru-categories": ["monitoring", "developer_tools"], + "x-logo": { "url": "https://sentry.io/favicon.ico" }, + "x-origin": [{ "format": "openapi", "url": "https://raw.githubusercontent.com/getsentry/sentry-api-schema/main/openapi-derefed.json", "version": "3.0" }], + "x-providerName": "sentry.io" + }, + "swaggerUrl": "https://raw.githubusercontent.com/getsentry/sentry-api-schema/main/openapi-derefed.json", + "openapiVer": "3.0.3" + } + } + }, + "pagerduty.com": { + "added": "2026-02-11T00:00:00.000Z", + "preferred": "v2", + "versions": { + "v2": { + "added": "2026-02-11T00:00:00.000Z", + "updated": "2026-02-11T00:00:00.000Z", + "info": { + "title": "PagerDuty API", + "description": "PagerDuty's REST API for incident management, on-call scheduling, and alerting.", + "version": "v2", + "x-apisguru-categories": ["monitoring", "developer_tools"], + "x-logo": { "url": "https://www.pagerduty.com/favicon.ico" }, + "x-origin": [{ "format": "openapi", "url": "https://raw.githubusercontent.com/PagerDuty/api-schema/main/reference/REST/openapiv3.json", "version": "3.0" }], + "x-providerName": "pagerduty.com" + }, + "swaggerUrl": "https://raw.githubusercontent.com/PagerDuty/api-schema/main/reference/REST/openapiv3.json", + "openapiVer": "3.0.1" + } + } + }, + "intercom.io": { + "added": "2026-02-11T00:00:00.000Z", + "preferred": "2.13", + "versions": { + "2.13": { + "added": "2026-02-11T00:00:00.000Z", + "updated": "2026-02-11T00:00:00.000Z", + "info": { + "title": "Intercom API", + "description": "The Intercom API. Build customer messaging integrations with contacts, conversations, and more.", + "version": "2.13", + "x-apisguru-categories": ["messaging", "customer_support"], + "x-logo": { "url": "https://www.intercom.com/favicon.ico" }, + "x-origin": [{ "format": "openapi", "url": "https://raw.githubusercontent.com/intercom/Intercom-OpenAPI/main/descriptions/2.13/api.intercom.io.yaml", "version": "3.0" }], + "x-providerName": "intercom.io" + }, + "swaggerUrl": "https://raw.githubusercontent.com/intercom/Intercom-OpenAPI/main/descriptions/2.13/api.intercom.io.yaml", + "openapiVer": "3.0.1" + } + } + } +} diff --git a/sources/tsconfig.json b/sources/tsconfig.json new file mode 100644 index 000000000..54cb0729b --- /dev/null +++ b/sources/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": [ + "bun" + ], + "lib": [ + "ESNext", + "DOM" + ] + }, + "include": [ + "server.ts" + ] +}