@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
text/typescript
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
}