import { mkdir, readFile, readdir, rm, stat, writeFile, } from 'node:fs/promises'; import path from 'node:path'; import { shellQuote } from '@ai-sdk/harness/utils'; import type { Experimental_SandboxSession } from '@ai-sdk/provider-utils'; /* * Pi runs on the host with its working directory pointed at the local mirror, * but the only thing it reads from that directory is its own resource * configuration: the `.pi` and `.agents` directories (skills, prompts, themes, * extensions) and the root-level agent context files (`AGENTS.md`). The model * never reads workspace source through the host — file reads, directory * listings, and greps all run as tools against the sandbox. Mirroring the whole * sandbox workspace to the host would therefore copy files Pi never looks at, * one `readBinaryFile` round-trip per file. For a real project that has been * cloned and had its dependencies installed (hundreds of thousands of files * under `node_modules`) that makes session startup take hours. The mirror is * consequently scoped to exactly the paths Pi's resource loader consults. * * Within those config directories, symlinks are resolved and their targets * copied as real files. `.agents/skills` is frequently a symlink to a `skills` * directory living elsewhere in the workspace; a mirrored symlink would dangle * because its target falls outside the scoped mirror, so the linked content is * walked and copied verbatim instead. */ const PI_CONFIG_DIRS = ['.pi', '.agents'] as const; const PI_CONTEXT_FILENAMES = ['AGENTS.md', 'AGENTS.MD'] as const; function normalizeRelativePath(inputPath: string): string { const normalized = inputPath.split(path.posix.sep).join(path.sep); const relative = path.normalize(normalized); if ( relative === '' || relative === '.' || path.isAbsolute(relative) || relative === '..' || relative.startsWith(`..${path.sep}`) ) { throw new Error( `Sandbox workspace mirror received an invalid relative path: ${inputPath}`, ); } return relative; } async function readCommandOutput( sandbox: Experimental_SandboxSession, command: string, ): Promise { const result = await sandbox.run({ command }); if (result.exitCode != null && result.exitCode !== 0) { throw new Error( result.stderr || result.stdout || `Sandbox command failed with exit code ${result.exitCode}`, ); } return result.stdout || result.stderr; } async function listRemoteWorkspaceEntries( sandbox: Experimental_SandboxSession, sandboxWorkDir: string, ): Promise<{ directories: string[]; files: Array<{ relativePath: string; sandboxPath: string }>; }> { // Enumerate only the `.pi`/`.agents` config subtrees plus the root-level // context files — never the rest of the workspace. Use shell glob traversal // instead of `find -L`, which is not supported by all sandbox shells. // Resolve each scoped root path component explicitly with `readlink`, // retaining both the scoped mirror path and the resolved sandbox path. Once // a root is resolved, queued descendants use physical parent paths and only // need resolution when the descendant itself is a symlink. This avoids // relying on shells that can inspect a symlinked directory but cannot // traverse paths below it, including when the symlink is an ancestor of the // sandbox work directory, without repeatedly resolving every component of // deep ordinary paths. const scopedPaths = [ ...PI_CONFIG_DIRS.map(dir => `./${dir}`), ...PI_CONTEXT_FILENAMES.map(name => `./${name}`), ]; const listCommand = [ `pi_config_sources=(${scopedPaths .map(scopedPath => shellQuote(path.posix.join(sandboxWorkDir, scopedPath.slice(2))), ) .join(' ')})`, `pi_config_relatives=(${scopedPaths .map(scopedPath => shellQuote(scopedPath.slice(2))) .join(' ')})`, `pi_config_ancestors=(${scopedPaths.map(() => "''").join(' ')})`, `pi_config_resolve_ancestors=(${scopedPaths.map(() => '1').join(' ')})`, 'pi_config_index=0', 'while [ "$pi_config_index" -lt "${#pi_config_sources[@]}" ]; do', ' source=${pi_config_sources[$pi_config_index]}', ' relative=${pi_config_relatives[$pi_config_index]}', ' ancestors=${pi_config_ancestors[$pi_config_index]}', ' resolve_ancestors=${pi_config_resolve_ancestors[$pi_config_index]}', ' pi_config_index=$((pi_config_index + 1))', ' if [ "$resolve_ancestors" = 1 ] || [ -L "$source" ]; then', ' pending=$source', ' resolved=', ' seen_links=', ' case "$pending" in', ' /*) ;;', ' *) pending=$PWD/$pending ;;', ' esac', ' while [ -n "$pending" ]; do', ' pending=${pending#/}', ' [ -n "$pending" ] || break', ' component=${pending%%/*}', ' if [ "$pending" = "$component" ]; then', ' pending=', ' else', ' pending=${pending#*/}', ' fi', ' case "$component" in', ' ""|.) continue ;;', ' ..)', ' resolved=${resolved%/*}', ' continue', ' ;;', ' esac', ' candidate=$resolved/$component', ' if [ -L "$candidate" ]; then', ' case "$seen_links" in', ' *"', '"$candidate"', '"*)', ` printf 'Pi config traversal encountered a symlink cycle at %s\\n' "$candidate" >&2`, ' exit 1', ' ;;', ' esac', ' seen_links=$seen_links"', '"$candidate"', '"', ' target=$(readlink "$candidate") || exit $?', ' case "$target" in', ' /*) pending=$target${pending:+/$pending} ;;', ' *) pending=${candidate%/*}/$target${pending:+/$pending} ;;', ' esac', ' resolved=', ' else', ' resolved=$candidate', ' fi', ' done', ' if [ -z "$resolved" ]; then', ` printf 'Pi config traversal resolved %s to the filesystem root; skipping\\n' "$relative" >&2`, ' continue', ' fi', ' source=$resolved', ' fi', ' if [ -d "$source" ]; then', ' case "$ancestors" in', ' *"', '"$source"', '"*)', ` printf 'Pi config traversal encountered a symlink cycle at %s\\n' "$relative" >&2`, ' exit 1', ' ;;', ' esac', ' ancestors=$ancestors"', '"$source"', '"', ` printf 'd\\t%s\\n' "$relative"`, ' for child in "$source"/* "$source"/.[!.]* "$source"/..?*; do', ' if [ -L "$child" ] || [ -d "$child" ] || [ -f "$child" ]; then', ' child_name=${child##*/}', ' pi_config_sources+=("$child")', ' pi_config_relatives+=("$relative/$child_name")', ' pi_config_ancestors+=("$ancestors")', ' pi_config_resolve_ancestors+=(0)', ' fi', ' done', ' elif [ -f "$source" ]; then', ` printf 'f\\t%s\\t%s\\n' "$relative" "$source"`, ' fi', ' true', 'done', 'true', ].join('\n'); const output = await readCommandOutput( sandbox, [`cd ${shellQuote(sandboxWorkDir)}`, listCommand].join(' && '), ); const directories: string[] = []; const files: Array<{ relativePath: string; sandboxPath: string }> = []; for (const line of output.split('\n').filter(Boolean)) { const [kind, rawPath, sandboxPath] = line.split('\t', 3); if (!rawPath) continue; const relativePath = normalizeRelativePath(rawPath); if (kind === 'd') directories.push(relativePath); else if (kind === 'f' && sandboxPath) { files.push({ relativePath, sandboxPath }); } } return { directories, files }; } async function pathKind( target: string, ): Promise<'file' | 'directory' | undefined> { try { const stats = await stat(target); if (stats.isDirectory()) return 'directory'; if (stats.isFile()) return 'file'; return undefined; } catch { return undefined; } } async function collectHostSubtree( rootDir: string, currentDir: string, directories: string[], files: string[], ): Promise { const entries = await readdir(currentDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isSymbolicLink()) continue; const absolutePath = path.join(currentDir, entry.name); const relativePath = path.relative(rootDir, absolutePath); if (entry.isDirectory()) { directories.push(relativePath); await collectHostSubtree(rootDir, absolutePath, directories, files); } else if (entry.isFile()) { files.push(relativePath); } } } /** * Enumerate the locally-mirrored entries that fall within Pi's scope: the * `.pi`/`.agents` config subtrees and the root-level context files. Anything * else on the local side (it should not normally exist) is intentionally * ignored so the reconcile below neither copies nor deletes it. */ async function collectHostScopedEntries( rootDir: string, ): Promise<{ directories: string[]; files: string[] }> { const directories: string[] = []; const files: string[] = []; for (const dir of PI_CONFIG_DIRS) { const configDir = path.join(rootDir, dir); if ((await pathKind(configDir)) === 'directory') { directories.push(dir); await collectHostSubtree(rootDir, configDir, directories, files); } } for (const name of PI_CONTEXT_FILENAMES) { if ((await pathKind(path.join(rootDir, name))) === 'file') { files.push(name); } } return { directories, files }; } function buildRequiredDirectories( remoteDirectories: string[], remoteFiles: Array<{ relativePath: string }>, ): Set { const directories = new Set(); for (const directory of remoteDirectories) { directories.add(normalizeRelativePath(directory)); } for (const file of remoteFiles) { let current = path.dirname(normalizeRelativePath(file.relativePath)); while (current !== '.' && current !== path.sep && current.length > 0) { directories.add(current); current = path.dirname(current); } } return directories; } export async function syncHostWorkspaceFromSandbox(args: { sandbox: Experimental_SandboxSession; sandboxWorkDir: string; hostWorkDir: string; }): Promise { const { sandbox, sandboxWorkDir, hostWorkDir } = args; const remoteEntries = await listRemoteWorkspaceEntries( sandbox, sandboxWorkDir, ); const hostEntries = await collectHostScopedEntries(hostWorkDir); const remoteFiles = new Set( remoteEntries.files.map(file => file.relativePath), ); const requiredDirectories = buildRequiredDirectories( remoteEntries.directories, remoteEntries.files, ); for (const relativePath of hostEntries.files) { if (!remoteFiles.has(relativePath)) { await rm(path.join(hostWorkDir, relativePath), { force: true }); } } const removableDirectories = [...hostEntries.directories] .filter(p => !requiredDirectories.has(p)) .sort((a, b) => b.length - a.length); for (const relativePath of removableDirectories) { await rm(path.join(hostWorkDir, relativePath), { recursive: true, force: true, }); } for (const relativePath of [...requiredDirectories].sort( (a, b) => a.length - b.length, )) { await mkdir(path.join(hostWorkDir, relativePath), { recursive: true }); } for (const { relativePath, sandboxPath } of remoteEntries.files) { const bytes = await sandbox.readBinaryFile({ path: sandboxPath }); if (!bytes) { throw new Error( `Sandbox workspace file disappeared during mirror sync: ${sandboxPath}`, ); } const content = Buffer.from(bytes); const hostPath = path.join(hostWorkDir, relativePath); let shouldWrite = true; try { const existing = await readFile(hostPath); shouldWrite = !existing.equals(content); } catch { shouldWrite = true; } if (shouldWrite) { await mkdir(path.dirname(hostPath), { recursive: true }); await writeFile(hostPath, content); } } } export async function writeHostWorkspaceFile( hostWorkDir: string, relativePath: string, content: Buffer, ): Promise { const normalizedPath = normalizeRelativePath(relativePath); const hostPath = path.join(hostWorkDir, normalizedPath); await mkdir(path.dirname(hostPath), { recursive: true }); await writeFile(hostPath, content); }