Agent Sandbox Registry

awesome-providers

opensandbox/src/convert.mjs

无中文版

opensandbox/src/convert.mjs

// Pure conversion helpers (no I/O): easy to unit test.

import { badParams } from "./errors.mjs";

export const EXECD_PORT = 44772;
export const MIN_TTL_SECONDS = 60;

const NAME_RE = /^[A-Za-z0-9]([-A-Za-z0-9_.]*[A-Za-z0-9])?$/;
const DNS_RE = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/;

/** OpenSandbox stores metadata as Kubernetes labels and rejects anything else at create time. */
export function validateMetadata(metadata) {
  const out = {};
  for (const [key, rawValue] of Object.entries(metadata ?? {})) {
    const value = String(rawValue);
    const slash = key.indexOf("/");
    const prefix = slash >= 0 ? key.slice(0, slash) : "";
    const name = slash >= 0 ? key.slice(slash + 1) : key;
    if (slash >= 0 && (prefix.length === 0 || prefix.length > 253 || !DNS_RE.test(prefix))) {
      throw badParams(`Invalid metadata key '${key}': the prefix before '/' must be a DNS subdomain (max 253 chars).`);
    }
    if (name.length === 0 || name.length > 63 || !NAME_RE.test(name)) {
      throw badParams(
        `Invalid metadata key '${key}': OpenSandbox stores metadata as Kubernetes labels; ` +
          `a key name is 1-63 chars of [A-Za-z0-9._-], starting and ending alphanumeric.`,
      );
    }
    if (value.length > 63 || (value.length > 0 && !NAME_RE.test(value))) {
      throw badParams(
        `Invalid metadata value for '${key}': at most 63 chars of [A-Za-z0-9._-], ` +
          `starting and ending alphanumeric (got ${JSON.stringify(value.slice(0, 40))}).`,
      );
    }
    out[key] = value;
  }
  return out;
}

const STATE_TO_STATUS = {
  Pending: "creating",
  Running: "running",
  Pausing: "paused",
  Paused: "paused",
  Resuming: "creating",
  Stopping: "stopping",
  Terminated: "stopped",
  Failed: "error",
};

export function mapState(state) {
  return STATE_TO_STATUS[state] ?? "error";
}

const STATUS_TO_STATES = {
  running: ["Running"],
  paused: ["Paused", "Pausing"],
  creating: ["Pending", "Resuming"],
  stopping: ["Stopping"],
  stopped: ["Terminated"],
  error: ["Failed"],
};

export function statusToStates(status) {
  return status ? (STATUS_TO_STATES[status] ?? []) : [];
}

export function cpuLimit(cpu) {
  if (cpu === undefined || cpu === null || cpu === "") return "500m";
  const n = Number(cpu);
  if (!Number.isFinite(n) || n <= 0) throw badParams(`Invalid cpu '${cpu}': expected a positive number of cores.`);
  return Number.isInteger(n) ? String(n) : `${Math.round(n * 1000)}m`;
}

export function memoryLimit(memoryMb) {
  if (memoryMb === undefined || memoryMb === null || memoryMb === "") return "512Mi";
  const n = Number(memoryMb);
  if (!Number.isFinite(n) || n <= 0) throw badParams(`Invalid memory '${memoryMb}': expected megabytes.`);
  return `${Math.round(n)}Mi`;
}

/** OpenSandbox accepts only a numeric GPU count; Agent Sandbox's gpu is a free-form spec such as "A10". */
export function gpuLimit(gpu) {
  if (gpu === undefined || gpu === null || gpu === "") return undefined;
  if (/^\d+$/.test(String(gpu))) return String(gpu);
  throw badParams(`Unsupported gpu '${gpu}': OpenSandbox takes a numeric GPU count, not a GPU type.`);
}

/** TTL seconds -> server `timeout` (minimum 60). Returns {timeout, clamped}. */
export function ttlSeconds(timeout) {
  if (timeout === undefined || timeout === null) return { timeout: undefined, clamped: false };
  const n = Math.ceil(Number(timeout));
  if (!Number.isFinite(n) || n <= 0) throw badParams(`Invalid timeout '${timeout}': expected seconds.`);
  return n < MIN_TTL_SECONDS ? { timeout: MIN_TTL_SECONDS, clamped: true } : { timeout: n, clamped: false };
}

/** "a=b,c=d" or ["a=b"] or {a: "b"} -> object. */
export function parseAliasMap(value) {
  if (!value) return {};
  if (typeof value === "object" && !Array.isArray(value)) return { ...value };
  const items = Array.isArray(value) ? value : String(value).split(",");
  const out = {};
  for (const item of items) {
    const eq = String(item).indexOf("=");
    if (eq > 0) out[String(item).slice(0, eq).trim()] = String(item).slice(eq + 1).trim();
  }
  return out;
}

