Browse Source

feat: 资料库第三期——远程存储(S3 兼容、WebDAV):挂载服务为每个远程存储启动固定版本的 rclone(v1.75.2,部署时校验 sha256),经内部挂载再只读提供给 Agent、隐藏以 . 开头的文件;凭据只写不读、经环境变量传给 rclone、不进命令行和审计日志;状态区分认证失败、无法连接、路径不存在,定时连接检查、rclone 退出后自动重启;后台远程存储表单与测试连接;SDK 0.8.0

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

+ 1 - 1
README.md

@@ -72,7 +72,7 @@ NODE_ENV=production node apps/server/dist/main.js
 ```bash
 pnpm test        # 服务端单元与集成测试(文件访问策略、API 全流程、LibreOffice 转换)
 pnpm typecheck   # 全部包类型检查
-(cd apps/mounter && go test ./...)   # 资料库挂载服务(含真实 FUSE 挂载测试)
+(cd apps/mounter && RCLONE_BIN=/srv/agent-server/app/mounter/rclone go test ./...)   # 资料库挂载服务(含真实 FUSE 挂载、rclone 两层挂载测试)
 ```
 
 `apps/server/scripts/` 下有两个手动验证脚本(会消耗真实额度):`smoke-claude.ts` 用来验证 SDK 和登录态,`try-claude-adapter.ts` 用来验证审批流和越权拦截。

+ 2 - 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, KnowledgeBrowseDto, KnowledgeCollectionDto, KnowledgeCollectionInput, KnowledgeInput, KnowledgeLinkTestDto, KnowledgeLocalCheckDto, KnowledgeMounterDto, 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, KnowledgeRemoteTestDto, 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'
 
@@ -168,6 +168,7 @@ export const admin = {
   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)}`),
+  testKnowledgeRemote: (remote: NonNullable<KnowledgeInput['remote']>, id?: string) => req<KnowledgeRemoteTestDto>('POST', '/knowledge/test-remote', { remote, id }),
   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),

+ 96 - 6
apps/admin/src/views/KnowledgeView.vue

@@ -3,8 +3,9 @@
 import { computed, onMounted, ref } from 'vue'
 import type {
   KnowledgeBrowseDto, KnowledgeCollectionDto, KnowledgeCollectionInput, KnowledgeInput, KnowledgeKind, KnowledgeLinkTestDto, KnowledgeLocalCheckDto,
-  KnowledgeMounterDto, KnowledgeRootDto, KnowledgeState,
+  KnowledgeMounterDto, KnowledgeRemoteTestDto, KnowledgeRemoteType, KnowledgeRootDto, KnowledgeState,
 } from '@agent-server/shared'
+import { KNOWLEDGE_REMOTE_TYPES } from '@agent-server/shared'
 import PageHeader from '../components/PageHeader.vue'
 import { admin, errorMessage } from '../lib/api'
 import { formatTime, toast } from '../lib/ui'
@@ -23,11 +24,14 @@ const pickerLoading = ref(false)
 const localCheck = ref<KnowledgeLocalCheckDto | null>(null)
 const localError = ref('')
 const showDeviceHelp = ref(false)
+// 远程存储:"测试连接"结果;已设置的凭据(编辑时留空表示沿用)
+const remoteTest = ref<KnowledgeRemoteTestDto | null>(null)
+const remoteSet = ref<Record<string, boolean>>({})
 
 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: true },
-  { kind: 'remote', label: '远程存储', icon: 'cloud', desc: 'S3 兼容存储(MinIO、OSS、COS 等)、WebDAV,只读挂载,按需读取', available: false },
+  { kind: 'remote', label: '远程存储', icon: 'cloud', desc: 'S3 兼容存储(MinIO、OSS、COS 等)、WebDAV,只读挂载,按需读取', available: true },
   { kind: 'link', label: '网络链接', icon: 'link', desc: '网页或文件地址,Agent 用时自己读取;同一站点下的内网地址也可以访问', available: true },
 ]
 const kindOf = (k: KnowledgeKind) => KINDS.find(x => x.kind === k)!
@@ -40,7 +44,45 @@ const hasMounted = computed(() => roots.value.some(k => k.kind === 'local' || k.
 
 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
+const where = (k: KnowledgeRootDto) => {
+  if (k.kind === 'link') return k.url
+  if (k.kind === 'local') return k.local?.path
+  if (k.kind === 'remote' && k.remote) {
+    const p = k.remote.params
+    return `${KNOWLEDGE_REMOTE_TYPES[k.remote.type].label}:${p.url ?? `${p.endpoint ?? ''} ${p.bucket ?? ''}${p.prefix ? `/${p.prefix}` : ''}`}`
+  }
+  return k.path
+}
+
+function emptyRemote(type: KnowledgeRemoteType): NonNullable<KnowledgeInput['remote']> {
+  const def = KNOWLEDGE_REMOTE_TYPES[type]
+  const params: Record<string, string> = {}
+  for (const f of def.params) params[f.name] = f.options?.[0]?.value ?? ''
+  return { type, params, secrets: def.secrets.map(f => ({ name: f.name, value: '' })) }
+}
+
+function setRemoteType(type: KnowledgeRemoteType) {
+  if (!editing.value) return
+  editing.value.remote = emptyRemote(type)
+  remoteSet.value = {}
+  remoteTest.value = null
+}
+
+const remoteDef = computed(() => editing.value?.remote ? KNOWLEDGE_REMOTE_TYPES[editing.value.remote.type] : null)
+
+async function testRemote() {
+  const e = editing.value
+  if (!e?.remote) return
+  testing.value = true
+  remoteTest.value = null
+  try {
+    remoteTest.value = await admin.testKnowledgeRemote(e.remote, e.id)
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    testing.value = false
+  }
+}
 
 async function load() {
   ;[roots.value, collections.value, mounter.value] = await Promise.all([
@@ -55,15 +97,27 @@ function edit(k?: KnowledgeRootDto) {
   picker.value = null
   localCheck.value = null
   localError.value = ''
+  remoteTest.value = null
+  remoteSet.value = Object.fromEntries((k?.remote?.secrets ?? []).map(s => [s.name, s.isSet]))
+  const remote = k?.remote
+    ? { ...emptyRemote(k.remote.type), params: { ...emptyRemote(k.remote.type).params, ...k.remote.params } }
+    : emptyRemote('s3')
   editing.value = k
-    ? { 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: '' }
+    ? { 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 ?? '', remote }
+    : { kind: 'managed', name: '', description: '', access: 'read', enabled: true, url: '', localPath: '', remote }
 }
 
 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()
+  if (e.kind === 'remote') {
+    const r = e.remote
+    if (!r) return false
+    const def = KNOWLEDGE_REMOTE_TYPES[r.type]
+    return def.params.every(f => !f.required || !!r.params[f.name]?.trim())
+      && def.secrets.every(f => !f.required || !!r.secrets.find(s => s.name === f.name)?.value || remoteSet.value[f.name])
+  }
   return e.kind !== 'link' || !!e.url?.trim()
 })
 
@@ -107,7 +161,8 @@ async function save() {
   if (!e) return
   saving.value = true
   try {
-    const { id, ...body } = e
+    const { id, ...rest } = e
+    const body = { ...rest, remote: e.kind === 'remote' ? e.remote : undefined }
     if (id) await admin.updateKnowledge(id, body)
     else await admin.createKnowledge(body)
     editing.value = null
@@ -319,6 +374,41 @@ onMounted(load)
             <div>桌面自动挂载(/media/&lt;登录用户&gt;/…)需要有人登录桌面,而且只有那个用户能读,不适合作为资料来源。</div>
           </div>
         </div>
+        <div v-if="editing.kind === 'remote' && editing.remote && remoteDef" class="flex flex-col gap-2">
+          <div class="flex gap-4">
+            <label v-for="(d, t) in KNOWLEDGE_REMOTE_TYPES" :key="t" class="flex items-center gap-1.5" :class="editing.id ? 'opacity-60' : 'cursor-pointer'">
+              <input type="radio" :checked="editing.remote.type === t" :disabled="!!editing.id" @change="setRemoteType(t)">{{ d.label }}
+            </label>
+          </div>
+          <div v-for="f in remoteDef.params" :key="f.name">
+            <label class="label" :for="`r-${f.name}`">{{ f.label }}<span v-if="f.required" class="text-error"> *</span></label>
+            <select v-if="f.options" :id="`r-${f.name}`" v-model="editing.remote.params[f.name]" class="field">
+              <option v-for="o in f.options" :key="o.value" :value="o.value">{{ o.label }}</option>
+            </select>
+            <input v-else :id="`r-${f.name}`" v-model="editing.remote.params[f.name]" class="field font-mono" :placeholder="f.placeholder">
+            <div v-if="f.hint" class="hint">{{ f.hint }}</div>
+          </div>
+          <div v-for="sec in editing.remote.secrets" :key="sec.name">
+            <label class="label" :for="`s-${sec.name}`">
+              {{ remoteDef.secrets.find(f => f.name === sec.name)?.label }}<span v-if="remoteDef.secrets.find(f => f.name === sec.name)?.required && !remoteSet[sec.name]" class="text-error"> *</span>
+            </label>
+            <input :id="`s-${sec.name}`" v-model="sec.value" type="password" autocomplete="new-password" class="field font-mono" :placeholder="remoteSet[sec.name] ? '已设置,留空表示不修改' : ''">
+          </div>
+          <div class="flex items-center gap-2">
+            <button type="button" class="btn-secondary" :disabled="testing" @click="testRemote"><span class="codicon codicon-plug" />测试连接</button>
+            <span v-if="testing" class="hint">正在连接…</span>
+          </div>
+          <div v-if="remoteTest" class="text-[12px] flex flex-col gap-0.5" :class="remoteTest.ok ? 'text-success' : 'text-error'">
+            <div class="flex items-start gap-1">
+              <span class="codicon mt-0.5" :class="remoteTest.ok ? 'codicon-pass' : 'codicon-error'" />
+              <span v-if="remoteTest.ok">连接成功{{ remoteTest.entries.length ? `,根目录下有:${remoteTest.entries.slice(0, 10).join('、')}${remoteTest.entries.length > 10 ? ' 等' : ''}` : ',根目录为空' }}</span>
+              <span v-else>{{ STATE_LABEL[remoteTest.state] }}:{{ remoteTest.message }}</span>
+            </div>
+          </div>
+          <div class="hint">
+            只读挂载给 Agent,用到时才下载,缓存最多 1 GB、1 小时后清除;远程的改动约 1 分钟后可见。建议使用只读权限的密钥;文件很多时用路径前缀只挂需要的部分。凭据保存后不再显示。
+          </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,按产品型号分目录" />

+ 4 - 4
apps/mounter/fs.go

@@ -402,10 +402,10 @@ func mountFS(mountpoint, name string, src *source) (*fuse.Server, error) {
 		AttrTimeout:     &sec,
 		NegativeTimeout: &sec,
 		MountOptions: fuse.MountOptions{
-			AllowOther: true,
-			FsName:     name,
-			Name:       "agentkb",
-			Options:    []string{"ro", "nosuid", "nodev", "noexec"},
+			AllowOther:   true,
+			FsName:       name,
+			Name:         "agentkb",
+			Options:      []string{"ro", "nosuid", "nodev", "noexec"},
 			MaxReadAhead: 128 * 1024,
 		},
 	})

+ 18 - 1
apps/mounter/main.go

@@ -14,11 +14,12 @@ import (
 	"golang.org/x/sys/unix"
 )
 
-const version = "1.0.0"
+const version = "1.1.0"
 
 func main() {
 	dir := flag.String("dir", "/srv/agent-server/mounter", "挂载服务目录(socket、密钥、state.json)")
 	mnt := flag.String("mnt", "/srv/agent-server/knowledge/mnt", "挂载点所在目录")
+	rclone := flag.String("rclone", "/srv/agent-server/app/mounter/rclone", "rclone 程序(远程存储)")
 	showVersion := flag.Bool("version", false, "显示版本")
 	flag.Parse()
 	if *showVersion {
@@ -39,6 +40,22 @@ func main() {
 	unix.Umask(0o027)
 
 	mgr := newManager(realSystem{}, allowedRoots, filepath.Clean(*mnt), filepath.Join(*dir, "state.json"))
+	if _, err := os.Stat(*rclone); err == nil {
+		remoteDir, cacheDir := filepath.Join(*dir, "remote"), filepath.Join(*dir, "cache")
+		for _, d := range []string{remoteDir, cacheDir} {
+			if err := os.MkdirAll(d, 0o700); err != nil {
+				log.Fatalf("创建 %s 失败:%v", d, err)
+			}
+		}
+		// 空的配置文件:不读取部署用户自己的 rclone.conf,连接参数全部来自环境变量
+		conf := filepath.Join(*dir, "rclone.conf")
+		if err := os.WriteFile(conf, nil, 0o600); err != nil {
+			log.Fatalf("创建 %s 失败:%v", conf, err)
+		}
+		mgr.withRemote(&rcloneRunner{bin: *rclone, config: conf, home: *dir}, remoteDir, cacheDir)
+	} else {
+		log.Printf("没有找到 rclone(%s),远程存储不可用", *rclone)
+	}
 	mgr.cleanupStale()
 	if specs := mgr.loadState(); len(specs) > 0 {
 		if _, err := mgr.sync(specs, false); err != nil {

+ 135 - 12
apps/mounter/manager.go

@@ -23,9 +23,10 @@ import (
 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"`
