Agent Sandbox Registry

awesome-providers

opensandbox/src/sandbox.mjs

无中文版

opensandbox/src/sandbox.mjs

// sandbox.* and ports.url methods (OpenSandbox lifecycle API).

import { CODES, RpcError, badParams, failure } from "./errors.mjs";
import {
  CODE_INTERPRETER_ENTRYPOINT,
  DEFAULT_ENTRYPOINT,
  withCreateOptions,
} from "./config.mjs";
import {
  EXECD_PORT,
  cpuLimit,
  gpuLimit,
  isCodeInterpreterImage,
  mapState,
  memoryLimit,
  parseHttpDate,
  resolveImage,
  statusToStates,
  ttlSeconds,
  validateMetadata,
} from "./convert.mjs";
import { sleep } from "./http.mjs";

function sameArgv(a, b) {
  return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((part, i) => part === b[i]);
}

/**
 * Entrypoint for one create.
 *
 * An omitted entrypoint, and the manifest default the host delivers when the user did not
 * set one, are automatic: stock images stay up (`tail -f /dev/null`) so execd can serve
 * exec and files, and a code-interpreter image keeps its runtime script. Any other
 * command is used as given (`python3 commands.py` for a generated image).
 */
export function selectEntrypoint(cfg, image) {
  const configured = cfg.entrypoint;
  const automatic = !configured || sameArgv(configured, DEFAULT_ENTRYPOINT);
  if (automatic && isCodeInterpreterImage(image)) return [...CODE_INTERPRETER_ENTRYPOINT];
  return configured?.length ? configured : [...DEFAULT_ENTRYPOINT];
}

const need = (params, key) => {
  const value = params[key];
  if (typeof value !== "string" || value === "") throw badParams(`Missing parameter '${key}'.`);
  return value;
};

export const sandboxId = (params) => need(params, "sandbox_id");

export function toRecord(sb, fallbackImage = "") {
  const record = {
    sandbox_id: sb.id,
    template: sb.image?.uri ?? (sb.snapshotId ? `snapshot:${sb.snapshotId}` : fallbackImage),
    status: mapState(sb.status?.state),
    metadata: sb.metadata ?? {},
  };
  if (sb.createdAt) record.created_at = sb.createdAt;
  if (sb.expiresAt) record.expires_at = sb.expiresAt;
  if (sb.status?.state) record.state = sb.status.state;
  if (sb.status?.message) record.status_message = sb.status.message;
  return record;
}

export function buildCreateBody(params, baseCfg, log = () => {}) {
  const cfg = withCreateOptions(baseCfg, params.options);
  if (params.disk !== undefined && params.disk !== null && params.disk !== 0) {
    throw badParams("OpenSandbox cannot size the disk: 'disk' is ignored by its Docker runtime, so it is refused.", {
      suggestion: "Omit disk, or use a volume through a custom deployment.",
    });
  }
  const image = resolveImage(params.template, cfg);
  const limits = { cpu: cpuLimit(params.cpu), memory: memoryLimit(params.memory) };
  const gpu = gpuLimit(params.gpu);
  if (gpu !== undefined) limits.gpu = gpu;
  const entrypoint = selectEntrypoint(cfg, image);
  const body = {
    image: { uri: image },
    entrypoint,
    resourceLimits: limits,
  };
  if (cfg.registryUsername) body.image.auth = { username: cfg.registryUsername, password: cfg.registryPassword ?? "" };
  const { timeout, clamped } = ttlSeconds(params.timeout);
  if (timeout !== undefined) body.timeout = timeout;
  if (clamped) log(`timeout ${params.timeout}s is below OpenSandbox's 60 s minimum; using 60 s`);
  if (params.envs && Object.keys(params.envs).length > 0) body.env = Object.fromEntries(Object.entries(params.envs).map(([k, v]) => [k, String(v)]));
  const metadata = validateMetadata(params.metadata);
  if (Object.keys(metadata).length > 0) body.metadata = metadata;
  if (cfg.networkPolicy) body.networkPolicy = cfg.networkPolicy;
  if (cfg.platform) body.platform = cfg.platform;
  // `secure` is sent by the host only when the user set it; otherwise the configured default applies.
  if (typeof params.secure === "boolean" ? params.secure : cfg.secureAccess) body.secureAccess = true;
  return { body, image };
}

