UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

166 lines (165 loc) • 7.42 kB
import { l as normalizeOptionalString } from "./string-coerce-6kR-86XC.mjs"; import { r as truncateUtf16Safe } from "./utf16-slice-D_ngcYKd.mjs"; import { _ as redactSensitiveText } from "./redact-BEIE6NCT.mjs"; import { d as AUTH_RATE_LIMIT_SCOPE_WORKER_TRANSFER } from "./auth-rate-limit-ljr4u3Yk.mjs"; import { n as withSerializedRateLimitAttempt } from "./rate-limit-attempt-serialization-DgZvaJXP.mjs"; import { _ as watchClientDisconnect, u as sendJson } from "./http-common-Cl0x-bzn.mjs"; import { t as ArtifactTransferBusyError } from "./artifact-transfer-service-KEVfY7-P.mjs"; import { Transform } from "node:stream"; import { pipeline as pipeline$1 } from "node:stream/promises"; //#region src/gateway/worker-environments/artifact-transfer-http.ts const SHA256_PATTERN = /^[a-f0-9]{64}$/u; function sendOpaqueNotFound(res) { sendJson(res, 404, { error: "not_found" }); } function streamInterruptionReason(error, token, tarballPath) { const message = (error instanceof Error ? error.message : String(error)).replaceAll(token, "[redacted]").replaceAll(tarballPath, "[artifact]").replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s"'<>]+/giu, "[url]").replace(/(["'])[^"'<>]*[\\/][^"'<>]*\1/gu, "[path]").replace(/[^\s"'<>(),;=]*[\\/][^\s"'<>(),;]*/gu, "[path]"); const redacted = redactSensitiveText(message, { mode: "tools" }).replace(/\s+/gu, " ").trim(); return `stream error (${truncateUtf16Safe(redacted || "unknown error", 240)})`; } async function handleArtifactTransferHttpRequest(params) { const parsed = URL.parse(params.req.url ?? "/", "http://localhost"); if (!parsed || params.classifyPath(parsed.pathname) === "outside") return false; params.res.setHeader("Cache-Control", "no-store"); const prefix = params.routePrefix; const artifactKey = parsed.pathname.startsWith(prefix) ? parsed.pathname.slice(prefix.length) : ""; if (params.req.method !== "GET" || !SHA256_PATTERN.test(artifactKey) || parsed.search || parsed.hash) { sendOpaqueNotFound(params.res); return true; } return handleWorkerTransferHttpRequest(params, (bearer) => params.callback?.({ req: params.req, res: params.res, artifactKey, bearer })); } async function handleWorkerTransferHttpRequest(params, authorize) { const authorization = normalizeOptionalString(params.req.headers.authorization); const bearer = authorization?.toLowerCase().startsWith("bearer ") ? normalizeOptionalString(authorization.slice(7)) : void 0; const admission = await withSerializedRateLimitAttempt({ ip: params.clientIp, scope: AUTH_RATE_LIMIT_SCOPE_WORKER_TRANSFER, run: async () => { const rateCheck = params.rateLimiter?.check(params.clientIp, AUTH_RATE_LIMIT_SCOPE_WORKER_TRANSFER); if (rateCheck && !rateCheck.allowed) return { kind: "rate-limited", retryAfterMs: rateCheck.retryAfterMs }; const pendingAuthorization = bearer ? authorize(bearer) : void 0; const outcome = pendingAuthorization ? await pendingAuthorization : { kind: "unauthorized" }; if (outcome.kind === "unauthorized") params.rateLimiter?.recordFailure(params.clientIp, AUTH_RATE_LIMIT_SCOPE_WORKER_TRANSFER); else params.rateLimiter?.reset(params.clientIp, AUTH_RATE_LIMIT_SCOPE_WORKER_TRANSFER); return outcome; } }); if (admission.kind === "rate-limited") { if (admission.retryAfterMs > 0) params.res.setHeader("Retry-After", String(Math.ceil(admission.retryAfterMs / 1e3))); sendJson(params.res, 429, { error: "rate_limited" }); return true; } if (admission.kind === "unauthorized") { sendOpaqueNotFound(params.res); return true; } await admission.handle(); return true; } function createArtifactTransferHttpCallback(service) { return async ({ req, res, artifactKey, bearer }) => { let authorization; try { authorization = service.authorize({ token: bearer, artifactKey }); } catch (error) { if (!(error instanceof ArtifactTransferBusyError)) throw error; return { kind: "authorized", handle: () => sendJson(res, 503, { error: "transfer_in_progress" }) }; } if (!authorization) return { kind: "unauthorized" }; const { capability } = authorization; return { kind: "authorized", handle: async () => { let servedBytes = 0; let interruptionReason; const authoritySignal = service.authorizationSignal(authorization); const recordAuthorityClosure = () => { interruptionReason ??= `authority closed (${capability.revocationReason ?? "authorization lost"})`; }; authoritySignal.addEventListener("abort", recordAuthorityClosure, { once: true }); if (authoritySignal.aborted) recordAuthorityClosure(); const clientAbort = new AbortController(); const stopWatchingDisconnect = watchClientDisconnect(req, res, clientAbort, () => { interruptionReason ??= "client disconnected"; }); const signal = AbortSignal.any([authoritySignal, clientAbort.signal]); let fileHandle; try { const file = await service.openFile(authorization); fileHandle = file?.handle; if (!file || signal.aborted || !service.isAuthorizationCurrent(authorization)) { sendOpaqueNotFound(res); return; } const range = req.headers.range; const start = range === void 0 ? 0 : Number(/^bytes=(\d+)-$/u.exec(range)?.[1]); if (range !== void 0 && (!Number.isSafeInteger(start) || start >= file.bytes)) { res.setHeader("Content-Range", `bytes */${file.bytes}`); sendJson(res, 416, { error: "range_not_satisfiable" }); return; } servedBytes = start; const checkAuthority = new Transform({ transform(chunk, _encoding, next) { if (!service.isAuthorizationCurrent(authorization)) { next(/* @__PURE__ */ new Error("Worker artifact transfer authority closed")); return; } servedBytes += chunk.length; try { capability.onProgress?.(servedBytes); } catch {} next(null, chunk); } }); res.writeHead(range === void 0 ? 200 : 206, { "content-type": "application/octet-stream", "content-length": String(file.bytes - start), "accept-ranges": "bytes", ...range === void 0 ? {} : { "content-range": `bytes ${start}-${file.bytes - 1}/${file.bytes}` }, "x-openclaw-content-sha256": file.sha256 }); const stream = file.handle.createReadStream({ start, end: file.bytes - 1, autoClose: false }); stream.once("error", (error) => { interruptionReason ??= streamInterruptionReason(error, capability.token, capability.artifact.tarballPath); }); await pipeline$1(stream, checkAuthority, res, { signal }); } catch (error) { interruptionReason ??= streamInterruptionReason(error, capability.token, capability.artifact.tarballPath); if (!res.headersSent && !res.destroyed) sendOpaqueNotFound(res); else if (!res.destroyed) res.destroy(); } finally { stopWatchingDisconnect(); authoritySignal.removeEventListener("abort", recordAuthorityClosure); if (servedBytes !== capability.artifact.tarballBytes || !res.writableFinished) try { capability.onInterrupted?.(servedBytes, interruptionReason ?? "stream error"); } catch {} try { await fileHandle?.close(); } finally { service.finish(authorization); } } } }; }; } //#endregion export { handleArtifactTransferHttpRequest as n, handleWorkerTransferHttpRequest as r, createArtifactTransferHttpCallback as t };