Parcourir la source

feat: 改用专用用户 agentsvc 部署,新增技能与插件管理

安全
- 文档转换沙箱改为白名单挂载(只挂载 /usr、/etc、字体缓存和当前工作区),
  修复沙箱内可以连通 LXD API 的问题;/usr、/etc 中的 socket 用 /dev/null 覆盖;
  新增沙箱隔离回归测试
- 服务改由 systemd 以专用用户 agentsvc 运行(不在 docker/lxd/sudo 组);
  程序、运行环境、技能归 root 所有、服务只读,数据目录 0700;
  屏蔽 docker/LXD socket,禁止提权(ADR-0012)

部署
- 新增 deploy/:install.sh(用户、目录权限、systemd unit、agent-claude 命令)、
  setup-runtime.sh(系统工具、Python venv、Node 包,可重复执行)、
  deploy.sh(构建、pnpm deploy、发布、重启、健康检查)、agent-server.service
- 改用 Agent SDK 自带的 Claude Code 二进制,不再依赖个人 home 中的 CLI
- 开发与正式环境分离:开发端口 8788、数据目录 .dev-data/;
  runtime 与 skills 由两者共用(AGENT_SERVER_SHARED_DIR)

技能与插件
- 新增 PluginService:通过 claude plugin 命令管理插件市场和插件,读取技能清单、
  识别许可证;运行时按安装路径加载已启用的插件,专有许可的插件只提供给 Claude(ADR-0013)
- 后台新增"技能与插件"页面;Profile 的技能选择改为带插件前缀的技能列表
- 新增 recalc_workbook 工具:在沙箱中用 LibreOffice 重算 Excel 公式并检查错误单元格
- 运行环境新增 Node 包(docx、pptxgenjs、sharp、react)、qpdf、tesseract 中文 OCR;
  设置中新增 Node 包目录(NODE_PATH)

Claude 适配
- 会话记录不存在时(例如更换了运行账号)自动以新上下文重试
- 引擎状态检查兼容未登录时 auth status 返回非零退出码

文档
- 新增 ADR-0012(专用用户)、ADR-0013(官方技能通过插件机制使用)
- 更新 06-security、10-deployment、11-roadmap、README

测试:33 个通过(新增插件生命周期、转换沙箱隔离、公式重算)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang il y a 1 semaine
Parent
commit
4ff9fac715
48 fichiers modifiés avec 1263 ajouts et 154 suppressions
  1. 1 0
      .gitignore
  2. 22 7
      README.md
  3. 1 0
      apps/admin/src/components/Workbench.vue
  4. 5 3
      apps/admin/src/lib/api.ts
  5. 1 0
      apps/admin/src/router.ts
  6. 1 1
      apps/admin/src/views/OverviewView.vue
  7. 216 0
      apps/admin/src/views/PluginsView.vue
  8. 17 10
      apps/admin/src/views/ProfilesView.vue
  9. 2 2
      apps/admin/src/views/SettingsView.vue
  10. 2 1
      apps/admin/vite.config.ts
  11. 6 2
      apps/server/package.json
  12. 1 1
      apps/server/scripts/try-claude-adapter.ts
  13. 4 2
      apps/server/src/app.ts
  14. 9 3
      apps/server/src/config.ts
  15. 12 3
      apps/server/src/http/routes/admin.ts
  16. 25 39
      apps/server/src/providers/claude/adapter.ts
  17. 40 0
      apps/server/src/providers/claude/cli.ts
  18. 3 2
      apps/server/src/providers/claude/policy.ts
  19. 44 0
      apps/server/src/providers/claude/skills.ts
  20. 19 2
      apps/server/src/providers/claude/tools.ts
  21. 3 0
      apps/server/src/providers/types.ts
  22. 1 0
      apps/server/src/services/access-policy.ts
  23. 120 16
      apps/server/src/services/office.ts
  24. 189 0
      apps/server/src/services/plugins.ts
  25. 5 2
      apps/server/src/services/prompt.ts
  26. 4 0
      apps/server/src/services/runs.ts
  27. 9 7
      apps/server/src/services/settings.ts
  28. 45 1
      apps/server/test/office.test.ts
  29. 76 0
      apps/server/test/plugins.test.ts
  30. 1 0
      apps/server/test/setup.ts
  31. 11 0
      deploy/agent-claude
  32. 44 0
      deploy/agent-server.service
  33. 30 0
      deploy/deploy.sh
  34. 39 0
      deploy/install.sh
  35. 3 0
      deploy/runtime/matplotlibrc
  36. 13 0
      deploy/runtime/package.json
  37. 57 0
      deploy/runtime/requirements.txt
  38. 24 0
      deploy/setup-runtime.sh
  39. 4 2
      docs/README.md
  40. 1 1
      docs/adr/0009-isolation-without-dedicated-user.md
  41. 18 0
      docs/adr/0012-dedicated-service-user.md
  42. 17 0
      docs/adr/0013-official-skills-via-plugins.md
  43. 11 2
      docs/design/06-security.md
  44. 44 41
      docs/design/10-deployment.md
  45. 3 3
      docs/design/11-roadmap.md
  46. 56 0
      packages/shared/src/api.ts
  47. 2 0
      packages/shared/src/settings.ts
  48. 2 1
      skills/skills/report-conventions/SKILL.md

+ 1 - 0
.gitignore

@@ -5,3 +5,4 @@ dist/
 .env.*
 /data/
 .DS_Store
+.dev-data/

+ 22 - 7
README.md

@@ -13,31 +13,46 @@ packages/shared    事件与 API 类型(前后端共享)
 packages/client    TS 客户端 SDK(会话、SSE 断线续传、交互回复、上传)
 packages/chat-ui   Vue 对话组件(时间线、工具卡片、审批/提问卡片、输入框),业务平台可直接复用
 packages/ui-tokens 设计令牌(tokens.css)与 UnoCSS preset
-skills/            共享技能(部署到 /srv/agent-server/skills)
+skills/            本地技能(部署到 /srv/agent-server/skills;官方技能通过后台"技能与插件"安装,不放在这里)
+deploy/            安装、运行环境、发布脚本与 systemd unit
 docs/              设计文档、ADR、调研
 ```
 
 ## 前置条件
 
 - Node.js 22+、pnpm 8+
-- 已在终端登录 `claude`(服务直接复用 `~/.claude` 中的登录态)
+- 正式服务:用 `agent-claude` 以服务账号 agentsvc 登录 Claude(一次即可);开发环境:本人终端已登录 `claude`
 - bubblewrap、socat(Claude 沙箱);LibreOffice、poppler-utils、pandoc、ripgrep、fonts-noto-cjk
-- 数据目录 `/srv/agent-server`(含 Python 运行环境 `runtime/py`),见 [docs/design/10-deployment.md](docs/design/10-deployment.md)
+- 数据目录与运行环境由 `deploy/install.sh`、`deploy/setup-runtime.sh` 创建,见 [docs/design/10-deployment.md](docs/design/10-deployment.md)
+
+## 部署(正式服务)
+
+服务以专用用户 `agentsvc` 运行(systemd),详见 [docs/design/10-deployment.md](docs/design/10-deployment.md):
+
+```bash
+sudo deploy/install.sh          # 首次:创建 agentsvc、目录权限、systemd 服务、agent-claude 命令
+sudo deploy/setup-runtime.sh    # 首次 / 依赖变化:Python、Node、系统工具
+deploy/deploy.sh                # 每次发布:构建、发布到 /srv/agent-server/app、重启
+agent-claude                    # 以 agentsvc 身份打开 Claude Code,输入 /login 登录(只需一次)
+```
+
+后台:http://127.0.0.1:8787/admin/ ;日志:`journalctl -u agent-server -f`
 
 ## 开发
 
 ```bash
 pnpm install
