import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { env } from './env'; import { assertContainedRealPath } from './path-containment'; import { ensureUserContainer, inspectUserBoxStatus, stopWorkspaceContainer, userContainerName, } from './runtime/docker'; import { resetBox } from './user-container'; // Matches FileTreeNode in apps/next/src/components/agent-workspace/types.ts. type FileTreeNode = { name: string; path: string; type: 'file' | 'directory'; children?: FileTreeNode[]; }; // Per-user box home layout, shared by the HTTP routes and the terminal WS bridge // so path derivation lives in exactly one place (must match worker.ts). export const boxHomePaths = (username: string) => ({ homeDir: path.resolve(env.workdir, 'homes', username), containerHome: path.posix.join('/home', username), }); export type BoxStatus = { running: boolean; image: string | null; startedAt: string | null; memoryLimitBytes: number | null; containerName: string; }; export const getBoxStatus = async (username: string): Promise => { const status = await inspectUserBoxStatus(username); return { ...status, containerName: userContainerName(username) }; }; export const startBox = async (username: string): Promise => { const { homeDir, containerHome } = boxHomePaths(username); await ensureUserContainer({ username, workdir: homeDir, containerHome }); }; export const stopBox = async (username: string): Promise => { await stopWorkspaceContainer(userContainerName(username)); resetBox(username); }; export const restartBox = async (username: string): Promise => { await stopBox(username); await startBox(username); }; // Directories never surfaced in the box file tree — VCS internals, package // caches and build output that would bloat (or hang) the walk. const BOX_TREE_IGNORE = new Set([ '.git', 'node_modules', '.cache', '.npm', '.bun', '.cargo', 'dist', 'build', '.next', ]); // Home-relative paths (POSIX) that are always skipped regardless of depth. const BOX_TREE_IGNORE_PATHS = new Set(['.local/share/Trash']); // Guard against a huge home hanging the request; stop descending past this. const BOX_TREE_MAX_NODES = 5000; // Realpath-checked absolute path inside the user's home. Rejects lexical `..` // escapes and symlinks that resolve outside home (reuses the Phase-1 // containment helper); for writes, also rejects a symlinked final target. const safeHomePath = ( homeDir: string, relPath: string, opts: { forWrite?: boolean } = {}, ): Promise => assertContainedRealPath(homeDir, relPath, { ...opts, label: 'home' }); export const listBoxTree = async (username: string): Promise => { const { homeDir } = boxHomePaths(username); let count = 0; const buildChildren = async ( absoluteDir: string, relativeDir: string, ): Promise => { const entries = await readdir(absoluteDir); const nodes = await Promise.all( entries .sort((a, b) => a.localeCompare(b)) .map(async (entry): Promise => { const relativePath = relativeDir ? `${relativeDir}/${entry}` : entry; if ( BOX_TREE_IGNORE.has(entry) || BOX_TREE_IGNORE_PATHS.has(relativePath) ) { return null; } if (count >= BOX_TREE_MAX_NODES) return null; count += 1; const absolutePath = path.join(absoluteDir, entry); const stats = await stat(absolutePath).catch(() => null); if (!stats) return null; if (stats.isDirectory()) { return { name: entry, path: relativePath, type: 'directory', children: await buildChildren(absolutePath, relativePath), }; } return { name: entry, path: relativePath, type: 'file' }; }), ); return nodes.filter((node): node is FileTreeNode => Boolean(node)); }; return { name: '~', path: '', type: 'directory', children: await buildChildren(homeDir, ''), }; }; export const readBoxFile = async ( username: string, relPath: string, ): Promise => { const { homeDir } = boxHomePaths(username); const target = await safeHomePath(homeDir, relPath); return await readFile(target, 'utf8'); }; export const writeBoxFile = async ( username: string, relPath: string, content: string, ): Promise<{ success: true }> => { const { homeDir } = boxHomePaths(username); const target = await safeHomePath(homeDir, relPath, { forWrite: true }); await mkdir(path.dirname(target), { recursive: true }); await writeFile(target, content); return { success: true }; };