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.

201 lines (187 loc) 7.12 kB
/** * Workspace bootstrap engine — provider-agnostic because it only uses the * {@link SandboxHandle} contract. Runs once when a sandbox is freshly created * (or restored without its working tree): land the source, inject secrets, * detect the package manager, and run setup commands. * * Harness-specific projection (CLAUDE.md, agent skills, MCP config) is NOT done * here — that's each adapter's `projectWorkspace()` hook, since the format * differs per harness. */ import { resolveHarnessCwd } from './harness-cwd' import { buildSetupPlan } from './setup-plan' import { createBootstrapShell } from './shell' import { mergeAgentsContent, resolveGitSkillDir, writeAgentsFile, } from './agents-file' import { resolveAllSecrets, resolveSecret } from './secrets' import type { SandboxHandle } from './contracts' import type { PackageManager, WorkspaceDefinition } from './workspace' const LOCKFILES: Record<Exclude<PackageManager, 'auto'>, string> = { pnpm: 'pnpm-lock.yaml', yarn: 'yarn.lock', bun: 'bun.lockb', npm: 'package-lock.json', } export const DEFAULT_WORKSPACE_ROOT = '/workspace' /** Resolve the package manager, detecting from a lockfile when `'auto'`. */ export async function detectPackageManager( handle: SandboxHandle, workspace: WorkspaceDefinition, root: string, ): Promise<Exclude<PackageManager, 'auto'> | undefined> { const pm = workspace.packageManager ?? 'auto' if (pm !== 'auto') return pm for (const [manager, lockfile] of Object.entries(LOCKFILES) as Array< [Exclude<PackageManager, 'auto'>, string] >) { if (await handle.fs.exists(`${root}/${lockfile}`)) return manager } return undefined } export interface BootstrapResult { packageManager?: Exclude<PackageManager, 'auto'> ranSetup: Array<string> } /** * Bootstrap a freshly created sandbox's workspace. Idempotent enough to be safe * on restore: a git clone into a populated dir is skipped by checking for the * target dir first. */ export async function bootstrapWorkspace( handle: SandboxHandle, workspace: WorkspaceDefinition, options: { signal?: AbortSignal } = {}, ): Promise<BootstrapResult> { const root = workspace.root ?? DEFAULT_WORKSPACE_ROOT // Secrets live only in the running sandbox env (never persisted). if (workspace.secrets !== undefined) { const resolved = resolveAllSecrets(workspace.secrets) if (Object.keys(resolved).length > 0) { await handle.env.set(resolved) } } // Land the source. Clone into the handle's own default root (each provider // maps the conventional `/workspace` virtual root to its real backing dir), // rather than passing a virtual `dir` that can't be remapped inside a shell // command string. if (workspace.source.type === 'git') { const alreadyCloned = await handle.fs.exists(`${root}/.git`) if (!alreadyCloned) { await handle.git.clone({ url: workspace.source.url, ref: workspace.source.ref, auth: workspace.source.auth, ...(workspace.source.depth !== undefined ? { depth: workspace.source.depth } : {}), }) } } // 'local' is provider-pre-populated at create; 'none' starts empty. // Clone git-skill repos so setup steps (and the harness projector) can use // them. gitSkill clones are always shallow (depth 1) unless the skill's own // repo entry carries a depth override — the WorkspaceSkill `git` variant // does not expose one, so depth always defaults to 1 inside git.clone. const skills = workspace.skills ?? [] for (const skill of skills) { if (skill.kind === 'git') { const url = skill.repo.startsWith('http') ? skill.repo : `https://github.com/${skill.repo}.git` const dir = resolveHarnessCwd( handle, skill.into ?? resolveGitSkillDir(root, skill), ) const auth = skill.secret !== undefined && workspace.secrets !== undefined ? { token: resolveSecret(workspace.secrets, skill.secret) } : undefined await handle.git.clone({ url, dir, ...(auth !== undefined ? { auth } : {}), depth: 1, }) } } // Write AGENTS.md (and its per-CLI symlinks) when instructions are provided // directly on the workspace, via a fileSkill whose path is `AGENTS.md`, or // when named workspace scripts should be surfaced for the agent. let agentsContent: string | undefined if ( workspace.instructions !== undefined && workspace.instructions.length > 0 ) { agentsContent = workspace.instructions } else { const agentsFileSkill = skills.find( (s): s is Extract<typeof s, { kind: 'file' }> => s.kind === 'file' && s.path === 'AGENTS.md', ) if (agentsFileSkill !== undefined) { agentsContent = agentsFileSkill.content } } agentsContent = mergeAgentsContent(agentsContent, workspace.scripts) if (agentsContent !== undefined) { await writeAgentsFile(handle, root, agentsContent) } // Write all other fileSkills directly into the workspace root. for (const skill of skills) { if (skill.kind === 'file' && skill.path !== 'AGENTS.md') { await handle.fs.write(`${root}/${skill.path}`, skill.content) } } const packageManager = await detectPackageManager(handle, workspace, root) // Run setup over a single persistent shell so `cd`/exports persist across // serial steps. Parallel groups fork the shell's current cwd+env into // concurrent one-shot exec calls. const ranSetup: Array<string> = [] const plan = buildSetupPlan(workspace.setup) if (plan.length > 0) { const shell = await createBootstrapShell(handle, { cwd: root }) try { for (const group of plan) { if (group.kind === 'serial') { const result = await shell.run(group.command) if (result.exitCode !== 0) { const tail = result.stdout.trim().slice(-1500) throw new Error( `setup step failed: ${group.command} (exit ${result.exitCode})${tail ? `\n${tail}` : ''}`, ) } ranSetup.push(group.command) } else { const { cwd, env } = await shell.forkState() const results = await Promise.all( group.commands.map((command) => handle.process .exec(command, { cwd, env, ...(options.signal ? { signal: options.signal } : {}), }) .then((res) => ({ command, res })), ), ) const failed = results.find((entry) => entry.res.exitCode !== 0) if (failed !== undefined) { const tail = `${failed.res.stdout}\n${failed.res.stderr}` .trim() .slice(-1500) throw new Error( `setup step failed: ${failed.command} (exit ${failed.res.exitCode})${tail ? `\n${tail}` : ''}`, ) } ranSetup.push(...group.commands) } } } finally { await shell.dispose() } } return { packageManager, ranSetup } }