History

wrangler? hardly know'er!

Rhys Sullivan committed 8 months ago

Commit f202f1e1 parent 889122f5 files · +620 −0Browse files

Unified · Split

5 files
  1. v2/packages/runtime-cloudflare-worker-loader/worker/src/index.ts +382 −0
  2. v2/packages/runtime-cloudflare-worker-loader/worker/src/isolate/globals.isolate.js +1 −0
  3. v2/packages/runtime-cloudflare-worker-loader/worker/src/isolate/harness.isolate.js +202 −0
  4. v2/packages/runtime-cloudflare-worker-loader/worker/wrangler.jsonc +21 −0
  5. v2/packages/schema/src/models/runtime-tool.ts +14 −0
v2/packages/runtime-cloudflare-worker-loader/worker/src/index.ts+382−0
@@ -0,0 +1,382 @@
1+import { WorkerEntrypoint } from "cloudflare:workers";
2+
3+import GLOBALS_MODULE from "./isolate/globals.isolate.js";
4+import HARNESS_CODE from "./isolate/harness.isolate.js";
5+
6+const DEFAULT_TIMEOUT_MS = 30_000;
7+const MAX_TIMEOUT_MS = 120_000;
8+
9+type RuntimeToolCallResult =
10+ | {
11+ ok: true;
12+ value: unknown;
13+ }
14+ | {
15+ ok: false;
16+ kind: "pending";
17+ approvalId: string;
18+ retryAfterMs: number;
19+ error?: string;
20+ }
21+ | {
22+ ok: false;
23+ kind: "denied";
24+ error: string;
25+ }
26+ | {
27+ ok: false;
28+ kind: "failed";
29+ error: string;
30+ };
31+
32+type RunRequest = {
33+ runId: string;
34+ taskId: string;
35+ code: string;
36+ timeoutMs?: number;
37+ callback: {
38+ url: string;
39+ internalSecret?: string;
40+ };
41+};
42+
43+type RunResult = {
44+ status: "completed" | "failed" | "timed_out" | "denied";
45+ result?: unknown;
46+ error?: string;
47+ exitCode?: number;
48+};
49+
50+type BridgeProps = {
51+ callbackUrl: string;
52+ callbackInternalSecret: string | null;
53+ runId: string;
54+};
55+
56+type Env = {
57+ AUTH_TOKEN: string;
58+ LOADER: {
59+ get: (
60+ id: string,
61+ init: () => Promise<{
62+ compatibilityDate: string;
63+ mainModule: string;
64+ modules: Record<string, string>;
65+ env: Record<string, unknown>;
66+ globalOutbound: null;
67+ }>,
68+ ) => {
69+ getEntrypoint: () => {
70+ fetch: (url: string, init: RequestInit) => Promise<Response>;
71+ };
72+ };
73+ };
74+};
75+
76+const json = (value: unknown, status = 200): Response =>
77+ new Response(JSON.stringify(value), {
78+ status,
79+ headers: {
80+ "content-type": "application/json",
81+ },
82+ });
83+
84+const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
85+ typeof value === "object" && value !== null && !Array.isArray(value);
86+
87+const isRuntimeToolCallResult = (value: unknown): value is RuntimeToolCallResult => {
88+ if (!isObjectRecord(value)) {
89+ return false;
90+ }
91+
92+ if (value.ok === true) {
93+ return Object.prototype.hasOwnProperty.call(value, "value");
94+ }
95+
96+ if (value.ok === false && value.kind === "pending") {
97+ return (
98+ typeof value.approvalId === "string" &&
99+ typeof value.retryAfterMs === "number"
100+ );
101+ }
102+
103+ if (value.ok === false && value.kind === "denied") {
104+ return typeof value.error === "string";
105+ }
106+
107+ if (value.ok === false && value.kind === "failed") {
108+ return typeof value.error === "string";
109+ }
110+
111+ return false;
112+};
113+
114+const isRunResult = (value: unknown): value is RunResult => {
115+ if (!isObjectRecord(value)) {
116+ return false;
117+ }
118+
119+ if (
120+ value.status !== "completed" &&
121+ value.status !== "failed" &&
122+ value.status !== "timed_out" &&
123+ value.status !== "denied"
124+ ) {
125+ return false;
126+ }
127+
128+ if (value.error !== undefined && typeof value.error !== "string") {
129+ return false;
130+ }
131+
132+ if (value.exitCode !== undefined && typeof value.exitCode !== "number") {
133+ return false;
134+ }
135+
136+ return true;
137+};
138+
139+const toFailedResult = (error: string): RunResult => ({
140+ status: "failed",
141+ error,
142+});
143+
144+const validateRunRequest = (value: unknown): RunRequest | null => {
145+ if (!isObjectRecord(value)) {
146+ return null;
147+ }
148+
149+ if (
150+ typeof value.runId !== "string" ||
151+ typeof value.taskId !== "string" ||
152+ typeof value.code !== "string"
153+ ) {
154+ return null;
155+ }
156+
157+ if (!isObjectRecord(value.callback) || typeof value.callback.url !== "string") {
158+ return null;
159+ }
160+
161+ if (
162+ value.callback.internalSecret !== undefined &&
163+ typeof value.callback.internalSecret !== "string"
164+ ) {
165+ return null;
166+ }
167+
168+ if (value.timeoutMs !== undefined && typeof value.timeoutMs !== "number") {
169+ return null;
170+ }
171+
172+ return {
173+ runId: value.runId,
174+ taskId: value.taskId,
175+ code: value.code,
176+ timeoutMs: value.timeoutMs,
177+ callback: {
178+ url: value.callback.url,
179+ internalSecret: value.callback.internalSecret,
180+ },
181+ };
182+};
183+
184+const buildUserModule = (userCode: string): string =>
185+ `export async function run(tools, console) {\n"use strict";\n${userCode}\n}\n`;
186+
187+const getBridgePropsFromContext = (
188+ context: ExecutionContext | Record<string, unknown> | null | undefined,
189+): BridgeProps => {
190+ if (!isObjectRecord(context) || !isObjectRecord(context.props)) {
191+ throw new Error("ToolBridge props are missing");
192+ }
193+
194+ const props = context.props;
195+
196+ if (
197+ typeof props.callbackUrl !== "string" ||
198+ typeof props.runId !== "string"
199+ ) {
200+ throw new Error("ToolBridge props are invalid");
201+ }
202+
203+ if (
204+ props.callbackInternalSecret !== null &&
205+ props.callbackInternalSecret !== undefined &&
206+ typeof props.callbackInternalSecret !== "string"
207+ ) {
208+ throw new Error("ToolBridge callback secret is invalid");
209+ }
210+
211+ return {
212+ callbackUrl: props.callbackUrl,
213+ callbackInternalSecret:
214+ props.callbackInternalSecret === undefined ? null : props.callbackInternalSecret,
215+ runId: props.runId,
216+ };
217+};
218+
219+class ToolBridge extends WorkerEntrypoint<Env> {
220+ private get props(): BridgeProps {
221+ return getBridgePropsFromContext(this.ctx);
222+ }
223+
224+ async callTool(
225+ toolPath: string,
226+ input: unknown,
227+ callId?: string,
228+ ): Promise<RuntimeToolCallResult> {
229+ const headers = new Headers({
230+ "content-type": "application/json",
231+ });
232+
233+ if (this.props.callbackInternalSecret) {
234+ headers.set("x-internal-secret", this.props.callbackInternalSecret);
235+ }
236+
237+ const response = await fetch(this.props.callbackUrl, {
238+ method: "POST",
239+ headers,
240+ body: JSON.stringify({
241+ runId: this.props.runId,
242+ callId: callId ?? `call_${crypto.randomUUID()}`,
243+ toolPath,
244+ input,
245+ }),
246+ }).catch((error: unknown) => {
247+ throw new Error(
248+ `Tool callback transport failed: ${
249+ error instanceof Error ? error.message : String(error)
250+ }`,
251+ );
252+ });
253+
254+ if (response.status !== 200) {
255+ return {
256+ ok: false,
257+ kind: "failed",
258+ error: `Tool callback returned ${response.status}`,
259+ };
260+ }
261+
262+ const payload = await response.json().catch(() => null);
263+ if (!isRuntimeToolCallResult(payload)) {
264+ return {
265+ ok: false,
266+ kind: "failed",
267+ error: "Tool callback returned invalid result payload",
268+ };
269+ }
270+
271+ return payload;
272+ }
273+}
274+
275+const executeSandboxRun = async (
276+ request: RunRequest,
277+ ctx: ExecutionContext,
278+ env: Env,
279+): Promise<RunResult> => {
280+ const timeoutMsRaw = request.timeoutMs ?? DEFAULT_TIMEOUT_MS;
281+ const timeoutMs = Math.max(1, Math.min(MAX_TIMEOUT_MS, Math.floor(timeoutMsRaw)));
282+
283+ const contextAsRecord = ctx as unknown as Record<string, unknown>;
284+ if (!isObjectRecord(contextAsRecord.exports)) {
285+ return toFailedResult("Execution context exports are unavailable");
286+ }
287+
288+ const toolBridgeFactory = contextAsRecord.exports.ToolBridge;
289+ if (typeof toolBridgeFactory !== "function") {
290+ return toFailedResult("Execution context ToolBridge export is unavailable");
291+ }
292+
293+ const toolBridgeBinding = toolBridgeFactory({
294+ props: {
295+ callbackUrl: request.callback.url,
296+ callbackInternalSecret: request.callback.internalSecret ?? null,
297+ runId: request.runId,
298+ },
299+ });
300+
301+ const worker = env.LOADER.get(request.taskId, async () => ({
302+ compatibilityDate: "2025-06-01",
303+ mainModule: "harness.js",
304+ modules: {
305+ "harness.js": HARNESS_CODE,
306+ "globals.js": GLOBALS_MODULE,
307+ "user-code.js": buildUserModule(request.code),
308+ },
309+ env: {
310+ TOOL_BRIDGE: toolBridgeBinding,
311+ },
312+ globalOutbound: null,
313+ }));
314+
315+ const entrypoint = worker.getEntrypoint();
316+ const controller = new AbortController();
317+ const timer = setTimeout(() => controller.abort(), timeoutMs);
318+
319+ let response: Response;
320+ try {
321+ response = await entrypoint.fetch("http://sandbox.internal/run", {
322+ method: "POST",
323+ signal: controller.signal,
324+ });
325+ } catch (error) {
326+ clearTimeout(timer);
327+ if (error instanceof DOMException && error.name === "AbortError") {
328+ return {
329+ status: "timed_out",
330+ error: `Execution timed out after ${timeoutMs}ms`,
331+ };
332+ }
333+
334+ return toFailedResult(
335+ `Sandbox isolate request failed: ${
336+ error instanceof Error ? error.message : String(error)
337+ }`,
338+ );
339+ }
340+
341+ clearTimeout(timer);
342+
343+ const payload = await response.json().catch(() => null);
344+ if (!isRunResult(payload)) {
345+ return toFailedResult("Sandbox isolate returned invalid response payload");
346+ }
347+
348+ return payload;
349+};
350+
351+export { ToolBridge };
352+
353+export default {
354+ async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
355+ const url = new URL(request.url);
356+
357+ if (url.pathname === "/health") {
358+ return new Response("ok", { status: 200 });
359+ }
360+
361+ if (url.pathname !== "/v1/runs" || request.method !== "POST") {
362+ return json({ error: "not_found" }, 404);
363+ }
364+
365+ if (typeof env.AUTH_TOKEN !== "string" || env.AUTH_TOKEN.length === 0) {
366+ return json({ error: "misconfigured_auth_token" }, 500);
367+ }
368+
369+ if (request.headers.get("authorization") !== `Bearer ${env.AUTH_TOKEN}`) {
370+ return json({ error: "unauthorized" }, 401);
371+ }
372+
373+ const payloadRaw = await request.json().catch(() => null);
374+ const payload = validateRunRequest(payloadRaw);
375+ if (!payload) {
376+ return json({ error: "invalid_payload" }, 400);
377+ }
378+
379+ const runResult = await executeSandboxRun(payload, ctx, env);
380+ return json(runResult, 200);
381+ },
382+};
v2/packages/runtime-cloudflare-worker-loader/worker/src/isolate/globals.isolate.js+1−0
@@ -0,0 +1 @@
1+export const ResponseJson = Response.json.bind(Response);
v2/packages/runtime-cloudflare-worker-loader/worker/src/isolate/harness.isolate.js+202−0
@@ -0,0 +1,202 @@
1+import { ResponseJson as _ResponseJson } from "./globals.js";
2+import { run } from "./user-code.js";
3+
4+const APPROVAL_DENIED_PREFIX = "APPROVAL_DENIED:";
5+
6+function decodeToolBridgeResult(rawResult) {
7+ const parsed = (() => {
8+ if (typeof rawResult === "string") {
9+ try {
10+ return JSON.parse(rawResult);
11+ } catch {
12+ return null;
13+ }
14+ }
15+
16+ if (rawResult && typeof rawResult === "object") {
17+ return rawResult;
18+ }
19+
20+ return null;
21+ })();
22+
23+ if (!parsed || typeof parsed !== "object") {
24+ return {
25+ ok: false,
26+ kind: "failed",
27+ error: "Tool bridge returned an invalid payload",
28+ };
29+ }
30+
31+ if (parsed.ok === true) {
32+ return {
33+ ok: true,
34+ value: parsed.value,
35+ };
36+ }
37+
38+ if (
39+ parsed.ok === false
40+ && parsed.kind === "pending"
41+ && typeof parsed.approvalId === "string"
42+ && typeof parsed.retryAfterMs === "number"
43+ ) {
44+ return {
45+ ok: false,
46+ kind: "pending",
47+ approvalId: parsed.approvalId,
48+ retryAfterMs: parsed.retryAfterMs,
49+ ...(typeof parsed.error === "string" ? { error: parsed.error } : {}),
50+ };
51+ }
52+
53+ if (parsed.ok === false && parsed.kind === "denied" && typeof parsed.error === "string") {
54+ return {
55+ ok: false,
56+ kind: "denied",
57+ error: parsed.error,
58+ };
59+ }
60+
61+ if (parsed.ok === false && parsed.kind === "failed" && typeof parsed.error === "string") {
62+ return {
63+ ok: false,
64+ kind: "failed",
65+ error: parsed.error,
66+ };
67+ }
68+
69+ return {
70+ ok: false,
71+ kind: "failed",
72+ error: "Tool bridge returned a malformed payload",
73+ };
74+}
75+
76+function describeExecutionError(error) {
77+ if (error instanceof Error) {
78+ const message = typeof error.message === "string" ? error.message.trim() : "";
79+ if (message.length > 0) {
80+ return message;
81+ }
82+
83+ const stack = typeof error.stack === "string" ? error.stack.trim() : "";
84+ if (stack.length > 0) {
85+ return stack;
86+ }
87+
88+ return "Execution failed with an empty Error object";
89+ }
90+
91+ const text = String(error).trim();
92+ if (text.length > 0) {
93+ return text;
94+ }
95+
96+ return "Execution failed with an empty non-Error throw value";
97+}
98+
99+function createToolsProxy(bridge, path = []) {
100+ const callable = () => {};
101+
102+ return new Proxy(callable, {
103+ get(_target, prop) {
104+ if (prop === "then") {
105+ return undefined;
106+ }
107+
108+ if (typeof prop !== "string") {
109+ return undefined;
110+ }
111+
112+ return createToolsProxy(bridge, [...path, prop]);
113+ },
114+
115+ async apply(_target, _thisArg, args) {
116+ const toolPath = path.join(".");
117+ if (!toolPath) {
118+ throw new Error("Tool path missing");
119+ }
120+
121+ const input = args.length > 0 ? args[0] : {};
122+ const callId = `call_${crypto.randomUUID()}`;
123+ const result = decodeToolBridgeResult(
124+ await bridge.callTool(toolPath, input, callId),
125+ );
126+
127+ if (result.ok) {
128+ return result.value;
129+ }
130+
131+ if (result.kind === "pending") {
132+ throw new Error(
133+ `Tool call is unexpectedly pending approval (${result.approvalId})`,
134+ );
135+ }
136+
137+ if (result.kind === "denied") {
138+ throw new Error(APPROVAL_DENIED_PREFIX + result.error);
139+ }
140+
141+ const errorText = typeof result.error === "string" ? result.error.trim() : "";
142+ throw new Error(
143+ errorText.length > 0
144+ ? errorText
145+ : `Tool call failed without an error message (${toolPath})`,
146+ );
147+ },
148+ });
149+}
150+
151+function sanitizeExecutionResult(value) {
152+ if (value === undefined) {
153+ return undefined;
154+ }
155+
156+ try {
157+ const serialized = JSON.stringify(value);
158+ if (serialized === undefined) {
159+ return null;
160+ }
161+
162+ return JSON.parse(serialized);
163+ } catch {
164+ return String(value);
165+ }
166+}
167+
168+export default {
169+ async fetch(_request, env, _ctx) {
170+ const tools = createToolsProxy(env.TOOL_BRIDGE);
171+ const console = {
172+ log: (..._args) => {},
173+ info: (..._args) => {},
174+ warn: (..._args) => {},
175+ error: (..._args) => {},
176+ };
177+
178+ try {
179+ const value = await run(tools, console);
180+ return _ResponseJson({
181+ status: "completed",
182+ result: sanitizeExecutionResult(value),
183+ exitCode: 0,
184+ });
185+ } catch (error) {
186+ const message = describeExecutionError(error);
187+
188+ if (message.startsWith(APPROVAL_DENIED_PREFIX)) {
189+ const denied = message.replace(APPROVAL_DENIED_PREFIX, "").trim();
190+ return _ResponseJson({
191+ status: "denied",
192+ error: denied,
193+ });
194+ }
195+
196+ return _ResponseJson({
197+ status: "failed",
198+ error: message,
199+ });
200+ }
201+ },
202+};
v2/packages/runtime-cloudflare-worker-loader/worker/wrangler.jsonc+21−0
@@ -0,0 +1,21 @@
1+{
2+ "name": "executor-v2-runtime-cloudflare-worker-loader",
3+ "main": "src/index.ts",
4+ "compatibility_date": "2025-06-01",
5+ "compatibility_flags": ["enable_ctx_exports", "nodejs_compat"],
6+ "vars": {
7+ "AUTH_TOKEN": "sandbox-token"
8+ },
9+ "rules": [
10+ {
11+ "type": "Text",
12+ "globs": ["**/*.isolate.js"],
13+ "fallthrough": true
14+ }
15+ ],
16+ "worker_loaders": [
17+ {
18+ "binding": "LOADER"
19+ }
20+ ]
21+}
v2/packages/schema/src/models/runtime-tool.ts+14−0
@@ -62,6 +62,12 @@ export const OpenApiInvocationPayloadSchema = Schema.Struct({ · 61 unmodified lines
6262 requestBody: Schema.NullOr(OpenApiToolRequestBodySchema),
6363 });
6464
65+export const DiscoveryTypingPayloadSchema = Schema.Struct({
66+ inputSchemaJson: Schema.optional(Schema.String),
67+ outputSchemaJson: Schema.optional(Schema.String),
68+ refHintKeys: Schema.optional(Schema.Array(Schema.String)),
69+});
70+
6571 export const OpenApiExtractedToolSchema = Schema.Struct({
6672 toolId: Schema.String,
6773 name: Schema.String,
@@ -70,12 +76,19 @@ export const OpenApiExtractedToolSchema = Schema.Struct({ · 2 unmodified lines
7076 path: Schema.String,
7177 invocation: OpenApiInvocationPayloadSchema,
7278 operationHash: Schema.String,
79+ typing: Schema.optional(DiscoveryTypingPayloadSchema),
7380 });
7481
7582 export const OpenApiToolManifestSchema = Schema.Struct({
7683 version: Schema.Literal(1),
7784 sourceHash: Schema.String,
7885 tools: Schema.Array(OpenApiExtractedToolSchema),
86+ refHintTable: Schema.optional(
87+ Schema.Record({
88+ key: Schema.String,
89+ value: Schema.String,
90+ }),
91+ ),
7992 });
8093
8194 const SourceBackedCanonicalToolDescriptorBaseSchema = Schema.Struct({
@@ -170,6 +183,7 @@ export type OpenApiParameterLocation = typeof OpenApiParameterLocationSchema.Typ · 88 unmodified lines
170183 export type OpenApiToolParameter = typeof OpenApiToolParameterSchema.Type;
171184 export type OpenApiToolRequestBody = typeof OpenApiToolRequestBodySchema.Type;
172185 export type OpenApiInvocationPayload = typeof OpenApiInvocationPayloadSchema.Type;
186+export type DiscoveryTypingPayload = typeof DiscoveryTypingPayloadSchema.Type;
173187 export type OpenApiExtractedTool = typeof OpenApiExtractedToolSchema.Type;
174188 export type OpenApiToolManifest = typeof OpenApiToolManifestSchema.Type;
175189