import type { SuperagentSandboxFileNode } from '../../types'; const HIDDEN_FILE_NAMES = new Set(['.gitignore', '.gitkeep', 'BOOTSTRAP.md', 'bootstrap.md']); export const DEFAULT_SANDBOX_FILE_PATHS = [ '.agents/', '.agents/rules/', '.agents/skills/', '.agents/mcps/', '.agents/hooks/', '.agents/cron/', '.agents/memory/', 'incoming_files/', ]; export type SuperagentFileCategory = 'code' | 'html' | 'image' | 'markdown' | 'pdf' | 'text'; export function sanitizeSandboxFilePath(path: string) { // Strip absolute / `..` / `.` segments and null bytes so a picked file name can't // write outside the sandbox via path traversal (defense in depth, not a substitute // for the backend's own validation). return path .replace(/\\/g, '/') .replace(/\0/g, '') .split('/') .map((segment) => segment.trim()) .filter((segment) => segment && segment !== '.' && segment !== '..') .join('/'); } export function normalizeFilePaths(paths: string[] = []) { return paths .filter((path) => typeof path === 'string' && path.trim().length > 0) .map((path) => path.replace(/^\/+/, '')) .filter((path) => !path.includes('\0')) .filter((path) => !HIDDEN_FILE_NAMES.has(getFileName(path))); } export function buildFileTree(paths: string[]) { const root: Record = {}; for (const path of normalizeFilePaths(paths)) { const parts = path.split('/').filter(Boolean); let current = root; for (let index = 0; index < parts.length; index += 1) { const part = parts[index]; const isFile = index === parts.length - 1 && !path.endsWith('/'); // Own-key check: `in` would match Object.prototype keys, so a file named // "toString" would vanish and its children would land on the prototype fn. if (!Object.prototype.hasOwnProperty.call(current, part)) { current[part] = isFile ? null : {}; } if (current[part] !== null) { current = current[part] as Record; } } } const toNodes = (value: Record): SuperagentSandboxFileNode[] => Object.entries(value) .map(([name, children]) => { if (children === null) { return { name, type: 'file' as const }; } return { children: toNodes(children as Record), name, type: 'folder' as const, }; }) // Mirror the web builder: a folder is shown only when it (transitively) holds // at least one file, so empty scaffold/placeholder folders never appear. .filter((node) => node.type === 'file' || (node.children?.length ?? 0) > 0) .sort((first, second) => { if (first.type !== second.type) { return first.type === 'folder' ? -1 : 1; } return first.name.localeCompare(second.name); }); const tree = toNodes(root); return { fileCount: flattenFileTree(tree).length, folderCount: countFolders(tree), tree }; } function countFolders(nodes: SuperagentSandboxFileNode[]): number { return nodes.reduce( (total, node) => (node.type === 'folder' ? total + 1 + countFolders(node.children ?? []) : total), 0, ); } export function flattenFileTree(nodes: SuperagentSandboxFileNode[], parentPath = '') { const paths: string[] = []; for (const node of nodes) { const path = parentPath ? `${parentPath}/${node.name}` : node.name; if (node.type === 'file') { paths.push(path); } else { paths.push(...flattenFileTree(node.children ?? [], path)); } } return paths; } export function getFileCategory(filePath: string): SuperagentFileCategory { const extension = getFileExtension(filePath); if (extension === 'html' || extension === 'htm') { return 'html'; } if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico', 'bmp'].includes(extension)) { return 'image'; } if (extension === 'pdf') { return 'pdf'; } if (extension === 'md' || extension === 'mdx') { return 'markdown'; } if ([ 'bash', 'css', 'env', 'gitignore', 'graphql', 'js', 'json', 'jsx', 'py', 'sh', 'sql', 'toml', 'ts', 'tsx', 'xml', 'yaml', 'yml', ].includes(extension)) { return 'code'; } return 'text'; } export function isEditableFile(filePath: string) { const category = getFileCategory(filePath); return category !== 'image' && category !== 'pdf'; } export function getFileName(path: string) { return path.split('/').filter(Boolean).pop() ?? path; } export function getFolderPath(path: string) { const parts = path.split('/').filter(Boolean); parts.pop(); return parts.join('/'); } export function getFileExtension(path: string) { const fileName = getFileName(path).toLowerCase(); const dotIndex = fileName.lastIndexOf('.'); return dotIndex >= 0 ? fileName.slice(dotIndex + 1) : ''; } export function getImageMimeType(filePath: string) { switch (getFileExtension(filePath)) { case 'bmp': return 'image/bmp'; case 'gif': return 'image/gif'; case 'ico': return 'image/x-icon'; case 'jpg': case 'jpeg': return 'image/jpeg'; case 'svg': return 'image/svg+xml'; case 'webp': return 'image/webp'; default: return 'image/png'; } }