async function waitRunning(ctx, id, signal) {
  const deadline = Date.now() + ctx.cfg.readyTimeout * 1000;
  for (;;) {
    const { json } = await ctx.osb.lifecycle("GET", `/sandboxes/${encodeURIComponent(id)}`, { signal });
    const state = json?.status?.state;
    if (state === "Running") return json;
    if (state === "Failed" || state === "Terminated") {
      throw failure(`Sandbox ${id} entered state ${state}${json?.status?.message ? `: ${json.status.message}` : ""}.`);
    }
    if (Date.now() > deadline) throw new RpcError(CODES.TIMEOUT, `Sandbox ${id} was not running after ${ctx.cfg.readyTimeout}s (state ${state}).`);
    await sleep(500);
  }
}

async function waitExecd(ctx, id, signal) {
  const deadline = Date.now() + ctx.cfg.readyTimeout * 1000;
  let last;
  for (;;) {
    try {
      await ctx.osb.execd(id, "GET", "/ping", { signal, timeoutMs: 5000, idempotent: false, operation: "execd ping" });
      return;
    } catch (err) {
      if (!(err instanceof RpcError) || err.data?.cancelled) throw err;
      last = err;
      ctx.osb.forget(id);
    }
    if (Date.now() > deadline) {
      throw new RpcError(CODES.TIMEOUT, `execd of sandbox ${id} did not become ready within ${ctx.cfg.readyTimeout}s: ${last?.message ?? ""}`);
    }
    await sleep(250);
  }
}

async function killQuietly(ctx, id) {
  try {
    await ctx.osb.lifecycle("DELETE", `/sandboxes/${encodeURIComponent(id)}`, { idempotent: false });
  } catch {
    /* best effort */
  }
}

