import { createHash, randomUUID } from 'node:crypto' import { chmod, lstat, readdir, readFile, realpath, rename, unlink, writeFile } from 'node:fs/promises' import { dirname, join, relative, resolve, sep } from 'node:path' import { safeAgentPath, type AgentCheckedChange, type AgentToolRequest } from '../shared/agentTools' const textFile = /\.(c|cc|cpp|cxx|h|hh|hpp|hxx|py|js|jsx|ts|tsx|json|ya?ml|xml|md|txt|toml|ini|cfg|conf|sh|bash|launch|urdf|xacro|rviz|proto|cmake)$|^(Dockerfile|CMakeLists\.txt)$/i export const agentFileHash = (content: string): string => createHash('sha256').update(content, 'utf8').digest('hex') async function checkedPath(root: string, path: string, allowMissing = false): Promise { safeAgentPath(path) const base = await realpath(root) const absolute = resolve(base, path) if (!absolute.startsWith(base + sep)) throw new Error('모듈 경로를 벗어날 수 없습니다.') let current = base const parts = relative(base, absolute).split(sep) for (let i = 0; i < parts.length; i++) { current = join(current, parts[i]) try { const info = await lstat(current) if (info.isSymbolicLink() || (i < parts.length - 1 && !info.isDirectory())) throw new Error('심볼릭 링크와 일반 파일이 아닌 경로는 사용할 수 없습니다.') } catch (error) { if (allowMissing && i === parts.length - 1 && (error as NodeJS.ErrnoException).code === 'ENOENT') return absolute throw error } } return absolute } async function readText(root: string, path: string): Promise { if (!textFile.test(path.split('/').pop() ?? '')) throw new Error('텍스트 소스 파일만 조회·수정할 수 있습니다.') const absolute = await checkedPath(root, path) const info = await lstat(absolute) if (!info.isFile() || info.size > 1_048_576) throw new Error('일반 텍스트 파일(1MB 이하)만 조회할 수 있습니다.') const text = await readFile(absolute, 'utf8') if (text.includes('\0') || text.includes('\uFFFD')) throw new Error('UTF-8 텍스트 파일이 아닙니다.') return text } export async function runAgentFileTool(root: string, request: AgentToolRequest): Promise<{ data: unknown; truncated: boolean }> { const args = request.args ?? {} if (request.name === 'files.read') { const path = safeAgentPath(args.path) try { const content = await readText(root, path) const lines = content.split('\n') const partial = args.startLine !== undefined || args.lineCount !== undefined || content.length > 40_000 const startLine = args.startLine ?? 1 const selected = partial ? lines.slice(startLine - 1, startLine - 1 + (args.lineCount ?? 200)).join('\n').slice(0, 40_000) : content return { data: { path, exists: true, hash: agentFileHash(content), content: selected, complete: !partial, startLine, totalLines: lines.length }, truncated: partial } } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error // A missing leaf in an existing directory is evidence for a new file. await checkedPath(root, path, true) return { data: { path, exists: false, hash: null, content: '', complete: true }, truncated: false } } } const prefix = safeAgentPath(args.path, true) if (prefix) await checkedPath(root, prefix) const paths: string[] = [] const matches: Array<{ path: string; line: number; text: string }> = [] let visited = 0 let scanned = 0 let truncated = false const visit = async (directory: string, depth: number): Promise => { if (depth > 10 || visited >= 5000 || paths.length >= 250 || matches.length >= 80 || scanned >= 4_000_000) { truncated = true; return } const entries = (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name)) for (const entry of entries) { const path = relative(root, join(directory, entry.name)).split(sep).join('/') if (prefix && path !== prefix && !path.startsWith(prefix + '/') && !prefix.startsWith(path + '/')) continue if (++visited > 5000 || paths.length >= 250 || matches.length >= 80 || scanned >= 4_000_000) { truncated = true; break } try { safeAgentPath(path) } catch { continue } if (entry.isSymbolicLink()) continue if (entry.isDirectory()) { await visit(join(directory, entry.name), depth + 1); continue } if (!entry.isFile() || !textFile.test(entry.name)) continue if (prefix && path !== prefix && !path.startsWith(prefix + '/')) continue if (request.name === 'files.list') { paths.push(path); continue } let content: string try { content = await readText(root, path) } catch { continue } scanned += content.length for (const [index, line] of content.split('\n').entries()) { if (line.toLowerCase().includes(args.query!.toLowerCase())) { matches.push({ path, line: index + 1, text: line.slice(0, 500) }) if (matches.length >= 80) { truncated = true; break } } } } } await visit(root, 0) return { data: request.name === 'files.list' ? { paths } : { query: args.query, matches }, truncated } } export async function applyAgentFiles(changes: Array): Promise { if (!changes.length || changes.length > 24 || changes.reduce((n, c) => n + c.content.length, 0) > 1_000_000) throw new Error('변경 파일 용량을 확인하세요.') const prepared: Array<{ path: string; temp: string; original: string | null; mode: number }> = [] const committed: typeof prepared = [] const seen = new Set() try { for (const change of changes) { if (change.expectedHash !== null && !/^[a-f0-9]{64}$/.test(change.expectedHash)) throw new Error('원본 파일 조회 정보가 없습니다. 다시 분석해주세요.') if (!textFile.test(change.path.split('/').pop() ?? '')) throw new Error('텍스트 소스 파일만 수정할 수 있습니다.') const path = await checkedPath(change.root, change.path, true) if (seen.has(path)) throw new Error('중복된 변경 파일입니다.') seen.add(path) let original: string | null = null let mode = 0o644 try { original = await readText(change.root, change.path); mode = (await lstat(path)).mode } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error } if ((original === null ? null : agentFileHash(original)) !== change.expectedHash) throw new Error(`분석 이후 파일이 변경되었습니다: ${change.moduleName}/${change.path}. 다시 분석해주세요.`) prepared.push({ path, original, mode, temp: join(dirname(path), `.lms-agent-${randomUUID()}.tmp`) }) } for (const [index, file] of prepared.entries()) { await writeFile(file.temp, changes[index].content, { encoding: 'utf8', mode: file.mode, flag: 'wx' }) await chmod(file.temp, file.mode & 0o777) } // Recheck the entire batch immediately before committing any file. for (const file of prepared) { let latest: string | null = null try { latest = await readFile(file.path, 'utf8') } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error } if (latest !== file.original) throw new Error('적용 직전 파일이 변경되었습니다. 다시 분석해주세요.') } for (const file of prepared) { await rename(file.temp, file.path); committed.push(file) } } catch (error) { const failures: string[] = [] for (const file of committed.reverse()) { try { if (file.original === null) await unlink(file.path); else await writeFile(file.path, file.original, { mode: file.mode }) } catch { failures.push(file.path) } } if (failures.length) throw new Error(`일부 파일 복구에 실패했습니다: ${failures.join(', ')}. ${String(error)}`) throw error } finally { await Promise.all(prepared.map(file => unlink(file.temp).catch(() => undefined))) } }