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.

449 lines (434 loc) • 17.4 kB
import { posix } from 'node:path' import { createOutputTail } from '@tanstack/ai-utils' import { shellQuote } from './journal' import type { WorkspaceBackend } from '@tanstack/ai-harness/plugins/coding' import type { ExecResult, SandboxFs, SandboxFsOptions, SandboxHandle, } from './contracts' /** A background job keeps the newest 1 MiB of its output. */ const MAX_JOB_OUTPUT = 1024 * 1024 /** `realpath` follows at most this many links, like Linux. */ const MAX_LINKS = 40 /** The first bytes that `readLines` gives, for the file type check. */ const HEAD_BYTES = 8 * 1024 /** `readLines` gives no more lines after this many bytes. */ const MAX_PAGE_BYTES = 64 * 1024 /** The line in the output of `readLines` between the head and the lines. */ const 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: string, first: number) { const rows = stdout.split('\n') // The output ends with a newline, so the last row is empty. if (rows.at(-1) === '') rows.pop() const mark = rows.indexOf(LINES_MARK) const status = rows.at(-1) ?? '' const isPage = mark > 0 && mark < rows.length - 1 && /^(more|total \d+)$/.test(status) if (!isPage) { 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: undefined } // An empty file has one empty line, like `''.split('\n')`. if (size === 0) return { head, size, lines: first === 1 ? [''] : [], total: 1 } return { head, size, lines, total: Number(status.slice('total '.length)) } } /** * 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', * }), * }, * }) * ``` */ export function sandboxWorkspaceBackend( handle: SandboxHandle, { owned = false }: { owned?: boolean } = {}, ) { const { fs } = handle // The jobs that this backend started and that still run. const running = new Set<{ kill: () => void }>() let destroyed: Promise<void> | undefined // Without `lstat`, the read of the sandbox follows links, so this // follows links too. A path that `readBytes` cannot read is a folder. // ponytail: reads the whole file for its size. Only a sandbox without // `lstat` comes here. const follow: WorkspaceBackend['stat'] = async (path, options) => { if (!(await fs.exists(path, options))) return undefined const bytes = await fs.readBytes(path, options).catch(() => undefined) if (!bytes) return { type: 'dir', size: 0, mtimeMs: 0 } return { type: 'file', size: bytes.byteLength, mtimeMs: 0 } } const stat: WorkspaceBackend['stat'] = async (path, options) => { const { lstat } = fs if (!lstat) return follow(path, options) const info = await lstat(path, options) if (!info) return undefined switch (info.type) { case 'file': return { type: 'file', size: info.size, mtimeMs: 0 } case 'dir': return { type: 'dir', size: 0, mtimeMs: 0 } // Like `fs.stat` on the host, a fifo or a socket is an empty file. case 'other': return { type: 'file', size: 0, mtimeMs: 0 } case 'symlink': { // The target, by its real path, so the file is not read. A link to // a missing target is nothing, like `fs.stat` on the host. const resolved = await resolveLinks(lstat, path, options) if (resolved.missing) return undefined return stat(resolved.real, options) } } } /** The target of the link `path`, as `readlink` in the sandbox gives it. */ const readlink = async (path: string, options?: SandboxFsOptions) => { // A relative name in its folder: the sandbox maps the folder, not the // arguments, to its own files. 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: NonNullable<SandboxFs['lstat']>, path: string, options?: SandboxFsOptions, ) => { let parts = posix.resolve('/', path).split('/').filter(Boolean) // The first parts that came from a link target. A missing one means a // link to a missing target. A missing part of `path` itself means // nothing is at `path`. 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) { const missing = isFromLink ? ('target' as const) : ('path' as const) return { real: undefined, missing } } 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) // An absolute target starts again at `/`. A relative one is in `real`. if (target.startsWith('/')) real = '/' const targetParts = target.split('/').filter(Boolean) parts = [...targetParts, ...parts] // The parts after it can come from an earlier link too. fromLink += targetParts.length } return { real, missing: undefined } } /** * `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: WorkspaceBackend['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 undefined: return resolved.real case 'path': return undefined 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: NonNullable<WorkspaceBackend['readLines']> = async ( path, { offset, limit, signal }, ) => { const first = Math.max(1, Math.floor(offset)) const end = first + Math.max(0, Math.floor(limit)) // A relative name in its folder, like `readlink`. `./` keeps a name // that starts with `-` from being read as an option. const name = shellQuote(`./${posix.basename(path)}`) // `awk` prints the page, then one last line: `more` when the file goes // on after the page, else `total` and the number of lines. const awk = `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 }` 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(awk)} ${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: WorkspaceBackend['spawn'] = (command, options) => { const output = createOutputTail(MAX_JOB_OUTPUT) const onOutput = options?.onOutput const collect = async (stream: AsyncIterable<string>) => { for await (const chunk of stream) { output.push(chunk) onOutput?.(chunk) } } // `onOutput` is for this backend. The sandbox gets the folder and the env. const started = handle.process.spawn(command, { cwd: options?.cwd, env: options?.env, }) // Wait for the end of the output too, so `output()` is complete. const exited = started .then(async (job) => { const [exitCode] = await Promise.all([ job.wait(), collect(job.stdout), collect(job.stderr), ]) return { exitCode } }) .catch((error: unknown) => { output.push(String(error)) return { exitCode: 1 } }) const job = { wait: () => exited, // Before the process starts, this kills it when it starts. kill: () => { started.then((each) => each.kill()).catch(() => undefined) }, output: output.text, // The pid and the start time: a later process can get the same pid. jobId: started.then( async (each) => { const start = await processStart(String(each.pid)) return start === undefined ? String(each.pid) : `${each.pid}:${start}` }, () => undefined, ), } running.add(job) void exited.then(() => running.delete(job)) return job } const close: NonNullable<WorkspaceBackend['close']> = async ({ reason }) => { // The next machine resumes the sandbox and its jobs. 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: string) => { const result = await handle.process .exec(`LC_ALL=C ps -o lstart= -p ${pid}`) .catch(() => undefined) const start = result?.exitCode === 0 ? result.stdout.trim() : '' return start === '' ? undefined : start } // The id is `pid:start`. Only the process that started with the job is // stopped: after a restart (also of the host of a local-process // sandbox), another process can have its pid. const killJob: NonNullable<WorkspaceBackend['killJob']> = async (jobId) => { const match = /^(\d+)(?::(.+))?$/.exec(jobId) const pid = match?.[1] if (pid === undefined) return false const start = await processStart(pid) if (start === undefined) { // No start time: no such process, or no `ps` to check it with. const alive = await handle.process.exec(`kill -0 ${pid}`) if (alive.exitCode !== 0) return false } if (start === undefined || match?.[2] === undefined) { 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 // The process group first, so the commands of the job stop too. 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), // The sandbox `remove` also removes folders, and it does not fail for a // missing path. This backend removes one file only. remove: async (path, options?) => { const isFile = (await stat(path, options))?.type === 'file' if (!isFile) 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 // ponytail: one `lstat` for each entry, so a big folder is slow in a // remote sandbox. Without `lstat`, a link has the type of its target. return Promise.all( entries.map(async (entry) => { const isLink = lstat !== undefined && (await lstat(entry.path, options))?.type === 'symlink' const type = isLink ? ('link' as const) : entry.type return { name: entry.name, type } }), ) }, exec: (command, options = {}) => { const { cwd, env, timeoutMs, signal } = options const controller = new AbortController() // Stop with an exit code. The first reason wins. The result does not // wait for the sandbox, because some sandboxes cannot stop a command. let stop = (_exitCode: number) => {} const stopped = new Promise<ExecResult>((resolve) => { stop = (exitCode) => { resolve({ exitCode, stdout: '', stderr: '' }) controller.abort() } }) // A timeout of 0 means no timeout. const timer = timeoutMs ? setTimeout(() => stop(124), timeoutMs) : undefined const onAbort = () => stop(1) signal?.addEventListener('abort', onAbort) if (signal?.aborted) onAbort() // A command that does not start gives exit code 1, like `hostBackend`. const run = handle.process .exec(command, { cwd, env, signal: controller.signal }) .catch( (error: unknown): ExecResult => ({ exitCode: 1, stdout: '', stderr: String(error), }), ) return Promise.race([stopped, run]).finally(() => { clearTimeout(timer) signal?.removeEventListener('abort', onAbort) }) }, ...(handle.capabilities.backgroundProcesses ? { spawn } : {}), close, } satisfies WorkspaceBackend }