-pnpm dev:server          # 服务端:http://127.0.0.1:8787(tsx watch)
-pnpm dev:admin           # 后台:http://127.0.0.1:5173/admin/(代理 /v1、/admin/v1 到服务端)
+pnpm dev:server          # 服务端:http://127.0.0.1:8788(tsx watch,数据在 .dev-data/,使用你自己的 claude 登录态)
+pnpm dev:admin           # 后台:http://127.0.0.1:5173/admin/(代理到 8788)
 ```
 
 首次启动时,如果没有设置 `AGENT_SERVER_ADMIN_PASSWORD`,会生成随机的后台密码并打印在日志里(只打印一次),登录后请在"设置"中修改。
 
 | 环境变量 | 默认值 | 说明 |
 |---|---|---|
-| `AGENT_SERVER_HOST` / `AGENT_SERVER_PORT` | `127.0.0.1` / `8787` | 监听地址 |
-| `AGENT_SERVER_DATA_DIR` | `/srv/agent-server` | 数据目录 |
+| `AGENT_SERVER_HOST` / `AGENT_SERVER_PORT` | `127.0.0.1` / `8787`(开发脚本为 8788) | 监听地址 |
+| `AGENT_SERVER_DATA_DIR` | 生产 `/srv/agent-server`,开发 `.dev-data/` | 数据目录 |
+| `AGENT_SERVER_SHARED_DIR` | `/srv/agent-server` | 共享只读资源(runtime、skills) |
 | `AGENT_SERVER_ADMIN_PASSWORD` | (随机生成) | 仅首次启动时使用 |
 | `AGENT_SERVER_ADMIN_DIST` | `apps/admin/dist` | 后台静态资源目录 |
 

+ 1 - 0
apps/admin/src/components/Workbench.vue

@@ -15,6 +15,7 @@ const nav = [
   { to: '/overview', icon: 'codicon-dashboard', label: '概览' },
   { to: '/profiles', icon: 'codicon-hubot', label: 'Profiles' },
   { to: '/knowledge', icon: 'codicon-library', label: '资料库' },
+  { to: '/plugins', icon: 'codicon-extensions', label: '技能与插件' },
   { to: '/api-keys', icon: 'codicon-key', label: 'API Keys' },
   { to: '/audit', icon: 'codicon-checklist', label: '审计日志' },
 ]

+ 5 - 3
apps/admin/src/lib/api.ts

@@ -1,8 +1,8 @@
 // 后台 API:同源 Cookie 鉴权,复用 AgentClient 的请求与错误处理
 import { AgentClient } from '@agent-server/client'
 import type {
-  ApiKeyDto, AppSettings, DeepPartial, FileAccessPolicy, KnowledgeRootDto, PathCheck, ProfileDto, ProfileInput,
-  ProviderStatusDto, ResolvedAccessDto,
+  ApiKeyDto, AppSettings, DeepPartial, FileAccessPolicy, KnowledgeRootDto, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput,
+  ProviderStatusDto, ResolvedAccessDto, SkillInfo,
 } from '@agent-server/shared'
 
 export const client = new AgentClient({ baseUrl: '' })
@@ -20,7 +20,9 @@ export const admin = {
   settings: () => req<{ settings: AppSettings; defaults: AppSettings; builtinGlobalDeny: string[] }>('GET', '/settings'),
   updateSettings: (patch: DeepPartial<AppSettings>) => req<{ settings: AppSettings }>('PATCH', '/settings', patch),
   checkPath: (path: string, purpose: 'knowledge' | 'generic' = 'knowledge') => req<PathCheck>('POST', '/paths/check', { path, purpose }),
-  skills: () => req<string[]>('GET', '/skills'),
+  skills: () => req<SkillInfo[]>('GET', '/skills'),
+  plugins: (refresh = false) => req<PluginStateDto>('GET', `/plugins${refresh ? '?refresh=1' : ''}`),
+  pluginAction: (a: PluginAction) => req<{ message: string; state: PluginStateDto }>('POST', '/plugins/actions', a),
   profiles: () => req<ProfileDto[]>('GET', '/profiles'),
   createProfile: (p: ProfileInput) => req<ProfileDto>('POST', '/profiles', p),
   updateProfile: (id: string, p: ProfileInput) => req<ProfileDto>('PUT', `/profiles/${id}`, p),

+ 1 - 0
apps/admin/src/router.ts

@@ -11,6 +11,7 @@ export const router = createRouter({
     { path: '/overview', component: () => import('./views/OverviewView.vue') },
     { path: '/profiles/:id?', name: 'profiles', component: () => import('./views/ProfilesView.vue') },
     { path: '/knowledge', component: () => import('./views/KnowledgeView.vue') },
+    { path: '/plugins', component: () => import('./views/PluginsView.vue') },
     { path: '/api-keys', component: () => import('./views/ApiKeysView.vue') },
     { path: '/audit', component: () => import('./views/AuditView.vue') },
     { path: '/settings', component: () => import('./views/SettingsView.vue') },

+ 1 - 1
apps/admin/src/views/OverviewView.vue

@@ -54,7 +54,7 @@ onMounted(load)
             <template v-if="p.message"><span class="text-fg-muted">说明</span><span class="text-warning">{{ p.message }}</span></template>
           </div>
         </div>
-        <div class="hint">登录态复用本机终端:未登录时请在服务器终端执行 <code class="font-mono">claude</code> 并完成 /login(Codex 为 <code class="font-mono">codex login</code>),无需重启服务。</div>
+        <div class="hint">服务以专用账号 agentsvc 运行。未登录时在服务器终端执行 <code class="font-mono">agent-claude</code> 并输入 /login,完成后点刷新即可,无需重启服务(开发环境直接用 <code class="font-mono">claude</code>)。</div>
       </section>
     </div>
   </div>

+ 216 - 0
apps/admin/src/views/PluginsView.vue

@@ -0,0 +1,216 @@
+<script setup lang="ts">
+import { computed, onMounted, ref } from 'vue'
+import type { AvailablePluginDto, PluginAction, PluginStateDto } from '@agent-server/shared'
+import PageHeader from '../components/PageHeader.vue'
+import { admin, errorMessage } from '../lib/api'
+import { formatTime, toast } from '../lib/ui'
+
+const OFFICIAL_SOURCE = 'anthropics/skills'
+
+const state = ref<PluginStateDto | null>(null)
+const loading = ref(false)
+const busy = ref<string | null>(null)
+const newSource = ref('')
+const search = ref('')
+const mktFilter = ref('')
+const confirmInstall = ref<AvailablePluginDto | null>(null)
+const expanded = ref<Record<string, boolean>>({})
+
+const officialAdded = computed(() => state.value?.marketplaces.some(m => /anthropics\/skills/.test(m.location ?? '') || m.name === 'anthropic-agent-skills') ?? false)
+const available = computed(() => (state.value?.available ?? []).filter(p =>
+  (!mktFilter.value || p.marketplaceName === mktFilter.value)
+  && (!search.value || `${p.name} ${p.description}`.toLowerCase().includes(search.value.toLowerCase()))))
+// 来自 Anthropic 官方技能市场的插件(其中的文档技能为专有许可)
+const isOfficialSkills = (p: { marketplaceName?: string; marketplace?: string }) => (p.marketplaceName ?? p.marketplace) === 'anthropic-agent-skills'
+
+async function load(refresh = false) {
+  loading.value = true
+  try {
+    state.value = await admin.plugins(refresh)
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    loading.value = false
+  }
+}
+
+async function act(a: PluginAction, key: string) {
+  busy.value = key
+  try {
+    const r = await admin.pluginAction(a)
+    state.value = r.state
+    toast(r.message.split('\n').pop() ?? '完成', 'success')
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    busy.value = null
+  }
+}
+
+function confirmThen(question: string, a: PluginAction, key: string) {
+  if (window.confirm(question)) void act(a, key)
+}
+
+function install(p: AvailablePluginDto) {
+  if (isOfficialSkills(p)) confirmInstall.value = p
+  else void act({ action: 'install', plugin: p.pluginId }, p.pluginId)
+}
+
+function addMarketplace(source: string) {
+  if (!source.trim()) return
+  void act({ action: 'marketplace-add', source: source.trim() }, `mkt:${source}`).then(() => (newSource.value = ''))
+}
+
+onMounted(() => load())
+</script>
+
+<template>
+  <div class="h-full flex flex-col">
+    <PageHeader title="技能与插件" subtitle="通过 Claude Code 官方插件机制安装和管理,AgentServer 不复制、不修改插件内容">
+      <button type="button" class="btn-secondary" :disabled="loading" @click="load(true)"><span class="codicon codicon-refresh" />刷新</button>
+    </PageHeader>
+    <div class="flex-1 overflow-y-auto">
+      <div class="max-w-240 p-6 flex flex-col gap-8">
+        <!-- 合规说明 -->
+        <section class="card p-4 flex gap-3 border-[color-mix(in_srgb,var(--app-warning)_45%,transparent)]">
+          <span class="codicon codicon-law text-warning text-[18px] mt-0.5" />
+          <div class="flex flex-col gap-1.5 text-[12.5px]">
+            <div class="font-600 text-[13px]">关于 Anthropic 官方技能的许可</div>
+            <div>官方文档技能(docx / xlsx / pptx / pdf)是<b>源码可见、非开源</b>的专有许可:只能在 Anthropic 的服务(Claude Code)中使用,<b>不得复制到服务之外、修改或分发</b>,使用受你与 Anthropic 之间的协议约束。</div>
+            <div>本页的做法:由 Claude Code 自带的 <code class="font-mono">claude plugin</code> 命令(以服务账号运行)下载和管理插件,AgentServer 只读取清单、在运行时按路径加载,<b>只提供给 Claude 引擎</b>。不要把这些技能文件复制到本地技能目录或提交到代码仓库,也不要修改它们。</div>
+            <div class="hint">这是对许可条款的理解,不构成法律意见。登录账号属于组织(Team)计划时,以组织与 Anthropic 的协议为准。</div>
+          </div>
+        </section>
+
+        <div v-if="state?.error" class="text-error text-[12px]">读取插件状态失败:{{ state.error }}</div>
+
+        <!-- 已安装插件 -->
+        <section class="flex flex-col gap-3">
+          <h2 class="section-title m-0">已安装的插件</h2>
+          <div v-if="state && !state.installed.length" class="card p-4 flex items-center gap-3">
+            <span class="hint flex-1">还没有安装插件。{{ officialAdded ? '在下方"可安装的插件"中选择安装。' : '可以先添加 Anthropic 官方技能市场。' }}</span>
+            <button v-if="!officialAdded" type="button" class="btn-primary" :disabled="!!busy" @click="addMarketplace(OFFICIAL_SOURCE)">
+              <span class="codicon codicon-add" />添加官方技能市场
+            </button>
+          </div>
+          <div v-for="p in state?.installed" :key="p.id" class="card p-4 flex flex-col gap-2" :class="{ 'opacity-70': !p.enabled }">
+            <div class="flex items-center gap-2">
+              <span class="codicon codicon-extensions text-[18px]" :class="p.enabled ? 'text-accent' : 'text-fg-muted'" />
+              <span class="font-600">{{ p.name }}</span>
+              <span class="hint">{{ p.marketplace }} · v{{ p.version ?? '?' }}</span>
+              <span v-if="p.license.kind === 'restricted'" class="badge bg-code text-warning" :title="p.license.summary ?? ''">专有许可 · 仅 Claude</span>
+              <span v-else-if="p.license.kind === 'open'" class="badge bg-code text-success">开源</span>
+              <span class="flex-1" />
+              <label class="flex items-center gap-1.5 cursor-pointer text-[12px]">
+                <input type="checkbox" :checked="p.enabled" :disabled="!!busy" @change="act({ action: p.enabled ? 'disable' : 'enable', plugin: p.id }, p.id)">启用
+              </label>
+              <button type="button" class="btn-ghost" :disabled="!!busy" @click="act({ action: 'update', plugin: p.id }, p.id)">
+                <span class="codicon" :class="busy === p.id ? 'codicon-loading codicon-modifier-spin' : 'codicon-cloud-download'" />更新
+              </button>
+              <button type="button" class="icon-btn" title="卸载" :disabled="!!busy" @click="confirmThen(`卸载插件 ${p.name}?`, { action: 'uninstall', plugin: p.id }, p.id)">
+                <span class="codicon codicon-trash" />
+              </button>
+            </div>
+            <div class="hint">安装于 {{ formatTime(p.installedAt) }},最近更新 {{ formatTime(p.lastUpdated) }}</div>
+            <button type="button" class="self-start border-none bg-transparent p-0 cursor-pointer text-link text-[12px]" @click="expanded[p.id] = !expanded[p.id]">
+              {{ expanded[p.id] ? '收起' : `查看 ${p.skills.length} 个技能` }}
+            </button>
+            <div v-if="expanded[p.id]" class="flex flex-col gap-1.5 pl-1">
+              <div v-for="s in p.skills" :key="s.id" class="text-[12.5px]">
+                <span class="font-mono">{{ s.id }}</span>
+                <span v-if="s.license === 'restricted'" class="codicon codicon-lock text-[11px] text-warning ml-1" title="专有许可" />
+                <div class="hint line-clamp-2">{{ s.description }}</div>
+              </div>
+              <div v-if="!p.skills.length" class="hint">这个插件不包含技能</div>
+            </div>
+          </div>
+        </section>
+
+        <!-- 本地技能 -->
+        <section class="flex flex-col gap-2">
+          <h2 class="section-title m-0">本地技能目录</h2>
+          <div class="hint">自己编写的技能,位于设置中的"共享技能目录",随代码仓库的 skills/ 部署。</div>
+          <div class="card divide-y divide-solid divide-border">
+            <div v-for="s in state?.localSkills" :key="s.id" class="px-4 py-2 text-[12.5px]">
+              <span class="font-mono">{{ s.id }}</span>
+              <div class="hint line-clamp-2">{{ s.description }}</div>
+            </div>
+            <div v-if="state && !state.localSkills.length" class="px-4 py-3 hint">本地技能目录为空</div>
+          </div>
+        </section>
+
+        <!-- 市场 -->
+        <section class="flex flex-col gap-3">
+          <h2 class="section-title m-0">插件市场</h2>
+          <div class="flex flex-col gap-1">
+            <div v-for="m in state?.marketplaces" :key="m.name" class="flex items-center gap-3 px-3 h-9 rounded-md hover:bg-hover">
+              <span class="codicon codicon-package text-fg-muted" />
+              <span class="font-500">{{ m.name }}</span>
+              <span class="hint font-mono truncate">{{ m.location ?? m.source }}</span>
+              <span class="flex-1" />
+              <button type="button" class="btn-ghost" :disabled="!!busy" @click="act({ action: 'marketplace-update', name: m.name }, `mkt:${m.name}`)">
+                <span class="codicon" :class="busy === `mkt:${m.name}` ? 'codicon-loading codicon-modifier-spin' : 'codicon-sync'" />更新目录
+              </button>
+              <button type="button" class="icon-btn" title="移除市场" :disabled="!!busy" @click="confirmThen(`移除市场 ${m.name}?已安装的插件不受影响。`, { action: 'marketplace-remove', name: m.name }, `mkt:${m.name}`)">
+                <span class="codicon codicon-close" />
+              </button>
+            </div>
+          </div>
+          <form class="flex gap-2" @submit.prevent="addMarketplace(newSource)">
+            <input v-model="newSource" class="field max-w-100 font-mono" placeholder="GitHub 仓库(owner/repo)、Git URL 或本地路径" aria-label="市场来源">
+            <button type="submit" class="btn-secondary" :disabled="!newSource.trim() || !!busy">
+              <span class="codicon" :class="busy?.startsWith('mkt:') ? 'codicon-loading codicon-modifier-spin' : 'codicon-add'" />添加市场
+            </button>
+            <button v-if="!officialAdded" type="button" class="btn-ghost" :disabled="!!busy" @click="addMarketplace(OFFICIAL_SOURCE)">添加官方技能市场({{ OFFICIAL_SOURCE }})</button>
+          </form>
+        </section>
+
+        <!-- 可安装 -->
+        <section class="flex flex-col gap-3">
+          <div class="flex items-center gap-3">
+            <h2 class="section-title m-0 flex-1">可安装的插件</h2>
+            <input v-model="search" class="field w-56" placeholder="搜索" aria-label="搜索插件">
+            <select v-model="mktFilter" class="field w-56" aria-label="市场">
+              <option value="">全部市场</option>
+              <option v-for="m in state?.marketplaces" :key="m.name" :value="m.name">{{ m.name }}</option>
+            </select>
+          </div>
+          <div class="card divide-y divide-solid divide-border">
+            <div v-for="p in available" :key="p.pluginId" class="flex items-start gap-3 px-4 py-2.5">
+              <div class="flex-1 min-w-0">
+                <div class="flex items-center gap-2">
+                  <span class="font-500">{{ p.name }}</span>
+                  <span class="hint">{{ p.marketplaceName }}<template v-if="p.version"> · v{{ p.version }}</template></span>
+                  <span v-if="isOfficialSkills(p)" class="badge bg-code text-warning">Anthropic 官方</span>
+                </div>
+                <div class="hint line-clamp-2">{{ p.description }}</div>
+              </div>
+              <span v-if="p.installed" class="hint flex-none mt-1">已安装</span>
+              <button v-else type="button" class="btn-secondary flex-none" :disabled="!!busy" @click="install(p)">
+                <span class="codicon" :class="busy === p.pluginId ? 'codicon-loading codicon-modifier-spin' : 'codicon-cloud-download'" />安装
+              </button>
+            </div>
+            <div v-if="state && !available.length" class="px-4 py-3 hint">没有匹配的插件</div>
+          </div>
+        </section>
+      </div>
+    </div>
+
+    <!-- 官方技能安装确认 -->
+    <div v-if="confirmInstall" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="confirmInstall = null">
+      <div class="w-[560px] card bg-input-alt p-5 flex flex-col gap-3 shadow-xl text-[12.5px]">
+        <div class="font-600 text-[14px]">安装 {{ confirmInstall.name }}</div>
+        <div>这个插件来自 Anthropic 官方技能市场,其中的文档技能使用 Anthropic 专有许可。安装后:</div>
+        <ul class="m-0 pl-5 flex flex-col gap-1">
+          <li>插件由 Claude Code 下载并存放在服务账号的 Claude Code 目录中,AgentServer 不会复制或修改它;</li>
+          <li>只有 Claude 引擎会加载这些技能,Codex 不会使用;</li>
+          <li>你需要遵守你(或你所在组织)与 Anthropic 之间的协议,以及技能自带的 LICENSE 条款。</li>
+        </ul>
+        <div class="flex justify-end gap-2 mt-2">
+          <button type="button" class="btn-secondary" @click="confirmInstall = null">取消</button>
+          <button type="button" class="btn-primary" @click="act({ action: 'install', plugin: confirmInstall.pluginId }, confirmInstall.pluginId); confirmInstall = null">我已了解,安装</button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>

+ 17 - 10
apps/admin/src/views/ProfilesView.vue

@@ -1,7 +1,7 @@
 <script setup lang="ts">
 import { computed, onMounted, ref, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
-import type { KnowledgeRootDto, PathRule, ProfileDto, ProfileInput, ProviderStatusDto, ResolvedAccessDto } from '@agent-server/shared'
+import type { KnowledgeRootDto, PathRule, ProfileDto, ProfileInput, ProviderStatusDto, ResolvedAccessDto, SkillInfo } from '@agent-server/shared'
 import PageHeader from '../components/PageHeader.vue'
 import SidePane from '../components/SidePane.vue'
 import { admin, client, errorMessage } from '../lib/api'
@@ -11,7 +11,7 @@ const route = useRoute()
 const router = useRouter()
 const profiles = ref<ProfileDto[]>([])
 const knowledge = ref<KnowledgeRootDto[]>([])
-const skills = ref<string[]>([])
+const skills = ref<SkillInfo[]>([])
 const providers = ref<ProviderStatusDto[]>([])
 const draft = ref<ProfileInput | null>(null)
 const preview = ref<ResolvedAccessDto | null>(null)
@@ -62,12 +62,14 @@ watch(() => draft.value && JSON.stringify([draft.value.fileAccess, draft.value.k
 
 const skillsAll = computed({
   get: () => draft.value?.skills === 'all',
-  set: v => { if (draft.value) draft.value.skills = v ? 'all' : [...skills.value] },
+  set: v => { if (draft.value) draft.value.skills = v ? 'all' : skills.value.map(s => s.id) },
 })
-function toggleSkill(name: string) {
+// 历史数据中不带前缀的技能名指本地技能目录
+const skillSelected = (s: SkillInfo) => Array.isArray(draft.value?.skills) && (draft.value!.skills.includes(s.id) || (s.source === 'local' && draft.value!.skills.includes(s.name)))
+function toggleSkill(s: SkillInfo) {
   const d = draft.value
   if (!d || d.skills === 'all') return
-  d.skills = d.skills.includes(name) ? d.skills.filter(s => s !== name) : [...d.skills, name]
+  d.skills = skillSelected(s) ? d.skills.filter(x => x !== s.id && !(s.source === 'local' && x === s.name)) : [...d.skills, s.id]
 }
 function toggleKnowledge(kid: string) {
   const d = draft.value!
@@ -164,13 +166,18 @@ onMounted(() => load().catch(err => toast(errorMessage(err), 'error')))
 
           <section class="flex flex-col gap-2">
             <div class="label m-0">技能</div>
-            <label class="flex items-center gap-2 cursor-pointer"><input v-model="skillsAll" type="checkbox">启用技能目录中的全部技能</label>
-            <div v-if="!skillsAll" class="flex flex-wrap gap-2">
-              <label v-for="s in skills" :key="s" class="pill cursor-pointer">
-                <input type="checkbox" :checked="Array.isArray(draft.skills) && draft.skills.includes(s)" @change="toggleSkill(s)">{{ s }}
+            <label class="flex items-center gap-2 cursor-pointer"><input v-model="skillsAll" type="checkbox">启用全部可用技能(本地技能 + 已启用插件中的技能)</label>
+            <div class="flex flex-col gap-1">
+              <label v-for="s in skills" :key="s.id" class="flex items-start gap-2 cursor-pointer" :class="{ 'opacity-60': skillsAll }">
+                <input type="checkbox" class="mt-1" :checked="skillsAll || skillSelected(s)" :disabled="skillsAll" @change="toggleSkill(s)">
+                <span class="min-w-0">
+                  <span class="font-mono text-[12px]">{{ s.id }}</span>
+                  <span v-if="s.license === 'restricted'" class="badge bg-code text-warning ml-1">专有许可 · 仅 Claude</span>
+                  <span class="block hint truncate">{{ s.description }}</span>
+                </span>
               </label>
             </div>
-            <div class="hint">可用:{{ skills.join('、') || '(技能目录为空)' }}</div>
+            <div v-if="!skills.length" class="hint">没有可用技能。可以在"技能与插件"页面安装插件,或在本地技能目录中添加技能。</div>
           </section>
 
           <section class="grid grid-cols-2 gap-4">

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

@@ -27,7 +27,7 @@ async function load() {
 }
 
 // 路径实时校验(只检查存在与可读,工作区根目录不做资料库限制)
-const PATH_KEYS = ['workspaceRoot', 'skillsDir', 'pythonRuntime', 'matplotlibrc'] as const
+const PATH_KEYS = ['workspaceRoot', 'skillsDir', 'pythonRuntime', 'nodeModules', 'matplotlibrc'] as const
 let timer: ReturnType<typeof setTimeout> | undefined
 watch(() => draft.value && JSON.stringify(draft.value.paths), () => {
   clearTimeout(timer)
@@ -73,7 +73,7 @@ watch(theme, applyTheme)
 onMounted(() => load().catch(err => toast(errorMessage(err), 'error')))
 
 const LABELS: Record<(typeof PATH_KEYS)[number], string> = {
-  workspaceRoot: '会话工作区根目录', skillsDir: '共享技能目录', pythonRuntime: 'Python 运行环境(venv)', matplotlibrc: 'matplotlib 中文字体配置',
+  workspaceRoot: '会话工作区根目录', skillsDir: '共享技能目录', pythonRuntime: 'Python 运行环境(venv)', nodeModules: 'Node 包目录(NODE_PATH)', matplotlibrc: 'matplotlib 中文字体配置',
 }
 </script>
 

+ 2 - 1
apps/admin/vite.config.ts

@@ -2,7 +2,8 @@ import vue from '@vitejs/plugin-vue'
 import UnoCSS from 'unocss/vite'
 import { defineConfig } from 'vite'
 
-const target = process.env.AGENT_SERVER_URL ?? 'http://127.0.0.1:8787'
+// 开发时服务端默认监听 8788(8787 留给 systemd 部署的正式服务)
+const target = process.env.AGENT_SERVER_URL ?? 'http://127.0.0.1:8788'
 
 export default defineConfig({
   base: '/admin/',

+ 6 - 2
apps/server/package.json

@@ -4,7 +4,7 @@
   "private": true,
   "type": "module",
   "scripts": {
-    "dev": "tsx watch --clear-screen=false src/main.ts",
+    "dev": "AGENT_SERVER_PORT=${AGENT_SERVER_PORT:-8788} tsx watch --clear-screen=false src/main.ts",
     "build": "tsup",
     "start": "node dist/main.js",
     "typecheck": "tsc --noEmit",
@@ -38,5 +38,9 @@
     "tsx": "^4.23.15",
     "typescript": "^5.9.3",
     "vitest": "^5.0.5"
-  }
+  },
+  "files": [
+    "dist",
+    "drizzle"
+  ]
 }

+ 1 - 1
apps/server/scripts/try-claude-adapter.ts

@@ -16,7 +16,7 @@ const ac = new AbortController()
 const res = await new ClaudeProvider().run({
   sessionId: 'ses_test', runId: 'run_test', model: 'haiku', permissionMode: mode as PermissionMode,
   providerSessionId: null, workspaceDir: ws, systemPromptAppend: '', allowedTools: [], disallowedTools: [], skills: 'all',
-  input: { text: prompt, images: [], attachments: [] }, access, settings, signal: ac.signal,
+  plugins: [], input: { text: prompt, images: [], attachments: [] }, access, settings, signal: ac.signal,
   emit: (type, payload) => {
     if (type === 'message.delta' || type === 'reasoning.delta') return
     console.log(`[event] ${type}`, JSON.stringify(payload).slice(0, Number(process.env.EVENT_MAX ?? 220)))

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

@@ -9,6 +9,7 @@ import { ApiKeyService, AuditService, KnowledgeService, ProfileService } from '.
 import { EventStore } from './services/event-store'
 import { FileService } from './services/files'
 import { InteractionService } from './services/interactions'
+import { PluginService } from './services/plugins'
 import { RunManager } from './services/runs'
 import { SessionService } from './services/sessions'
 import { SettingsService } from './services/settings'
@@ -34,10 +35,11 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
   const interactions = new InteractionService(db, events, settings)
   const sessions = new SessionService(db, settings, profiles, files, events)
   const adminAuth = new AdminAuthService(db, log, config.adminPassword)
+  const plugins = new PluginService(settings, audit)
   const providers: Partial<Record<ProviderId, AgentProvider>> = opts.providers ?? { claude: new ClaudeProvider() }
-  const runs = new RunManager({ db, log, settings, sessions, profiles, knowledge, files, events, interactions, audit, providers })
+  const runs = new RunManager({ db, log, settings, sessions, profiles, knowledge, files, events, interactions, audit, plugins, providers })
 
-  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, adminAuth, providers, runs }
+  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, adminAuth, plugins, providers, runs }
 }
 
 export type App = ReturnType<typeof createApp>

+ 9 - 3
apps/server/src/config.ts

@@ -1,20 +1,26 @@
 // 启动必需配置(ADR-0011):其余运行配置都在后台 settings 中管理
 import os from 'node:os'
 import path from 'node:path'
+import { fileURLToPath } from 'node:url'
 
 const env = process.env
+const isDev = env.NODE_ENV !== 'production'
+/** 仓库根目录(仅开发时使用) */
+const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..')
 
 export const config = {
   host: env.AGENT_SERVER_HOST ?? '127.0.0.1',
   port: Number(env.AGENT_SERVER_PORT ?? 8787),
-  /** 服务数据目录 */
-  dataDir: path.resolve(env.AGENT_SERVER_DATA_DIR ?? '/srv/agent-server'),
+  /** 服务数据目录:生产为 /srv/agent-server(属主 agentsvc);开发时默认放在仓库的 .dev-data,避免与生产服务冲突 */
+  dataDir: path.resolve(env.AGENT_SERVER_DATA_DIR ?? (isDev ? path.join(repoRoot, '.dev-data') : '/srv/agent-server')),
+  /** 共享只读资源(Python 运行环境、技能目录)所在目录,开发与生产共用 */
+  sharedDir: path.resolve(env.AGENT_SERVER_SHARED_DIR ?? '/srv/agent-server'),
   /** 首次启动时的后台密码;未设置则随机生成并打印到日志 */
   adminPassword: env.AGENT_SERVER_ADMIN_PASSWORD,
   /** 后台静态资源目录(生产部署时由服务托管) */
   adminDist: env.AGENT_SERVER_ADMIN_DIST,
   home: os.homedir(),
-  isDev: env.NODE_ENV !== 'production',
+  isDev,
   logLevel: env.LOG_LEVEL ?? 'info',
 }
 

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

@@ -1,10 +1,9 @@
 // 管理 API:仅后台登录态可访问
 import type { FastifyInstance } from 'fastify'
 import { z } from 'zod'
-import { BUILTIN_GLOBAL_DENY, type AppSettings, type DeepPartial, type FileAccessPolicy } from '@agent-server/shared'
+import { BUILTIN_GLOBAL_DENY, type AppSettings, type DeepPartial, type FileAccessPolicy, type PluginAction } from '@agent-server/shared'
 import type { App } from '../../app'
 import { AppError } from '../../lib/errors'
-import { listSkills } from '../../providers/claude/adapter'
 import { checkKnowledgePath, checkPathBasic, resolveAccess, toResolvedDto } from '../../services/access-policy'
 import { parse, type AuditKind, type KnowledgeInput } from '../../services/catalog'
 import { defaultSettings } from '../../services/settings'
@@ -13,6 +12,12 @@ import { providerStatuses } from './v1'
 
 type IdParams = { Params: { id: string } }
 
+const pluginActionSchema: z.ZodType<PluginAction> = z.union([
+  z.object({ action: z.literal('marketplace-add'), source: z.string().trim().min(1).max(300) }),
+  z.object({ action: z.enum(['marketplace-remove', 'marketplace-update']), name: z.string().min(1).max(120) }),
+  z.object({ action: z.enum(['install', 'uninstall', 'update', 'enable', 'disable']), plugin: z.string().min(1).max(200) }),
+])
+
 export default async function adminRoutes(f: FastifyInstance, { app }: { app: App }) {
   // ---- 登录(无需认证)----
   // 简单的失败限流:每个 IP 10 分钟内最多失败 5 次
@@ -68,7 +73,11 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
       app.audit.log({ actor: 'admin', kind: 'admin', summary: '修改系统设置', detail: req.body })
       return { settings: next }
     })
-    g.get('/skills', async () => listSkills(app.settings.get().paths.skillsDir))
+    g.get('/skills', async () => app.plugins.skills())
+
+    // 技能与插件(调用 Claude Code 自带的插件命令)
+    g.get<{ Querystring: { refresh?: string } }>('/plugins', async req => app.plugins.state(req.query.refresh === '1'))
+    g.post('/plugins/actions', async req => app.plugins.act(parse(pluginActionSchema, req.body)))
 
     g.post('/paths/check', async req => {
       const { path, purpose } = parse(z.object({ path: z.string().min(1), purpose: z.enum(['knowledge', 'generic']).default('knowledge') }), req.body)

+ 25 - 39
apps/server/src/providers/claude/adapter.ts

@@ -1,8 +1,5 @@
 // Claude 适配器:Agent SDK → 统一事件(docs/design/03-providers.md §2,ADR-0002)
-import { execFile } from 'node:child_process'
-import fs from 'node:fs'
 import path from 'node:path'
-import { promisify } from 'node:util'
 import {
   query,
   type CanUseTool,
@@ -16,9 +13,10 @@ import { toClaudeSandbox } from '../../services/access-policy'
 import { newId } from '../../lib/ids'
 import { ProviderRunError, type AgentProvider, type RunContext, type RunResult } from '../types'
 import { DEFAULT_DISALLOWED_TOOLS, decideTool, toolTitle, type PolicyContext } from './policy'
+import { runClaudeCli } from './cli'
+import { localPlugin, resolveSkills } from './skills'
 import { OFFICE_SERVER, officeServer } from './tools'
 
-const execFileP = promisify(execFile)
 const MAX_TOOL_OUTPUT = 64 * 1024
 
 export class ClaudeProvider implements AgentProvider {
@@ -27,6 +25,18 @@ export class ClaudeProvider implements AgentProvider {
   private sessionAllowed = new Map<string, Set<string>>()
 
   async run(ctx: RunContext): Promise<RunResult> {
+    try {
+      return await this.runOnce(ctx, ctx.providerSessionId)
+    } catch (err) {
+      // CLI 中找不到原会话记录(例如更换了运行账号或清理了 ~/.claude),以新上下文重试一次
+      if (!(err instanceof ProviderRunError) || err.code !== 'session_not_found' || !ctx.providerSessionId) throw err
+      ctx.log.warn({ providerSessionId: ctx.providerSessionId }, 'claude 会话记录不存在,以新上下文继续')
+      const note = '(系统说明:此前的对话上下文已无法恢复,请仅根据工作区中的文件和下面的消息继续。)\n\n'
+      return this.runOnce({ ...ctx, input: { ...ctx.input, text: note + ctx.input.text } }, null)
+    }
+  }
+
+  private async runOnce(ctx: RunContext, resume: string | null): Promise<RunResult> {
     const allowed = this.sessionAllowed.get(ctx.sessionId) ?? new Set<string>()
     this.sessionAllowed.set(ctx.sessionId, allowed)
     const policyCtx: PolicyContext = {
@@ -94,7 +104,7 @@ export class ClaudeProvider implements AgentProvider {
       options: {
         cwd: ctx.workspaceDir,
         model: ctx.model,
-        resume: ctx.providerSessionId ?? undefined,
+        resume: resume ?? undefined,
         pathToClaudeCodeExecutable: ctx.settings.providers.claude.cliPath,
         settingSources: [],
         includePartialMessages: true,
@@ -103,7 +113,7 @@ export class ClaudeProvider implements AgentProvider {
         allowedTools: ctx.allowedTools.length ? ctx.allowedTools : undefined,
         disallowedTools: [...DEFAULT_DISALLOWED_TOOLS, ...ctx.disallowedTools],
         ...skillsOptions(ctx),
-        mcpServers: { [OFFICE_SERVER]: officeServer(ctx.workspaceDir) },
+        mcpServers: { [OFFICE_SERVER]: officeServer(ctx.workspaceDir, ctx.settings.paths.pythonRuntime) },
         sandbox: toClaudeSandbox(ctx.access, ctx.settings),
         hooks: { PreToolUse: [{ hooks: [preToolUse] }] },
         canUseTool,
@@ -162,18 +172,15 @@ export class ClaudeProvider implements AgentProvider {
       cliPath: s.cliPath, version: null, models: s.models, defaultModel: s.defaultModel, message: null,
     }
     try {
-      const [{ stdout: ver }, { stdout: auth }] = await Promise.all([
-        execFileP(s.cliPath, ['--version'], { timeout: 15000 }),
-        execFileP(s.cliPath, ['auth', 'status', '--json'], { timeout: 15000 }),
-      ])
-      const info = JSON.parse(auth) as { loggedIn?: boolean; authMethod?: string; subscriptionType?: string; email?: string }
+      const [ver, auth] = await Promise.all([runClaudeCli(s.cliPath, ['--version'], { timeoutMs: 15000 }), runClaudeCli(s.cliPath, ['auth', 'status', '--json'], { timeoutMs: 15000 })])
+      const info = JSON.parse(auth.stdout) as { loggedIn?: boolean; subscriptionType?: string; email?: string }
       return {
         ...base,
-        version: ver.trim().split(' ')[0] ?? null,
+        version: ver.stdout.trim().split(' ')[0] || null,
         loggedIn: !!info.loggedIn,
         available: s.enabled && !!info.loggedIn,
         account: info.loggedIn ? [info.email, info.subscriptionType && `(${info.subscriptionType})`].filter(Boolean).join(' ') : null,
-        message: info.loggedIn ? null : '未登录:请在终端执行 claude 并完成 /login',
+        message: info.loggedIn ? null : '未登录:请在服务器终端执行 agent-claude,然后输入 /login(开发环境直接执行 claude)',
       }
     } catch (err) {
       return { ...base, message: `无法执行 ${s.cliPath}:${(err as Error).message}` }
@@ -203,32 +210,9 @@ function buildUserMessage(ctx: RunContext): SDKUserMessage {
   return { type: 'user', message: { role: 'user', content }, parent_tool_use_id: null } as SDKUserMessage
 }
 
-/**
- * 共享技能目录按 Claude 插件格式组织:<dir>/.claude-plugin/plugin.json + <dir>/skills/<name>/SKILL.md。
- * 必须显式列出技能:省略 skills 时 CLI 会加载用户自己的技能(包括 claude.ai 同步的技能)。
- */
-function skillsOptions(ctx: RunContext): { plugins?: { type: 'local'; path: string }[]; skills: string[] } {
-  const dir = ctx.settings.paths.skillsDir
-  let pluginName: string
-  try {
-    pluginName = (JSON.parse(fs.readFileSync(path.join(dir, '.claude-plugin/plugin.json'), 'utf8')) as { name: string }).name
-  } catch {
-    return { skills: [] }
-  }
-  const available = listSkills(dir)
-  const chosen = ctx.skills === 'all' ? available : ctx.skills.filter(s => available.includes(s))
-  return { plugins: [{ type: 'local', path: dir }], skills: chosen.map(n => `${pluginName}:${n}`) }
-}
-
-export function listSkills(dir: string): string[] {
-  try {
-    return fs.readdirSync(path.join(dir, 'skills'), { withFileTypes: true })
-      .filter(d => d.isDirectory() && fs.existsSync(path.join(dir, 'skills', d.name, 'SKILL.md')))
-      .map(d => d.name)
-      .sort()
-  } catch {
-    return []
-  }
+function skillsOptions(ctx: RunContext) {
+  const local = localPlugin(ctx.settings.paths.skillsDir)
+  return resolveSkills(ctx.skills, [...(local ? [local] : []), ...ctx.plugins])
 }
 
 function agentEnv(ctx: RunContext): Record<string, string | undefined> {
@@ -239,6 +223,7 @@ function agentEnv(ctx: RunContext): Record<string, string | undefined> {
     PATH: `${path.join(py, 'bin')}:${process.env.PATH ?? ''}`,
     VIRTUAL_ENV: py,
     MATPLOTLIBRC: ctx.settings.paths.matplotlibrc,
+    NODE_PATH: ctx.settings.paths.nodeModules,
     MPLCONFIGDIR: path.join(agentDir, 'mpl'),
     // 不覆盖 TMPDIR:沙箱在其中创建 Unix socket,路径过长会导致沙箱无法启动
     CLAUDE_AGENT_SDK_CLIENT_APP: 'agent-server/0.1.0',
@@ -299,6 +284,7 @@ class MessageMapper {
         }
         if (m.subtype !== 'success' || m.is_error) {
           const detail = m.subtype === 'success' ? m.result : m.errors.join('; ') || m.subtype
+          if (/No conversation found with session ID/i.test(detail)) throw new ProviderRunError('session_not_found', detail)
           throw new ProviderRunError(m.subtype, detail || '运行失败')
         }
         return { usage }

+ 40 - 0
apps/server/src/providers/claude/cli.ts

@@ -0,0 +1,40 @@
+// 定位 Agent SDK 自带的 Claude Code 可执行文件(官方未修改的二进制,版本随 SDK 锁定)
+import { execFile } from 'node:child_process'
+import fs from 'node:fs'
+import { createRequire } from 'node:module'
+import path from 'node:path'
+
+const require = createRequire(import.meta.url)
+
+export function bundledClaudePath(): string | null {
+  const pkg = `@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}`
+  try {
+    // 平台包是 SDK 的可选依赖,从 SDK 所在位置解析
+    const sdkDir = path.dirname(require.resolve('@anthropic-ai/claude-agent-sdk'))
+    const fromSdk = createRequire(path.join(sdkDir, 'package.json'))
+    const bin = path.join(path.dirname(fromSdk.resolve(`${pkg}/package.json`)), 'claude')
+    return fs.existsSync(bin) ? bin : null
+  } catch {
+    return null
+  }
+}
+
+export interface CliResult { code: number; stdout: string; stderr: string }
+
+/** 运行 Claude Code CLI 子命令;非零退出码不抛错(例如未登录时 auth status 返回 1),只有无法启动时才抛错 */
+export function runClaudeCli(bin: string, args: string[], opts: { timeoutMs?: number; input?: string } = {}): Promise<CliResult> {
+  return new Promise((resolve, reject) => {
+    const child = execFile(bin, args, { timeout: opts.timeoutMs ?? 30_000, maxBuffer: 16 * 1048576, env: cliEnv() }, (err, stdout, stderr) => {
+      if (err && typeof (err as { code?: unknown }).code !== 'number') return reject(err)
+      resolve({ code: err ? Number((err as { code: number }).code) : 0, stdout, stderr })
+    })
+    if (opts.input !== undefined) child.stdin?.end(opts.input)
+  })
+}
+
+function cliEnv() {
+  const env = { ...process.env }
+  delete env.ANTHROPIC_API_KEY
+  delete env.ANTHROPIC_AUTH_TOKEN
+  return env
+}

+ 3 - 2
apps/server/src/providers/claude/policy.ts

@@ -5,7 +5,7 @@ import net from 'node:net'
 import path from 'node:path'
 import type { PermissionMode } from '@agent-server/shared'
 import { checkAccess, type AccessOp, type ResolvedAccess } from '../../services/access-policy'
-import { CONVERT_TOOL } from './tools'
+import { CONVERT_TOOL, RECALC_TOOL } from './tools'
 
 export type ToolDecision =
   | { kind: 'allow' }
@@ -79,7 +79,7 @@ export async function decideTool(tool: string, input: Record<string, unknown>, c
 
   if (SAFE_TOOLS.has(tool)) return { kind: 'allow' }
   // 文档转换只读写工作区,且在独立沙箱中执行
-  if (tool === CONVERT_TOOL) {
+  if (tool === CONVERT_TOOL || tool === RECALC_TOOL) {
     return ctx.permissionMode === 'readonly' ? { kind: 'deny', reason: '当前为只读模式,不允许生成文件', audit: null } : { kind: 'allow' }
   }
   return needsApproval(tool, ctx)
@@ -139,6 +139,7 @@ export function toolTitle(tool: string, input: Record<string, unknown>, cwd: str
     case 'Skill': return String(input.skill ?? input.name ?? '')
     case 'Agent': case 'Task': return String(input.description ?? '')
     case CONVERT_TOOL: return `${rel(input.input)} → ${String(input.format ?? '')}`
+    case RECALC_TOOL: return rel(input.input)
     default: return ''
   }
 }

+ 44 - 0
apps/server/src/providers/claude/skills.ts

@@ -0,0 +1,44 @@
+// 共享技能目录(Claude 插件格式)与运行时技能选择
+import fs from 'node:fs'
+import path from 'node:path'
+
+/** 本地技能目录的默认插件名(读取 plugin.json 失败时使用) */
+export const LOCAL_PLUGIN_FALLBACK = 'agent-skills'
+
+export interface PluginRef { name: string; path: string; skills: string[] }
+
+export function listSkills(dir: string): string[] {
+  try {
+    return fs.readdirSync(path.join(dir, 'skills'), { withFileTypes: true })
+      .filter(d => d.isDirectory() && fs.existsSync(path.join(dir, 'skills', d.name, 'SKILL.md')))
+      .map(d => d.name)
+      .sort()
+  } catch {
+    return []
+  }
+}
+
+/** 本地技能目录作为插件加载(目录不是插件格式时返回 null) */
+export function localPlugin(skillsDir: string): PluginRef | null {
+  try {
+    const name = (JSON.parse(fs.readFileSync(path.join(skillsDir, '.claude-plugin/plugin.json'), 'utf8')) as { name: string }).name
+    return { name, path: skillsDir, skills: listSkills(skillsDir).map(s => `${name}:${s}`) }
+  } catch {
+    return null
+  }
+}
+
+/**
+ * 计算 SDK 的 plugins 与 skills 选项。必须显式列出技能:省略 skills 时 CLI 会加载
+ * 运行账号自己的技能(包括 claude.ai 同步的技能)。Profile 中不带前缀的技能名指本地技能目录。
+ */
+export function resolveSkills(selected: string[] | 'all', plugins: PluginRef[]) {
+  const available = plugins.flatMap(p => p.skills)
+  const local = plugins[0]
+  const wanted = selected === 'all'
+    ? available
+    : selected.map(s => (s.includes(':') || !local ? s : `${local.name}:${s}`)).filter(s => available.includes(s))
+  // 只加载至少有一个技能被选中的插件
+  const used = plugins.filter(p => p.skills.some(s => wanted.includes(s)))
+  return { plugins: used.map(p => ({ type: 'local' as const, path: p.path })), skills: wanted }
+}

+ 19 - 2
apps/server/src/providers/claude/tools.ts

@@ -2,12 +2,13 @@
 import path from 'node:path'
 import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
 import { z } from 'zod'
-import { CONVERT_FORMATS, convertDocument } from '../../services/office'
+import { CONVERT_FORMATS, convertDocument, recalcWorkbook } from '../../services/office'
 
 export const OFFICE_SERVER = 'office'
 export const CONVERT_TOOL = `mcp__${OFFICE_SERVER}__convert_document`
+export const RECALC_TOOL = `mcp__${OFFICE_SERVER}__recalc_workbook`
 
-export function officeServer(workspaceDir: string) {
+export function officeServer(workspaceDir: string, pythonRuntime: string) {
   return createSdkMcpServer({
     name: OFFICE_SERVER,
     version: '0.1.0',
@@ -30,6 +31,22 @@ export function officeServer(workspaceDir: string) {
           }
         },
       ),
+      tool(
+        'recalc_workbook',
+        '用 LibreOffice 重新计算 Excel 文件中的所有公式并写回原文件,然后报告公式数量和错误单元格(#REF!、#DIV/0! 等)。'
+          + '用 openpyxl 写入公式后必须调用它,否则文件里没有计算结果。不要在 Bash 里调用 soffice 或 recalc.py,它们在沙箱中无法运行。',
+        { input: z.string().describe('xlsx/xlsm 文件路径(相对工作区或绝对路径)') },
+        async args => {
+          try {
+            const r = await recalcWorkbook({ workspaceDir, input: args.input, pythonRuntime })
+            const lines = [`已重算:${path.relative(workspaceDir, r.path)},公式 ${r.formulas} 个,错误 ${r.errorCount} 个`]
+            for (const e of r.errors) lines.push(`- ${e.sheet}!${e.cell}: ${e.error}`)
+            return { content: [{ type: 'text', text: lines.join('\n') }], isError: r.errorCount > 0 }
+          } catch (err) {
+            return { content: [{ type: 'text', text: (err as Error).message }], isError: true }
+          }
+        },
+      ),
     ],
   })
 }

+ 3 - 0
apps/server/src/providers/types.ts

@@ -1,5 +1,6 @@
 import type { AgentEventType, AppSettings, EventPayloads, PermissionMode, ProviderId, ProviderStatusDto, UsagePayload } from '@agent-server/shared'
 import type { ResolvedAccess } from '../services/access-policy'
+import type { PluginRef } from './claude/skills'
 import type { InteractionOutcome, NewApproval, NewQuestion } from '../services/interactions'
 
 export interface RunInput {
@@ -21,6 +22,8 @@ export interface RunContext {
   allowedTools: string[]
   disallowedTools: string[]
   skills: string[] | 'all'
+  /** 通过 Claude Code 插件机制安装并启用的插件(只在 Claude 中加载) */
+  plugins: PluginRef[]
   input: RunInput
   access: ResolvedAccess
   settings: AppSettings

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

@@ -53,6 +53,7 @@ export function resolveAccess(opts: {
   writable.add(claudeScratchDir(opts.workspaceDir))
   readable.add(settings.paths.pythonRuntime)
   readable.add(settings.paths.matplotlibrc)
+  readable.add(settings.paths.nodeModules)
   readable.add(settings.paths.skillsDir)
 
   for (const id of opts.selectedKnowledgeRootIds) {

+ 120 - 16
apps/server/src/services/office.ts

@@ -1,11 +1,10 @@
 // 文档格式转换:在独立的 bubblewrap 沙箱中运行 LibreOffice。
 // 引擎的 Bash 沙箱禁止创建 Unix socket(LibreOffice 必需),而放开全部 Unix socket 会暴露 docker.sock 等,
-// 因此转换不走 Bash,而是作为受控工具提供:断网、私有 /tmp 与 /run、隐藏 home 与服务数据,只挂载当前工作区。
+// 因此转换不走 Bash,而是作为受控工具提供:断网、白名单挂载(见 sandboxArgs),只有当前工作区可写。
 import { execFile } from 'node:child_process'
 import fs from 'node:fs'
 import path from 'node:path'
 import { promisify } from 'node:util'
-import { config } from '../config'
 import { isWithin, realpathLoose } from '../lib/paths'
 
 const execFileP = promisify(execFile)
@@ -17,6 +16,61 @@ const TIMEOUT_MS = 120_000
 
 export class ConvertError extends Error {}
 
+/**
+ * 白名单挂载的 bubblewrap 参数:只提供 /usr、/etc、字体缓存和当前工作区。
+ * /var、/srv、/home、/run 等目录在沙箱内不存在,docker.sock、LXD(位于 /var/snap)等宿主 Unix socket 一概不可达;
+ * /usr 与 /etc 中零星存在的 socket(如 VPN 客户端)用 /dev/null 覆盖。
+ */
+export function sandboxArgs(workspaceDir: string, readOnly: string[] = []): string[] {
+  return [
+    '--ro-bind', '/usr', '/usr',
+    '--ro-bind', '/etc', '/etc',
+    '--symlink', 'usr/lib', '/lib',
+    '--symlink', 'usr/lib64', '/lib64',
+    '--symlink', 'usr/bin', '/bin',
+    '--symlink', 'usr/sbin', '/sbin',
+    '--ro-bind-try', '/var/cache/fontconfig', '/var/cache/fontconfig',
+    ...hostSockets().flatMap(p => ['--ro-bind', '/dev/null', p]),
+    '--dev', '/dev',
+    '--proc', '/proc',
+    '--tmpfs', '/tmp',
+    ...readOnly.flatMap(p => ['--ro-bind', p, p]),
+    '--bind', workspaceDir, workspaceDir,
+    '--unshare-all',
+    '--die-with-parent',
+    '--new-session',
+    '--setenv', 'HOME', '/tmp',
+    '--setenv', 'TMPDIR', '/tmp',
+    '--setenv', 'LANG', 'C.UTF-8',
+  ]
+}
+
+let socketCache: string[] | null = null
+/** /usr 与 /etc 下的 Unix socket(进程启动时扫描一次) */
+function hostSockets(): string[] {
+  if (socketCache) return socketCache
+  const found: string[] = []
+  const walk = (dir: string, depth: number) => {
+    if (depth > 8) return
+    let entries: fs.Dirent[]
+    try {
+      entries = fs.readdirSync(dir, { withFileTypes: true })
+    } catch {
+      return
+    }
+    for (const e of entries) {
+      const p = path.join(dir, e.name)
+      if (e.isSocket()) found.push(p)
+      else if (e.isDirectory()) walk(p, depth + 1)
+    }
+  }
+  walk('/etc', 0)
+  walk('/usr/share', 0)
+  walk('/usr/local', 0)
+  socketCache = found
+  return found
+}
+
 export async function convertDocument(opts: { workspaceDir: string; input: string; format: ConvertFormat; outDir?: string }): Promise<string> {
   const ws = fs.realpathSync(opts.workspaceDir)
   const input = realpathLoose(path.resolve(ws, opts.input))
@@ -28,21 +82,8 @@ export async function convertDocument(opts: { workspaceDir: string; input: strin
   fs.mkdirSync(outDir, { recursive: true })
 
   const args = [
-    '--ro-bind', '/', '/',
-    '--dev', '/dev',
-    '--proc', '/proc',
-    '--tmpfs', '/tmp',
-    '--tmpfs', '/run',
-    '--tmpfs', config.home,
-    '--tmpfs', config.dataDir,
-    '--bind', ws, ws,
-    '--unshare-all',
-    '--die-with-parent',
-    '--new-session',
+    ...sandboxArgs(ws),
     '--chdir', ws,
-    '--setenv', 'HOME', '/tmp',
-    '--setenv', 'TMPDIR', '/tmp',
-    '--setenv', 'LANG', 'C.UTF-8',
     'soffice', '-env:UserInstallation=file:///tmp/lo', '--headless', '--norestore',
     '--convert-to', opts.format, '--outdir', outDir, input,
   ]
@@ -56,3 +97,66 @@ export async function convertDocument(opts: { workspaceDir: string; input: strin
   if (!fs.existsSync(out)) throw new ConvertError('LibreOffice 没有生成输出文件,请检查输入文件是否能被打开')
   return out
 }
+
+// LibreOffice 配置:打开 xlsx/ods 时总是重新计算公式(0 = 总是)
+const RECALC_PROFILE = `<?xml version="1.0" encoding="UTF-8"?>
+<oor:items xmlns:oor="http://openoffice.org/2001/registry" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+<item oor:path="/org.openoffice.Office.Calc/Formula/Load"><prop oor:name="OOXMLRecalcMode" oor:op="fuse"><value>0</value></prop></item>
+<item oor:path="/org.openoffice.Office.Calc/Formula/Load"><prop oor:name="ODFRecalcMode" oor:op="fuse"><value>0</value></prop></item>
+</oor:items>
+`
+
+const ERROR_SCAN = `
+import json, sys, openpyxl
+path = sys.argv[1]
+formulas = sum(1 for ws in openpyxl.load_workbook(path).worksheets for row in ws.iter_rows() for c in row if isinstance(c.value, str) and c.value.startswith('='))
+errors = []
+for ws in openpyxl.load_workbook(path, data_only=True).worksheets:
+    for row in ws.iter_rows():
+        for c in row:
+            if isinstance(c.value, str) and c.value in ('#REF!', '#DIV/0!', '#VALUE!', '#NAME?', '#N/A', '#NUM!', '#NULL!'):
+                errors.append({'sheet': ws.title, 'cell': c.coordinate, 'error': c.value})
+print(json.dumps({'formulas': formulas, 'errors': errors[:200], 'errorCount': len(errors)}))
+`
+
+export interface RecalcResult { path: string; formulas: number; errorCount: number; errors: { sheet: string; cell: string; error: string }[] }
+
+/** 用 LibreOffice 重新计算 Excel 公式并写回文件,然后检查公式错误(openpyxl 写入的公式没有缓存值) */
+export async function recalcWorkbook(opts: { workspaceDir: string; input: string; pythonRuntime: string }): Promise<RecalcResult> {
+  const ws = fs.realpathSync(opts.workspaceDir)
+  const input = realpathLoose(path.resolve(ws, opts.input))
+  if (!isWithin(input, ws)) throw new ConvertError(`文件必须位于工作区内:${opts.input}`)
+  if (!fs.existsSync(input) || !fs.statSync(input).isFile()) throw new ConvertError(`文件不存在:${opts.input}`)
+  const ext = path.extname(input).slice(1).toLowerCase()
+  if (!['xlsx', 'xlsm'].includes(ext)) throw new ConvertError('只支持 .xlsx / .xlsm 文件')
+
+  const work = fs.mkdtempSync(path.join(ws, '.agent', 'recalc-'))
+  try {
+    fs.mkdirSync(path.join(work, 'profile/user'), { recursive: true })
+    fs.writeFileSync(path.join(work, 'profile/user/registrymodifications.xcu'), RECALC_PROFILE)
+    const outDir = path.join(work, 'out')
+    try {
+      await execFileP('bwrap', [
+        ...sandboxArgs(ws), '--chdir', ws,
+        'soffice', `-env:UserInstallation=file://${path.join(work, 'profile')}`, '--headless', '--norestore',
+        '--convert-to', ext, '--outdir', outDir, input,
+      ], { timeout: TIMEOUT_MS, killSignal: 'SIGKILL', maxBuffer: 4 * 1048576 })
+    } catch (err) {
+      const e = err as { stderr?: string; killed?: boolean; message: string }
+      throw new ConvertError(e.killed ? '重算超时' : `重算失败:${(e.stderr || e.message).trim().slice(-800)}`)
+    }
+    const out = path.join(outDir, path.basename(input))
+    if (!fs.existsSync(out)) throw new ConvertError('LibreOffice 没有生成输出文件,请检查文件是否能被打开')
+    fs.copyFileSync(out, input)
+
+    const runtime = fs.realpathSync(opts.pythonRuntime)
+    const { stdout } = await execFileP('bwrap', [
+      ...sandboxArgs(ws, [runtime]), '--chdir', ws,
+      path.join(runtime, 'bin/python'), '-c', ERROR_SCAN, input,
+    ], { timeout: 60_000, maxBuffer: 4 * 1048576 })
+    const scan = JSON.parse(stdout) as Omit<RecalcResult, 'path'>
+    return { path: input, ...scan }
+  } finally {
+    fs.rmSync(work, { recursive: true, force: true })
+  }
+}