/** Minimal POSIX-ish word splitter (quotes and backslashes) for the `entrypoint` setting. */
export function shellSplit(text) {
  const out = [];
  let cur = "";
  let quote = "";
  let active = false;
  for (let i = 0; i < text.length; i += 1) {
    const ch = text[i];
    if (quote) {
      if (ch === quote) quote = "";
      else if (ch === "\\" && quote === '"' && i + 1 < text.length) cur += text[++i];
      else cur += ch;
    } else if (ch === "'" || ch === '"') {
      quote = ch;
      active = true;
    } else if (ch === "\\" && i + 1 < text.length) {
      cur += text[++i];
      active = true;
    } else if (/\s/.test(ch)) {
      if (active || cur) out.push(cur);
      cur = "";
      active = false;
    } else {
      cur += ch;
      active = true;
    }
  }
  if (quote) throw badParams(`Unterminated quote in '${text}'.`);
  if (active || cur) out.push(cur);
  return out;
}

export function shellQuote(word) {
  const s = String(word);
  return s === "" ? "''" : /^[A-Za-z0-9_@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
}

export const shellJoin = (argv) => argv.map(shellQuote).join(" ");

/** Template -> image URI: `base`/empty -> default image, alias -> image, anything else is a literal URI. */
export function resolveImage(template, cfg) {
  const name = String(template ?? "").trim();
  if (!name || name === "base") return cfg.defaultImage;
  if (Object.hasOwn(cfg.imageAliases, name)) return cfg.imageAliases[name];
  return name;
}

export function isCodeInterpreterImage(uri) {
  return /(^|\/)opensandbox\/code-interpreter(:|@|$)/.test(uri);
}

/** `Endpoint.endpoint` has no scheme ("host:port[/path]"). */
export function endpointUrl(endpoint, scheme) {
  return /^[a-z][a-z0-9+.-]*:\/\//i.test(endpoint) ? endpoint : `${scheme}://${endpoint}`;
}

/** Mode accepted as int (0o644 = 420), "0644"/"644"/"0o644" (octal text) -> execd's octal-digits int (644). */
export function modeDigits(mode) {
  if (mode === undefined || mode === null || mode === "") return undefined;
  if (typeof mode === "number") {
    if (!Number.isInteger(mode) || mode < 0) throw badParams(`Invalid file mode '${mode}'.`);
    return Number.parseInt(mode.toString(8), 10);
  }
  const text = String(mode).replace(/^0o/i, "");
  if (!/^[0-7]{1,4}$/.test(text)) throw badParams(`Invalid file mode '${mode}': expected octal digits.`);
  return Number.parseInt(text, 10);
}

export const basename = (path) => {
  const trimmed = String(path).replace(/\/+$/, "");
  return trimmed.slice(trimmed.lastIndexOf("/") + 1) || "/";
};

/** execd FileInfo -> EPP files entry. */
export function fileEntry(info) {
  const kind = info.type === "directory" ? "directory" : "file";
  const entry = {
    name: basename(info.path),
    path: info.path,
    type: kind,
    size: Number(info.size ?? 0),
  };
  if (info.modified_at) entry.mtime = info.modified_at;
  if (info.type === "symlink") entry.symlink = true;
  if (info.mode !== undefined) entry.mode = info.mode;
  if (info.owner) entry.owner = info.owner;
  if (info.group) entry.group = info.group;
  return entry;
}

/** Exit code carried by an execd `error` stream event (see the mapping doc §1.4). */
export function exitFromErrorEvent(error) {
  const evalue = String(error?.evalue ?? "");
  const trace = Array.isArray(error?.traceback) ? error.traceback.join(" ") : "";
  if (/^-?\d+$/.test(evalue)) {
    const code = Number(evalue);
    if (code === -1 && /killed|signal/i.test(trace)) return { exitCode: -1, killed: true, message: trace };
    return { exitCode: code, killed: false, message: trace };
  }
  const lower = evalue.toLowerCase();
  const notFound = lower.includes("not found") || lower.includes("no such file") || lower.includes("executable file");
  return { exitCode: notFound ? 127 : 126, killed: false, message: evalue || trace, startFailure: true };
}

/** execd < the line-preserving build emits one event per line without the terminator. */
export function restoreLineEnding(text) {
  return /[\r\n]$/.test(text) ? text : `${text}\n`;
}

export function parseHttpDate(value) {
  if (!value) return undefined;
  const ms = Date.parse(value);
  return Number.isNaN(ms) ? undefined : ms;
}