Browse Source

feat: 资料库第二期——挂载服务 agent-mounter(Go,以部署用户运行)把 /home、/mnt、/media 下的本机目录只读挂给 Agent,隐藏以 . 开头的文件;外接设备按文件系统 UUID 识别,拔出显示"设备未连接"、插回(挂载位置变了也可以)自动恢复,不占用设备、可安全弹出;后台逐级选择目录并显示所在设备;deploy.sh 编译并安装挂载服务;SDK 0.7.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 13 hours ago
parent
commit
3c690bd7af

+ 4 - 2
README.md

@@ -20,7 +20,8 @@ docs/              设计文档、ADR、调研
 
 ## 前置条件
 
-- Node.js 22+、pnpm 8+
+- Node.js 22+、pnpm 8+;Go 1.26+(编译资料库挂载服务 `apps/mounter`)
+- fuse3(`fusermount3`),`/etc/fuse.conf` 中开启 `user_allow_other`(`install.sh` 会处理)
 - 正式服务:用 `agent-claude` 以服务账号 agentsvc 登录 Claude(一次即可);开发环境:本人终端已登录 `claude`
 - bubblewrap、socat(Claude 沙箱);LibreOffice、poppler-utils、pandoc、ripgrep、fonts-noto-cjk
 - 数据目录与运行环境由 `deploy/install.sh`、`deploy/setup-runtime.sh` 创建,见 [docs/design/10-deployment.md](docs/design/10-deployment.md)
@@ -32,7 +33,7 @@ docs/              设计文档、ADR、调研
 ```bash
 sudo deploy/install.sh          # 首次:创建 agentsvc、目录权限、systemd 服务、agent-claude 命令
 sudo deploy/setup-runtime.sh    # 首次 / 依赖变化:Python、Node、系统工具
-deploy/deploy.sh                # 每次发布:构建、发布到 /srv/agent-server/app、重启
+deploy/deploy.sh                # 每次发布:构建、发布到 /srv/agent-server/app、重启(挂载服务 agent-mounter 以执行者身份运行,程序有变化时才重启)
 # 然后在后台"概览"页点"登录"完成 Claude 登录(也可以在终端执行 agent-claude auth login)
 ```
 
@@ -71,6 +72,7 @@ NODE_ENV=production node apps/server/dist/main.js
 ```bash
 pnpm test        # 服务端单元与集成测试(文件访问策略、API 全流程、LibreOffice 转换)
 pnpm typecheck   # 全部包类型检查
+(cd apps/mounter && go test ./...)   # 资料库挂载服务(含真实 FUSE 挂载测试)
 ```
 
 `apps/server/scripts/` 下有两个手动验证脚本(会消耗真实额度):`smoke-claude.ts` 用来验证 SDK 和登录态,`try-claude-adapter.ts` 用来验证审批流和越权拦截。

+ 4 - 1
apps/admin/src/lib/api.ts

@@ -1,7 +1,7 @@
 // 后台 API:同源 Cookie 鉴权,复用 AgentClient 的请求与错误处理
 import { AgentClient, BotClient } from '@agent-server/client'
 import type {
-  ApiKeyDto, ApiKeyProfiles, BotDto, BotInput, BotKeyDto, ApiReferenceDto, AppSettings, BrowserTokenDto, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginInstallResultDto, CodexSkillCatalogDto, CommunitySearchResultDto, CustomSkillDetailDto, CustomSkillDto, FileDiffDto, ServerDirListDto, SkillApproveInput, SkillDeviceLoginDto, SkillPublishKeyDto, SkillPublishKeyInput, SkillSubmissionDiffDto, SkillSubmissionItemDetailDto, SkillSubmissionItemDto, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, FsEntryDto, FsRootDto, FsShortcutDto, KnowledgeCollectionDto, KnowledgeCollectionInput, KnowledgeInput, KnowledgeLinkTestDto, KnowledgeRootDto, McpCredentialDto, McpCredentialInput, McpServerDto, McpServerInput, McpStateDto, McpTestResultDto, McpToolHints, McpToolPolicy, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput, ProfileVersionDto,
+  ApiKeyDto, ApiKeyProfiles, BotDto, BotInput, BotKeyDto, ApiReferenceDto, AppSettings, BrowserTokenDto, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginInstallResultDto, CodexSkillCatalogDto, CommunitySearchResultDto, CustomSkillDetailDto, CustomSkillDto, FileDiffDto, ServerDirListDto, SkillApproveInput, SkillDeviceLoginDto, SkillPublishKeyDto, SkillPublishKeyInput, SkillSubmissionDiffDto, SkillSubmissionItemDetailDto, SkillSubmissionItemDto, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, FsEntryDto, FsRootDto, FsShortcutDto, KnowledgeBrowseDto, KnowledgeCollectionDto, KnowledgeCollectionInput, KnowledgeInput, KnowledgeLinkTestDto, KnowledgeLocalCheckDto, KnowledgeMounterDto, KnowledgeRootDto, McpCredentialDto, McpCredentialInput, McpServerDto, McpServerInput, McpStateDto, McpTestResultDto, McpToolHints, McpToolPolicy, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput, ProfileVersionDto,
   ProviderQuotaDto, ProviderStatusDto, RecentErrorDto, ResolvedAccessDto, SkillInfo, UsageSummaryDto, TeamCaseDto, TeamCaseRunDto, TeamDto, ScheduleOverviewDto, ScheduleRunDto, ScheduleDto, BackgroundCallDto, BackgroundCallOverviewDto,
 } from '@agent-server/shared'
 