+ 189 - 0
apps/server/src/services/plugins.ts

@@ -0,0 +1,189 @@
+// 技能与插件:调用 Claude Code 自带的 `claude plugin` 命令(以服务账号运行)安装和管理插件。
+// 插件文件由 Claude Code 自己下载、存放和更新,AgentServer 只读取清单并在运行时按路径加载,
+// 不复制、不修改插件内容(ADR-0013,满足官方技能许可证的要求)。
+import fs from 'node:fs'
+import path from 'node:path'
+import type {
+  AvailablePluginDto, InstalledPluginDto, LicenseKind, MarketplaceDto, PluginAction, PluginStateDto, SkillInfo,
+} from '@agent-server/shared'
+import { badRequest } from '../lib/errors'
+import { listSkills, LOCAL_PLUGIN_FALLBACK } from '../providers/claude/skills'
+import { runClaudeCli } from '../providers/claude/cli'
+import type { AuditService } from './catalog'
+import type { SettingsService } from './settings'
+
+const CACHE_MS = 60_000
+const ACTION_TIMEOUT_MS = 300_000
+
+export interface LoadablePlugin { name: string; path: string; skills: string[]; license: LicenseKind }
+
+interface RawInstalled { id: string; version?: string; enabled?: boolean; installPath: string; installedAt?: string; lastUpdated?: string }
+interface RawAvailable { pluginId: string; name: string; description?: string; marketplaceName: string; version?: string }
+interface RawMarketplace { name: string; source: string; path?: string; url?: string; repo?: string; installLocation?: string }
+
+export class PluginService {
+  private cache: { at: number; state: PluginStateDto } | null = null
+  private queue: Promise<unknown> = Promise.resolve()
+
+  constructor(private settings: SettingsService, private audit: AuditService) {}
+
+  private get cli() {
+    return this.settings.get().providers.claude.cliPath
+  }
+
+  async state(force = false): Promise<PluginStateDto> {
+    if (!force && this.cache && Date.now() - this.cache.at < CACHE_MS) return this.cache.state
+    const localSkills = readLocalSkills(this.settings.get().paths.skillsDir)
+    try {
+      const [list, mkts] = await Promise.all([
+        runClaudeCli(this.cli, ['plugin', 'list', '--json', '--available'], { timeoutMs: 60_000 }),
+        runClaudeCli(this.cli, ['plugin', 'marketplace', 'list', '--json'], { timeoutMs: 60_000 }),
+      ])
+      const parsed = parseJson<{ installed: RawInstalled[]; available: RawAvailable[] }>(list.stdout) ?? { installed: [], available: [] }
+      const installed = parsed.installed.map(toInstalled)
+      const installedIds = new Set(installed.map(p => p.id))
+      const state: PluginStateDto = {
+        cliPath: this.cli,
+        installed,
+        available: parsed.available.map(a => ({
+          pluginId: a.pluginId, name: a.name, description: a.description ?? '', marketplaceName: a.marketplaceName,
+          version: a.version ?? null, installed: installedIds.has(a.pluginId),
+        }) satisfies AvailablePluginDto),
+        marketplaces: (parseJson<RawMarketplace[]>(mkts.stdout) ?? []).map(m => ({
+          name: m.name, source: m.source, location: m.repo ?? m.url ?? m.path ?? m.installLocation ?? null,
+        }) satisfies MarketplaceDto),
+        localSkills,
+        error: list.code === 0 ? null : (list.stderr || list.stdout).trim().slice(-500),
+      }
+      this.cache = { at: Date.now(), state }
+      return state
+    } catch (err) {
+      return { cliPath: this.cli, installed: [], available: [], marketplaces: [], localSkills, error: (err as Error).message }
+    }
+  }
+
+  /** 运行时加载:已安装且已启用的插件 */
+  async loadable(): Promise<LoadablePlugin[]> {
+    const s = await this.state()
+    return s.installed.filter(p => p.enabled && fs.existsSync(p.installPath))
+      .map(p => ({ name: p.name, path: p.installPath, skills: p.skills.map(k => k.id), license: p.license.kind }))
+  }
+
+  /** 后台可选的全部技能:本地技能目录 + 已启用插件中的技能 */
+  async skills(): Promise<SkillInfo[]> {
+    const s = await this.state()
+    return [...s.localSkills, ...s.installed.filter(p => p.enabled).flatMap(p => p.skills)]
+  }
+
+  /** 串行执行插件命令,避免并发修改 Claude Code 的插件配置 */
+  act(a: PluginAction): Promise<{ message: string; state: PluginStateDto }> {
+    const run = async () => {
+      const args = actionArgs(a)
+      const r = await runClaudeCli(this.cli, ['plugin', ...args, '--json'], { timeoutMs: ACTION_TIMEOUT_MS })
+      const line = r.stdout.trim().split('\n').reverse().map(l => parseJson<{ outcome?: string; message?: string }>(l)).find(Boolean)
+      const ok = line ? line.outcome === 'ok' : r.code === 0
+      const message = line?.message ?? (r.stderr || r.stdout).trim().slice(-500)
+      this.audit.log({ actor: 'admin', kind: 'admin', summary: `插件操作 ${args.join(' ')}:${ok ? '成功' : '失败'}`, detail: { message } })
+      if (!ok) throw badRequest(message || '操作失败')
+      return { message, state: await this.state(true) }
+    }
+    const p = this.queue.then(run, run)
+    this.queue = p.catch(() => {})
+    return p
+  }
+}
+
+function actionArgs(a: PluginAction): string[] {
+  const id = /^[\w.@/:+-]+$/
+  switch (a.action) {
+    case 'marketplace-add':
+      if (!/^[\w.@/:~+-]+$/.test(a.source)) throw badRequest('市场来源格式不正确')
+      return ['marketplace', 'add', a.source]
+    case 'marketplace-remove': case 'marketplace-update':
+      if (!id.test(a.name)) throw badRequest('市场名称格式不正确')
+      return ['marketplace', a.action === 'marketplace-remove' ? 'remove' : 'update', a.name]
+    default:
+      if (!id.test(a.plugin)) throw badRequest('插件 ID 格式不正确')
+      return [a.action, a.plugin]
+  }
+}
+
+function toInstalled(r: RawInstalled): InstalledPluginDto {
+  const [name = r.id, marketplace = ''] = r.id.split('@')
+  const rootLicense = detectLicense(r.installPath)
+  const skills = readSkills(path.join(r.installPath, 'skills'), name, 'plugin', rootLicense.kind)
+  // 仓库根目录可能是开源许可,而单个技能是专有许可:任一技能受限即视为受限插件
+  const restricted = skills.find(k => k.license === 'restricted')
+  const license = restricted && rootLicense.kind !== 'restricted' ? RESTRICTED : rootLicense
+  return {
+    id: r.id, name, marketplace, version: r.version ?? null, enabled: r.enabled !== false, installPath: r.installPath,
+    installedAt: r.installedAt ?? null, lastUpdated: r.lastUpdated ?? null, license, skills,
+  }
+}
+
+function readLocalSkills(skillsDir: string): SkillInfo[] {
+  let plugin = LOCAL_PLUGIN_FALLBACK
+  try {
+    plugin = (JSON.parse(fs.readFileSync(path.join(skillsDir, '.claude-plugin/plugin.json'), 'utf8')) as { name: string }).name
+  } catch {
+    return []
+  }
+  return listSkills(skillsDir).length ? readSkills(path.join(skillsDir, 'skills'), plugin, 'local', 'open') : []
+}
+
+function readSkills(dir: string, plugin: string, source: SkillInfo['source'], pluginLicense: LicenseKind): SkillInfo[] {
+  let entries: fs.Dirent[]
+  try {
+    entries = fs.readdirSync(dir, { withFileTypes: true })
+  } catch {
+    return []
+  }
+  return entries.filter(e => e.isDirectory() && fs.existsSync(path.join(dir, e.name, 'SKILL.md'))).map(e => {
+    const fm = frontmatter(fs.readFileSync(path.join(dir, e.name, 'SKILL.md'), 'utf8'))
+    const own = detectLicense(path.join(dir, e.name)).kind
+    return {
+      id: `${plugin}:${fm.name ?? e.name}`, name: fm.name ?? e.name, description: fm.description ?? '', plugin, source,
+      license: own === 'unknown' ? pluginLicense : own,
+    }
+  }).sort((a, b) => a.name.localeCompare(b.name))
+}
+
+function frontmatter(md: string): Record<string, string> {
+  const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(md)
+  const out: Record<string, string> = {}
+  for (const line of m?.[1]?.split(/\r?\n/) ?? []) {
+    const kv = /^([A-Za-z_-]+):\s*(.*)$/.exec(line)
+    if (kv) out[kv[1]!] = kv[2]!.replace(/^["']|["']$/g, '').trim()
+  }
+  return out
+}
+
+/** 识别许可证:Anthropic 专有许可(源码可见、仅限在其服务内使用)或常见开源许可 */
+function detectLicense(dir: string): { kind: LicenseKind; summary: string | null } {
+  let text = ''
+  try {
+    const f = fs.readdirSync(dir).find(n => /^licen[cs]e/i.test(n))
+    if (f) text = fs.readFileSync(path.join(dir, f), 'utf8').slice(0, 4000)
+  } catch {
+    return { kind: 'unknown', summary: null }
+  }
+  if (!text) return { kind: 'unknown', summary: null }
+  if (/agreement with Anthropic/i.test(text) || /All rights reserved/i.test(text)) {
+    return RESTRICTED
+  }
+  if (/Apache License|MIT License|BSD|Mozilla Public License|ISC License/i.test(text)) return { kind: 'open', summary: '开源许可' }
+  return { kind: 'unknown', summary: null }
+}
+
+const RESTRICTED = {
+  kind: 'restricted' as const,
+  summary: 'Anthropic 专有许可:只能在 Anthropic 的服务(Claude Code)中使用;不得复制到服务之外、修改或分发',
+}
+
+function parseJson<T>(s: string): T | null {
+  try {
+    return JSON.parse(s) as T
+  } catch {
+    return null
+  }
+}

+ 5 - 2
apps/server/src/services/prompt.ts

@@ -19,8 +19,11 @@ export function buildSystemPrompt(opts: {
   - 用户上传的文件在 uploads/ 下
   - **最终交付的文件请保存到 outputs/**,用户可以在界面上直接预览和下载;中间文件随意放置
 - Python:直接使用 \`python\`(即 ${py}),已预装 pandas、numpy、pyarrow、duckdb、matplotlib、seaborn、plotly、openpyxl、xlsxwriter、python-docx、python-pptx、pypdf、pdfplumber、reportlab、markitdown。不要向这个共享环境 pip install;确实需要额外依赖时先询问用户。
-- 系统工具:pandoc、pdftotext/pdftoppm、ripgrep(rg)。
-- 文档格式转换(docx/xlsx/pptx → pdf 等)请用 \`convert_document\` 工具;不要在 Bash 里调用 soffice/libreoffice,它在沙箱中无法运行。
+- 系统工具:pandoc、pdftotext/pdftoppm、qpdf、tesseract(含简体中文 OCR)、ripgrep(rg)。
+- LibreOffice 相关操作必须使用专用工具,不要在 Bash 里调用 soffice/libreoffice(包括技能自带的 recalc.py 等脚本),它们在沙箱中无法运行:
+  - 格式转换(docx/xlsx/pptx → pdf 等):\`convert_document\`
+  - Excel 公式重算与错误检查:\`recalc_workbook\`
+- Node 脚本可以直接 require 预装的包:docx、pptxgenjs、sharp、react、react-dom、react-icons(不要 npm install)。
 - matplotlib 已配置中文字体,图表可以直接使用中文。
 - 你只能访问被授权的路径;访问被拒绝时向用户说明,不要尝试绕过限制。${opts.permissionMode === 'readonly' ? '\n- 当前会话是只读模式:不能修改文件或执行命令。' : ''}
 - 默认用中文回复,结论先行,表格和要点优先。`,

+ 4 - 0
apps/server/src/services/runs.ts

@@ -16,6 +16,7 @@ import { parse, type AuditService, type KnowledgeService, type ProfileService }
 import type { EventStore } from './event-store'
 import type { FileService } from './files'
 import type { InteractionService } from './interactions'
+import type { PluginService } from './plugins'
 import type { SessionRow, SessionService } from './sessions'
 import type { SettingsService } from './settings'
 import { buildSystemPrompt } from './prompt'
@@ -56,6 +57,7 @@ export class RunManager {
       events: EventStore
       interactions: InteractionService
       audit: AuditService
+      plugins: PluginService
       providers: Partial<Record<ProviderId, AgentProvider>>
     },
   ) {
@@ -182,6 +184,8 @@ export class RunManager {
         allowedTools: profile.allowedTools,
         disallowedTools: profile.disallowedTools,
         skills: profile.skills,
+        // 插件只在 Claude 中加载;专有许可的插件不能提供给其他引擎(ADR-0013)
+        plugins: session.provider === 'claude' ? await deps.plugins.loadable().catch(() => []) : [],
         input: await this.buildInput(session, text, attachmentIds),
         access,
         settings,

+ 9 - 7
apps/server/src/services/settings.ts

@@ -7,17 +7,19 @@ import { config } from '../config'
 import type { DB } from '../db'
 import { settings as settingsTable } from '../db/schema'
 import { badRequest } from '../lib/errors'
+import { bundledClaudePath } from '../providers/claude/cli'
 
 const KEY = 'app'
 
 export function defaultSettings(): AppSettings {
-  const d = config.dataDir
+  const shared = config.sharedDir
   return {
     paths: {
-      workspaceRoot: path.join(d, 'workspaces'),
-      skillsDir: path.join(d, 'skills'),
-      pythonRuntime: path.join(d, 'runtime/py'),
-      matplotlibrc: path.join(d, 'runtime/matplotlibrc'),
+      workspaceRoot: path.join(config.dataDir, 'workspaces'),
+      skillsDir: path.join(shared, 'skills'),
+      pythonRuntime: path.join(shared, 'runtime/py'),
+      nodeModules: path.join(shared, 'runtime/node/node_modules'),
+      matplotlibrc: path.join(shared, 'runtime/matplotlibrc'),
     },
     security: { globalDeny: [], defaultFileAccessMode: 'restricted' },
     network: { allowedDomains: ['pypi.org', 'files.pythonhosted.org'], blockPrivateFetch: true },
@@ -31,7 +33,7 @@ export function defaultSettings(): AppSettings {
     providers: {
       claude: {
         enabled: true,
-        cliPath: path.join(config.home, '.local/bin/claude'),
+        cliPath: bundledClaudePath() ?? path.join(config.home, '.local/bin/claude'),
         defaultModel: 'sonnet',
         models: ['sonnet', 'opus', 'haiku', 'fable'],
       },
@@ -50,7 +52,7 @@ const absPath = z.string().min(1).refine(p => path.isAbsolute(p), '必须是绝
 const domain = z.string().regex(/^(\*\.)?[a-z0-9-]+(\.[a-z0-9-]+)+$/i, '域名格式不正确')
 
 const settingsSchema: z.ZodType<AppSettings> = z.object({
-  paths: z.object({ workspaceRoot: absPath, skillsDir: absPath, pythonRuntime: absPath, matplotlibrc: absPath }),
+  paths: z.object({ workspaceRoot: absPath, skillsDir: absPath, pythonRuntime: absPath, nodeModules: absPath, matplotlibrc: absPath }),
   security: z.object({
     globalDeny: z.array(z.string().min(1)),
     defaultFileAccessMode: z.enum(['restricted', 'broad']),

+ 45 - 1
apps/server/test/office.test.ts

@@ -2,7 +2,8 @@ import fs from 'node:fs'
 import path from 'node:path'
 import { describe, expect, it } from 'vitest'
 import { config } from '../src/config'
-import { convertDocument } from '../src/services/office'
+import { execFileSync } from 'node:child_process'
+import { convertDocument, recalcWorkbook, sandboxArgs } from '../src/services/office'
 
 // 真实调用 LibreOffice(约数秒),验证独立沙箱中的转换可用
 describe('convertDocument', () => {
@@ -21,3 +22,46 @@ describe('convertDocument', () => {
     await expect(convertDocument({ workspaceDir: ws, input: '中文 报告.txt', format: 'pdf', outDir: '/tmp' })).rejects.toThrow(/工作区内/)
   })
 })
+
+describe('转换沙箱隔离', () => {
+  const ws = path.join(config.dataDir, 'workspaces/ses_iso')
+  fs.mkdirSync(ws, { recursive: true })
+  const run = (cmd: string) =>
+    execFileSync('bwrap', [...sandboxArgs(fs.realpathSync(ws)), 'sh', '-c', cmd], { encoding: 'utf8' }).trim()
+
+  it('看不到 home、/var、/srv、/run,也连不上宿主 socket', () => {
+    expect(run('for d in /home /srv /run /mnt /root; do [ -e $d ] && echo $d; done; true')).toBe('')
+    // /var 中只有字体缓存
+    expect(run('find /var -mindepth 1 -maxdepth 2 | sort')).toBe('/var/cache\n/var/cache/fontconfig')
+    expect(run('[ -S /var/run/docker.sock ] || [ -S /var/snap/lxd/common/lxd/unix.socket ] && echo found || echo none')).toBe('none')
+  })
+
+  it('/usr 与 /etc 中的 socket 被屏蔽', () => {
+    // find 按目录项类型报告,需要 stat 实际 inode:被 /dev/null 覆盖后应为字符设备
+    expect(run('find /usr/share /usr/local /etc -type s 2>/dev/null | while read f; do LC_ALL=C stat -c %F "$f"; done | grep -c socket || true')).toBe('0')
+  })
+
+  it('断网', () => {
+    expect(run('cat /sys/class/net/*/operstate 2>/dev/null | grep -c up || true')).toBe('0')
+  })
+})
+
+describe('recalcWorkbook', () => {
+  const ws = path.join(config.dataDir, 'workspaces/ses_recalc')
+  fs.mkdirSync(path.join(ws, '.agent'), { recursive: true })
+  const py = '/srv/agent-server/runtime/py'
+
+  it('重算公式并报告错误单元格', async () => {
+    // 用 openpyxl 写入公式(无缓存值),包含一个除零错误
+    execFileSync(path.join(py, 'bin/python'), ['-c', `
+import openpyxl
+wb = openpyxl.Workbook(); s = wb.active; s.title = '数据'
+s['A1'] = 10; s['A2'] = 32; s['A3'] = '=SUM(A1:A2)'; s['B1'] = '=A1/0'
+wb.save('${ws}/book.xlsx')`])
+    const r = await recalcWorkbook({ workspaceDir: ws, input: 'book.xlsx', pythonRuntime: py })
+    expect(r.formulas).toBe(2)
+    expect(r.errors).toEqual([{ sheet: '数据', cell: 'B1', error: '#DIV/0!' }])
+    const value = execFileSync(path.join(py, 'bin/python'), ['-c', `import openpyxl; print(openpyxl.load_workbook('${ws}/book.xlsx', data_only=True).active['A3'].value)`], { encoding: 'utf8' })
+    expect(value.trim()).toBe('42')
+  }, 120_000)
+})

