Переглянути джерело

feat: 文件管理开放整个服务目录(只读浏览)

- 新增只读位置“服务目录”,覆盖程序、运行环境、技能、数据、日志与引擎 home
- 登录凭证与密钥不显示;数据库及其备份可见但不能读取或下载
- 常用目录增加已安装技能、Python 运行环境、服务数据、日志

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 1 тиждень тому
батько
коміт
0a700d759a

+ 2 - 2
apps/admin/src/views/FilesView.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-// 文件管理(管理员):会话工作区与资料库可读写,本地技能目录与运行环境只读;服务数据与凭证不可见。
+// 文件管理(管理员):会话工作区与资料库可读写,整个服务目录只读浏览;登录凭证不可见,数据库文件不能下载。
 // 状态放在地址中(?root=&path=&file=),可以直接链接到某个目录或文件
 import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
@@ -177,7 +177,7 @@ onMounted(async () => {
 
 <template>
   <div class="h-full flex flex-col">
-    <PageHeader title="文件管理" subtitle="以管理员身份管理会话工作区与资料库;本地技能目录与运行环境只读,服务数据与凭证不可见">
+    <PageHeader title="文件管理" subtitle="会话工作区与资料库可读写;整个服务目录只读浏览(登录凭证不显示,数据库文件不提供下载)">
       <button type="button" class="btn-secondary" @click="loadRoots(); loadDir()"><span class="codicon codicon-refresh" />刷新</button>
     </PageHeader>
     <div class="flex-1 min-h-0 flex text-[12.5px]">

+ 8 - 0
apps/server/src/services/access-policy.ts

@@ -101,6 +101,14 @@ function ruleToPath(rule: FileAccessPolicy['read'][number], roots: Map<string, K
 const matcherCache = new Map<string, (p: string) => boolean>()
 
 /** 路径是否命中全局禁止项(内置 + 设置中追加的);后台文件管理也遵守 */
+/** 登录凭证与密钥(内置禁止项中除服务数据目录以外的部分):后台文件管理里也不显示。同时按服务账号 home 展开(开发环境中它不是 ~) */
+export function isCredentialPath(target: string): boolean {
+  const real = realpathLoose(target)
+  const inHome = BUILTIN_GLOBAL_DENY.filter(e => e.startsWith('~/'))
+  const entries = [...inHome.map(expandPath), ...inHome.map(e => path.join(config.dataDir, 'home', e.slice(2))), path.join(codexHome(), 'auth.json')]
+  return entries.some(e => matchesEntry(real, e))
+}
+
 export function isGloballyDenied(target: string, settings: AppSettings): boolean {
   const real = realpathLoose(target)
   return globalDenyList(settings).some(e => matchesEntry(real, e))

+ 38 - 15
apps/server/src/services/file-manager.ts

@@ -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 } {

+ 18 - 8
apps/server/test/file-manager.test.ts

@@ -30,9 +30,9 @@ beforeAll(async () => {
 const kbRoot = () => app.fileManager.roots().find(r => r.kind === 'knowledge')!.id
 
 describe('后台文件管理', () => {
-  it('位置:会话工作区与资料库可写,本地技能、已安装技能与运行环境只读;常用目录包含最近的会话与资料库', () => {
+  it('位置:会话工作区与资料库可写,整个服务目录只读;常用目录包含最近的会话与资料库', () => {
     const roots = app.fileManager.roots()
-    expect(roots.map(r => [r.id.split(':')[0], r.writable])).toEqual([['workspaces', true], ['kb', true], ['skills', false], ['managed-skills', false], ['runtime', false]])
+    expect(roots.map(r => [r.id.split(':')[0], r.writable])).toEqual([['workspaces', true], ['kb', true], ['server', false]])
     const sc = app.fileManager.shortcuts()
     expect(sc[0]).toMatchObject({ rootId: 'workspaces', path: '' })
     expect(sc.some(s => s.rootId === 'workspaces' && s.path === sid)).toBe(true)
@@ -63,7 +63,7 @@ describe('后台文件管理', () => {
   })
 
   it('保护:只读位置不能写;路径不能越出位置;外部符号链接不跟随;会话目录不能在这里删除', () => {
-    expect(() => app.fileManager.writeText('skills', 'x.md', 'x', null)).toThrow(/只读/)
+    expect(() => app.fileManager.writeText('server', 'x.md', 'x', null)).toThrow(/只读/)
     expect(() => app.fileManager.list('workspaces', '../')).toThrow(/路径不正确/)
     expect(() => app.fileManager.list('workspaces', `${sid}/../..`)).toThrow(/路径不正确/)
     const kb = kbRoot()
@@ -73,12 +73,22 @@ describe('后台文件管理', () => {
     expect(() => app.fileManager.delete('workspaces', '')).toThrow(/根目录/)
   })
 
-  it('服务数据目录与全局禁止项不可见', () => {
-    // 包含服务数据目录的路径不能登记为资料库,文件管理也就无从进入
+  it('服务目录整体只读可见:登录凭证不显示,数据库文件看得到但不能读取或下载', () => {
+    fs.mkdirSync(path.join(config.dataDir, 'home', '.codex'), { recursive: true })
+    fs.writeFileSync(path.join(config.dataDir, 'home', '.codex', 'auth.json'), '{}')
+    fs.writeFileSync(path.join(config.dataDir, 'home', '.codex', 'config.toml'), '')
+    fs.mkdirSync(path.join(config.dataDir, 'home', '.claude'), { recursive: true })
+    fs.writeFileSync(path.join(config.dataDir, 'home', '.claude', '.credentials.json'), '{}')
+    expect(app.fileManager.list('server', '').entries.map(e => e.name)).toEqual(expect.arrayContaining(['data', 'home', 'workspaces']))
+    expect(app.fileManager.list('server', 'home/.codex').entries.map(e => e.name)).toEqual(['config.toml'])
+    expect(app.fileManager.list('server', 'home/.claude').entries.map(e => e.name)).not.toContain('.credentials.json')
+    expect(() => app.fileManager.readText('server', 'home/.codex/auth.json')).toThrow(/不允许访问/)
+    expect(app.fileManager.list('server', 'data').entries.map(e => e.name)).toContain('agent.db')
+    expect(() => app.fileManager.openFile('server', 'data/agent.db')).toThrow(/数据库文件/)
+    expect(() => app.fileManager.writeText('server', 'workspaces/x.md', 'x', null)).toThrow(/只读/)
+    expect(() => app.fileManager.zipDir('server', 'data')).not.toThrow()
+    // 包含服务数据目录的路径不能登记为资料库
     expect(() => app.knowledge.create({ name: '数据目录上层', path: config.dataDir, description: '', access: 'read', enabled: true })).toThrow(/全局禁止项/)
-    app.settings.update({ security: { globalDeny: [path.join(kbDir, '.ssh-like')] } })
-    expect(app.fileManager.list(kbRoot(), '').entries.map(e => e.name)).not.toContain('.ssh-like')
-    app.settings.update({ security: { globalDeny: [] } })
   })
 
   it('二进制文件不能在线编辑;目录可以打包下载', () => {

+ 1 - 1
docs/design/09-admin-ui.md

@@ -26,7 +26,7 @@
 | 🗂 会话 | 全部会话(所有平台/使用者) | 只读回放(时间线 + 原始事件 + 文件),可"在 Playground 中继续" |
 | 🧩 Profiles | Profile 列表 | 表单编辑:引擎、模型、提示词(Monaco 编辑器)、权限、工具、技能、资料库、**文件访问策略**(模式、可读/可写/禁止路径,右侧显示合并后的生效预览,见 06 §4.3);版本历史 |
 | 📚 资料库 | 资料目录列表 | 名称、路径(带目录选择器)、说明、读写属性、启用;可见性检测结果;被哪些 Profile 使用;索引状态(M3) |
-| 🗃 文件管理 | 位置 + 常用目录(全部会话工作区、最近 8 个会话、各资料库、本地技能目录) | 以管理员身份浏览与管理文件:会话工作区与资料库可写(新建文件/目录、上传与拖放、重命名/移动、删除、目录打包下载),本地技能目录与 Python 运行环境只读;文本文件用 Monaco 编辑(Ctrl/⌘+S 保存,打开后被改动则拒绝覆盖),图片预览,其他文件下载。服务数据目录、引擎 home 与全局禁止项不可见;路径不能越出位置,外部符号链接不跟随;会话目录本身不能在这里删除或移动;修改会话文件后同步该会话的文件记录。实现:`apps/server/src/services/file-manager.ts`、`/admin/v1/fs/*` |
+| 🗃 文件管理 | 位置 + 常用目录(全部会话工作区、最近 8 个会话、各资料库、本地技能目录、已安装技能、Python 运行环境、服务数据、日志) | 以管理员身份浏览与管理文件:会话工作区与资料库可写(新建文件/目录、上传与拖放、重命名/移动、删除、目录打包下载);**整个服务目录**(`/srv/agent-server`:程序、运行环境、技能、数据、日志、引擎 home)只读浏览。文本文件用 Monaco 编辑或查看(Ctrl/⌘+S 保存,打开后被改动则拒绝覆盖),图片预览,其他文件下载。登录凭证与密钥(内置全局禁止项中的 `~/.claude/.credentials.json`、`~/.codex/auth.json`、`~/.ssh` 等)不显示;数据库及其备份能看到但不能读取或下载(含 MCP 服务密钥等只写不读的配置)。路径不能越出位置,外部符号链接不跟随;会话目录本身不能在这里删除或移动;修改会话文件后同步该会话的文件记录。实现:`apps/server/src/services/file-manager.ts`、`/admin/v1/fs/*` |
 | 🔑 引擎状态 | Claude / Codex | 登录状态、账户摘要、额度、CLI 路径与版本、生效的工具清单(用于确认个人插件已被禁用);未登录时提示在终端执行 `claude` / `codex login` |
 | 🗝 API Keys | Key 列表 | 签发(明文只显示一次)、吊销、最近使用时间 |
 | 🔌 MCP 服务 | — | 内置工具(只读展示);第三方服务:添加远程 HTTP 或本地进程(沙箱)服务、测试连接、刷新工具、逐个工具设置策略(直接调用 / 跟随权限模式 / 关闭)、启停、删除;密钥只写不读;显示使用它的 Profile 与连接状态(ADR-0021) |