+	ID     string       `json:"id"`
+	Kind   string       `json:"kind"`
+	Local  *localSource `json:"local,omitempty"`
+	Remote *remoteSpec  `json:"remote,omitempty"`
 }
 
 type mountStatus struct {
@@ -38,14 +39,16 @@ type mountStatus struct {
 }
 
 type mount struct {
-	spec     mountSpec
-	point    string
-	src      *source
-	server   *fuse.Server
-	state    string
-	message  string
-	since    time.Time
-	realPath string
+	spec      mountSpec
+	point     string
+	src       *source
+	server    *fuse.Server
+	state     string
+	message   string
+	since     time.Time
+	realPath  string
+	remote    *remoteProc // 远程存储的 rclone 挂载
+	remoteErr string      // 远程存储配置不正确
 }
 
 type manager struct {
@@ -58,12 +61,22 @@ type manager struct {
 	lastMiss  time.Time
 	// mountFn 便于测试替换
 	mountFn func(point, name string, src *source) (*fuse.Server, error)
+	// 远程存储:rclone 的内部挂载点与缓存目录
+	remoteDir string
+	cacheDir  string
+	runner    *rcloneRunner
 }
 
 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}
 }
 
+// withRemote 启用远程存储(rclone 程序、内部挂载点目录、缓存目录)
+func (m *manager) withRemote(runner *rcloneRunner, remoteDir, cacheDir string) *manager {
+	m.runner, m.remoteDir, m.cacheDir = runner, remoteDir, cacheDir
+	return m
+}
+
 func (m *manager) setState(mt *mount, state, msg string) {
 	if mt.state != state || mt.message != msg {
 		if mt.state != "" {
@@ -99,8 +112,44 @@ func (m *manager) resolve(spec mountSpec) (string, uint64, uint64, *checkError)
 	return real, st.Dev, st.Ino, nil
 }
 
+// resolveRemote 远程存储:rclone 已挂载时,源目录为内部挂载点;状态来自最近一次连接检查
+func (m *manager) resolveRemote(mt *mount) (string, uint64, uint64, string, string) {
+	if mt.remoteErr != "" {
+		return "", 0, 0, "mount_failed", mt.remoteErr
+	}
+	if mt.remote == nil {
+		return "", 0, 0, "mount_failed", "没有配置 rclone"
+	}
+	mounted, state, msg := mt.remote.status()
+	if !mounted || !mt.remote.isMounted() {
+		if state == "ok" {
+			state, msg = "pending", "正在连接"
+		}
+		return "", 0, 0, state, msg
+	}
+	var st unix.Stat_t
+	if err := unix.Stat(mt.remote.point, &st); err != nil {
+		return "", 0, 0, "unreachable", "无法访问 rclone 挂载:" + err.Error()
+	}
+	return mt.remote.point, st.Dev, st.Ino, state, msg
+}
+
 // refreshLocked 重新计算一个挂载的状态
 func (m *manager) refreshLocked(mt *mount) {
+	if mt.spec.Kind == "remote" {
+		real, dev, ino, state, msg := m.resolveRemote(mt)
+		if real == "" {
+			mt.src.close()
+		} else {
+			mt.src.set(real, dev, ino)
+		}
+		mt.realPath = ""
+		if mt.server == nil && state == "ok" {
+			state, msg = "mount_failed", "挂载失败"
+		}
+		m.setState(mt, state, msg)
+		return
+	}
 	real, dev, ino, cerr := m.resolve(mt.spec)
 	if cerr != nil {
 		mt.src.close()
@@ -163,6 +212,39 @@ func (m *manager) unmountLocked(mt *mount) {
 	}
 	mt.src.close()
 	_ = os.Remove(mt.point)
+	if mt.remote != nil {
+		mt.remote.shutdown()
+		mt.remote = nil
+	}
+}
+
+// setupRemoteLocked 远程存储:配置变化时重启 rclone,返回新启动的进程
+func (m *manager) setupRemoteLocked(mt *mount) *remoteProc {
+	mt.remoteErr = ""
+	if m.runner == nil {
+		mt.remoteErr = "挂载服务没有配置 rclone"
+		return nil
+	}
+	env, err := buildRemote(*mt.spec.Remote, m.runner.bin)
+	if err != nil {
+		mt.remoteErr = err.Error()
+		if mt.remote != nil {
+			mt.remote.shutdown()
+			mt.remote = nil
+		}
+		return nil
+	}
+	key := remoteKey(mt.spec.Remote)
+	if mt.remote != nil && mt.remote.key == key {
+		return nil
+	}
+	if mt.remote != nil {
+		log.Printf("%s:远程存储配置有变化,重新连接", mt.spec.ID)
+		mt.remote.shutdown()
+	}
+	mt.remote = newRemoteProc(mt.spec.ID, m.remoteDir, m.cacheDir, m.runner, m.sys, env, key, func() { go m.refreshAll() })
+	mt.remote.start()
+	return mt.remote
 }
 
 // sync 按完整列表挂载、卸载、更新
@@ -175,9 +257,16 @@ func (m *manager) sync(specs []mountSpec, save bool) ([]mountStatus, error) {
 		if s.Kind == "local" && (s.Local == nil || !filepath.IsAbs(s.Local.Path)) {
 			return nil, fmt.Errorf("%s:缺少本机目录", s.ID)
 		}
+		if s.Kind == "remote" && s.Remote == nil {
+			return nil, fmt.Errorf("%s:缺少远程存储配置", s.ID)
+		}
+		if s.Kind != "local" && s.Kind != "remote" {
+			return nil, fmt.Errorf("%s:不支持的类型 %s", s.ID, s.Kind)
+		}
 		want[s.ID] = s
 	}
 	m.mu.Lock()
+	var started []*remoteProc
 	for id, mt := range m.mounts {
 		if _, ok := want[id]; !ok {
 			m.unmountLocked(mt)
@@ -193,8 +282,17 @@ func (m *manager) sync(specs []mountSpec, save bool) ([]mountStatus, error) {
 			m.mounts[id] = mt
 			log.Printf("%s:登记 %s", id, describeSpec(s))
 		} else {
+			if mt.spec.Kind != s.Kind && mt.remote != nil {
+				mt.remote.shutdown()
+				mt.remote = nil
+			}
 			mt.spec = s
 		}
+		if s.Kind == "remote" {
+			if p := m.setupRemoteLocked(mt); p != nil {
+				started = append(started, p)
+			}
+		}
 		m.mountLocked(mt)
 		m.refreshLocked(mt)
 	}
@@ -204,10 +302,35 @@ func (m *manager) sync(specs []mountSpec, save bool) ([]mountStatus, error) {
 			log.Printf("保存 state.json 失败:%v", err)
 		}
 	}
+	// 新连接的远程存储:等首次得出状态(最多 20 秒),让平台拿到准确的状态
+	timeout := time.After(20 * time.Second)
+	for _, p := range started {
+		select {
+		case <-p.ready:
+		case <-timeout:
+		}
+	}
+	if len(started) > 0 {
+		m.refreshAll()
+	}
 	return m.status(), nil
 }
 
+// checkRemotes 状态查询时,超过 30 秒没检查过的远程存储在后台重新检查
+func (m *manager) checkRemotes() {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	for _, mt := range m.mounts {
+		if mt.remote != nil {
+			mt.remote.checkIfStale()
+		}
+	}
+}
+
 func describeSpec(s mountSpec) string {
+	if s.Remote != nil {
+		return fmt.Sprintf("远程存储 %s %s", s.Remote.Type, s.Remote.Params["endpoint"]+s.Remote.Params["url"]+"/"+s.Remote.Params["bucket"])
+	}
 	if s.Local != nil {
 		if s.Local.UUID != "" {
 			return fmt.Sprintf("%s(UUID %s,设备内 %s)", s.Local.Path, s.Local.UUID, s.Local.RelPath)
@@ -274,7 +397,7 @@ func (m *manager) loadState() []mountSpec {
 // cleanupStale 启动时清理 mnt/ 下残留的挂载(上次进程退出后失效)和多余的空目录
 func (m *manager) cleanupStale() {
 	for _, e := range m.sys.mountinfo() {
-		if filepath.Dir(e.Point) == m.mntDir {
+		if filepath.Dir(e.Point) == m.mntDir || (m.remoteDir != "" && filepath.Dir(e.Point) == m.remoteDir) {
 			if err := exec.Command("fusermount3", "-uz", e.Point).Run(); err != nil {
 				log.Printf("清理残留挂载 %s 失败:%v", e.Point, err)
 			} else {

+ 589 - 0
apps/mounter/remote.go

@@ -0,0 +1,589 @@
+package main
+
+// 远程存储(docs/design/21-knowledge-sources.md §6):每个资料库一个 rclone 子进程,只读挂到 mounter/remote/<id>(内部,只有本用户能访问),
+// 再由本进程的文件系统(隐藏文件过滤、只读)提供给 knowledge/mnt/<id>。
+// 连接参数和凭据通过子进程的环境变量传给 rclone,不放在命令行里(命令行在进程列表中所有人可见)。
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"log"
+	"net/url"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"regexp"
+	"sort"
+	"strings"
+	"sync"
+	"syscall"
+	"time"
+)
+
+type remoteSpec struct {
+	Type    string            `json:"type"`
+	Params  map[string]string `json:"params"`
+	Secrets map[string]string `json:"secrets"`
+}
+
+// allowLocalRemote 仅测试使用:rclone 的 local 后端,不需要网络
+var allowLocalRemote = false
+
+var (
+	bucketPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$`)
+	s3Providers   = map[string]bool{"Minio": true, "AWS": true, "Alibaba": true, "TencentCOS": true, "HuaweiOBS": true, "Qiniu": true, "Ceph": true, "Cloudflare": true, "Other": true}
+	webdavVendors = map[string]bool{"other": true, "nextcloud": true, "owncloud": true, "sharepoint": true, "sharepoint-ntlm": true}
+)
+
+type rcloneEnv struct {
+	env  []string // RCLONE_CONFIG_KB_*
+	path string   // kb:bucket/prefix
+}
+
+func clean(v string) (string, error) {
+	v = strings.TrimSpace(v)
+	if strings.ContainsAny(v, "\x00\r\n") {
+		return "", fmt.Errorf("参数中不能有换行")
+	}
+	return v, nil
+}
+
+func httpURL(v, name string) (string, error) {
+	u, err := url.Parse(v)
+	if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
+		return "", fmt.Errorf("%s必须是 http 或 https 地址", name)
+	}
+	if u.User != nil {
+		return "", fmt.Errorf("%s中不能包含用户名或密码", name)
+	}
+	return v, nil
+}
+
+// buildRemote 按存储类型生成 rclone 的环境变量与远程路径
+func buildRemote(r remoteSpec, rclone string) (*rcloneEnv, error) {
+	p := map[string]string{}
+	for k, v := range r.Params {
+		c, err := clean(v)
+		if err != nil {
+			return nil, err
+		}
+		p[k] = c
+	}
+	sec := func(k string) (string, error) {
+		v := r.Secrets[k]
+		if strings.ContainsAny(v, "\x00\r\n") {
+			return "", fmt.Errorf("凭据中不能有换行")
+		}
+		return v, nil
+	}
+	out := &rcloneEnv{}
+	add := func(k, v string) { out.env = append(out.env, "RCLONE_CONFIG_KB_"+k+"="+v) }
+	switch r.Type {
+	case "s3":
+		provider := p["provider"]
+		if provider == "" {
+			provider = "Other"
+		}
+		if !s3Providers[provider] {
+			return nil, fmt.Errorf("不支持的 S3 服务商:%s", provider)
+		}
+		if p["endpoint"] == "" && provider != "AWS" {
+			return nil, fmt.Errorf("请填写服务地址(endpoint)")
+		}
+		if p["endpoint"] != "" {
+			if _, err := httpURL(p["endpoint"], "服务地址"); err != nil {
+				return nil, err
+			}
+		}
+		if !bucketPattern.MatchString(p["bucket"]) {
+			return nil, fmt.Errorf("存储桶名称不正确")
+		}
+		ak, err := sec("accessKeyId")
+		if err != nil {
+			return nil, err
+		}
+		sk, err := sec("secretAccessKey")
+		if err != nil {
+			return nil, err
+		}
+		if ak == "" || sk == "" {
+			return nil, fmt.Errorf("请填写 Access Key 和 Secret Key")
+		}
+		add("TYPE", "s3")
+		add("PROVIDER", provider)
+		add("ENV_AUTH", "false")
+		add("ACCESS_KEY_ID", ak)
+		add("SECRET_ACCESS_KEY", sk)
+		add("NO_CHECK_BUCKET", "true")
+		if p["endpoint"] != "" {
+			add("ENDPOINT", p["endpoint"])
+		}
+		if p["region"] != "" {
+			add("REGION", p["region"])
+		}
+		prefix := strings.Trim(p["prefix"], "/")
+		if strings.Contains("/"+prefix+"/", "/../") {
+			return nil, fmt.Errorf("路径前缀不能包含 ..")
+		}
+		out.path = "kb:" + p["bucket"]
+		if prefix != "" {
+			out.path += "/" + prefix
+		}
+	case "webdav":
+		u, err := httpURL(p["url"], "地址")
+		if err != nil {
+			return nil, err
+		}
+		vendor := p["vendor"]
+		if vendor == "" {
+			vendor = "other"
+		}
+		if !webdavVendors[vendor] {
+			return nil, fmt.Errorf("不支持的 WebDAV 服务类型:%s", vendor)
+		}
+		add("TYPE", "webdav")
+		add("URL", u)
+		add("VENDOR", vendor)
+		if p["user"] != "" {
+			add("USER", p["user"])
+		}
+		pass, err := sec("password")
+		if err != nil {
+			return nil, err
+		}
+		if pass != "" {
+			ob, err := obscure(rclone, pass)
+			if err != nil {
+				return nil, err
+			}
+			add("PASS", ob)
+		}
+		out.path = "kb:"
+	case "local":
+		if !allowLocalRemote {
+			return nil, fmt.Errorf("不支持的存储类型:local")
+		}
+		add("TYPE", "local")
+		out.path = "kb:" + p["path"]
+	default:
+		return nil, fmt.Errorf("不支持的存储类型:%s", r.Type)
+	}
+	return out, nil
+}
+
+// obscure rclone 的 WebDAV 密码需要先混淆;明文从标准输入传入,不出现在命令行
+func obscure(rclone, pass string) (string, error) {
+	cmd := exec.Command(rclone, "obscure", "-")
+	cmd.Env = []string{"PATH=/usr/bin:/bin"}
+	cmd.Stdin = strings.NewReader(pass)
+	out, err := cmd.Output()
+	if err != nil {
+		return "", fmt.Errorf("处理密码失败:%v", err)
+	}
+	return strings.TrimSpace(string(out)), nil
+}
+
+// classify 根据 rclone 的错误输出判断状态
+func classify(stderr string) (state, msg string) {
+	low := strings.ToLower(stderr)
+	detail := lastError(stderr)
+	switch {
+	case containsAny(low, "signaturedoesnotmatch", "invalidaccesskeyid", "accessdenied", "access denied", "statuscode: 401", "statuscode: 403", "401 unauthorized", "403 forbidden", "authentication", "unauthorized"):
+		return "auth_failed", "认证失败或没有权限:" + detail
+	case containsAny(low, "nosuchbucket", "bucket does not exist", "directory not found", "404 not found", "statuscode: 404"):
+		return "path_missing", "存储桶或路径不存在:" + detail
+	case containsAny(low, "connection refused", "no such host", "i/o timeout", "timeout", "network is unreachable", "tls:", "x509", "connection reset", "unexpected eof"):
+		return "unreachable", "无法连接:" + detail
+	}
+	if detail == "" {
+		detail = "未知错误"
+	}
+	return "unreachable", detail
+}
+
+var (
+	apiErr = regexp.MustCompile(`api error ([A-Za-z]+): ([^\n]+)`)
+	netErr = regexp.MustCompile(`(dial tcp [^\n]*|lookup [^\n]*|tls: [^\n]*|x509: [^\n]*)`)
+)
+
+func lastError(s string) string {
+	if m := apiErr.FindAllStringSubmatch(s, -1); len(m) > 0 {
+		last := m[len(m)-1]
+		return strings.TrimSpace(last[1] + ":" + last[2])
+	}
+	if m := netErr.FindAllString(s, -1); len(m) > 0 {
+		return strings.TrimSpace(m[len(m)-1])
+	}
+	lines := strings.Split(strings.TrimSpace(s), "\n")
+	for i := len(lines) - 1; i >= 0; i-- {
+		l := lines[i]
+		if strings.Contains(l, "ERROR") || strings.Contains(l, "CRITICAL") || strings.Contains(l, "Failed") {
+			if j := strings.Index(l, ": "); j >= 0 && strings.Contains(l[:j], "/") {
+				l = l[j+2:]
+			}
+			if len(l) > 300 {
+				l = l[:300] + "…"
+			}
+			return strings.TrimSpace(l)
+		}
+	}
+	return ""
+}
+
+func containsAny(s string, subs ...string) bool {
+	for _, x := range subs {
+		if strings.Contains(s, x) {
+			return true
+		}
+	}
+	return false
+}
+
+// rcloneRunner 运行 rclone 的公共参数
+type rcloneRunner struct {
+	bin    string
+	config string // 空的配置文件,避免读取部署用户自己的 rclone.conf
+	home   string
+}
+
+func (r *rcloneRunner) command(ctx context.Context, env *rcloneEnv, args ...string) *exec.Cmd {
+	cmd := exec.CommandContext(ctx, r.bin, append(args, "--config", r.config)...)
+	cmd.Env = append([]string{"PATH=/usr/bin:/bin", "HOME=" + r.home}, env.env...)
+	return cmd
+}
+
+type probeResult struct {
+	State   string   `json:"state"`
+	Message string   `json:"message"`
+	Entries []string `json:"entries"`
+}
+
+// probe 列出远程根目录(带超时),用于"测试连接"与状态检查
+func (r *rcloneRunner) probe(parent context.Context, env *rcloneEnv, timeout time.Duration) probeResult {
+	ctx, cancel := context.WithTimeout(parent, timeout)
+	defer cancel()
+	cmd := r.command(ctx, env, "lsf", env.path, "--max-depth", "1", "--low-level-retries", "1", "--retries", "1", "--contimeout", "5s", "--timeout", "10s", "--log-level", "ERROR")
+	var out, errb bytes.Buffer
+	cmd.Stdout, cmd.Stderr = &limitWriter{w: &out, n: 64 << 10}, &limitWriter{w: &errb, n: 64 << 10}
+	err := cmd.Run()
+	if ctx.Err() == context.DeadlineExceeded {
+		return probeResult{State: "unreachable", Message: "连接超时"}
+	}
+	if ctx.Err() != nil {
+		return probeResult{State: "pending", Message: "已取消"}
+	}
+	// rclone lsf 出错时退出码可能为 0,以错误输出为准
+	if err != nil || strings.Contains(errb.String(), "ERROR") {
+		st, msg := classify(errb.String())
+		return probeResult{State: st, Message: msg}
+	}
+	var entries []string
+	for _, l := range strings.Split(strings.TrimSpace(out.String()), "\n") {
+		if l != "" && !strings.HasPrefix(l, ".") && len(entries) < 50 {
+			entries = append(entries, l)
+		}
+	}
+	return probeResult{State: "ok", Entries: entries}
+}
+
+type limitWriter struct {
+	w *bytes.Buffer
+	n int
+}
+
+func (l *limitWriter) Write(p []byte) (int, error) {
+	if rest := l.n - l.w.Len(); rest > 0 {
+		if len(p) > rest {
+			l.w.Write(p[:rest])
+		} else {
+			l.w.Write(p)
+		}
+	}
+	return len(p), nil
+}
+
+// ringLog 保留 rclone 最近的错误输出
+type ringLog struct {
+	mu  sync.Mutex
+	buf []string
+}
+
+func (r *ringLog) Write(p []byte) (int, error) {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	for _, l := range strings.Split(strings.TrimRight(string(p), "\n"), "\n") {
+		if l == "" {
+			continue
+		}
+		r.buf = append(r.buf, l)
+		if len(r.buf) > 50 {
+			r.buf = r.buf[len(r.buf)-50:]
+		}
+	}
+	return len(p), nil
+}
+
+func (r *ringLog) String() string {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	return strings.Join(r.buf, "\n")
+}
+
+func (r *ringLog) reset() {
+	r.mu.Lock()
+	r.buf = nil
+	r.mu.Unlock()
+}
+
+// remoteProc 一个远程存储的 rclone 挂载及其状态
+type remoteProc struct {
+	id     string
+	point  string // 内部挂载点
+	cache  string
+	runner *rcloneRunner
+	sys    system
+	env    *rcloneEnv
+	key    string // 配置的指纹,变化时重启
+
+	mu        sync.Mutex
+	mounted   bool
+	state     string
+	message   string
+	probedAt  time.Time
+	ready     chan struct{} // 首次得出状态后关闭
+	readyOnce sync.Once
+	stop      chan struct{}
+	done      chan struct{}
+	cmd       *exec.Cmd
+	logs      ringLog
+	onChange  func()
+}
+
+func (p *remoteProc) status() (mounted bool, state, msg string) {
+	p.mu.Lock()
+	defer p.mu.Unlock()
+	return p.mounted, p.state, p.message
+}
+
+func (p *remoteProc) set(mounted bool, state, msg string) {
+	p.mu.Lock()
+	changed := p.mounted != mounted || p.state != state || p.message != msg
+	p.mounted, p.state, p.message = mounted, state, msg
+	p.mu.Unlock()
+	if state != "pending" {
+		p.readyOnce.Do(func() { close(p.ready) })
+	}
+	if changed && p.onChange != nil {
+		p.onChange()
+	}
+}
+
+// isMounted 内部挂载点上确实是 rclone 的挂载(而不是下面的空目录)
+func (p *remoteProc) isMounted() bool {
+	for _, e := range p.sys.mountinfo() {
+		if e.Point == p.point && strings.HasPrefix(e.FSType, "fuse.rclone") {
+			return true
+		}
+	}
+	return false
+}
+
+// run 启动并看护 rclone:退出时按退避间隔重启
+func (p *remoteProc) run() {
+	defer close(p.done)
+	backoff := 5 * time.Second
+	for {
+		started := time.Now()
+		p.runOnce()
+		select {
+		case <-p.stop:
+			return
+		default:
+		}
+		if time.Since(started) > time.Minute {
+			backoff = 5 * time.Second
+		}
+		select {
+		case <-p.stop:
+			return
+		case <-time.After(backoff):
+		}
+		if backoff < 5*time.Minute {
+			backoff *= 2
+		}
+	}
+}
+
+func (p *remoteProc) runOnce() {
+	_ = exec.Command("fusermount3", "-uz", p.point).Run()
+	if err := os.MkdirAll(p.point, 0o700); err != nil {
+		p.set(false, "mount_failed", "创建内部挂载点失败:"+err.Error())
+		return
+	}
+	_ = os.MkdirAll(p.cache, 0o700)
+	p.logs.reset()
+	cmd := p.runner.command(context.Background(), p.env, "mount", p.env.path, p.point,
+		"--read-only", "--vfs-cache-mode", "full", "--cache-dir", p.cache,
+		"--vfs-cache-max-size", "1G", "--vfs-cache-max-age", "1h", "--dir-cache-time", "1m",
+		"--low-level-retries", "3", "--contimeout", "10s", "--timeout", "60s", "--log-level", "NOTICE")
+	cmd.Stdout, cmd.Stderr = &p.logs, &p.logs
+	cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGTERM}
+	if err := cmd.Start(); err != nil {
+		p.set(false, "mount_failed", "启动 rclone 失败:"+err.Error())
+		return
+	}
+	p.mu.Lock()
+	p.cmd = cmd
+	p.mu.Unlock()
+	exited := make(chan error, 1)
+	go func() { exited <- cmd.Wait() }()
+
+	// 等挂载出现
+	deadline := time.After(30 * time.Second)
+	tick := time.NewTicker(200 * time.Millisecond)
+	defer tick.Stop()
+wait:
+	for {
+		select {
+		case err := <-exited:
+			st, msg := classify(p.logs.String())
+			if msg == "未知错误" || msg == "" {
+				msg = fmt.Sprintf("rclone 退出:%v", err)
+			}
+			log.Printf("%s:rclone 退出:%s", p.id, lastError(p.logs.String()))
+			p.set(false, st, msg)
+			return
+		case <-p.stop:
+			p.terminate(exited)
+			return
+		case <-deadline:
+			log.Printf("%s:rclone 30 秒内没有完成挂载", p.id)
+			p.set(false, "unreachable", "挂载超时")
+			p.terminate(exited)
+			return
+		case <-tick.C:
+			if p.isMounted() {
+				break wait
+			}
+		}
+	}
+	log.Printf("%s:rclone 已挂载 %s", p.id, p.env.path)
+	p.set(true, "pending", "正在连接")
+	p.checkNow()
+
+	// 运行期间:未连通时每分钟检查一次,正常时每 5 分钟
+	for {
+		_, state, _ := p.status()
+		every := 5 * time.Minute
+		if state != "ok" {
+			every = time.Minute
+		}
+		select {
+		case err := <-exited:
+			st, msg := "unreachable", fmt.Sprintf("rclone 意外退出(%v),稍后自动重启", err)
+			if detail := lastError(p.logs.String()); detail != "" {
+				st, msg = classify(p.logs.String())
+			}
+			log.Printf("%s:rclone 意外退出(%v):%s", p.id, err, lastError(p.logs.String()))
+			p.set(false, st, msg)
+			_ = exec.Command("fusermount3", "-uz", p.point).Run()
+			return
+		case <-p.stop:
+			p.terminate(exited)
+			return
+		case <-time.After(every):
+			p.checkNow()
+		}
+	}
+}
+
+// checkNow 列远程根目录,得出连接状态
+func (p *remoteProc) checkNow() {
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+	go func() {
+		select {
+		case <-p.stop:
+			cancel()
+		case <-ctx.Done():
+		}
+	}()
+	r := p.runner.probe(ctx, p.env, 20*time.Second)
+	if r.State == "pending" {
+		return
+	}
+	if r.State == "ok" {
+		// 之前的错误已经过去,rclone 之后退出时不再据此判断
+		p.logs.reset()
+	}
+	p.mu.Lock()
+	p.probedAt = time.Now()
+	mounted := p.mounted
+	p.mu.Unlock()
+	p.set(mounted, r.State, r.Message)
+}
+
+// checkIfStale 状态查询时:超过 30 秒没检查过的,在后台重新检查
+func (p *remoteProc) checkIfStale() {
+	p.mu.Lock()
+	stale := p.mounted && time.Since(p.probedAt) > 30*time.Second
+	if stale {
+		p.probedAt = time.Now()
+	}
+	p.mu.Unlock()
+	if stale {
+		go p.checkNow()
+	}
+}
+
+func (p *remoteProc) terminate(exited chan error) {
+	p.mu.Lock()
+	cmd := p.cmd
+	p.mu.Unlock()
+	if cmd != nil && cmd.Process != nil {
+		_ = cmd.Process.Signal(syscall.SIGTERM)
+		select {
+		case <-exited:
+		case <-time.After(5 * time.Second):
+			_ = cmd.Process.Kill()
+			<-exited
+		}
+	}
+	_ = exec.Command("fusermount3", "-uz", p.point).Run()
+}
+
+func newRemoteProc(id, remoteDir, cacheDir string, runner *rcloneRunner, sys system, env *rcloneEnv, key string, onChange func()) *remoteProc {
+	return &remoteProc{
+		id: id, point: filepath.Join(remoteDir, id), cache: filepath.Join(cacheDir, id), runner: runner, sys: sys, env: env, key: key,
+		state: "pending", message: "正在连接", ready: make(chan struct{}), stop: make(chan struct{}), done: make(chan struct{}), onChange: onChange,
+	}
+}
+
+func (p *remoteProc) start() { go p.run() }
+
+// shutdown 停止 rclone、卸载、删除缓存
+func (p *remoteProc) shutdown() {
+	close(p.stop)
+	<-p.done
+	_ = os.Remove(p.point)
+	_ = os.RemoveAll(p.cache)
+}
+
+// remoteKey 配置指纹(含凭据),用于判断是否需要重启 rclone
+func remoteKey(r *remoteSpec) string {
+	var b strings.Builder
+	b.WriteString(r.Type)
+	for _, m := range []map[string]string{r.Params, r.Secrets} {
+		keys := make([]string, 0, len(m))
+		for k := range m {
+			keys = append(keys, k)
+		}
+		sort.Strings(keys)
+		for _, k := range keys {
+			fmt.Fprintf(&b, "\x00%s=%s", k, m[k])
+		}
+		b.WriteString("\x01")
+	}
+	return b.String()
+}

+ 145 - 0
apps/mounter/remote_test.go

@@ -0,0 +1,145 @@
+package main
+
+import (
+	"errors"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+)
+
+func TestBuildRemote(t *testing.T) {
+	env, err := buildRemote(remoteSpec{Type: "s3", Params: map[string]string{"provider": "Minio", "endpoint": "http://127.0.0.1:9198", "bucket": "kb", "prefix": "/docs/2024/"}, Secrets: map[string]string{"accessKeyId": "ak", "secretAccessKey": "sk"}}, "")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if env.path != "kb:kb/docs/2024" {
+		t.Errorf("远程路径:%s", env.path)
+	}
+	joined := strings.Join(env.env, "\n")
+	for _, want := range []string{"RCLONE_CONFIG_KB_TYPE=s3", "RCLONE_CONFIG_KB_PROVIDER=Minio", "RCLONE_CONFIG_KB_ENDPOINT=http://127.0.0.1:9198", "RCLONE_CONFIG_KB_SECRET_ACCESS_KEY=sk", "RCLONE_CONFIG_KB_ENV_AUTH=false"} {
+		if !strings.Contains(joined, want) {
+			t.Errorf("缺少 %s", want)
+		}
+	}
+	bad := []remoteSpec{
+		{Type: "s3", Params: map[string]string{"endpoint": "http://x", "bucket": "kb"}, Secrets: map[string]string{"accessKeyId": "ak"}},
+		{Type: "s3", Params: map[string]string{"endpoint": "ftp://x", "bucket": "kb"}, Secrets: map[string]string{"accessKeyId": "a", "secretAccessKey": "b"}},
+		{Type: "s3", Params: map[string]string{"endpoint": "http://x", "bucket": "-rf"}, Secrets: map[string]string{"accessKeyId": "a", "secretAccessKey": "b"}},
+		{Type: "s3", Params: map[string]string{"endpoint": "http://x", "bucket": "kb", "prefix": "a/../../b"}, Secrets: map[string]string{"accessKeyId": "a", "secretAccessKey": "b"}},
+		{Type: "s3", Params: map[string]string{"endpoint": "http://x\nRCLONE_X=1", "bucket": "kb"}, Secrets: map[string]string{"accessKeyId": "a", "secretAccessKey": "b"}},
+		{Type: "webdav", Params: map[string]string{"url": "http://u:p@dav.local/"}},
+		{Type: "webdav", Params: map[string]string{"url": "http://dav.local/", "vendor": "evil"}},
+		{Type: "local", Params: map[string]string{"path": "/"}},
+		{Type: "sftp"},
+	}
+	for i, r := range bad {
+		if _, err := buildRemote(r, ""); err == nil {
+			t.Errorf("第 %d 个配置应该被拒绝:%+v", i, r)
+		}
+	}
+}
+
+func TestClassify(t *testing.T) {
+	cases := map[string]string{
+		"ERROR : api error SignatureDoesNotMatch: The request signature we calculated does not match":                "auth_failed",
+		"ERROR : https response error StatusCode: 403, api error AccessDenied: Access Denied.":                       "auth_failed",
+		"ERROR : api error NoSuchBucket: The specified bucket does not exist":                                        "path_missing",
+		`ERROR : request send failed, Get "http://127.0.0.1:1/x": dial tcp 127.0.0.1:1: connect: connection refused`: "unreachable",
+		"ERROR : dial tcp: lookup nas.local: no such host":                                                           "unreachable",
+	}
+	for in, want := range cases {
+		if st, msg := classify(in); st != want || msg == "" {
+			t.Errorf("%q → %s %q,应为 %s", in, st, msg, want)
+		}
+	}
+	if _, msg := classify("2026/11/06 ERROR : api error AccessDenied: Access Denied."); msg != "认证失败或没有权限:AccessDenied:Access Denied." {
+		t.Errorf("错误信息:%q", msg)
+	}
+	long := `ERROR : error listing: operation error S3: ListObjectsV2, exceeded maximum number of attempts, 1, request send failed, Get "http://127.0.0.1:1/kb?x=1": dial tcp 127.0.0.1:1: connect: connection refused`
+	if _, msg := classify(long); msg != "无法连接:dial tcp 127.0.0.1:1: connect: connection refused" {
+		t.Errorf("网络错误信息:%q", msg)
+	}
+}
+
+// 需要 rclone:RCLONE_BIN 或 PATH 中的 rclone;用 rclone 的 local 后端,不需要网络
+func rcloneBin(t *testing.T) string {
+	if b := os.Getenv("RCLONE_BIN"); b != "" {
+		return b
+	}
+	if b, err := exec.LookPath("rclone"); err == nil {
+		return b
+	}
+	t.Skip("没有 rclone(设置 RCLONE_BIN)")
+	return ""
+}
+
+func TestRemoteMount(t *testing.T) {
+	needFuse(t)
+	bin := rcloneBin(t)
+	allowLocalRemote = true
+	defer func() { allowLocalRemote = false }()
+	base, roots := tempRoots(t)
+	data := filepath.Join(base, "remote-data")
+	os.MkdirAll(filepath.Join(data, "产品手册", ".git"), 0o755)
+	os.WriteFile(filepath.Join(data, "产品手册", "规格.csv"), []byte("型号,功率\nA100,5\n"), 0o644)
+	os.WriteFile(filepath.Join(data, ".env"), []byte("SECRET=1"), 0o644)
+	dir := filepath.Join(base, "mounter")
+	mntDir := filepath.Join(base, "kbmnt")
+	for _, d := range []string{dir, mntDir, filepath.Join(dir, "remote"), filepath.Join(dir, "cache")} {
+		os.MkdirAll(d, 0o755)
+	}
+	conf := filepath.Join(dir, "rclone.conf")
+	os.WriteFile(conf, nil, 0o600)
+	m := newManager(realSystem{}, roots, mntDir, filepath.Join(dir, "state.json")).withRemote(&rcloneRunner{bin: bin, config: conf, home: dir}, filepath.Join(dir, "remote"), filepath.Join(dir, "cache"))
+	defer m.unmountAll()
+
+	spec := mountSpec{ID: "kb_r1", Kind: "remote", Remote: &remoteSpec{Type: "local", Params: map[string]string{"path": data}}}
+	st, err := m.sync([]mountSpec{spec}, true)
+	if err != nil || len(st) != 1 || st[0].State != "ok" {
+		t.Fatalf("远程存储挂载:%+v %v", st, err)
+	}
+	mp := filepath.Join(mntDir, "kb_r1")
+	if got := names(t, mp); len(got) != 1 || got[0] != "产品手册" {
+		t.Errorf("目录列表(隐藏文件不可见):%v", got)
+	}
+	if b, err := os.ReadFile(filepath.Join(mp, "产品手册", "规格.csv")); err != nil || !strings.Contains(string(b), "A100") {
+		t.Errorf("读取:%q %v", b, err)
+	}
+	if _, err := os.Stat(filepath.Join(mp, ".env")); !errors.Is(err, os.ErrNotExist) {
+		t.Errorf(".env 应该不存在:%v", err)
+	}
+	if err := os.WriteFile(filepath.Join(mp, "x.txt"), []byte("x"), 0o644); err == nil {
+		t.Error("应该不能写入")
+	}
+	// 凭据不出现在命令行
+	m.mu.Lock()
+	cmd := m.mounts["kb_r1"].remote.cmd
+	m.mu.Unlock()
+	if strings.Contains(strings.Join(cmd.Args, " "), "RCLONE_CONFIG") {
+		t.Errorf("命令行中不应有配置:%v", cmd.Args)
+	}
+
+	// 配置错误:状态为挂载失败
+	bad := mountSpec{ID: "kb_r2", Kind: "remote", Remote: &remoteSpec{Type: "s3", Params: map[string]string{"bucket": "x"}}}
+	st, _ = m.sync([]mountSpec{spec, bad}, false)
+	if st[1].State != "mount_failed" {
+		t.Errorf("配置错误:%+v", st[1])
+	}
+
+	// 删除:停止 rclone、卸载、清理缓存
+	if _, err := m.sync(nil, false); err != nil {
+		t.Fatal(err)
+	}
+	time.Sleep(200 * time.Millisecond)
+	for _, p := range []string{mp, filepath.Join(dir, "remote", "kb_r1"), filepath.Join(dir, "cache", "kb_r1")} {
+		if _, err := os.Stat(p); !errors.Is(err, os.ErrNotExist) {
+			t.Errorf("%s 应该已删除:%v", p, err)
+		}
+	}
+	if cmd.ProcessState == nil {
+		t.Error("rclone 应该已退出")
+	}
+}

+ 3 - 1
apps/mounter/rules_test.go

@@ -34,7 +34,9 @@ func TestRuleProblem(t *testing.T) {
 	}
 }
 
-func contains(s, sub string) bool { return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0) }
+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 {

+ 15 - 0
apps/mounter/server.go

@@ -4,6 +4,7 @@ package main
 
 import (
 	"bufio"
+	"context"
 	"crypto/rand"
 	"crypto/subtle"
 	"encoding/hex"
@@ -20,6 +21,7 @@ type request struct {
 	Op     string      `json:"op"`
 	Path   string      `json:"path,omitempty"`
 	Mounts []mountSpec `json:"mounts,omitempty"`
+	Remote *remoteSpec `json:"remote,omitempty"`
 }
 
 type server struct {
@@ -66,6 +68,7 @@ func (s *server) handle(req request) map[string]any {
 	case "status":
 		// 顺便重新检查:路径被删除、改名不会引起挂载表变化
 		s.mgr.refreshAll()
+		s.mgr.checkRemotes()
 		return map[string]any{"ok": true, "version": s.version, "mounts": s.mgr.status()}
 	case "list":
 		res, cerr := browse(req.Path, s.mgr.roots)
@@ -79,6 +82,18 @@ func (s *server) handle(req request) map[string]any {
 			return fail(cerr)
 		}
 		return map[string]any{"ok": true, "result": identify(s.mgr.sys, real)}
+	case "test":
+		if req.Remote == nil {
+			return map[string]any{"ok": false, "error": "缺少远程存储配置"}
+		}
+		if s.mgr.runner == nil {
+			return map[string]any{"ok": false, "error": "挂载服务没有配置 rclone"}
+		}
+		env, err := buildRemote(*req.Remote, s.mgr.runner.bin)
+		if err != nil {
+			return map[string]any{"ok": false, "error": err.Error(), "state": "mount_failed"}
+		}
+		return map[string]any{"ok": true, "result": s.mgr.runner.probe(context.Background(), env, 20*time.Second)}
 	case "sync":
 		st, err := s.mgr.sync(req.Mounts, true)
 		if err != nil {

+ 8 - 1
apps/server/src/http/routes/admin.ts

@@ -462,7 +462,10 @@ 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
+    const kbWhere = (k: KnowledgeRootDto) => k.kind === 'link' ? k.url
+      : k.kind === 'local' ? k.local?.path
+      : k.kind === 'remote' ? [k.remote?.type, k.remote?.params.endpoint ?? k.remote?.params.url, k.remote?.params.bucket, k.remote?.params.prefix].filter(Boolean).join(' ')
+      : k.path
     g.get('/knowledge', async () => {
       await app.knowledge.refreshMounts(1000)
       return app.knowledge.list()
@@ -472,6 +475,10 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
       return app.knowledge.mounterInfo()
     })
     g.get<{ Querystring: { path?: string } }>('/knowledge/browse', async req => app.knowledge.browse(req.query.path ?? ''))
+    g.post('/knowledge/test-remote', async req => {
+      const body = parse(z.object({ remote: z.unknown(), id: z.string().optional() }), req.body)
+      return app.knowledge.testRemote(body.remote, body.id)
+    })
     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)

+ 23 - 6
apps/server/src/knowledge/mounter.ts

@@ -3,12 +3,23 @@
 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'
+import type { KnowledgeBrowseDto, KnowledgeLocalCheckDto, KnowledgeMounterDto, KnowledgeRemoteType, KnowledgeState, KnowledgeStatus } from '@agent-server/shared'
 
-export interface MountSpec {
-  id: string
-  kind: 'local'
-  local: { path: string; uuid: string | null; relPath: string | null }
+/** 远程存储的完整配置(含凭据,只发给挂载服务) */
+export interface RemoteSpec {
+  type: KnowledgeRemoteType
+  params: Record<string, string>
+  secrets: Record<string, string>
+}
+
+export type MountSpec =
+  | { id: string; kind: 'local'; local: { path: string; uuid: string | null; relPath: string | null } }
+  | { id: string; kind: 'remote'; remote: RemoteSpec }
+
+export interface RemoteProbe {
+  state: KnowledgeState
+  message: string
+  entries: string[] | null
 }
 
 export interface MountStatusRow {
@@ -24,6 +35,7 @@ export interface MounterApi {
   check(path: string): Promise<KnowledgeLocalCheckDto>
   sync(mounts: MountSpec[]): Promise<MountStatusRow[]>
   status(timeoutMs?: number): Promise<{ version: string; mounts: MountStatusRow[] }>
+  test(remote: RemoteSpec): Promise<RemoteProbe>
 }
 
 /** 挂载服务没有运行、连不上 */
@@ -47,7 +59,12 @@ export class MounterClient implements MounterApi {
   }
 
   sync(mounts: MountSpec[]) {
-    return this.call<{ mounts: MountStatusRow[] }>({ op: 'sync', mounts }, 30_000).then(r => r.mounts)
+    // 新连接的远程存储要等首次连接检查(最多 20 秒),停止 rclone 也要几秒
+    return this.call<{ mounts: MountStatusRow[] }>({ op: 'sync', mounts }, 60_000).then(r => r.mounts)
+  }
+
+  test(remote: RemoteSpec) {
+    return this.call<{ result: RemoteProbe }>({ op: 'test', remote }, 30_000).then(r => r.result)
   }
 
   status(timeoutMs = 5000) {

+ 83 - 12
apps/server/src/knowledge/service.ts

@@ -6,15 +6,16 @@ import { eq } from 'drizzle-orm'
 import { z } from 'zod'
 import type {
   KnowledgeBrowseDto, KnowledgeCollectionDto, KnowledgeCollectionInput, KnowledgeInput, KnowledgeLinkTestDto, KnowledgeLocalCheckDto, KnowledgeLocalSource,
-  KnowledgeMounterDto, KnowledgeRootDto, KnowledgeStatus,
+  KnowledgeMounterDto, KnowledgeRemoteTestDto, KnowledgeRemoteType, KnowledgeRootDto, KnowledgeStatus,
 } from '@agent-server/shared'
+import { KNOWLEDGE_REMOTE_TYPES } from '@agent-server/shared'
 import { knowledgeFilesDir, knowledgeMountDir } from '../config'
 import type { DB } from '../db'
 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'
+import { KnowledgeMounts, MounterRejected, MounterUnavailable, type MounterApi, type MountSpec, type RemoteSpec } from './mounter'
 
 type Row = typeof knowledgeRoots.$inferSelect
 
@@ -26,8 +27,15 @@ const inputSchema = z.object({
   access: z.enum(['read', 'write']).default('read'),
   url: z.string().trim().optional(),
   localPath: z.string().trim().optional(),
+  remote: z.object({
+    type: z.enum(['s3', 'webdav']),
+    params: z.record(z.string(), z.string().max(1000)).default({}),
+    secrets: z.array(z.object({ name: z.string(), value: z.string().max(4000).nullable() })).default([]),
+  }).optional(),
 })
 
+type RemoteInput = NonNullable<z.infer<typeof inputSchema>['remote']>
+
 const collectionSchema = z.object({
   name: z.string().trim().min(1, '名称不能为空').max(60, '名称不能超过 60 个字'),
   description: z.string().max(1000).default(''),
@@ -71,6 +79,18 @@ export class KnowledgeService {
     return this.mounter.list(p).catch(mounterError)
   }
 
+  /** 远程存储"测试连接":编辑已有资料库时,没有重新填写的凭据沿用原值 */
+  async testRemote(input: unknown, id?: string): Promise<KnowledgeRemoteTestDto> {
+    const remote = parse(inputSchema.shape.remote.unwrap(), input)
+    const prev = id ? this.row(id) : undefined
+    const spec = remoteSpec(remote, prev?.kind === 'remote' && prev.source && 'type' in prev.source && prev.source.type === remote.type ? prev.secrets : {})
+    const r = await this.mounter.test(spec).catch(err => {
+      if (err instanceof MounterRejected) return { state: 'mount_failed' as const, message: err.message, entries: [] }
+      return mounterError(err)
+    })
+    return { ok: r.state === 'ok', state: r.state, message: r.message || null, entries: r.entries ?? [] }
+  }
+
   /** 检查本机目录(新建前在后台预览:是否在系统盘、设备 UUID) */
   checkLocal(p: string): Promise<KnowledgeLocalCheckDto> {
     return this.mounter.check(p).catch(mounterError)
@@ -196,7 +216,7 @@ export class KnowledgeService {
   private toDto(r: Row, collectionIds: string[]): KnowledgeRootDto {
     const local = r.kind === 'local' ? r.source as KnowledgeLocalSource : null
     const remote = r.kind === 'remote' && r.source && 'type' in r.source
-      ? { type: r.source.type, params: r.source.params, secrets: Object.entries(r.secrets).map(([name, v]) => ({ name, isSet: !!v })) }
+      ? { type: r.source.type, params: r.source.params, secrets: KNOWLEDGE_REMOTE_TYPES[r.source.type].secrets.map(f => ({ name: f.name, isSet: !!r.secrets[f.name] })) }
       : null
     return {
       id: r.id, name: r.name, kind: r.kind, description: r.description, enabled: r.enabled,
@@ -230,19 +250,34 @@ export class KnowledgeService {
         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('远程存储尚未提供')
+      case 'remote': {
+        if (!d.remote) throw badRequest('请填写远程存储的连接参数')
+        const prevRow = prev ? this.row(prev.id) : undefined
+        const keep = prevRow?.source && 'type' in prevRow.source && prevRow.source.type === d.remote.type ? prevRow.secrets : {}
+        const spec = remoteSpec(d.remote, keep)
+        return { ...base, source: { type: spec.type, params: spec.params }, secrets: spec.secrets }
+      }
     }
   }
 
-  /** 发给挂载服务的登记列表:启用的本机目录 */
+  private row(id: string): Row {
+    const row = this.db.select().from(knowledgeRoots).where(eq(knowledgeRoots.id, id)).get()
+    if (!row) throw notFound('资料库')
+    return row
+  }
+
+  /** 发给挂载服务的登记列表:启用的本机目录与远程存储(远程存储含凭据) */
   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 } }
-      })
+    const out: MountSpec[] = []
+    for (const r of this.db.select().from(knowledgeRoots).all()) {
+      if (!r.enabled || !r.source) continue
+      if (r.kind === 'local' && 'path' in r.source) {
+        out.push({ id: r.id, kind: 'local', local: { path: r.source.path, uuid: r.source.uuid, relPath: r.source.relPath } })
+      } else if (r.kind === 'remote' && 'type' in r.source) {
+        out.push({ id: r.id, kind: 'remote', remote: { type: r.source.type, params: r.source.params, secrets: r.secrets } })
+      }
+    }
+    return out
   }
 
   private assertNameFree(name: string, self?: string) {
@@ -281,6 +316,42 @@ export class KnowledgeService {
 
 export const managedDir = (id: string) => path.join(knowledgeFilesDir(), id)
 
+/**
+ * 整理远程存储配置:只保留表单定义的字段;凭据留空(null 或空字符串)表示沿用原值。
+ * 必填项检查在这里做,连接参数的格式由挂载服务再检查一遍。
+ */
+function remoteSpec(input: RemoteInput, keepSecrets: Record<string, string>): RemoteSpec {
+  const def = KNOWLEDGE_REMOTE_TYPES[input.type as KnowledgeRemoteType]
+  const params: Record<string, string> = {}
+  for (const f of def.params) {
+    const v = (input.params[f.name] ?? '').trim()
+    if (f.required && !v) throw badRequest(`请填写${f.label}`)
+    if (f.options && v && !f.options.some(o => o.value === v)) throw badRequest(`${f.label}不正确:${v}`)
+    if (v) params[f.name] = v
+  }
+  if (input.type === 's3' && !params.endpoint && params.provider !== 'AWS') throw badRequest('请填写服务地址(endpoint)')
+  for (const k of ['endpoint', 'url']) {
+    if (!params[k]) continue
+    let u: URL
+    try {
+      u = new URL(params[k])
+    } catch {
+      throw badRequest(`地址格式不正确:${params[k]}`)
+    }
+    if (!['http:', 'https:'].includes(u.protocol)) throw badRequest('地址只支持 http 和 https')
+    if (u.username || u.password) throw badRequest('地址中不能包含用户名或密码,请分别填写')
+  }
+  const given = new Map(input.secrets.map(s => [s.name, s.value]))
+  const secrets: Record<string, string> = {}
+  for (const f of def.secrets) {
+    const v = given.get(f.name)
+    const value = v ? v : (keepSecrets[f.name] ?? '')
+    if (f.required && !value) throw badRequest(`请填写${f.label}`)
+    if (value) secrets[f.name] = value
+  }
+  return { type: input.type, params, secrets }
+}
+
 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)`)

+ 65 - 3
apps/server/test/knowledge.test.ts

@@ -9,13 +9,14 @@ import type { KnowledgeBrowseDto, KnowledgeLocalCheckDto, ProfileInput } from '@
 import { createApp, type App } from '../src/app'
 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'
+import { MounterRejected, MounterUnavailable, type MounterApi, type MountSpec, type MountStatusRow, type RemoteSpec } from '../src/knowledge/mounter'
 
 /** 模拟挂载服务:/home/u 下在系统盘,/media/u/Data 下在外接盘(UUID aaaa) */
 class FakeMounter implements MounterApi {
   up = true
   synced: MountSpec[][] = []
   checks: string[] = []
+  tests: RemoteSpec[] = []
   states = new Map<string, Omit<MountStatusRow, 'id'>>()
   private guard() {
     if (!this.up) throw new MounterUnavailable('挂载服务没有运行')
@@ -31,6 +32,13 @@ class FakeMounter implements MounterApi {
     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')
   }
+  async test(remote: RemoteSpec) {
+    this.guard()
+    this.tests.push(remote)
+    return remote.secrets.secretAccessKey === 'sk-secret'
+      ? { state: 'ok' as const, message: '', entries: ['产品手册/'] }
+      : { state: 'auth_failed' as const, message: '认证失败或没有权限:SignatureDoesNotMatch', entries: null }
+  }
   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' }) }))
   }
@@ -90,11 +98,11 @@ describe('资料库类型', () => {
     await expect(app.knowledge.create({ kind: 'link', name: '带密码', url: 'http://u:p@a.com/' })).rejects.toThrow(/用户名或密码/)
   })
 
-  it('名称不能重复;类型不能修改;远程存储尚未提供', async () => {
+  it('名称不能重复;类型不能修改;远程存储必须填写连接参数', async () => {
     const k = app.knowledge.list().find(x => x.name === '内部文档')!
     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(/尚未提供/)
+    await expect(app.knowledge.create({ kind: 'remote', name: '远程' })).rejects.toThrow(/连接参数/)
   })
 
   it('测试访问:返回状态码、标题;失败时给出原因', async () => {
@@ -224,3 +232,57 @@ describe('本机目录(挂载服务)', () => {
     expect(app.knowledge.get(k.id).status.state).toBe('ok')
   })
 })
+
+describe('远程存储', () => {
+  const settle = () => new Promise(r => setTimeout(r, 20))
+  const s3 = (secrets: { name: string; value: string | null }[], params: Record<string, string> = {}) => ({
+    type: 's3' as const,
+    params: { provider: 'Minio', endpoint: 'http://127.0.0.1:9198', bucket: 'agentserver-kb', prefix: 'docs', unknown: 'x', ...params },
+    secrets,
+  })
+
+  it('凭据只写不读:DTO 只显示是否已设置;留空沿用原值;随登记列表发给挂载服务', async () => {
+    await expect(app.knowledge.create({ kind: 'remote', name: '对象存储', remote: s3([{ name: 'accessKeyId', value: 'agentserver' }]) })).rejects.toThrow(/Secret Key/)
+    await expect(app.knowledge.create({ kind: 'remote', name: '对象存储', remote: s3([], { bucket: '' }) })).rejects.toThrow(/存储桶/)
+    await expect(app.knowledge.create({ kind: 'remote', name: '对象存储', remote: s3([], { endpoint: 'http://u:p@x/' }) })).rejects.toThrow(/用户名或密码/)
+    await expect(app.knowledge.create({ kind: 'remote', name: '对象存储', remote: s3([], { provider: 'Evil' }) })).rejects.toThrow(/服务商不正确/)
+
+    const k = await app.knowledge.create({ kind: 'remote', name: '对象存储', access: 'write', remote: s3([{ name: 'accessKeyId', value: 'agentserver' }, { name: 'secretAccessKey', value: 'sk-secret' }]) })
+    expect(k).toMatchObject({
+      kind: 'remote', access: 'read', path: path.join(knowledgeMountDir(), k.id),
+      remote: { type: 's3', params: { provider: 'Minio', endpoint: 'http://127.0.0.1:9198', bucket: 'agentserver-kb', prefix: 'docs' }, secrets: [{ name: 'accessKeyId', isSet: true }, { name: 'secretAccessKey', isSet: true }] },
+    })
+    expect(k.remote!.params).not.toHaveProperty('unknown')
+    expect(JSON.stringify(app.knowledge.list())).not.toContain('sk-secret')
+    await settle()
+    expect(mounter.synced.at(-1)).toContainEqual({ id: k.id, kind: 'remote', remote: { type: 's3', params: k.remote!.params, secrets: { accessKeyId: 'agentserver', secretAccessKey: 'sk-secret' } } })
+
+    // 修改时凭据留空:沿用原值
+    await app.knowledge.update(k.id, { kind: 'remote', name: '对象存储', remote: s3([{ name: 'accessKeyId', value: null }, { name: 'secretAccessKey', value: '' }], { prefix: '' }) })
+    await settle()
+    const spec = mounter.synced.at(-1)!.find(m => m.id === k.id)!
+    expect(spec).toMatchObject({ remote: { secrets: { accessKeyId: 'agentserver', secretAccessKey: 'sk-secret' } } })
+    expect(spec.kind === 'remote' && spec.remote.params).not.toHaveProperty('prefix')
+
+    // 提示词中标注远程资料
+    expect(describeKnowledge([app.knowledge.get(k.id)])).toContain('远程资料:先按文件名和目录结构查找')
+  })
+
+  it('测试连接:新建时用填写的凭据;编辑时没有重新填写的凭据沿用原值', async () => {
+    const k = app.knowledge.list().find(x => x.name === '对象存储')!
+    expect(await app.knowledge.testRemote(s3([{ name: 'accessKeyId', value: 'a' }, { name: 'secretAccessKey', value: 'wrong' }]))).toEqual({ ok: false, state: 'auth_failed', message: '认证失败或没有权限:SignatureDoesNotMatch', entries: [] })
+    expect(await app.knowledge.testRemote(s3([]), k.id)).toEqual({ ok: true, state: 'ok', message: null, entries: ['产品手册/'] })
+    expect(mounter.tests.at(-1)!.secrets).toEqual({ accessKeyId: 'agentserver', secretAccessKey: 'sk-secret' })
+    // 换了存储类型:不沿用原来的凭据
+    await expect(app.knowledge.testRemote({ type: 'webdav', params: { url: 'http://dav.local/' }, secrets: [] }, k.id)).resolves.toMatchObject({ ok: false })
+    expect(mounter.tests.at(-1)!.secrets).toEqual({})
+    await expect(app.knowledge.testRemote({ type: 'webdav', params: {}, secrets: [] })).rejects.toThrow(/请填写地址/)
+  })
+
+  it('停用后不再挂载', async () => {
+    const k = app.knowledge.list().find(x => x.name === '对象存储')!
+    await app.knowledge.update(k.id, { kind: 'remote', name: '对象存储', enabled: false, remote: s3([]) })
+    await settle()
+    expect(mounter.synced.at(-1)!.map(m => m.id)).not.toContain(k.id)
+  })
+})

+ 18 - 5
deploy/deploy.sh

@@ -28,6 +28,17 @@ 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"
+# rclone(远程存储):固定版本的官方程序,下载后校验 sha256,缓存在 ~/.cache/agent-server
+RCLONE_VERSION=v1.75.2
+RCLONE_SHA256=349ac8fba6ff65d6247043f1750cdcb518ec5d500ef91463a10d37c0ccdf3702
+RCLONE_ZIP="$HOME/.cache/agent-server/rclone-$RCLONE_VERSION-linux-amd64.zip"
+if ! echo "$RCLONE_SHA256  $RCLONE_ZIP" | sha256sum -c --quiet 2>/dev/null; then
+  mkdir -p "$(dirname "$RCLONE_ZIP")"
+  curl -fsSL -o "$RCLONE_ZIP.part" "https://downloads.rclone.org/$RCLONE_VERSION/rclone-$RCLONE_VERSION-linux-amd64.zip"
+  echo "$RCLONE_SHA256  $RCLONE_ZIP.part" | sha256sum -c --quiet || { echo "rclone 校验失败" >&2; rm -f "$RCLONE_ZIP.part"; exit 1; }
+  mv "$RCLONE_ZIP.part" "$RCLONE_ZIP"
+fi
+unzip -p "$RCLONE_ZIP" "rclone-$RCLONE_VERSION-linux-amd64/rclone" > "$STAGE/rclone"
 
 sudo rsync -a --delete "$STAGE/server/" "$ROOT/app/server/"
 sudo rsync -a --delete apps/admin/dist/ "$ROOT/app/admin/"
@@ -37,10 +48,12 @@ 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
+for f in agent-mounter rclone; do
+  if ! sudo cmp -s "$STAGE/$f" "$ROOT/app/mounter/$f"; then
+    sudo install -D -m 0755 "$STAGE/$f" "$ROOT/app/mounter/$f"
+    mounter_changed=1
+  fi
+done
 sudo chown -R root:root "$ROOT/app" "$ROOT/skills"
 sudo chmod -R a+rX "$ROOT/app" "$ROOT/skills"
 
@@ -64,7 +77,7 @@ if ! sudo cmp -s "$STAGE/agent-mounter.service" /etc/systemd/system/agent-mounte
 fi
 if [ $reload = 1 ]; then sudo systemctl daemon-reload; fi
 sudo systemctl enable --quiet agent-mounter
-# 只有挂载程序或 unit 有变化时才重启挂载服务(挂载会中断几秒后自动恢复)
+# 只有挂载程序、rclone 或 unit 有变化时才重启挂载服务(挂载会中断几秒后自动恢复)
 if [ $mounter_changed = 1 ] || ! systemctl is-active --quiet agent-mounter; then sudo systemctl restart agent-mounter; fi
 
 sudo systemctl restart agent-server

+ 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 可写并在文件管理中打开;本机目录逐级选择目录(只能选 /home、/mnt、/media 下的),显示所在设备(系统盘按路径、外接设备按 UUID 识别);网络链接可测试访问;挂载服务不可用时页面顶部提示。资料集合:成员、被哪些 Profile 使用 |
+| 📚 资料库 | 资料库列表、资料集合 | 四种类型(托管目录、本机目录、远程存储、网络链接,见 [21](21-knowledge-sources.md)):名称、说明、启用、状态;托管目录可设 Agent 可写并在文件管理中打开;本机目录逐级选择目录(只能选 /home、/mnt、/media 下的),显示所在设备(系统盘按路径、外接设备按 UUID 识别);远程存储(S3 兼容、WebDAV)填写连接参数与凭据(只写不读)并可测试连接;网络链接可测试访问;挂载服务不可用时页面顶部提示。资料集合:成员、被哪些 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 列表 | 签发(明文只显示一次)、吊销、最近使用时间 |

+ 28 - 6
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. 背景
 
@@ -193,8 +193,9 @@
   - 缓存有上限,过期自动清除,符合"用时读取,不长期保存"。
 - **连接参数和凭据**:通过子进程的环境变量传给 rclone(`RCLONE_CONFIG_KB_*`),**不放在命令行**里,因为命令行在进程列表中所有人可见。
 - **状态**:
-  - 根据 rclone 子进程是否存活、列挂载根目录(带超时)的结果、rclone 日志中的认证错误,得出 `ok`、`unreachable`、`auth_failed`。
-  - rclone 自己会重试连接;子进程退出时,挂载进程按退避间隔重启它。
+  - 根据 rclone 子进程是否存活、连接检查(另起 `rclone lsf` 列根目录,带超时)的结果和错误信息,得出 `ok`、`unreachable`、`auth_failed`、`path_missing`(存储桶不存在)、`mount_failed`(配置不正确)。
+  - 连接检查的时机:挂载完成后;不正常时每分钟一次,正常时每 5 分钟一次;平台查询状态时,超过 30 秒没检查过的在后台检查。这是唯一的定时检查,本机目录不需要。
+  - rclone 子进程退出时,挂载进程按退避间隔(5 秒起,最长 5 分钟)重启它。
   - 恢复后状态自动变回正常。
 - 挂载进程需要联网,只访问管理员登记的地址。
 
@@ -357,9 +358,17 @@ profiles
 | 挂载服务重启 | ✅ 按 state.json 恢复挂载,随后平台同步的列表为准 |
 | 现场外接盘 `/media/jerry-remote/Data` | ✅ 识别为 `/dev/sda`,UUID 88bc33be-…,设备内路径 `/` |
 
-**待测**:
-- 两个引擎的沙箱中读取挂载目录(需要后台登记本机目录后开会话);
-- MinIO 加 rclone 两层挂载的读取、pandas 随机读取、断网与恢复(第三期)。
+**2026-11-06**:远程存储(第三期),本机 MinIO(专用只读账户 `agentserver`,存储桶 `agentserver-kb`)。
+
+| 测试 | 结果 |
+|---|---|
+| 测试连接:正确凭据 / 错误凭据 / 地址错误 | ✅ 正常并列出根目录 / 认证失败(SignatureDoesNotMatch)/ 无法连接 |
+| agentsvc 在平台服务的命名空间中读取:`ls`、`cat`、`grep -r`、pandas 读 CSV | ✅;`.env`、`.git` 不可见;写入为"只读文件系统" |
+| 凭据 | ✅ 不在 rclone 的命令行中;agentsvc 读不到 rclone 进程的环境变量,也进不了内部挂载点 |
+| 在 MinIO 中禁用账户 | ✅ 30 秒内显示"认证失败",读取返回"输入/输出错误";重新启用后约 40 秒自动恢复 |
+| 强制结束 rclone | ✅ 显示"rclone 意外退出",5 秒后自动重启并恢复 |
+
+**待测**:两个引擎的沙箱中读取本机目录与远程存储(需要在后台登记后开会话)。
 
 ## 12. 以后可以支持的介质(不在本次范围)
 
@@ -427,3 +436,16 @@ profiles
 - 平台:`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(共享类型有变化)。
+
+### 第三期(2026-11-06)
+
+- 挂载服务(1.1.0):`remote.go`
+  - 连接参数与凭据生成 rclone 的环境变量(`RCLONE_CONFIG_KB_*`),WebDAV 密码经 `rclone obscure -`(从标准输入)处理;使用空的 `mounter/rclone.conf`,不读部署用户自己的 rclone 配置;
+  - 每个远程存储一个 rclone 子进程,挂到 `mounter/remote/<id>`(只有部署用户能访问),缓存在 `mounter/cache/<id>`;本进程的文件系统确认那里确实是 rclone 的挂载后再提供;
+  - 状态判断、定时连接检查、退出后按退避间隔重启;删除或配置变化时停止 rclone、卸载、删除缓存;
+  - `test` 请求:用填写的配置列根目录,返回状态和根目录条目;
+  - 测试:配置生成与校验(必填、地址格式、存储桶名、`..`、换行注入)、错误分类、用 rclone 的 local 后端做真实的两层挂载(读取、隐藏文件、只读、凭据不在命令行、删除后清理)。
+- 部署:`deploy.sh` 下载固定版本的 rclone(v1.75.2,校验 sha256,缓存在 `~/.cache/agent-server`),放到 `/srv/agent-server/app/mounter/rclone`。
+- 平台:远程存储的字段定义放在共享包(`KNOWLEDGE_REMOTE_TYPES`,后台表单与服务端校验共用);凭据只写不读,留空沿用原值,换了存储类型不沿用;登记列表中的远程存储带凭据发给挂载服务;后台接口 `/knowledge/test-remote`(编辑时没有重新填写的凭据沿用原值);审计日志只记录类型、地址、存储桶、前缀。
+- 后台:远程存储表单(S3 兼容:服务商、地址、区域、存储桶、路径前缀、Access Key、Secret Key;WebDAV:地址、服务类型、用户名、密码),"测试连接"显示结果和根目录条目。
+- SDK 升级到 0.8.0。

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

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

+ 1 - 1
packages/client/package.json

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

+ 1 - 1
packages/shared/package.json

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

+ 57 - 0
packages/shared/src/api.ts

@@ -224,6 +224,63 @@ export interface KnowledgeLocalCheckDto {
   device: string
 }
 
+/** 远程存储的表单字段(后台表单与服务端校验共用) */
+export interface KnowledgeRemoteField {
+  name: string
+  label: string
+  required?: boolean
+  placeholder?: string
+  hint?: string
+  options?: { value: string; label: string }[]
+}
+
+export const KNOWLEDGE_REMOTE_TYPES: Record<KnowledgeRemoteType, { label: string; params: KnowledgeRemoteField[]; secrets: KnowledgeRemoteField[] }> = {
+  s3: {
+    label: 'S3 兼容存储',
+    params: [
+      {
+        name: 'provider', label: '服务商', options: [
+          { value: 'Minio', label: 'MinIO' }, { value: 'AWS', label: 'AWS S3' }, { value: 'Alibaba', label: '阿里云 OSS' },
+          { value: 'TencentCOS', label: '腾讯云 COS' }, { value: 'HuaweiOBS', label: '华为云 OBS' }, { value: 'Qiniu', label: '七牛' },
+          { value: 'Ceph', label: 'Ceph' }, { value: 'Cloudflare', label: 'Cloudflare R2' }, { value: 'Other', label: '其他 S3 兼容' },
+        ],
+      },
+      { name: 'endpoint', label: '服务地址(endpoint)', placeholder: 'http://127.0.0.1:9000', hint: 'AWS S3 可以不填' },
+      { name: 'region', label: '区域', placeholder: '可选,例如 us-east-1' },
+      { name: 'bucket', label: '存储桶', required: true },
+      { name: 'prefix', label: '路径前缀', placeholder: '可选,只挂载其中的这个目录,例如 docs/2024' },
+    ],
+    secrets: [
+      { name: 'accessKeyId', label: 'Access Key', required: true },
+      { name: 'secretAccessKey', label: 'Secret Key', required: true },
+    ],
+  },
+  webdav: {
+    label: 'WebDAV',
+    params: [
+      { name: 'url', label: '地址', required: true, placeholder: 'https://dav.example.com/remote.php/dav/files/user/' },
+      {
+        name: 'vendor', label: '服务类型', options: [
+          { value: 'other', label: '通用(坚果云、群晖等)' }, { value: 'nextcloud', label: 'Nextcloud' },
+          { value: 'owncloud', label: 'ownCloud' }, { value: 'sharepoint', label: 'SharePoint' },
+        ],
+      },
+      { name: 'user', label: '用户名' },
+    ],
+    secrets: [{ name: 'password', label: '密码' }],
+  },
+}
+
+/** 远程存储"测试连接"的结果 */
+export interface KnowledgeRemoteTestDto {
+  ok: boolean
+  /** ok、auth_failed、unreachable、path_missing、mount_failed(配置不正确) */
+  state: KnowledgeState
+  message: string | null
+  /** 根目录下的条目(不含隐藏的,最多 50 个) */
+  entries: string[]
+}
+
 /** 网络链接的测试访问结果 */
 export interface KnowledgeLinkTestDto {
   ok: boolean