+ 76 - 0
apps/server/test/plugins.test.ts

@@ -0,0 +1,76 @@
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { beforeAll, describe, expect, it } from 'vitest'
+import { resolveSkills } from '../src/providers/claude/skills'
+import { AuditService } from '../src/services/catalog'
+import { PluginService } from '../src/services/plugins'
+import { SettingsService } from '../src/services/settings'
+import { createDb, migrateDb } from '../src/db'
+
+describe('resolveSkills', () => {
+  const plugins = [
+    { name: 'agent-skills', path: '/a', skills: ['agent-skills:report-conventions'] },
+    { name: 'document-skills', path: '/b', skills: ['document-skills:xlsx', 'document-skills:docx'] },
+  ]
+  it('all 选中全部技能并加载全部插件', () => {
+    const r = resolveSkills('all', plugins)
+    expect(r.skills).toHaveLength(3)
+    expect(r.plugins.map(p => p.path)).toEqual(['/a', '/b'])
+  })
+  it('不带前缀的名称指本地技能;只加载被用到的插件', () => {
+    const r = resolveSkills(['report-conventions'], plugins)
+    expect(r.skills).toEqual(['agent-skills:report-conventions'])
+    expect(r.plugins.map(p => p.path)).toEqual(['/a'])
+  })
+  it('忽略不存在的技能;空列表不加载任何技能', () => {
+    expect(resolveSkills(['document-skills:pptx'], plugins).skills).toEqual([])
+    expect(resolveSkills([], plugins)).toEqual({ plugins: [], skills: [] })
+  })
+})
+
+// 用本地测试市场驱动真实的 `claude plugin` 命令(不联网、不涉及官方技能文件)
+describe('PluginService(真实 CLI)', () => {
+  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'plugins-'))
+  const mkt = path.join(tmp, 'mkt')
+  let svc: PluginService
+
+  beforeAll(() => {
+    process.env.CLAUDE_CONFIG_DIR = path.join(tmp, 'cfg')
+    fs.mkdirSync(path.join(mkt, '.claude-plugin'), { recursive: true })
+    fs.mkdirSync(path.join(mkt, 'demo/.claude-plugin'), { recursive: true })
+    fs.mkdirSync(path.join(mkt, 'demo/skills/hello'), { recursive: true })
+    fs.writeFileSync(path.join(mkt, '.claude-plugin/marketplace.json'), JSON.stringify({ name: 'test-mkt', owner: { name: 't' }, plugins: [{ name: 'demo', source: './demo', description: '测试插件' }] }))
+    fs.writeFileSync(path.join(mkt, 'demo/.claude-plugin/plugin.json'), JSON.stringify({ name: 'demo', version: '1.0.0' }))
+    fs.writeFileSync(path.join(mkt, 'demo/skills/hello/SKILL.md'), '---\nname: hello\ndescription: "打个招呼"\n---\nhi\n')
+    fs.writeFileSync(path.join(mkt, 'demo/skills/hello/LICENSE.txt'), '© 2025 Anthropic, PBC. All rights reserved.\nUse is governed by your agreement with Anthropic.')
+    const db = createDb(path.join(tmp, 'db.sqlite'))
+    migrateDb(db)
+    svc = new PluginService(new SettingsService(db), new AuditService(db))
+  })
+
+  it('添加市场 → 安装 → 列出技能与许可 → 停用 → 卸载', async () => {
+    await svc.act({ action: 'marketplace-add', source: mkt })
+    let s = (await svc.act({ action: 'install', plugin: 'demo@test-mkt' })).state
+    const p = s.installed.find(x => x.id === 'demo@test-mkt')!
+    expect(p.enabled).toBe(true)
+    expect(p.license.kind).toBe('restricted')
+    expect(p.skills).toEqual([expect.objectContaining({ id: 'demo:hello', description: '打个招呼', license: 'restricted' })])
+    expect((await svc.loadable()).map(l => l.name)).toEqual(['demo'])
+
+    s = (await svc.act({ action: 'disable', plugin: 'demo@test-mkt' })).state
+    expect(s.installed.find(x => x.id === 'demo@test-mkt')!.enabled).toBe(false)
+    expect(await svc.loadable()).toEqual([])
+
+    s = (await svc.act({ action: 'uninstall', plugin: 'demo@test-mkt' })).state
+    expect(s.installed).toEqual([])
+  }, 120_000)
+
+  it('失败的操作返回错误信息', async () => {
+    await expect(svc.act({ action: 'install', plugin: 'nope@nowhere' })).rejects.toThrow(/not found/)
+  }, 60_000)
+
+  it('拒绝可疑参数', async () => {
+    await expect(svc.act({ action: 'install', plugin: 'x; rm -rf /' })).rejects.toThrow(/格式不正确/)
+  })
+})

