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.

241 lines (234 loc) 10.1 kB
import type { SandboxFileEvent, SandboxFileHookEvent } from '@tanstack/ai' import type { InternalLogger } from '@tanstack/ai/adapter-internals' import type { SandboxHandle } from './contracts' /** Path relative to the repo/workspace root, POSIX form. */ function relTo(root: string, path: string): string { const prefix = root.endsWith('/') ? root : `${root}/` return path.startsWith(prefix) ? path.slice(prefix.length) : path } /** * POSIX single-quote escape for embedding a value in a shell command. */ function q(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'` } /** * Unified add-patch for a brand-new file, closely following the shape `git * diff` produces for an added file (`diff --git` header + `new file mode` + * `--- /dev/null` + `+++ b/<rel>`), so synthesized `create` diffs align with * the real `git diff` output emitted for `change` events. `rel` must be the * repo-root-relative POSIX path (like git's). Reproduces git's `\ No newline * at end of file` marker and the header-only form for a zero-byte file, so a * consumer applying the patch reconstructs the file byte-for-byte. It is not * byte-identical to git — it omits the `index <hash>..<hash>` line and always * writes the `+1,N` hunk count (git omits `,1`) — but both are valid * unified-diff and accepted by `git apply`/`patch`. */ function synthesizeAddPatch(rel: string, content: string): string { const header = `diff --git a/${rel} b/${rel}\nnew file mode 100644\n` // A zero-byte new file has no hunk in git's output — just the header. if (content === '') return header const hasFinalNewline = content.endsWith('\n') const lines = content.replace(/\n$/, '').split('\n') const body = lines.map((l) => `+${l}`).join('\n') return ( header + `--- /dev/null\n` + `+++ b/${rel}\n` + `@@ -0,0 +1,${lines.length} @@\n` + body + (hasFinalNewline ? '\n' : '\n\\ No newline at end of file\n') ) } /** * Wrap a raw {@link SandboxFileEvent} with lazy git-backed accessors bound to * the live handle. `baseSha` is the session baseline (`''` when the workspace * isn't a git repo). Never throws — every git/fs failure falls back to `''` * (or a synthesized add-patch), but is logged first via `logger` so a failure * is observable instead of silently becoming empty data. */ export function buildFileHookEvent( handle: SandboxHandle, root: string, baseSha: string, event: SandboxFileEvent, logger?: InternalLogger, ): SandboxFileHookEvent { const after = async (): Promise<string> => { if (event.type === 'delete') return '' try { return await handle.fs.read(event.path) } catch (error) { logger?.warn('sandbox after() failed to read file', { path: event.path, error, }) return '' } } const before = async (): Promise<string> => { if (baseSha === '') return '' const rel = relTo(root, event.path) try { const res = await handle.process.exec( `git show ${q(baseSha)}:${q(rel)}`, { cwd: root }, ) if (res.exitCode === 0) return res.stdout // Non-zero exit is EXPECTED when the file didn't exist at the baseline // (a newly created file) — git exits 128 with "exists on disk, but not // in <sha>". Log under `sandbox` (off by default) rather than warn so a // create event's before() doesn't spam a warning on every new file. logger?.sandbox('before() git show non-zero exit', { path: event.path, exitCode: res.exitCode, stderr: res.stderr, }) return '' } catch (error) { logger?.warn('sandbox before() git show failed', { path: event.path, error, }) return '' } } // Empty `git diff` fallback: `git diff <sha> -- <path>` shows nothing for a // file git isn't tracking, so an untracked file (the common agent action — // and every subsequent edit to it, which arrives as a `change`, not just the // first `create`) yields empty stdout even though it has content. Synthesize // an add-patch, but ONLY when the file is genuinely untracked at the // baseline — an empty diff for a *tracked* file means "identical to // baseline", a real no-op that must stay empty. Presence at `baseSha` // distinguishes them, probed below via the `git show` EXIT CODE (NOT // `before()`'s `''`, which also means "git show threw" — see the inline note). // ponytail: reached only when `git diff` came back empty — the common case // for an untracked file (and every edit to it), rare for a tracked one. It // spends up to two extra subprocesses (`git check-ignore`, then `git show`) // plus one `after()` read per such event; both verdicts are invariant per // path across a run, but memoizing them would need cross-event state the // per-event accessor doesn't hold. Add a per-path cache in the watcher if // edit-burst latency matters. const synthesizeIfUntracked = async (rel: string): Promise<string> => { const content = await after() if (content === '') return '' // deleted / empty / unreadable — nothing to add // Don't expose the CONTENTS of a git-ignored file (`.env`, credentials, // build artifacts, …) in the diff feed. The file event still fires so // consumers are notified it changed — only its diff is withheld. A // force-added, ignored-yet-TRACKED file produces a non-empty `git diff` // above and never reaches here, so its real diff is unaffected. try { const ignored = await handle.process.exec( `git check-ignore -q -- ${q(rel)}`, { cwd: root }, ) // check-ignore: exit 0 ⇒ path is ignored; 1 ⇒ not ignored; 128 ⇒ error. if (ignored.exitCode === 0) { logger?.sandbox('sandbox diff() withheld for git-ignored file', { path: event.path, }) return '' } if (ignored.exitCode !== 1) { // Not the expected "not ignored" (1) — a real check-ignore error (128: // corrupt repo, bad invocation). Same anomaly class as the throw below, // so `warn`. We still fall through and diff, so a broken probe never // silently withholds; but log it, or an error here would look exactly // like "not ignored" and could expose a would-be-withheld file's diff. logger?.warn('sandbox diff() git check-ignore non-zero exit', { path: event.path, exitCode: ignored.exitCode, stderr: ignored.stderr, }) } } catch (error) { // check-ignore threw (git/exec broken) — same anomaly class as the other // git execs here, so `warn`. We fall through and diff as usual rather // than withhold, so a broken probe can't hide a legitimate diff. logger?.warn('sandbox diff() git check-ignore failed', { path: event.path, error, }) } // Distinguish "absent at the baseline (untracked)" from "present at the // baseline" by the git-show EXIT CODE, not by before()'s `''` — which also // means "git show threw". Conflating a transient git-show failure with // untracked would fabricate a full-file add-patch for an unchanged tracked // file the agent never touched. try { const res = await handle.process.exec( `git show ${q(baseSha)}:${q(rel)}`, { cwd: root }, ) // exit 0 ⇒ tracked; `git diff` was already empty ⇒ identical to baseline // ⇒ genuine no-op. if (res.exitCode === 0) return '' // Non-zero is the EXPECTED "absent at baseline ⇒ untracked" case (exit // 128), but a genuine git-show error (bad object, corrupt repo, invalid // sha) also exits non-zero and would silently fabricate a full-file // add-patch. Log it under `sandbox` (like `before()` does) so a // persistent probe error is greppable, then fall back to synthesize. logger?.sandbox( 'sandbox diff() tracked-ness probe non-zero exit (treating as untracked)', { path: event.path, exitCode: res.exitCode, stderr: res.stderr }, ) return synthesizeAddPatch(rel, content) } catch (error) { // Uncertain — don't fabricate a full-file add-patch on a probe failure. logger?.warn('sandbox diff() tracked-ness probe failed', { path: event.path, error, }) return '' } } const diff = async (): Promise<string> => { if (baseSha === '') { if (event.type === 'delete') return '' return synthesizeAddPatch(relTo(root, event.path), await after()) } // Pathspec must be relative to `root` (like `before()` above) — a bare // leading `/` (e.g. the virtual `/workspace/x.ts`) is resolved by git // against the filesystem root, not the repo root, and fails with // "fatal: Invalid path" whenever the real repo root differs from // `/workspace` (e.g. every local-process sandbox). const rel = relTo(root, event.path) try { const res = await handle.process.exec( `git diff ${q(baseSha)} -- ${q(rel)}`, { cwd: root, }, ) if (res.exitCode !== 0) { logger?.warn('sandbox diff() git diff non-zero exit', { path: event.path, exitCode: res.exitCode, stderr: res.stderr, }) return '' } if (res.stdout !== '') return res.stdout return synthesizeIfUntracked(rel) } catch (error) { logger?.warn('sandbox diff() git diff failed', { path: event.path, error, }) return '' } } return { ...event, before, after, diff } } export interface ResolvedFileEvents { enabled: boolean diff: boolean } /** Normalize the `fileEvents` option (`boolean | { diff?: boolean }`). */ export function resolveFileEvents( opt: boolean | { diff?: boolean } | undefined, ): ResolvedFileEvents { if (opt === false) return { enabled: false, diff: false } if (opt === undefined || opt === true) return { enabled: true, diff: false } return { enabled: true, diff: opt.diff === true } }