diff --git a/libs/deepagents/src/sandbox-ptc/index.ts b/libs/deepagents/src/sandbox-ptc/index.ts index c89c70329..b4234cb4f 100644 --- a/libs/deepagents/src/sandbox-ptc/index.ts +++ b/libs/deepagents/src/sandbox-ptc/index.ts @@ -9,6 +9,8 @@ export type { SandboxPtcMiddlewareOptions, PtcToolCallTrace, PtcExecuteResult, + NetworkPolicy, + NetworkRule, } from "./types.js"; export { DEFAULT_PTC_EXCLUDED_TOOLS } from "./types.js"; diff --git a/libs/deepagents/src/sandbox-ptc/middleware.ts b/libs/deepagents/src/sandbox-ptc/middleware.ts index 05b786561..5516ebee0 100644 --- a/libs/deepagents/src/sandbox-ptc/middleware.ts +++ b/libs/deepagents/src/sandbox-ptc/middleware.ts @@ -44,7 +44,12 @@ import { generateWorkerReplPrompt, } from "./prompt.js"; import { WorkerRepl } from "./worker-repl.js"; -import type { SandboxPtcMiddlewareOptions, PtcExecuteResult } from "./types.js"; +import { policyFetch } from "./network-policy.js"; +import type { + SandboxPtcMiddlewareOptions, + PtcExecuteResult, + NetworkPolicy, +} from "./types.js"; import { DEFAULT_PTC_EXCLUDED_TOOLS } from "./types.js"; function getBackend( @@ -125,6 +130,37 @@ function isSandboxWithInteractive( ); } +/** + * Create a synthetic __http_fetch tool for sandbox PTC runtimes. + * The tool enforces the network policy and delegates to real fetch. + */ +function createHttpFetchTool(network: NetworkPolicy) { + return tool( + async (input: { url: string; method?: string; headers?: Record; body?: string }) => { + const result = await policyFetch( + input.url, + { + method: input.method || "GET", + headers: input.headers, + body: input.body, + }, + network, + ); + return JSON.stringify({ ok: result.ok, status: result.status, body: result.body }); + }, + { + name: "__http_fetch", + description: "Policy-enforced HTTP fetch (internal, used by PTC runtimes)", + schema: z.object({ + url: z.string(), + method: z.string().optional(), + headers: z.record(z.string(), z.string()).optional(), + body: z.string().optional(), + }), + }, + ); +} + /** * Create a middleware that enables Programmatic Tool Calling (PTC). * @@ -138,7 +174,7 @@ function isSandboxWithInteractive( export function createSandboxPtcMiddleware( options: SandboxPtcMiddlewareOptions = {}, ) { - const { backend, ptc = true, timeoutMs = 300_000 } = options; + const { backend, ptc = true, timeoutMs = 300_000, network } = options; let ptcTools: StructuredToolInterface[] = []; let cachedSandboxPrompt: string | null = null; @@ -146,10 +182,12 @@ export function createSandboxPtcMiddleware( let repl: WorkerRepl | null = null; let detectedSandbox: boolean | null = null; + const httpFetchTool = network ? createHttpFetchTool(network) : null; + const jsEvalTool = tool( async (input: { code: string }, runnableConfig: ToolRuntime) => { if (!repl) { - repl = new WorkerRepl(ptcTools, { timeoutMs }); + repl = new WorkerRepl(ptcTools, { timeoutMs, network }); } repl.tools = ptcTools; @@ -182,6 +220,9 @@ export function createSandboxPtcMiddleware( wrapModelCall: async (request, handler) => { const agentTools = (request.tools || []) as StructuredToolInterface[]; ptcTools = filterToolsForPtc(agentTools, ptc); + if (httpFetchTool && !ptcTools.some((t) => t.name === "__http_fetch")) { + ptcTools.push(httpFetchTool); + } // Detect sandbox support lazily (once) if (detectedSandbox === null) { @@ -196,7 +237,7 @@ export function createSandboxPtcMiddleware( if (detectedSandbox) { // Sandbox mode: inject bash/python/node PTC prompt, hide js_eval if (ptcTools.length > 0 && !cachedSandboxPrompt) { - cachedSandboxPrompt = generateSandboxPtcPrompt(ptcTools); + cachedSandboxPrompt = generateSandboxPtcPrompt(ptcTools, network); } const tools = (request.tools as { name: string }[]).filter( (t) => t.name !== "js_eval", @@ -210,7 +251,7 @@ export function createSandboxPtcMiddleware( // Worker REPL mode: inject JS REPL prompt, hide PTC tools from the // model so it must use toolCall()/spawnAgent() inside js_eval if (ptcTools.length > 0 && !cachedReplPrompt) { - cachedReplPrompt = generateWorkerReplPrompt(ptcTools); + cachedReplPrompt = generateWorkerReplPrompt(ptcTools, network); } const ptcToolNames = new Set(ptcTools.map((t) => t.name)); const visibleTools = (request.tools as { name: string }[]).filter( diff --git a/libs/deepagents/src/sandbox-ptc/network-policy.test.ts b/libs/deepagents/src/sandbox-ptc/network-policy.test.ts new file mode 100644 index 000000000..5d5904abe --- /dev/null +++ b/libs/deepagents/src/sandbox-ptc/network-policy.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from "vitest"; + +import { findMatchingRule, summarizePolicy } from "./network-policy.js"; +import type { NetworkPolicy } from "./types.js"; + +const basePolicy: NetworkPolicy = { + allowed: { + "google.com": {}, + "api.google.com/v1": { + headers: { "X-Api-Key": "key-123" }, + methods: ["GET", "POST"], + maxResponseBytes: 5 * 1024 * 1024, + timeoutMs: 10_000, + }, + "api.google.com": {}, + }, + blocked: ["169.254.169.254", "api.google.com/v2"], + defaultHeaders: { "User-Agent": "DeepAgent/1.0" }, + defaultMaxResponseBytes: 10 * 1024 * 1024, + defaultTimeoutMs: 30_000, +}; + +describe("findMatchingRule", () => { + it("should allow requests to a whitelisted host", () => { + const result = findMatchingRule("https://google.com/search?q=hello", "GET", basePolicy); + expect(result.allowed).toBe(true); + }); + + it("should reject requests to a non-whitelisted host", () => { + const result = findMatchingRule("https://evil.com/steal", "GET", basePolicy); + expect(result.allowed).toBe(false); + if (!result.allowed) { + expect(result.reason).toContain("not in allowed list"); + } + }); + + it("should block requests matching the blocked list", () => { + const result = findMatchingRule("http://169.254.169.254/latest/meta-data/", "GET", basePolicy); + expect(result.allowed).toBe(false); + if (!result.allowed) { + expect(result.reason).toContain("Blocked by policy"); + } + }); + + it("should block path-specific blocked entries", () => { + const result = findMatchingRule("https://api.google.com/v2/translate", "GET", basePolicy); + expect(result.allowed).toBe(false); + if (!result.allowed) { + expect(result.reason).toContain("Blocked by policy"); + } + }); + + it("should match the most specific allowed rule", () => { + const result = findMatchingRule("https://api.google.com/v1/search", "GET", basePolicy); + expect(result.allowed).toBe(true); + if (result.allowed) { + expect(result.mergedHeaders["X-Api-Key"]).toBe("key-123"); + expect(result.maxResponseBytes).toBe(5 * 1024 * 1024); + expect(result.timeoutMs).toBe(10_000); + } + }); + + it("should fall back to less specific rule when path doesn't match", () => { + const result = findMatchingRule("https://api.google.com/v3/other", "GET", basePolicy); + expect(result.allowed).toBe(true); + if (result.allowed) { + expect(result.mergedHeaders["X-Api-Key"]).toBeUndefined(); + expect(result.maxResponseBytes).toBe(10 * 1024 * 1024); + } + }); + + it("should merge defaultHeaders with rule headers", () => { + const result = findMatchingRule("https://api.google.com/v1/search", "GET", basePolicy); + expect(result.allowed).toBe(true); + if (result.allowed) { + expect(result.mergedHeaders["User-Agent"]).toBe("DeepAgent/1.0"); + expect(result.mergedHeaders["X-Api-Key"]).toBe("key-123"); + } + }); + + it("should enforce method restrictions", () => { + const getResult = findMatchingRule("https://api.google.com/v1/search", "GET", basePolicy); + expect(getResult.allowed).toBe(true); + + const deleteResult = findMatchingRule("https://api.google.com/v1/search", "DELETE", basePolicy); + expect(deleteResult.allowed).toBe(false); + if (!deleteResult.allowed) { + expect(deleteResult.reason).toContain("Method DELETE not allowed"); + } + }); + + it("should allow all methods when no restriction is set", () => { + const result = findMatchingRule("https://google.com/anything", "DELETE", basePolicy); + expect(result.allowed).toBe(true); + }); + + it("should reject invalid URLs", () => { + const result = findMatchingRule("not-a-url", "GET", basePolicy); + expect(result.allowed).toBe(false); + if (!result.allowed) { + expect(result.reason).toContain("Invalid URL"); + } + }); + + it("should use defaults when no per-rule overrides exist", () => { + const result = findMatchingRule("https://google.com/test", "GET", basePolicy); + expect(result.allowed).toBe(true); + if (result.allowed) { + expect(result.maxResponseBytes).toBe(10 * 1024 * 1024); + expect(result.timeoutMs).toBe(30_000); + } + }); +}); + +describe("summarizePolicy", () => { + it("should list allowed origins and blocked entries", () => { + const summary = summarizePolicy(basePolicy); + expect(summary).toContain("google.com"); + expect(summary).toContain("api.google.com/v1"); + expect(summary).toContain("169.254.169.254"); + expect(summary).toContain("api.google.com/v2"); + }); +}); diff --git a/libs/deepagents/src/sandbox-ptc/network-policy.ts b/libs/deepagents/src/sandbox-ptc/network-policy.ts new file mode 100644 index 000000000..c142faef9 --- /dev/null +++ b/libs/deepagents/src/sandbox-ptc/network-policy.ts @@ -0,0 +1,231 @@ +/** + * Network policy enforcement for PTC fetch(). + * + * Validates URLs against the configured NetworkPolicy, finds the most + * specific matching rule, merges headers, and enforces response limits. + */ + +import type { NetworkPolicy, NetworkRule } from "./types.js"; + +const DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // 10MB +const DEFAULT_TIMEOUT_MS = 30_000; + +export interface ResolvedRule { + allowed: true; + rule: NetworkRule; + mergedHeaders: Record; + maxResponseBytes: number; + timeoutMs: number; +} + +export interface RejectedRule { + allowed: false; + reason: string; +} + +export type PolicyResult = ResolvedRule | RejectedRule; + +/** + * Find the matching rule for a URL against the network policy. + * + * Matching logic: + * 1. Parse the URL to extract host and path + * 2. Check blocked list — if any entry is a prefix of host+path, reject + * 3. Find the most specific prefix match in allowed + * 4. Merge defaultHeaders + rule.headers (rule wins on conflict) + * 5. Resolve timeouts and size limits + */ +export function findMatchingRule( + url: string, + method: string, + policy: NetworkPolicy, +): PolicyResult { + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + return { allowed: false, reason: `Invalid URL: ${url}` }; + } + + const host = parsedUrl.hostname; + const path = parsedUrl.pathname; + const hostPath = host + path; + + // 1. Check blocked list (takes precedence) + if (policy.blocked) { + for (const blocked of policy.blocked) { + if (host === blocked || hostPath.startsWith(blocked)) { + return { allowed: false, reason: `Blocked by policy: ${blocked}` }; + } + } + } + + // 2. Find the most specific match in allowed (longest prefix wins) + let bestMatch: { key: string; rule: NetworkRule } | null = null; + + for (const [key, rule] of Object.entries(policy.allowed)) { + const slashIdx = key.indexOf("/"); + const ruleHost = slashIdx === -1 ? key : key.slice(0, slashIdx); + const rulePath = slashIdx === -1 ? "" : key.slice(slashIdx); + + if (host !== ruleHost) continue; + + if (rulePath && !path.startsWith(rulePath)) continue; + + if (!bestMatch || key.length > bestMatch.key.length) { + bestMatch = { key, rule }; + } + } + + if (!bestMatch) { + return { + allowed: false, + reason: `Host not in allowed list: ${host}`, + }; + } + + // 3. Check method restriction + const allowedMethods = bestMatch.rule.methods; + if (allowedMethods && !allowedMethods.includes(method.toUpperCase())) { + return { + allowed: false, + reason: `Method ${method.toUpperCase()} not allowed for ${bestMatch.key} (allowed: ${allowedMethods.join(", ")})`, + }; + } + + // 4. Merge headers: defaults + per-rule (rule wins) + const mergedHeaders: Record = { + ...(policy.defaultHeaders || {}), + ...(bestMatch.rule.headers || {}), + }; + + // 5. Resolve limits + const maxResponseBytes = + bestMatch.rule.maxResponseBytes ?? + policy.defaultMaxResponseBytes ?? + DEFAULT_MAX_RESPONSE_BYTES; + + const timeoutMs = + bestMatch.rule.timeoutMs ?? + policy.defaultTimeoutMs ?? + DEFAULT_TIMEOUT_MS; + + return { + allowed: true, + rule: bestMatch.rule, + mergedHeaders, + maxResponseBytes, + timeoutMs, + }; +} + +/** + * Execute a policy-enforced fetch request. + * + * Applies merged headers, timeout via AbortController, and response + * size limits. Returns the response body as a string. + */ +export async function policyFetch( + url: string, + init: RequestInit | undefined, + policy: NetworkPolicy, +): Promise<{ ok: boolean; status: number; body: string }> { + const method = (init?.method || "GET").toUpperCase(); + const result = findMatchingRule(url, method, policy); + + if (!result.allowed) { + throw new Error(result.reason); + } + + const { mergedHeaders, maxResponseBytes, timeoutMs } = result; + + // Merge headers from init with policy headers (policy wins) + const requestHeaders = new Headers(init?.headers); + for (const [k, v] of Object.entries(mergedHeaders)) { + requestHeaders.set(k, v); + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url, { + ...init, + method, + headers: requestHeaders, + signal: controller.signal, + }); + + const contentLength = response.headers.get("content-length"); + if (contentLength && parseInt(contentLength, 10) > maxResponseBytes) { + throw new Error( + `Response too large: ${contentLength} bytes exceeds limit of ${maxResponseBytes} bytes`, + ); + } + + const reader = response.body?.getReader(); + if (!reader) { + return { ok: response.ok, status: response.status, body: "" }; + } + + const chunks: Uint8Array[] = []; + let totalBytes = 0; + + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > maxResponseBytes) { + reader.cancel(); + throw new Error( + `Response too large: exceeded limit of ${maxResponseBytes} bytes`, + ); + } + chunks.push(value); + } + + const body = new TextDecoder().decode( + chunks.length === 1 + ? chunks[0] + : new Uint8Array( + chunks.reduce((acc, c) => { + const merged = new Uint8Array(acc.byteLength + c.byteLength); + merged.set(acc); + merged.set(c, acc.byteLength); + return merged; + }), + ), + ); + + return { ok: response.ok, status: response.status, body }; + } finally { + clearTimeout(timer); + } +} + +/** + * Generate a human-readable summary of the network policy for prompts. + */ +export function summarizePolicy(policy: NetworkPolicy): string { + const allowed = Object.keys(policy.allowed); + const blocked = policy.blocked || []; + + const lines: string[] = []; + for (const key of allowed) { + const rule = policy.allowed[key]; + const extras: string[] = []; + if (rule.headers) extras.push("custom headers injected"); + if (rule.methods) extras.push(`methods: ${rule.methods.join(", ")}`); + const suffix = extras.length > 0 ? ` (${extras.join(", ")})` : ""; + const hasPath = key.includes("/"); + lines.push(`- ${key}${hasPath ? "/*" : " (all paths)"}${suffix}`); + } + + let summary = `\`fetch()\` is available for HTTP requests, restricted to:\n${lines.join("\n")}`; + + if (blocked.length > 0) { + summary += `\n\nBlocked: ${blocked.join(", ")}`; + } + + return summary; +} diff --git a/libs/deepagents/src/sandbox-ptc/prompt.ts b/libs/deepagents/src/sandbox-ptc/prompt.ts index cc08a3823..32bd4efa3 100644 --- a/libs/deepagents/src/sandbox-ptc/prompt.ts +++ b/libs/deepagents/src/sandbox-ptc/prompt.ts @@ -10,6 +10,8 @@ import type { StructuredToolInterface } from "@langchain/core/tools"; import { toJsonSchema } from "@langchain/core/utils/json_schema"; +import type { NetworkPolicy } from "./types.js"; +import { summarizePolicy } from "./network-policy.js"; function safeToJsonSchema( schema: unknown, @@ -51,6 +53,7 @@ function schemaToExample( */ export function generateSandboxPtcPrompt( tools: StructuredToolInterface[], + network?: NetworkPolicy, ): string { if (tools.length === 0) return ""; @@ -190,6 +193,7 @@ const syncResult = toolCallSync("tool_name", { key: "value" }); ${toolEntries} ${subagentSection} +${network ? `### Network access (fetch)\n\n${summarizePolicy(network)}\n` : ""} `; } @@ -199,6 +203,7 @@ ${subagentSection} */ export function generateWorkerReplPrompt( tools: StructuredToolInterface[], + network?: NetworkPolicy, ): string { if (tools.length === 0) return ""; @@ -279,5 +284,6 @@ console.log(results); ${toolEntries} ${subagentSection} +${network ? `### Network access (fetch)\n\n${summarizePolicy(network)}\n` : ""} `; } diff --git a/libs/deepagents/src/sandbox-ptc/types.ts b/libs/deepagents/src/sandbox-ptc/types.ts index c6c304c69..402a942b9 100644 --- a/libs/deepagents/src/sandbox-ptc/types.ts +++ b/libs/deepagents/src/sandbox-ptc/types.ts @@ -45,6 +45,61 @@ export interface SandboxPtcMiddlewareOptions { /** Execution timeout in milliseconds (default: 300_000 — 5 minutes) */ timeoutMs?: number; + + /** + * Network policy for `fetch()`. When set, a policy-enforced `fetch` + * function is exposed inside scripts. + * If not set, `fetch` is not available (fully locked down). + */ + network?: NetworkPolicy; +} + +/** + * Per-origin network rule. Overrides defaults for requests matching + * this origin prefix. + */ +export interface NetworkRule { + /** Headers to inject for requests to this origin. Merged with defaultHeaders. */ + headers?: Record; + /** Allowed HTTP methods for this origin. Overrides default (all methods). */ + methods?: string[]; + /** Maximum response size in bytes. Overrides defaultMaxResponseBytes. */ + maxResponseBytes?: number; + /** Request timeout in ms. Overrides defaultTimeoutMs. */ + timeoutMs?: number; +} + +/** + * Network access policy for the PTC sandbox/REPL. + * + * Uses origin+path keys for fine-grained control: + * - `"google.com"` — all paths on google.com + * - `"api.google.com/v1"` — only api.google.com/v1/* + * + * A `blocked` list takes precedence over `allowed`. + */ +export interface NetworkPolicy { + /** + * Allowed origins with optional per-origin rules. + * Keys are `hostname` or `hostname/path-prefix`. + * An empty `{}` means "allow with defaults". + * + * Matching is prefix-based on host+path. The most specific match wins. + */ + allowed: Record; + + /** + * Blocked origin prefixes. Takes precedence over `allowed`. + * E.g. `["169.254.169.254", "api.google.com/v2"]` + */ + blocked?: string[]; + + /** Headers injected into every request (merged with per-origin headers). */ + defaultHeaders?: Record; + /** Default max response size in bytes. Default: 10MB */ + defaultMaxResponseBytes?: number; + /** Default request timeout in ms. Default: 30_000 */ + defaultTimeoutMs?: number; } /** diff --git a/libs/deepagents/src/sandbox-ptc/worker-repl.ts b/libs/deepagents/src/sandbox-ptc/worker-repl.ts index 50fd00cf7..cce92ef34 100644 --- a/libs/deepagents/src/sandbox-ptc/worker-repl.ts +++ b/libs/deepagents/src/sandbox-ptc/worker-repl.ts @@ -11,8 +11,13 @@ import type { ToolRuntime } from "langchain"; import type { StructuredToolInterface } from "@langchain/core/tools"; -import type { PtcToolCallTrace, PtcExecuteResult } from "./types.js"; +import type { + PtcToolCallTrace, + PtcExecuteResult, + NetworkPolicy, +} from "./types.js"; import { wrapUserCode } from "./worker-runtime.js"; +import { policyFetch } from "./network-policy.js"; interface NodeProcess { getBuiltinModule?: (id: string) => Record | undefined; @@ -73,7 +78,7 @@ export class WorkerRepl { constructor( tools: StructuredToolInterface[], - private options: { timeoutMs?: number } = {}, + private options: { timeoutMs?: number; network?: NetworkPolicy } = {}, ) { this.tools = tools; const detected = detectWorkerImpl(); @@ -88,7 +93,9 @@ export class WorkerRepl { async eval(code: string, config?: ToolRuntime): Promise { const timeoutMs = this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const wrappedCode = wrapUserCode(code, this.impl); + const wrappedCode = wrapUserCode(code, this.impl, { + hasFetch: !!this.options.network, + }); const toolCallTraces: PtcToolCallTrace[] = []; const logs: string[] = []; @@ -175,6 +182,50 @@ export class WorkerRepl { error: errMsg, }); } + } else if (msg.type === "fetch") { + const uuid = msg.uuid as string; + const network = this.options.network; + if (!network) { + postToWorker({ + type: "tool_result", + uuid, + ok: false, + error: "fetch is not available", + }); + return; + } + (async () => { + try { + const result = await policyFetch( + msg.url as string, + { + method: msg.method as string, + headers: msg.headers as Record, + body: msg.body as string | undefined, + }, + network, + ); + postToWorker({ + type: "tool_result", + uuid, + ok: true, + result: JSON.stringify({ + ok: result.ok, + status: result.status, + body: result.body, + }), + }); + } catch (e: unknown) { + // eslint-disable-next-line no-instanceof/no-instanceof + const errMsg = e instanceof Error ? e.message : String(e); + postToWorker({ + type: "tool_result", + uuid, + ok: false, + error: errMsg, + }); + } + })(); } else if (msg.type === "log") { logs.push(msg.text as string); } else if (msg.type === "result") { diff --git a/libs/deepagents/src/sandbox-ptc/worker-runtime.ts b/libs/deepagents/src/sandbox-ptc/worker-runtime.ts index 78a90f130..f6250f004 100644 --- a/libs/deepagents/src/sandbox-ptc/worker-runtime.ts +++ b/libs/deepagents/src/sandbox-ptc/worker-runtime.ts @@ -89,12 +89,33 @@ const __da_console = { }, }; +// ── fetch proxy (only if network policy is configured) ────────────── +// __DA_HAS_FETCH__ is replaced with "true" or "false" at build time +const __da_hasFetch = __DA_HAS_FETCH__; + +function fetch(url, init) { + if (!__da_hasFetch) return Promise.reject(new Error("fetch is not available (no network policy configured)")); + const uuid = __da_uuid(); + return new Promise((resolve, reject) => { + __da_pending.set(uuid, { resolve, reject }); + __da_postMessage({ + type: "fetch", + uuid, + url: String(url), + method: (init && init.method) || "GET", + headers: (init && init.headers) || {}, + body: (init && init.body) || undefined, + }); + }); +} + // ── Run user code in a restricted VM context ──────────────────────── const __da_sandbox = vm.createContext({ // PTC globals toolCall, spawnAgent, + fetch: __da_hasFetch ? fetch : undefined, console: __da_console, // Safe JS built-ins @@ -226,21 +247,52 @@ console.error = (...args) => { __da_logs.push(line); __da_postMessage({ type: "log", text: line }); }; + +// fetch proxy (only if network policy is configured) +const __da_hasFetch_web = __DA_HAS_FETCH_WEB__; + +if (__da_hasFetch_web) { + const __origFetch = typeof self.fetch === "function" ? self.fetch.bind(self) : null; + self.fetch = function(url, init) { + const uuid = __da_uuid(); + return new Promise((resolve, reject) => { + __da_pending.set(uuid, { resolve, reject }); + __da_postMessage({ + type: "fetch", + uuid, + url: String(url), + method: (init && init.method) || "GET", + headers: (init && init.headers) || {}, + body: (init && init.body) || undefined, + }); + }); + }; +} else { + self.fetch = function() { return Promise.reject(new Error("fetch is not available (no network policy configured)")); }; +} `; /** * Build the complete Worker code by injecting the user's code into the * appropriate bootstrap (Node.js with vm sandbox, or Web Worker). */ -export function wrapUserCode(code: string, impl: "node" | "web"): string { +export function wrapUserCode( + code: string, + impl: "node" | "web", + options: { hasFetch?: boolean } = {}, +): string { + const hasFetch = options.hasFetch ?? false; + if (impl === "node") { const escaped = JSON.stringify(code); - const [before, after] = NODE_WORKER_BOOTSTRAP.split('"@@SPLIT@@"'); + let bootstrap = NODE_WORKER_BOOTSTRAP.split("__DA_HAS_FETCH__").join(String(hasFetch)); + const [before, after] = bootstrap.split('"@@SPLIT@@"'); return before + escaped + after; } // Web Worker: run code directly (already sandboxed by the browser) - return `${WEB_WORKER_BOOTSTRAP} + const webBootstrap = WEB_WORKER_BOOTSTRAP.split("__DA_HAS_FETCH_WEB__").join(String(hasFetch)); + return `${webBootstrap} (async () => { try {