The sandbox→host RPC bridge JSON.stringify'd every tool argument before crossing the Worker RPC boundary, and JSON.parse'd it on the host side. Binary values were silently destroyed: `new Blob([...])` arrived as `{}`, and `new Uint8Array([...])` arrived as a numeric-keyed object. Multipart upload tools (OpenAI Files API, anything with a `multipart/form-data` request body) then produced malformed parts and upstream servers returned 400. Switches the dispatcher from `(path: string, argsJson: string) => string` to `(path: string, args: unknown) => WorkerRpcResponse`, so structured clone carries `Uint8Array` / `ArrayBuffer` natively. Workers RPC excludes `Blob` / `File` from its allow-list, so we add a small codec that walks args and results in both directions and round-trips them as tagged ArrayBuffer envelopes (`{ __executorBinary: 1, kind, type, name?, lastModified?, buffer }`). The same codec runs symmetrically on tool results so a tool returning a Blob (e.g. file downloads) survives the host→sandbox hop. Adds repro tests for both directions: - Uint8Array arg survives intact - Blob arg survives with type - Uint8Array result returns to user code - Blob result returns with type and bytes Existing dispatcher tests update from the JSON-string contract to the structured-value contract; nothing outside this package consumed the dropped `encodeWorkerRpcResponse` / `decodeWorkerRpcResponse` helpers.
Unified · Split
| @@ -136,7 +136,12 @@ const renderTransportMessage = (value: unknown): string => { · 135 unmodified lines | ||
| 136 | 136 | return value.message; |
| 137 | 137 | } |
| 138 | 138 | |
| 139 | − if (typeof value === "object" && value !== null && "message" in value && typeof value.message === "string") { | |
| 139 | + if ( | |
| 140 | + typeof value === "object" && | |
| 141 | + value !== null && | |
| 142 | + "message" in value && | |
| 143 | + typeof value.message === "string" | |
| 144 | + ) { | |
| 140 | 145 | return value.message; |
| 141 | 146 | } |
| 142 | 147 | |
| @@ -156,8 +161,12 @@ const renderTransportMessage = (value: unknown): string => { · 13 unmodified lines | ||
| 156 | 161 | }; |
| 157 | 162 | |
| 158 | 163 | export const serializeWorkerCause = (cause: Cause.Cause<unknown>): SerializedWorkerError => { |
| 159 | − const failures = cause.reasons.filter(Cause.isFailReason).map((reason) => serializeWorkerErrorValue(reason.error)); | |
| 160 | − const defects = cause.reasons.filter(Cause.isDieReason).map((reason) => serializeWorkerErrorValue(reason.defect)); | |
| 164 | + const failures = cause.reasons | |
| 165 | + .filter(Cause.isFailReason) | |
| 166 | + .map((reason) => serializeWorkerErrorValue(reason.error)); | |
| 167 | + const defects = cause.reasons | |
| 168 | + .filter(Cause.isDieReason) | |
| 169 | + .map((reason) => serializeWorkerErrorValue(reason.defect)); | |
| 161 | 170 | const interrupted = cause.reasons.some(Cause.isInterruptReason); |
| 162 | 171 | const primary = failures[0] ?? defects[0] ?? null; |
| 163 | 172 | const kind = |
| @@ -215,10 +224,93 @@ export const renderWorkerError = (error: SerializedWorkerError): string => { · 51 unmodified lines | ||
| 215 | 224 | return error.message; |
| 216 | 225 | }; |
| 217 | 226 | |
| 218 | −const encodeWorkerRpcResponse = (response: WorkerRpcResponse): string => JSON.stringify(response); | |
| 227 | +export type { WorkerRpcResponse }; | |
| 228 | + | |
| 229 | +// --------------------------------------------------------------------------- | |
| 230 | +// Blob/File codec (both directions across the dispatcher boundary) | |
| 231 | +// | |
| 232 | +// Workers RPC's structured-clone allow-list excludes `Blob` / `File`, so | |
| 233 | +// we encode them to a tagged ArrayBuffer envelope and rehydrate on the | |
| 234 | +// far side. Symmetric in both directions: sandbox encodes args + host | |
| 235 | +// rehydrates them; host encodes result + sandbox rehydrates it. The | |
| 236 | +// matching encoder lives inside `module-template.ts` because it runs in | |
| 237 | +// the dynamic Worker isolate. `ArrayBuffer` / typed arrays / primitives | |
| 238 | +// cross structured clone natively. | |
| 239 | +// --------------------------------------------------------------------------- | |
| 240 | + | |
| 241 | +type BinaryEnvelope = { | |
| 242 | + readonly __executorBinary: 1; | |
| 243 | + readonly kind: "blob" | "file"; | |
| 244 | + readonly type: string; | |
| 245 | + readonly name?: string; | |
| 246 | + readonly lastModified?: number; | |
| 247 | + readonly buffer: ArrayBuffer; | |
| 248 | +}; | |
| 249 | + | |
| 250 | +const isBinaryEnvelope = (value: unknown): value is BinaryEnvelope => | |
| 251 | + typeof value === "object" && | |
| 252 | + value !== null && | |
| 253 | + (value as { __executorBinary?: unknown }).__executorBinary === 1 && | |
| 254 | + (value as { buffer?: unknown }).buffer instanceof ArrayBuffer && | |
| 255 | + typeof (value as { type?: unknown }).type === "string"; | |
| 256 | + | |
| 257 | +const isPlainObject = (value: object): boolean => { | |
| 258 | + const proto = Object.getPrototypeOf(value); | |
| 259 | + return proto === Object.prototype || proto === null; | |
| 260 | +}; | |
| 261 | + | |
| 262 | +const rehydrateBinary = (value: unknown): unknown => { | |
| 263 | + if (value === null || typeof value !== "object") return value; | |
| 264 | + if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return value; | |
| 265 | + if (isBinaryEnvelope(value)) { | |
| 266 | + if (value.kind === "file" && typeof value.name === "string") { | |
| 267 | + return new File([value.buffer], value.name, { | |
| 268 | + type: value.type, | |
| 269 | + ...(typeof value.lastModified === "number" ? { lastModified: value.lastModified } : {}), | |
| 270 | + }); | |
| 271 | + } | |
| 272 | + return new Blob([value.buffer], { type: value.type }); | |
| 273 | + } | |
| 274 | + if (Array.isArray(value)) return value.map(rehydrateBinary); | |
| 275 | + if (!isPlainObject(value)) return value; | |
| 276 | + const out: Record<string, unknown> = {}; | |
| 277 | + for (const [k, v] of Object.entries(value as Record<string, unknown>)) { | |
| 278 | + out[k] = rehydrateBinary(v); | |
| 279 | + } | |
| 280 | + return out; | |
| 281 | +}; | |
| 219 | 282 | |
| 220 | −export const decodeWorkerRpcResponse = (raw: string): WorkerRpcResponse => | |
| 221 | − JSON.parse(raw) as WorkerRpcResponse; | |
| 283 | +// Async because `Blob.arrayBuffer()` is async. Used on tool results before | |
| 284 | +// the dispatcher hands them back to the sandbox. | |
| 285 | +const encodeBinary = async (value: unknown): Promise<unknown> => { | |
| 286 | + if (value === null || typeof value !== "object") return value; | |
| 287 | + if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return value; | |
| 288 | + if (typeof File !== "undefined" && value instanceof File) { | |
| 289 | + return { | |
| 290 | + __executorBinary: 1 as const, | |
| 291 | + kind: "file" as const, | |
| 292 | + type: value.type, | |
| 293 | + name: value.name, | |
| 294 | + lastModified: value.lastModified, | |
| 295 | + buffer: await value.arrayBuffer(), | |
| 296 | + }; | |
| 297 | + } | |
| 298 | + if (value instanceof Blob) { | |
| 299 | + return { | |
| 300 | + __executorBinary: 1 as const, | |
| 301 | + kind: "blob" as const, | |
| 302 | + type: value.type, | |
| 303 | + buffer: await value.arrayBuffer(), | |
| 304 | + }; | |
| 305 | + } | |
| 306 | + if (Array.isArray(value)) return Promise.all(value.map(encodeBinary)); | |
| 307 | + if (!isPlainObject(value)) return value; | |
| 308 | + const out: Record<string, unknown> = {}; | |
| 309 | + for (const [k, v] of Object.entries(value as Record<string, unknown>)) { | |
| 310 | + out[k] = await encodeBinary(v); | |
| 311 | + } | |
| 312 | + return out; | |
| 313 | +}; | |
| 222 | 314 | |
| 223 | 315 | // --------------------------------------------------------------------------- |
| 224 | 316 | // ToolDispatcher — bridges RPC calls back to SandboxToolInvoker |
| @@ -227,7 +319,12 @@ export const decodeWorkerRpcResponse = (raw: string): WorkerRpcResponse => · 2 unmodified lines | ||
| 227 | 319 | /** |
| 228 | 320 | * An `RpcTarget` passed to the dynamic Worker so that sandboxed code can |
| 229 | 321 | * invoke tools on the host. The dynamic worker calls |
| 230 | − * `__dispatcher.call(path, argsJson)` over Workers RPC. | |
| 322 | + * `__dispatcher.call(path, args)` over Workers RPC. `Uint8Array` / | |
| 323 | + * `ArrayBuffer` cross structured clone natively; `Blob` / `File` are | |
| 324 | + * encoded sandbox-side as a tagged envelope and rehydrated here via | |
| 325 | + * `rehydrateBinary` before the invoker sees them. JSON serialization on | |
| 326 | + * this hop would replace those values with `"{}"` or numeric-keyed | |
| 327 | + * objects, which is what broke `multipart/form-data` uploads. | |
| 231 | 328 | * |
| 232 | 329 | * Each call is wrapped in an `executor.tool.rpc_dispatch` span so the |
| 233 | 330 | * tool-invocation shell (Workers RPC roundtrip → local invoker → |
| @@ -248,15 +345,17 @@ export class ToolDispatcher extends RpcTarget { · 14 unmodified lines | ||
| 248 | 345 | this.#runPromise = runPromise; |
| 249 | 346 | } |
| 250 | 347 | |
| 251 | − async call(path: string, argsJson: string): Promise<string> { | |
| 252 | − const args = argsJson ? JSON.parse(argsJson) : undefined; | |
| 253 | − | |
| 348 | + async call(path: string, args: unknown): Promise<WorkerRpcResponse> { | |
| 349 | + const decodedArgs = rehydrateBinary(args); | |
| 254 | 350 | return this.#runPromise( |
| 255 | − this.#invoker.invoke({ path, args }).pipe( | |
| 256 | − Effect.map( | |
| 257 | − (value): WorkerRpcResponse => ({ | |
| 258 | − ok: true, | |
| 259 | − result: value, | |
| 351 | + this.#invoker.invoke({ path, args: decodedArgs }).pipe( | |
| 352 | + Effect.flatMap((value) => | |
| 353 | + Effect.tryPromise({ | |
| 354 | + try: (): Promise<WorkerRpcResponse> => | |
| 355 | + encodeBinary(value).then((result) => ({ ok: true, result })), | |
| 356 | + // Encoding failed (e.g. Blob.arrayBuffer rejected) — surface | |
| 357 | + // it as a normal failure envelope rather than throwing. | |
| 358 | + catch: (cause) => cause, | |
| 260 | 359 | }), |
| 261 | 360 | ), |
| 262 | 361 | Effect.catchCause((cause) => |
| @@ -265,11 +364,9 @@ export class ToolDispatcher extends RpcTarget { · 2 unmodified lines | ||
| 265 | 364 | error: serializeWorkerCause(cause), |
| 266 | 365 | }), |
| 267 | 366 | ), |
| 268 | − Effect.map(encodeWorkerRpcResponse), | |
| 269 | 367 | Effect.withSpan("executor.tool.rpc_dispatch", { |
| 270 | 368 | attributes: { |
| 271 | 369 | "mcp.tool.name": path, |
| 272 | − "executor.tool.args_length": argsJson.length, | |
| 273 | 370 | }, |
| 274 | 371 | }), |
| 275 | 372 | ), |
| @@ -6,7 +6,6 @@ import * as Effect from "effect/Effect"; · 5 unmodified lines | ||
| 6 | 6 | import type { SandboxToolInvoker } from "@executor-js/codemode-core"; |
| 7 | 7 | import { |
| 8 | 8 | ToolDispatcher, |
| 9 | − decodeWorkerRpcResponse, | |
| 10 | 9 | makeDynamicWorkerExecutor, |
| 11 | 10 | renderWorkerError, |
| 12 | 11 | serializeWorkerCause, |
| @@ -35,15 +34,15 @@ describe("ToolDispatcher", () => { · 22 unmodified lines | ||
| 35 | 34 | const invoker = makeInvoker(({ args }) => args); |
| 36 | 35 | const dispatcher = new ToolDispatcher(invoker, Effect.runPromise); |
| 37 | 36 | |
| 38 | − const result = await dispatcher.call("test.tool", '{"key":"value"}'); | |
| 39 | − expect(decodeWorkerRpcResponse(result)).toEqual({ ok: true, result: { key: "value" } }); | |
| 37 | + const result = await dispatcher.call("test.tool", { key: "value" }); | |
| 38 | + expect(result).toEqual({ ok: true, result: { key: "value" } }); | |
| 40 | 39 | }); |
| 41 | 40 | |
| 42 | 41 | it("serializes tagged failures into a structured error envelope", async () => { |
| 43 | 42 | const dispatcher = new ToolDispatcher(failingInvoker("tool broke"), Effect.runPromise); |
| 44 | 43 | |
| 45 | − const result = await dispatcher.call("broken.tool", "{}"); | |
| 46 | − expect(decodeWorkerRpcResponse(result)).toMatchObject({ | |
| 44 | + const result = await dispatcher.call("broken.tool", {}); | |
| 45 | + expect(result).toMatchObject({ | |
| 47 | 46 | ok: false, |
| 48 | 47 | error: { |
| 49 | 48 | kind: "fail", |
| @@ -68,8 +67,8 @@ describe("ToolDispatcher", () => { · 18 unmodified lines | ||
| 68 | 67 | Effect.runPromise, |
| 69 | 68 | ); |
| 70 | 69 | |
| 71 | − const result = await dispatcher.call("broken.tool", "{}"); | |
| 72 | − expect(decodeWorkerRpcResponse(result)).toEqual({ | |
| 70 | + const result = await dispatcher.call("broken.tool", {}); | |
| 71 | + expect(result).toEqual({ | |
| 73 | 72 | ok: false, |
| 74 | 73 | error: { |
| 75 | 74 | kind: "fail", |
| @@ -94,8 +93,8 @@ describe("ToolDispatcher", () => { · 18 unmodified lines | ||
| 94 | 93 | const invoker = makeInvoker(({ args }) => args); |
| 95 | 94 | const dispatcher = new ToolDispatcher(invoker, Effect.runPromise); |
| 96 | 95 | |
| 97 | − const result = await dispatcher.call("test.tool", ""); | |
| 98 | − expect(decodeWorkerRpcResponse(result)).toEqual({ ok: true, result: undefined }); | |
| 96 | + const result = await dispatcher.call("test.tool", undefined); | |
| 97 | + expect(result).toEqual({ ok: true, result: undefined }); | |
| 99 | 98 | }); |
| 100 | 99 | |
| 101 | 100 | it("passes the tool path correctly", async () => { |
| @@ -106,7 +105,7 @@ describe("ToolDispatcher", () => { · 4 unmodified lines | ||
| 106 | 105 | }); |
| 107 | 106 | const dispatcher = new ToolDispatcher(invoker, Effect.runPromise); |
| 108 | 107 | |
| 109 | − await dispatcher.call("my.deep.tool.path", "{}"); | |
| 108 | + await dispatcher.call("my.deep.tool.path", {}); | |
| 110 | 109 | expect(capturedPath).toBe("my.deep.tool.path"); |
| 111 | 110 | }); |
| 112 | 111 | }); |
| @@ -145,7 +144,10 @@ describe("makeDynamicWorkerExecutor", () => { · 32 unmodified lines | ||
| 145 | 144 | const invoker = makeInvoker(() => null); |
| 146 | 145 | |
| 147 | 146 | const result = await Effect.runPromise( |
| 148 | − executor.execute(["Use this snippet.", "", "```ts", "async () => 42", "```"].join("\n"), invoker), | |
| 147 | + executor.execute( | |
| 148 | + ["Use this snippet.", "", "```ts", "async () => 42", "```"].join("\n"), | |
| 149 | + invoker, | |
| 150 | + ), | |
| 149 | 151 | ); |
| 150 | 152 | |
| 151 | 153 | expect(result.error).toBeUndefined(); |
| @@ -319,4 +321,104 @@ describe("makeDynamicWorkerExecutor", () => { · 167 unmodified lines | ||
| 319 | 321 | |
| 320 | 322 | expect(result.error).toBeDefined(); |
| 321 | 323 | }); |
| 324 | + | |
| 325 | + // Multipart/form-data uploads (OpenAI Files API, any spec with a | |
| 326 | + // `multipart/form-data` body) need Blob/File/Uint8Array values to | |
| 327 | + // survive the sandbox→host RPC hop intact. JSON.stringify turns a Blob | |
| 328 | + // into "{}" and a Uint8Array into a numeric-keyed object, so | |
| 329 | + // `coerceFormDataRecord` produces a malformed multipart part and the | |
| 330 | + // upstream server 400s. | |
| 331 | + it.effect("preserves Uint8Array tool args across the dispatcher boundary", () => | |
| 332 | + Effect.gen(function* () { | |
| 333 | + const executor = makeDynamicWorkerExecutor({ loader }); | |
| 334 | + let captured: { file?: unknown } = {}; | |
| 335 | + const invoker = makeInvoker(({ args }) => { | |
| 336 | + captured = (args ?? {}) as { file?: unknown }; | |
| 337 | + return null; | |
| 338 | + }); | |
| 339 | + | |
| 340 | + yield* executor.execute( | |
| 341 | + `async () => { | |
| 342 | + const bytes = new Uint8Array([0xde, 0xad, 0xbe, 0xef]); | |
| 343 | + await tools.uploads.send({ file: bytes }); | |
| 344 | + }`, | |
| 345 | + invoker, | |
| 346 | + ); | |
| 347 | + | |
| 348 | + expect(captured.file).toBeInstanceOf(Uint8Array); | |
| 349 | + expect(Array.from(captured.file as Uint8Array)).toEqual([0xde, 0xad, 0xbe, 0xef]); | |
| 350 | + }), | |
| 351 | + ); | |
| 352 | + | |
| 353 | + it.effect("preserves Blob tool args across the dispatcher boundary", () => | |
| 354 | + Effect.gen(function* () { | |
| 355 | + const executor = makeDynamicWorkerExecutor({ loader }); | |
| 356 | + let captured: { file?: unknown } = {}; | |
| 357 | + const invoker = makeInvoker(({ args }) => { | |
| 358 | + captured = (args ?? {}) as { file?: unknown }; | |
| 359 | + return null; | |
| 360 | + }); | |
| 361 | + | |
| 362 | + const result = yield* executor.execute( | |
| 363 | + `async () => { | |
| 364 | + const file = new Blob(["hello multipart"], { type: "text/plain" }); | |
| 365 | + await tools.uploads.send({ file }); | |
| 366 | + }`, | |
| 367 | + invoker, | |
| 368 | + ); | |
| 369 | + | |
| 370 | + expect(result.error).toBeUndefined(); | |
| 371 | + expect(captured.file).toBeInstanceOf(Blob); | |
| 372 | + const blob = captured.file as Blob; | |
| 373 | + expect(blob.type).toBe("text/plain"); | |
| 374 | + const body = yield* Effect.promise(() => blob.text()); | |
| 375 | + expect(body).toBe("hello multipart"); | |
| 376 | + }), | |
| 377 | + ); | |
| 378 | + | |
| 379 | + // Symmetric direction: tool RESULT contains Blob/Uint8Array/File. Workers | |
| 380 | + // RPC has the same "Could not serialize Blob" limit on the way back, so | |
| 381 | + // tool implementations that return file-like data need the host→sandbox | |
| 382 | + // codec too. This pins down which types survive both directions. | |
| 383 | + it.effect("returns Uint8Array tool results to the sandbox intact", () => | |
| 384 | + Effect.gen(function* () { | |
| 385 | + const executor = makeDynamicWorkerExecutor({ loader }); | |
| 386 | + const invoker = makeInvoker(() => new Uint8Array([0xca, 0xfe, 0xba, 0xbe])); | |
| 387 | + | |
| 388 | + const result = yield* executor.execute( | |
| 389 | + `async () => { | |
| 390 | + const bytes = await tools.download.fetch({}); | |
| 391 | + if (!(bytes instanceof Uint8Array)) return { kind: typeof bytes, ctor: bytes && bytes.constructor && bytes.constructor.name }; | |
| 392 | + return { kind: 'Uint8Array', length: bytes.length, bytes: Array.from(bytes) }; | |
| 393 | + }`, | |
| 394 | + invoker, | |
| 395 | + ); | |
| 396 | + | |
| 397 | + expect(result.error).toBeUndefined(); | |
| 398 | + expect(result.result).toEqual({ | |
| 399 | + kind: "Uint8Array", | |
| 400 | + length: 4, | |
| 401 | + bytes: [0xca, 0xfe, 0xba, 0xbe], | |
| 402 | + }); | |
| 403 | + }), | |
| 404 | + ); | |
| 405 | + | |
| 406 | + it.effect("returns Blob tool results to the sandbox intact", () => | |
| 407 | + Effect.gen(function* () { | |
| 408 | + const executor = makeDynamicWorkerExecutor({ loader }); | |
| 409 | + const invoker = makeInvoker(() => new Blob(["DOWNLOAD"], { type: "text/plain" })); | |
| 410 | + | |
| 411 | + const result = yield* executor.execute( | |
| 412 | + `async () => { | |
| 413 | + const blob = await tools.download.fetch({}); | |
| 414 | + if (!(blob instanceof Blob)) return { kind: typeof blob }; | |
| 415 | + return { kind: 'Blob', type: blob.type, text: await blob.text() }; | |
| 416 | + }`, | |
| 417 | + invoker, | |
| 418 | + ); | |
| 419 | + | |
| 420 | + expect(result.error).toBeUndefined(); | |
| 421 | + expect(result.result).toEqual({ kind: "Blob", type: "text/plain", text: "DOWNLOAD" }); | |
| 422 | + }), | |
| 423 | + ); | |
| 322 | 424 | }); |
| @@ -7,6 +7,13 @@ · 6 unmodified lines | ||
| 7 | 7 | * 2. Creates a recursive `tools` Proxy that dispatches calls via RPC. |
| 8 | 8 | * 3. Executes the normalised user code with a `Promise.race` timeout. |
| 9 | 9 | * 4. Returns `{ result, error?, logs }`. |
| 10 | + * | |
| 11 | + * Tool args cross the dispatcher boundary via Workers RPC, not JSON, so | |
| 12 | + * `Uint8Array` / `ArrayBuffer` survive intact. `Blob` / `File` aren't on | |
| 13 | + * RPC's structured-clone allow-list, so we encode them to a tagged | |
| 14 | + * envelope (`__encodeBinary`) and the host rehydrates them. This is | |
| 15 | + * required for any tool whose upstream wants `multipart/form-data` | |
| 16 | + * (OpenAI Files API, etc.). | |
| 10 | 17 | */ |
| 11 | 18 | export const buildExecutorModule = (body: string, timeoutMs: number): string => |
| 12 | 19 | [ |
| @@ -65,6 +72,58 @@ export const buildExecutorModule = (body: string, timeoutMs: number): string => · 52 unmodified lines | ||
| 65 | 72 | " };", |
| 66 | 73 | " };", |
| 67 | 74 | "", |
| 75 | + // Blob/File aren't on Workers RPC's structured-clone allow-list, so | |
| 76 | + // we encode them to a tagged ArrayBuffer envelope before the call and | |
| 77 | + // the host rehydrates them — and symmetrically the host encodes | |
| 78 | + // Blob/File results which we rehydrate here. ArrayBuffer / typed | |
| 79 | + // arrays cross natively. | |
| 80 | + " const __isPlainObject = (v) => {", | |
| 81 | + " const p = Object.getPrototypeOf(v);", | |
| 82 | + " return p === Object.prototype || p === null;", | |
| 83 | + " };", | |
| 84 | + " const __isBinaryEnvelope = (v) =>", | |
| 85 | + " v && typeof v === 'object' && v.__executorBinary === 1 &&", | |
| 86 | + " v.buffer instanceof ArrayBuffer && typeof v.type === 'string';", | |
| 87 | + " const __encodeBinary = async (value) => {", | |
| 88 | + " if (value === null || typeof value !== 'object') return value;", | |
| 89 | + " if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return value;", | |
| 90 | + " if (typeof File !== 'undefined' && value instanceof File) {", | |
| 91 | + " return {", | |
| 92 | + " __executorBinary: 1,", | |
| 93 | + " kind: 'file',", | |
| 94 | + " type: value.type,", | |
| 95 | + " name: value.name,", | |
| 96 | + " lastModified: value.lastModified,", | |
| 97 | + " buffer: await value.arrayBuffer(),", | |
| 98 | + " };", | |
| 99 | + " }", | |
| 100 | + " if (value instanceof Blob) {", | |
| 101 | + " return { __executorBinary: 1, kind: 'blob', type: value.type, buffer: await value.arrayBuffer() };", | |
| 102 | + " }", | |
| 103 | + " if (Array.isArray(value)) return Promise.all(value.map(__encodeBinary));", | |
| 104 | + " if (!__isPlainObject(value)) return value;", | |
| 105 | + " const out = {};", | |
| 106 | + " for (const [k, v] of Object.entries(value)) out[k] = await __encodeBinary(v);", | |
| 107 | + " return out;", | |
| 108 | + " };", | |
| 109 | + " const __decodeBinary = (value) => {", | |
| 110 | + " if (value === null || typeof value !== 'object') return value;", | |
| 111 | + " if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return value;", | |
| 112 | + " if (__isBinaryEnvelope(value)) {", | |
| 113 | + " if (value.kind === 'file' && typeof value.name === 'string') {", | |
| 114 | + " return new File([value.buffer], value.name, {", | |
| 115 | + " type: value.type,", | |
| 116 | + " ...(typeof value.lastModified === 'number' ? { lastModified: value.lastModified } : {}),", | |
| 117 | + " });", | |
| 118 | + " }", | |
| 119 | + " return new Blob([value.buffer], { type: value.type });", | |
| 120 | + " }", | |
| 121 | + " if (Array.isArray(value)) return value.map(__decodeBinary);", | |
| 122 | + " if (!__isPlainObject(value)) return value;", | |
| 123 | + " const out = {};", | |
| 124 | + " for (const [k, v] of Object.entries(value)) out[k] = __decodeBinary(v);", | |
| 125 | + " return out;", | |
| 126 | + " };", | |
| 68 | 127 | " const __makeToolsProxy = (path = []) => new Proxy(() => undefined, {", |
| 69 | 128 | " get(_target, prop) {", |
| 70 | 129 | " if (prop === 'then' || typeof prop === 'symbol') return undefined;", |
| @@ -73,13 +132,14 @@ export const buildExecutorModule = (body: string, timeoutMs: number): string => · 2 unmodified lines | ||
| 73 | 132 | " apply(_target, _thisArg, args) {", |
| 74 | 133 | " const toolPath = path.join('.');", |
| 75 | 134 | " if (!toolPath) throw new Error('Tool path missing in invocation');", |
| 76 | − " return __dispatcher.call(toolPath, JSON.stringify(args[0] ?? {})).then((raw) => {", | |
| 77 | − " const data = JSON.parse(raw);", | |
| 135 | + " return (async () => {", | |
| 136 | + " const encoded = await __encodeBinary(args[0]);", | |
| 137 | + " const data = await __dispatcher.call(toolPath, encoded);", | |
| 78 | 138 | " if (!data.ok) {", |
| 79 | 139 | " throw (data.error && data.error.primary !== null ? data.error.primary : data.error.message);", |
| 80 | 140 | " }", |
| 81 | − " return data.result;", | |
| 82 | − " });", | |
| 141 | + " return __decodeBinary(data.result);", | |
| 142 | + " })();", | |
| 83 | 143 | " },", |
| 84 | 144 | " });", |
| 85 | 145 | " const tools = __makeToolsProxy();", |