+ 1 - 0
apps/server/test/setup.ts

@@ -4,4 +4,5 @@ import path from 'node:path'
 
 const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-server-test-'))
 process.env.AGENT_SERVER_DATA_DIR = dir
+process.env.AGENT_SERVER_SHARED_DIR = dir
 process.env.AGENT_SERVER_ADMIN_PASSWORD = 'test-password'

+ 11 - 0
deploy/agent-claude

@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+# 以服务账号 agentsvc 运行 AgentServer 自带的 Claude Code(官方二进制,版本与 Agent SDK 一致)。
+# 用途:登录(agent-claude 后输入 /login)、查看登录状态(agent-claude auth status)、
+#       插件管理(agent-claude plugin ...,后台"技能与插件"页面也会调用同样的命令)
+set -euo pipefail
+BIN=$(sudo find /srv/agent-server/app/server/node_modules/.pnpm -path '*claude-agent-sdk-linux-*/claude' -type f 2>/dev/null | head -1)
+if [ -z "$BIN" ]; then
+  echo "找不到 AgentServer 自带的 Claude Code,请先运行 deploy/deploy.sh" >&2
+  exit 1
+fi
+exec sudo -u agentsvc -H sh -c 'cd "$HOME" && exec "$0" "$@"' "$BIN" "$@"

