// 后台文件管理(管理员):只能访问登记的"位置"——会话工作区、资料库(可读写),整个服务目录(只读浏览)。 // 登录凭证与密钥一律不可见;数据库文件(含 MCP 服务密钥等)可见但不能读取或下载。 // 路径以"位置 + 相对路径"表示;解析符号链接后必须仍在该位置之内 import fs from 'node:fs' import path from 'node:path' import { eq } from 'drizzle-orm' import { zipSync } from 'fflate' import type { AppSettings, FsEntryDto, FsRootDto, FsShortcutDto, KnowledgeRootDto } from '@agent-server/shared' import { config } from '../config' import type { DB } from '../db' import { sessions } from '../db/schema' import { AppError, badRequest, conflict, notFound } from '../lib/errors' import { isWithin, realpathLoose } from '../lib/paths' import { isCredentialPath } from './access-policy' import type { FileService } from './files' const MAX_EDIT_BYTES = 2 * 1048576 const MAX_ZIP_BYTES = 200 * 1048576 const MAX_ZIP_FILES = 5000 const MAX_LIST = 5000 interface Root { id: string; name: string; kind: FsRootDto['kind']; path: string; writable: boolean; description: string } export class FileManagerService { constructor( private db: DB, private settings: () => AppSettings, private knowledge: () => KnowledgeRootDto[], private files: FileService, private audit: (summary: string, detail?: unknown) => void, ) {} roots(): FsRootDto[] { return this.rootList().map(r => ({ id: r.id, name: r.name, kind: r.kind, writable: r.writable, description: r.description, exists: fs.existsSync(r.path) })) } /** 常用目录:各资料库、服务目录中的常用子目录(会话工作区在"位置"里,不重复列出) */ shortcuts(): FsShortcutDto[] { const s = this.settings() const out: FsShortcutDto[] = [] for (const k of this.knowledge()) if (k.enabled) out.push({ label: k.name, rootId: `kb:${k.id}`, path: '', icon: 'library' }) const server = realpathLoose(config.dataDir) const dirs: [string, string, string][] = [ [s.paths.skillsDir, '本地技能目录', 'symbol-misc'], [path.join(config.dataDir, 'managed-skills'), '已安装技能', 'extensions'], [s.paths.pythonRuntime, 'Python 运行环境', 'symbol-namespace'], [path.join(config.dataDir, 'data'), '服务数据', 'database'], [path.join(config.dataDir, 'logs'), '日志', 'output'], ] for (const [dir, label, icon] of dirs) { const real = realpathLoose(dir) if (isWithin(real, server)) out.push({ label, rootId: 'server', path: path.relative(server, real).split(path.sep).join('/'), icon }) else if (dir === s.paths.skillsDir) out.push({ label, rootId: 'skills', path: '', icon }) else if (dir === s.paths.pythonRuntime) out.push({ label, rootId: 'runtime', path: '', icon }) } return out } list(rootId: string, rel: string): { entries: FsEntryDto[]; truncated: boolean; sessionTitle: string | null } { const { root, abs } = this.resolve(rootId, rel) const titles = root.kind === 'workspaces' ? new Map(this.db.select({ id: sessions.id, title: sessions.title }).from(sessions).all().map(s => [s.id, s.title || '新会话'])) : new Map() const atWorkspaceRoot = root.kind === 'workspaces' && abs === realpathLoose(root.path) if (!fs.statSync(abs).isDirectory()) throw badRequest('不是目录') const entries: FsEntryDto[] = [] const names = fs.readdirSync(abs) for (const name of names.slice(0, MAX_LIST)) { const p = path.join(abs, name) if (this.hidden(p)) continue let st: fs.Stats try { st = fs.lstatSync(p) } catch { continue } let type: FsEntryDto['type'] = st.isDirectory() ? 'dir' : st.isFile() ? 'file' : st.isSymbolicLink() ? 'link' : 'other' // 指向位置之内的符号链接按目标显示,指向外部的只显示为链接 if (type === 'link') { const target = realpathLoose(p) if (isWithin(target, realpathLoose(root.path)) && fs.existsSync(target)) type = fs.statSync(target).isDirectory() ? 'dir' : 'file' } entries.push({ name, type, size: st.isFile() ? st.size : null, mtime: st.mtime.toISOString(), ...(atWorkspaceRoot && titles.has(name) ? { label: titles.get(name) } : {}) }) } entries.sort((a, b) => Number(b.type === 'dir') - Number(a.type === 'dir') || a.name.localeCompare(b.name)) const sessionId = root.kind === 'workspaces' ? path.relative(realpathLoose(root.path), abs).split(path.sep)[0] : '' return { entries, truncated: names.length > MAX_LIST, sessionTitle: sessionId ? titles.get(sessionId) ?? null : null } } /** 读取文本(编辑用):不超过 2 MB,且必须是 UTF-8 文本 */ readText(rootId: string, rel: string): { content: string; mtime: string } { const { abs } = this.resolve(rootId, rel) this.assertReadable(abs) const st = this.fileStat(abs) if (st.size > MAX_EDIT_BYTES) throw badRequest(`文件超过 ${MAX_EDIT_BYTES / 1048576} MB,不能在线编辑,请下载`) const buf = fs.readFileSync(abs) if (buf.subarray(0, 8000).includes(0)) throw badRequest('这是二进制文件,不能在线编辑') return { content: buf.toString('utf8'), mtime: st.mtime.toISOString() } } /** 下载或预览 */ openFile(rootId: string, rel: string): { abs: string; name: string; size: number } { const { abs } = this.resolve(rootId, rel) this.assertReadable(abs) const st = this.fileStat(abs) return { abs, name: path.basename(abs), size: st.size } } /** 保存文本:baseMtime 与磁盘不一致时拒绝(文件在打开后被别人修改了) */ writeText(rootId: string, rel: string, content: string, baseMtime: string | null): { mtime: string } { const { root, abs } = this.resolve(rootId, rel, { write: true, allowMissing: true }) if (Buffer.byteLength(content) > MAX_EDIT_BYTES) throw badRequest(`内容超过 ${MAX_EDIT_BYTES / 1048576} MB`) if (fs.existsSync(abs)) { const st = this.fileStat(abs) if (baseMtime && st.mtime.toISOString() !== baseMtime) throw conflict('file_changed', '文件在打开后被修改过,请重新加载后再保存') } else if (baseMtime) { throw conflict('file_changed', '文件已被删除或移动') } fs.mkdirSync(path.dirname(abs), { recursive: true }) fs.writeFileSync(abs, content) this.audit(`文件管理:保存 ${root.name}/${rel}`) this.afterChange(root, abs) return { mtime: fs.statSync(abs).mtime.toISOString() } } mkdir(rootId: string, rel: string) { const { root, abs } = this.resolve(rootId, rel, { write: true, allowMissing: true }) if (fs.existsSync(abs)) throw badRequest('已存在同名文件或目录') fs.mkdirSync(abs, { recursive: true }) this.audit(`文件管理:新建目录 ${root.name}/${rel}`) } upload(rootId: string, relDir: string, name: string, data: Buffer): FsEntryDto { const safe = path.basename(name).replace(/[\x00-\x1f/\\]/g, '_').trim() if (!safe || safe === '.' || safe === '..') throw badRequest('文件名不正确') const rel = relDir ? `${relDir.replace(/\/+$/, '')}/${safe}` : safe const { root, abs } = this.resolve(rootId, rel, { write: true, allowMissing: true }) fs.mkdirSync(path.dirname(abs), { recursive: true }) fs.writeFileSync(abs, data) this.audit(`文件管理:上传 ${root.name}/${rel}(${(data.length / 1024).toFixed(1)} KB)`) this.afterChange(root, abs) const st = fs.statSync(abs) return { name: safe, type: 'file', size: st.size, mtime: st.mtime.toISOString() } } /** 重命名或移动(同一位置内) */ move(rootId: string, from: string, to: string) { const src = this.resolve(rootId, from, { write: true }) const dst = this.resolve(rootId, to, { write: true, allowMissing: true }) if (src.abs === realpathLoose(src.root.path)) throw badRequest('不能移动位置的根目录') if (fs.existsSync(dst.abs)) throw badRequest('目标已存在') if (isWithin(dst.abs, src.abs)) throw badRequest('不能移动到自身之内') this.protectSessionDir(src.root, src.abs) fs.mkdirSync(path.dirname(dst.abs), { recursive: true }) fs.renameSync(src.abs, dst.abs) this.audit(`文件管理:移动 ${src.root.name}/${from} → ${to}`) this.afterChange(src.root, src.abs) this.afterChange(dst.root, dst.abs) } delete(rootId: string, rel: string) { const { root, abs } = this.resolve(rootId, rel, { write: true }) if (abs === realpathLoose(root.path)) throw badRequest('不能删除位置的根目录') this.protectSessionDir(root, abs) const isDir = fs.lstatSync(abs).isDirectory() fs.rmSync(abs, { recursive: true, force: true }) this.audit(`文件管理:删除${isDir ? '目录' : '文件'} ${root.name}/${rel}`) this.afterChange(root, abs) } /** 目录打包下载(跳过禁止的路径与符号链接) */ zipDir(rootId: string, rel: string): { name: string; data: Buffer } { const { root, abs } = this.resolve(rootId, rel) if (!fs.statSync(abs).isDirectory()) throw badRequest('不是目录') const entries: Record = {} let total = 0 let count = 0 const walk = (dir: string) => { for (const e of fs.readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, e.name) if (this.hidden(p) || this.sealed(p) || e.isSymbolicLink()) continue if (e.isDirectory()) walk(p) else if (e.isFile()) { const size = fs.statSync(p).size total += size if (++count > MAX_ZIP_FILES || total > MAX_ZIP_BYTES) throw badRequest(`目录太大(超过 ${MAX_ZIP_FILES} 个文件或 ${MAX_ZIP_BYTES / 1048576} MB),请分别下载`) entries[path.relative(abs, p).split(path.sep).join('/')] = new Uint8Array(fs.readFileSync(p)) } } } walk(abs) const base = abs === realpathLoose(root.path) ? root.name : path.basename(abs) return { name: `${base}.zip`, data: Buffer.from(zipSync(entries, { level: 6 })) } } // ---- 内部 ---- private rootList(): Root[] { const s = this.settings() const roots: Root[] = [ { id: 'workspaces', name: '会话工作区', kind: 'workspaces', path: s.paths.workspaceRoot, writable: true, description: '每个会话一个目录:uploads 上传的文件、outputs Agent 产出' }, ] for (const k of this.knowledge()) { roots.push({ id: `kb:${k.id}`, name: `资料库:${k.name}`, kind: 'knowledge', path: k.path, writable: true, description: `${k.description}${k.access === 'read' ? '(Agent 只读)' : '(Agent 可读写)'}` }) } roots.push({ id: 'server', name: '服务目录', kind: 'readonly', path: config.dataDir, writable: false, description: '整个服务目录(程序、运行环境、技能、数据、日志),只读浏览;要修改文件请到会话工作区或资料库' }) // 开发环境中技能目录与运行环境不在服务目录之下,单独列出 const server = realpathLoose(config.dataDir) if (!isWithin(realpathLoose(s.paths.skillsDir), server)) roots.push({ id: 'skills', name: '本地技能目录', kind: 'readonly', path: s.paths.skillsDir, writable: false, description: '随部署同步,在线修改会被覆盖,因此只读' }) if (!isWithin(realpathLoose(s.paths.pythonRuntime), server)) roots.push({ id: 'runtime', name: 'Python 运行环境', kind: 'readonly', path: s.paths.pythonRuntime, writable: false, description: '随部署安装,只读' }) return roots } /** 登录凭证与密钥不可见 */ private hidden(abs: string): boolean { return isCredentialPath(abs) } /** 数据库及其备份:保存着 MCP 服务密钥等只写不读的配置,可以看到但不能读取或下载 */ private sealed(abs: string): boolean { return isWithin(abs, realpathLoose(path.join(config.dataDir, 'data'))) && /^agent\.db(-wal|-shm|-journal|\.bak.*)?$/.test(path.basename(abs)) } private assertReadable(abs: string) { if (this.sealed(abs)) throw new AppError(403, 'denied', '数据库文件含有密钥,不提供读取或下载') } private resolve(rootId: string, rel: string, opts: { write?: boolean; allowMissing?: boolean } = {}): { root: Root; abs: string } { const root = this.rootList().find(r => r.id === rootId) if (!root) throw notFound('位置') if (opts.write && !root.writable) throw new AppError(403, 'readonly_root', `${root.name}是只读的`) const rootReal = realpathLoose(root.path) if (!fs.existsSync(rootReal)) throw notFound(`${root.name}(${root.path})`) const clean = (rel ?? '').replace(/^\/+|\/+$/g, '') // eslint-disable-next-line no-control-regex if (/[\x00-\x1f\\]/.test(clean) || clean.split('/').some(p => p === '..' || p === '.')) throw badRequest('路径不正确') const joined = path.join(rootReal, ...clean.split('/').filter(Boolean)) // 解析符号链接后仍须在位置之内(新建时检查父目录) const exists = fs.existsSync(joined) if (!exists && !opts.allowMissing) throw notFound(clean || root.name) const real = exists ? realpathLoose(joined) : path.join(realpathLoose(path.dirname(joined)), path.basename(joined)) if (!isWithin(real, rootReal)) throw new AppError(403, 'outside_root', '路径指向位置之外') if (this.hidden(real)) throw new AppError(403, 'denied', '这个路径不允许访问') return { root, abs: real } } private fileStat(abs: string): fs.Stats { const st = fs.statSync(abs) if (!st.isFile()) throw badRequest('不是文件') return st } /** 会话目录本身由会话管理,不能在这里删除或移动(删除会话请在会话页面) */ private protectSessionDir(root: Root, abs: string) { if (root.kind === 'workspaces' && path.dirname(abs) === realpathLoose(root.path)) throw badRequest('会话目录不能在这里删除或移动,请在会话页面删除会话') } /** 会话工作区中的改动:重新登记文件,删除已不存在的记录 */ private afterChange(root: Root, abs: string) { if (root.kind !== 'workspaces') return const rel = path.relative(realpathLoose(root.path), abs) const sessionId = rel.split(path.sep)[0] if (!sessionId) return const row = this.db.select().from(sessions).where(eq(sessions.id, sessionId)).get() if (!row) return this.files.prune(row.id, row.workspaceDir) void this.files.scan(row.id, row.workspaceDir).catch(() => {}) } }