UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

217 lines (216 loc) 8.41 kB
import { l as normalizeOptionalString } from "./string-coerce-CIXf7egm.js"; import { d as AUTH_RATE_LIMIT_SCOPE_WORKER_TRANSFER } from "./auth-rate-limit-C2k5aTEA.js"; import { n as withSerializedRateLimitAttempt } from "./rate-limit-attempt-serialization-CvMQJrpm.js"; import { m as watchClientDisconnect, s as sendJson } from "./http-common-BaZaosnr.js"; import { s as classifyNodeWorkspaceTransferPath } from "./gateway-http-route-contracts-jBtizxKU.js"; import { n as NODE_WORKSPACE_TRANSFER_PATH } from "./node-workspace-transfer-protocol-DhJOhHIM.js"; import { n as isNodeWorkspaceTransferLimitError, r as nodeWorkspaceTransferInvalidReason } from "./node-workspace-transfer-service-CpwkwhwW.js"; import fs from "node:fs"; import fs$1 from "node:fs/promises"; import { pipeline } from "node:stream/promises"; //#region src/gateway/worker-environments/node-workspace-transfer-http.ts const SHA256_PATTERN = /^[a-f0-9]{64}$/u; const TRANSFER_TIMEOUT_MS = 6e5; const MAX_ENVIRONMENT_ID_LENGTH = 256; const OPAQUE_NOT_FOUND = { error: "not_found" }; function decodeEnvironmentId(segment) { let value; try { value = decodeURIComponent(segment); } catch { return; } if (!value || value.length > MAX_ENVIRONMENT_ID_LENGTH || value.includes("/") || value.includes("\\") || value.includes("\0")) return; return value; } function parseNodeWorkspaceTransferHttpRoute(pathname, method) { if (!pathname.startsWith(`/__openclaw__/worker-transfer/v1/`)) return; const segments = pathname.slice(NODE_WORKSPACE_TRANSFER_PATH.length + 1).split("/"); const environmentId = segments[1] ? decodeEnvironmentId(segments[1]) : void 0; if (!environmentId) return; if (method === "GET" && segments.length === 5 && segments[0] === "environments" && segments[2] === "snapshots" && segments[3] !== void 0 && SHA256_PATTERN.test(segments[3]) && (segments[4] === "manifest" || segments[4] === "pack")) return { kind: segments[4], direction: "download", environmentId, manifestRef: `sha256:${segments[3]}` }; if (method === "GET" && segments.length === 4 && segments[0] === "environments" && segments[2] === "blobs" && segments[3] !== void 0 && SHA256_PATTERN.test(segments[3])) return { kind: "blob", direction: "download", environmentId, sha256: segments[3] }; if (method === "POST" && segments.length === 4 && segments[0] === "environments" && segments[2] === "reconciliations" && segments[3] !== void 0 && SHA256_PATTERN.test(segments[3])) return { kind: "reconcile", direction: "upload", environmentId, baseManifestRef: `sha256:${segments[3]}` }; } function bearerToken(req) { const authorization = normalizeOptionalString(req.headers.authorization); if (!authorization?.toLowerCase().startsWith("bearer ")) return; return normalizeOptionalString(authorization.slice(7)); } function sendOpaqueNotFound(res) { sendJson(res, 404, OPAQUE_NOT_FOUND); } function sendTransferRateLimited(res, retryAfterMs) { if (retryAfterMs > 0) res.setHeader("Retry-After", String(Math.ceil(retryAfterMs / 1e3))); sendJson(res, 429, { error: "rate_limited" }); } /** Reserve and authenticate the node workspace transfer namespace before normal HTTP routing. */ async function handleNodeWorkspaceTransferHttpRequest(params) { const parsed = URL.parse(params.req.url ?? "/", "http://localhost"); if (!parsed?.pathname || classifyNodeWorkspaceTransferPath(parsed.pathname) === "outside") return false; params.res.setHeader("Cache-Control", "no-store"); const route = parseNodeWorkspaceTransferHttpRoute(parsed.pathname, params.req.method); if (!route || parsed.search) { sendOpaqueNotFound(params.res); return true; } const bearer = bearerToken(params.req); 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 outcome = bearer && params.callback ? await params.callback({ req: params.req, res: params.res, route, bearer }) : { kind: "unauthorized" }; if (outcome.kind === "unauthorized") { params.rateLimiter?.recordFailure(params.clientIp, AUTH_RATE_LIMIT_SCOPE_WORKER_TRANSFER); return outcome; } params.rateLimiter?.reset(params.clientIp, AUTH_RATE_LIMIT_SCOPE_WORKER_TRANSFER); return outcome; } }); if (admission.kind === "rate-limited") { sendTransferRateLimited(params.res, admission.retryAfterMs); return true; } if (admission.kind === "unauthorized") { sendOpaqueNotFound(params.res); return true; } await admission.handle(); return true; } function createNodeWorkspaceTransferHttpCallback(service) { return async ({ req, res, route, bearer }) => { const authorization = service.authorize({ route, token: bearer }); if (!authorization) return { kind: "unauthorized" }; return { kind: "authorized", handle: async () => { const clientAbort = new AbortController(); const stopWatchingDisconnect = watchClientDisconnect(req, res, clientAbort); const signal = AbortSignal.any([ service.authorizationSignal(authorization), clientAbort.signal, AbortSignal.timeout(TRANSFER_TIMEOUT_MS) ]); const abortRequest = () => { if (!req.destroyed) req.destroy(signal.reason instanceof Error ? signal.reason : void 0); }; signal.addEventListener("abort", abortRequest, { once: true }); const stillCurrent = () => !signal.aborted && service.isAuthorizationCurrent(authorization); try { if (route.kind === "manifest" || route.kind === "pack") { const snapshot = service.snapshot(authorization); if (!snapshot) { sendOpaqueNotFound(res); return; } if (route.kind === "manifest") { const body = Buffer.from(snapshot.rawManifest); if (!stillCurrent()) return; res.writeHead(200, { "content-type": "application/json; charset=utf-8", "content-length": String(body.byteLength) }); res.end(body); return; } const packPath = await service.pack(authorization); if (!packPath) { sendOpaqueNotFound(res); return; } const stats = await fs$1.stat(packPath); if (!stillCurrent()) return; res.writeHead(200, { "content-type": "application/octet-stream", "content-length": String(stats.size) }); await pipeline(fs.createReadStream(packPath), res, { signal }); return; } if (route.kind === "blob") { const blob = service.blob(authorization); if (!blob || !await service.verifyBlob({ path: blob.path, size: blob.size, sha256: blob.sha256 })) { sendOpaqueNotFound(res); return; } if (!stillCurrent()) return; res.writeHead(200, { "content-type": "application/octet-stream", "content-length": String(blob.size) }); await pipeline(fs.createReadStream(blob.path), res, { signal }); return; } try { const result = await service.receiveUpload({ authorization, request: req, signal }); if (!stillCurrent()) return; const body = Buffer.from(JSON.stringify(result)); res.writeHead(200, { "content-type": "application/json; charset=utf-8", "content-length": String(body.byteLength) }); res.end(body); } catch (error) { if (signal.aborted || res.destroyed) return; const limit = isNodeWorkspaceTransferLimitError(error); const reason = nodeWorkspaceTransferInvalidReason(error); const body = Buffer.from(JSON.stringify({ error: limit ? "workspace_transfer_limit" : "workspace_transfer_invalid", ...reason ? { reason } : {} })); res.writeHead(limit ? 413 : 400, { "content-type": "application/json; charset=utf-8", "content-length": String(body.byteLength) }); res.end(body); } } catch (error) { if (!signal.aborted && !res.destroyed) throw error; } finally { signal.removeEventListener("abort", abortRequest); stopWatchingDisconnect(); } } }; }; } //#endregion export { handleNodeWorkspaceTransferHttpRequest as n, createNodeWorkspaceTransferHttpCallback as t };