export const methods = {
  async "sandbox.create"(params, ctx) {
    const { body, image } = buildCreateBody(params, ctx.cfg, ctx.log);
    const requestMs = Math.max(ctx.cfg.httpTimeoutMs, ctx.cfg.readyTimeout * 1000, (Number(params.request_timeout) || 0) * 1000);
    const { json } = await ctx.osb.lifecycle("POST", "/sandboxes", {
      body,
      signal: ctx.signal,
      timeoutMs: requestMs,
      idempotent: false,
      operation: "create sandbox",
    });
    const id = json?.id;
    if (!id) throw failure("OpenSandbox returned no sandbox id.");
    try {
      await waitRunning(ctx, id, ctx.signal);
      await waitExecd(ctx, id, ctx.signal);
    } catch (err) {
      await killQuietly(ctx, id);
      throw err;
    }
    const { json: full } = await ctx.osb.lifecycle("GET", `/sandboxes/${encodeURIComponent(id)}`, { signal: ctx.signal });
    return toRecord(full ?? json, image);
  },

  async "sandbox.get"(params, ctx) {
    const { json } = await ctx.osb.lifecycle("GET", `/sandboxes/${encodeURIComponent(sandboxId(params))}`, { signal: ctx.signal, operation: "get sandbox" });
    return toRecord(json);
  },

  async "sandbox.connect"(params, ctx) {
    return methods["sandbox.get"](params, ctx);
  },

  async "sandbox.list"(params, ctx) {
    const page = params.cursor ? Number.parseInt(String(params.cursor), 10) : 1;
    if (!Number.isInteger(page) || page < 1) throw badParams(`Invalid cursor '${params.cursor}'.`);
    const limit = Math.min(Math.max(Number(params.limit) || 20, 1), 200);
    const query = [["page", String(page)], ["pageSize", String(limit)]];
    for (const state of statusToStates(params.status)) query.push(["state", state]);
    const filter = Object.entries(params.metadata ?? {});
    if (filter.length > 0) query.push(["metadata", filter.map(([k, v]) => `${k}=${v}`).join("&")]);
    const { json } = await ctx.osb.lifecycle("GET", "/sandboxes", { query, signal: ctx.signal, operation: "list sandboxes" });
    const result = { sandboxes: (json?.items ?? []).map((item) => toRecord(item)) };
    if (json?.pagination?.hasNextPage) result.next = String(page + 1);
    return result;
  },

  async "sandbox.kill"(params, ctx) {
    const path = `/sandboxes/${encodeURIComponent(sandboxId(params))}`;
    const attempt = () => ctx.osb.lifecycle("DELETE", path, { signal: ctx.signal, idempotent: false, operation: "kill sandbox" });
    try {
      try {
        await attempt();
      } catch (err) {
        if (err.httpStatus !== 409) throw err;
        await sleep(1000);
        await attempt();
      }
    } catch (err) {
      if (err.code !== CODES.NOT_FOUND) throw err;
    }
    ctx.osb.forget(params.sandbox_id);
    return {};
  },

  async "sandbox.set_timeout"(params, ctx) {
    const id = sandboxId(params);
    const seconds = Math.ceil(Number(params.timeout));
    if (!Number.isFinite(seconds) || seconds <= 0) throw badParams(`Invalid timeout '${params.timeout}'.`);
    // The API sets an absolute time; derive "now" from the server's clock to avoid client skew.
    const { headers } = await ctx.osb.lifecycle("GET", `/sandboxes/${encodeURIComponent(id)}`, { signal: ctx.signal, operation: "read server time" });
    const now = parseHttpDate(headers.get("date")) ?? Date.now();
    const expiresAt = new Date(now + seconds * 1000).toISOString();
    await ctx.osb.lifecycle("POST", `/sandboxes/${encodeURIComponent(id)}/renew-expiration`, {
      body: { expiresAt },
      signal: ctx.signal,
      idempotent: false,
      operation: "set timeout",
    });
    return { expires_at: expiresAt };
  },

  async "sandbox.pause"(params, ctx) {
    return transition(ctx, sandboxId(params), "pause", "Paused");
  },

  async "sandbox.resume"(params, ctx) {
    return transition(ctx, sandboxId(params), "resume", "Running");
  },

  async "ports.url"(params, ctx) {
    const port = Number(params.port);
    if (!Number.isInteger(port) || port < 1 || port > 65535) throw badParams(`Invalid port '${params.port}'.`);
    const ep = await ctx.osb.endpoint(sandboxId(params), port, { force: true, signal: ctx.signal });
    const result = { url: ep.url };
    if (Object.keys(ep.headers).length > 0) result.headers = ep.headers;
    return result;
  },
};

async function transition(ctx, id, verb, target) {
  const enc = encodeURIComponent(id);
  await ctx.osb.lifecycle("POST", `/sandboxes/${enc}/${verb}`, { signal: ctx.signal, idempotent: false, operation: `${verb} sandbox` });
  const deadline = Date.now() + 30_000;
  for (;;) {
    const { json } = await ctx.osb.lifecycle("GET", `/sandboxes/${enc}`, { signal: ctx.signal });
    if (json?.status?.state === target) break;
    if (json?.status?.state === "Failed") throw failure(`Sandbox ${id} failed while trying to ${verb}.`);
    if (Date.now() > deadline) throw new RpcError(CODES.TIMEOUT, `Sandbox ${id} did not reach ${target} within 30s.`);
    await sleep(300);
  }
  if (verb === "resume") await waitExecd(ctx, id, ctx.signal);
  return {};
}

export { EXECD_PORT };