UNPKG

@tanstack/ai-sandbox

Version:

Provider-agnostic sandbox layer for TanStack AI — run harness adapters inside isolated sandboxes (defineSandbox, defineWorkspace, withSandbox) with a uniform SandboxHandle, workspace bootstrap, policy, and resumable lifecycle.

383 lines (382 loc) • 13.2 kB
import { shellQuote } from "./journal.js"; import { posix } from "node:path"; import { createOutputTail } from "@tanstack/ai-utils"; //#region src/workspace-backend.ts /** A background job keeps the newest 1 MiB of its output. */ var MAX_JOB_OUTPUT = 1048576; /** `realpath` follows at most this many links, like Linux. */ var MAX_LINKS = 40; /** The first bytes that `readLines` gives, for the file type check. */ var HEAD_BYTES = 8192; /** `readLines` gives no more lines after this many bytes. */ var MAX_PAGE_BYTES = 65536; /** The line in the output of `readLines` between the head and the lines. */ var LINES_MARK = "@lines"; /** * The page in the output of the `readLines` command, for a page from line * `first`. The output is the size, the first bytes as hex from `od`, a line * {@link LINES_MARK}, the lines of the page, and a last line: `more`, or * `total` and the number of lines. */ function parsePage(stdout, first) { const rows = stdout.split("\n"); if (rows.at(-1) === "") rows.pop(); const mark = rows.indexOf(LINES_MARK); const status = rows.at(-1) ?? ""; if (!(mark > 0 && mark < rows.length - 1 && /^(more|total \d+)$/.test(status))) throw new Error("readLines: the output of the sandbox is not a page."); const size = Number((rows[0] ?? "").trim()); const hex = rows.slice(1, mark).join(" ").split(/\s+/).filter(Boolean); const head = Uint8Array.from(hex, (byte) => Number.parseInt(byte, 16)); const lines = rows.slice(mark + 1, -1); if (status === "more") return { head, size, lines, total: void 0 }; if (size === 0) return { head, size, lines: first === 1 ? [""] : [], total: 1 }; return { head, size, lines, total: Number(status.slice(6)) }; } /** * A `WorkspaceBackend` over a live sandbox. With it, `workspaceTools()` * reads and writes files and runs commands in the sandbox, not on this * machine. * * Who stops the sandbox: * * - By default, you. `close()` of the backend stops the jobs that it * started, and leaves the sandbox running. * - With `owned: true`, the backend. `close({ reason: 'closed' })` also * destroys the sandbox. Use it for a sandbox that you create in * `workspace.bind`, one for each thread. * * - Paths are POSIX paths in the sandbox, like `/workspace/src/a.ts`. * - `exec` gives exit code 124 when `timeoutMs` stops a command, and 1 when * `signal` stops it. A sandbox with `capabilities.killableProcesses` off * cannot stop the command, so it continues in the sandbox. * - `spawn` is there only when the sandbox has * `capabilities.backgroundProcesses`. * - `stat` gives `mtimeMs: 0`. The sandbox file system does not give the * time of the last change. With `lstat`, `stat` never reads the file. * - The `signal` of a file call goes to the sandbox file system and to the * commands that the call runs, like `readlink`. * - `readLines` reads one page of a file in the sandbox with `wc`, `od`, * and `awk`, so only the page comes out of the sandbox. * - `realpath` resolves links in the sandbox: `lstat` finds each link, and * `readlink` in the sandbox reads its target. A sandbox without `lstat` * cannot find links, so `realpath` throws, and the tools refuse each path. * * - `spawn` keeps the newest 1 MiB of the output of a job, and gives each * chunk to `onOutput` as it comes. * - `close({ reason: 'moved' })` keeps the jobs and the sandbox, so the * next machine can resume them. * - `binding()` gives the provider and the id of the sandbox. The session * log keeps them, so `reattach` of `workspace` can resume the sandbox on * another machine. * - The id of a job is its pid in the sandbox and its start time from * `ps`. `killJob` stops it, also from a backend over the same sandbox on * another machine, and only when the process with that pid started at * that time. A sandbox without a `ps -o lstart` cannot check it, so * `killJob` rejects for a job that runs. * * @param handle - The sandbox, from `provider.create()` or `provider.resume()`. * @param options - `owned: true` when the backend owns the sandbox. Then * `close` destroys it. Default: `false`. * * @example * ```ts * const provider = localProcessSandbox() * workspaceTools({ * workspace: { * bind: async () => ({ * ...sandboxWorkspaceBackend(await provider.create({}), { owned: true }), * root: '/workspace', * }), * }, * }) * ``` */ function sandboxWorkspaceBackend(handle, { owned = false } = {}) { const { fs } = handle; const running = /* @__PURE__ */ new Set(); let destroyed; const follow = async (path, options) => { if (!await fs.exists(path, options)) return void 0; const bytes = await fs.readBytes(path, options).catch(() => void 0); if (!bytes) return { type: "dir", size: 0, mtimeMs: 0 }; return { type: "file", size: bytes.byteLength, mtimeMs: 0 }; }; const stat = async (path, options) => { const { lstat } = fs; if (!lstat) return follow(path, options); const info = await lstat(path, options); if (!info) return void 0; switch (info.type) { case "file": return { type: "file", size: info.size, mtimeMs: 0 }; case "dir": return { type: "dir", size: 0, mtimeMs: 0 }; case "other": return { type: "file", size: 0, mtimeMs: 0 }; case "symlink": { const resolved = await resolveLinks(lstat, path, options); if (resolved.missing) return void 0; return stat(resolved.real, options); } } }; /** The target of the link `path`, as `readlink` in the sandbox gives it. */ const readlink = async (path, options) => { const name = shellQuote(posix.basename(path)); const { exitCode, stdout, stderr } = await handle.process.exec(`readlink -- ${name}`, { cwd: posix.dirname(path), signal: options?.signal }); if (exitCode !== 0) throw new Error(`Cannot read the link ${path}: ${stderr.trim()}`); return stdout.replace(/\n$/, ""); }; /** * `path` with every link resolved, part by part. `missing` is `'path'` * when nothing is at `path`, and `'target'` for a link to a missing * target. Throws when there are more than 40 links, like a loop. */ const resolveLinks = async (lstat, path, options) => { let parts = posix.resolve("/", path).split("/").filter(Boolean); let fromLink = 0; let real = "/"; let links = 0; while (parts.length > 0) { const [name = "", ...rest] = parts; parts = rest; const isFromLink = fromLink > 0; fromLink = Math.max(0, fromLink - 1); if (name === ".") continue; if (name === "..") { real = posix.dirname(real); continue; } const next = posix.join(real, name); const info = await lstat(next, options); if (!info) return { real: void 0, missing: isFromLink ? "target" : "path" }; if (info.type !== "symlink") { real = next; continue; } links += 1; if (links > MAX_LINKS) throw new Error(`ELOOP: too many links in ${path}.`); const target = await readlink(next, options); if (target.startsWith("/")) real = "/"; const targetParts = target.split("/").filter(Boolean); parts = [...targetParts, ...parts]; fromLink += targetParts.length; } return { real, missing: void 0 }; }; /** * `path` with every link resolved, or `undefined` when nothing is at * `path`. Throws for a link to a missing target, and when there are more * than 40 links, like a loop. */ const realpath = async (path, options) => { const { lstat } = fs; if (!lstat) throw new Error(`This sandbox has no lstat, so links in ${path} cannot be checked.`); const resolved = await resolveLinks(lstat, path, options); switch (resolved.missing) { case void 0: return resolved.real; case "path": return; case "target": throw new Error(`ENOENT: ${path} is a link to a target that is not there.`); } }; /** * One page of the file `path`, read in the sandbox. One command gives the * size (`wc`), the first 8 KiB as hex (`od`), and the lines of the page * (`awk`). So only the page comes out of the sandbox, not the whole file. * `awk` stops at the first line after the page, so `total` is there only * when the page gets to the end of the file. */ const readLines = async (path, { offset, limit, signal }) => { const first = Math.max(1, Math.floor(offset)); const end = first + Math.max(0, Math.floor(limit)); const name = shellQuote(`./${posix.basename(path)}`); const command = [ `wc -c < ${name}`, `od -An -v -tx1 -N ${HEAD_BYTES} ${name}`, `echo ${LINES_MARK}`, `awk -v o=${first} -v e=${end} -v max=${MAX_PAGE_BYTES} ${shellQuote(`NR >= e { more = 1; exit } NR >= o { print; n += length($0) + 1; if (n > max) e = NR + 1 } END { if (more) print "more"; else print "total " NR }`)} ${name}` ].join(" && "); const { exitCode, stdout, stderr } = await handle.process.exec(command, { cwd: posix.dirname(path), signal }); if (exitCode !== 0) throw new Error(`Cannot read ${path}: ${stderr.trim()}`); return parsePage(stdout, first); }; const spawn = (command, options) => { const output = createOutputTail(MAX_JOB_OUTPUT); const onOutput = options?.onOutput; const collect = async (stream) => { for await (const chunk of stream) { output.push(chunk); onOutput?.(chunk); } }; const started = handle.process.spawn(command, { cwd: options?.cwd, env: options?.env }); const exited = started.then(async (job) => { const [exitCode] = await Promise.all([ job.wait(), collect(job.stdout), collect(job.stderr) ]); return { exitCode }; }).catch((error) => { output.push(String(error)); return { exitCode: 1 }; }); const job = { wait: () => exited, kill: () => { started.then((each) => each.kill()).catch(() => void 0); }, output: output.text, jobId: started.then(async (each) => { const start = await processStart(String(each.pid)); return start === void 0 ? String(each.pid) : `${each.pid}:${start}`; }, () => void 0) }; running.add(job); exited.then(() => running.delete(job)); return job; }; const close = async ({ reason }) => { if (reason === "moved") return; for (const job of running) job.kill(); if (!owned) return; destroyed ??= handle.destroy(); await destroyed; }; /** * When the process `pid` in the sandbox started, as `ps` gives it, or * `undefined` when `ps` gives nothing: no such process, or no `ps` that * can tell (busybox). */ const processStart = async (pid) => { const result = await handle.process.exec(`LC_ALL=C ps -o lstart= -p ${pid}`).catch(() => void 0); const start = result?.exitCode === 0 ? result.stdout.trim() : ""; return start === "" ? void 0 : start; }; const killJob = async (jobId) => { const match = /^(\d+)(?::(.+))?$/.exec(jobId); const pid = match?.[1]; if (pid === void 0) return false; const start = await processStart(pid); if (start === void 0) { if ((await handle.process.exec(`kill -0 ${pid}`)).exitCode !== 0) return false; } if (start === void 0 || match?.[2] === void 0) throw new Error(`Cannot check that process ${pid} in the sandbox is the job: no start time from ps.`); if (start !== match[2]) return false; const { exitCode } = await handle.process.exec(`kill -9 -- -${pid} 2>/dev/null || kill -9 ${pid}`); return exitCode === 0; }; return { shell: "sh", binding: () => ({ kind: "sandbox", provider: handle.provider, sandboxId: handle.id }), killJob, readFile: (path, options) => fs.readBytes(path, options), writeFile: (path, data, options) => fs.write(path, data, options), remove: async (path, options) => { if (!((await stat(path, options))?.type === "file")) throw new Error(`Cannot remove ${path}: no such file.`); await fs.remove(path, options); }, readLines, stat, realpath, readdir: async (path, options) => { const entries = await fs.list(path, options); const { lstat } = fs; return Promise.all(entries.map(async (entry) => { const type = lstat !== void 0 && (await lstat(entry.path, options))?.type === "symlink" ? "link" : entry.type; return { name: entry.name, type }; })); }, exec: (command, options = {}) => { const { cwd, env, timeoutMs, signal } = options; const controller = new AbortController(); let stop = (_exitCode) => {}; const stopped = new Promise((resolve) => { stop = (exitCode) => { resolve({ exitCode, stdout: "", stderr: "" }); controller.abort(); }; }); const timer = timeoutMs ? setTimeout(() => stop(124), timeoutMs) : void 0; const onAbort = () => stop(1); signal?.addEventListener("abort", onAbort); if (signal?.aborted) onAbort(); const run = handle.process.exec(command, { cwd, env, signal: controller.signal }).catch((error) => ({ exitCode: 1, stdout: "", stderr: String(error) })); return Promise.race([stopped, run]).finally(() => { clearTimeout(timer); signal?.removeEventListener("abort", onAbort); }); }, ...handle.capabilities.backgroundProcesses ? { spawn } : {}, close }; } //#endregion export { sandboxWorkspaceBackend }; //# sourceMappingURL=workspace-backend.js.map