|
|
@@ -1,17 +1,17 @@
|
|
|
-// 后台文件管理(管理员):只能访问登记的"位置"——会话工作区、资料库(可读写),本地技能目录、Python 运行环境(只读)。
|
|
|
-// 服务数据目录(数据库、版本库、MCP 数据)、服务账号 home(CLI 凭证)与全局禁止项一律不可见。
|
|
|
+// 后台文件管理(管理员):只能访问登记的"位置"——会话工作区、资料库(可读写),整个服务目录(只读浏览)。
|
|
|
+// 登录凭证与密钥一律不可见;数据库文件(含 MCP 服务密钥等)可见但不能读取或下载。
|
|
|
// 路径以"位置 + 相对路径"表示;解析符号链接后必须仍在该位置之内
|
|
|
import fs from 'node:fs'
|
|
|
import path from 'node:path'
|
|
|
import { desc, eq } from 'drizzle-orm'
|
|
|
import { zipSync } from 'fflate'
|
|
|
import type { AppSettings, FsEntryDto, FsRootDto, FsShortcutDto, KnowledgeRootDto } from '@agent-server/shared'
|
|
|
-import { config, managedSkillsDir } from '../config'
|
|
|
+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 { isGloballyDenied } from './access-policy'
|
|
|
+import { isCredentialPath } from './access-policy'
|
|
|
import type { FileService } from './files'
|
|
|
|
|
|
const MAX_EDIT_BYTES = 2 * 1048576
|
|
|
@@ -34,7 +34,7 @@ export class FileManagerService {
|
|
|
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[] = [{ label: '全部会话工作区', rootId: 'workspaces', path: '', icon: 'folder-library' }]
|
|
|
@@ -44,7 +44,20 @@ export class FileManagerService {
|
|
|
out.push({ label: r.title || '新会话', rootId: 'workspaces', path: path.relative(s.paths.workspaceRoot, r.dir), icon: 'comment-discussion' })
|
|
|
}
|
|
|
for (const k of this.knowledge()) if (k.enabled) out.push({ label: k.name, rootId: `kb:${k.id}`, path: '', icon: 'library' })
|
|
|
- out.push({ label: '本地技能目录', rootId: 'skills', path: '', icon: 'symbol-misc' })
|
|
|
+ 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
|
|
|
}
|
|
|
|
|
|
@@ -80,6 +93,7 @@ export class FileManagerService {
|
|
|
/** 读取文本(编辑用):不超过 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)
|
|
|
@@ -90,6 +104,7 @@ export class FileManagerService {
|
|
|
/** 下载或预览 */
|
|
|
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 }
|
|
|
}
|
|
|
@@ -166,7 +181,7 @@ export class FileManagerService {
|
|
|
const walk = (dir: string) => {
|
|
|
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
|
const p = path.join(dir, e.name)
|
|
|
- if (this.hidden(p) || e.isSymbolicLink()) continue
|
|
|
+ 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
|
|
|
@@ -191,18 +206,26 @@ export class FileManagerService {
|
|
|
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: 'skills', name: '本地技能目录', kind: 'readonly', path: s.paths.skillsDir, writable: false, description: '随部署同步,在线修改会被覆盖,因此只读' })
|
|
|
- roots.push({ id: 'managed-skills', name: '已安装技能', kind: 'readonly', path: managedSkillsDir(), writable: false, description: '第三方技能与已发布的自定义技能,由服务生成,在"技能"页管理,因此只读' })
|
|
|
- roots.push({ id: 'runtime', name: 'Python 运行环境', kind: 'readonly', path: s.paths.pythonRuntime, writable: false, description: '随部署安装,只读' })
|
|
|
+ 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
|
|
|
}
|
|
|
|
|
|
- /** 服务数据目录、服务账号 home 与全局禁止项不可见 */
|
|
|
+ /** 登录凭证与密钥不可见 */
|
|
|
private hidden(abs: string): boolean {
|
|
|
- const real = realpathLoose(abs)
|
|
|
- const s = this.settings()
|
|
|
- if (isWithin(real, realpathLoose(path.join(config.dataDir, 'data'))) || isWithin(real, realpathLoose(path.join(config.dataDir, 'home')))) return true
|
|
|
- return isGloballyDenied(real, s)
|
|
|
+ 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 } {
|