Agent Sandbox Registry

awesome-providers

opensandbox/src/http.mjs

无中文版

opensandbox/src/http.mjs

// HTTP access to the OpenSandbox lifecycle server and to per-sandbox `execd` daemons.

import { CODES, RpcError, mapHttpError, redact } from "./errors.mjs";
import { EXECD_PORT, endpointUrl } from "./convert.mjs";

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

/** Abort controller that fires on `signal` or after `ms`; `timedOut()` tells which. */
function deadline(signal, ms) {
  const controller = new AbortController();
  let timedOut = false;
  const timer = ms > 0 ? setTimeout(() => { timedOut = true; controller.abort(); }, ms) : undefined;
  const onAbort = () => controller.abort();
  if (signal) {
    if (signal.aborted) controller.abort();
    else signal.addEventListener("abort", onAbort, { once: true });
  }
  return {
    signal: controller.signal,
    timedOut: () => timedOut,
    done() {
      if (timer) clearTimeout(timer);
      signal?.removeEventListener("abort", onAbort);
    },
  };
}

function networkError(err, url, secrets) {
  const reason = err?.cause?.code ?? err?.cause?.message ?? err?.message ?? String(err);
  const target = redact(new URL(url).origin, secrets);
  return new RpcError(CODES.FAIL, `Cannot reach OpenSandbox at ${target}: ${reason}`, {
    retryable: true,
    suggestion: "Check ABX_OPENSANDBOX_BASE_URL and that the server (or the sandbox, if paused) is running.",
  });
}

export class OsbClient {
  constructor(cfg, { fetchImpl = globalThis.fetch, log = () => {} } = {}) {
    this.cfg = cfg;
    this.fetch = fetchImpl;
    this.log = log;
    this.endpoints = new Map();
  }

  #lifecycleHeaders(extra = {}) {
    const headers = { Accept: "application/json", ...extra };
    if (this.cfg.apiKey) headers["OPEN-SANDBOX-API-KEY"] = this.cfg.apiKey;
    return headers;
  }

  /** Low level: one fetch with timeout, retry (idempotent calls only) and error mapping. */
  async #request(url, init, { signal, timeoutMs, idempotent = false, operation, what, feature, streaming = false }) {
    const attempts = idempotent ? this.cfg.maxRetries + 1 : 1;
    let lastError;
    for (let attempt = 0; attempt < attempts; attempt += 1) {
      if (attempt > 0) await sleep(Math.min(200 * 2 ** (attempt - 1), 2000));
      const limit = deadline(signal, timeoutMs ?? this.cfg.httpTimeoutMs);
      try {
        const response = await this.fetch(url, { ...init, signal: limit.signal });
        if (!response.ok) {
          const text = await response.text();
          const err = mapHttpError(response.status, text, { operation, what, feature });
          err.message = redact(err.message, this.cfg.secrets);
          if (err.data.retryable && attempt + 1 < attempts) {
            lastError = err;
            limit.done();
            continue;
          }
          throw err;
        }
        if (streaming) {
          return { response, release: limit.done, timedOut: limit.timedOut };
        }
        const out = { response, buffer: Buffer.from(await response.arrayBuffer()) };
        limit.done();
        return out;
      } catch (err) {
        limit.done();
        if (err instanceof RpcError) throw err;
        if (limit.timedOut()) {
          throw new RpcError(CODES.TIMEOUT, `OpenSandbox did not answer ${operation ?? "the request"} in time.`, { retryable: idempotent });
        }
        if (signal?.aborted) throw new RpcError(CODES.FAIL, "Request cancelled.", { cancelled: true });
        lastError = networkError(err, url, this.cfg.secrets);
        if (attempt + 1 >= attempts) throw lastError;
      }
    }
    throw lastError;
  }

  /** Lifecycle API. Returns `{status, json, headers}`. */
  async lifecycle(method, path, { query, body, signal, timeoutMs, idempotent = method === "GET", operation } = {}) {
    const url = new URL(`${this.cfg.apiBase}${path}`);
    for (const [key, value] of query ?? []) url.searchParams.append(key, value);
    const init = { method, headers: this.#lifecycleHeaders() };
    if (body !== undefined) {
      init.body = JSON.stringify(body);
      init.headers["Content-Type"] = "application/json";
    }
    const { response, buffer } = await this.#request(url.toString(), init, {
      signal,
      timeoutMs,
      idempotent,
      operation: operation ?? `${method} ${path}`,
      what: "sandbox",
    });
    const text = buffer.toString("utf8");
    let json;
    try {
      json = text ? JSON.parse(text) : undefined;
    } catch {
      json = undefined;
    }
    return { status: response.status, json, headers: response.headers };
  }

  /** `{url, headers}` for a sandbox port (cached for the execd port). */
  async endpoint(sandboxId, port, { force = false, signal } = {}) {
    const cacheable = port === EXECD_PORT;
    if (cacheable && !force && this.endpoints.has(sandboxId)) return this.endpoints.get(sandboxId);
    const query = this.cfg.useServerProxy ? [["use_server_proxy", "true"]] : [];
    const { json } = await this.lifecycle("GET", `/sandboxes/${encodeURIComponent(sandboxId)}/endpoints/${port}`, {
      query,
      signal,
      operation: `resolve endpoint for port ${port}`,
    });
    if (!json || typeof json.endpoint !== "string") {
      throw new RpcError(CODES.FAIL, `OpenSandbox returned no endpoint for port ${port}.`);
    }
    const resolved = { url: endpointUrl(json.endpoint, this.cfg.scheme).replace(/\/+$/, ""), headers: { ...(json.headers ?? {}) } };
    if (cacheable) this.endpoints.set(sandboxId, resolved);
    return resolved;
  }

  forget(sandboxId) {
    this.endpoints.delete(sandboxId);
  }

  /**
   * Data-plane request against the sandbox's execd. `Endpoint.headers` are replayed verbatim.
   * Returns `{response, buffer}`; with `streaming: true` returns `{response, release, timedOut}` (drain the body, then call `release`).
   */
  async execd(sandboxId, method, path, opts = {}) {
    const { query, body, rawBody, contentType, signal, timeoutMs, idempotent = method === "GET", operation, what, headers: extra } = opts;
    let refreshed = false;
    for (;;) {
      const ep = await this.endpoint(sandboxId, EXECD_PORT, { force: refreshed, signal });
      const url = new URL(`${ep.url}${path}`);
      for (const [key, value] of query ?? []) url.searchParams.append(key, value);
      const init = { method, headers: { ...ep.headers, ...(extra ?? {}) } };
      if (body !== undefined) {
        init.body = JSON.stringify(body);
        init.headers["Content-Type"] = "application/json";
      } else if (rawBody !== undefined) {
        init.body = rawBody;
        init.headers["Content-Type"] = contentType;
      }
      try {
        return await this.#request(url.toString(), init, {
          signal,
          timeoutMs,
          idempotent,
          operation: operation ?? `${method} ${path}`,
          what,
          streaming: opts.streaming,
        });
      } catch (err) {
        const connectionFailure = err instanceof RpcError && err.data?.retryable && err.httpStatus === undefined;
        if (connectionFailure && !refreshed) {
          this.forget(sandboxId);
          refreshed = true;
          continue;
        }
        throw err;
      }
    }
  }

  async execdJson(sandboxId, method, path, opts = {}) {
    const { buffer } = await this.execd(sandboxId, method, path, opts);
    const text = buffer.toString("utf8");
    return text ? JSON.parse(text) : undefined;
  }
}

export { sleep };