+ 44 - 0
deploy/agent-server.service

@@ -0,0 +1,44 @@
+# AgentServer systemd 服务(docs/design/06-security.md §5,ADR-0012)
+# 以专用用户 agentsvc 运行:该用户不在 docker/lxd/sudo 组,Agent 失控时的影响上限是它自己的数据
+[Unit]
+Description=AgentServer
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=agentsvc
+Group=agentsvc
+WorkingDirectory=/srv/agent-server/app/server
+Environment=NODE_ENV=production
+Environment=HOME=/srv/agent-server/home
+Environment=AGENT_SERVER_DATA_DIR=/srv/agent-server
+Environment=AGENT_SERVER_ADMIN_DIST=/srv/agent-server/app/admin
+ExecStart=/usr/local/bin/node dist/main.js
+Restart=on-failure
+RestartSec=3
+
+# 文件系统:整盘只读,home 不可见,只有服务数据可写;程序与运行环境由 root 所有、只读
+ProtectSystem=strict
+ProtectHome=yes
+ReadWritePaths=/srv/agent-server/home /srv/agent-server/data /srv/agent-server/workspaces /srv/agent-server/logs /srv/agent-server/knowledge
+PrivateTmp=yes
+UMask=0077
+# 纵深防御:即使权限配置出错,也看不到这些高权限 socket
+InaccessiblePaths=-/var/run/docker.sock -/run/docker.sock -/var/snap/lxd/common/lxd/unix.socket
+
+# 内核与提权
+NoNewPrivileges=yes
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectControlGroups=yes
+LockPersonality=yes
+# 不能开启 RestrictNamespaces:bubblewrap 沙箱需要 user/mount/net namespace
+
+# 资源上限
+MemoryMax=8G
+CPUQuota=600%
+TasksMax=1024
+
+[Install]
+WantedBy=multi-user.target

