{"version":3,"file":"host-services.d.ts","sourceRoot":"","sources":["../../../src/components/capabilities/host-services.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AACvD,OAAO,KAAK,EAAgC,oBAAoB,EAAgC,MAAM,cAAc,CAAC;AAOrH,MAAM,WAAW,wBAAwB;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,wBAAwB;IACxC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,6BAA6B;IAC7C,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC;CACtB;AAgFD,wBAAgB,4BAA4B,CAC3C,OAAO,EAAE,wBAAwB,GAC/B,OAAO,CAAC,MAAM,CAAC,iBAAiB,EAAE,oBAAoB,CAAC,CAAC,CAwF1D;AA2FD,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,wBAAwB,GAAG,oBAAoB,CAmGnG;AAyED,wBAAgB,gCAAgC,CAAC,OAAO,EAAE,6BAA6B,GAAG,oBAAoB,CA8D7G","sourcesContent":["import { Buffer } from \"node:buffer\";\nimport { type ChildProcess, spawn } from \"node:child_process\";\nimport { realpath } from \"node:fs/promises\";\nimport { isAbsolute, join, relative, resolve } from \"node:path\";\nimport {\n\tatomicWriteRegularFileNoFollow,\n\treadRegularDirectoryNoFollow,\n\treadRegularFileNoFollow,\n\tresolveRegularDirectory,\n} from \"../../secure-file.ts\";\nimport { canonicalJson } from \"../../storage.ts\";\nimport type { EvoCapabilityName } from \"./protocol.ts\";\nimport type { EvoCapabilityExecutionResult, EvoCapabilityService, EvoPreparedCapabilityRequest } from \"./service.ts\";\n\nconst ROOT_ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;\nconst COMMAND_ALIAS_PATTERN = ROOT_ALIAS_PATTERN;\nconst MAX_ARGUMENTS = 256;\nconst MAX_ARGUMENT_BYTES = 64 * 1024;\n\nexport interface EvoFileCapabilityOptions {\n\treadRoots?: Record<string, string>;\n\twriteRoots?: Record<string, string>;\n\tmaxReadBytes?: number;\n\tmaxWriteBytes?: number;\n\tmaxListEntries?: number;\n}\n\nexport interface EvoExecCapabilityOptions {\n\tcommands: Record<string, string>;\n\tcwdRoots: Record<string, string>;\n\tmaxOutputBytes?: number;\n\tmaxTimeoutMs?: number;\n\tterminationGraceMs?: number;\n}\n\nexport interface EvoHttpFetchCapabilityOptions {\n\torigins: string[];\n\tmaxResponseBytes?: number;\n\tmaxRequestBytes?: number;\n\tmaxTimeoutMs?: number;\n}\n\nfunction asRecord(value: unknown, label: string): Record<string, unknown> {\n\tif (typeof value !== \"object\" || value === null || Array.isArray(value)) {\n\t\tthrow new Error(`${label} must be an object`);\n\t}\n\treturn value as Record<string, unknown>;\n}\n\nfunction rejectUnknownKeys(record: Record<string, unknown>, allowed: readonly string[], label: string): void {\n\tconst allowedKeys = new Set(allowed);\n\tfor (const key of Object.keys(record)) {\n\t\tif (!allowedKeys.has(key)) throw new Error(`${label} has unknown key: ${key}`);\n\t}\n}\n\nfunction positiveInteger(value: unknown, fallback: number, label: string): number {\n\tconst resolved = value ?? fallback;\n\tif (!Number.isSafeInteger(resolved) || (resolved as number) <= 0) {\n\t\tthrow new Error(`${label} must be a positive integer`);\n\t}\n\treturn resolved as number;\n}\n\nfunction parseRootMap(value: Record<string, string> | undefined, label: string): Record<string, string> {\n\tconst roots: Record<string, string> = {};\n\tfor (const [alias, path] of Object.entries(value ?? {})) {\n\t\tif (!ROOT_ALIAS_PATTERN.test(alias)) throw new Error(`${label} has an invalid alias: ${alias}`);\n\t\tif (!isAbsolute(path)) throw new Error(`${label}.${alias} must be absolute`);\n\t\troots[alias] = resolve(path);\n\t}\n\treturn roots;\n}\n\nfunction parseRelativePath(value: unknown, label: string): string {\n\tif (typeof value !== \"string\" || value.includes(\"\\\\\") || value.startsWith(\"/\") || value.includes(\"\\0\")) {\n\t\tthrow new Error(`${label} must be a normalized relative path`);\n\t}\n\tif (value === \"\") return value;\n\tif (value.split(\"/\").some((part) => !part || part === \".\" || part === \"..\")) {\n\t\tthrow new Error(`${label} contains an unsafe segment`);\n\t}\n\treturn value;\n}\n\nfunction parseRootRequest(value: unknown, label: string): { root: string; path: string } {\n\tconst request = asRecord(value, label);\n\tif (typeof request.root !== \"string\" || !ROOT_ALIAS_PATTERN.test(request.root)) {\n\t\tthrow new Error(`${label}.root is invalid`);\n\t}\n\treturn { root: request.root, path: parseRelativePath(request.path, `${label}.path`) };\n}\n\nfunction isWithin(root: string, path: string): boolean {\n\tconst child = relative(root, path);\n\treturn child === \"\" || (!child.startsWith(\"..\") && !isAbsolute(child));\n}\n\nasync function resolveExistingPath(roots: Record<string, string>, rootAlias: string, path: string): Promise<string> {\n\tconst configuredRoot = roots[rootAlias];\n\tif (!configuredRoot) throw new Error(`Root is not granted: ${rootAlias}`);\n\tconst [realRoot, realTarget] = await Promise.all([\n\t\trealpath(configuredRoot),\n\t\trealpath(path ? join(configuredRoot, path) : configuredRoot),\n\t]);\n\tif (!isWithin(realRoot, realTarget)) throw new Error(\"Path escapes its granted root\");\n\treturn realTarget;\n}\n\nasync function resolveGrantedRoot(roots: Record<string, string>, rootAlias: string, label: string): Promise<string> {\n\tconst configuredRoot = roots[rootAlias];\n\tif (!configuredRoot) throw new Error(`Root is not granted: ${rootAlias}`);\n\treturn resolveRegularDirectory(configuredRoot, label);\n}\n\nfunction prepared(request: unknown): EvoPreparedCapabilityRequest {\n\tcanonicalJson(request);\n\treturn { request };\n}\n\nexport function createFileCapabilityServices(\n\toptions: EvoFileCapabilityOptions,\n): Partial<Record<EvoCapabilityName, EvoCapabilityService>> {\n\tconst readRoots = parseRootMap(options.readRoots, \"readRoots\");\n\tconst writeRoots = parseRootMap(options.writeRoots, \"writeRoots\");\n\tconst maxReadBytes = positiveInteger(options.maxReadBytes, 1024 * 1024, \"maxReadBytes\");\n\tconst maxWriteBytes = positiveInteger(options.maxWriteBytes, 1024 * 1024, \"maxWriteBytes\");\n\tconst maxListEntries = positiveInteger(options.maxListEntries, 10_000, \"maxListEntries\");\n\treturn {\n\t\t\"read-file\": {\n\t\t\tprepare(value) {\n\t\t\t\tconst request = asRecord(value, \"read-file request\");\n\t\t\t\trejectUnknownKeys(request, [\"root\", \"path\", \"encoding\"], \"read-file request\");\n\t\t\t\tconst rootPath = parseRootRequest(request, \"read-file request\");\n\t\t\t\tif (request.encoding !== \"utf8\" && request.encoding !== \"base64\") {\n\t\t\t\t\tthrow new Error(\"read-file request.encoding must be 'utf8' or 'base64'\");\n\t\t\t\t}\n\t\t\t\treturn prepared({ ...rootPath, encoding: request.encoding });\n\t\t\t},\n\t\t\tasync execute(value, context): Promise<EvoCapabilityExecutionResult> {\n\t\t\t\tif (context.signal.aborted) throw new Error(\"read-file request aborted\");\n\t\t\t\tconst request = asRecord(value, \"read-file request\");\n\t\t\t\tconst root = await resolveGrantedRoot(readRoots, request.root as string, \"read-file granted root\");\n\t\t\t\tconst target = request.path ? join(root, request.path as string) : root;\n\t\t\t\tconst file = await readRegularFileNoFollow(target, \"read-file target\", maxReadBytes);\n\t\t\t\treturn {\n\t\t\t\t\tresult: {\n\t\t\t\t\t\tcontent: request.encoding === \"base64\" ? file.toString(\"base64\") : file.toString(\"utf8\"),\n\t\t\t\t\t\tbytes: file.byteLength,\n\t\t\t\t\t\tencoding: request.encoding,\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t},\n\t\t},\n\t\t\"write-file\": {\n\t\t\tprepare(value) {\n\t\t\t\tconst request = asRecord(value, \"write-file request\");\n\t\t\t\trejectUnknownKeys(request, [\"root\", \"path\", \"content\", \"encoding\"], \"write-file request\");\n\t\t\t\tconst rootPath = parseRootRequest(request, \"write-file request\");\n\t\t\t\tif (typeof request.content !== \"string\") throw new Error(\"write-file request.content must be a string\");\n\t\t\t\tif (request.encoding !== \"utf8\") throw new Error(\"write-file request.encoding must be 'utf8'\");\n\t\t\t\tconst content = Buffer.from(request.content, \"utf8\");\n\t\t\t\tif (content.byteLength > maxWriteBytes)\n\t\t\t\t\tthrow new Error(\"write-file request exceeds the configured byte limit\");\n\t\t\t\treturn prepared({ ...rootPath, content: request.content, encoding: request.encoding });\n\t\t\t},\n\t\t\tasync execute(value, context): Promise<EvoCapabilityExecutionResult> {\n\t\t\t\tif (context.signal.aborted) throw new Error(\"write-file request aborted\");\n\t\t\t\tconst request = asRecord(value, \"write-file request\");\n\t\t\t\tif (!request.path) throw new Error(\"write-file request.path must name a file\");\n\t\t\t\tconst root = await resolveGrantedRoot(writeRoots, request.root as string, \"write-file granted root\");\n\t\t\t\tconst target = join(root, request.path as string);\n\t\t\t\tconst content = request.content as string;\n\t\t\t\tif (context.signal.aborted) throw new Error(\"write-file request aborted\");\n\t\t\t\tawait atomicWriteRegularFileNoFollow(target, content, \"write-file target\");\n\t\t\t\treturn { result: { bytes: Buffer.byteLength(content, \"utf8\") } };\n\t\t\t},\n\t\t},\n\t\t\"list-dir\": {\n\t\t\tprepare(value) {\n\t\t\t\tconst request = asRecord(value, \"list-dir request\");\n\t\t\t\trejectUnknownKeys(request, [\"root\", \"path\"], \"list-dir request\");\n\t\t\t\treturn prepared(parseRootRequest(request, \"list-dir request\"));\n\t\t\t},\n\t\t\tasync execute(value, context): Promise<EvoCapabilityExecutionResult> {\n\t\t\t\tif (context.signal.aborted) throw new Error(\"list-dir request aborted\");\n\t\t\t\tconst request = asRecord(value, \"list-dir request\");\n\t\t\t\tconst root = await resolveGrantedRoot(readRoots, request.root as string, \"list-dir granted root\");\n\t\t\t\tconst target = request.path ? join(root, request.path as string) : root;\n\t\t\t\tconst entries = await readRegularDirectoryNoFollow(target, \"list-dir target\");\n\t\t\t\tif (entries.length > maxListEntries) throw new Error(\"list-dir result exceeds the configured entry limit\");\n\t\t\t\treturn {\n\t\t\t\t\tresult: {\n\t\t\t\t\t\tentries: entries\n\t\t\t\t\t\t\t.map((entry) => ({\n\t\t\t\t\t\t\t\tname: entry.name,\n\t\t\t\t\t\t\t\ttype: entry.isDirectory()\n\t\t\t\t\t\t\t\t\t? \"directory\"\n\t\t\t\t\t\t\t\t\t: entry.isFile()\n\t\t\t\t\t\t\t\t\t\t? \"file\"\n\t\t\t\t\t\t\t\t\t\t: entry.isSymbolicLink()\n\t\t\t\t\t\t\t\t\t\t\t? \"symlink\"\n\t\t\t\t\t\t\t\t\t\t\t: \"other\",\n\t\t\t\t\t\t\t}))\n\t\t\t\t\t\t\t.sort((left, right) => left.name.localeCompare(right.name)),\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t},\n\t\t},\n\t};\n}\n\nfunction parseExecRequest(value: unknown, maxTimeoutMs: number) {\n\tconst request = asRecord(value, \"exec request\");\n\trejectUnknownKeys(request, [\"command\", \"args\", \"cwd\", \"timeoutMs\"], \"exec request\");\n\tif (typeof request.command !== \"string\" || !COMMAND_ALIAS_PATTERN.test(request.command)) {\n\t\tthrow new Error(\"exec request.command is invalid\");\n\t}\n\tif (\n\t\t!Array.isArray(request.args) ||\n\t\trequest.args.length > MAX_ARGUMENTS ||\n\t\trequest.args.some((argument) => typeof argument !== \"string\" || argument.includes(\"\\0\"))\n\t) {\n\t\tthrow new Error(\"exec request.args must be a bounded string array\");\n\t}\n\tif (Buffer.byteLength(request.args.join(\"\\0\"), \"utf8\") > MAX_ARGUMENT_BYTES) {\n\t\tthrow new Error(\"exec request.args exceeds the byte limit\");\n\t}\n\tconst cwd = parseRootRequest(request.cwd, \"exec request.cwd\");\n\tconst timeoutMs = positiveInteger(request.timeoutMs, maxTimeoutMs, \"exec request.timeoutMs\");\n\tif (timeoutMs > maxTimeoutMs) throw new Error(\"exec request.timeoutMs exceeds the configured limit\");\n\treturn { command: request.command, args: request.args as string[], cwd, timeoutMs };\n}\n\ninterface ExecProcessClose {\n\tcode: number | null;\n\tsignal: NodeJS.Signals | null;\n}\n\nfunction signalProcessGroup(child: ChildProcess, signal: NodeJS.Signals): void {\n\tconst pid = child.pid;\n\tif (pid === undefined) return;\n\ttry {\n\t\tprocess.kill(-pid, signal);\n\t} catch (error) {\n\t\tif (typeof error === \"object\" && error !== null && \"code\" in error && error.code === \"ESRCH\") return;\n\t\tthrow error;\n\t}\n}\n\nfunction processGroupExists(pid: number): boolean {\n\ttry {\n\t\tprocess.kill(-pid, 0);\n\t\treturn true;\n\t} catch (error) {\n\t\tif (typeof error === \"object\" && error !== null && \"code\" in error && error.code === \"ESRCH\") return false;\n\t\tthrow error;\n\t}\n}\n\nasync function waitForProcessGroupExit(pid: number, timeoutMs: number): Promise<boolean> {\n\tconst deadline = Date.now() + timeoutMs;\n\twhile (processGroupExists(pid)) {\n\t\tconst remaining = deadline - Date.now();\n\t\tif (remaining <= 0) return false;\n\t\tawait new Promise<void>((resolvePromise) => setTimeout(resolvePromise, Math.min(remaining, 10)));\n\t}\n\treturn true;\n}\n\nasync function terminateExecProcessGroup(\n\tchild: ChildProcess,\n\tclosed: Promise<ExecProcessClose>,\n\tterminationGraceMs: number,\n): Promise<void> {\n\tif (process.platform === \"win32\") {\n\t\tthrow new Error(\"exec capability cannot guarantee process-tree teardown on win32\");\n\t}\n\tconst pid = child.pid;\n\tif (pid === undefined) {\n\t\tawait closed;\n\t\treturn;\n\t}\n\ttry {\n\t\tsignalProcessGroup(child, \"SIGTERM\");\n\t\tif (await waitForProcessGroupExit(pid, terminationGraceMs)) {\n\t\t\tawait closed;\n\t\t\treturn;\n\t\t}\n\t\tsignalProcessGroup(child, \"SIGKILL\");\n\t\tawait closed;\n\t\tif (!(await waitForProcessGroupExit(pid, terminationGraceMs))) {\n\t\t\tthrow new Error(\"exec process group still exists after SIGKILL\");\n\t\t}\n\t} catch (error) {\n\t\tchild.kill(\"SIGKILL\");\n\t\tawait closed;\n\t\tthrow new Error(\"exec process-group teardown failed\", { cause: error });\n\t}\n}\n\nexport function createExecCapabilityService(options: EvoExecCapabilityOptions): EvoCapabilityService {\n\tconst commands: Record<string, string> = {};\n\tfor (const [alias, command] of Object.entries(options.commands)) {\n\t\tif (!COMMAND_ALIAS_PATTERN.test(alias)) throw new Error(`commands has an invalid alias: ${alias}`);\n\t\tif (!isAbsolute(command)) throw new Error(`commands.${alias} must be absolute`);\n\t\tcommands[alias] = resolve(command);\n\t}\n\tconst cwdRoots = parseRootMap(options.cwdRoots, \"cwdRoots\");\n\tconst maxOutputBytes = positiveInteger(options.maxOutputBytes, 1024 * 1024, \"maxOutputBytes\");\n\tconst maxTimeoutMs = positiveInteger(options.maxTimeoutMs, 60_000, \"maxTimeoutMs\");\n\tconst terminationGraceMs = positiveInteger(options.terminationGraceMs, 1_000, \"terminationGraceMs\");\n\treturn {\n\t\tprepare(value) {\n\t\t\treturn prepared(parseExecRequest(value, maxTimeoutMs));\n\t\t},\n\t\tasync execute(value, context): Promise<EvoCapabilityExecutionResult> {\n\t\t\tif (process.platform === \"win32\") {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t\"exec capability is unavailable on win32 because process-tree teardown cannot be guaranteed\",\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (context.signal.aborted) throw new Error(\"exec request aborted\");\n\t\t\tconst request = parseExecRequest(value, maxTimeoutMs);\n\t\t\tconst command = commands[request.command];\n\t\t\tif (!command) throw new Error(`Command is not granted: ${request.command}`);\n\t\t\tconst cwd = await resolveExistingPath(cwdRoots, request.cwd.root, request.cwd.path);\n\t\t\treturn new Promise((resolvePromise, reject) => {\n\t\t\t\tconst child = spawn(command, request.args, {\n\t\t\t\t\tcwd,\n\t\t\t\t\tenv: { HOME: cwd, LANG: \"C.UTF-8\", PATH: \"\" },\n\t\t\t\t\tstdio: [\"ignore\", \"pipe\", \"pipe\"],\n\t\t\t\t\tdetached: true,\n\t\t\t\t});\n\t\t\t\tconst stdout: Buffer[] = [];\n\t\t\t\tconst stderr: Buffer[] = [];\n\t\t\t\tlet bytes = 0;\n\t\t\t\tlet settled = false;\n\t\t\t\tlet timer: NodeJS.Timeout | undefined;\n\t\t\t\tlet resolveClosed: (value: ExecProcessClose) => void = () => {};\n\t\t\t\tconst closed = new Promise<ExecProcessClose>((resolveClose) => {\n\t\t\t\t\tresolveClosed = resolveClose;\n\t\t\t\t});\n\t\t\t\tlet abort = (): void => {};\n\t\t\t\tconst cleanup = (): void => {\n\t\t\t\t\tif (timer) clearTimeout(timer);\n\t\t\t\t\tcontext.signal.removeEventListener(\"abort\", abort);\n\t\t\t\t};\n\t\t\t\tconst finishError = (error: Error): void => {\n\t\t\t\t\tif (settled) return;\n\t\t\t\t\tsettled = true;\n\t\t\t\t\tcleanup();\n\t\t\t\t\tvoid terminateExecProcessGroup(child, closed, terminationGraceMs).then(\n\t\t\t\t\t\t() => reject(error),\n\t\t\t\t\t\t(teardownError: unknown) =>\n\t\t\t\t\t\t\treject(new Error(`${error.message}; exec process teardown failed`, { cause: teardownError })),\n\t\t\t\t\t);\n\t\t\t\t};\n\t\t\t\tconst capture =\n\t\t\t\t\t(target: Buffer[]) =>\n\t\t\t\t\t(chunk: Buffer | string): void => {\n\t\t\t\t\t\tif (settled) return;\n\t\t\t\t\t\tconst buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);\n\t\t\t\t\t\tbytes += buffer.byteLength;\n\t\t\t\t\t\tif (bytes > maxOutputBytes) {\n\t\t\t\t\t\t\tfinishError(new Error(\"exec output exceeds the configured byte limit\"));\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t}\n\t\t\t\t\t\ttarget.push(buffer);\n\t\t\t\t\t};\n\t\t\t\tchild.stdout.on(\"data\", capture(stdout));\n\t\t\t\tchild.stderr.on(\"data\", capture(stderr));\n\t\t\t\tchild.once(\"error\", finishError);\n\t\t\t\tchild.once(\"close\", (code, signal) => {\n\t\t\t\t\tresolveClosed({ code, signal });\n\t\t\t\t\tif (settled) return;\n\t\t\t\t\tsettled = true;\n\t\t\t\t\tcleanup();\n\t\t\t\t\tconst response: EvoCapabilityExecutionResult = {\n\t\t\t\t\t\tresult: {\n\t\t\t\t\t\t\texitCode: code,\n\t\t\t\t\t\t\tsignal,\n\t\t\t\t\t\t\tstdout: Buffer.concat(stdout).toString(\"utf8\"),\n\t\t\t\t\t\t\tstderr: Buffer.concat(stderr).toString(\"utf8\"),\n\t\t\t\t\t\t},\n\t\t\t\t\t};\n\t\t\t\t\tvoid terminateExecProcessGroup(child, closed, terminationGraceMs).then(\n\t\t\t\t\t\t() => resolvePromise(response),\n\t\t\t\t\t\t(teardownError: unknown) =>\n\t\t\t\t\t\t\treject(new Error(\"exec process teardown failed after command exit\", { cause: teardownError })),\n\t\t\t\t\t);\n\t\t\t\t});\n\t\t\t\tabort = (): void => finishError(new Error(\"exec request aborted\"));\n\t\t\t\tcontext.signal.addEventListener(\"abort\", abort, { once: true });\n\t\t\t\ttimer = setTimeout(() => finishError(new Error(\"exec request timed out\")), request.timeoutMs);\n\t\t\t\ttimer.unref();\n\t\t\t\tif (context.signal.aborted) abort();\n\t\t\t});\n\t\t},\n\t};\n}\n\nfunction parseFetchRequest(value: unknown, maxRequestBytes: number, maxTimeoutMs: number) {\n\tconst request = asRecord(value, \"http-fetch request\");\n\trejectUnknownKeys(\n\t\trequest,\n\t\t[\"url\", \"method\", \"headers\", \"body\", \"responseEncoding\", \"timeoutMs\"],\n\t\t\"http-fetch request\",\n\t);\n\tif (typeof request.url !== \"string\" || request.url.length > 8_192)\n\t\tthrow new Error(\"http-fetch request.url is invalid\");\n\tconst method = request.method ?? \"GET\";\n\tif (method !== \"GET\" && method !== \"HEAD\" && method !== \"POST\") {\n\t\tthrow new Error(\"http-fetch request.method must be GET, HEAD, or POST\");\n\t}\n\tconst headers = request.headers === undefined ? {} : asRecord(request.headers, \"http-fetch request.headers\");\n\tconst normalizedHeaders: Record<string, string> = {};\n\tfor (const [name, headerValue] of Object.entries(headers)) {\n\t\tconst lower = name.toLowerCase();\n\t\tif (!new Set([\"accept\", \"content-type\", \"user-agent\"]).has(lower) || typeof headerValue !== \"string\") {\n\t\t\tthrow new Error(`http-fetch request header is not allowed: ${name}`);\n\t\t}\n\t\tnormalizedHeaders[lower] = headerValue;\n\t}\n\tif (request.body !== undefined && typeof request.body !== \"string\") {\n\t\tthrow new Error(\"http-fetch request.body must be a string\");\n\t}\n\tif (method !== \"POST\" && request.body !== undefined) throw new Error(\"http-fetch body requires POST\");\n\tif (Buffer.byteLength((request.body as string | undefined) ?? \"\", \"utf8\") > maxRequestBytes) {\n\t\tthrow new Error(\"http-fetch request body exceeds the configured byte limit\");\n\t}\n\tconst responseEncoding = request.responseEncoding ?? \"utf8\";\n\tif (responseEncoding !== \"utf8\" && responseEncoding !== \"base64\") {\n\t\tthrow new Error(\"http-fetch request.responseEncoding must be 'utf8' or 'base64'\");\n\t}\n\tconst timeoutMs = positiveInteger(request.timeoutMs, maxTimeoutMs, \"http-fetch request.timeoutMs\");\n\tif (timeoutMs > maxTimeoutMs) throw new Error(\"http-fetch request.timeoutMs exceeds the configured limit\");\n\treturn {\n\t\turl: request.url,\n\t\tmethod,\n\t\theaders: normalizedHeaders,\n\t\t...(request.body === undefined ? {} : { body: request.body }),\n\t\tresponseEncoding,\n\t\ttimeoutMs,\n\t};\n}\n\nfunction validateFetchUrl(value: string, origins: Set<string>): URL {\n\tconst url = new URL(value);\n\tif (url.protocol !== \"https:\" || url.username || url.password || !origins.has(url.origin)) {\n\t\tthrow new Error(\"http-fetch URL is outside the granted HTTPS origins\");\n\t}\n\treturn url;\n}\n\nasync function boundedResponseBody(response: Response, maxBytes: number): Promise<Buffer> {\n\tif (!response.body) return Buffer.alloc(0);\n\tconst reader = response.body.getReader();\n\tconst chunks: Buffer[] = [];\n\tlet bytes = 0;\n\twhile (true) {\n\t\tconst next = await reader.read();\n\t\tif (next.done) break;\n\t\tbytes += next.value.byteLength;\n\t\tif (bytes > maxBytes) {\n\t\t\tawait reader.cancel();\n\t\t\tthrow new Error(\"http-fetch response exceeds the configured byte limit\");\n\t\t}\n\t\tchunks.push(Buffer.from(next.value));\n\t}\n\treturn Buffer.concat(chunks);\n}\n\nexport function createHttpFetchCapabilityService(options: EvoHttpFetchCapabilityOptions): EvoCapabilityService {\n\tconst origins = new Set(\n\t\toptions.origins.map((origin) => {\n\t\t\tconst parsed = new URL(origin);\n\t\t\tif (parsed.protocol !== \"https:\" || parsed.origin !== origin || parsed.username || parsed.password) {\n\t\t\t\tthrow new Error(`Invalid granted HTTPS origin: ${origin}`);\n\t\t\t}\n\t\t\treturn origin;\n\t\t}),\n\t);\n\tconst maxResponseBytes = positiveInteger(options.maxResponseBytes, 1024 * 1024, \"maxResponseBytes\");\n\tconst maxRequestBytes = positiveInteger(options.maxRequestBytes, 1024 * 1024, \"maxRequestBytes\");\n\tconst maxTimeoutMs = positiveInteger(options.maxTimeoutMs, 60_000, \"maxTimeoutMs\");\n\treturn {\n\t\tprepare(value) {\n\t\t\tconst request = parseFetchRequest(value, maxRequestBytes, maxTimeoutMs);\n\t\t\tvalidateFetchUrl(request.url, origins);\n\t\t\treturn prepared(request);\n\t\t},\n\t\tasync execute(value, context): Promise<EvoCapabilityExecutionResult> {\n\t\t\tif (context.signal.aborted) throw new Error(\"http-fetch request aborted\");\n\t\t\tconst request = parseFetchRequest(value, maxRequestBytes, maxTimeoutMs);\n\t\t\tlet url = validateFetchUrl(request.url, origins);\n\t\t\tconst controller = new AbortController();\n\t\t\tconst abort = (): void => controller.abort(context.signal.reason);\n\t\t\tcontext.signal.addEventListener(\"abort\", abort, { once: true });\n\t\t\tif (context.signal.aborted) abort();\n\t\t\tconst timer = setTimeout(() => controller.abort(new Error(\"http-fetch request timed out\")), request.timeoutMs);\n\t\t\ttimer.unref();\n\t\t\ttry {\n\t\t\t\tfor (let redirects = 0; redirects <= 5; redirects += 1) {\n\t\t\t\t\tconst response = await fetch(url, {\n\t\t\t\t\t\tmethod: request.method,\n\t\t\t\t\t\theaders: request.headers,\n\t\t\t\t\t\t...(request.body === undefined ? {} : { body: request.body }),\n\t\t\t\t\t\tredirect: \"manual\",\n\t\t\t\t\t\tsignal: controller.signal,\n\t\t\t\t\t});\n\t\t\t\t\tif (response.status >= 300 && response.status < 400) {\n\t\t\t\t\t\tconst location = response.headers.get(\"location\");\n\t\t\t\t\t\tif (!location || redirects === 5) throw new Error(\"http-fetch redirect limit exceeded\");\n\t\t\t\t\t\turl = validateFetchUrl(new URL(location, url).toString(), origins);\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\t\t\t\t\tconst body = await boundedResponseBody(response, maxResponseBytes);\n\t\t\t\t\treturn {\n\t\t\t\t\t\tresult: {\n\t\t\t\t\t\t\turl: url.toString(),\n\t\t\t\t\t\t\tstatus: response.status,\n\t\t\t\t\t\t\theaders: Object.fromEntries(response.headers.entries()),\n\t\t\t\t\t\t\tbody: request.responseEncoding === \"base64\" ? body.toString(\"base64\") : body.toString(\"utf8\"),\n\t\t\t\t\t\t\tencoding: request.responseEncoding,\n\t\t\t\t\t\t},\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t\tthrow new Error(\"http-fetch redirect limit exceeded\");\n\t\t\t} finally {\n\t\t\t\tclearTimeout(timer);\n\t\t\t\tcontext.signal.removeEventListener(\"abort\", abort);\n\t\t\t}\n\t\t},\n\t};\n}\n"]}