@@ -166,6 +166,9 @@ export const admin = {
   updateKnowledge: (id: string, k: KnowledgeInput) => req<KnowledgeRootDto>('PUT', `/knowledge/${id}`, k),
   deleteKnowledge: (id: string) => req<void>('DELETE', `/knowledge/${id}`),
   testKnowledgeLink: (url: string) => req<KnowledgeLinkTestDto>('POST', '/knowledge/test-link', { url }),
+  knowledgeMounter: () => req<KnowledgeMounterDto>('GET', '/knowledge/mounter'),
+  browseKnowledge: (path: string) => req<KnowledgeBrowseDto>('GET', `/knowledge/browse?path=${encodeURIComponent(path)}`),
+  checkKnowledgeLocal: (path: string) => req<KnowledgeLocalCheckDto>('POST', '/knowledge/check-local', { path }),
   knowledgeCollections: () => req<KnowledgeCollectionDto[]>('GET', '/knowledge-collections'),
   createKnowledgeCollection: (c: KnowledgeCollectionInput) => req<KnowledgeCollectionDto>('POST', '/knowledge-collections', c),
   updateKnowledgeCollection: (id: string, c: KnowledgeCollectionInput) => req<KnowledgeCollectionDto>('PUT', `/knowledge-collections/${id}`, c),

+ 105 - 8
apps/admin/src/views/KnowledgeView.vue

@@ -1,7 +1,10 @@
 <script setup lang="ts">
 // 资料库与资料集合(docs/design/21-knowledge-sources.md):托管目录、本机目录、远程存储、网络链接;集合供 Profile 整体勾选
 import { computed, onMounted, ref } from 'vue'
-import type { KnowledgeCollectionDto, KnowledgeCollectionInput, KnowledgeInput, KnowledgeKind, KnowledgeLinkTestDto, KnowledgeRootDto, KnowledgeState } from '@agent-server/shared'
+import type {
+  KnowledgeBrowseDto, KnowledgeCollectionDto, KnowledgeCollectionInput, KnowledgeInput, KnowledgeKind, KnowledgeLinkTestDto, KnowledgeLocalCheckDto,
+  KnowledgeMounterDto, KnowledgeRootDto, KnowledgeState,
+} from '@agent-server/shared'
 import PageHeader from '../components/PageHeader.vue'
 import { admin, errorMessage } from '../lib/api'
 import { formatTime, toast } from '../lib/ui'
@@ -13,10 +16,17 @@ const editingCol = ref<(KnowledgeCollectionInput & { id?: string }) | null>(null
 const saving = ref(false)
 const linkTest = ref<KnowledgeLinkTestDto | null>(null)
 const testing = ref(false)
+const mounter = ref<KnowledgeMounterDto | null>(null)
+// 本机目录:逐级选择与检查结果
+const picker = ref<KnowledgeBrowseDto | null>(null)
+const pickerLoading = ref(false)
+const localCheck = ref<KnowledgeLocalCheckDto | null>(null)
+const localError = ref('')
+const showDeviceHelp = ref(false)
 
 const KINDS: { kind: KnowledgeKind; label: string; icon: string; desc: string; available: boolean }[] = [
   { kind: 'managed', label: '托管目录', icon: 'folder', desc: '平台自己的目录,在"文件管理"中上传、编辑文件;可以允许 Agent 写入', available: true },
-  { kind: 'local', label: '本机目录', icon: 'device-desktop', desc: '/home、/mnt、/media 下的目录(含外接硬盘),只读挂载,不显示隐藏文件', available: false },
+  { kind: 'local', label: '本机目录', icon: 'device-desktop', desc: '/home、/mnt、/media 下的目录(含外接硬盘),只读挂载,不显示隐藏文件', available: true },
   { kind: 'remote', label: '远程存储', icon: 'cloud', desc: 'S3 兼容存储(MinIO、OSS、COS 等)、WebDAV,只读挂载,按需读取', available: false },
   { kind: 'link', label: '网络链接', icon: 'link', desc: '网页或文件地址,Agent 用时自己读取;同一站点下的内网地址也可以访问', available: true },
 ]
@@ -24,33 +34,74 @@ const kindOf = (k: KnowledgeKind) => KINDS.find(x => x.kind === k)!
 
 const STATE_LABEL: Record<KnowledgeState, string> = {
   ok: '正常', path_missing: '路径不存在', device_missing: '设备未连接', not_allowed: '位置不允许',
-  unreachable: '无法连接', auth_failed: '认证失败', mounter_down: '挂载服务不可用', pending: '正在挂载',
+  unreachable: '无法连接', auth_failed: '认证失败', mounter_down: '挂载服务不可用', pending: '正在挂载', mount_failed: '挂载失败',
 }
+const hasMounted = computed(() => roots.value.some(k => k.kind === 'local' || k.kind === 'remote'))
 
 const rootName = (id: string) => roots.value.find(r => r.id === id)?.name ?? id
 const colName = (id: string) => collections.value.find(c => c.id === id)?.name ?? id
 const where = (k: KnowledgeRootDto) => k.kind === 'link' ? k.url : k.kind === 'local' ? k.local?.path : k.path
 
 async function load() {
-  ;[roots.value, collections.value] = await Promise.all([
+  ;[roots.value, collections.value, mounter.value] = await Promise.all([
     admin.knowledge().catch(err => (toast(errorMessage(err), 'error'), [])),
     admin.knowledgeCollections().catch(err => (toast(errorMessage(err), 'error'), [])),
+    admin.knowledgeMounter().catch(() => null),
   ])
 }
 
 function edit(k?: KnowledgeRootDto) {
   linkTest.value = null
+  picker.value = null
+  localCheck.value = null
+  localError.value = ''
   editing.value = k
-    ? { id: k.id, kind: k.kind, name: k.name, description: k.description, access: k.access, enabled: k.enabled, url: k.url ?? '' }
-    : { kind: 'managed', name: '', description: '', access: 'read', enabled: true, url: '' }
+    ? { id: k.id, kind: k.kind, name: k.name, description: k.description, access: k.access, enabled: k.enabled, url: k.url ?? '', localPath: k.local?.path ?? '' }
+    : { kind: 'managed', name: '', description: '', access: 'read', enabled: true, url: '', localPath: '' }
 }
 
 const canSave = computed(() => {
   const e = editing.value
   if (!e || !e.name.trim() || !kindOf(e.kind).available) return false
+  if (e.kind === 'local') return !!e.localPath?.trim()
   return e.kind !== 'link' || !!e.url?.trim()
 })
 
+async function browseTo(path: string) {
+  pickerLoading.value = true
+  try {
+    picker.value = await admin.browseKnowledge(path)
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    pickerLoading.value = false
+  }
+}
+
+function openPicker() {
+  const cur = editing.value?.localPath?.trim()
+  void browseTo(cur ? cur : '')
+}
+
+async function chooseLocal(path: string) {
+  if (!editing.value) return
+  editing.value.localPath = path
+  picker.value = null
+  await checkLocal()
+}
+
+async function checkLocal() {
+  const p = editing.value?.localPath?.trim()
+  localCheck.value = null
+  localError.value = ''
+  if (!p) return
+  try {
+    localCheck.value = await admin.checkKnowledgeLocal(p)
+  } catch (err) {
+    localError.value = errorMessage(err)
+  }
+}
+
 async function save() {
   const e = editing.value
   if (!e) return
@@ -142,6 +193,10 @@ onMounted(load)
       <button type="button" class="btn-primary" @click="edit()"><span class="codicon codicon-add" />添加资料库</button>
     </PageHeader>
     <div class="flex-1 overflow-y-auto p-6 flex flex-col gap-6">
+      <div v-if="mounter && !mounter.available && hasMounted" class="max-w-240 card p-3 flex items-start gap-2 text-error">
+        <span class="codicon codicon-warning mt-0.5" />
+        <div>挂载服务不可用:{{ mounter.error }}。本机目录暂时无法读取,请检查 agent-mounter 服务(<span class="font-mono">systemctl status agent-mounter</span>)。</div>
+      </div>
       <section class="max-w-240 flex flex-col gap-2">
         <div class="section-title">资料库</div>
         <div v-if="!roots.length" class="hint">还没有资料库。</div>
@@ -158,9 +213,13 @@ onMounted(load)
               </span>
               <span v-for="c in k.collectionIds" :key="c" class="badge bg-code"><span class="codicon codicon-folder-library text-[11px]" />{{ colName(c) }}</span>
             </div>
-            <div class="font-mono text-[12px] text-fg-muted break-all">{{ where(k) }}</div>
+            <div class="font-mono text-[12px] text-fg-muted break-all">
+              {{ where(k) }}<span v-if="k.local?.uuid" class="font-sans" :title="`设备内路径:${k.local.relPath}`">(外接设备 {{ k.local.uuid }})</span>
+            </div>
             <div v-if="k.description" class="mt-1">{{ k.description }}</div>
-            <div v-if="k.status.state !== 'ok' && k.status.message" class="mt-1 text-[12px] text-error">{{ k.status.message }}</div>
+            <div v-if="k.status.state !== 'ok' && k.status.message" class="mt-1 text-[12px] text-error">
+              {{ k.status.message }}<span v-if="k.status.since">({{ formatTime(k.status.since) }} 起)</span>
+            </div>
           </div>
           <RouterLink v-if="k.kind !== 'link'" :to="{ path: '/files', query: { root: `kb:${k.id}` } }" class="icon-btn" title="在文件管理中打开"><span class="codicon codicon-folder-opened" /></RouterLink>
           <button v-else type="button" class="icon-btn" title="测试访问" @click="testListed(k)"><span class="codicon codicon-debug-start" /></button>
@@ -222,6 +281,44 @@ onMounted(load)
           </div>
           <div class="hint mt-1">Agent 用 web_fetch 读取;与这个链接同一站点(协议、主机、端口相同)的地址都可以访问,包括内网地址。暂不支持需要登录的地址。</div>
         </div>
+        <div v-if="editing.kind === 'local'" class="flex flex-col gap-1">
+          <label class="label" for="k-path">目录</label>
+          <div class="flex gap-2">
+            <input id="k-path" v-model="editing.localPath" class="field flex-1 font-mono" placeholder="/media/用户/硬盘/资料" @change="checkLocal">
+            <button type="button" class="btn-secondary" :disabled="pickerLoading" @click="openPicker"><span class="codicon codicon-folder-opened" />选择</button>
+          </div>
+          <div v-if="picker" class="card p-2 flex flex-col gap-0.5 max-h-72 overflow-y-auto">
+            <div class="flex items-center gap-2 pb-1 mb-1 border-b border-border">
+              <button v-if="picker.path" type="button" class="icon-btn" title="上一级" @click="browseTo(picker.parent)"><span class="codicon codicon-arrow-up" /></button>
+              <span class="font-mono text-[12px] flex-1 break-all">{{ picker.path || '可以选择的位置' }}</span>
+              <button type="button" class="icon-btn" title="关闭" @click="picker = null"><span class="codicon codicon-close" /></button>
+            </div>
+            <div v-if="!picker.entries.length" class="hint px-1">没有子目录</div>
+            <div v-for="d in picker.entries" :key="d.path" class="flex items-center gap-2 px-1 py-0.5 rounded hover:bg-code">
+              <button type="button" class="flex-1 min-w-0 text-left flex items-center gap-1.5" title="进入" @click="browseTo(d.path)">
+                <span class="codicon codicon-folder" />
+                <span class="truncate">{{ d.name }}</span>
+                <span v-if="d.reason" class="hint truncate">{{ d.reason }}</span>
+              </button>
+              <button v-if="d.selectable" type="button" class="btn-secondary !py-0.5" @click="chooseLocal(d.path)">选择</button>
+            </div>
+          </div>
+          <div v-if="localError" class="text-[12px] text-error flex items-center gap-1"><span class="codicon codicon-error" />{{ localError }}</div>
+          <div v-else-if="localCheck" class="text-[12px] text-success flex items-start gap-1">
+            <span class="codicon codicon-pass mt-0.5" />
+            <span v-if="localCheck.uuid">
+              在外接设备上({{ localCheck.device }},{{ localCheck.fsType }},UUID {{ localCheck.uuid }},挂载在 {{ localCheck.mountPoint }})。按设备识别:设备拔出时显示"设备未连接",插回后自动恢复,挂载位置变了也能找到。
+            </span>
+            <span v-else>{{ localCheck.systemDisk ? '在系统盘上,按路径识别。' : `按路径识别(${localCheck.device || '未识别到设备 UUID'})。` }}</span>
+          </div>
+          <div class="hint">只读挂载给 Agent,以 . 开头的文件和目录不可见。只能选择 /home、/mnt、/media 下的目录,不能选择用户主目录本身。</div>
+          <button type="button" class="hint text-left underline w-fit" @click="showDeviceHelp = !showDeviceHelp">外接硬盘、U 盘怎么设置?</button>
+          <div v-if="showDeviceHelp" class="card p-2 text-[12px] flex flex-col gap-1">
+            <div>挂载服务只负责识别设备是否回来,不负责把设备挂到系统上。建议由管理员在 <span class="font-mono">/etc/fstab</span> 中按 UUID 挂载用作资料的硬盘:</div>
+            <div class="font-mono bg-code p-1 rounded break-all">UUID=&lt;设备 UUID&gt; /mnt/资料盘 ext4 ro,nofail,x-systemd.automount,x-systemd.device-timeout=1s 0 0</div>
+            <div>桌面自动挂载(/media/&lt;登录用户&gt;/…)需要有人登录桌面,而且只有那个用户能读,不适合作为资料来源。</div>
+          </div>
+        </div>
         <div>
           <label class="label" for="k-desc">说明(会告诉 Agent 这里有什么资料)</label>
           <textarea id="k-desc" v-model="editing.description" class="field-area h-20 font-sans" placeholder="例如:各产品线的用户手册与规格书 PDF,按产品型号分目录" />

+ 230 - 0
apps/mounter/device.go

@@ -0,0 +1,230 @@
+package main
+
+// 本机目录的设备识别(docs/design/21-knowledge-sources.md §4.4):
+// 系统盘上的目录只按路径识别;其他设备上的目录记录文件系统 UUID 与设备内路径,
+// 每次解析时找到这个 UUID 当前挂在哪里,再拼出实际路径。
+
+import (
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+
+	"golang.org/x/sys/unix"
+)
+
+type mountEntry struct {
+	Major, Minor uint32
+	Root         string // 文件系统内被挂载的路径(bind mount 时不是 /)
+	Point        string
+	FSType       string
+	Source       string
+}
+
+func unescapeMountinfo(s string) string {
+	if !strings.Contains(s, `\`) {
+		return s
+	}
+	var b strings.Builder
+	for i := 0; i < len(s); i++ {
+		if s[i] == '\\' && i+3 < len(s) {
+			if n, err := strconv.ParseUint(s[i+1:i+4], 8, 8); err == nil {
+				b.WriteByte(byte(n))
+				i += 3
+				continue
+			}
+		}
+		b.WriteByte(s[i])
+	}
+	return b.String()
+}
+
+// parseMountinfo 解析 /proc/self/mountinfo
+func parseMountinfo(data string) []mountEntry {
+	var out []mountEntry
+	for _, line := range strings.Split(data, "\n") {
+		f := strings.Fields(line)
+		if len(f) < 10 {
+			continue
+		}
+		sep := -1
+		for i := 6; i < len(f); i++ {
+			if f[i] == "-" {
+				sep = i
+				break
+			}
+		}
+		if sep < 0 || sep+2 >= len(f) {
+			continue
+		}
+		mm := strings.SplitN(f[2], ":", 2)
+		if len(mm) != 2 {
+			continue
+		}
+		maj, err1 := strconv.ParseUint(mm[0], 10, 32)
+		min, err2 := strconv.ParseUint(mm[1], 10, 32)
+		if err1 != nil || err2 != nil {
+			continue
+		}
+		out = append(out, mountEntry{
+			Major: uint32(maj), Minor: uint32(min),
+			Root: unescapeMountinfo(f[3]), Point: unescapeMountinfo(f[4]),
+			FSType: f[sep+1], Source: unescapeMountinfo(f[sep+2]),
+		})
+	}
+	return out
+}
+
+// system 提供识别所需的系统信息,测试中替换为模拟数据
+type system interface {
+	mountinfo() []mountEntry
+	// uuids 返回 设备号 → 文件系统 UUID(来自 /dev/disk/by-uuid)
+	uuids() map[uint64]string
+	// stat 返回路径所在的设备号(不跟随最后的符号链接以外的行为与 stat 相同)
+	devOf(path string) (uint64, error)
+}
+
+type realSystem struct{}
+
+func (realSystem) mountinfo() []mountEntry {
+	b, err := os.ReadFile("/proc/self/mountinfo")
+	if err != nil {
+		return nil
+	}
+	return parseMountinfo(string(b))
+}
+
+func (realSystem) uuids() map[uint64]string {
+	out := map[uint64]string{}
+	ents, err := os.ReadDir("/dev/disk/by-uuid")
+	if err != nil {
+		return out
+	}
+	for _, e := range ents {
+		var st unix.Stat_t
+		if unix.Stat(filepath.Join("/dev/disk/by-uuid", e.Name()), &st) == nil && st.Mode&unix.S_IFMT == unix.S_IFBLK {
+			out[st.Rdev] = e.Name()
+		}
+	}
+	return out
+}
+
+func (realSystem) devOf(path string) (uint64, error) {
+	var st unix.Stat_t
+	if err := unix.Stat(path, &st); err != nil {
+		return 0, err
+	}
+	return st.Dev, nil
+}
+
+func devOfEntry(e mountEntry) uint64 { return unix.Mkdev(e.Major, e.Minor) }
+
+// within 判断 p 是否等于 base 或在 base 之下
+func within(p, base string) bool {
+	return p == base || base == "/" || strings.HasPrefix(p, base+"/")
+}
+
+// identity 本机目录的识别结果
+type identity struct {
+	Path       string `json:"path"`
+	UUID       string `json:"uuid"`    // 系统盘或无法识别的设备上为空
+	RelPath    string `json:"relPath"` // 设备内路径
+	SystemDisk bool   `json:"systemDisk"`
+	MountPoint string `json:"mountPoint"`
+	FSType     string `json:"fsType"`
+	Device     string `json:"device"`
+}
+
+// identify 识别已检查通过的真实路径所在的文件系统
+func identify(sys system, real string) identity {
+	id := identity{Path: real}
+	dev, err := sys.devOf(real)
+	if err != nil {
+		return id
+	}
+	rootDev, _ := sys.devOf("/")
+	// 找到包含该路径、设备号相同、挂载点最长的条目
+	var best *mountEntry
+	for _, e := range sys.mountinfo() {
+		if devOfEntry(e) == dev && within(real, e.Point) && (best == nil || len(e.Point) >= len(best.Point)) {
+			e := e
+			best = &e
+		}
+	}
+	if best != nil {
+		id.MountPoint, id.FSType, id.Device = best.Point, best.FSType, best.Source
+	}
+	if dev == rootDev {
+		id.SystemDisk = true
+		return id
+	}
+	uuid := sys.uuids()[dev]
+	if uuid == "" || best == nil {
+		return id
+	}
+	id.UUID = uuid
+	id.RelPath = filepath.Join(best.Root, strings.TrimPrefix(real, best.Point))
+	if !strings.HasPrefix(id.RelPath, "/") {
+		id.RelPath = "/" + id.RelPath
+	}
+	return id
+}
+
+// localSource 平台登记的本机目录
+type localSource struct {
+	Path    string `json:"path"`
+	UUID    string `json:"uuid"`
+	RelPath string `json:"relPath"`
+}
+
+// locate 找到本机目录当前的实际路径。返回的路径还需要经过 checkSource。
+func locate(sys system, src localSource) (string, *checkError) {
+	if src.UUID == "" {
+		return src.Path, nil
+	}
+	var dev uint64
+	found := false
+	for d, u := range sys.uuids() {
+		if u == src.UUID {
+			dev, found = d, true
+			break
+		}
+	}
+	if !found {
+		return "", &checkError{"device_missing", "设备未连接(文件系统 UUID " + src.UUID + ")"}
+	}
+	candidates := func() []string {
+		var out []string
+		for _, e := range sys.mountinfo() {
+			if devOfEntry(e) == dev && within(src.RelPath, e.Root) {
+				rest := strings.TrimPrefix(src.RelPath, e.Root)
+				if e.Root == "/" {
+					rest = src.RelPath
+				}
+				out = append(out, filepath.Join(e.Point, rest))
+			}
+		}
+		return out
+	}
+	list := candidates()
+	if len(list) == 0 {
+		// 可能是 x-systemd.automount:访问原路径触发自动挂载后再找一次
+		_, _ = sys.devOf(src.Path)
+		list = candidates()
+	}
+	if len(list) == 0 {
+		return "", &checkError{"device_missing", "设备已连接,但没有挂载到系统上(文件系统 UUID " + src.UUID + ")"}
+	}
+	// 优先选原来的路径,其次选第一个在允许范围内的
+	for _, p := range list {
+		if p == src.Path {
+			return p, nil
+		}
+	}
+	for _, p := range list {
+		if ruleProblem(p, allowedRoots) == "" {
+			return p, nil
+		}
+	}
+	return list[0], nil
+}

+ 111 - 0
apps/mounter/device_test.go

@@ -0,0 +1,111 @@
+package main
+
+import (
+	"testing"
+
+	"golang.org/x/sys/unix"
+)
+
+const sampleMountinfo = `22 1 252:0 / / rw,relatime shared:1 - ext4 /dev/mapper/vg-root rw
+30 22 0:5 / /dev rw,nosuid shared:2 - devtmpfs udev rw
+95 22 8:1 / /media/u/Data rw,relatime shared:50 - ext4 /dev/sda1 rw
+96 22 8:1 /photos /mnt/photos\040bind rw,relatime shared:50 - ext4 /dev/sda1 rw
+`
+
+func TestParseMountinfo(t *testing.T) {
+	es := parseMountinfo(sampleMountinfo)
+	if len(es) != 4 {
+		t.Fatalf("应解析出 4 条:%d", len(es))
+	}
+	if e := es[3]; e.Point != "/mnt/photos bind" || e.Root != "/photos" || e.Major != 8 || e.Minor != 1 || e.FSType != "ext4" || e.Source != "/dev/sda1" {
+		t.Errorf("解析不正确:%+v", e)
+	}
+}
+
+// fakeSystem 模拟挂载表、UUID 与设备号
+type fakeSystem struct {
+	info  string
+	uuid  map[uint64]string
+	devs  map[string]uint64 // 路径前缀 → 设备号(最长匹配)
+	stats []string
+}
+
+func (f *fakeSystem) mountinfo() []mountEntry  { return parseMountinfo(f.info) }
+func (f *fakeSystem) uuids() map[uint64]string { return f.uuid }
+func (f *fakeSystem) devOf(p string) (uint64, error) {
+	f.stats = append(f.stats, p)
+	best, dev := "", uint64(0)
+	for prefix, d := range f.devs {
+		if within(p, prefix) && len(prefix) >= len(best) {
+			best, dev = prefix, d
+		}
+	}
+	if best == "" {
+		return 0, unix.ENOENT
+	}
+	return dev, nil
+}
+
+func TestIdentifyAndLocate(t *testing.T) {
+	sda := unix.Mkdev(8, 1)
+	root := unix.Mkdev(252, 0)
+	sys := &fakeSystem{
+		info: sampleMountinfo,
+		uuid: map[uint64]string{sda: "aaaa-1111", root: "rootuuid"},
+		devs: map[string]uint64{"/": root, "/media/u/Data": sda, "/mnt/photos bind": sda},
+	}
+	// 系统盘:只记路径
+	if id := identify(sys, "/home/u/docs"); !id.SystemDisk || id.UUID != "" {
+		t.Errorf("系统盘上的目录:%+v", id)
+	}
+	// 外接盘:UUID + 设备内路径
+	id := identify(sys, "/media/u/Data/资料/合同")
+	if id.UUID != "aaaa-1111" || id.RelPath != "/资料/合同" || id.SystemDisk || id.MountPoint != "/media/u/Data" {
+		t.Fatalf("外接盘上的目录:%+v", id)
+	}
+	src := localSource{Path: id.Path, UUID: id.UUID, RelPath: id.RelPath}
+	if p, err := locate(sys, src); err != nil || p != "/media/u/Data/资料/合同" {
+		t.Errorf("设备在原位置:%q %v", p, err)
+	}
+	// bind mount 的子目录
+	if p, err := locate(sys, localSource{Path: "/mnt/photos bind/2024", UUID: "aaaa-1111", RelPath: "/photos/2024"}); err != nil || p != "/mnt/photos bind/2024" {
+		t.Errorf("bind mount:%q %v", p, err)
+	}
+
+	// 设备拔出:UUID 不在了
+	sys.uuid = map[uint64]string{root: "rootuuid"}
+	if _, err := locate(sys, src); err == nil || err.State != "device_missing" {
+		t.Errorf("设备拔出应为 device_missing:%v", err)
+	}
+
+	// 设备插回,但挂到了别的位置(sdb)
+	sdb := unix.Mkdev(8, 17)
+	sys.uuid = map[uint64]string{sdb: "aaaa-1111", root: "rootuuid"}
+	sys.info = "22 1 252:0 / / rw - ext4 /dev/mapper/vg-root rw\n97 22 8:17 / /media/u/Data1 rw - ext4 /dev/sdb1 rw\n"
+	if p, err := locate(sys, src); err != nil || p != "/media/u/Data1/资料/合同" {
+		t.Errorf("挂载位置变化后应能找到:%q %v", p, err)
+	}
+
+	// 另一块盘挂在原路径上:UUID 不同,不会被误认
+	sys.uuid = map[uint64]string{sdb: "bbbb-2222", root: "rootuuid"}
+	sys.info = "22 1 252:0 / / rw - ext4 /dev/mapper/vg-root rw\n97 22 8:17 / /media/u/Data rw - ext4 /dev/sdb1 rw\n"
+	if _, err := locate(sys, src); err == nil || err.State != "device_missing" {
+		t.Errorf("换了一块盘应为 device_missing:%v", err)
+	}
+
+	// 设备已连接但没有挂载(例如自动挂载):会访问原路径触发挂载
+	sys.uuid = map[uint64]string{sda: "aaaa-1111", root: "rootuuid"}
+	sys.info = "22 1 252:0 / / rw - ext4 /dev/mapper/vg-root rw\n"
+	sys.stats = nil
+	if _, err := locate(sys, src); err == nil || err.State != "device_missing" || !contains(err.Message, "没有挂载") {
+		t.Errorf("已连接未挂载:%v", err)
+	}
+	if len(sys.stats) == 0 || sys.stats[0] != src.Path {
+		t.Errorf("应访问原路径以触发自动挂载:%v", sys.stats)
+	}
+
+	// 只记路径的目录
+	if p, err := locate(sys, localSource{Path: "/home/u/docs"}); err != nil || p != "/home/u/docs" {
+		t.Errorf("系统盘:%q %v", p, err)
+	}
+}

+ 412 - 0
apps/mounter/fs.go

@@ -0,0 +1,412 @@
+package main
+
+// 只读文件系统(docs/design/21-knowledge-sources.md §4.2):
+// - 以 . 开头的文件和目录不出现在列表中,按路径访问也返回"不存在";
+// - 文件一律显示为所有人只读,目录为所有人可进入;
+// - 每次访问都先打开源目录并核对设备号、inode 号,再用 openat2(RESOLVE_BENEATH|RESOLVE_NO_SYMLINKS|RESOLVE_NO_XDEV) 打开,
+//   访问过程中目录被替换成符号链接也不会读到源目录以外的内容,也不会跨到源目录下挂载的其他文件系统;
+// - 资料库不可用时根目录照常存在,读取返回"输入/输出错误"。
+
+import (
+	"context"
+	"strings"
+	"sync"
+	"syscall"
+	"time"
+
+	"github.com/hanwen/go-fuse/v2/fs"
+	"github.com/hanwen/go-fuse/v2/fuse"
+	"golang.org/x/sys/unix"
+)
+
+// source 一个挂载对应的源目录:真实路径及其设备号、inode 号;设备拔出、恢复时由 manager 更新。
+// 不长期持有源目录的句柄,否则设备会一直"忙",无法卸载(安全弹出、自动挂载的空闲卸载都会失败)。
+type source struct {
+	mu   sync.RWMutex
+	path string // 为空表示不可用
+	dev  uint64
+	ino  uint64
+	gen  uint64
+	// onMiss 不可用时有人访问,通知 manager 重新检查(异步、限速)
+	onMiss func()
+}
+
+func newSource() *source { return &source{} }
+
+// set 更新源目录(path 为空表示不可用),返回是否有变化
+func (s *source) set(path string, dev, ino uint64) bool {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.path == path && s.dev == dev && s.ino == ino {
+		return false
+	}
+	s.path, s.dev, s.ino = path, dev, ino
+	s.gen++
+	return true
+}
+
+func (s *source) close() { s.set("", 0, 0) }
+
+func (s *source) miss() syscall.Errno {
+	if s.onMiss != nil {
+		go s.onMiss()
+	}
+	return syscall.EIO
+}
+
+// openRoot 打开源目录并确认还是登记时的那个目录(设备拔出后,原路径可能露出挂载点下面的其他文件)
+func openRoot(path string, dev, ino uint64) (int, bool) {
+	how := unix.OpenHow{Flags: unix.O_PATH | unix.O_DIRECTORY | unix.O_CLOEXEC, Resolve: unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_NO_MAGICLINKS}
+	fd, err := unix.Openat2(unix.AT_FDCWD, path, &how)
+	if err != nil {
+		return -1, false
+	}
+	var st unix.Stat_t
+	if unix.Fstat(fd, &st) != nil || st.Dev != dev || st.Ino != ino {
+		unix.Close(fd)
+		return -1, false
+	}
+	return fd, true
+}
+
+// openat 相对源目录打开 rel("." 表示源目录本身)
+func (s *source) openat(rel string, flags int) (int, syscall.Errno) {
+	s.mu.RLock()
+	path, dev, ino := s.path, s.dev, s.ino
+	s.mu.RUnlock()
+	if path == "" {
+		return -1, s.miss()
+	}
+	root, ok := openRoot(path, dev, ino)
+	if !ok {
+		return -1, s.miss()
+	}
+	defer unix.Close(root)
+	how := unix.OpenHow{Flags: uint64(flags | unix.O_CLOEXEC | unix.O_NOFOLLOW), Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_NO_MAGICLINKS | unix.RESOLVE_NO_XDEV}
+	for {
+		fd, err := unix.Openat2(root, rel, &how)
+		if err == unix.EINTR || err == unix.EAGAIN {
+			continue
+		}
+		if err != nil {
+			errno := err.(syscall.Errno)
+			// 路径中出现符号链接、越界或跨到其他文件系统:对调用方表现为不存在
+			if errno == unix.ELOOP || errno == unix.EXDEV {
+				return -1, syscall.ENOENT
+			}
+			return -1, errno
+		}
+		return fd, 0
+	}
+}
+
+func (s *source) generation() uint64 {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+	return s.gen
+}
+
+type kbNode struct {
+	fs.Inode
+	src *source
+}
+
+var (
+	_ fs.NodeLookuper   = (*kbNode)(nil)
+	_ fs.NodeGetattrer  = (*kbNode)(nil)
+	_ fs.NodeOpener     = (*kbNode)(nil)
+	_ fs.NodeReaddirer  = (*kbNode)(nil)
+	_ fs.NodeReadlinker = (*kbNode)(nil)
+	_ fs.NodeStatfser   = (*kbNode)(nil)
+)
+
+func (n *kbNode) rel() string {
+	p := n.Path(n.Root())
+	if p == "" {
+		return "."
+	}
+	return p
+}
+
+func (n *kbNode) child(name string) string {
+	if r := n.rel(); r != "." {
+		return r + "/" + name
+	}
+	return name
+}
+
+func hidden(name string) bool { return strings.HasPrefix(name, ".") }
+
+// statRel 不跟随符号链接地获取属性
+func (n *kbNode) statRel(rel string) (unix.Stat_t, syscall.Errno) {
+	var st unix.Stat_t
+	fd, errno := n.src.openat(rel, unix.O_PATH)
+	if errno != 0 {
+		return st, errno
+	}
+	defer unix.Close(fd)
+	if err := unix.Fstat(fd, &st); err != nil {
+		return st, err.(syscall.Errno)
+	}
+	return st, 0
+}
+
+func permFor(mode uint32) uint32 {
+	switch mode & unix.S_IFMT {
+	case unix.S_IFDIR:
+		return 0o555
+	case unix.S_IFLNK:
+		return 0o777
+	default:
+		return 0o444
+	}
+}
+
+// inoFor 设备号参与计算,避免源目录下挂了其他文件系统时 inode 号冲突
+func inoFor(st *unix.Stat_t) uint64 {
+	return st.Ino ^ (uint64(unix.Major(st.Dev))<<56 | uint64(unix.Minor(st.Dev)&0xffff)<<40)
+}
+
+func fillAttr(st *unix.Stat_t, out *fuse.Attr) {
+	out.Ino = inoFor(st)
+	out.Size = uint64(st.Size)
+	out.Blocks = uint64(st.Blocks)
+	out.Blksize = uint32(st.Blksize)
+	out.Atime, out.Atimensec = uint64(st.Atim.Sec), uint32(st.Atim.Nsec)
+	out.Mtime, out.Mtimensec = uint64(st.Mtim.Sec), uint32(st.Mtim.Nsec)
+	out.Ctime, out.Ctimensec = uint64(st.Ctim.Sec), uint32(st.Ctim.Nsec)
+	out.Nlink = uint32(st.Nlink)
+	out.Mode = st.Mode&unix.S_IFMT | permFor(st.Mode)
+	out.Uid, out.Gid = 0, 0
+}
+
+func (n *kbNode) Lookup(ctx context.Context, name string, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
+	if hidden(name) {
+		return nil, syscall.ENOENT
+	}
+	st, errno := n.statRel(n.child(name))
+	if errno != 0 {
+		return nil, errno
+	}
+	fillAttr(&st, &out.Attr)
+	ch := n.NewInode(ctx, &kbNode{src: n.src}, fs.StableAttr{Mode: st.Mode & unix.S_IFMT, Ino: inoFor(&st), Gen: n.src.generation()})
+	return ch, 0
+}
+
+func (n *kbNode) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
+	if f, ok := fh.(*kbFile); ok {
+		return f.Getattr(ctx, out)
+	}
+	st, errno := n.statRel(n.rel())
+	if errno != 0 {
+		if n.IsRoot() {
+			// 不可用时根目录照常存在
+			now := uint64(time.Now().Unix())
+			out.Mode = unix.S_IFDIR | 0o555
+			out.Nlink = 2
+			out.Atime, out.Mtime, out.Ctime = now, now, now
+			return 0
+		}
+		return errno
+	}
+	fillAttr(&st, &out.Attr)
+	return 0
+}
+
+func (n *kbNode) Open(ctx context.Context, flags uint32) (fs.FileHandle, uint32, syscall.Errno) {
+	if flags&(unix.O_WRONLY|unix.O_RDWR|unix.O_TRUNC|unix.O_APPEND|unix.O_CREAT) != 0 {
+		return nil, 0, syscall.EROFS
+	}
+	fd, errno := n.src.openat(n.rel(), unix.O_RDONLY)
+	if errno != 0 {
+		return nil, 0, errno
+	}
+	return &kbFile{fd: fd}, 0, 0
+}
+
+func (n *kbNode) Readdir(ctx context.Context) (fs.DirStream, syscall.Errno) {
+	fd, errno := n.src.openat(n.rel(), unix.O_RDONLY|unix.O_DIRECTORY)
+	if errno != 0 {
+		return nil, errno
+	}
+	defer unix.Close(fd)
+	var dirSt unix.Stat_t
+	_ = unix.Fstat(fd, &dirSt)
+	var list []fuse.DirEntry
+	buf := make([]byte, 32*1024)
+	for {
+		nread, err := unix.Getdents(fd, buf)
+		if err == unix.EINTR {
+			continue
+		}
+		if err != nil {
+			return nil, err.(syscall.Errno)
+		}
+		if nread <= 0 {
+			break
+		}
+		for off := 0; off < nread; {
+			reclen, ino, typ, name := parseDirent(buf[off:nread])
+			if reclen == 0 {
+				break
+			}
+			off += reclen
+			if name == "" || hidden(name) {
+				continue
+			}
+			st := unix.Stat_t{Ino: ino, Dev: dirSt.Dev}
+			list = append(list, fuse.DirEntry{Name: name, Mode: direntMode(typ), Ino: inoFor(&st)})
+		}
+	}
+	return fs.NewListDirStream(list), 0
+}
+
+func (n *kbNode) Readlink(ctx context.Context) ([]byte, syscall.Errno) {
+	fd, errno := n.src.openat(n.rel(), unix.O_PATH)
+	if errno != 0 {
+		return nil, errno
+	}
+	defer unix.Close(fd)
+	buf := make([]byte, 4096)
+	k, err := unix.Readlinkat(fd, "", buf)
+	if err != nil {
+		return nil, err.(syscall.Errno)
+	}
+	return buf[:k], 0
+}
+
+func (n *kbNode) Statfs(ctx context.Context, out *fuse.StatfsOut) syscall.Errno {
+	fd, errno := n.src.openat(".", unix.O_PATH)
+	if errno != 0 {
+		return 0
+	}
+	defer unix.Close(fd)
+	var s unix.Statfs_t
+	if unix.Fstatfs(fd, &s) == nil {
+		out.Blocks, out.Bfree, out.Bavail = s.Blocks, 0, 0
+		out.Files, out.Ffree = s.Files, 0
+		out.Bsize, out.Frsize = uint32(s.Bsize), uint32(s.Frsize)
+		out.NameLen = uint32(s.Namelen)
+	}
+	return 0
+}
+
+// kbFile 只读文件句柄
+type kbFile struct {
+	mu sync.Mutex
+	fd int
+}
+
+var (
+	_ fs.FileReader    = (*kbFile)(nil)
+	_ fs.FileReleaser  = (*kbFile)(nil)
+	_ fs.FileGetattrer = (*kbFile)(nil)
+	_ fs.FileLseeker   = (*kbFile)(nil)
+)
+
+func (f *kbFile) Read(ctx context.Context, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) {
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	return fuse.ReadResultFd(uintptr(f.fd), off, len(dest)), 0
+}
+
+func (f *kbFile) Release(ctx context.Context) syscall.Errno {
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	if f.fd >= 0 {
+		unix.Close(f.fd)
+		f.fd = -1
+	}
+	return 0
+}
+
+func (f *kbFile) Getattr(ctx context.Context, out *fuse.AttrOut) syscall.Errno {
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	var st unix.Stat_t
+	if err := unix.Fstat(f.fd, &st); err != nil {
+		return err.(syscall.Errno)
+	}
+	fillAttr(&st, &out.Attr)
+	return 0
+}
+
+func (f *kbFile) Lseek(ctx context.Context, off uint64, whence uint32) (uint64, syscall.Errno) {
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	n, err := unix.Seek(f.fd, int64(off), int(whence))
+	if err != nil {
+		return 0, err.(syscall.Errno)
+	}
+	return uint64(n), 0
+}
+
+func direntMode(typ uint8) uint32 {
+	switch typ {
+	case unix.DT_DIR:
+		return unix.S_IFDIR
+	case unix.DT_LNK:
+		return unix.S_IFLNK
+	case unix.DT_FIFO:
+		return unix.S_IFIFO
+	case unix.DT_SOCK:
+		return unix.S_IFSOCK
+	case unix.DT_CHR:
+		return unix.S_IFCHR
+	case unix.DT_BLK:
+		return unix.S_IFBLK
+	default:
+		return unix.S_IFREG
+	}
+}
+
+// parseDirent 解析一条 linux_dirent64:d_ino(8) d_off(8) d_reclen(2) d_type(1) d_name
+func parseDirent(b []byte) (reclen int, ino uint64, typ uint8, name string) {
+	if len(b) < 19 {
+		return 0, 0, 0, ""
+	}
+	reclen = int(uint16(b[16]) | uint16(b[17])<<8)
+	if reclen < 19 || reclen > len(b) {
+		return 0, 0, 0, ""
+	}
+	for i := 0; i < 8; i++ {
+		ino |= uint64(b[i]) << (8 * i)
+	}
+	typ = b[18]
+	nb := b[19:reclen]
+	if i := indexZero(nb); i >= 0 {
+		nb = nb[:i]
+	}
+	name = string(nb)
+	if name == "." || name == ".." {
+		name = ""
+	}
+	return
+}
+
+func indexZero(b []byte) int {
+	for i, c := range b {
+		if c == 0 {
+			return i
+		}
+	}
+	return -1
+}
+
+// mountFS 把源目录挂到 mountpoint
+func mountFS(mountpoint, name string, src *source) (*fuse.Server, error) {
+	sec := time.Second
+	root := &kbNode{src: src}
+	return fs.Mount(mountpoint, root, &fs.Options{
+		EntryTimeout:    &sec,
+		AttrTimeout:     &sec,
+		NegativeTimeout: &sec,
+		MountOptions: fuse.MountOptions{
+			AllowOther: true,
+			FsName:     name,
+			Name:       "agentkb",
+			Options:    []string{"ro", "nosuid", "nodev", "noexec"},
+			MaxReadAhead: 128 * 1024,
+		},
+	})
+}

+ 202 - 0
apps/mounter/fs_test.go

@@ -0,0 +1,202 @@
+package main
+
+// 真实挂载测试:需要 /dev/fuse、fusermount3,以及 /etc/fuse.conf 中的 user_allow_other
+
+import (
+	"bufio"
+	"encoding/json"
+	"errors"
+	"net"
+	"os"
+	"path/filepath"
+	"sort"
+	"testing"
+	"time"
+)
+
+func needFuse(t *testing.T) {
+	if _, err := os.Stat("/dev/fuse"); err != nil {
+		t.Skip("没有 /dev/fuse")
+	}
+}
+
+func names(t *testing.T, dir string) []string {
+	ents, err := os.ReadDir(dir)
+	if err != nil {
+		t.Fatalf("读取 %s:%v", dir, err)
+	}
+	var out []string
+	for _, e := range ents {
+		out = append(out, e.Name())
+	}
+	sort.Strings(out)
+	return out
+}
+
+func TestMountedFilesystem(t *testing.T) {
+	needFuse(t)
+	base, roots := tempRoots(t)
+	src := filepath.Join(roots[0], "data", "docs")
+	outside := filepath.Join(base, "outside")
+	os.MkdirAll(outside, 0o755)
+	os.WriteFile(filepath.Join(outside, "b.txt"), []byte("机密"), 0o600)
+	os.WriteFile(filepath.Join(src, "a.txt"), []byte("资料内容"), 0o600)
+	os.WriteFile(filepath.Join(src, ".env"), []byte("SECRET=1"), 0o600)
+	os.MkdirAll(filepath.Join(src, "sub", ".git"), 0o700)
+	os.WriteFile(filepath.Join(src, "sub", ".git", "config"), []byte("x"), 0o600)
+	os.WriteFile(filepath.Join(src, "sub", "b.txt"), []byte("子目录"), 0o600)
+	os.Symlink("a.txt", filepath.Join(src, "link"))
+
+	mntDir := filepath.Join(base, "kbmnt")
+	os.MkdirAll(mntDir, 0o755)
+	m := newManager(realSystem{}, roots, mntDir, filepath.Join(base, "state.json"))
+	defer m.unmountAll()
+
+	st, err := m.sync([]mountSpec{{ID: "kb_t1", Kind: "local", Local: &localSource{Path: src}}}, true)
+	if err != nil || len(st) != 1 || st[0].State != "ok" {
+		t.Fatalf("挂载:%+v %v", st, err)
+	}
+	mp := filepath.Join(mntDir, "kb_t1")
+
+	// 隐藏文件不出现、按路径也访问不到
+	if got := names(t, mp); len(got) != 3 || got[0] != "a.txt" || got[1] != "link" || got[2] != "sub" {
+		t.Errorf("目录列表:%v", got)
+	}
+	if got := names(t, filepath.Join(mp, "sub")); len(got) != 1 {
+		t.Errorf("子目录列表:%v", got)
+	}
+	for _, p := range []string{".env", "sub/.git", "sub/.git/config"} {
+		if _, err := os.Stat(filepath.Join(mp, p)); !errors.Is(err, os.ErrNotExist) {
+			t.Errorf("%s 应该不存在:%v", p, err)
+		}
+	}
+	// 读取、权限、符号链接
+	if b, err := os.ReadFile(filepath.Join(mp, "a.txt")); err != nil || string(b) != "资料内容" {
+		t.Errorf("读取:%q %v", b, err)
+	}
+	if b, err := os.ReadFile(filepath.Join(mp, "link")); err != nil || string(b) != "资料内容" {
+		t.Errorf("相对符号链接:%q %v", b, err)
+	}
+	fi, _ := os.Stat(filepath.Join(mp, "a.txt"))
+	if fi.Mode().Perm() != 0o444 {
+		t.Errorf("文件权限应为 0444:%v", fi.Mode())
+	}
+	di, _ := os.Stat(filepath.Join(mp, "sub"))
+	if di.Mode().Perm() != 0o555 {
+		t.Errorf("目录权限应为 0555:%v", di.Mode())
+	}
+	// 只读
+	if err := os.WriteFile(filepath.Join(mp, "new.txt"), []byte("x"), 0o644); err == nil {
+		t.Error("应该不能写入")
+	}
+	if f, err := os.OpenFile(filepath.Join(mp, "a.txt"), os.O_WRONLY, 0); err == nil {
+		f.Close()
+		t.Error("应该不能以写方式打开")
+	}
+
+	// 访问过程中目录被替换为指向外部的符号链接:不会读到外部内容
+	if _, err := os.Stat(filepath.Join(mp, "sub")); err != nil {
+		t.Fatal(err)
+	}
+	os.Rename(filepath.Join(src, "sub"), filepath.Join(src, "sub.bak"))
+	os.Symlink(outside, filepath.Join(src, "sub"))
+	if b, err := os.ReadFile(filepath.Join(mp, "sub", "b.txt")); err == nil {
+		t.Errorf("不应读到源目录以外的内容:%q", b)
+	}
+	os.Remove(filepath.Join(src, "sub"))
+	os.Rename(filepath.Join(src, "sub.bak"), filepath.Join(src, "sub"))
+
+	// 不长期持有源目录的句柄(否则设备无法卸载)
+	fds, _ := os.ReadDir("/proc/self/fd")
+	for _, fd := range fds {
+		if target, _ := os.Readlink(filepath.Join("/proc/self/fd", fd.Name())); target != "" && within(target, src) {
+			t.Errorf("仍打开着源目录中的 %s", target)
+		}
+	}
+
+	// 原路径上换成了另一个目录(例如设备拔出后露出挂载点下面的目录):重新检查之前不提供其中的内容
+	os.Rename(src, src+".orig")
+	os.MkdirAll(src, 0o755)
+	os.WriteFile(filepath.Join(src, "other.txt"), []byte("挂载点下面的文件"), 0o644)
+	time.Sleep(1100 * time.Millisecond)
+	if b, err := os.ReadFile(filepath.Join(mp, "other.txt")); err == nil {
+		t.Errorf("不应提供另一个目录的内容:%q", b)
+	}
+	os.RemoveAll(src)
+	os.Rename(src+".orig", src)
+	m.refreshAll()
+	time.Sleep(1100 * time.Millisecond)
+
+	// 源目录不存在:状态变为 path_missing,读取返回错误;恢复后自动可用
+	os.Rename(src, src+".moved")
+	m.refreshAll()
+	if s := m.status()[0]; s.State != "path_missing" {
+		t.Errorf("源目录移走后:%+v", s)
+	}
+	if _, err := os.ReadDir(mp); err == nil {
+		t.Error("不可用时读取应该出错")
+	}
+	if _, err := os.Stat(mp); err != nil {
+		t.Errorf("不可用时挂载点根目录照常存在:%v", err)
+	}
+	os.Rename(src+".moved", src)
+	m.refreshAll()
+	time.Sleep(1100 * time.Millisecond) // 等内核缓存过期
+	if s := m.status()[0]; s.State != "ok" {
+		t.Errorf("恢复后:%+v", s)
+	}
+	if got := names(t, mp); len(got) != 3 {
+		t.Errorf("恢复后目录列表:%v", got)
+	}
+
+	// state.json 已保存;同步空列表时卸载并删除挂载点
+	if b, err := os.ReadFile(filepath.Join(base, "state.json")); err != nil || !contains(string(b), "kb_t1") {
+		t.Errorf("state.json:%s %v", b, err)
+	}
+	if _, err := m.sync(nil, true); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := os.Stat(mp); !errors.Is(err, os.ErrNotExist) {
+		t.Errorf("卸载后挂载点应删除:%v", err)
+	}
+}
+
+func TestSyncRejectsBadIDs(t *testing.T) {
+	m := newManager(realSystem{}, allowedRoots, t.TempDir(), filepath.Join(t.TempDir(), "s.json"))
+	for _, id := range []string{"../etc", "kb_A", "x", "kb_a/b"} {
+		if _, err := m.sync([]mountSpec{{ID: id, Kind: "local", Local: &localSource{Path: "/mnt/x"}}}, false); err == nil {
+			t.Errorf("%q 应该被拒绝", id)
+		}
+	}
+}
+
+func TestServerToken(t *testing.T) {
+	_, roots := tempRoots(t)
+	s := &server{token: "secret", mgr: newManager(realSystem{}, roots, t.TempDir(), filepath.Join(t.TempDir(), "s.json")), version: "t"}
+	call := func(req map[string]any) map[string]any {
+		a, b := net.Pipe()
+		go s.serveConn(b)
+		defer a.Close()
+		line, _ := json.Marshal(req)
+		a.Write(append(line, '\n'))
+		resp, err := bufio.NewReader(a).ReadBytes('\n')
+		if err != nil {
+			t.Fatal(err)
+		}
+		var out map[string]any
+		json.Unmarshal(resp, &out)
+		return out
+	}
+	if r := call(map[string]any{"token": "wrong", "op": "ping"}); r["ok"] != false || r["error"] != "密钥不正确" {
+		t.Errorf("错误的密钥:%v", r)
+	}
+	if r := call(map[string]any{"op": "status"}); r["ok"] != false {
+		t.Errorf("没有密钥:%v", r)
+	}
+	if r := call(map[string]any{"token": "secret", "op": "ping"}); r["ok"] != true {
+		t.Errorf("正确的密钥:%v", r)
+	}
+	if r := call(map[string]any{"token": "secret", "op": "check", "path": "/etc"}); r["ok"] != false || r["state"] != "not_allowed" {
+		t.Errorf("检查范围外的目录:%v", r)
+	}
+}

+ 8 - 0
apps/mounter/go.mod

@@ -0,0 +1,8 @@
+module agent-server/mounter
+
+go 1.26.4
+
+require (
+	github.com/hanwen/go-fuse/v2 v2.12.1
+	golang.org/x/sys v0.28.0
+)

+ 8 - 0
apps/mounter/go.sum

@@ -0,0 +1,8 @@
+github.com/hanwen/go-fuse/v2 v2.12.1 h1:b6NXOPCraS9NvN4vtHZXDN1nsNKdoDlyafWxWnuO30Q=
+github.com/hanwen/go-fuse/v2 v2.12.1/go.mod h1:Jhm1VRAQoI4MYiawzVhc0K3StCLBob07TTT37CwmNpM=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
+github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
+golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
+golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

+ 79 - 0
apps/mounter/main.go

@@ -0,0 +1,79 @@
+// agent-mounter:资料库挂载服务(docs/design/21-knowledge-sources.md §4)。
+// 以部署用户运行,把本机目录(以后还有远程存储)只读挂到 knowledge/mnt/<id>,隐藏以 . 开头的文件。
+package main
+
+import (
+	"flag"
+	"log"
+	"net"
+	"os"
+	"os/signal"
+	"path/filepath"
+	"syscall"
+
+	"golang.org/x/sys/unix"
+)
+
+const version = "1.0.0"
+
+func main() {
+	dir := flag.String("dir", "/srv/agent-server/mounter", "挂载服务目录(socket、密钥、state.json)")
+	mnt := flag.String("mnt", "/srv/agent-server/knowledge/mnt", "挂载点所在目录")
+	showVersion := flag.Bool("version", false, "显示版本")
+	flag.Parse()
+	if *showVersion {
+		println(version)
+		return
+	}
+	log.SetFlags(0)
+	log.SetOutput(os.Stderr)
+
+	var st unix.Stat_t
+	if err := unix.Stat(*dir, &st); err != nil {
+		log.Fatalf("挂载服务目录不存在:%s(先执行 deploy/deploy.sh)", *dir)
+	}
+	gid := int(st.Gid)
+	if err := unix.Stat(*mnt, &st); err != nil {
+		log.Fatalf("挂载点目录不存在:%s", *mnt)
+	}
+	unix.Umask(0o027)
+
+	mgr := newManager(realSystem{}, allowedRoots, filepath.Clean(*mnt), filepath.Join(*dir, "state.json"))
+	mgr.cleanupStale()
+	if specs := mgr.loadState(); len(specs) > 0 {
+		if _, err := mgr.sync(specs, false); err != nil {
+			log.Printf("按 state.json 恢复挂载失败:%v", err)
+		} else {
+			log.Printf("已按 state.json 恢复 %d 个挂载", len(specs))
+		}
+	}
+	stop := make(chan struct{})
+	go mgr.watchMountinfo(stop)
+
+	srv := &server{token: newToken(), mgr: mgr, version: version}
+	sock := filepath.Join(*dir, "mounter.sock")
+	_ = os.Remove(sock)
+	l, err := net.Listen("unix", sock)
+	if err != nil {
+		log.Fatalf("监听 %s 失败:%v", sock, err)
+	}
+	if err := os.Chown(sock, -1, gid); err != nil {
+		log.Fatalf("设置 socket 的组失败:%v", err)
+	}
+	if err := os.Chmod(sock, 0o660); err != nil {
+		log.Fatalf("设置 socket 权限失败:%v", err)
+	}
+	if err := writeGroupFile(filepath.Join(*dir, "token"), []byte(srv.token+"\n"), 0o640, gid); err != nil {
+		log.Fatalf("写入密钥失败:%v", err)
+	}
+	go srv.serve(l)
+	log.Printf("agent-mounter %s 已启动:%s", version, sock)
+
+	sig := make(chan os.Signal, 1)
+	signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT)
+	<-sig
+	close(stop)
+	l.Close()
+	mgr.unmountAll()
+	log.Printf("已停止")
+}

+ 334 - 0
apps/mounter/manager.go

@@ -0,0 +1,334 @@
+package main
+
+// 挂载管理(docs/design/21-knowledge-sources.md §4.4、§4.5):
+// 平台用 sync 下发完整的登记列表;挂载进程挂上缺少的、卸载多余的、更新有变化的。
+// 挂载表变化时(设备插拔)立即重新计算状态,不需要定时检查。
+
+import (
+	"encoding/json"
+	"fmt"
+	"log"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"regexp"
+	"sort"
+	"sync"
+	"time"
+
+	"github.com/hanwen/go-fuse/v2/fuse"
+	"golang.org/x/sys/unix"
+)
+
+var idPattern = regexp.MustCompile(`^kb_[0-9a-z]{1,64}$`)
+
+type mountSpec struct {
+	ID    string       `json:"id"`
+	Kind  string       `json:"kind"`
+	Local *localSource `json:"local,omitempty"`
+}
+
+type mountStatus struct {
+	ID      string  `json:"id"`
+	State   string  `json:"state"`
+	Message *string `json:"message"`
+	Since   string  `json:"since"`
+	// 当前的实际路径(本机目录)
+	RealPath string `json:"realPath,omitempty"`
+}
+
+type mount struct {
+	spec     mountSpec
+	point    string
+	src      *source
+	server   *fuse.Server
+	state    string
+	message  string
+	since    time.Time
+	realPath string
+}
+
+type manager struct {
+	mu        sync.Mutex
+	sys       system
+	roots     []string
+	mntDir    string
+	stateFile string
+	mounts    map[string]*mount
+	lastMiss  time.Time
+	// mountFn 便于测试替换
+	mountFn func(point, name string, src *source) (*fuse.Server, error)
+}
+
+func newManager(sys system, roots []string, mntDir, stateFile string) *manager {
+	return &manager{sys: sys, roots: roots, mntDir: mntDir, stateFile: stateFile, mounts: map[string]*mount{}, mountFn: mountFS}
+}
+
+func (m *manager) setState(mt *mount, state, msg string) {
+	if mt.state != state || mt.message != msg {
+		if mt.state != "" {
+			log.Printf("%s:%s → %s %s", mt.spec.ID, mt.state, state, msg)
+		}
+		mt.state, mt.message, mt.since = state, msg, time.Now()
+	}
+}
+
+// resolve 按登记找到源目录并检查,返回真实路径与设备号、inode 号
+func (m *manager) resolve(spec mountSpec) (string, uint64, uint64, *checkError) {
+	if spec.Kind != "local" || spec.Local == nil {
+		return "", 0, 0, &checkError{"mount_failed", "暂不支持这种资料库类型"}
+	}
+	p, cerr := locate(m.sys, *spec.Local)
+	if cerr != nil {
+		return "", 0, 0, cerr
+	}
+	real, cerr := checkSource(p, m.roots)
+	if cerr != nil {
+		return "", 0, 0, cerr
+	}
+	how := unix.OpenHow{Flags: unix.O_PATH | unix.O_DIRECTORY | unix.O_CLOEXEC, Resolve: unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_NO_MAGICLINKS}
+	fd, err := unix.Openat2(unix.AT_FDCWD, real, &how)
+	if err != nil {
+		return "", 0, 0, &checkError{"path_missing", "无法打开目录:" + real}
+	}
+	defer unix.Close(fd)
+	var st unix.Stat_t
+	if err := unix.Fstat(fd, &st); err != nil {
+		return "", 0, 0, &checkError{"path_missing", "无法读取目录:" + real}
+	}
+	return real, st.Dev, st.Ino, nil
+}
+
+// refreshLocked 重新计算一个挂载的状态
+func (m *manager) refreshLocked(mt *mount) {
+	real, dev, ino, cerr := m.resolve(mt.spec)
+	if cerr != nil {
+		mt.src.close()
+		mt.realPath = ""
+		m.setState(mt, cerr.State, cerr.Message)
+		return
+	}
+	mt.src.set(real, dev, ino)
+	mt.realPath = real
+	if mt.server == nil {
+		m.setState(mt, "mount_failed", "挂载失败")
+		return
+	}
+	m.setState(mt, "ok", "")
+}
+
+func (m *manager) refreshAll() {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	for _, mt := range m.mounts {
+		m.refreshLocked(mt)
+	}
+}
+
+// onMiss 资料库不可用时有人访问:可能是设备刚接上(如自动挂载),限速重新检查
+func (m *manager) onMiss() {
+	m.mu.Lock()
+	if time.Since(m.lastMiss) < 2*time.Second {
+		m.mu.Unlock()
+		return
+	}
+	m.lastMiss = time.Now()
+	m.mu.Unlock()
+	go m.refreshAll()
+}
+
+func (m *manager) mountLocked(mt *mount) {
+	if mt.server != nil {
+		return
+	}
+	if err := os.MkdirAll(mt.point, 0o755); err != nil {
+		log.Printf("%s:创建挂载点失败:%v", mt.spec.ID, err)
+		return
+	}
+	srv, err := m.mountFn(mt.point, "kb:"+mt.spec.ID, mt.src)
+	if err != nil {
+		log.Printf("%s:挂载失败:%v", mt.spec.ID, err)
+		return
+	}
+	mt.server = srv
+}
+
+func (m *manager) unmountLocked(mt *mount) {
+	if mt.server != nil {
+		if err := mt.server.Unmount(); err != nil {
+			// 有文件正被打开:延迟卸载
+			_ = exec.Command("fusermount3", "-uz", mt.point).Run()
+		}
+		mt.server = nil
+	}
+	mt.src.close()
+	_ = os.Remove(mt.point)
+}
+
+// sync 按完整列表挂载、卸载、更新
+func (m *manager) sync(specs []mountSpec, save bool) ([]mountStatus, error) {
+	want := map[string]mountSpec{}
+	for _, s := range specs {
+		if !idPattern.MatchString(s.ID) {
+			return nil, fmt.Errorf("资料库 ID 格式不正确:%q", s.ID)
+		}
+		if s.Kind == "local" && (s.Local == nil || !filepath.IsAbs(s.Local.Path)) {
+			return nil, fmt.Errorf("%s:缺少本机目录", s.ID)
+		}
+		want[s.ID] = s
+	}
+	m.mu.Lock()
+	for id, mt := range m.mounts {
+		if _, ok := want[id]; !ok {
+			m.unmountLocked(mt)
+			delete(m.mounts, id)
+			log.Printf("%s:已卸载", id)
+		}
+	}
+	for id, s := range want {
+		mt := m.mounts[id]
+		if mt == nil {
+			mt = &mount{spec: s, point: filepath.Join(m.mntDir, id), src: newSource()}
+			mt.src.onMiss = m.onMiss
+			m.mounts[id] = mt
+			log.Printf("%s:登记 %s", id, describeSpec(s))
+		} else {
+			mt.spec = s
+		}
+		m.mountLocked(mt)
+		m.refreshLocked(mt)
+	}
+	m.mu.Unlock()
+	if save {
+		if err := m.saveState(specs); err != nil {
+			log.Printf("保存 state.json 失败:%v", err)
+		}
+	}
+	return m.status(), nil
+}
+
+func describeSpec(s mountSpec) string {
+	if s.Local != nil {
+		if s.Local.UUID != "" {
+			return fmt.Sprintf("%s(UUID %s,设备内 %s)", s.Local.Path, s.Local.UUID, s.Local.RelPath)
+		}
+		return s.Local.Path
+	}
+	return s.Kind
+}
+
+func (m *manager) status() []mountStatus {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	out := make([]mountStatus, 0, len(m.mounts))
+	for id, mt := range m.mounts {
+		st := mountStatus{ID: id, State: mt.state, Since: mt.since.UTC().Format(time.RFC3339), RealPath: mt.realPath}
+		if mt.message != "" {
+			msg := mt.message
+			st.Message = &msg
+		}
+		out = append(out, st)
+	}
+	sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
+	return out
+}
+
+func (m *manager) unmountAll() {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	for id, mt := range m.mounts {
+		m.unmountLocked(mt)
+		delete(m.mounts, id)
+	}
+}
+
+type stateFile struct {
+	Mounts []mountSpec `json:"mounts"`
+}
+
+func (m *manager) saveState(specs []mountSpec) error {
+	b, err := json.MarshalIndent(stateFile{Mounts: specs}, "", "  ")
+	if err != nil {
+		return err
+	}
+	tmp := m.stateFile + ".tmp"
+	if err := os.WriteFile(tmp, b, 0o600); err != nil {
+		return err
+	}
+	return os.Rename(tmp, m.stateFile)
+}
+
+func (m *manager) loadState() []mountSpec {
+	b, err := os.ReadFile(m.stateFile)
+	if err != nil {
+		return nil
+	}
+	var s stateFile
+	if json.Unmarshal(b, &s) != nil {
+		log.Printf("state.json 格式不正确,忽略")
+		return nil
+	}
+	return s.Mounts
+}
+
+// cleanupStale 启动时清理 mnt/ 下残留的挂载(上次进程退出后失效)和多余的空目录
+func (m *manager) cleanupStale() {
+	for _, e := range m.sys.mountinfo() {
+		if filepath.Dir(e.Point) == m.mntDir {
+			if err := exec.Command("fusermount3", "-uz", e.Point).Run(); err != nil {
+				log.Printf("清理残留挂载 %s 失败:%v", e.Point, err)
+			} else {
+				log.Printf("已清理残留挂载 %s", e.Point)
+			}
+		}
+	}
+	ents, _ := os.ReadDir(m.mntDir)
+	for _, e := range ents {
+		if idPattern.MatchString(e.Name()) {
+			_ = os.Remove(filepath.Join(m.mntDir, e.Name()))
+		}
+	}
+}
+
+// watchMountinfo 挂载表变化时(POLLPRI)重新计算状态
+func (m *manager) watchMountinfo(stop <-chan struct{}) {
+	f, err := os.Open("/proc/self/mountinfo")
+	if err != nil {
+		log.Printf("无法监听挂载表:%v", err)
+		return
+	}
+	defer f.Close()
+	buf := make([]byte, 64*1024)
+	drain := func() {
+		_, _ = f.Seek(0, 0)
+		for {
+			n, err := f.Read(buf)
+			if n == 0 || err != nil {
+				return
+			}
+		}
+	}
+	drain()
+	fds := []unix.PollFd{{Fd: int32(f.Fd()), Events: unix.POLLPRI}}
+	for {
+		select {
+		case <-stop:
+			return
+		default:
+		}
+		n, err := unix.Poll(fds, 1000)
+		if err != nil && err != unix.EINTR {
+			log.Printf("监听挂载表出错:%v", err)
+			time.Sleep(time.Second)
+			continue
+		}
+		if n <= 0 || fds[0].Revents&(unix.POLLPRI|unix.POLLERR) == 0 {
+			continue
+		}
+		drain()
+		// 设备插入时往往连续变化几次,稍等一下再统一计算
+		time.Sleep(300 * time.Millisecond)
+		drain()
+		m.refreshAll()
+	}
+}

+ 193 - 0
apps/mounter/rules.go

@@ -0,0 +1,193 @@
+package main
+
+// 本机目录的访问范围(docs/design/21-knowledge-sources.md §4.3)。
+// 允许的根目录写死在代码中,不接受平台传来的配置。
+
+import (
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+
+	"golang.org/x/sys/unix"
+)
+
+var allowedRoots = []string{"/home", "/mnt", "/media"}
+
+// ruleProblem 按 §4.3 检查一个已解析符号链接的绝对路径,通过时返回空字符串。
+func ruleProblem(real string, roots []string) string {
+	if !filepath.IsAbs(real) || filepath.Clean(real) != real {
+		return "路径必须是规范的绝对路径"
+	}
+	root := ""
+	for _, r := range roots {
+		if real == r || strings.HasPrefix(real, r+"/") {
+			root = r
+			break
+		}
+	}
+	if root == "" {
+		return "只能选择 " + strings.Join(roots, "、") + " 下的目录"
+	}
+	if real == root {
+		return "不能选择 " + root + " 本身,请选择其中的子目录"
+	}
+	rest := strings.Split(strings.TrimPrefix(real, root+"/"), "/")
+	if root == "/home" && len(rest) == 1 {
+		return "不能选择用户主目录本身,请选择其中的子目录"
+	}
+	if hasHidden(real) {
+		return "不能选择隐藏目录(以 . 开头)或其中的目录"
+	}
+	return ""
+}
+
+func hasHidden(p string) bool {
+	for _, seg := range strings.Split(p, "/") {
+		if strings.HasPrefix(seg, ".") {
+			return true
+		}
+	}
+	return false
+}
+
+// inBrowseRange:逐级选择目录时可以进入的范围(允许的根目录本身与 home 目录本身也可以进入,但不能选择)
+func inBrowseRange(real string, roots []string) bool {
+	if hasHidden(real) {
+		return false
+	}
+	for _, r := range roots {
+		if real == r || strings.HasPrefix(real, r+"/") {
+			return true
+		}
+	}
+	return false
+}
+
+// checkError 带状态码的检查失败
+type checkError struct {
+	State   string // not_allowed | path_missing
+	Message string
+}
+
+func (e *checkError) Error() string { return e.Message }
+
+// checkSource 检查要登记(或解析出的)本机目录,返回解析符号链接后的真实路径
+func checkSource(p string, roots []string) (string, *checkError) {
+	if !filepath.IsAbs(p) {
+		return "", &checkError{"not_allowed", "请填写绝对路径"}
+	}
+	p = filepath.Clean(p)
+	if hasHidden(p) {
+		return "", &checkError{"not_allowed", "不能选择隐藏目录(以 . 开头)或其中的目录"}
+	}
+	real, err := filepath.EvalSymlinks(p)
+	if err != nil {
+		return "", &checkError{"path_missing", "目录不存在:" + p}
+	}
+	if msg := ruleProblem(real, roots); msg != "" {
+		if real != p {
+			msg += "(实际位置:" + real + ")"
+		}
+		return "", &checkError{"not_allowed", msg}
+	}
+	st, err := os.Stat(real)
+	if err != nil {
+		return "", &checkError{"path_missing", "目录不存在:" + p}
+	}
+	if !st.IsDir() {
+		return "", &checkError{"not_allowed", "不是目录:" + p}
+	}
+	if unix.Access(real, unix.R_OK|unix.X_OK) != nil {
+		return "", &checkError{"not_allowed", "挂载服务没有读取这个目录的权限:" + p}
+	}
+	return real, nil
+}
+
+type browseEntry struct {
+	Name       string `json:"name"`
+	Path       string `json:"path"`
+	Selectable bool   `json:"selectable"`
+	Reason     string `json:"reason,omitempty"`
+}
+
+type browseResult struct {
+	Path    string        `json:"path"`
+	Parent  string        `json:"parent"`
+	Entries []browseEntry `json:"entries"`
+}
+
+const maxBrowseEntries = 2000
+
+// browse 列出可选择的子目录:隐藏以 . 开头的,标出不能选择的及原因。path 为空时列出允许的根目录。
+func browse(p string, roots []string) (*browseResult, *checkError) {
+	if p == "" || p == "/" {
+		res := &browseResult{Path: "", Parent: "", Entries: []browseEntry{}}
+		for _, r := range roots {
+			if st, err := os.Stat(r); err == nil && st.IsDir() {
+				res.Entries = append(res.Entries, browseEntry{Name: r, Path: r, Reason: "请进入后选择子目录"})
+			}
+		}
+		return res, nil
+	}
+	if !filepath.IsAbs(p) {
+		return nil, &checkError{"not_allowed", "请填写绝对路径"}
+	}
+	p = filepath.Clean(p)
+	real, err := filepath.EvalSymlinks(p)
+	if err != nil {
+		return nil, &checkError{"path_missing", "目录不存在:" + p}
+	}
+	if hasHidden(p) || !inBrowseRange(real, roots) {
+		return nil, &checkError{"not_allowed", "只能浏览 " + strings.Join(roots, "、") + " 下的目录"}
+	}
+	f, err := os.Open(real)
+	if err != nil {
+		return nil, &checkError{"not_allowed", "没有读取权限:" + p}
+	}
+	names, err := f.Readdirnames(-1)
+	f.Close()
+	if err != nil {
+		return nil, &checkError{"not_allowed", "无法读取目录:" + p}
+	}
+	sort.Strings(names)
+	parent := filepath.Dir(p)
+	isRoot := false
+	for _, r := range roots {
+		if p == r {
+			isRoot = true
+		}
+	}
+	if isRoot {
+		parent = ""
+	}
+	res := &browseResult{Path: p, Parent: parent, Entries: []browseEntry{}}
+	for _, name := range names {
+		if strings.HasPrefix(name, ".") {
+			continue
+		}
+		child := filepath.Join(p, name)
+		st, err := os.Stat(child)
+		if err != nil || !st.IsDir() {
+			continue
+		}
+		e := browseEntry{Name: name, Path: child, Selectable: true}
+		childReal, err := filepath.EvalSymlinks(child)
+		if err != nil {
+			continue
+		}
+		if msg := ruleProblem(childReal, roots); msg != "" {
+			e.Selectable, e.Reason = false, msg
+		} else if unix.Access(childReal, unix.R_OK|unix.X_OK) != nil {
+			e.Selectable, e.Reason = false, "没有读取权限"
+		}
+		if childReal != child && e.Selectable {
+			e.Reason = "链接到 " + childReal
+		}
+		res.Entries = append(res.Entries, e)
+		if len(res.Entries) >= maxBrowseEntries {
+			break
+		}
+	}
+	return res, nil
+}

+ 119 - 0
apps/mounter/rules_test.go

@@ -0,0 +1,119 @@
+package main
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func TestRuleProblem(t *testing.T) {
+	roots := []string{"/home", "/mnt", "/media"}
+	ok := []string{"/home/u/docs", "/mnt/nas/a", "/media/u/Data/资料", "/mnt/x"}
+	bad := map[string]string{
+		"/etc":               "只能选择",
+		"/home":              "本身",
+		"/mnt":               "本身",
+		"/home/u":            "用户主目录",
+		"/home/u/.ssh":       "隐藏",
+		"/home/u/a/.git/x":   "隐藏",
+		"/homex/u/a":         "只能选择",
+		"/home/u/../../etc":  "规范",
+		"relative/path":      "规范",
+		"/srv/agent-server":  "只能选择",
+		"/media/u/Data/.env": "隐藏",
+	}
+	for _, p := range ok {
+		if msg := ruleProblem(p, roots); msg != "" {
+			t.Errorf("%s 应该允许,实际:%s", p, msg)
+		}
+	}
+	for p, want := range bad {
+		if msg := ruleProblem(p, roots); msg == "" || !contains(msg, want) {
+			t.Errorf("%s 应该拒绝(%s),实际:%q", p, want, msg)
+		}
+	}
+}
+
+func contains(s, sub string) bool { return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0) }
+func indexOf(s, sub string) int {
+	for i := 0; i+len(sub) <= len(s); i++ {
+		if s[i:i+len(sub)] == sub {
+			return i
+		}
+	}
+	return -1
+}
+
+// 用临时目录模拟允许的根目录
+func tempRoots(t *testing.T) (string, []string) {
+	base, _ := filepath.EvalSymlinks(t.TempDir())
+	root := filepath.Join(base, "mnt")
+	if err := os.MkdirAll(filepath.Join(root, "data", "docs"), 0o755); err != nil {
+		t.Fatal(err)
+	}
+	return base, []string{root}
+}
+
+func TestCheckSourceAndBrowse(t *testing.T) {
+	base, roots := tempRoots(t)
+	root := roots[0]
+	os.MkdirAll(filepath.Join(root, "data", ".hidden"), 0o755)
+	os.MkdirAll(filepath.Join(base, "outside"), 0o755)
+	os.Symlink(filepath.Join(base, "outside"), filepath.Join(root, "escape"))
+	os.Symlink(filepath.Join(root, "data", "docs"), filepath.Join(root, "inner"))
+	os.WriteFile(filepath.Join(root, "file.txt"), []byte("x"), 0o644)
+
+	if real, err := checkSource(filepath.Join(root, "data", "docs"), roots); err != nil || real != filepath.Join(root, "data", "docs") {
+		t.Fatalf("应该通过:%v %v", real, err)
+	}
+	if real, err := checkSource(filepath.Join(root, "inner"), roots); err != nil || real != filepath.Join(root, "data", "docs") {
+		t.Fatalf("范围内的符号链接解析为真实路径:%v %v", real, err)
+	}
+	cases := map[string]string{
+		filepath.Join(root, "escape"):          "not_allowed",
+		filepath.Join(root, "data", ".hidden"): "not_allowed",
+		filepath.Join(root, "file.txt"):        "not_allowed",
+		filepath.Join(root, "nope"):            "path_missing",
+		root:                                   "not_allowed",
+		"relative":                             "not_allowed",
+	}
+	for p, state := range cases {
+		if _, err := checkSource(p, roots); err == nil || err.State != state {
+			t.Errorf("%s 应该是 %s,实际 %v", p, state, err)
+		}
+	}
+
+	res, cerr := browse(root, roots)
+	if cerr != nil {
+		t.Fatal(cerr)
+	}
+	got := map[string]browseEntry{}
+	for _, e := range res.Entries {
+		got[e.Name] = e
+	}
+	if _, ok := got["file.txt"]; ok {
+		t.Error("文件不应出现在目录选择中")
+	}
+	if !got["data"].Selectable || got["escape"].Selectable || !got["inner"].Selectable {
+		t.Errorf("可选状态不正确:%+v", got)
+	}
+	if res.Parent != "" {
+		t.Errorf("根目录的上级应为空:%q", res.Parent)
+	}
+	sub, _ := browse(filepath.Join(root, "data"), roots)
+	for _, e := range sub.Entries {
+		if e.Name == ".hidden" {
+			t.Error("隐藏目录不应出现")
+		}
+	}
+	if _, cerr := browse(filepath.Join(base, "outside"), roots); cerr == nil {
+		t.Error("不能浏览范围以外的目录")
+	}
+	if _, cerr := browse(filepath.Join(root, "escape"), roots); cerr == nil {
+		t.Error("不能经由符号链接浏览范围以外的目录")
+	}
+	top, _ := browse("", roots)
+	if len(top.Entries) != 1 || top.Entries[0].Selectable {
+		t.Errorf("顶层列出允许的根目录且不能直接选择:%+v", top.Entries)
+	}
+}

+ 139 - 0
apps/mounter/server.go

@@ -0,0 +1,139 @@
+package main
+
+// 与平台的通信(docs/design/21-knowledge-sources.md §4.5):Unix socket,一行一个 JSON 请求和响应,每个请求都要带密钥。
+
+import (
+	"bufio"
+	"crypto/rand"
+	"crypto/subtle"
+	"encoding/hex"
+	"encoding/json"
+	"errors"
+	"log"
+	"net"
+	"os"
+	"time"
+)
+
+type request struct {
+	Token  string      `json:"token"`
+	Op     string      `json:"op"`
+	Path   string      `json:"path,omitempty"`
+	Mounts []mountSpec `json:"mounts,omitempty"`
+}
+
+type server struct {
+	token   string
+	mgr     *manager
+	version string
+}
+
+func newToken() string {
+	b := make([]byte, 32)
+	if _, err := rand.Read(b); err != nil {
+		panic(err)
+	}
+	return hex.EncodeToString(b)
+}
+
+// writeGroupFile 写入文件并设为目录的组(平台以 agentsvc 组身份读取)
+func writeGroupFile(path string, data []byte, mode os.FileMode, gid int) error {
+	tmp := path + ".tmp"
+	if err := os.WriteFile(tmp, data, mode); err != nil {
+		return err
+	}
+	if err := os.Chown(tmp, -1, gid); err != nil {
+		os.Remove(tmp)
+		return err
+	}
+	if err := os.Chmod(tmp, mode); err != nil {
+		return err
+	}
+	return os.Rename(tmp, path)
+}
+
+func (s *server) handle(req request) map[string]any {
+	fail := func(err error) map[string]any {
+		var ce *checkError
+		if errors.As(err, &ce) {
+			return map[string]any{"ok": false, "error": ce.Message, "state": ce.State}
+		}
+		return map[string]any{"ok": false, "error": err.Error()}
+	}
+	switch req.Op {
+	case "ping":
+		return map[string]any{"ok": true, "version": s.version}
+	case "status":
+		// 顺便重新检查:路径被删除、改名不会引起挂载表变化
+		s.mgr.refreshAll()
+		return map[string]any{"ok": true, "version": s.version, "mounts": s.mgr.status()}
+	case "list":
+		res, cerr := browse(req.Path, s.mgr.roots)
+		if cerr != nil {
+			return fail(cerr)
+		}
+		return map[string]any{"ok": true, "result": res}
+	case "check":
+		real, cerr := checkSource(req.Path, s.mgr.roots)
+		if cerr != nil {
+			return fail(cerr)
+		}
+		return map[string]any{"ok": true, "result": identify(s.mgr.sys, real)}
+	case "sync":
+		st, err := s.mgr.sync(req.Mounts, true)
+		if err != nil {
+			return fail(err)
+		}
+		return map[string]any{"ok": true, "mounts": st}
+	default:
+		return map[string]any{"ok": false, "error": "未知请求:" + req.Op}
+	}
+}
+
+func (s *server) serveConn(c net.Conn) {
+	defer c.Close()
+	r := bufio.NewReader(c)
+	enc := json.NewEncoder(c)
+	for {
+		_ = c.SetReadDeadline(time.Now().Add(60 * time.Second))
+		line, err := r.ReadBytes('\n')
+		if len(line) > 4<<20 {
+			_ = enc.Encode(map[string]any{"ok": false, "error": "请求过大"})
+			return
+		}
+		if len(line) == 0 && err != nil {
+			return
+		}
+		var req request
+		if jerr := json.Unmarshal(line, &req); jerr != nil {
+			_ = enc.Encode(map[string]any{"ok": false, "error": "请求格式不正确"})
+			return
+		}
+		if subtle.ConstantTimeCompare([]byte(req.Token), []byte(s.token)) != 1 {
+			_ = enc.Encode(map[string]any{"ok": false, "error": "密钥不正确"})
+			return
+		}
+		_ = c.SetWriteDeadline(time.Now().Add(30 * time.Second))
+		if err := enc.Encode(s.handle(req)); err != nil {
+			return
+		}
+		if err != nil {
+			return
+		}
+	}
+}
+
+func (s *server) serve(l net.Listener) {
+	for {
+		c, err := l.Accept()
+		if err != nil {
+			if errors.Is(err, net.ErrClosed) {
+				return
+			}
+			log.Printf("accept:%v", err)
+			time.Sleep(100 * time.Millisecond)
+			continue
+		}
+		go s.serveConn(c)
+	}
+}

+ 4 - 3
apps/server/src/app.ts

@@ -2,13 +2,14 @@
 import fs from 'node:fs'
 import path from 'node:path'
 import type { ProviderId } from '@agent-server/shared'
-import { communitySkillsDir, config, customSkillsDir, dbPath, teamCasesDir } from './config'
+import { communitySkillsDir, config, customSkillsDir, dbPath, mounterDir, teamCasesDir } from './config'
 import { createDb, migrateDb, type DB } from './db'
 import { ClaudeProvider } from './providers/claude/adapter'
 import { CodexProvider } from './providers/codex/adapter'
 import type { AgentProvider } from './providers/types'
 import { AdminAuthService } from './services/admin-auth'
 import { KnowledgeService } from './knowledge/service'
+import { MounterClient, type MounterApi } from './knowledge/mounter'
 import { ApiKeyService, AuditService, ProfileService } from './services/catalog'
 import { EventStore } from './services/event-store'
 import { FileService } from './services/files'
@@ -53,7 +54,7 @@ export interface AppLogger {
   child(b: object): AppLogger
 }
 
-export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<Record<ProviderId, AgentProvider>> } = {}) {
+export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<Record<ProviderId, AgentProvider>>; mounter?: MounterApi } = {}) {
   const db = opts.db ?? createDb(dbPath())
   migrateDb(db)
 
@@ -61,7 +62,7 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
   const events = new EventStore(db)
   const files = new FileService(db)
   const profiles = new ProfileService(db, settings)
-  const knowledge = new KnowledgeService(db)
+  const knowledge = new KnowledgeService(db, opts.mounter ?? new MounterClient(mounterDir()), log.child({ mod: 'knowledge' }))
   const apiKeys = new ApiKeyService(db, profiles)
   const audit = new AuditService(db)
   const usage = new UsageService(db)

+ 12 - 3
apps/server/src/http/routes/admin.ts

@@ -463,15 +463,24 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
     // 资料库与资料集合(docs/design/21-knowledge-sources.md)
     const kbKind = { managed: '托管目录', local: '本机目录', remote: '远程存储', link: '网络链接' } as const
     const kbWhere = (k: KnowledgeRootDto) => k.kind === 'link' ? k.url : k.kind === 'local' ? k.local?.path : k.kind === 'remote' ? k.remote?.type : k.path
-    g.get('/knowledge', async () => app.knowledge.list())
+    g.get('/knowledge', async () => {
+      await app.knowledge.refreshMounts(1000)
+      return app.knowledge.list()
+    })
+    g.get('/knowledge/mounter', async () => {
+      await app.knowledge.refreshMounts(1000)
+      return app.knowledge.mounterInfo()
+    })
+    g.get<{ Querystring: { path?: string } }>('/knowledge/browse', async req => app.knowledge.browse(req.query.path ?? ''))
+    g.post('/knowledge/check-local', async req => app.knowledge.checkLocal(parse(z.object({ path: z.string().trim().min(1) }), req.body).path))
     g.post('/knowledge', async (req, reply) => {
+      const k = await app.knowledge.create(req.body as KnowledgeInput)
       reply.code(201)
-      const k = app.knowledge.create(req.body as KnowledgeInput)
       app.audit.log({ actor: 'admin', kind: 'admin', summary: `添加资料库:${k.name}(${kbKind[k.kind]}:${kbWhere(k)})` })
       return k
     })
     g.put<IdParams>('/knowledge/:id', async req => {
-      const k = app.knowledge.update(req.params.id, req.body as KnowledgeInput)
+      const k = await app.knowledge.update(req.params.id, req.body as KnowledgeInput)
       app.audit.log({ actor: 'admin', kind: 'admin', summary: `修改资料库:${k.name}(${kbKind[k.kind]}:${kbWhere(k)})` })
       return k
     })

+ 1 - 1
apps/server/src/knowledge/describe.ts

@@ -5,7 +5,7 @@ const KIND_LABEL: Record<KnowledgeRootDto['kind'], string> = { managed: '托管
 
 const STATE_LABEL: Record<KnowledgeState, string> = {
   ok: '正常', path_missing: '路径不存在', device_missing: '设备未连接', not_allowed: '位置不允许',
-  unreachable: '无法连接', auth_failed: '认证失败', mounter_down: '挂载服务不可用', pending: '正在挂载',
+  unreachable: '无法连接', auth_failed: '认证失败', mounter_down: '挂载服务不可用', pending: '正在挂载', mount_failed: '挂载失败',
 }
 
 export const knowledgeStateLabel = (s: KnowledgeState) => STATE_LABEL[s]

+ 188 - 0
apps/server/src/knowledge/mounter.ts

@@ -0,0 +1,188 @@
+// 与挂载服务 agent-mounter 通信(docs/design/21-knowledge-sources.md §4.5):
+// Unix socket,一行一个 JSON 请求和响应,每个请求都带上挂载服务启动时生成的密钥。
+import fs from 'node:fs'
+import net from 'node:net'
+import path from 'node:path'
+import type { KnowledgeBrowseDto, KnowledgeLocalCheckDto, KnowledgeMounterDto, KnowledgeState, KnowledgeStatus } from '@agent-server/shared'
+
+export interface MountSpec {
+  id: string
+  kind: 'local'
+  local: { path: string; uuid: string | null; relPath: string | null }
+}
+
+export interface MountStatusRow {
+  id: string
+  state: KnowledgeState
+  message: string | null
+  since: string
+}
+
+/** 挂载服务的接口(测试中替换为模拟实现) */
+export interface MounterApi {
+  list(path: string): Promise<KnowledgeBrowseDto>
+  check(path: string): Promise<KnowledgeLocalCheckDto>
+  sync(mounts: MountSpec[]): Promise<MountStatusRow[]>
+  status(timeoutMs?: number): Promise<{ version: string; mounts: MountStatusRow[] }>
+}
+
+/** 挂载服务没有运行、连不上 */
+export class MounterUnavailable extends Error {}
+/** 挂载服务拒绝了请求(例如目录不在允许范围内) */
+export class MounterRejected extends Error {
+  constructor(message: string, readonly state: KnowledgeState | null) {
+    super(message)
+  }
+}
+
+export class MounterClient implements MounterApi {
+  constructor(private dir: string) {}
+
+  list(p: string) {
+    return this.call<{ result: KnowledgeBrowseDto }>({ op: 'list', path: p }).then(r => r.result)
+  }
+
+  check(p: string) {
+    return this.call<{ result: KnowledgeLocalCheckDto }>({ op: 'check', path: p }).then(r => ({ ...r.result, uuid: r.result.uuid || null, relPath: r.result.uuid ? r.result.relPath : null }))
+  }
+
+  sync(mounts: MountSpec[]) {
+    return this.call<{ mounts: MountStatusRow[] }>({ op: 'sync', mounts }, 30_000).then(r => r.mounts)
+  }
+
+  status(timeoutMs = 5000) {
+    return this.call<{ version: string; mounts: MountStatusRow[] }>({ op: 'status' }, timeoutMs)
+  }
+
+  private call<T>(req: Record<string, unknown>, timeoutMs = 5000): Promise<T> {
+    let token: string
+    try {
+      token = fs.readFileSync(path.join(this.dir, 'token'), 'utf8').trim()
+    } catch {
+      return Promise.reject(new MounterUnavailable('挂载服务没有运行'))
+    }
+    return new Promise<T>((resolve, reject) => {
+      const sock = net.createConnection(path.join(this.dir, 'mounter.sock'))
+      let buf = ''
+      let done = false
+      const finish = (err: Error | null, value?: T) => {
+        if (done) return
+        done = true
+        clearTimeout(timer)
+        sock.destroy()
+        if (err) reject(err)
+        else resolve(value as T)
+      }
+      const timer = setTimeout(() => finish(new MounterUnavailable('挂载服务没有响应')), timeoutMs)
+      sock.setEncoding('utf8')
+      sock.on('connect', () => sock.write(`${JSON.stringify({ ...req, token })}\n`))
+      sock.on('data', chunk => {
+        buf += chunk
+        const nl = buf.indexOf('\n')
+        if (nl < 0) return
+        let res: { ok: boolean; error?: string; state?: KnowledgeState } & Record<string, unknown>
+        try {
+          res = JSON.parse(buf.slice(0, nl))
+        } catch {
+          return finish(new MounterUnavailable('挂载服务的响应格式不正确'))
+        }
+        if (!res.ok) return finish(res.error === '密钥不正确' ? new MounterUnavailable('挂载服务密钥不正确(可能刚刚重启)') : new MounterRejected(res.error ?? '挂载服务拒绝了请求', res.state ?? null))
+        finish(null, res as T)
+      })
+      sock.on('error', err => finish(new MounterUnavailable(`挂载服务没有运行(${(err as NodeJS.ErrnoException).code ?? err.message})`)))
+      sock.on('close', () => finish(new MounterUnavailable('挂载服务断开了连接')))
+    })
+  }
+}
+
+interface Logger {
+  info(o: object, m?: string): void
+  warn(o: object, m?: string): void
+}
+
+/**
+ * 平台一侧的挂载状态:资料库变更时 sync(失败按退避间隔重试);后台打开资料库页、会话开始运行时 status。
+ * 不定时同步:设备插拔由挂载服务自己监听挂载表处理。
+ */
+export class KnowledgeMounts {
+  private statuses = new Map<string, MountStatusRow>()
+  private available = false
+  private version: string | null = null
+  private error: string | null = '尚未连接挂载服务'
+  private syncing = false
+  private again = false
+  private retryTimer: NodeJS.Timeout | null = null
+  private retryDelay = 1000
+
+  constructor(private api: MounterApi, private specs: () => MountSpec[], private log: Logger) {}
+
+  info(): KnowledgeMounterDto {
+    return { available: this.available, version: this.available ? this.version : null, error: this.available ? null : this.error }
+  }
+
+  statusOf(id: string): KnowledgeStatus | null {
+    if (!this.available) return null
+    const s = this.statuses.get(id)
+    if (!s) return { state: 'pending', message: '正在挂载', since: null }
+    return { state: s.state, message: s.message, since: s.since }
+  }
+
+  /** 发送完整的登记列表;正在同步时排队再同步一次 */
+  async sync(): Promise<void> {
+    if (this.syncing) {
+      this.again = true
+      return
+    }
+    this.syncing = true
+    if (this.retryTimer) clearTimeout(this.retryTimer)
+    this.retryTimer = null
+    try {
+      const rows = await this.api.sync(this.specs())
+      this.accept(rows)
+      this.retryDelay = 1000
+    } catch (err) {
+      this.fail(err as Error)
+      if (err instanceof MounterUnavailable) {
+        this.retryTimer = setTimeout(() => void this.sync(), this.retryDelay)
+        this.retryTimer.unref()
+        this.retryDelay = Math.min(this.retryDelay * 2, 60_000)
+      } else {
+        this.log.warn({ err: (err as Error).message }, '挂载服务同步失败')
+      }
+    } finally {
+      this.syncing = false
+      if (this.again) {
+        this.again = false
+        void this.sync()
+      }
+    }
+  }
+
+  /** 查询最新状态;挂载服务的登记与平台不一致时(例如 state.json 丢失)重新同步 */
+  async refresh(timeoutMs = 1000): Promise<void> {
+    try {
+      const r = await this.api.status(timeoutMs)
+      this.version = r.version
+      this.accept(r.mounts)
+      const want = this.specs().map(s => s.id).sort().join(',')
+      const have = r.mounts.map(m => m.id).sort().join(',')
+      if (want !== have) void this.sync()
+    } catch (err) {
+      this.fail(err as Error)
+      if (!this.retryTimer && !this.syncing) void this.sync()
+    }
+  }
+
+  private accept(rows: MountStatusRow[]) {
+    if (!this.available) this.log.info({}, '已连接挂载服务')
+    this.available = true
+    this.error = null
+    this.statuses = new Map(rows.map(r => [r.id, r]))
+  }
+
+  private fail(err: Error) {
+    if (this.available) this.log.warn({ err: err.message }, '挂载服务不可用')
+    this.available = false
+    this.error = err.message
+  }
+}

+ 68 - 14
apps/server/src/knowledge/service.ts

@@ -5,7 +5,8 @@ import path from 'node:path'
 import { eq } from 'drizzle-orm'
 import { z } from 'zod'
 import type {
-  KnowledgeCollectionDto, KnowledgeCollectionInput, KnowledgeInput, KnowledgeLinkTestDto, KnowledgeLocalSource, KnowledgeRootDto, KnowledgeStatus,
+  KnowledgeBrowseDto, KnowledgeCollectionDto, KnowledgeCollectionInput, KnowledgeInput, KnowledgeLinkTestDto, KnowledgeLocalCheckDto, KnowledgeLocalSource,
+  KnowledgeMounterDto, KnowledgeRootDto, KnowledgeStatus,
 } from '@agent-server/shared'
 import { knowledgeFilesDir, knowledgeMountDir } from '../config'
 import type { DB } from '../db'
@@ -13,6 +14,7 @@ import { knowledgeCollections, knowledgeRoots, profiles } from '../db/schema'
 import { AppError, badRequest, notFound } from '../lib/errors'
 import { newId, nowIso } from '../lib/ids'
 import { parse } from '../services/catalog'
+import { KnowledgeMounts, MounterRejected, MounterUnavailable, type MounterApi, type MountSpec } from './mounter'
 
 type Row = typeof knowledgeRoots.$inferSelect
 
@@ -34,16 +36,44 @@ const collectionSchema = z.object({
 
 const OK: KnowledgeStatus = { state: 'ok', message: null, since: null }
 
-/** 挂载服务提供的本机目录、远程存储状态;挂载服务不可用时为 null */
-export type MountStatusSource = (id: string) => KnowledgeStatus | null
+interface Logger {
+  info(o: object, m?: string): void
+  warn(o: object, m?: string): void
+}
 
 export class KnowledgeService {
-  private mountStatus: MountStatusSource = () => null
+  private mounts: KnowledgeMounts
 
-  constructor(private db: DB, private onChanged: () => void = () => {}) {}
+  constructor(private db: DB, private mounter: MounterApi, log: Logger) {
+    this.mounts = new KnowledgeMounts(mounter, () => this.mountSpecs(), log)
+  }
 
-  setMountStatusSource(source: MountStatusSource) {
-    this.mountStatus = source
+  /** 平台启动时调用:把登记列表发给挂载服务 */
+  start() {
+    void this.mounts.sync()
+  }
+
+  private onChanged() {
+    void this.mounts.sync()
+  }
+
+  /** 重新查询挂载状态(后台打开资料库页、会话开始运行时) */
+  refreshMounts(timeoutMs = 1000) {
+    return this.mounts.refresh(timeoutMs)
+  }
+
+  mounterInfo(): KnowledgeMounterDto {
+    return this.mounts.info()
+  }
+
+  /** 逐级选择本机目录 */
+  browse(p: string): Promise<KnowledgeBrowseDto> {
+    return this.mounter.list(p).catch(mounterError)
+  }
+
+  /** 检查本机目录(新建前在后台预览:是否在系统盘、设备 UUID) */
+  checkLocal(p: string): Promise<KnowledgeLocalCheckDto> {
+    return this.mounter.check(p).catch(mounterError)
   }
 
   list(): KnowledgeRootDto[] {
@@ -57,8 +87,8 @@ export class KnowledgeService {
     return this.toDto(row, this.memberships().get(id) ?? [])
   }
 
-  create(input: KnowledgeInput): KnowledgeRootDto {
-    const data = this.validate(input)
+  async create(input: KnowledgeInput): Promise<KnowledgeRootDto> {
+    const data = await this.validate(input)
     this.assertNameFree(data.name)
     const id = newId('kb')
     this.db.insert(knowledgeRoots).values({ id, ...data }).run()
@@ -67,9 +97,9 @@ export class KnowledgeService {
     return this.get(id)
   }
 
-  update(id: string, input: KnowledgeInput): KnowledgeRootDto {
+  async update(id: string, input: KnowledgeInput): Promise<KnowledgeRootDto> {
     const prev = this.get(id)
-    const data = this.validate(input)
+    const data = await this.validate(input, prev)
     if (data.kind !== prev.kind) throw badRequest('资料库类型不能修改,请新建一个资料库')
     this.assertNameFree(data.name, id)
     this.db.update(knowledgeRoots).set({ ...data, updatedAt: nowIso() }).where(eq(knowledgeRoots.id, id)).run()
@@ -180,11 +210,11 @@ export class KnowledgeService {
     switch (r.kind) {
       case 'managed': return fs.existsSync(managedDir(r.id)) ? OK : { state: 'path_missing', message: '目录不存在', since: null }
       case 'link': return OK
-      default: return this.mountStatus(r.id) ?? { state: 'mounter_down', message: '挂载服务不可用', since: null }
+      default: return this.mounts.statusOf(r.id) ?? { state: 'mounter_down', message: this.mounts.info().error ?? '挂载服务不可用', since: null }
     }
   }
 
-  private validate(input: KnowledgeInput) {
+  private async validate(input: KnowledgeInput, prev?: KnowledgeRootDto) {
     const d = parse(inputSchema, input)
     const base = { name: d.name, kind: d.kind, description: d.description, enabled: d.enabled, access: 'read' as const, source: null, url: null }
     switch (d.kind) {
@@ -192,11 +222,29 @@ export class KnowledgeService {
         return { ...base, access: d.access }
       case 'link':
         return { ...base, url: parseLink(d.url ?? '').toString() }
+      case 'local': {
+        const p = d.localPath ?? ''
+        if (!p) throw badRequest('请选择本机目录')
+        // 路径没变时沿用原来的识别结果(设备暂时拔出时也能修改名称、说明)
+        if (prev?.local && (p === prev.local.path)) return { ...base, source: prev.local }
+        const c = await this.checkLocal(p)
+        return { ...base, source: { path: c.path, uuid: c.uuid, relPath: c.uuid ? c.relPath : null } satisfies KnowledgeLocalSource }
+      }
       default:
-        throw badRequest('本机目录和远程存储需要挂载服务,尚未提供')
+        throw badRequest('远程存储尚未提供')
     }
   }
 
+  /** 发给挂载服务的登记列表:启用的本机目录 */
+  private mountSpecs(): MountSpec[] {
+    return this.db.select().from(knowledgeRoots).all()
+      .filter(r => r.kind === 'local' && r.enabled && r.source && 'path' in r.source)
+      .map(r => {
+        const src = r.source as KnowledgeLocalSource
+        return { id: r.id, kind: 'local' as const, local: { path: src.path, uuid: src.uuid, relPath: src.relPath } }
+      })
+  }
+
   private assertNameFree(name: string, self?: string) {
     const dup = this.db.select({ id: knowledgeRoots.id }).from(knowledgeRoots).where(eq(knowledgeRoots.name, name)).all().find(r => r.id !== self)
     if (dup) throw badRequest(`名称"${name}"已被使用`)
@@ -233,6 +281,12 @@ export class KnowledgeService {
 
 export const managedDir = (id: string) => path.join(knowledgeFilesDir(), id)
 
+function mounterError(err: unknown): never {
+  if (err instanceof MounterRejected) throw badRequest(err.message)
+  if (err instanceof MounterUnavailable) throw new AppError(503, 'mounter_unavailable', `${err.message},请检查 agent-mounter 服务(systemctl status agent-mounter)`)
+  throw err
+}
+
 /** Agent 看到的目录:托管目录在 files/<id>,本机目录与远程存储挂载在 mnt/<id>,网络链接没有目录 */
 export function agentPath(kind: KnowledgeRootDto['kind'], id: string): string {
   if (kind === 'managed') return managedDir(id)

+ 2 - 0
apps/server/src/main.ts

@@ -23,3 +23,5 @@ process.on('SIGTERM', () => void shutdown('SIGTERM'))
 
 await server.listen({ host: config.host, port: config.port })
 logger.info({ dataDir: config.dataDir }, `AgentServer 已启动:http://${config.host}:${config.port}`)
+// 把资料库登记列表发给挂载服务(挂载服务没启动时按退避间隔重试)
+app.knowledge.start()

+ 1 - 1
apps/server/src/services/access-policy.ts

@@ -110,7 +110,7 @@ const matcherCache = new Map<string, (p: string) => boolean>()
 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')]
+  const entries = [...inHome.map(expandPath), ...inHome.map(e => path.join(config.dataDir, 'home', e.slice(2))), path.join(codexHome(), 'auth.json'), mounterDir()]
   return entries.some(e => matchesEntry(real, e))
 }
 

+ 6 - 1
apps/server/src/services/runs.ts

@@ -188,7 +188,12 @@ export class RunManager {
       session = deps.sessions.getRow(sessionId)
       const settings = deps.settings.get()
       const { profile, permissionMode } = deps.sessions.effectiveProfile(session)
-      const knowledgeRoots = deps.knowledge.effective(profile)
+      let knowledgeRoots = deps.knowledge.effective(profile)
+      // 本机目录、远程存储:查询最新状态(超时 1 秒),不可用的在提示词中标注"暂不可用"
+      if (knowledgeRoots.some(k => k.kind === 'local' || k.kind === 'remote')) {
+        await deps.knowledge.refreshMounts(1000)
+        knowledgeRoots = deps.knowledge.effective(profile)
+      }
       const access = resolveAccess({
         policy: profile.fileAccess,
         knowledgeRoots,

+ 2 - 2
apps/server/test/file-manager.test.ts

@@ -19,12 +19,12 @@ beforeAll(async () => {
   sid = s.id
   ws = s.workspaceDir
   fs.writeFileSync(path.join(ws, 'uploads', 'data.csv'), 'a,b\n1,2\n')
-  kbDir = app.knowledge.create({ kind: 'managed', name: '账单', description: '家庭账单' }).path
+  kbDir = (await app.knowledge.create({ kind: 'managed', name: '账单', description: '家庭账单' })).path
   fs.writeFileSync(path.join(kbDir, 'note.md'), '# 账单\n')
   fs.symlinkSync('/etc', path.join(kbDir, 'etc-link'))
   fs.mkdirSync(path.join(kbDir, '.ssh-like'))
   // 网络链接没有目录,不出现在文件管理中
-  app.knowledge.create({ kind: 'link', name: '内部文档', url: 'http://wiki.local/' })
+  await app.knowledge.create({ kind: 'link', name: '内部文档', url: 'http://wiki.local/' })
 })
 
 const kbRoot = () => app.fileManager.roots().find(r => r.kind === 'knowledge')!.id

+ 122 - 21
apps/server/test/knowledge.test.ts

@@ -5,17 +5,53 @@ import type { AddressInfo } from 'node:net'
 import path from 'node:path'
 import pino from 'pino'
 import { afterAll, beforeAll, describe, expect, it } from 'vitest'
-import type { ProfileInput } from '@agent-server/shared'
+import type { KnowledgeBrowseDto, KnowledgeLocalCheckDto, ProfileInput } from '@agent-server/shared'
 import { createApp, type App } from '../src/app'
-import { knowledgeFilesDir } from '../src/config'
+import { knowledgeFilesDir, knowledgeMountDir } from '../src/config'
 import { describeKnowledge, linkOrigins } from '../src/knowledge/describe'
+import { MounterRejected, MounterUnavailable, type MounterApi, type MountSpec, type MountStatusRow } from '../src/knowledge/mounter'
+
+/** 模拟挂载服务:/home/u 下在系统盘,/media/u/Data 下在外接盘(UUID aaaa) */
+class FakeMounter implements MounterApi {
+  up = true
+  synced: MountSpec[][] = []
+  checks: string[] = []
+  states = new Map<string, Omit<MountStatusRow, 'id'>>()
+  private guard() {
+    if (!this.up) throw new MounterUnavailable('挂载服务没有运行')
+  }
+  async list(p: string): Promise<KnowledgeBrowseDto> {
+    this.guard()
+    return { path: p, parent: '', entries: [{ name: 'docs', path: `${p}/docs`, selectable: true }] }
+  }
+  async check(p: string): Promise<KnowledgeLocalCheckDto> {
+    this.guard()
+    this.checks.push(p)
+    if (p.startsWith('/media/u/Data/')) return { path: p, uuid: 'aaaa', relPath: p.slice('/media/u/Data'.length), systemDisk: false, mountPoint: '/media/u/Data', fsType: 'ext4', device: '/dev/sda1' }
+    if (p.startsWith('/home/u/')) return { path: p, uuid: null, relPath: null, systemDisk: true, mountPoint: '/', fsType: 'ext4', device: '/dev/dm-0' }
+    throw new MounterRejected('只能选择 /home、/mnt、/media 下的目录', 'not_allowed')
+  }
+  private rows(): MountStatusRow[] {
+    return (this.synced.at(-1) ?? []).map(m => ({ id: m.id, ...(this.states.get(m.id) ?? { state: 'ok', message: null, since: '2026-11-06T00:00:00Z' }) }))
+  }
+  async sync(mounts: MountSpec[]) {
+    this.guard()
+    this.synced.push(mounts)
+    return this.rows()
+  }
+  async status() {
+    this.guard()
+    return { version: 'test', mounts: this.rows() }
+  }
+}
+const mounter = new FakeMounter()
 
 let app: App
 let site: http.Server
 let siteOrigin: string
 
 beforeAll(async () => {
-  app = createApp(pino({ level: 'silent' }), { providers: {} })
+  app = createApp(pino({ level: 'silent' }), { providers: {}, mounter })
   site = http.createServer((req, res) => {
     if (req.url === '/missing') return res.writeHead(404).end()
     res.setHeader('content-type', 'text/html; charset=utf-8')
@@ -35,30 +71,30 @@ const profile = (over: Partial<ProfileInput> = {}): ProfileInput => ({
 })
 
 describe('资料库类型', () => {
-  it('托管目录:平台在 files/<id> 下创建目录,可以设为 Agent 可写;删除时连同文件一起删除', () => {
-    const k = app.knowledge.create({ kind: 'managed', name: '产品资料', description: '产品手册', access: 'write' })
+  it('托管目录:平台在 files/<id> 下创建目录,可以设为 Agent 可写;删除时连同文件一起删除', async () => {
+    const k = await app.knowledge.create({ kind: 'managed', name: '产品资料', description: '产品手册', access: 'write' })
     expect(k.path).toBe(path.join(knowledgeFilesDir(), k.id))
     expect(fs.statSync(k.path).isDirectory()).toBe(true)
     expect(k).toMatchObject({ kind: 'managed', access: 'write', url: null, status: { state: 'ok' } })
     fs.writeFileSync(path.join(k.path, 'a.md'), 'x')
-    expect(app.knowledge.update(k.id, { kind: 'managed', name: '产品资料', access: 'read' }).access).toBe('read')
+    expect((await app.knowledge.update(k.id, { kind: 'managed', name: '产品资料', access: 'read' })).access).toBe('read')
     app.knowledge.delete(k.id)
     expect(fs.existsSync(k.path)).toBe(false)
   })
 
-  it('网络链接:只接受 http/https,不能带用户名密码;没有目录、只读', () => {
-    const k = app.knowledge.create({ kind: 'link', name: '内部文档', url: 'http://wiki.local/docs/', access: 'write' })
+  it('网络链接:只接受 http/https,不能带用户名密码;没有目录、只读', async () => {
+    const k = await app.knowledge.create({ kind: 'link', name: '内部文档', url: 'http://wiki.local/docs/', access: 'write' })
     expect(k).toMatchObject({ path: '', url: 'http://wiki.local/docs/', access: 'read', status: { state: 'ok' } })
-    expect(() => app.knowledge.create({ kind: 'link', name: 'ftp', url: 'ftp://a.com/x' })).toThrow(/http/)
-    expect(() => app.knowledge.create({ kind: 'link', name: '坏链接', url: 'not a url' })).toThrow(/格式不正确/)
-    expect(() => app.knowledge.create({ kind: 'link', name: '带密码', url: 'http://u:p@a.com/' })).toThrow(/用户名或密码/)
+    await expect(app.knowledge.create({ kind: 'link', name: 'ftp', url: 'ftp://a.com/x' })).rejects.toThrow(/http/)
+    await expect(app.knowledge.create({ kind: 'link', name: '坏链接', url: 'not a url' })).rejects.toThrow(/格式不正确/)
+    await expect(app.knowledge.create({ kind: 'link', name: '带密码', url: 'http://u:p@a.com/' })).rejects.toThrow(/用户名或密码/)
   })
 
-  it('名称不能重复;类型不能修改;本机目录与远程存储需要挂载服务', () => {
+  it('名称不能重复;类型不能修改;远程存储尚未提供', async () => {
     const k = app.knowledge.list().find(x => x.name === '内部文档')!
-    expect(() => app.knowledge.create({ kind: 'managed', name: '内部文档' })).toThrow(/已被使用/)
-    expect(() => app.knowledge.update(k.id, { kind: 'managed', name: '内部文档' })).toThrow(/类型不能修改/)
-    expect(() => app.knowledge.create({ kind: 'local', name: '本机', localPath: '/home/x/docs' })).toThrow(/挂载服务/)
+    await expect(app.knowledge.create({ kind: 'managed', name: '内部文档' })).rejects.toThrow(/已被使用/)
+    await expect(app.knowledge.update(k.id, { kind: 'managed', name: '内部文档' })).rejects.toThrow(/类型不能修改/)
+    await expect(app.knowledge.create({ kind: 'remote', name: '远程' })).rejects.toThrow(/尚未提供/)
   })
 
   it('测试访问:返回状态码、标题;失败时给出原因', async () => {
@@ -81,10 +117,10 @@ describe('资料库类型', () => {
 })
 
 describe('资料集合', () => {
-  it('Profile 生效的资料库 = 单独勾选的 ∪ 集合成员,去掉停用的;集合成员变化立即生效', () => {
-    const a = app.knowledge.create({ kind: 'managed', name: '合同' })
-    const b = app.knowledge.create({ kind: 'managed', name: '发票' })
-    const c = app.knowledge.create({ kind: 'link', name: '报价单', url: 'https://erp.example.com/quotes' })
+  it('Profile 生效的资料库 = 单独勾选的 ∪ 集合成员,去掉停用的;集合成员变化立即生效', async () => {
+    const a = await app.knowledge.create({ kind: 'managed', name: '合同' })
+    const b = await app.knowledge.create({ kind: 'managed', name: '发票' })
+    const c = await app.knowledge.create({ kind: 'link', name: '报价单', url: 'https://erp.example.com/quotes' })
     const col = app.knowledge.createCollection({ name: '财务', description: '财务相关', rootIds: [a.id, b.id, a.id] })
     expect(col.rootIds).toEqual([a.id, b.id])
     expect(app.knowledge.get(a.id).collectionIds).toEqual([col.id])
@@ -94,10 +130,10 @@ describe('资料集合', () => {
     const names = () => app.knowledge.effective(app.profiles.get(p.id)).map(k => k.name).sort()
     expect(names()).toEqual(['发票', '合同', '报价单'])
 
-    app.knowledge.update(b.id, { kind: 'managed', name: '发票', enabled: false })
+    await app.knowledge.update(b.id, { kind: 'managed', name: '发票', enabled: false })
     expect(names()).toEqual(['合同', '报价单'])
     app.knowledge.updateCollection(col.id, { name: '财务', rootIds: [a.id] })
-    app.knowledge.update(b.id, { kind: 'managed', name: '发票', enabled: true })
+    await app.knowledge.update(b.id, { kind: 'managed', name: '发票', enabled: true })
     expect(names()).toEqual(['合同', '报价单'])
 
     // 被 Profile 使用的集合不能删除;删除资料库时从集合中移除
@@ -123,3 +159,68 @@ describe('资料集合', () => {
     expect(v2.version).toBe(2)
   })
 })
+
+describe('本机目录(挂载服务)', () => {
+  const settle = () => new Promise(r => setTimeout(r, 20))
+
+  it('新建时由挂载服务检查并识别设备;登记列表同步给挂载服务,状态来自挂载服务', async () => {
+    const home = await app.knowledge.create({ kind: 'local', name: '项目文档', localPath: '/home/u/docs' })
+    expect(home).toMatchObject({ kind: 'local', access: 'read', path: path.join(knowledgeMountDir(), home.id), local: { path: '/home/u/docs', uuid: null, relPath: null } })
+    const disk = await app.knowledge.create({ kind: 'local', name: '移动硬盘资料', localPath: '/media/u/Data/资料', access: 'write' })
+    expect(disk).toMatchObject({ access: 'read', local: { path: '/media/u/Data/资料', uuid: 'aaaa', relPath: '/资料' } })
+    await settle()
+    expect(mounter.synced.at(-1)).toEqual(expect.arrayContaining([
+      { id: home.id, kind: 'local', local: { path: '/home/u/docs', uuid: null, relPath: null } },
+      { id: disk.id, kind: 'local', local: { path: '/media/u/Data/资料', uuid: 'aaaa', relPath: '/资料' } },
+    ]))
+    expect(app.knowledge.get(home.id).status.state).toBe('ok')
+
+    // 设备拔出:状态与原因来自挂载服务,提示词中标注暂不可用
+    mounter.states.set(disk.id, { state: 'device_missing', message: '设备未连接(文件系统 UUID aaaa)', since: '2026-11-06T01:00:00Z' })
+    await app.knowledge.refreshMounts()
+    expect(app.knowledge.get(disk.id).status).toEqual({ state: 'device_missing', message: '设备未连接(文件系统 UUID aaaa)', since: '2026-11-06T01:00:00Z' })
+    expect(describeKnowledge([app.knowledge.get(disk.id)])).toContain('(本机目录,只读,暂不可用:设备未连接(文件系统 UUID aaaa))')
+    mounter.states.delete(disk.id)
+
+    // 停用后不再挂载;删除后从登记列表中移除
+    await app.knowledge.update(home.id, { kind: 'local', name: '项目文档', localPath: '/home/u/docs', enabled: false })
+    await settle()
+    expect(mounter.synced.at(-1)!.map(m => m.id)).not.toContain(home.id)
+    app.knowledge.delete(home.id)
+    await settle()
+    expect(app.knowledge.list().some(k => k.id === home.id)).toBe(false)
+  })
+
+  it('挂载服务拒绝的目录不能登记;路径不变时修改名称不重新检查', async () => {
+    await expect(app.knowledge.create({ kind: 'local', name: '系统目录', localPath: '/etc' })).rejects.toThrow(/只能选择/)
+    await expect(app.knowledge.create({ kind: 'local', name: '空路径' })).rejects.toThrow(/请选择本机目录/)
+    const k = app.knowledge.list().find(x => x.name === '移动硬盘资料')!
+    mounter.checks = []
+    await app.knowledge.update(k.id, { kind: 'local', name: '移动硬盘', localPath: '/media/u/Data/资料' })
+    expect(mounter.checks).toEqual([])
+    expect(app.knowledge.get(k.id).local).toEqual({ path: '/media/u/Data/资料', uuid: 'aaaa', relPath: '/资料' })
+  })
+
+  it('挂载服务不可用:本机目录状态为 mounter_down,不能新建;恢复后重新同步', async () => {
+    mounter.up = false
+    await app.knowledge.refreshMounts()
+    const k = app.knowledge.list().find(x => x.name === '移动硬盘')!
+    expect(k.status).toMatchObject({ state: 'mounter_down', message: '挂载服务没有运行' })
+    expect(app.knowledge.mounterInfo()).toEqual({ available: false, version: null, error: '挂载服务没有运行' })
+    await expect(app.knowledge.create({ kind: 'local', name: '新目录', localPath: '/home/u/new' })).rejects.toMatchObject({ status: 503 })
+    await expect(app.knowledge.browse('/home')).rejects.toThrow(/agent-mounter/)
+    // 挂载服务不可用时仍可修改名称、说明,也可以停用
+    await app.knowledge.update(k.id, { kind: 'local', name: '移动硬盘', description: '合同扫描件', localPath: '/media/u/Data/资料', enabled: false })
+
+    // 恢复后:挂载服务的登记与平台不一致,重新同步
+    mounter.up = true
+    await app.knowledge.refreshMounts()
+    expect(app.knowledge.mounterInfo()).toEqual({ available: true, version: 'test', error: null })
+    await settle()
+    expect(mounter.synced.at(-1)!.map(m => m.id)).not.toContain(k.id)
+    await app.knowledge.update(k.id, { kind: 'local', name: '移动硬盘', localPath: '/media/u/Data/资料', enabled: true })
+    await settle()
+    expect(mounter.synced.at(-1)!.map(m => m.id)).toContain(k.id)
+    expect(app.knowledge.get(k.id).status.state).toBe('ok')
+  })
+})

+ 21 - 0
deploy/agent-mounter.service

@@ -0,0 +1,21 @@
+# AgentServer 资料库挂载服务(docs/design/21-knowledge-sources.md §4.1)
+# 以部署用户运行(__USER__ 由 deploy.sh 替换),把本机目录只读挂到 /srv/agent-server/knowledge/mnt/<id>。
+# 注意:
+# - 挂载必须发生在主机的挂载命名空间,不能用 ProtectSystem、ProtectHome、PrivateTmp、ProtectKernel* 等会创建挂载命名空间的选项;
+# - fusermount3 是 setuid 程序,不能开 NoNewPrivileges,也不能用会隐含它的选项(SystemCallFilter、LockPersonality、RestrictRealtime 等)。
+[Unit]
+Description=AgentServer 资料库挂载服务
+After=local-fs.target
+
+[Service]
+Type=simple
+User=__USER__
+SupplementaryGroups=agentsvc
+ExecStart=/srv/agent-server/app/mounter/agent-mounter
+Restart=on-failure
+RestartSec=3
+MemoryMax=1G
+TasksMax=256
+
+[Install]
+WantedBy=multi-user.target

+ 2 - 2
deploy/agent-server.service

@@ -2,8 +2,8 @@
 # 以专用用户 agentsvc 运行:该用户不在 docker/lxd/sudo 组,Agent 失控时的影响上限是它自己的数据
 [Unit]
 Description=AgentServer
-After=network-online.target
-Wants=network-online.target
+After=network-online.target agent-mounter.service
+Wants=network-online.target agent-mounter.service
 
 [Service]
 Type=simple

+ 25 - 1
deploy/deploy.sh

@@ -25,6 +25,9 @@ fi
 STAGE=$(mktemp -d)
 trap 'rm -rf "$STAGE"' EXIT
 pnpm --filter @agent-server/server deploy --prod "$STAGE/server" >/dev/null
+# 资料库挂载服务(Go,静态编译;同一代码编译结果逐字节相同,没有变化时不重启)
+(cd apps/mounter && CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o "$STAGE/agent-mounter" .)
+sed "s/__USER__/$(id -un)/" deploy/agent-mounter.service > "$STAGE/agent-mounter.service"
 
 sudo rsync -a --delete "$STAGE/server/" "$ROOT/app/server/"
 sudo rsync -a --delete apps/admin/dist/ "$ROOT/app/admin/"
@@ -33,15 +36,36 @@ sudo mkdir -p "$ROOT/app/sdk"
 sudo rsync -a dist-sdk/ "$ROOT/app/sdk/"
 sudo rm -f "$ROOT/app/sdk/agent-skills.tgz" "$ROOT/app/sdk/index.json"   # 旧的 /sdk/ 分发方式留下的文件
 sudo rsync -a --delete skills/ "$ROOT/skills/"
+mounter_changed=0
+if ! sudo cmp -s "$STAGE/agent-mounter" "$ROOT/app/mounter/agent-mounter"; then
+  sudo install -D -m 0755 "$STAGE/agent-mounter" "$ROOT/app/mounter/agent-mounter"
+  mounter_changed=1
+fi
 sudo chown -R root:root "$ROOT/app" "$ROOT/skills"
 sudo chmod -R a+rX "$ROOT/app" "$ROOT/skills"
 
 # 服务可写目录与 unit 文件(新增目录或修改 unit 后,不必重跑 install.sh)
 sudo install -d -o agentsvc -g agentsvc -m 0700 "$ROOT/managed-skills"
+# 资料库:托管目录由平台写入;挂载点与挂载服务目录属于部署用户,平台(agentsvc 组)只能进入和读取
+sudo install -d -o agentsvc -g agentsvc -m 2770 "$ROOT/knowledge"
+sudo install -d -o agentsvc -g agentsvc -m 2770 "$ROOT/knowledge/files"
+sudo install -d -o "$(id -un)" -g agentsvc -m 0750 "$ROOT/knowledge/mnt"
+sudo install -d -o "$(id -un)" -g agentsvc -m 2750 "$ROOT/mounter"
+grep -q '^user_allow_other' /etc/fuse.conf || { echo "请先在 /etc/fuse.conf 中开启 user_allow_other(sudo deploy/install.sh 会处理)" >&2; exit 1; }
+reload=0
 if ! sudo cmp -s deploy/agent-server.service /etc/systemd/system/agent-server.service; then
   sudo install -m 0644 deploy/agent-server.service /etc/systemd/system/agent-server.service
-  sudo systemctl daemon-reload
+  reload=1
+fi
+if ! sudo cmp -s "$STAGE/agent-mounter.service" /etc/systemd/system/agent-mounter.service; then
+  sudo install -m 0644 "$STAGE/agent-mounter.service" /etc/systemd/system/agent-mounter.service
+  reload=1
+  mounter_changed=1
 fi
+if [ $reload = 1 ]; then sudo systemctl daemon-reload; fi
+sudo systemctl enable --quiet agent-mounter
+# 只有挂载程序或 unit 有变化时才重启挂载服务(挂载会中断几秒后自动恢复)
+if [ $mounter_changed = 1 ] || ! systemctl is-active --quiet agent-mounter; then sudo systemctl restart agent-mounter; fi
 
 sudo systemctl restart agent-server
 for _ in $(seq 1 20); do

+ 13 - 3
deploy/install.sh

@@ -25,16 +25,26 @@ done
 
 # 资料库:agentsvc 组可读写,管理员加入该组后可以直接往里放资料
 install -d -o "$SVC" -g "$SVC" -m 2770 "$ROOT/knowledge"
-chown -R "$SVC:$SVC" "$ROOT/knowledge"
-chmod -R g+rwX "$ROOT/knowledge"
+chown "$SVC:$SVC" "$ROOT/knowledge"
 if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != root ]; then
   usermod -aG "$SVC" "$SUDO_USER"
   echo "已把 $SUDO_USER 加入 $SVC 组(重新登录后生效,用于往资料库放文件)"
 fi
 
+# 资料库挂载服务(docs/design/21-knowledge-sources.md §9.4):允许平台(agentsvc)访问部署用户挂载的目录
+if [ -f /etc/fuse.conf ] && ! grep -q '^user_allow_other' /etc/fuse.conf; then
+  echo user_allow_other >> /etc/fuse.conf
+  echo "已在 /etc/fuse.conf 中开启 user_allow_other"
+fi
+if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != root ]; then
+  install -d -o "$SVC" -g "$SVC" -m 2770 "$ROOT/knowledge/files"
+  install -d -o "$SUDO_USER" -g "$SVC" -m 0750 "$ROOT/knowledge/mnt"
+  install -d -o "$SUDO_USER" -g "$SVC" -m 2750 "$ROOT/mounter"
+fi
+
 install -m 0644 deploy/agent-server.service /etc/systemd/system/agent-server.service
 install -m 0755 deploy/agent-claude /usr/local/bin/agent-claude
 install -m 0755 deploy/agent-codex /usr/local/bin/agent-codex
 systemctl daemon-reload
 systemctl enable agent-server >/dev/null
-echo "安装完成。下一步:deploy/deploy.sh 部署程序,然后执行 agent-claude 并输入 /login 登录。"
+echo "安装完成(挂载服务 agent-mounter 由 deploy.sh 安装,以 ${SUDO_USER:-部署用户} 运行)。下一步:deploy/deploy.sh 部署程序,然后执行 agent-claude 并输入 /login 登录。"

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

@@ -40,7 +40,7 @@
 | 📊 概览 | — | 运行中任务、今日用量、7 天趋势、错误列表、凭证状态 |
 | 🗂 会话 | 全部会话(所有平台/使用者) | 只读回放(时间线 + 原始事件 + 文件),可"在 Playground 中继续" |
 | 🧩 Profiles | Profile 列表 | 表单编辑:引擎、模型(下拉选择当前引擎的模型)、提示词(Monaco 编辑器)、权限、工具、技能、资料库(资料集合与单个资料库,显示实际可用的列表)、第三方 MCP 服务、Codex 插件(仅 Codex,默认不开放,ADR-0018 修订)、**文件访问策略**(模式、可读/可写/禁止路径,右侧显示合并后的生效预览,见 06 §4.3);版本历史 |
-| 📚 资料库 | 资料库列表、资料集合 | 四种类型(托管目录、本机目录、远程存储、网络链接,见 [21](21-knowledge-sources.md)):名称、说明、启用、状态;托管目录可设 Agent 可写并在文件管理中打开;网络链接可测试访问。资料集合:成员、被哪些 Profile 使用 |
+| 📚 资料库 | 资料库列表、资料集合 | 四种类型(托管目录、本机目录、远程存储、网络链接,见 [21](21-knowledge-sources.md)):名称、说明、启用、状态;托管目录可设 Agent 可写并在文件管理中打开;本机目录逐级选择目录(只能选 /home、/mnt、/media 下的),显示所在设备(系统盘按路径、外接设备按 UUID 识别);网络链接可测试访问;挂载服务不可用时页面顶部提示。资料集合:成员、被哪些 Profile 使用 |
 | 🗃 文件管理 | 位置 + 常用目录(各资料库、本地技能目录、已安装技能、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 列表 | 签发(明文只显示一次)、吊销、最近使用时间 |

+ 35 - 7
docs/design/21-knowledge-sources.md

@@ -1,6 +1,6 @@
 # 21 资料库:托管目录、本机目录、远程存储、网络链接与资料集合
 
-> 状态:**实施中(2026-11-06)**:第一期(资料库类型重构、网络链接、资料集合)已完成,见 §15。取代 [07 §4.1](07-files-and-knowledge.md) 中"登记服务器路径"的资料库。
+> 状态:**实施中(2026-11-06)**:第一期(资料库类型重构、网络链接、资料集合)、第二期(挂载服务、本机目录)已完成,见 §15。取代 [07 §4.1](07-files-and-knowledge.md) 中"登记服务器路径"的资料库。
 
 ## 1. 背景
 
@@ -58,7 +58,7 @@
 - **systemd 限制**:
   - 挂载必须发生在主机的挂载命名空间,所以不能用 `ProtectSystem`、`ProtectHome`、`PrivateTmp`、`PrivateMounts`;
   - `fusermount3` 是 setuid 程序,所以不能开 `NoNewPrivileges`;
-  - 只能使用与挂载无关的加固,例如 `ProtectKernelModules`、`LockPersonality`。
+  - systemd 中 `ProtectKernel*` 等选项会创建挂载命名空间,`LockPersonality`、`RestrictRealtime`、`SystemCallFilter` 等在非 root 用户下会隐含 `NoNewPrivileges`,所以都不能用。实际只设了 `MemoryMax`、`TasksMax`(实施时更正)。
 - 进程退出时,它提供的所有挂载随之失效。启动时先清理 `mnt/` 下失效的挂载(`fusermount3 -uz`),再按登记重新挂载。只有挂载程序本身有改动时,`deploy.sh` 才重启它,挂载中断几秒后自动恢复。
 
 ### 4.2 文件系统的行为
@@ -71,7 +71,8 @@
 | 隐藏文件 | 以 `.` 开头的文件和目录**不出现在目录列表中,按路径访问也返回"不存在"**。`ls -a`、`find`、`grep -r` 都看不到(已实测) |
 | 权限 | 文件显示为所有人只读、目录为所有人可进入。实际以部署用户的身份读取,不修改原文件的权限 |
 | 符号链接 | 原样呈现,由读取方在自己的文件系统视图中解析。指向挂载范围外的链接在平台和沙箱中会失效 |
-| 防止路径替换 | 打开文件时,从源目录开始逐级打开(`openat2` + `RESOLVE_BENEATH \| RESOLVE_NO_SYMLINKS`)。即使有人在访问过程中把目录替换成符号链接,也不会读到源目录以外的内容 |
+| 防止路径替换 | 每次访问先打开源目录并核对设备号、inode 号,再相对它打开(`openat2` + `RESOLVE_BENEATH \| RESOLVE_NO_SYMLINKS \| RESOLVE_NO_XDEV`)。即使有人在访问过程中把目录替换成符号链接,也不会读到源目录以外的内容;源目录下另外挂载的文件系统不提供 |
+| 不占用设备 | 不长期持有源目录的句柄(只有 Agent 正在打开的文件会占用),外接设备可以正常卸载、安全弹出,自动挂载的空闲卸载也不受影响。设备拔出后、挂载表变化被处理之前,原路径即使露出挂载点下面的其他文件,也会因设备号不符而返回"输入/输出错误" |
 | 不可用时 | 资料库状态不是"正常"时,挂载点根目录照常存在,读取目录或文件返回"输入/输出错误" |
 
 ### 4.3 访问范围(本机目录,由挂载进程强制检查)
@@ -342,10 +343,23 @@ profiles
 | bubblewrap 沙箱中 | ✅ 同上 |
 | 只读、传入平台服务 | ✅ |
 
-**待测**(实施时):
-- 开启 `user_allow_other` 后,agentsvc 在平台服务和两个引擎的沙箱中读取挂载目录;
-- 外接设备拔出、插回(或用 root 创建 loop 设备模拟);
-- MinIO 加 rclone 两层挂载的读取、pandas 随机读取、断网与恢复。
+**2026-11-06**:挂载服务(第二期)在生产环境实测,用 root 创建的 loop 设备(ext4 镜像)模拟外接盘。
+
+| 测试 | 结果 |
+|---|---|
+| 平台连接挂载服务(平台的 `/srv` 是只读的,socket 仍可连接) | ✅ |
+| agentsvc 在平台服务的命名空间中读取:`ls`、`cat`、`grep -r` | ✅ 原目录权限 0700,agentsvc 不能直接读,经挂载可读;`.env`、`.git` 不可见 |
+| 写入 | ✅ "只读文件系统" |
+| 安全弹出(umount) | ✅ 第一版长期持有源目录句柄,umount 报"设备忙",已改为每次访问时打开 |
+| 弹出后 | ✅ 挂载表变化立即处理,状态"设备未连接",读取返回"输入/输出错误",挂载点根目录照常存在 |
+| 原路径下面有其他文件 | ✅ 不提供 |
+| 插回并挂到另一个位置(`/mnt/kbtest` → `/media/kbdisk`) | ✅ 立即恢复,内容正确 |
+| 挂载服务重启 | ✅ 按 state.json 恢复挂载,随后平台同步的列表为准 |
+| 现场外接盘 `/media/jerry-remote/Data` | ✅ 识别为 `/dev/sda`,UUID 88bc33be-…,设备内路径 `/` |
+
+**待测**:
+- 两个引擎的沙箱中读取挂载目录(需要后台登记本机目录后开会话);
+- MinIO 加 rclone 两层挂载的读取、pandas 随机读取、断网与恢复(第三期)。
 
 ## 12. 以后可以支持的介质(不在本次范围)
 
@@ -399,3 +413,17 @@ profiles
 - 文件访问:只有托管目录可写;网络链接不对应路径;`/srv/agent-server/mounter/` 加入内置全局禁止项。去掉"任意路径"的检查(`checkKnowledgePath`),`/paths/check` 只保留通用检查。
 - 后台:资料库页按类型新建,显示状态与所属集合,托管目录可在文件管理中打开,网络链接可测试访问;资料集合的增删改与使用者;Profile 中分别勾选集合与单个资料库,并显示实际可用的列表。
 - 测试:`test/knowledge.test.ts`,以及 access-policy、gateway(同源放行、跳转到其他站点仍拦截)、file-manager 的相应修改。
+
+### 第二期(2026-11-06)
+
+- 挂载服务 `apps/mounter`(Go,go-fuse):
+  - `rules.go`:允许范围(写死 `/home`、`/mnt`、`/media`,不能选根目录本身、用户主目录本身、隐藏目录)、逐级选择目录;
+  - `device.go`:解析 `/proc/self/mountinfo`、`/dev/disk/by-uuid`,识别系统盘与外接设备,按 UUID 找到设备当前的挂载位置;设备已连接未挂载时访问原路径以触发自动挂载;
+  - `fs.go`:只读文件系统,隐藏 `.` 开头的文件,统一权限(文件 0444、目录 0555、属主 root),每次访问核对源目录的设备号和 inode 号;
+  - `manager.go`:`sync` 增删改、状态、state.json、启动时清理残留挂载、监听挂载表;不可用时有人访问会触发重新检查(限速 2 秒);
+  - `server.go`:Unix socket 协议(`ping`、`status`、`list`、`check`、`sync`),密钥每次启动重新生成。
+  - 测试:路径规则、设备识别(模拟挂载表:拔出、换位置、换一块盘、已连接未挂载)、真实 FUSE 挂载(隐藏文件、只读、权限、符号链接替换、不占用源目录、源目录被替换、路径消失与恢复、卸载)、密钥。
+- 部署:`deploy/agent-mounter.service`(以执行 deploy.sh 的用户运行,附加 agentsvc 组);`deploy.sh` 编译挂载服务,创建 `knowledge/mnt`、`mounter` 目录,程序或 unit 有变化时才重启挂载服务;`install.sh` 开启 `user_allow_other`;平台服务 `Wants=agent-mounter.service`。
+- 平台:`knowledge/mounter.ts`(socket 客户端;`KnowledgeMounts` 负责同步与状态:变更时 sync,挂载服务不可用时按 1 秒起、最长 60 秒的间隔重试;status 发现登记不一致时重新同步)。本机目录新建、修改路径时由挂载服务检查并记录 UUID 与设备内路径;路径不变时不重新检查(设备拔出时也能修改名称、说明)。会话开始时如果用到本机目录,先查询状态(超时 1 秒)。后台接口 `/knowledge/mounter`、`/knowledge/browse`、`/knowledge/check-local`。挂载服务目录在后台文件管理中也不显示。
+- 后台:本机目录可以新建,逐级选择目录,显示所在设备与识别方式;挂载服务不可用时页面顶部提示;外接硬盘的 fstab 配置说明。
+- 新增状态 `mount_failed`(挂载失败)。SDK 升级到 0.7.0(共享类型有变化)。

+ 1 - 1
packages/chat-ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@agent-server/chat-ui",
-  "version": "0.6.0",
+  "version": "0.7.0",
   "private": true,
   "type": "module",
   "exports": {

+ 1 - 1
packages/client/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@agent-server/client",
-  "version": "0.6.0",
+  "version": "0.7.0",
   "private": true,
   "type": "module",
   "exports": {

+ 1 - 1
packages/shared/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@agent-server/shared",
-  "version": "0.6.0",
+  "version": "0.7.0",
   "private": true,
   "type": "module",
   "exports": {

+ 30 - 2
packages/shared/src/api.ts

@@ -120,9 +120,9 @@ export type KnowledgeKind = 'managed' | 'local' | 'remote' | 'link'
 
 /**
  * 资料库状态:ok 正常;path_missing 路径不存在;device_missing 设备未连接;not_allowed 位置不允许;
- * unreachable 无法连接;auth_failed 认证失败;mounter_down 挂载服务不可用;pending 正在挂载
+ * unreachable 无法连接;auth_failed 认证失败;mounter_down 挂载服务不可用;pending 正在挂载;mount_failed 挂载失败
  */
-export type KnowledgeState = 'ok' | 'path_missing' | 'device_missing' | 'not_allowed' | 'unreachable' | 'auth_failed' | 'mounter_down' | 'pending'
+export type KnowledgeState = 'ok' | 'path_missing' | 'device_missing' | 'not_allowed' | 'unreachable' | 'auth_failed' | 'mounter_down' | 'pending' | 'mount_failed'
 
 export interface KnowledgeStatus {
   state: KnowledgeState
@@ -196,6 +196,34 @@ export interface KnowledgeCollectionInput {
   rootIds: string[]
 }
 
+/** 挂载服务的状态 */
+export interface KnowledgeMounterDto {
+  available: boolean
+  version: string | null
+  /** 不可用的原因 */
+  error: string | null
+}
+
+/** 逐级选择本机目录:只列出子目录,隐藏以 . 开头的;path 为空时列出允许的根目录 */
+export interface KnowledgeBrowseDto {
+  path: string
+  /** 上一级(为空表示回到允许的根目录列表) */
+  parent: string
+  entries: { name: string; path: string; selectable: boolean; reason?: string }[]
+}
+
+/** 本机目录的检查结果:解析符号链接后的路径,以及所在的设备 */
+export interface KnowledgeLocalCheckDto {
+  path: string
+  /** 在系统盘以外的设备上时为文件系统 UUID,否则为 null */
+  uuid: string | null
+  relPath: string | null
+  systemDisk: boolean
+  mountPoint: string
+  fsType: string
+  device: string
+}
+
 /** 网络链接的测试访问结果 */
 export interface KnowledgeLinkTestDto {
   ok: boolean