+ 30 - 0
deploy/deploy.sh

@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+# 构建并发布到 /srv/agent-server/app,同步技能目录,重启服务。
+# 用法:deploy/deploy.sh(需要 sudo 权限;首次部署前先执行 sudo deploy/install.sh)
+set -euo pipefail
+cd "$(dirname "$0")/.."
+ROOT=/srv/agent-server
+
+pnpm install --frozen-lockfile
+pnpm -r build
+
+STAGE=$(mktemp -d)
+trap 'rm -rf "$STAGE"' EXIT
+pnpm --filter @agent-server/server deploy --prod "$STAGE/server" >/dev/null
+
+sudo rsync -a --delete "$STAGE/server/" "$ROOT/app/server/"
+sudo rsync -a --delete apps/admin/dist/ "$ROOT/app/admin/"
+sudo rsync -a --delete skills/ "$ROOT/skills/"
+sudo chown -R root:root "$ROOT/app" "$ROOT/skills"
+sudo chmod -R a+rX "$ROOT/app" "$ROOT/skills"
+
+sudo systemctl restart agent-server
+for _ in $(seq 1 20); do
+  if curl -sf http://127.0.0.1:8787/healthz >/dev/null; then
+    echo "部署完成:http://127.0.0.1:8787/admin/"
+    exit 0
+  fi
+  sleep 0.5
+done
+echo "服务没有正常启动,查看日志:journalctl -u agent-server -n 50" >&2
+exit 1

+ 39 - 0
deploy/install.sh

@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+# 一次性安装(可重复执行):创建专用用户 agentsvc、目录与权限、systemd 服务、agent-claude 命令。
+# 用法:sudo deploy/install.sh
+set -euo pipefail
+cd "$(dirname "$0")/.."
+ROOT=/srv/agent-server
+SVC=agentsvc
+[ "$(id -u)" = 0 ] || { echo "请用 sudo 运行" >&2; exit 1; }
+
+if ! id "$SVC" &>/dev/null; then
+  useradd --system --home-dir "$ROOT/home" --no-create-home --shell /usr/sbin/nologin "$SVC"
+  echo "已创建用户 $SVC"
+fi
+
+# 程序与共享资源:root 所有、服务只读(Agent 无法篡改自身代码、Python 环境和技能)
+install -d -o root -g root -m 0755 "$ROOT" "$ROOT/app" "$ROOT/runtime" "$ROOT/skills"
+chown -R root:root "$ROOT/app" "$ROOT/runtime" "$ROOT/skills"
+chmod -R a+rX "$ROOT/runtime" "$ROOT/skills"
+
+# 服务数据:只有 agentsvc 可访问(含 CLI 登录态、数据库、会话工作区)
+for d in home data workspaces logs; do
+  install -d -o "$SVC" -g "$SVC" -m 0700 "$ROOT/$d"
+  chown -R "$SVC:$SVC" "$ROOT/$d"
+done
+
+# 资料库:agentsvc 组可读写,管理员加入该组后可以直接往里放资料
+install -d -o "$SVC" -g "$SVC" -m 2770 "$ROOT/knowledge"
+chown -R "$SVC:$SVC" "$ROOT/knowledge"
+chmod -R g+rwX "$ROOT/knowledge"
+if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != root ]; then
+  usermod -aG "$SVC" "$SUDO_USER"
+  echo "已把 $SUDO_USER 加入 $SVC 组(重新登录后生效,用于往资料库放文件)"
+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
+systemctl daemon-reload
+systemctl enable agent-server >/dev/null
+echo "安装完成。下一步:deploy/deploy.sh 部署程序,然后执行 agent-claude 并输入 /login 登录。"

+ 3 - 0
deploy/runtime/matplotlibrc

@@ -0,0 +1,3 @@
+font.family: sans-serif
+font.sans-serif: Noto Sans CJK JP, Noto Sans CJK SC, DejaVu Sans
+axes.unicode_minus: False

+ 13 - 0
deploy/runtime/package.json

@@ -0,0 +1,13 @@
+{
+  "name": "agent-runtime-node",
+  "private": true,
+  "description": "Agent 在沙箱中可直接 require 的 Node 包(通过 NODE_PATH 提供)",
+  "dependencies": {
+    "docx": "^9.14.0",
+    "pptxgenjs": "^4.0.1",
+    "react": "^19.3.1",
+    "react-dom": "^19.3.1",
+    "react-icons": "^5.7.0",
+    "sharp": "^0.35.5"
+  }
+}

+ 57 - 0
deploy/runtime/requirements.txt

@@ -0,0 +1,57 @@
+beautifulsoup4==4.15.0
+certifi==2026.10.19
+cffi==2.1.1
+charset-normalizer==3.5.2
+click==8.5.0
+cobble==0.1.4
+coloredlogs==15.0.1
+contourpy==1.3.2
+cryptography==50.0.2
+cycler==0.12.1
+defusedxml==0.7.1
+duckdb==1.5.6
+et_xmlfile==2.0.0
+flatbuffers==25.12.19
+fonttools==4.65.0
+humanfriendly==10.0
+idna==3.20
+kiwisolver==1.5.1
+lxml==6.1.3
+magika==1.0.3
+mammoth==1.13.0
+markdownify==1.2.3
+markitdown==0.1.9
+matplotlib==3.10.9
+mpmath==1.3.0
+narwhals==2.27.1
+numpy==2.2.6
+onnxruntime==1.23.2
+openpyxl==3.1.5
+packaging==26.3
+pandas==2.3.3
+pdfminer.six==20260107
+pdfplumber==0.11.10
+pillow==12.3.0
+plotly==7.1.0
+protobuf==7.36.2
+pyarrow==25.0.1
+pycparser==3.11
+pyparsing==3.3.3
+pypdf==6.21.2
+pypdfium2==5.14.0
+python-dateutil==3.0.0
+python-docx==1.2.0
+python-pptx==1.0.2
+pytz==2026.5
+reportlab==5.1.0
+requests==2.34.2
+seaborn==0.13.2
+six==1.17.0
+soupsieve==2.10
+sympy==1.14.0
+typing_extensions==4.16.0
+tzdata==2026.5
+urllib3==2.8.0
+xlsxwriter==3.3.0
+pytesseract
+pypdfium2

+ 24 - 0
deploy/setup-runtime.sh

@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+# 安装/更新 Agent 的共享运行环境(系统工具、Python venv、Node 包、字体配置),可重复执行。
+# 运行环境位于 /srv/agent-server/runtime,root 所有、服务只读。用法:sudo deploy/setup-runtime.sh
+set -euo pipefail
+cd "$(dirname "$0")/.."
+RT=/srv/agent-server/runtime
+[ "$(id -u)" = 0 ] || { echo "请用 sudo 运行" >&2; exit 1; }
+
+apt-get install -y -qq bubblewrap socat ripgrep pandoc poppler-utils qpdf libreoffice-core libreoffice-writer libreoffice-calc libreoffice-impress \
+  fonts-noto-cjk tesseract-ocr tesseract-ocr-chi-sim python3-venv >/dev/null
+
+install -d -m 0755 "$RT"
+[ -x "$RT/py/bin/python" ] || python3 -m venv "$RT/py"
+"$RT/py/bin/pip" install -q --upgrade pip
+"$RT/py/bin/pip" install -q -r deploy/runtime/requirements.txt
+
+install -d -m 0755 "$RT/node"
+install -m 0644 deploy/runtime/package.json "$RT/node/package.json"
+npm --prefix "$RT/node" install --omit=dev --no-audit --no-fund --loglevel=error
+
+install -m 0644 deploy/runtime/matplotlibrc "$RT/matplotlibrc"
+chown -R root:root "$RT"
+chmod -R a+rX "$RT"
+echo "运行环境已就绪:$RT"

+ 4 - 2
docs/README.md

@@ -36,10 +36,12 @@ docs/
 | [0005](adr/0005-cli-login-credentials.md) | 模型凭证使用 CLI 登录态,按使用者路由 | Superseded → 0008 |
 | [0006](adr/0006-mcp-gateway.md) | 内部平台数据通过统一 MCP Gateway 暴露 | Accepted(连接器暂缓) |
 | [0007](adr/0007-admin-ui-vue-vscode-style.md) | 后台用 Vue 3 + UnoCSS,视觉对齐 Claude VS Code 插件 | Accepted |
-| [0008](adr/0008-personal-cli-login.md) | 复用本人终端的 CLI 登录态,不区分使用者 | Accepted |
-| [0009](adr/0009-isolation-without-dedicated-user.md) | 暂不使用专用用户:引擎沙箱 + systemd 加固 + 应用层校验 | Accepted |
+| [0008](adr/0008-personal-cli-login.md) | 复用本人终端的 CLI 登录态,不区分使用者 | 部分被 0012 取代 |
+| [0009](adr/0009-isolation-without-dedicated-user.md) | 暂不使用专用用户:引擎沙箱 + systemd 加固 + 应用层校验 | Superseded → 0012 |
 | [0010](adr/0010-profile-file-access-policy.md) | 文件读写权限由 Profile 配置,三层合并 | Accepted |
 | [0011](adr/0011-admin-managed-settings.md) | 路径等运行配置由后台管理,不写死在配置文件中 | Accepted |
+| [0012](adr/0012-dedicated-service-user.md) | 以专用用户 agentsvc 运行服务 | Accepted |
+| [0013](adr/0013-official-skills-via-plugins.md) | 官方技能通过 Claude Code 插件机制使用 | Accepted |
 
 ## 调研
 

+ 1 - 1
docs/adr/0009-isolation-without-dedicated-user.md

@@ -1,6 +1,6 @@
 # ADR-0009 暂不使用专用用户:引擎沙箱 + systemd 加固 + 应用层校验
 
-- 状态:Accepted · 日期:2026-10-22 · 取代 ADR-0004
+- 状态:**Superseded by [ADR-0012](0012-dedicated-service-user.md)** · 日期:2026-10-22 · 取代 ADR-0004
 
 ## 决策
 - 服务以 `jerry-remote` 身份运行(为了直接使用本人的 CLI 登录态)

+ 18 - 0
docs/adr/0012-dedicated-service-user.md

@@ -0,0 +1,18 @@
+# ADR-0012 以专用用户 agentsvc 运行服务
+
+- 状态:Accepted · 日期:2026-10-23 · 取代 ADR-0009、ADR-0008 中"复用本人终端登录态"的部分
+
+## 背景
+运行账号 jerry-remote 在 `docker`、`lxd` 组中,并且有免密 sudo,三者都等同 root。Agent 的命令以运行账号的身份执行,一旦某层沙箱出现漏洞,影响范围就是整台机器。2026-10-23 复查时就发现,文档转换沙箱里可以连通 LXD 的 API(见 06 §8)。
+
+## 决策
+- 新建系统用户 `agentsvc`(不在 docker/lxd/sudo 组,shell 为 nologin,home 为 `/srv/agent-server/home`),由 systemd 以该用户运行服务(`deploy/agent-server.service`)
+- 目录权限:程序 `app/`、运行环境 `runtime/`、技能 `skills/` 由 root 所有,服务只读(Agent 无法篡改自身代码和环境);`home/ data/ workspaces/ logs/` 只有 agentsvc 可访问(0700);`knowledge/` 为 agentsvc 组可写,管理员加入该组后可以放资料
+- 使用 Agent SDK 自带的 Claude Code 二进制(官方未修改,版本随 SDK 锁定),不再依赖个人 home 中的 CLI
+- 登录:管理员执行 `agent-claude`(以 agentsvc 身份运行该二进制)并输入 /login,用的仍是本人的 Claude 账号;登录态保存在 agentsvc 的 home 中,与个人终端互不影响
+- 开发环境以本人身份运行(端口 8788,数据在仓库 `.dev-data/`),共享只读的 runtime 与 skills
+
+## 后果
+- 正式服务与开发环境的数据、登录态、会话历史完全分开
+- 迁移前创建的会话,其 Claude 会话记录留在个人账号中,续聊时会自动以新上下文继续(03 §6)
+- systemd 额外屏蔽 docker/LXD socket 并禁止提权,作为纵深防御

+ 17 - 0
docs/adr/0013-official-skills-via-plugins.md

@@ -0,0 +1,17 @@
+# ADR-0013 官方技能通过 Claude Code 插件机制使用
+
+- 状态:Accepted · 日期:2026-10-23
+
+## 背景
+Anthropic 官方的 docx/xlsx/pptx/pdf 技能是"源码可见、非开源"的专有许可:使用受你与 Anthropic 之间的协议约束,并且额外禁止把它们提取到服务之外、在服务之外保留副本、复制、制作衍生作品、分发和逆向工程。
+
+## 决策
+- 只通过 Claude Code 自带的 `claude plugin` 命令(以 agentsvc 身份运行)添加市场、安装、更新、启用、停用和卸载插件。文件由 Claude Code 下载并存放在它自己的插件目录中
+- AgentServer 只读取插件清单和 SKILL.md 的元数据(用于展示),在运行时通过 SDK 的 `plugins` 选项按路径加载,**不复制、不修改、不提交到仓库**
+- 带专有许可的插件只提供给 Claude 引擎;Codex(M2)不加载
+- 后台"技能与插件"页面负责管理,安装官方技能前要求管理员确认许可说明
+- 官方技能中调用 LibreOffice 的脚本在 Bash 沙箱中无法运行,由服务端工具 `convert_document`、`recalc_workbook` 替代(写在我们自己的提示词和技能中,不修改官方技能)
+- 官方技能需要的依赖(Node:docx、pptxgenjs、sharp、react;系统:qpdf、tesseract 等)由 `deploy/setup-runtime.sh` 预装
+
+## 说明
+这是对许可条款的理解,不构成法律意见。登录账号属于组织(Team)计划时,适用组织与 Anthropic 的商业协议,以组织的规定为准。

+ 11 - 2
docs/design/06-security.md

@@ -130,7 +130,10 @@ TasksMax=512
 - **推荐**:把资料放到 home 以外(如 `/srv/agent-server/knowledge`、`/data/...`)。
 - 或者在 unit 中追加一行 `BindReadOnlyPaths=`,然后重启服务。后台在保存资料库时会检测路径是否可见,不可见时直接给出需要追加的配置行。
 
-### 5.3 专用用户(暂不启用)
+### 5.3 专用用户(已于 2026-10-23 启用,ADR-0012)
+
+> 下面是启用前的分析,保留作为背景。当前的部署方式见 10-deployment.md。
+
 同 UID 运行时仍存在的缺口:服务可以向你的其他进程发信号(systemd 249 没有 `PrivatePIDs`);home 以外属于你的文件按权限仍可访问;加固配置出错时没有兜底。以后需要时,迁移步骤是:创建 `agentsvc` 用户 → 用该用户重新登录 CLI → 修改 unit 的 User 和绑定路径。服务代码不受影响,因为所有路径都从配置中读取。
 
 ## 6. 网络策略
@@ -160,7 +163,13 @@ TasksMax=512
 
 - **Bash 沙箱禁止创建 Unix socket**(seccomp:`socket(AF_UNIX) = EPERM`),LibreOffice 因此无法在 Bash 中运行。
   - **不采用** `sandbox.network.allowAllUnixSockets: true`:运行账号在 `docker`、`lxd` 组中,一旦放开,Agent 就能连接 `/var/run/docker.sock`,等同获得宿主 root 权限。
-  - 改为提供受控工具 `convert_document`(`services/office.ts`):服务进程自行用 bubblewrap 启动 LibreOffice,`--unshare-all`(断网、独立 IPC/PID)、私有 `/tmp` 和 `/run`、隐藏 home 和服务数据目录、只挂载当前工作区;输入和输出路径都必须在工作区内。
+  - 改为提供受控工具 `convert_document`(`services/office.ts`):服务进程自行用 bubblewrap 启动 LibreOffice,`--unshare-all`(断网、独立 IPC/PID),**白名单挂载**(只有 `/usr`、`/etc`、字体缓存和当前工作区),`/usr`、`/etc` 中零星的 socket 用 `/dev/null` 覆盖;输入和输出路径都必须在工作区内。
+  - 2026-10-23 复查:最初的实现是"整盘只读挂载,再隐藏 /run、home 和数据目录",这样 **LXD 的 socket(`/var/snap/lxd/common/lxd/unix.socket`)在沙箱内仍然可以连通**(实测 API 返回 200),所以改成了白名单方式,并加了回归测试(`test/office.test.ts`)。
 - **restricted 模式下 Bash 的读取范围**:沙箱只能整块屏蔽目录(home、`/srv`、`/mnt`、`/media`、`/root`),`/tmp`、`/etc` 等系统路径对 Bash 仍然可读。文件工具(Read/Grep/Glob)由 hook 严格限制在白名单内。这是已知、可接受的缺口;systemd 部署时 `PrivateTmp=yes` 会让 `/tmp` 成为服务私有。
 - Claude CLI 为每个工作目录分配的临时目录(`/tmp/claude-<uid>/<工作目录转义>`)已加入可写基线,否则模型会在写临时脚本时被拦截。
 - 建议(未实施):把运行账号移出 `docker` / `lxd` 组,或者尽早迁移到专用用户(§5.3)。这两个组本身就是本机上权限最大的入口。
+
+## 9. 专用用户启用后(2026-10-23)
+- 服务以 `agentsvc` 运行:不在 docker/lxd/sudo 组;程序、运行环境和技能由 root 所有、服务只读;数据目录 0700
+- 内置禁止项中的 `~` 现在指 `/srv/agent-server/home`:`~/.claude/.credentials.json`、`~/.claude.json` 保护的是服务账号自己的登录态;home 位于数据目录内,同时受"服务数据目录"的软禁止保护(其他会话的 Claude 会话记录也在这里)
+- 官方技能的许可约束与使用方式见 ADR-0013

+ 44 - 41
docs/design/10-deployment.md

@@ -1,47 +1,50 @@
 # 10 部署与运维
 
-## 1. 目标环境(当前服务器)
-- Ubuntu 22.04.5 LTS,systemd 249,12 核 / 30GB 内存
-- Node 22.15、Python 3.10
-- `claude`(`~/.local/bin/claude`)、`codex` 0.154.0(`~/.local/bin/codex`),均由本人在终端登录
-- unprivileged user namespace 已开启,`bwrap` 可正常运行(已验证)
+## 1. 目标环境
+- Ubuntu 22.04.5 LTS,systemd 249,12 核 / 30GB 内存,Node 22.15(`/usr/local/bin/node`)
+- 服务以专用用户 **agentsvc** 运行(ADR-0012),使用 Agent SDK 自带的 Claude Code 二进制
 
-## 2. 依赖安装状态(2026-10-22 已完成)
+## 2. 目录与权限
 
-| 依赖 | 状态 | 用途 |
-|---|---|---|
-| bubblewrap | ✅ 已有 | 两个引擎的沙箱 |
-| socat | ✅ 新装 | Claude 沙箱网络代理 |
-| ripgrep | ✅ 新装 | 资料检索、Codex 搜索 |
-| pandoc | ✅ 新装(2.9) | docx 读取与转换 |
-| fonts-noto-cjk | ✅ 新装 | 中文图表、PDF |
-| LibreOffice、poppler-utils | ✅ 已有 | Office 转换、公式重算、PDF 渲染 |
-| `/srv/agent-server/{data,workspaces,knowledge,skills,runtime,logs}` | ✅ 已创建(属主 jerry-remote,0750) | 服务数据目录 |
-| Python venv `/srv/agent-server/runtime/py` | ✅ 已创建 | pandas 2.3、numpy、pyarrow、duckdb 1.5、matplotlib 3.10、seaborn、plotly、openpyxl、xlsxwriter、python-docx、python-pptx、pypdf、pdfplumber、reportlab、markitdown |
-| `/srv/agent-server/runtime/requirements.lock` | ✅ | 锁定的 Python 依赖版本,用于重建环境 |
-| `/srv/agent-server/runtime/matplotlibrc` | ✅ 已验证中文渲染 | 默认字体为 Noto Sans CJK |
-| Node 依赖(docx 等) | ⏳ M1 | 随项目 `package.json` 安装 |
-| 官方技能(xlsx/docx/pdf/pptx) | ⏳ M1 | 核对许可证后放入 `skills/` |
-
-重建 Python 环境:
-```bash
-python3 -m venv /srv/agent-server/runtime/py
-/srv/agent-server/runtime/py/bin/pip install -r /srv/agent-server/runtime/requirements.lock
 ```
+/srv/agent-server/            root 0755
+├─ app/server/                root,只读:服务端(pnpm deploy 产物,含 SDK 自带的 claude)
+├─ app/admin/                 root,只读:后台静态资源
+├─ runtime/                   root,只读:py/(venv)、node/node_modules、matplotlibrc
+├─ skills/                    root,只读:本地技能(来自仓库 skills/)
+├─ home/                      agentsvc 0700:Claude Code 登录态与插件(~/.claude)
+├─ data/                      agentsvc 0700:agent.db
+├─ workspaces/                agentsvc 0700:会话工作区
+├─ logs/                      agentsvc 0700
+└─ knowledge/                 agentsvc:agentsvc 2770:默认资料库位置(管理员在 agentsvc 组中,可以直接放文件)
+```
+
+## 3. 脚本(deploy/)
+
+| 脚本 | 用途 | 何时执行 |
+|---|---|---|
+| `sudo deploy/install.sh` | 创建 agentsvc、目录与权限、安装 systemd unit 和 `agent-claude` 命令 | 首次安装;修改 unit 后 |
+| `sudo deploy/setup-runtime.sh` | 安装系统工具、Python venv(`deploy/runtime/requirements.txt`)、Node 包(`deploy/runtime/package.json`)、字体配置 | 首次安装;依赖变化后 |
+| `deploy/deploy.sh` | 构建 → `pnpm deploy` → rsync 到 app/、同步 skills/ → 重启服务 → 健康检查 | 每次发布 |
+| `agent-claude ...` | 以 agentsvc 身份运行自带的 Claude Code | 登录(`/login`)、查看状态(`auth status`)、手动管理插件 |
+
+首次安装顺序:`sudo deploy/install.sh` → `sudo deploy/setup-runtime.sh` → `deploy/deploy.sh` → `agent-claude`(输入 /login)。
+
+## 4. systemd 加固(deploy/agent-server.service)
+- `User=agentsvc`;`ProtectSystem=strict` + 只有 home/data/workspaces/logs/knowledge 可写;`ProtectHome=yes`;`PrivateTmp=yes`;`UMask=0077`
+- `NoNewPrivileges=yes`;`InaccessiblePaths=` docker.sock 与 LXD socket(纵深防御,agentsvc 本身也没有权限)
+- 不能开启 `RestrictNamespaces`(bubblewrap 需要)
+- 资源:`MemoryMax=8G`、`CPUQuota=600%`、`TasksMax=1024`
+- 资料库放在 `/srv/agent-server/knowledge` 以外时,需要在 unit 中追加 `ReadOnlyPaths=`(或在可写资料库中追加 `ReadWritePaths=`)并重启;home 下的路径对服务不可见
+
+## 5. 开发环境
+- `pnpm dev:server`:以本人身份运行,端口 **8788**,数据在仓库 `.dev-data/`,使用本人的 `~/.claude` 登录态
+- `pnpm dev:admin`:http://127.0.0.1:5173/admin/,代理到 8788
+- runtime 和 skills 与正式服务共用(只读)
 
-## 3. 运行方式
-- **开发期**:在项目目录直接 `pnpm dev` 运行,不经过 systemd,用于快速迭代。
-- **常驻运行**:使用 systemd 系统服务,`User=jerry-remote`,加固配置见 06 §5.1。构建产物部署到 `/srv/agent-server/app`(因为 ProtectHome 会隐藏 home 下的项目目录)。
-
-## 4. 对外访问
-- AgentServer 只监听 `127.0.0.1:8787`
-- 前面放 Caddy 或 Nginx 做 HTTPS 和反向代理;需要为 SSE 关闭缓冲(Nginx 用 `proxy_buffering off`,并加大读超时)
-- 只在内网或 VPN(如 Tailscale)中开放
-
-## 5. 运维
-- 日志:pino JSON 输出到 journald;`journalctl -u agent-server`
-- 备份:每天对 `data/agent.db` 做 SQLite 在线备份;CLI 登录态不备份
-- 健康检查:`GET /healthz`(进程存活)、`GET /readyz`(数据库、两个 CLI 的登录状态、app-server 进程、Codex 个人插件是否已禁用的自检结果)
-- 登录过期:后台"引擎状态"页和状态栏显示红色徽标;在终端重新登录即可,服务不需要重启
-- 升级:见 03 §5
-- 定时任务(进程内):工作区清理、用量日汇总、引擎健康检查(每 10 分钟)、资料库可见性复检
+## 6. 运维
+- 日志:`journalctl -u agent-server -f`
+- 状态:`systemctl status agent-server`;健康检查 `GET /healthz`、`GET /readyz`
+- 登录过期:后台"概览"和状态栏会提示,执行 `agent-claude` 重新登录,无需重启
+- 备份:每天对 `data/agent.db` 做 SQLite 在线备份;`home/` 中的登录态可以不备份(重新登录即可)
+- 升级 Claude Code:升级 `@anthropic-ai/claude-agent-sdk` 依赖后执行 deploy.sh(自带 CLI 版本随之更新),再在 Playground 回归验证

+ 3 - 3
docs/design/11-roadmap.md

@@ -23,7 +23,7 @@
 - Profile 管理(另存为、版本历史)
 - `packages/client` 与 `packages/chat-ui`,在一个上层平台中接入
 - 用量统计、概览页、审计日志页
-- systemd 常驻部署
+- ~~systemd 常驻部署~~(已提前完成,ADR-0012)
 
 ### M3:完善
 - 资料库全文索引(`kb_search`)
@@ -43,9 +43,9 @@
 | Q2 | codex app-server 协议的具体方法名与审批回复格式 | M2 用 `generate-ts` 落定后回填 03 §3 |
 | Q3 | 用 `-c` 能否完整禁用个人 config 中的插件和 MCP | M2 启动自检验证;不行时再评估独立 CODEX_HOME 方案(注意不能复制 auth.json) |
 | Q4 | Claude SDK 中 AskUserQuestion 的回答格式 | ✅ 已核对:answers 以问题原文为键(03 §6) |
-| Q5 | 官方 Skills 的许可证是否允许在自托管服务中使用 | 许可证为"受你与 Anthropic 的协议约束",无法自行判定;当前使用自写技能,是否引入由你决定 |
+| Q5 | 官方 Skills 的许可证是否允许在自托管服务中使用 | ✅ 采用 ADR-0013:只通过 Claude Code 插件机制安装、按路径加载、仅限 Claude;由管理员在后台确认后安装 |
 | Q6 | 本地资料的规模与存放位置 | 决定 M3 索引的优先级;建议放在 home 以外 |
-| Q7 | 运行账号在 docker / lxd 组中(相当于 root) | 建议移出这两个组,或者提前迁移到专用用户(06 §8) |
+| Q7 | 运行账号在 docker / lxd 组中(相当于 root) | ✅ 已迁移到专用用户 agentsvc(ADR-0012) |
 
 ## M1 交付清单
 

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

@@ -149,3 +149,59 @@ export interface Page<T> {
   items: T[]
   nextCursor: string | null
 }
+
+// ---- 技能与插件(通过 Claude Code 官方插件机制安装,ADR-0013)----
+
+export type LicenseKind = 'open' | 'restricted' | 'unknown'
+
+export interface SkillInfo {
+  /** 带插件前缀的技能 ID,例如 agent-skills:report-conventions、document-skills:xlsx */
+  id: string
+  name: string
+  description: string
+  plugin: string
+  source: 'local' | 'plugin'
+  license: LicenseKind
+}
+
+export interface InstalledPluginDto {
+  id: string
+  name: string
+  marketplace: string
+  version: string | null
+  enabled: boolean
+  installPath: string
+  installedAt: string | null
+  lastUpdated: string | null
+  skills: SkillInfo[]
+  license: { kind: LicenseKind; summary: string | null }
+}
+
+export interface AvailablePluginDto {
+  pluginId: string
+  name: string
+  description: string
+  marketplaceName: string
+  version: string | null
+  installed: boolean
+}
+
+export interface MarketplaceDto {
+  name: string
+  source: string
+  location: string | null
+}
+
+export interface PluginStateDto {
+  cliPath: string
+  installed: InstalledPluginDto[]
+  available: AvailablePluginDto[]
+  marketplaces: MarketplaceDto[]
+  localSkills: SkillInfo[]
+  error: string | null
+}
+
+export type PluginAction =
+  | { action: 'marketplace-add'; source: string }
+  | { action: 'marketplace-remove' | 'marketplace-update'; name: string }
+  | { action: 'install' | 'uninstall' | 'update' | 'enable' | 'disable'; plugin: string }

+ 2 - 0
packages/shared/src/settings.ts

@@ -5,6 +5,8 @@ export interface AppSettings {
     workspaceRoot: string
     skillsDir: string
     pythonRuntime: string
+    /** Agent 可直接 require 的 Node 包目录(NODE_PATH) */
+    nodeModules: string
     matplotlibrc: string
   }
   security: {

+ 2 - 1
skills/skills/report-conventions/SKILL.md

@@ -27,10 +27,11 @@ description: 生成数据分析报告、图表、Word/Excel/PDF 文档时的规
 
 ## Excel(openpyxl / xlsxwriter)
 - 汇总值尽量写公式(如 `=SUM(B2:B9)`),不要写死计算结果。
+- 用 openpyxl 写入公式后,**必须调用 `recalc_workbook` 工具**重新计算并检查错误单元格;它报告的错误要全部修正后再交付。
 - 表头加粗、冻结首行、调整列宽。
 
 ## PDF 与格式转换
-- 格式转换(docx/xlsx/pptx → pdf、doc → docx 等)**必须使用 `convert_document` 工具**。不要在 Bash 里调用 soffice/libreoffice,沙箱禁止它需要的进程通信,一定会失败。
+- 格式转换(docx/xlsx/pptx → pdf、doc → docx 等)**必须使用 `convert_document` 工具**。不要在 Bash 里调用 soffice/libreoffice(包括其他技能自带的 recalc.py、转换脚本),沙箱禁止它需要的进程通信,一定会失败。
 - 示例:`convert_document(input="outputs/Q3销售分析.docx", format="pdf")` 会生成 `outputs/Q3销售分析.pdf`。
 - 自检:用 `pdftoppm -png -r 60 <pdf> .agent/preview` 渲染预览图,再用 Read 工具查看版式。