Jelajahi Sumber

新增 Bot 模式插件、应用连接和工具菜单

Samien 1 bulan lalu
induk
melakukan
abcb6795bc

+ 52 - 0
docs/verification/2026-09-11-bot-plugins/README.md

@@ -0,0 +1,52 @@
+# Bot 模式插件与工具入口验收
+
+日期:2026-09-10 至 2026-09-11。参考 OpenMausBot 的插件管理和侧栏菜单;本次改动位于 Yaoyao,未修改 OpenMausBot。
+
+## 已实现
+
+- 仅 Bot 模式在账号上方显示“工具”:插件、自动化、已连接应用。点击账号打开设置、关于、帮助菜单,管理员保留切换聊天模式入口。
+- 插件在设置中心管理,包含应用目录、已连接应用与 MCP 服务。应用目录支持搜索和分批显示。关于页显示当前构建版本;帮助链接为 `https://yaoyao.samien.cn`。
+- MCP 支持本机 stdio 程序及 Streamable HTTP(JSON/SSE 响应);可添加、编辑、测试、启用、停用、移除,并选择授权 Bot。新增或修改配置默认停用,测试成功后才可启用。
+- 应用连接使用当前账号自己的 Composio 项目 API Key,支持授权链接、多账号别名、逐账号断开和 Bot 授权;可填写自定义 Auth Config 映射。连接清单读取失败时保留已知状态,不能把错误当成全部断开。
+- 自动化入口复用现有 Bot 定时任务:选择 Bot 后创建任务、查看运行日志和立即执行。
+
+## 隔离与兼容
+
+- 数据以账号为边界保存在 Web 的工作区存储中;API Key、MCP 环境变量/请求头及 Composio 会话 URL 加密保存,列表接口不回显凭据。环境变量/请求头的 `true` 表示保留已有值。
+- 自定义 MCP 在 Web 所在主机执行或连接,由管理员配置和使用。应用连接属于各自账号。授权只能选择当前账号的有效 Bot,远端引用和临时助手不在列表中。
+- 工具通过现有会话工具桥挂载到 Bot 当前调用,不修改 Hermes 的普通聊天配置。对应执行环境需要支持原生工具桥;停用、修改或移除插件后,旧工具调用会被拒绝。
+- 普通聊天的侧栏、账号切换和设置入口保持原行为,不显示新增 Bot 工具分类;既有团队、电脑和 Runner 工具共用原授权机制。
+- stdio 传输改编自 `OpenMausBot/server/stdio-mcp.ts`,保留 Apache-2.0 来源说明;子进程只获得最小环境及显式配置,不继承应用凭据。
+
+## 验证
+
+相关单元、服务端及客户端回归:11 个文件、176 项通过。
+
+```sh
+NODE_OPTIONS=--no-experimental-webstorage npx vitest run tests/server/workspacePlugins.test.ts tests/server/workspaceTeamTools.test.ts tests/server/workspaceRoutes.test.ts tests/server/workspacePanels.test.ts tests/server/workspace.test.ts tests/server/runner.test.ts tests/server/workspaceRemoteAgents.test.ts tests/server/workspacePairedNodes.test.ts tests/client/workspaceShell.test.ts tests/client/settingsCenterDialog.test.ts tests/client/botPluginsPanel.test.ts
+npm run typecheck
+npm run build
+```
+
+类型检查与构建成功。构建仍有既有的前端大 chunk 提示。
+
+浏览器通过 CUA 在独立测试服务验收,覆盖桌面 1280 px、手机 375 px、浅色/深色、菜单键盘操作,以及切换回普通聊天后的边界。环境启动方式:
+
+```sh
+WORKSPACE_FIXTURE_PORT=18833 WORKSPACE_FIXTURE_UPSTREAM_PORT=19133 WORKSPACE_FIXTURE_PLUGINS=1 node --import tsx tests/fixtures/workspace-server.ts
+```
+
+临时测试账号为 `fixture / fixture-pass`。仅在该 disposable fixture 使用;这些不是生产凭据。
+
+- 从 UI 新增 HTTP MCP 服务,测试发现一个工具,授权并启用后发送 `[plugin-roundtrip]` 验收消息,原生工具实际返回“Bot 插件原生调用成功”。
+- 使用模拟 Composio 服务配置应用连接,授权给 Bot;停用自定义 MCP 后,再次单独验证应用工具成功返回。
+- 从自动化入口创建任务并立即执行,运行日志显示完成,结果回到 Bot 聊天。
+- SQLite 只读核验记录了三次成功运行、三个工具调用、零待执行 turn。摘要见 [browser-result.json](browser-result.json)。
+
+MCP stdio 测试启动了真实的隔离子进程;HTTP MCP 测试和浏览器验收使用回环地址 fixture。Composio OAuth/第三方账号为模拟服务验证,未用真实项目密钥授权第三方应用。
+
+## 参考协议与交付范围
+
+实现参考 [Composio Session API](https://docs.composio.dev/reference/v3/api-reference/tool-router/postToolRouterSession)、[Composio MCP 会话](https://docs.composio.dev/docs/sessions-via-mcp) 和 [MCP HTTP 传输协议](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports)。
+
+本次仅源码、构建和隔离验收,没有部署或重启 `15300` / `8799` 服务,也没有修改真实用户的插件配置、第三方连接或定时任务。使用应用连接时,需要在 Bot 模式的插件设置中配置自己的 Composio API Key。

+ 28 - 0
docs/verification/2026-09-11-bot-plugins/browser-result.json

@@ -0,0 +1,28 @@
+{
+  "environment": "isolated browser fixture only",
+  "browserWidths": [
+    1280,
+    375
+  ],
+  "themes": [
+    "light",
+    "dark"
+  ],
+  "pluginRoundTrips": 3,
+  "toolCalls": 3,
+  "runStatuses": [
+    "complete",
+    "complete",
+    "complete"
+  ],
+  "pendingTurns": 0,
+  "routineRuns": 1,
+  "verifiedPaths": [
+    "Tools > Plugins > MCP > add, test, enable, call",
+    "Tools > Connected Apps > authorize Bot > call",
+    "Tools > Automation > create > run now > completed log",
+    "Account > About / Settings / Help",
+    "Ordinary chat has no Bot tools entry"
+  ],
+  "realThirdPartyOAuthTested": false
+}

+ 23 - 4
src/client/components/app/SettingsCenterDialog.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { computed, nextTick, reactive, ref, watch } from 'vue'
+import { computed, defineAsyncComponent, nextTick, reactive, ref, watch } from 'vue'
 import type { Profile } from '@shared/types'
 import AccountSecurityPanel from '@/components/app/AccountSecurityPanel.vue'
 import AgentIdentityPanel from '@/components/app/AgentIdentityPanel.vue'
@@ -16,8 +16,13 @@ import AgentAvatar from '@/components/common/AgentAvatar.vue'
 import AccountInitialAvatar from '@/components/common/AccountInitialAvatar.vue'
 import type { ProfileIdentityInput } from '@/api/profiles'
 import AppIcon from '@/components/common/AppIcon.vue'
+import BrandMark from '@/components/common/BrandMark.vue'
+import { version } from '../../../../package.json'
+const BotPluginsPanel = defineAsyncComponent(() => import('@/components/workspace/BotPluginsPanel.vue'))
+const BotAutomationPanel = defineAsyncComponent(() => import('@/components/workspace/BotAutomationPanel.vue'))
 
 type SettingsPage =
+  | 'bot-plugins' | 'bot-apps' | 'bot-routines' | 'bot-about'
   | 'agent-identity'
   | 'agent-models'
   | 'account-security'
@@ -32,7 +37,7 @@ type SettingsPage =
   | 'system-local-vm'
   | 'system-update'
 
-type SettingsIcon = 'users' | 'model' | 'settings' | 'panel' | 'monitor' | 'sun' | 'link' | 'bell' | 'audio' | 'refresh'
+type SettingsIcon = 'users' | 'model' | 'settings' | 'panel' | 'monitor' | 'sun' | 'link' | 'bell' | 'audio' | 'refresh' | 'tools' | 'info' | 'clock'
 type ThemePreference = 'light' | 'dark' | 'system'
 
 interface NavigationItem {
@@ -105,6 +110,12 @@ const accountItems = computed<NavigationItem[]>(() => [
   ...(props.isAdmin ? [{ key: 'account-mobile', label: '手机登录', icon: 'panel' } satisfies NavigationItem] : []),
   { key: 'appearance', label: '外观', icon: 'sun' },
 ])
+const botItems = computed<NavigationItem[]>(() => props.botMode ? [
+  { key: 'bot-plugins', label: '插件', icon: 'tools' },
+  { key: 'bot-apps', label: '已连接应用', icon: 'link' },
+  { key: 'bot-routines', label: '自动化', icon: 'clock' },
+  { key: 'bot-about', label: '关于', icon: 'info' },
+] : [])
 const systemItems: NavigationItem[] = [
   { key: 'system-local-vm', label: '本地虚拟机', icon: 'monitor' },
   { key: 'system-overview', label: '系统概览', icon: 'panel' },
@@ -119,11 +130,13 @@ const systemItems: NavigationItem[] = [
 const allAllowedPages = computed(() => new Set<SettingsPage>([
   ...agentItems.value.map(item => item.key),
   ...accountItems.value.map(item => item.key),
+  ...botItems.value.map(item => item.key),
   ...(props.isAdmin ? systemItems.map(item => item.key) : []),
 ]))
 const activeDirty = computed(() => Boolean(dirtyPages[activePage.value]))
 const showFixedFooter = computed(() => activePage.value === 'agent-identity' || activePage.value === 'account-security')
 const activeTitle = computed(() => ({
+  'bot-plugins': '插件', 'bot-apps': '已连接应用', 'bot-routines': '自动化', 'bot-about': '关于夭夭 AI',
   'agent-identity': '身份与头像',
   'agent-models': '模型与 Provider',
   'account-security': '登录与安全',
@@ -141,6 +154,7 @@ const activeTitle = computed(() => ({
 const accountName = computed(() => props.pairingUserName || props.userName || '当前账号')
 const showAgentSelector = computed(() => activePage.value.startsWith('agent-'))
 const activeScope = computed(() => {
+  if (activePage.value.startsWith('bot-')) return 'Bot 模式 · 当前账号'
   if (activePage.value.startsWith('agent-')) return `正在设置:${profileTitle(props.activeProfile)} / ${props.activeProfile?.name || '未选择'}`
   if (activePage.value.startsWith('account-')) return `当前账号:${accountName.value}${props.isAdmin ? ' · 管理员' : ''}`
   if (activePage.value === 'appearance') return '仅影响当前浏览器'
@@ -250,7 +264,7 @@ watch(() => [props.open, props.initialPage] as const, ([open, initialPage]) => {
     : props.activeProfile ? 'agent-identity' : 'account-security'
   for (const key of Object.keys(dirtyPages) as SettingsPage[]) dirtyPages[key] = false
   profileMenuOpen.value = false
-  mobileDetailOpen.value = false
+  mobileDetailOpen.value = initialPage.startsWith('bot-')
   void nextTick(() => dialog.value?.focus())
 }, { immediate: true })
 function requestModeSwitch() {
@@ -301,6 +315,7 @@ function requestModeSwitch() {
 
                 <nav>
                   <section v-if="isAdmin"><button type="button" :disabled="updateLocked" @click="requestModeSwitch"><AppIcon :name="botMode ? 'chat' : 'users'" :size="18" /><span>{{ botMode ? '进入聊天模式' : '进入 Bot 模式' }}</span></button></section>
+                  <section v-if="botItems.length"><h3>Bot 模式</h3><button v-for="item in botItems" :key="item.key" type="button" :class="{ active: activePage === item.key }" :aria-current="activePage === item.key ? 'page' : undefined" @click="selectPage(item.key)"><AppIcon :name="item.icon" :size="20" /><span>{{ item.label }}</span></button></section>
                   <section>
                     <h3>当前机器人</h3>
                     <button v-for="item in agentItems" :key="item.key" type="button" :class="{ active: activePage === item.key }" :aria-current="activePage === item.key ? 'page' : undefined" @click="selectPage(item.key)"><AppIcon :name="item.icon" :size="20" /><span>{{ item.label }}</span></button>
@@ -324,8 +339,11 @@ function requestModeSwitch() {
                 <button class="settings-center__close" type="button" aria-label="关闭设置中心" :disabled="updateLocked" @click="requestClose"><AppIcon name="close" :size="18" /></button>
               </header>
               <div class="settings-content__scroll">
+                <BotPluginsPanel v-if="botMode && (activePage === 'bot-plugins' || activePage === 'bot-apps')" :key="activePage" :connected-only="activePage === 'bot-apps'" @dirty-change="setDirty(activePage, $event)" />
+                <BotAutomationPanel v-else-if="botMode && activePage === 'bot-routines'" />
+                <section v-else-if="botMode && activePage === 'bot-about'" class="bot-about" aria-label="关于夭夭 AI"><BrandMark :size="48" /><h4>夭夭 AI</h4><p>版本 {{ version }}</p><p>与 AI 聊天,让 Bot 协作完成任务。</p><a href="https://yaoyao.samien.cn" target="_blank" rel="noopener noreferrer">帮助中心 <AppIcon name="external" :size="15" /></a></section>
                 <AgentIdentityPanel
-                  v-if="activePage === 'agent-identity' && activeProfile"
+                  v-else-if="activePage === 'agent-identity' && activeProfile"
                   :key="activeProfile.name"
                   :profile="activeProfile"
                   :busy="identityBusy"
@@ -394,6 +412,7 @@ function requestModeSwitch() {
 </template>
 
 <style scoped>
+.bot-about{display:grid;justify-items:center;gap:14px;padding:36px 12px;text-align:center}.bot-about h4{font-size:22px;margin:0}.bot-about p{margin:0;color:var(--text-secondary);line-height:1.6}.bot-about a{display:flex;gap:6px;align-items:center;color:var(--accent);padding:10px}.bot-about a:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
 .settings-center-layer { position: fixed; z-index: 300; inset: 0; display: grid; place-items: center; padding: 64px; background: color-mix(in srgb, #000 24%, transparent); backdrop-filter: blur(4px); }
 .settings-center { display: block; width: min(820px, calc(100vw - 128px)); height: min(600px, calc(100dvh - 128px)); overflow: hidden; border: 1px solid var(--line); border-radius: 16px; outline: 0; background: var(--surface-raised); box-shadow: 0 20px 60px rgba(0,0,0,.18); color: var(--text-primary); }
 .settings-center__close,.mobile-back { display: grid; width: 44px; height: 44px; place-items: center; padding: 0; border: 0; border-radius: 10px; background: transparent; color: var(--text-primary); cursor: pointer; }

+ 41 - 10
src/client/components/app/WorkspaceShell.vue

@@ -19,6 +19,7 @@ type NavItem = {
 }
 
 type SettingsPage =
+  | 'bot-plugins' | 'bot-apps' | 'bot-routines' | 'bot-about'
   | 'agent-identity'
   | 'agent-models'
   | 'account-security'
@@ -115,6 +116,9 @@ const settingsMenuOpen = ref(false)
 const settingsMenu = ref<HTMLElement | null>(null)
 let settingsTrigger: HTMLButtonElement | null = null
 const settingsMenuPosition = ref({ left: '8px', top: '8px' })
+const toolsMenuOpen = ref(false), toolsMenu = ref<HTMLElement | null>(null)
+const toolsPosition = ref({ left: '8px', top: '8px' })
+let toolsTrigger: HTMLButtonElement | null = null
 const desktopSidebarContext = ref<HTMLElement | null>(null)
 const mobileSidebarContext = ref<HTMLElement | null>(null)
 
@@ -172,6 +176,7 @@ function closeMenus(event: MouseEvent) {
   if (!target.closest('.sidebar-account-switcher')) profileMenuOpen.value = false
   if (!target.closest('.workspace-create-menu, .sidebar-create-trigger')) createMenuOpen.value = false
   if (!target.closest('.workspace-settings-menu, .sidebar-settings-trigger')) settingsMenuOpen.value = false
+  if (!target.closest('.workspace-tools-menu, .sidebar-tools-trigger')) toolsMenuOpen.value = false
 }
 function closeCreateMenu() {
   createMenuOpen.value = false
@@ -197,8 +202,8 @@ function chooseCreate(kind: 'agent' | 'group' | 'remote-agent') {
 function actionMenuKeydown(event: KeyboardEvent) {
   if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return
   event.preventDefault()
-  const buttons = [...(event.currentTarget as HTMLElement).querySelectorAll<HTMLButtonElement>('[role="menuitem"]')]
-  const index = buttons.indexOf(document.activeElement as HTMLButtonElement)
+  const buttons = [...(event.currentTarget as HTMLElement).querySelectorAll<HTMLElement>('[role="menuitem"]')]
+  const index = buttons.indexOf(document.activeElement as HTMLElement)
   buttons[event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 : (index + (event.key === 'ArrowUp' ? -1 : 1) + buttons.length) % buttons.length]?.focus()
 }
 
@@ -210,19 +215,36 @@ async function openSettingsMenu(event: MouseEvent) {
   if (settingsMenuOpen.value) { closeSettingsMenu(); return }
   settingsTrigger = event.currentTarget as HTMLButtonElement
   const rect = settingsTrigger.getBoundingClientRect()
-  settingsMenuPosition.value = { left: `${Math.max(8, Math.min(rect.right - 180, window.innerWidth - 188))}px`, top: `${Math.max(8, rect.top - 92)}px` }
+  const height = applicationWorkspace.value ? (props.isAdmin ? 4 : 3) * (window.innerWidth < 768 ? 44 : 36) + 18 : 92
+  settingsMenuPosition.value = { left: `${Math.max(8, Math.min(rect.right - 180, window.innerWidth - 188))}px`, top: `${Math.max(8, rect.top - height)}px` }
   profileMenuOpen.value = false
   createMenuOpen.value = false
+  toolsMenuOpen.value = false
   settingsMenuOpen.value = true
   await nextTick()
   settingsMenu.value?.querySelector<HTMLButtonElement>('[role="menuitem"]')?.focus()
 }
-function chooseSettingsAction(action: 'settings' | 'bots') {
+function chooseSettingsAction(action: 'settings' | 'bots' | 'about') {
   settingsMenuOpen.value = false
   if (action === 'bots') { switchInterfaceMode(); return }
   settingsReturnFocus.value = settingsTrigger?.closest('.mobile-drawer')
     ? mobileNavigationTrigger.value : settingsTrigger ?? undefined
-  openSettings(applicationWorkspace.value ? 'account-security' : 'agent-identity')
+  openSettings(action === 'about' && applicationWorkspace.value ? 'bot-about' : applicationWorkspace.value ? 'account-security' : 'agent-identity')
+}
+function closeToolsMenu() { toolsMenuOpen.value = false; void nextTick(() => toolsTrigger?.isConnected && toolsTrigger.focus()) }
+async function openToolsMenu(event: MouseEvent) {
+  if (!applicationWorkspace.value) return
+  if (toolsMenuOpen.value) { closeToolsMenu(); return }
+  toolsTrigger = event.currentTarget as HTMLButtonElement
+  const rect = toolsTrigger.getBoundingClientRect()
+  toolsPosition.value = { left: `${Math.max(8, Math.min(rect.left, window.innerWidth - 208))}px`, top: `${Math.max(8, rect.top - (window.innerWidth < 768 ? 150 : 126))}px` }
+  settingsMenuOpen.value = false; createMenuOpen.value = false; toolsMenuOpen.value = true
+  await nextTick(); toolsMenu.value?.querySelector<HTMLElement>('[role="menuitem"]')?.focus()
+}
+function chooseTool(page: 'bot-plugins' | 'bot-apps' | 'bot-routines') {
+  toolsMenuOpen.value = false
+  settingsReturnFocus.value = toolsTrigger?.closest('.mobile-drawer') ? mobileNavigationTrigger.value : toolsTrigger ?? undefined
+  openSettings(page)
 }
 function switchInterfaceMode() {
   if (!props.isAdmin) return
@@ -281,6 +303,8 @@ function openSettings(page: SettingsPage = 'agent-identity', event?: MouseEvent)
       : trigger
   }
   profileMenuOpen.value = false
+  toolsMenuOpen.value = false
+  settingsMenuOpen.value = false
   mobileDrawerOpen.value = false
   settingsPage.value = page
   settingsOpen.value = true
@@ -347,7 +371,7 @@ function handleSidebarFocusout(event: FocusEvent) {
 
 function handleSidebarSearchClosed() { sidebarSearchOpen.value = false }
 
-watch(() => route.fullPath, () => { mobileDrawerOpen.value = false; createMenuOpen.value = false; settingsMenuOpen.value = false })
+watch(() => route.fullPath, () => { mobileDrawerOpen.value = false; createMenuOpen.value = false; settingsMenuOpen.value = false; toolsMenuOpen.value = false })
 watch(() => activeNav.value.key, () => {
   sidebarSearchOpen.value = false
   profileMenuOpen.value = false
@@ -468,8 +492,9 @@ defineExpose({openLocalVm:()=>{if(props.isAdmin)openSettings('system-local-vm')}
       </section>
 
       <div class="sidebar-footer">
+        <button v-if="applicationWorkspace" class="sidebar-tools-trigger" type="button" aria-haspopup="menu" :aria-expanded="toolsMenuOpen" @click="openToolsMenu"><AppIcon name="tools" :size="18" /><span>工具</span><AppIcon name="chevron-down" :size="15" /></button>
         <div class="sidebar-account-switcher">
-          <button class="sidebar-account-switcher__main" type="button" :title="applicationWorkspace ? '当前账号' : `切换机器人:${profileTitle(activeProfile)}`" :aria-haspopup="applicationWorkspace ? undefined : 'listbox'" :aria-expanded="applicationWorkspace ? undefined : profileMenuOpen" @click="applicationWorkspace ? openSettings('account-security', $event) : toggleProfileMenu($event)">
+          <button class="sidebar-account-switcher__main" type="button" :title="applicationWorkspace ? '当前账号' : `切换机器人:${profileTitle(activeProfile)}`" :aria-haspopup="applicationWorkspace ? 'menu' : 'listbox'" :aria-expanded="applicationWorkspace ? settingsMenuOpen : profileMenuOpen" @click="applicationWorkspace ? openSettingsMenu($event) : toggleProfileMenu($event)">
             <AccountInitialAvatar v-if="applicationWorkspace" :name="userName" :image-url="userAvatar" :size="30" />
             <AgentAvatar v-else :name="profileTitle(activeProfile)" :avatar="activeProfile?.agentAvatar || ''" :size="30" />
             <span class="account-copy">
@@ -575,8 +600,9 @@ defineExpose({openLocalVm:()=>{if(props.isAdmin)openSettings('system-local-vm')}
       </section>
 
       <div class="sidebar-footer mobile-drawer__footer">
+        <button v-if="applicationWorkspace" class="sidebar-tools-trigger" type="button" aria-haspopup="menu" :aria-expanded="toolsMenuOpen" @click="openToolsMenu"><AppIcon name="tools" :size="18" /><span>工具</span><AppIcon name="chevron-down" :size="15" /></button>
         <div class="sidebar-account-switcher">
-          <button class="sidebar-account-switcher__main" type="button" :title="applicationWorkspace ? '当前账号' : `切换机器人:${profileTitle(activeProfile)}`" :aria-haspopup="applicationWorkspace ? undefined : 'listbox'" :aria-expanded="applicationWorkspace ? undefined : profileMenuOpen" @click="applicationWorkspace ? openSettings('account-security', $event) : toggleProfileMenu($event)">
+          <button class="sidebar-account-switcher__main" type="button" :title="applicationWorkspace ? '当前账号' : `切换机器人:${profileTitle(activeProfile)}`" :aria-haspopup="applicationWorkspace ? 'menu' : 'listbox'" :aria-expanded="applicationWorkspace ? settingsMenuOpen : profileMenuOpen" @click="applicationWorkspace ? openSettingsMenu($event) : toggleProfileMenu($event)">
             <AccountInitialAvatar v-if="applicationWorkspace" :name="userName" :image-url="userAvatar" :size="30" />
             <AgentAvatar v-else :name="profileTitle(activeProfile)" :avatar="activeProfile?.agentAvatar || ''" :size="30" />
             <span class="account-copy">
@@ -652,11 +678,14 @@ defineExpose({openLocalVm:()=>{if(props.isAdmin)openSettings('system-local-vm')}
     />
     <Teleport to="body">
       <div v-if="settingsMenuOpen" class="workspace-create-dismiss" @pointerdown.self="closeSettingsMenu" @keydown.esc.prevent.stop="closeSettingsMenu">
-        <div ref="settingsMenu" class="workspace-create-menu workspace-settings-menu" :style="settingsMenuPosition" role="menu" aria-label="设置与模式" @keydown="actionMenuKeydown">
-          <button type="button" role="menuitem" @click="chooseSettingsAction('settings')"><AppIcon name="settings" :size="17" />进入设置</button>
+        <div ref="settingsMenu" class="workspace-create-menu workspace-settings-menu" :class="{ 'workspace-settings-menu--bot': applicationWorkspace }" :style="settingsMenuPosition" role="menu" :aria-label="applicationWorkspace ? '账号菜单' : '设置与模式'" @keydown="actionMenuKeydown">
+          <button type="button" role="menuitem" @click="chooseSettingsAction('settings')"><AppIcon name="settings" :size="17" />{{ applicationWorkspace ? '设置' : '进入设置' }}</button>
+          <button v-if="applicationWorkspace" type="button" role="menuitem" @click="chooseSettingsAction('about')"><AppIcon name="info" :size="17" />关于</button>
+          <a v-if="applicationWorkspace" role="menuitem" href="https://yaoyao.samien.cn" target="_blank" rel="noopener noreferrer" @click="closeSettingsMenu"><AppIcon name="external" :size="17" />帮助</a>
           <button v-if="isAdmin" type="button" role="menuitem" @click="chooseSettingsAction('bots')"><AppIcon :name="applicationWorkspace ? 'chat' : 'users'" :size="17" />{{ applicationWorkspace ? '进入聊天模式' : '进入 Bot 模式' }}</button>
         </div>
       </div>
+      <div v-if="toolsMenuOpen && applicationWorkspace" class="workspace-create-dismiss" @pointerdown.self="closeToolsMenu" @keydown.esc.prevent.stop="closeToolsMenu"><div ref="toolsMenu" class="workspace-create-menu workspace-tools-menu" :style="toolsPosition" role="menu" aria-label="工具" @keydown="actionMenuKeydown"><button type="button" role="menuitem" @click="chooseTool('bot-plugins')"><AppIcon name="tools" :size="17" />插件</button><button type="button" role="menuitem" @click="chooseTool('bot-routines')"><AppIcon name="clock" :size="17" />自动化</button><button type="button" role="menuitem" @click="chooseTool('bot-apps')"><AppIcon name="link" :size="17" />已连接应用</button></div></div>
       <div v-if="createMenuOpen" class="workspace-create-dismiss" @pointerdown.self="closeCreateMenu" @keydown.esc.prevent.stop="closeCreateMenu">
         <div ref="createMenu" class="workspace-create-menu" :style="createPosition" role="menu" aria-label="新建聊天" @keydown="actionMenuKeydown">
           <button type="button" role="menuitem" @click="chooseCreate('agent')"><AppIcon name="users" :size="17" />新建 Bot</button>
@@ -669,6 +698,7 @@ defineExpose({openLocalVm:()=>{if(props.isAdmin)openSettings('system-local-vm')}
 </template>
 
 <style scoped>
+.sidebar-tools-trigger{display:flex;width:100%;min-height:44px;align-items:center;gap:10px;padding:8px 12px;margin:0 0 8px;border:0;border-radius:9px;background:transparent;color:var(--text-primary);font:500 13px var(--font-ui);cursor:pointer}.sidebar-tools-trigger span{flex:1;text-align:left}.sidebar-tools-trigger:hover,.sidebar-tools-trigger[aria-expanded=true]{background:var(--surface-hover)}.sidebar-tools-trigger:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.workspace-settings-menu a{display:flex;align-items:center;gap:8px;min-height:36px;padding:8px 10px;box-sizing:border-box;border-radius:7px;color:var(--text-primary);text-decoration:none;font-size:13px}.workspace-settings-menu a:hover{background:var(--surface-hover)}.workspace-settings-menu a:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
 .workspace-shell {
   display: grid;
   grid-template-columns: 264px minmax(0, 1fr) auto;
@@ -895,6 +925,7 @@ defineExpose({openLocalVm:()=>{if(props.isAdmin)openSettings('system-local-vm')}
 .workspace-create-menu{position:absolute;width:180px;padding:5px;border:1px solid var(--line);border-radius:11px;background:var(--surface-raised);box-shadow:var(--shadow-float)}
 .workspace-create-menu button{display:flex;width:100%;min-height:36px;align-items:center;gap:9px;padding:7px 10px;border:0;border-radius:7px;background:transparent;color:var(--text-primary);font:13px var(--font-ui);text-align:left;cursor:pointer}
 .workspace-create-menu button:hover,.workspace-create-menu button:focus-visible{outline:0;background:var(--surface-hover)}
+@media(max-width:767px){.workspace-tools-menu button,.workspace-settings-menu--bot button,.workspace-settings-menu--bot a{min-height:44px}}
 
 .bot-list-header{display:flex;align-items:center;gap:10px;padding:20px 20px 12px;min-height:74px}
 .bot-list-header__spacer{flex:1}

+ 4 - 0
src/client/components/common/AppIcon.vue

@@ -1,5 +1,7 @@
 <script setup lang="ts">
 type IconName =
+  | 'info'
+  | 'clock'
   | 'chat'
   | 'history'
   | 'groups'
@@ -104,6 +106,8 @@ withDefaults(defineProps<{ name: IconName; size?: number }>(), { size: 18 })
     <template v-else-if="name === 'arrow-up'"><path d="M12 19V5m-7 7 7-7 7 7" stroke-width="2.2" /></template>
     <template v-else-if="name === 'stop'"><rect x="6" y="6" width="12" height="12" rx="2.5" fill="currentColor" stroke="none" /></template>
     <template v-else-if="name === 'tools'"><path d="M14.7 6.3a4 4 0 0 0-5-5L12 3.6 8.4 7.2 6.1 4.9a4 4 0 0 0 5 5l-7.4 7.4a2.1 2.1 0 0 0 3 3l7.4-7.4a4 4 0 0 0 5-5l-2.3 2.3-3.6-3.6z" /></template>
+    <template v-else-if="name === 'info'"><circle cx="12" cy="12" r="9" /><path d="M12 11v6M12 7h.01" /></template>
+    <template v-else-if="name === 'clock'"><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></template>
     <template v-else-if="name === 'users'"><circle cx="9" cy="8" r="3" /><path d="M3 21v-2a6 6 0 0 1 12 0v2M16 3a3 3 0 0 1 0 6M18 14a5 5 0 0 1 3 5v2" /></template>
     <template v-else-if="name === 'chevron-down'"><path d="m6 9 6 6 6-6" /></template>
     <template v-else-if="name === 'chevron-left'"><path d="m15 18-6-6 6-6" /></template>

+ 11 - 0
src/client/components/workspace/BotAutomationPanel.vue

@@ -0,0 +1,11 @@
+<script setup lang="ts">
+import { computed, onMounted, ref } from 'vue'
+import { apiRequest } from '@/api/client'
+import type { WorkspaceAgent } from '@shared/workspace'
+import WorkspaceRoutinesPanel from './WorkspaceRoutinesPanel.vue'
+const agents = ref<WorkspaceAgent[]>([]), selected = ref(''), error = ref(''), loading = ref(true)
+const available = computed(() => agents.value.filter(a => !a.archived && !a.remoteAgentId && !a.temporaryGoalId))
+onMounted(async () => { try { agents.value = (await apiRequest<{ agents: WorkspaceAgent[] }>('/api/app/agents')).agents; selected.value = available.value[0]?.id ?? '' } catch (e) { error.value = e instanceof Error ? e.message : '无法读取 Bot' } finally { loading.value = false } })
+</script>
+<template><section class="bot-automations" aria-label="Bot 自动化"><p>选择 Bot,管理自动执行的任务和运行记录。</p><p v-if="error" role="alert">{{ error }}</p><p v-if="loading" role="status">正在读取…</p><template v-else-if="available.length"><label>执行 Bot<select v-model="selected"><option v-for="agent in available" :key="agent.id" :value="agent.id">{{ agent.name }}</option></select></label><WorkspaceRoutinesPanel v-if="selected" :key="selected" :agent-id="selected" /></template><p v-else>还没有可设置自动化的 Bot,请先创建机器人。</p></section></template>
+<style scoped>.bot-automations{display:grid;gap:16px;color:var(--text-primary);font-size:13px}.bot-automations>p{color:var(--text-secondary);line-height:1.6;margin:0}.bot-automations>label{display:grid;gap:8px}.bot-automations select{padding:10px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:inherit;font:inherit}.bot-automations select:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.bot-automations>[role=alert]{color:var(--danger)}</style>

File diff ditekan karena terlalu besar
+ 168 - 0
src/client/components/workspace/BotPluginsPanel.vue


+ 9 - 2
src/server/app.ts

@@ -3,6 +3,7 @@ import type {GrokAuth} from './grokAuth.js'
 import {GrokCloud} from './grokCloud.js'
 import {WorkspaceInspector} from './workspaceInspector.js'
 import {WorkspaceRoutines} from './workspaceRoutines.js'
+import { WorkspacePlugins } from './botPlugins/workspacePlugins.js'
 import { nativeMessageFileText } from '../shared/messageFiles.js'
 import { hermesBotRelay } from './hermesBotRelay.js'
 import { remoteAgentExports } from './workspaceRemoteAgents.js'
@@ -69,6 +70,7 @@ export interface ApplicationOptions {
   uploads?: UploadStore
   updates?: SystemUpdateManager
   auth?: LocalAuthStore
+  pluginFetch?: typeof fetch
   upstreamSession?: UpstreamServiceSession
   accountPairings?: AccountLoginPairingStore
   profileIdentities?: UpstreamProfileIdentityService
@@ -105,6 +107,7 @@ export interface ApplicationRuntime {
   desktopEnvironments:DesktopEnvironments
   grokAuth:GrokAuth
   workspaceRoutines:WorkspaceRoutines
+  workspacePlugins:WorkspacePlugins
   localVm: LocalVmService
   close(): void
 }
@@ -239,6 +242,8 @@ export function createApplication(options: ApplicationOptions = {}): Application
   const workspaceInspector=new WorkspaceInspector(workspace,auth)
   workspaceRuntime.inspector=workspaceInspector
   const workspaceRoutines=new WorkspaceRoutines(workspace,auth,workspaceNodes,workspaceRuntime)
+  const workspacePlugins=new WorkspacePlugins(workspace,workspaceNodes,auth,workspaceRuntime,config.home,options.pluginFetch)
+  workspaceRuntime.plugins=workspacePlugins
   workspaceRuntime.retireHelper=(owner,helper)=>runners.retireHelper(owner,helper)
   workspaceRuntime.onTeamCreated = (owner, team) => {
     try { push.setGroupSubscription(owner, team.id, true, team.lastSeq) } catch { /* Optional notifications do not undo a team. */ }
@@ -347,7 +352,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
       const path = ctx.path
       const publicPath = ['/api/status', '/api/auth/providers', '/api/auth/me', '/api/profiles', '/api/app/bootstrap', '/api/app/login', '/api/app/logout'].includes(path)
       const accountPath = ['/api/account/credentials', '/api/app/account/credentials', '/api/app/account/avatar'].includes(path)
-      const workspacePath = /^\/api\/app\/(?:capabilities|agents|conversations|runs|interactions|events|uploads|files|message-files|computers)(?:\/|$)/.test(path)
+      const workspacePath = /^\/api\/app\/(?:capabilities|agents|conversations|runs|interactions|events|uploads|files|message-files|computers|bot-tools)(?:\/|$)/.test(path)
         && path !== '/api/app/agents/remote'
       const pushPath = /^\/api\/(?:app\/)?push\/v1\//.test(path)
       const identityRead = path === '/api/app/server-identity' && ['GET', 'HEAD'].includes(ctx.method)
@@ -415,7 +420,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     } else await next()
   })
 
-  for(const router of [desktopEnvironments.router(),grokCloud.authorization.router(),grokCloud.router(),workspaceInspector.router(),workspaceRoutines.router()]){app.use(router.routes());app.use(router.allowedMethods())}
+  for(const router of [desktopEnvironments.router(),grokCloud.authorization.router(),grokCloud.router(),workspaceInspector.router(),workspaceRoutines.router(),workspacePlugins.router()]){app.use(router.routes());app.use(router.allowedMethods())}
   const sharedComputerRouter=sharedComputers.router();app.use(sharedComputerRouter.routes());app.use(sharedComputerRouter.allowedMethods())
   const localVmRouter=localVm.router();app.use(localVmRouter.routes());app.use(localVmRouter.allowedMethods())
   const computerRouter=computerControls.router();app.use(computerRouter.routes());app.use(computerRouter.allowedMethods())
@@ -483,6 +488,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     workspace,
     workspaceRuntime,
     workspaceRoutines,
+    workspacePlugins,
     desktopEnvironments,
     grokAuth:grokCloud.authorization,
     realtime,
@@ -510,6 +516,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
       desktopEnvironments.close()
       grokCloud.authorization.close()
       workspaceRoutines.close()
+      workspacePlugins.close()
       workspaceRuntime.close()
       workspaceNodes.close()
       upstream.close()

+ 151 - 0
src/server/botPlugins/connectedApps.ts

@@ -0,0 +1,151 @@
+import { randomUUID, createHash } from 'node:crypto'
+import { z } from 'zod'
+import type { WorkspaceStore } from '../workspaceStore.js'
+import type { WorkspaceNodes } from '../workspaceGateway.js'
+import { HttpError } from '../errors.js'
+import type { BotAppCard, BotAppConnection, BotPluginSettings } from '../../shared/workspacePlugins.js'
+
+const API = 'https://backend.composio.dev/api/v3.1'
+const CATALOG_API = 'https://backend.composio.dev/api/v3'
+export const appSlug = z.string().regex(/^[a-z][a-z0-9_-]{0,80}$/)
+const sessionSchema = z.object({ session_id: z.string().min(1), mcp: z.object({ url: z.string().url(), headers: z.record(z.string(), z.string()).optional() }), config: z.object({ user_id: z.string().optional() }).passthrough().optional() }).passthrough()
+type Session = z.infer<typeof sessionSchema>
+interface Configuration { owner: string; key: string; userId: string; revision: number; session?: Session; authConfigs: Record<string, string> }
+export const CURATED_APPS: BotAppCard[] = [
+  ['gmail', 'Gmail', '邮件与草稿'], ['googledrive', 'Google Drive', '云端文件'], ['googlecalendar', 'Google Calendar', '日历与日程'],
+  ['github', 'GitHub', '代码仓库与议题'], ['notion', 'Notion', '文档与知识库'], ['slack', 'Slack', '团队沟通'],
+  ['linear', 'Linear', '项目与任务'], ['discord', 'Discord', '社区消息'], ['outlook', 'Outlook', '邮件与日历'],
+  ['trello', 'Trello', '看板与任务'], ['airtable', 'Airtable', '表格与业务数据'], ['twitter', 'X', '社交内容'],
+].map(([slug, name, description]) => ({ slug: slug!, name: name!, description: description! }))
+
+function trustedUrl(value: string): string {
+  let url: URL
+  try { url = new URL(value) } catch { throw new HttpError(502, '应用连接服务返回的地址无效', 'plugin_untrusted_url') }
+  if (url.protocol !== 'https:' || url.username || url.password || !(url.hostname === 'composio.dev' || url.hostname.endsWith('.composio.dev'))) throw new HttpError(502, '应用连接服务返回的地址无效', 'plugin_untrusted_url')
+  return url.href
+}
+
+/** Account-owned Composio sessions. Keys, session URLs and remote credentials
+ * remain on the Web server, separate from ordinary Hermes configuration. */
+export class ConnectedApps {
+  private catalogCache = new Map<string, { at: number; cards: BotAppCard[] }>()
+  private sessions = new Map<string, Promise<Session>>()
+  constructor(readonly store: WorkspaceStore, readonly nodes: WorkspaceNodes, readonly fetchImpl: typeof fetch, readonly assertOwner: (owner: string) => void, readonly invalidate: (owner: string) => void) {}
+  read(owner: string): Configuration | undefined {
+    const record = this.store.get<{ sealed: string }>(owner, 'bot-plugin-settings', 'apps')
+    if (!record) return undefined
+    const value = this.nodes.open<Configuration>(record.sealed)
+    if (value.owner !== owner) throw new HttpError(403, '应用配置不属于当前账号', 'plugin_forbidden')
+    return value
+  }
+  status(owner: string): BotPluginSettings { const value = this.read(owner); return { configured: !!value?.key, revision: value?.revision ?? 0 } }
+  private current(owner: string, revision: number) {
+    this.assertOwner(owner)
+    if (this.status(owner).revision !== revision) throw new HttpError(409, '应用配置已变化,请刷新后重试', 'plugin_configuration_changed')
+  }
+  private async request(key: string, path: string, body?: unknown, method = body === undefined ? 'GET' : 'POST', signal?: AbortSignal, base = API): Promise<any> {
+    const response = await this.fetchImpl(base + path, { method, redirect: 'error', headers: { 'x-api-key': key, Accept: 'application/json', ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.any([AbortSignal.timeout(20_000), ...(signal ? [signal] : [])]) })
+    if (!response.ok) { await response.body?.cancel(); throw new HttpError(502, `应用连接服务请求失败(HTTP ${response.status}),请检查 API Key 和项目权限`, 'connected_apps_unavailable') }
+    if (response.status === 204) return {}
+    const reader = response.body?.getReader(); if (!reader) return {}
+    const chunks: Uint8Array[] = []; let size = 0
+    try { for (;;) { const next = await reader.read(); if (next.done) break; size += next.value.byteLength; if (size > 8 * 1024 * 1024) throw new HttpError(502, '应用连接服务响应过大', 'plugin_response_limit'); chunks.push(next.value) } }
+    finally { await reader.cancel().catch(() => {}) }
+    try { return JSON.parse(Buffer.concat(chunks).toString() || '{}') } catch { throw new HttpError(502, '应用连接服务响应格式无效', 'plugin_invalid_response') }
+  }
+  private async createSession(config: Configuration, slugs?: string[], signal?: AbortSignal): Promise<Session> {
+    const raw = await this.request(config.key, '/tool_router/session', {
+      user_id: config.userId, ...(slugs ? { toolkits: { enable: slugs } } : {}), auth_configs: config.authConfigs,
+      manage_connections: { enable: !slugs, enable_wait_for_connections: false, enable_connection_removal: false },
+      multi_account: { enable: true, max_accounts_per_toolkit: 5, require_explicit_selection: true },
+    }, 'POST', signal)
+    const session = sessionSchema.safeParse(raw)
+    if (!session.success || (session.data.config?.user_id && session.data.config.user_id !== config.userId)) throw new HttpError(502, '应用连接会话身份无效', 'plugin_invalid_response')
+    session.data.mcp.url = trustedUrl(session.data.mcp.url)
+    return session.data
+  }
+  async configure(owner: string, key: string, revision: number, authConfigs: Record<string, string>) {
+    this.current(owner, revision)
+    const old = this.read(owner)
+    const value: Configuration = { owner, key, revision: revision + 1, userId: old?.userId ?? `yaoyao_${randomUUID()}`, authConfigs }
+    if (key) value.session = await this.createSession(value)
+    this.current(owner, revision)
+    this.store.put(owner, 'bot-plugin-settings', 'apps', { sealed: this.nodes.seal(value) })
+    this.catalogCache.delete(owner); this.invalidate(owner)
+    return this.status(owner)
+  }
+  private require(owner: string) { const value = this.read(owner); if (!value?.key) throw new HttpError(409, '请先在插件设置中配置 Composio API Key', 'connected_apps_unconfigured'); return value }
+  async catalog(owner: string): Promise<{ cards: BotAppCard[]; source: 'api' | 'curated' }> {
+    const value = this.read(owner), cache = this.catalogCache.get(owner)
+    if (cache && Date.now() - cache.at < 600_000) return { cards: cache.cards, source: 'api' }
+    if (value?.key) try {
+      const items = await this.pages(value.key, '/toolkits', { limit: '500', sort_by: 'usage' }, undefined, CATALOG_API)
+      this.current(owner, value.revision)
+      const cards = items.filter(item => appSlug.safeParse(item.slug).success).map(item => ({ slug: item.slug, name: String(item.name ?? item.slug).slice(0, 100), description: String(item.meta?.description ?? item.description ?? '').slice(0, 180) }))
+      if (cards.length) { this.catalogCache.set(owner, { at: Date.now(), cards }); return { cards, source: 'api' } }
+    } catch { /* Catalog fallback never claims that account inventory is empty. */ }
+    return { cards: CURATED_APPS, source: 'curated' }
+  }
+  private async pages(key: string, path: string, params: Record<string, string>, signal?: AbortSignal, base = API): Promise<any[]> {
+    const items: any[] = [], cursors = new Set<string>(); let cursor = ''
+    for (let page = 0; page < 20; page++) {
+      const query = new URLSearchParams({ ...params, ...(cursor ? { cursor } : {}) })
+      const result = await this.request(key, path + '?' + query, undefined, 'GET', signal, base)
+      if (!Array.isArray(result.items)) throw new HttpError(502, '应用列表响应格式无效', 'plugin_invalid_response')
+      items.push(...result.items)
+      if (!result.next_cursor) return items
+      if (typeof result.next_cursor !== 'string' || cursors.has(result.next_cursor)) break
+      cursor = result.next_cursor; cursors.add(cursor)
+    }
+    throw new HttpError(502, '应用列表尚未完整读取,请重试', 'plugin_inventory_incomplete')
+  }
+  async inventory(owner: string): Promise<BotAppConnection[]> {
+    const config = this.require(owner)
+    const accounts = await this.pages(config.key, '/connected_accounts', { user_ids: config.userId, limit: '100' })
+    this.current(owner, config.revision)
+    const groups = new Map<string, BotAppConnection>()
+    for (const account of accounts) {
+      const slug = String(account.toolkit?.slug ?? '').toLowerCase()
+      if (!appSlug.safeParse(slug).success || !/^[A-Za-z0-9_-]{1,128}$/.test(account.id) || (account.user_id && account.user_id !== config.userId)) continue
+      const grant = this.grant(owner, slug)
+      const group = groups.get(slug) ?? { slug, accounts: [], ...grant }
+      group.accounts.push({ id: account.id, alias: typeof account.alias === 'string' ? account.alias.slice(0, 64) : undefined, status: String(account.status ?? 'UNKNOWN').slice(0, 40) })
+      groups.set(slug, group)
+    }
+    return [...groups.values()]
+  }
+  grant(owner: string, slug: string): { agentIds: string[]; revision: number } { return this.store.get(owner, 'bot-app-grant', slug) ?? { agentIds: [], revision: 0 } }
+  async authorize(owner: string, slug: string, alias?: string) {
+    const config = this.require(owner), inventory = await this.inventory(owner)
+    const existing = inventory.find(item => item.slug === slug)?.accounts ?? []
+    if (existing.some(a => /^(ACTIVE|INITIATED|PENDING|INITIALIZING)$/i.test(a.status)) && !alias) throw new HttpError(409, '添加另一个账号时请填写账号别名', 'plugin_alias_required')
+    if (alias && existing.some(a => a.alias?.toLowerCase() === alias.toLowerCase())) throw new HttpError(409, '该账号别名已经存在', 'plugin_alias_conflict')
+    if (!config.session) throw new HttpError(409, '请重新保存应用连接服务配置', 'plugin_session_missing')
+    const result = await this.request(config.key, `/tool_router/session/${encodeURIComponent(config.session.session_id)}/link`, { toolkit: slug, ...(alias ? { alias } : {}) })
+    this.current(owner, config.revision)
+    return { url: trustedUrl(String(result.redirect_url ?? '')) }
+  }
+  async disconnect(owner: string, slug: string, id: string) {
+    const config = this.require(owner)
+    if (!(await this.inventory(owner)).some(item => item.slug === slug && item.accounts.some(a => a.id === id))) throw new HttpError(404, '应用账号不存在', 'not_found')
+    this.current(owner, config.revision)
+    await this.request(config.key, `/connected_accounts/${encodeURIComponent(id)}?revoke_on_delete=true`, undefined, 'DELETE')
+    this.invalidate(owner)
+  }
+  async sessionFor(owner: string, agentId: string, slugs: string[], signal: AbortSignal): Promise<{ url: string; headers: Record<string, string>; revision: number }> {
+    const config = this.require(owner)
+    const id = createHash('sha256').update(JSON.stringify([agentId, config.revision, [...slugs].sort()])).digest('hex')
+    const saved = this.store.get<{ sealed: string }>(owner, 'bot-app-session', id)
+    const cacheKey = owner + ':' + id
+    let session = saved ? this.nodes.open<{ owner: string; session: Session }>(saved.sealed) : undefined
+    if (session && session.owner !== owner) throw new HttpError(403, '应用会话身份无效', 'plugin_forbidden')
+    if (!session) {
+      let pending = this.sessions.get(cacheKey)
+      if (!pending) { pending = this.createSession(config, slugs, signal); this.sessions.set(cacheKey, pending); void pending.finally(() => this.sessions.delete(cacheKey)).catch(() => {}) }
+      session = { owner, session: await pending }; this.current(owner, config.revision)
+      this.store.put(owner, 'bot-app-session', id, { sealed: this.nodes.seal(session) })
+    }
+    this.current(owner, config.revision)
+    return { url: trustedUrl(session.session.mcp.url), headers: { ...session.session.mcp.headers, 'x-api-key': config.key }, revision: config.revision }
+  }
+}

+ 91 - 0
src/server/botPlugins/httpMcp.ts

@@ -0,0 +1,91 @@
+import { randomUUID } from 'node:crypto'
+import type { McpTool, McpToolResult } from './stdioMcp.js'
+
+export interface PluginClient {
+  init(signal?: AbortSignal): Promise<void>
+  listTools(signal?: AbortSignal): Promise<McpTool[]>
+  callTool(name: string, args: unknown, signal?: AbortSignal): Promise<McpToolResult>
+  dispose(): void
+  waitClosed(): Promise<void>
+}
+
+/** Streamable HTTP MCP, including JSON and SSE responses. Never follows a
+ * redirect with the user's headers, and never advertises client-side execution. */
+export class HttpMcp implements PluginClient {
+  private controller = new AbortController()
+  private session = ''
+  private version = '2025-06-18'
+  constructor(readonly url: string, readonly headers: Record<string, string> = {}, readonly fetchImpl: typeof fetch = fetch) {}
+  async rpc(method: string, params: unknown, signal?: AbortSignal, notification = false): Promise<any> {
+    const id = randomUUID()
+    const response = await this.fetchImpl(this.url, {
+      method: 'POST', redirect: 'error',
+      headers: { ...this.headers, 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', 'MCP-Protocol-Version': this.version, ...(this.session ? { 'Mcp-Session-Id': this.session } : {}) },
+      body: JSON.stringify({ jsonrpc: '2.0', ...(notification ? {} : { id }), method, params }),
+      signal: AbortSignal.any([this.controller.signal, AbortSignal.timeout(method === 'tools/call' ? 120_000 : 15_000), ...(signal ? [signal] : [])]),
+    })
+    if (!response.ok) { await response.body?.cancel(); throw new Error(`MCP HTTP ${response.status}`) }
+    const session = response.headers.get('mcp-session-id')
+    if (session) this.session = session
+    if (notification || response.status === 204) { await response.body?.cancel(); return {} }
+    const reader = response.body?.getReader()
+    if (!reader) throw new Error('MCP 响应为空')
+    const decoder = new TextDecoder(); let body = '', bytes = 0
+    const sse = response.headers.get('content-type')?.includes('text/event-stream')
+    const unpack = (frame: any) => {
+      if (frame?.id !== id) return undefined
+      if (frame.error) throw new Error('MCP 服务拒绝了请求')
+      if (!frame.result || typeof frame.result !== 'object') throw new Error('MCP 响应格式无效')
+      return frame.result
+    }
+    try {
+      for (;;) {
+        const part = await reader.read()
+        if (part.done) break
+        bytes += part.value.byteLength
+        if (bytes > 8 * 1024 * 1024) throw new Error('MCP 响应超过 8 MiB')
+        body += decoder.decode(part.value, { stream: true })
+        if (sse) {
+          body = body.replace(/\r\n/g, '\n')
+          let end: number
+          while ((end = body.indexOf('\n\n')) >= 0) {
+            const event = body.slice(0, end); body = body.slice(end + 2)
+            const data = event.split('\n').filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n')
+            if (!data) continue
+            const result = unpack(JSON.parse(data))
+            if (result !== undefined) return result
+          }
+        }
+      }
+      const result = !sse ? unpack(JSON.parse(body + decoder.decode())) : undefined
+      if (result === undefined) throw new Error('MCP 未返回对应请求的结果')
+      return result
+    } finally { await reader.cancel().catch(() => {}) }
+  }
+  async init(signal?: AbortSignal) {
+    const result = await this.rpc('initialize', { protocolVersion: this.version, capabilities: {}, clientInfo: { name: 'yaoyao-bot-plugins', version: '1' } }, signal)
+    if (!['2024-11-05', '2025-03-26', '2025-06-18', '2025-11-25'].includes(result.protocolVersion)) throw new Error('MCP 协议版本不支持')
+    this.version = result.protocolVersion
+    await this.rpc('notifications/initialized', {}, signal, true)
+  }
+  async listTools(signal?: AbortSignal): Promise<McpTool[]> {
+    const tools: McpTool[] = [], cursors = new Set<string>(); let cursor: string | undefined
+    for (let page = 0; page < 20; page++) {
+      const result = await this.rpc('tools/list', cursor ? { cursor } : {}, signal)
+      if (!Array.isArray(result.tools)) throw new Error('MCP 工具目录无效')
+      tools.push(...result.tools)
+      if (tools.length > 500) throw new Error('MCP 工具数量超过 500')
+      if (!result.nextCursor) return tools
+      if (typeof result.nextCursor !== 'string' || cursors.has(result.nextCursor)) throw new Error('MCP 工具分页无效')
+      cursor = result.nextCursor; cursors.add(cursor!)
+    }
+    throw new Error('MCP 工具分页超出限制')
+  }
+  async callTool(name: string, args: unknown, signal?: AbortSignal): Promise<McpToolResult> {
+    const value = await this.rpc('tools/call', { name, arguments: args }, signal)
+    if (!Array.isArray(value.content)) throw new Error('MCP 工具返回格式无效')
+    return value
+  }
+  dispose() { this.controller.abort() }
+  async waitClosed() {}
+}

+ 261 - 0
src/server/botPlugins/stdioMcp.ts

@@ -0,0 +1,261 @@
+// Adapted from OpenMausBot server/stdio-mcp.ts (Apache-2.0).
+// Shared raw MCP transport. Keep this module Node-only: the Pi extension
+// imports a shipped source copy from an external agent process.
+import { spawn, execFile, type ChildProcessWithoutNullStreams } from "node:child_process";
+
+export interface McpServerDef {
+  command: string;
+  args?: string[];
+  env?: Record<string, string>;
+  scope?: string;
+}
+
+export interface McpTool {
+  name: string;
+  description?: string;
+  inputSchema?: unknown;
+}
+
+/** Content remains opaque here so images, audio, resource links, embedded
+ * files, annotations and structured output survive the engine boundary. */
+export interface McpToolResult {
+  content: unknown[];
+  structuredContent?: unknown;
+  isError?: boolean;
+  [key: string]: unknown;
+}
+
+/** A valid JSON-RPC refusal is not a broken subprocess/transport. */
+export class McpRpcError extends Error {
+  readonly code: number;
+  constructor(message: string, code: number) { super(message); this.code = code; }
+}
+
+export interface StdioMcpOptions {
+  onRequest?: (method: string, params: unknown, signal: AbortSignal) => Promise<unknown>;
+  onNotification?: (method: string, params: unknown) => void;
+  /** A complete inherited environment, before the explicitly granted env. */
+  env?: NodeJS.ProcessEnv;
+  clientName?: string;
+  /** Harness leases own and reap a dedicated group. Pi leaves its children
+   * in Pi's existing group so killing Pi still kills all of its tools. */
+  ownProcessGroup?: boolean;
+}
+
+const MCP_STARTUP_TIMEOUT_MS = 8_000;
+const MCP_TOOL_TIMEOUT_MS = 10 * 60_000;
+const MCP_MAX_LIST_PAGES = 100;
+const MCP_MAX_FRAME_BYTES = 32 * 1024 * 1024;
+
+/** Newline-delimited JSON-RPC 2.0, the transport used by the harness proxies. */
+export class StdioMcp {
+  private child: ChildProcessWithoutNullStreams;
+  private buf = "";
+  private nextId = 1;
+  private disposed = false;
+  private closed: Promise<void>;
+  private shutdownTimer?: ReturnType<typeof setTimeout>;
+  private clientName: string;
+  private ownsProcessGroup: boolean;
+  private options: StdioMcpOptions;
+  private inbound = new Map<string | number, AbortController>();
+  capabilities: Record<string, unknown> = {};
+  private pending = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void }>();
+
+  constructor(def: McpServerDef, options: StdioMcpOptions = {}) {
+    this.options = options;
+    this.clientName = options.clientName ?? "yaoyao-bot-plugins";
+    this.ownsProcessGroup = options.ownProcessGroup === true;
+    this.child = spawn(def.command, def.args ?? [], {
+      stdio: ["pipe", "pipe", "pipe"],
+      env: { ...(options.env ?? process.env), ...def.env },
+      ...(process.platform === "win32" ? { windowsHide: true } : this.ownsProcessGroup ? { detached: true } : {}),
+    });
+    this.closed = new Promise((resolve) => {
+      this.child.once("close", () => {
+        // A proxy can exit while one of its children is still running. Kill
+        // its dedicated group before relinquishing the lease.
+        this.killTree("SIGKILL");
+        if (this.shutdownTimer) clearTimeout(this.shutdownTimer);
+        resolve();
+      });
+    });
+    this.child.stderr.on("data", () => { /* drain without exposing secrets */ });
+    this.child.stdout.setEncoding("utf8");
+    this.child.stdout.on("data", (chunk: string) => this.onData(chunk));
+    this.child.on("error", (error) => this.closeWithError(error));
+    this.child.on("exit", (code) => this.closeWithError(new Error(`MCP server exited (code ${code ?? "?"})`)));
+    this.child.stdin.on("error", () => this.closeWithError(new Error("MCP server stdin closed")));
+  }
+
+  private killTree(signal: NodeJS.Signals): void {
+    const pid = this.child.pid;
+    if (!pid) return;
+    if (process.platform === "win32") {
+      if (this.child.exitCode !== null || this.child.signalCode !== null) return;
+      execFile("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true }, (error) => {
+        if (error) { try { this.child.kill(signal); } catch { /* already gone */ } }
+      });
+      return;
+    }
+    try {
+      if (this.ownsProcessGroup) process.kill(-pid, signal);
+      else this.child.kill(signal);
+    } catch { /* process group already gone */ }
+  }
+
+  private closeWithError(error: Error): void {
+    if (this.disposed) return;
+    this.disposed = true;
+    for (const entry of this.pending.values()) entry.reject(error);
+    this.pending.clear();
+    for (const controller of this.inbound.values()) controller.abort();
+    this.inbound.clear();
+    this.killTree("SIGTERM");
+    this.shutdownTimer = setTimeout(() => this.killTree("SIGKILL"), 500);
+    this.shutdownTimer.unref();
+  }
+
+  private onData(chunk: string): void {
+    if (this.disposed) return;
+    this.buf += chunk;
+    let newline: number;
+    while ((newline = this.buf.indexOf("\n")) !== -1) {
+      const line = this.buf.slice(0, newline).trim();
+      this.buf = this.buf.slice(newline + 1);
+      if (!line) continue;
+      if (Buffer.byteLength(line, "utf8") > MCP_MAX_FRAME_BYTES) {
+        this.closeWithError(new Error("MCP frame exceeded the size limit"));
+        return;
+      }
+      let message: { id?: unknown; method?: unknown; params?: unknown; result?: unknown; error?: { message?: string; code?: number } };
+      try { message = JSON.parse(line); } catch { continue; }
+      if (!message || typeof message !== "object") continue;
+      if (!message.method && typeof message.id === "number" && this.pending.has(message.id)) {
+        const entry = this.pending.get(message.id)!;
+        this.pending.delete(message.id);
+        if (message.error) entry.reject(new McpRpcError(message.error.message ?? "MCP error", message.error.code ?? -32603));
+        else entry.resolve(message.result);
+      } else if ((typeof message.id === "string" || typeof message.id === "number") && typeof message.method === "string") {
+        const { id, method, params } = message;
+        const controller = new AbortController();
+        if (this.inbound.has(id)) continue;
+        this.inbound.set(id, controller);
+        const timer = setTimeout(() => controller.abort(), MCP_TOOL_TIMEOUT_MS);
+        timer.unref();
+        void Promise.resolve().then(() => {
+          if (!this.options.onRequest) throw new Error("Client method not supported");
+          const aborted = new Promise<never>((_, reject) => {
+            if (controller.signal.aborted) reject(new Error("MCP request cancelled"));
+            else controller.signal.addEventListener("abort", () => reject(new Error("MCP request cancelled")), { once: true });
+          });
+          return Promise.race([this.options.onRequest(method, params, controller.signal), aborted]);
+        }).then(result => {
+          if (!this.disposed && !controller.signal.aborted) this.write({ jsonrpc: "2.0", id, result });
+        }, () => {
+          if (!this.disposed) this.write({ jsonrpc: "2.0", id, error: { code: -32601, message: "客户端请求被拒绝或不可用" } });
+        }).catch(() => {}).finally(() => { clearTimeout(timer); this.inbound.delete(id); });
+      } else if (typeof message.method === "string") {
+        if (message.method === "notifications/cancelled") {
+          const id = (message.params as { requestId?: string | number } | undefined)?.requestId;
+          if (id !== undefined) this.inbound.get(id)?.abort();
+        }
+        try { this.options.onNotification?.(message.method, message.params); } catch { /* observers cannot break transport */ }
+      }
+    }
+    if (Buffer.byteLength(this.buf, "utf8") > MCP_MAX_FRAME_BYTES) {
+      this.closeWithError(new Error("MCP frame exceeded the size limit without a newline"));
+    }
+  }
+
+  private write(frame: unknown): void {
+    if (this.disposed || this.child.stdin.destroyed || !this.child.stdin.writable) {
+      throw new Error("MCP server stdin is closed");
+    }
+    this.child.stdin.write(JSON.stringify(frame) + "\n");
+  }
+
+  private call(method: string, params: unknown, timeoutMs: number, signal?: AbortSignal): Promise<unknown> {
+    if (this.disposed) return Promise.reject(new Error("MCP client is closed"));
+    if (signal?.aborted) return Promise.reject(new Error(`MCP ${method} aborted`));
+    const id = this.nextId++;
+    return new Promise((resolve, reject) => {
+      let settled = false;
+      const settle = (complete: () => void) => {
+        if (settled) return;
+        settled = true;
+        clearTimeout(timer);
+        signal?.removeEventListener("abort", onAbort);
+        complete();
+      };
+      const cancel = (reason: string) => {
+        this.pending.delete(id);
+        try { this.write({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: id, reason } }); } catch { /* closed */ }
+        settle(() => reject(new Error(reason)));
+      };
+      const onAbort = () => cancel(`MCP ${method} aborted`);
+      const timer = setTimeout(() => cancel(`MCP ${method} timed out after ${timeoutMs}ms`), timeoutMs);
+      timer.unref();
+      signal?.addEventListener("abort", onAbort, { once: true });
+      this.pending.set(id, {
+        resolve: (value) => settle(() => resolve(value)),
+        reject: (error) => settle(() => reject(error)),
+      });
+      try { this.write({ jsonrpc: "2.0", id, method, params }); } catch (error) {
+        this.pending.delete(id);
+        settle(() => reject(error));
+      }
+    });
+  }
+
+  async init(signal?: AbortSignal): Promise<void> {
+    const result = await this.call("initialize", {
+      protocolVersion: "2025-06-18", capabilities: this.options.onRequest ? { sampling: {}, elicitation: { form: {} } } : {}, clientInfo: { name: this.clientName, version: "1" },
+    }, MCP_STARTUP_TIMEOUT_MS, signal) as { capabilities?: Record<string, unknown> };
+    this.capabilities = result?.capabilities ?? {};
+    this.write({ jsonrpc: "2.0", method: "notifications/initialized" });
+  }
+
+  async listTools(signal?: AbortSignal): Promise<McpTool[]> {
+    if (!this.capabilities.tools && (this.capabilities.resources || this.capabilities.prompts)) return [];
+    const tools: McpTool[] = [];
+    const seenCursors = new Set<string>();
+    const deadline = Date.now() + MCP_STARTUP_TIMEOUT_MS;
+    let cursor: string | undefined;
+    for (let page = 0; page < MCP_MAX_LIST_PAGES; page++) {
+      const result = await this.call("tools/list", cursor ? { cursor } : {}, Math.max(1, deadline - Date.now()), signal) as
+        { tools?: unknown; nextCursor?: unknown } | undefined;
+      if (!Array.isArray(result?.tools)) throw new Error("MCP tools/list returned no tools array");
+      for (const tool of result.tools) {
+        if (!tool || typeof tool !== "object" || typeof tool.name !== "string" || !tool.name) {
+          throw new Error("MCP tools/list returned an invalid tool");
+        }
+        tools.push(tool as McpTool);
+      }
+      if (typeof result.nextCursor !== "string" || !result.nextCursor) return tools;
+      if (seenCursors.has(result.nextCursor)) throw new Error("MCP tools/list repeated a pagination cursor");
+      seenCursors.add(result.nextCursor);
+      cursor = result.nextCursor;
+    }
+    throw new Error(`MCP tools/list exceeded ${MCP_MAX_LIST_PAGES} pages`);
+  }
+
+  async callTool(name: string, args: unknown, signal?: AbortSignal): Promise<McpToolResult> {
+    const result = await this.call("tools/call", { name, arguments: args ?? {} }, MCP_TOOL_TIMEOUT_MS, signal);
+    if (!result || typeof result !== "object" || !Array.isArray((result as McpToolResult).content)) {
+      throw new Error("MCP tools/call returned an invalid result");
+    }
+    return result as McpToolResult;
+  }
+
+  /** Capability-specific resources and prompts use the same owned transport. */
+  async request(method: string, params: unknown, signal?: AbortSignal): Promise<Record<string, unknown>> {
+    const value = await this.call(method, params, MCP_TOOL_TIMEOUT_MS, signal);
+    if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("MCP returned an invalid result");
+    return value as Record<string, unknown>;
+  }
+
+  /** Synchronous revocation; waitClosed also waits for the process tree. */
+  dispose(): void { this.closeWithError(new Error("MCP client disposed")); }
+  async waitClosed(): Promise<void> { await this.closed; }
+}

+ 211 - 0
src/server/botPlugins/workspacePlugins.ts

@@ -0,0 +1,211 @@
+import Router from '@koa/router'
+import { createHash, randomUUID } from 'node:crypto'
+import { mkdirSync } from 'node:fs'
+import { join } from 'node:path'
+import { z } from 'zod'
+import { parse, type WorkspaceStore } from '../workspaceStore.js'
+import type { WorkspaceNodes, GatewayTarget } from '../workspaceGateway.js'
+import type { WorkspaceRuntime } from '../workspaceRuntime.js'
+import type { LocalAuthStore } from '../localAuth.js'
+import type { WorkspaceAgent } from '../../shared/workspace.js'
+import type { BotMcpPlugin } from '../../shared/workspacePlugins.js'
+import { HttpError } from '../errors.js'
+import { requireTeamToolBridge } from '../workspaceToolLease.js'
+import { StdioMcp, type McpTool } from './stdioMcp.js'
+import { HttpMcp, type PluginClient } from './httpMcp.js'
+import { appSlug, ConnectedApps } from './connectedApps.js'
+
+const agentIds = z.array(z.string().uuid()).max(100)
+const secretMap = z.record(z.string().min(1).max(100), z.union([z.string().max(16_000), z.literal(true)])).refine(value => Object.keys(value).length <= 64)
+const definition = z.object({
+  name: z.string().trim().min(1).max(80), transport: z.enum(['stdio', 'http']),
+  command: z.string().trim().max(1024).optional(), args: z.array(z.string().max(4096)).max(64).default([]),
+  url: z.string().max(4096).optional(), env: secretMap.default({}), headers: secretMap.default({}), agentIds: agentIds.default([]),
+}).strict()
+type Definition = Omit<z.infer<typeof definition>, 'env' | 'headers'> & { env: Record<string, string>; headers: Record<string, string> }
+interface StoredPlugin { id: string; sealed: string; enabled: boolean; agentIds: string[]; revision: number; testedAt?: number; tools: McpTool[] }
+export interface OpenBotPlugins {
+  catalog(): Array<{ id: string; name: string; description: string; inputSchema: unknown }>
+  call(id: string, args: unknown): Promise<unknown>
+  dispose(): Promise<void>
+}
+const toolSchema = z.object({ name: z.string().min(1).max(128), description: z.string().max(16000).optional(), inputSchema: z.object({ type: z.literal('object') }).passthrough().optional() }).passthrough()
+function validateTools(value: McpTool[]): McpTool[] {
+  const tools = parse(z.array(toolSchema).max(500), value)
+  if (new Set(tools.map(t => t.name)).size !== tools.length) throw new HttpError(502, 'MCP 返回了重复工具名称', 'plugin_invalid_tools')
+  return tools
+}
+
+export class WorkspacePlugins {
+  readonly apps: ConnectedApps
+  private live = new Set<{ owner: string; client: PluginClient; pluginId: string }>()
+  private probes = 0
+  constructor(readonly store: WorkspaceStore, readonly nodes: WorkspaceNodes, readonly auth: LocalAuthStore, readonly runtime: WorkspaceRuntime, readonly home: string, readonly fetchImpl: typeof fetch = fetch) {
+    this.apps = new ConnectedApps(store, nodes, fetchImpl, owner => this.assertOwner(owner), owner => this.invalidate(owner, 'connected-apps'))
+  }
+  private assertOwner(owner: string) { if (!this.auth.isUserActive(owner)) throw new HttpError(403, '当前账号授权已失效', 'plugin_forbidden') }
+  private assertAdmin(owner: string) { this.assertOwner(owner); if (!this.auth.isAdminActive(owner)) throw new HttpError(403, '自定义 MCP 服务仅允许管理员配置和使用', 'plugin_admin_required') }
+  private agents(owner: string, ids: string[]) {
+    for (const id of ids) {
+      const agent = this.store.require<WorkspaceAgent>(owner, 'agent', id)
+      if (agent.archived || agent.remoteAgentId || agent.temporaryGoalId) throw new HttpError(409, '请选择当前账号的有效 Bot', 'plugin_agent_unavailable')
+      this.nodes.requireSource(owner, agent)
+    }
+    return [...new Set(ids)]
+  }
+  private value(owner: string, stored: StoredPlugin): Definition {
+    const decoded = this.nodes.open<{ owner: string; definition: Definition }>(stored.sealed)
+    if (decoded.owner !== owner) throw new HttpError(403, '插件不属于当前账号', 'plugin_forbidden')
+    return decoded.definition
+  }
+  private summary(owner: string, stored: StoredPlugin): BotMcpPlugin {
+    const { env, headers, ...value } = this.value(owner, stored)
+    return { ...value, id: stored.id, enabled: stored.enabled, agentIds: stored.agentIds, revision: stored.revision, testedAt: stored.testedAt, toolCount: stored.tools.length, envKeys: Object.keys(env).sort(), headerKeys: Object.keys(headers).sort() }
+  }
+  list(owner: string) { return this.store.list<StoredPlugin>(owner, 'bot-mcp-plugin').map(item => this.summary(owner, item)) }
+  save(owner: string, input: unknown, id: string = randomUUID(), revision = 0) {
+    this.assertAdmin(owner)
+    const data = parse(definition, input), old = this.store.get<StoredPlugin>(owner, 'bot-mcp-plugin', id)
+    if ((old?.revision ?? 0) !== revision) throw new HttpError(409, '插件已被修改,请刷新后重试', 'plugin_revision_conflict')
+    if (!old && this.list(owner).length >= 20) throw new HttpError(409, '最多添加 20 个 MCP 服务', 'plugin_limit')
+    if (this.list(owner).some(p => p.id !== id && p.name.toLowerCase() === data.name.toLowerCase())) throw new HttpError(409, '插件名称已存在', 'plugin_name_conflict')
+    const previous = old ? this.value(owner, old) : undefined
+    const secrets = (incoming: Record<string, string | true>, saved: Record<string, string> = {}, header = false) => Object.fromEntries(Object.entries(incoming).map(([key, value]) => {
+      if (!(header ? /^[A-Za-z0-9-]+$/ : /^[A-Za-z_][A-Za-z0-9_]*$/).test(key) || (header ? /^(host|cookie|origin|content-length|mcp-session-id|connection|transfer-encoding)$/i : /^(?:YAOYAO_|HERMES_|OMB_|OGB_|CODEX_)/).test(key)) throw new HttpError(400, '凭据字段名称无效或被保留', 'plugin_secret_key_invalid')
+      const actual = value === true ? Object.hasOwn(saved, key) ? saved[key] : undefined : value
+      if (actual === undefined || (header && /[\r\n]/.test(actual))) throw new HttpError(400, '凭据值无效;新增字段不能使用保留标记', 'plugin_secret_invalid')
+      return [key, actual]
+    }))
+    const value: Definition = { ...data, env: secrets(data.env, previous?.env), headers: secrets(data.headers, previous?.headers, true), agentIds: this.agents(owner, data.agentIds) }
+    if (value.transport === 'stdio') {
+      if (!value.command || value.command.includes('\0') || value.args.some(a => a.includes('\0'))) throw new HttpError(400, '请填写有效的程序和参数', 'plugin_command_invalid')
+      value.url = undefined; value.headers = {}
+    } else {
+      let url: URL
+      try { url = new URL(value.url ?? '') } catch { throw new HttpError(400, '请填写完整的 MCP 地址', 'plugin_url_invalid') }
+      if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) throw new HttpError(400, 'MCP 地址必须使用 HTTP 或 HTTPS,凭据请填入请求头', 'plugin_url_invalid')
+      value.url = url.href; value.command = undefined; value.args = []; value.env = {}
+    }
+    const entry: StoredPlugin = { id, sealed: this.nodes.seal({ owner, definition: value }), enabled: false, agentIds: value.agentIds, revision: revision + 1, tools: [] }
+    this.store.put(owner, 'bot-mcp-plugin', id, entry); this.invalidate(owner, id)
+    return this.summary(owner, entry)
+  }
+  private client(owner: string, value: Definition): PluginClient {
+    if (value.transport === 'http') return new HttpMcp(value.url!, value.headers, this.fetchImpl)
+    const directory = join(this.home, 'bot-plugins', createHash('sha256').update(owner).digest('hex'))
+    mkdirSync(directory, { recursive: true, mode: 0o700 })
+    return new StdioMcp({ command: value.command!, args: value.args, env: value.env }, { env: { PATH: process.env.PATH, HOME: directory, TMPDIR: process.env.TMPDIR, LANG: process.env.LANG ?? 'en_US.UTF-8' }, clientName: 'yaoyao-bot-plugins', ownProcessGroup: true })
+  }
+  async test(owner: string, id: string) {
+    this.assertAdmin(owner)
+    if (this.probes >= 2) throw new HttpError(429, '已有两个连接测试正在进行', 'plugin_test_busy')
+    const entry = this.store.require<StoredPlugin>(owner, 'bot-mcp-plugin', id)
+    const client = this.client(owner, this.value(owner, entry)), record = { owner, client, pluginId: id }; this.live.add(record); this.probes++
+    try {
+      const signal = AbortSignal.timeout(20_000)
+      await client.init(signal); const tools = validateTools(await client.listTools(signal))
+      this.assertAdmin(owner)
+      if (this.store.require<StoredPlugin>(owner, 'bot-mcp-plugin', id).revision !== entry.revision) throw new HttpError(409, '插件配置已变化,请重新测试', 'plugin_revision_conflict')
+      entry.testedAt = Date.now(); entry.tools = tools
+      this.store.put(owner, 'bot-mcp-plugin', id, entry)
+      return { plugin: this.summary(owner, entry), tools: tools.map(({ name, description }) => ({ name, description })) }
+    } catch (error) {
+      if (error instanceof HttpError) throw error
+      throw new HttpError(502, 'MCP 连接测试失败,请检查程序、地址和凭据', 'plugin_test_failed')
+    } finally { client.dispose(); await client.waitClosed(); this.live.delete(record); this.probes-- }
+  }
+  private invalidate(owner: string, pluginId: string) { for (const item of this.live) if (item.owner === owner && item.pluginId === pluginId) item.client.dispose() }
+  selected(owner: string, agent: WorkspaceAgent): boolean {
+    if (agent.remoteAgentId || agent.temporaryGoalId) return false
+    return this.store.list<StoredPlugin>(owner, 'bot-mcp-plugin').some(p => p.enabled && p.agentIds.includes(agent.id)) || (this.apps.status(owner).configured && this.store.list<{ agentIds: string[] }>(owner, 'bot-app-grant').some(g => g.agentIds.includes(agent.id)))
+  }
+  async open(owner: string, agent: WorkspaceAgent, target: GatewayTarget, signal: AbortSignal, authorize: () => void): Promise<OpenBotPlugins> {
+    authorize(); this.assertOwner(owner); this.agents(owner, [agent.id])
+    await requireTeamToolBridge(target, agent.profile)
+    authorize(); if(signal.aborted)throw new HttpError(403,'本轮插件授权已结束','plugin_grant_revoked')
+    const connections: Array<{ client: PluginClient; owner: string; pluginId: string }> = []
+    const tools = new Map<string, { client: PluginClient; tool: McpTool; name: string; check: () => void }>()
+    const dispose = async () => { for (const entry of connections) entry.client.dispose(); await Promise.all(connections.map(async entry => { await entry.client.waitClosed(); this.live.delete(entry) })); signal.removeEventListener('abort', abort) }
+    const abort = () => { void dispose() }; signal.addEventListener('abort', abort, { once: true })
+    const add = async (id: string, name: string, client: PluginClient, check: () => void) => {
+      const entry = { client, owner, pluginId: id }; connections.push(entry); this.live.add(entry)
+      await client.init(signal)
+      const list = validateTools(await client.listTools(signal)); authorize(); check()
+      for (const tool of list) {
+        const key = 'plugin_' + createHash('sha256').update(id + ':' + tool.name).digest('hex').slice(0, 32)
+        tools.set(key, { client, tool, name, check })
+      }
+      if (tools.size > 500) throw new HttpError(409, '所选插件工具总数超过 500,请减少授权的服务', 'plugin_tools_limit')
+    }
+    try {
+      for (const plugin of this.store.list<StoredPlugin>(owner, 'bot-mcp-plugin').filter(p => p.enabled && p.agentIds.includes(agent.id))) {
+        const check = () => {
+          this.assertAdmin(owner)
+          const current = this.store.get<StoredPlugin>(owner, 'bot-mcp-plugin', plugin.id)
+          if (!current?.enabled || current.revision !== plugin.revision || !current.agentIds.includes(agent.id)) throw new HttpError(403, '该插件的本轮授权已结束', 'plugin_grant_revoked')
+        }
+        check(); const value = this.value(owner, plugin)
+        await add(plugin.id, value.name, this.client(owner, value), check)
+      }
+      const grants = this.store.db.prepare("SELECT id,data FROM workspace_entities WHERE owner=? AND kind='bot-app-grant'").all(owner)
+        .map(row => ({ slug: String(row.id), ...JSON.parse(String(row.data)) as { agentIds: string[]; revision: number } })).filter(g => g.agentIds.includes(agent.id))
+      if (grants.length && this.apps.status(owner).configured) {
+        const session = await this.apps.sessionFor(owner, agent.id, grants.map(g => g.slug), signal)
+        const check = () => {
+          this.assertOwner(owner)
+          if (this.apps.status(owner).revision !== session.revision || grants.some(g => { const current = this.apps.grant(owner, g.slug); return current.revision !== g.revision || !current.agentIds.includes(agent.id) })) throw new HttpError(403, '应用连接的本轮授权已结束', 'plugin_grant_revoked')
+        }
+        await add('connected-apps', '已连接应用', new HttpMcp(session.url, session.headers, this.fetchImpl), check)
+      }
+      return {
+        catalog: () => [...tools].flatMap(([id, entry]) => { try { authorize(); entry.check(); return [{ id, name: id, description: `${entry.name} · ${entry.tool.name}:${entry.tool.description ?? ''}`, inputSchema: entry.tool.inputSchema ?? { type: 'object', properties: {} } }] } catch { return [] } }),
+        call: async (id, args) => {
+          authorize(); const entry = tools.get(id)
+          if (!entry) throw new HttpError(404, '插件工具不存在', 'plugin_tool_not_found')
+          entry.check()
+          try { const result = await entry.client.callTool(entry.tool.name, args, signal); authorize(); entry.check(); return result }
+          catch (error) { if (error instanceof HttpError) throw error; throw new HttpError(502, '插件工具调用失败或连接已关闭', 'plugin_call_failed') }
+        }, dispose,
+      }
+    } catch (error) { await dispose(); if (error instanceof HttpError) throw error; throw new HttpError(502, '插件初始化失败,请在设置中测试连接', 'plugin_initialization_failed') }
+  }
+  router() {
+    const router = new Router({ prefix: '/api/app/bot-tools' })
+    const body = (ctx: any) => ctx.request.body
+    router.get('/settings', ctx => { const owner = this.auth.require(ctx).id; ctx.body = { ...this.apps.status(owner), canManageMcp: this.auth.isAdminActive(owner) } })
+    router.put('/settings', async ctx => {
+      const owner = this.auth.require(ctx).id
+      const value = parse(z.object({ apiKey: z.string().trim().max(4000), revision: z.number().int().nonnegative(), authConfigs: z.record(appSlug, z.string().max(200)).default({}) }).strict(), body(ctx))
+      ctx.body = await this.apps.configure(owner, value.apiKey, value.revision, value.authConfigs)
+    })
+    router.get('/mcp', ctx => { const owner = this.auth.require(ctx).id; ctx.body = { plugins: this.list(owner), canManage: this.auth.isAdminActive(owner) } })
+    router.post('/mcp', ctx => { ctx.body = { plugin: this.save(this.auth.require(ctx).id, body(ctx)) }; ctx.status = 201 })
+    router.put('/mcp/:id', ctx => { const value = parse(z.object({ definition, revision: z.number().int().positive() }).strict(), body(ctx)); this.store.require(this.auth.require(ctx).id, 'bot-mcp-plugin', ctx.params.id); ctx.body = { plugin: this.save(this.auth.require(ctx).id, value.definition, ctx.params.id, value.revision) } })
+    router.post('/mcp/:id/test', async ctx => { ctx.body = await this.test(this.auth.require(ctx).id, ctx.params.id) })
+    router.patch('/mcp/:id', ctx => {
+      const owner = this.auth.require(ctx).id; this.assertAdmin(owner)
+      const value = parse(z.object({ enabled: z.boolean().optional(), agentIds: agentIds.optional(), revision: z.number().int().positive() }).strict(), body(ctx))
+      const entry = this.store.require<StoredPlugin>(owner, 'bot-mcp-plugin', ctx.params.id)
+      if (entry.revision !== value.revision) throw new HttpError(409, '插件配置已变化,请刷新', 'plugin_revision_conflict')
+      if (value.enabled && !entry.testedAt) throw new HttpError(409, '请先成功测试连接,再启用插件', 'plugin_not_tested')
+      if (value.enabled !== undefined) entry.enabled = value.enabled
+      if (value.agentIds) entry.agentIds = this.agents(owner, value.agentIds)
+      entry.revision++; this.store.put(owner, 'bot-mcp-plugin', entry.id, entry); this.invalidate(owner, entry.id)
+      ctx.body = { plugin: this.summary(owner, entry) }
+    })
+    router.delete('/mcp/:id', ctx => { const owner = this.auth.require(ctx).id; this.assertAdmin(owner); this.store.require(owner, 'bot-mcp-plugin', ctx.params.id); this.store.remove(owner, 'bot-mcp-plugin', ctx.params.id); this.invalidate(owner, ctx.params.id); ctx.body = { ok: true } })
+    router.get('/apps/catalog', async ctx => { ctx.body = await this.apps.catalog(this.auth.require(ctx).id) })
+    router.get('/apps', async ctx => { const owner = this.auth.require(ctx).id; ctx.body = { configured: this.apps.status(owner).configured, connections: this.apps.status(owner).configured ? await this.apps.inventory(owner) : [], authoritative: true } })
+    router.post('/apps/:slug/authorize', async ctx => { const value = parse(z.object({ alias: z.string().trim().min(1).max(64).optional() }).strict(), body(ctx)); ctx.body = await this.apps.authorize(this.auth.require(ctx).id, parse(appSlug, ctx.params.slug), value.alias) })
+    router.put('/apps/:slug/agents', ctx => {
+      const owner = this.auth.require(ctx).id, slug = parse(appSlug, ctx.params.slug), value = parse(z.object({ agentIds, revision: z.number().int().nonnegative() }).strict(), body(ctx))
+      const current = this.apps.grant(owner, slug)
+      if (current.revision !== value.revision) throw new HttpError(409, '应用授权已变化,请刷新', 'plugin_revision_conflict')
+      const grant = { agentIds: this.agents(owner, value.agentIds), revision: current.revision + 1 }
+      this.store.put(owner, 'bot-app-grant', slug, grant); this.invalidate(owner, 'connected-apps'); ctx.body = grant
+    })
+    router.delete('/apps/:slug/accounts/:id', async ctx => { await this.apps.disconnect(this.auth.require(ctx).id, parse(appSlug, ctx.params.slug), ctx.params.id); ctx.body = { ok: true } })
+    return router
+  }
+  close() { for (const entry of this.live) entry.client.dispose(); this.live.clear() }
+}

+ 11 - 3
src/server/workspaceRuntime.ts

@@ -18,6 +18,7 @@ import type {
 } from '../shared/workspace.js'
 
 import { WorkspaceScheduler, type Work, NO_REPLY, HOST_FALLBACK } from './workspaceScheduler.js'
+import type { WorkspacePlugins, OpenBotPlugins } from './botPlugins/workspacePlugins.js'
 import { mentionedAgents } from './workspaceMentions.js'
 import { WorkspaceTeamTools, TEAM_TOOL_RULES } from './workspaceTeamTools.js'
 import { createWorkspaceToolLease, type WorkspaceToolLease } from './workspaceToolLease.js'
@@ -62,6 +63,7 @@ export const sendInput = z
   .strict()
   .refine((b) => b.content.trim() || b.fileIds.length, '请输入消息或添加附件')
 export class WorkspaceRuntime extends WorkspaceScheduler {
+  plugins?: WorkspacePlugins
   get idleForUpdate(): boolean {
     return this.live.size === 0 && this.executing.size === 0 && !this.store.db.prepare("SELECT 1 FROM workspace_entities WHERE kind IN ('run','turn') AND COALESCE(json_extract(data,'$.status'),'unknown') NOT IN ('complete','failed','interrupted') LIMIT 1").get()
   }
@@ -245,6 +247,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     let flushTimer: ReturnType<typeof setTimeout> | undefined
     const toolController = new AbortController()
     let toolLease: WorkspaceToolLease | undefined
+    let pluginLease: OpenBotPlugins | undefined
     let resolveTurn!: (m: Message) => void, rejectTurn!: (e: Error) => void
     const completion = new Promise<Message>((resolve, reject) => {
       resolveTurn = resolve
@@ -257,6 +260,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       settled = true
       toolController.abort()
       void toolLease?.dispose()
+      void pluginLease?.dispose()
       clearTimeout(flushTimer)
       flushTimer = undefined
       this.live.delete(key)
@@ -544,8 +548,9 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         this.nodes.requireSource(owner, agent)
         const team=agent.canManageTeam===true&&!this.store.require<Run>(owner,'run',run.runId).assignmentId
         const cloud=!!this.cloud?.selected(owner,agent)
+        const plugins=!!this.plugins?.selected(owner,agent)
         const desktop=this.desktopEnvironments?.selected(owner,agent),desktopEpoch=this.desktopEnvironments?.epoch??''
-        if(team||cloud||desktop){
+        if(team||cloud||desktop||plugins){
           if(team){
             const granted=this.getWork(owner,run.id);granted.teamManagementRevision=agent.revision;this.saveWork(owner,granted)
             await this.teamTools.requireAvailable(owner,agent)
@@ -556,12 +561,14 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
             if(settled||this.closing||toolController.signal.aborted)throw new Error('运行已停止')
             this.requireAuthorization(owner,run.runId);this.nodes.requireSource(owner,this.store.require<Agent>(owner,'agent',agent.id))
             if(team)this.teamTools.assertTurn(owner,run.id)
+            if(plugins){const current=this.getWork(owner,run.id),latest=this.store.require<Agent>(owner,'agent',agent.id);if(current.cancelRequested||this.store.require<Run>(owner,'run',run.runId).stopRequested||latest.archived||!['running','waiting'].includes(current.status))throw new HttpError(403,'本轮插件授权已结束','plugin_grant_revoked')}
           }
+          if(plugins)pluginLease=await this.plugins!.open(owner,agent,target,toolController.signal,assertActive)
           toolLease=await createWorkspaceToolLease({
             target,profile:agent.profile,workId:run.id,signal:toolController.signal,
             session:()=>({runtimeId,storedId:binding!.storedId}),assertActive,
-            catalog:()=>[...(team?this.teamTools.catalog(owner,run.id):[]),...(cloud?GROK_COMPUTER_TOOLS:[]),...(desktop?DESKTOP_ENVIRONMENT_TOOLS.filter(t=>desktop==='browser'||t.id!=='desktop_browser'):[])],
-            call:async(toolId,args)=>{assertActive();return toolId.startsWith('desktop_')?this.desktopEnvironments!.call(owner,agent.id,toolId,args,toolController.signal,assertActive,desktop!,desktopEpoch):toolId.startsWith('cloud_computer_')?this.cloud!.call(owner,agent.id,toolId,args,toolController.signal):this.teamTools.call(owner,run.id,toolId,args)},
+            catalog:()=>[...(team?this.teamTools.catalog(owner,run.id):[]),...(cloud?GROK_COMPUTER_TOOLS:[]),...(desktop?DESKTOP_ENVIRONMENT_TOOLS.filter(t=>desktop==='browser'||t.id!=='desktop_browser'):[]),...(pluginLease?.catalog()??[])],
+            call:async(toolId,args)=>{assertActive();return toolId.startsWith('plugin_')&&pluginLease?pluginLease.call(toolId,args):toolId.startsWith('desktop_')?this.desktopEnvironments!.call(owner,agent.id,toolId,args,toolController.signal,assertActive,desktop!,desktopEpoch):toolId.startsWith('cloud_computer_')?this.cloud!.call(owner,agent.id,toolId,args,toolController.signal):this.teamTools.call(owner,run.id,toolId,args)},
             onFailure:error=>{if(!settled)void gateway.rpc('session.interrupt',{session_id:runtimeId}).catch(()=>{}).finally(()=>finish(error))},
           })
           await toolLease.bind();if(settled)return await completion
@@ -608,6 +615,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           goal ? `当前团队目标 ID:${goal.id}。目标:${goal.objective.slice(0,8000)}\n验收要求:${goal.acceptanceCriteria.join(';')}\n${assignmentId ? `你在执行子任务 ${assignmentId},请完成分派并提交结果,不要扩大团队或再次委派。` : '优先使用 workspace_assign_task 进行有依赖和验收要求的结构化分派;成员结果需要通过 workspace_review_assignment 复核。最终使用 workspace_finish_team_task 记录完成、受阻或等待用户。不要将一次模型回复结束当作目标完成。'}` : '',
           agent.temporaryGoalId ? `你是当前任务的临时助手,任务 ID:${agent.temporaryGoalId}。仅处理分派工作,使用 computer_export 回传产物。任务结束后会退役;不要创建团队或改变自身权限。` : '',
           team ? TEAM_TOOL_RULES : '',
+          plugins ? '本轮已挂载用户为当前 Bot 授权的插件工具,工具名以 plugin_ 开头,说明中包含实际服务和操作。仅按用户当前任务使用;连接或重新授权应用请让用户打开 Bot 模式的工具 → 已连接应用。不要索取 API Key 或在回复中展示凭据。' : '',
           desktop ? DESKTOP_ENVIRONMENT_RULES : '',
           cloud ? GROK_COMPUTER_RULES : '',
           `[yaoyao-run:${run.runId}:${resultMessage.id}]`,

+ 19 - 0
src/shared/workspacePlugins.ts

@@ -0,0 +1,19 @@
+export interface BotMcpPlugin {
+  id: string
+  name: string
+  transport: 'stdio' | 'http'
+  command?: string
+  args?: string[]
+  url?: string
+  envKeys: string[]
+  headerKeys: string[]
+  enabled: boolean
+  agentIds: string[]
+  revision: number
+  testedAt?: number
+  toolCount: number
+}
+export interface BotAppCard { slug: string; name: string; description: string }
+export interface BotAppAccount { id: string; alias?: string; status: string }
+export interface BotAppConnection { slug: string; accounts: BotAppAccount[]; agentIds: string[]; revision: number }
+export interface BotPluginSettings { configured: boolean; revision: number }

+ 58 - 0
tests/client/botPluginsPanel.test.ts

@@ -0,0 +1,58 @@
+import { mount, flushPromises } from '@vue/test-utils'
+import { beforeEach, afterEach, expect, it, vi } from 'vitest'
+import BotPluginsPanel from '@/components/workspace/BotPluginsPanel.vue'
+import { apiRequest } from '@/api/client'
+
+vi.mock('@/api/client', () => ({ apiRequest: vi.fn() }))
+let failInventory = false, canManage = true
+const plugin = { id: 'plugin-1', name: '现有插件', transport: 'http', url: 'https://mcp.example.test', envKeys: [], headerKeys: ['Authorization'], agentIds: [], enabled: false, revision: 2, toolCount: 1, testedAt: 1 }
+beforeEach(() => {
+  failInventory = false; canManage = true
+  vi.mocked(apiRequest).mockReset().mockImplementation(async (path, options) => {
+    if (options?.method && options.method !== 'GET') return {} as never
+    if (path.endsWith('/settings')) return { configured: true, revision: 1, canManageMcp: canManage } as never
+    if (path.endsWith('/mcp')) return { plugins: [plugin], canManage } as never
+    if (path.endsWith('/apps/catalog')) return { cards: [{ slug: 'gmail', name: 'Gmail', description: '邮件' }] } as never
+    if (path.endsWith('/apps')) {
+      if (failInventory) throw new Error('暂时无法读取连接状态')
+      return { authoritative: true, connections: [{ slug: 'gmail', accounts: [{ id: 'account-1', alias: '工作账号', status: 'ACTIVE' }], agentIds: [], revision: 0 }] } as never
+    }
+    return { agents: [] } as never
+  })
+})
+afterEach(() => { document.body.innerHTML = ''; vi.restoreAllMocks() })
+const render = (connectedOnly = false) => mount(BotPluginsPanel, { attachTo: document.body, props: { connectedOnly }, global: { stubs: { AppIcon: true } } })
+function button(wrapper: ReturnType<typeof render>, text: string) { return wrapper.findAll('button').find(b => b.text() === text)! }
+
+it('retains connected accounts on failed refresh instead of presenting them as disconnected', async () => {
+  const wrapper = render(true); await flushPromises()
+  expect(wrapper.text()).toContain('工作账号')
+  failInventory = true
+  await wrapper.get('[aria-label="刷新应用连接"]').trigger('click'); await flushPromises()
+  expect(wrapper.get('[role="alert"]').text()).toContain('暂时无法读取连接状态')
+  expect(wrapper.text()).toContain('工作账号')
+  expect(button(wrapper, '添加账号').exists()).toBe(true)
+  wrapper.unmount()
+})
+it('keeps secret placeholders and invalid form input without submitting a broken configuration', async () => {
+  const wrapper = render(); await flushPromises()
+  await button(wrapper, 'MCP 服务').trigger('click')
+  await button(wrapper, '编辑').trigger('click')
+  const headers = wrapper.get<HTMLTextAreaElement>('.plugin-editor textarea')
+  expect(JSON.parse(headers.element.value)).toEqual({ Authorization: true })
+  await headers.setValue('{bad json')
+  await wrapper.get('form[aria-label="MCP 服务配置"]').trigger('submit'); await flushPromises()
+  expect(wrapper.get('[role="alert"]').text()).toContain('有效 JSON')
+  expect(headers.element.value).toBe('{bad json')
+  expect(vi.mocked(apiRequest).mock.calls.some(([, options]) => options?.method === 'PUT')).toBe(false)
+  expect(wrapper.emitted('dirty-change')?.at(-1)).toEqual([true])
+  wrapper.unmount()
+})
+it('disables host MCP management for non-admin accounts', async () => {
+  canManage = false
+  const wrapper = render(); await flushPromises()
+  await button(wrapper, 'MCP 服务').trigger('click')
+  expect(button(wrapper, '添加服务').attributes('disabled')).toBeDefined()
+  expect(button(wrapper, '测试连接').attributes('disabled')).toBeDefined()
+  wrapper.unmount()
+})

+ 16 - 0
tests/client/settingsCenterDialog.test.ts

@@ -41,6 +41,8 @@ const childStubs = {
   Teleport: true,
   AgentAvatar: true,
   AppIcon: true,
+  BotPluginsPanel: simpleStub('BotPluginsPanel', 'bot-plugins'),
+  BotAutomationPanel: simpleStub('BotAutomationPanel', 'bot-automations'),
   AgentIdentityPanel: simpleStub('AgentIdentityPanel', 'agent-identity'),
   ModelServicesPanel: ModelServicesPanelStub,
   AccountSecurityPanel: simpleStub('AccountSecurityPanel', 'account-security'),
@@ -81,6 +83,20 @@ afterEach(() => {
 })
 
 describe('Settings center dialog', () => {
+  it('keeps plugin, connected-app and automation settings scoped to Bot mode', async () => {
+    const normal = mountSettings({ initialPage: 'bot-plugins' })
+    expect(normal.find('[data-testid="bot-plugins"]').exists()).toBe(false)
+    expect(normal.findAll('.settings-sidebar nav button').some(b => b.text() === '插件')).toBe(false)
+    normal.unmount()
+    const bot = mountSettings({ botMode: true, initialPage: 'bot-plugins' })
+    await vi.waitFor(() => expect(bot.find('[data-testid="bot-plugins"]').exists()).toBe(true))
+    await navigationButton(bot, '自动化').trigger('click')
+    await vi.waitFor(() => expect(bot.find('[data-testid="bot-automations"]').exists()).toBe(true))
+    await navigationButton(bot, '关于').trigger('click')
+    expect(bot.get('.bot-about').text()).toContain('夭夭 AI')
+    expect(bot.get('.bot-about a').attributes('href')).toBe('https://yaoyao.samien.cn')
+    bot.unmount()
+  })
   it('groups administrator pages and routes Agent, voice, and system content to the right scope', async () => {
     const wrapper = mountSettings()
 

+ 24 - 0
tests/client/workspaceShell.test.ts

@@ -160,6 +160,10 @@ it('uses the reference Bot list header and keeps mode changes in settings', asyn
   expect(rail.find('.sidebar-collapse').exists()).toBe(false)
   expect(rail.find('.sidebar-context__heading').exists()).toBe(false)
   await rail.get('.sidebar-account-switcher__main').trigger('click')
+  const accountItems = [...document.querySelectorAll<HTMLElement>('.workspace-settings-menu [role="menuitem"]')]
+  expect(accountItems.map(item => item.textContent?.trim())).toEqual(['设置', '关于', '帮助', '进入聊天模式'])
+  expect(document.querySelector<HTMLAnchorElement>('.workspace-settings-menu a')?.href).toBe('https://yaoyao.samien.cn/')
+  accountItems[0]!.click(); await wrapper.vm.$nextTick()
   expect(wrapper.get('[data-testid="settings-center"]').attributes('data-page')).toBe('account-security')
   await wrapper.get('[data-testid="close-settings"]').trigger('click')
   await rail.get('.sidebar-create-trigger').trigger('click')
@@ -177,3 +181,23 @@ it('uses the reference Bot list header and keeps mode changes in settings', asyn
   await wrapper.vm.$nextTick()
   wrapper.unmount()
 })
+
+it('shows Bot tools only in Bot mode and restores keyboard focus after closing', async () => {
+  const normal = await mountShell('/chat')
+  expect(normal.find('.sidebar-tools-trigger').exists()).toBe(false)
+  normal.unmount()
+  const wrapper = await mountShell('/conversations')
+  const trigger = wrapper.get<HTMLButtonElement>('.desktop-sidebar .sidebar-tools-trigger')
+  await trigger.trigger('click')
+  const menu = document.querySelector<HTMLElement>('.workspace-tools-menu')!
+  const items = [...menu.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')]
+  expect(items.map(b => b.textContent?.trim())).toEqual(['插件', '自动化', '已连接应用'])
+  expect(document.activeElement).toBe(items[0])
+  menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
+  expect(document.activeElement).toBe(items[1])
+  items[1]!.click(); await wrapper.vm.$nextTick()
+  expect(wrapper.get('[data-testid="settings-center"]').attributes('data-page')).toBe('bot-routines')
+  await wrapper.get('[data-testid="close-settings"]').trigger('click'); await wrapper.vm.$nextTick()
+  expect(document.activeElement).toBe(trigger.element)
+  wrapper.unmount()
+})

+ 64 - 0
tests/fixtures/bot-plugins.ts

@@ -0,0 +1,64 @@
+import type { IncomingMessage, ServerResponse } from 'node:http'
+import { randomUUID } from 'node:crypto'
+
+/** Fake Composio plus an actual loopback MCP endpoint and native Hermes bridge.
+ * Enabled only by WORKSPACE_FIXTURE_PLUGINS=1 in the disposable browser server. */
+export class FixtureBotPlugins {
+  private sessions = new Map<string, string>()
+  private accounts: any[] = []
+  private bindings = new Map<string, any>()
+  readonly fetch: typeof fetch = async (input, init) => {
+    const url = new URL(String(input))
+    if (!url.hostname.endsWith('composio.dev')) return fetch(input, init)
+    const body = JSON.parse(String(init?.body ?? '{}'))
+    const send = (value: unknown) => new Response(JSON.stringify(value), { headers: { 'content-type': 'application/json' } })
+    if (url.pathname.endsWith('/tool_router/session')) {
+      const id = 'trs_' + randomUUID(); this.sessions.set(id, body.user_id)
+      if (!this.accounts.some(a => a.user_id === body.user_id)) this.accounts.push({ id: 'ca_' + randomUUID(), user_id: body.user_id, alias: '隔离测试账号', toolkit: { slug: 'gmail' }, status: 'ACTIVE' })
+      return send({ session_id: id, config: { user_id: body.user_id }, mcp: { url: `https://backend.composio.dev/tool_router/${id}/mcp` } })
+    }
+    if (url.pathname.endsWith('/toolkits')) return send({ items: [{ slug: 'gmail', name: 'Gmail', description: '读取邮件与管理草稿' }, { slug: 'github', name: 'GitHub', description: '代码仓库与议题' }, { slug: 'notion', name: 'Notion', description: '文档与知识库' }] })
+    if (url.pathname.endsWith('/connected_accounts')) return send({ items: this.accounts.filter(a => a.user_id === url.searchParams.get('user_ids')) })
+    if (url.pathname.includes('/connected_accounts/') && init?.method === 'DELETE') { this.accounts = this.accounts.filter(a => a.id !== url.pathname.split('/').at(-1)); return send({ ok: true }) }
+    if (url.pathname.endsWith('/link')) return send({ redirect_url: 'https://connect.composio.dev/fixture-not-a-real-oauth-link' })
+    if (body.method === 'notifications/initialized') return new Response(null, { status: 202 })
+    return send(this.mcp(body))
+  }
+  private mcp(frame: any) {
+    const result = frame.method === 'initialize' ? { protocolVersion: '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'isolated-plugin-fixture', version: '1' } }
+      : frame.method === 'tools/list' ? { tools: [{ name: 'fixture_echo', description: '回传验收文字,不访问外部服务', inputSchema: { type: 'object', properties: { value: { type: 'string' } }, required: ['value'] } }] }
+        : { content: [{ type: 'text', text: String(frame.params?.arguments?.value ?? '') }], structuredContent: { verified: true } }
+    return { jsonrpc: '2.0', id: frame.id, result }
+  }
+  handle(req: IncomingMessage, res: ServerResponse, pathname: string): boolean {
+    if (pathname !== '/__fixture/mcp' && !pathname.startsWith('/api/plugins/yaoyao-bot-bridge/')) return false
+    if (pathname.endsWith('/capabilities')) { res.end(JSON.stringify({ version: 1, ready: true, native_tools: true, in_process: true })); return true }
+    let raw = ''
+    req.on('data', chunk => { raw += String(chunk) })
+    req.on('end', () => {
+      const frame = JSON.parse(raw || '{}')
+      if (pathname.endsWith('/bind')) this.bindings.set(frame.session_id, frame)
+      if (pathname.endsWith('/unbind')) this.bindings.delete(frame.session_id)
+      if (pathname === '/__fixture/mcp') {
+        if (!frame.id) { res.statusCode = 202; res.end(); return }
+        res.end(JSON.stringify(this.mcp(frame))); return
+      }
+      res.end(JSON.stringify({ ok: true, native_tools: true }))
+    })
+    return true
+  }
+  async exercise(sessionId: string) {
+    const binding = this.bindings.get(sessionId)
+    if (!binding) throw new Error('本轮没有原生插件绑定')
+    const call = async (path: string, body: unknown) => {
+      const response = await fetch(binding.bridge_url + path, { method: 'POST', headers: { Authorization: 'Bearer ' + binding.token, 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
+      if (!response.ok) throw new Error('插件桥拒绝了测试请求')
+      return response.json() as Promise<any>
+    }
+    const catalog = await call('/tools/list', {})
+    const tool = catalog.tools.find((t: any) => t.description.includes('fixture_echo'))
+    if (!tool) throw new Error('本轮没有已授权的插件工具')
+    const result = await call('/tools/call', { toolId: tool.id, arguments: { value: 'Bot 插件原生调用成功' }, callId: randomUUID() })
+    return { tool, result }
+  }
+}

+ 13 - 3
tests/fixtures/workspace-server.ts

@@ -1,5 +1,6 @@
 import {nativeEnvironmentFixture} from './native-environment.js'
 import {FixtureGrokAuthProvider} from './grok-auth-provider.js'
+import { FixtureBotPlugins } from './bot-plugins.js'
 /** Isolated, deterministic Hermes fixture. Optional team-tool readiness is only for editor UI tests. */
 import { createServer } from 'node:http'
 import { mkdtempSync, readFileSync, existsSync, writeFileSync } from 'node:fs'
@@ -30,6 +31,7 @@ const sessions = new Map<
 const latestRuntimeBySession = new Map<string, string>()
 const calls: Array<{ method: string; params: Record<string, unknown> }> = []
 const heldReplies: Array<() => void> = []
+const pluginsFixture = process.env.WORKSPACE_FIXTURE_PLUGINS === '1' ? new FixtureBotPlugins() : undefined
 const profileState = new Map([
   ['default', { name: 'default', display_name: '通用助手', is_default: true, gateway_running: true, ui_meta: {} as Record<string, unknown>, ui_meta_revisions: {} as Record<string, number> }],
   ['server', { name: 'server', display_name: '开发助手', is_default: false, gateway_running: true, ui_meta: {} as Record<string, unknown>, ui_meta_revisions: {} as Record<string, number> }],
@@ -58,6 +60,7 @@ const upstream = createServer((req, res) => {
   const url = new URL(req.url || '/', `http://127.0.0.1:${upstreamPort}`)
   res.setHeader('content-type', 'application/json')
   const send = (v: unknown) => res.end(JSON.stringify(v))
+  if (pluginsFixture?.handle(req, res, url.pathname)) return
   if (url.pathname === '/api/config') {
     send({ terminal: { cwd: `/tmp/hermes-fixture/${url.searchParams.get('profile') || 'default'}` } })
     return
@@ -261,7 +264,7 @@ wss.on('connection', (socket) => {
       const crossClientRun = String(f.params.text).includes('[cross-client-live]')
       const markdownPrefix = '# 流式标题\n\n**即时格式化**\n\n- 第一项\n- 第二项\n\n```ts\nconst answer = 42' + (String(f.params.text).includes('[long]') ? `\n\x60\x60\x60\n\n${'这是持续生成的长回复段落。\n\n'.repeat(80)}流式末尾标记\n\n\x60\x60\x60ts\nconst tail = 1` : '')
       const markdownFinal = `${markdownPrefix}\n\x60\x60\x60\n\n| 名称 | 数量 |\n| --- | --- |\n| 项目 | 2 |\n\n最终完整标记`
-      const text = markdownStream ? markdownFinal : `我是${name}。已按角色规则处理这条消息。\n\n- 会话独立保存\n- 可以继续交流\n\n[报告](/tmp/workspace-report.txt)${delegates ? `\n请${requested}处理任务。` : ''}`
+      let text = markdownStream ? markdownFinal : `我是${name}。已按角色规则处理这条消息。\n\n- 会话独立保存\n- 可以继续交流\n\n[报告](/tmp/workspace-report.txt)${delegates ? `\n请${requested}处理任务。` : ''}`
       event('message.start', {}, f.params.session_id)
       if (String(f.params.text).includes('[message-files]')) {
         event('reasoning.delta', { text: '![过程图](/tmp/process.png)' }, f.params.session_id)
@@ -281,7 +284,14 @@ wss.on('connection', (socket) => {
         })
         event('message.complete', { text, status: 'complete' }, f.params.session_id)
       }
-      if (crossClientRun || markdownStream || f.params.text.includes('[hold-workspace]')) heldReplies.push(complete)
+      if (pluginsFixture && f.params.text.includes('[plugin-roundtrip]')) {
+        void pluginsFixture.exercise(f.params.session_id).then(({ tool, result }) => {
+          event('tool.start', { tool_id: 'plugin-fixture', name: tool.name }, f.params.session_id)
+          event('tool.complete', { tool_id: 'plugin-fixture', name: tool.name, result }, f.params.session_id)
+          text = String(result.content?.[0]?.text ?? '插件未返回文字'); complete()
+        }).catch(error => { stored!.running = false; event('run.failed', { error: String(error) }, f.params.session_id) })
+      }
+      else if (crossClientRun || markdownStream || f.params.text.includes('[hold-workspace]')) heldReplies.push(complete)
       else setTimeout(complete, 250)
       return
     }
@@ -337,7 +347,7 @@ if (!existsSync(usersPath)) {
 }
 const auth = new LocalAuthStore(home, false)
 const grokAuth=process.env.WORKSPACE_FIXTURE_GROK_AUTH==='1'?new FixtureGrokAuthProvider():undefined
-const runtime = createApplication({ config, auth, grokFetch:grokAuth?.fetch }),
+const runtime = createApplication({ config, auth, grokFetch:grokAuth?.fetch, pluginFetch: pluginsFixture?.fetch }),
   node = createNodeServer(runtime)
 const closeNative=process.env.WORKSPACE_FIXTURE_NATIVE==='1'?nativeEnvironmentFixture(runtime,home,port):undefined
 runtime.app.use((ctx,next)=>{if(ctx.path!=='/__test/browser-page')return next();ctx.type='html';ctx.body='<!doctype html><meta charset="utf-8"><title>浏览器操作验收</title><body style="font:22px sans-serif;padding:48px;background:#edf4ff"><h1>机器人的独立浏览器</h1><p>这是隔离的真实浏览器验收页面。</p><input aria-label="输入内容"><button onclick="document.querySelector(\'output\').textContent=document.querySelector(\'input\').value">确认</button><output></output></body>'})

+ 195 - 0
tests/server/workspacePlugins.test.ts

@@ -0,0 +1,195 @@
+// @vitest-environment node
+import { beforeEach, afterEach, describe, it, expect } from 'vitest'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { randomUUID } from 'node:crypto'
+import request from 'supertest'
+import type Koa from 'koa'
+import { createApplication, type ApplicationRuntime } from '../../src/server/app'
+import { loadServerConfig } from '../../src/server/config'
+import { LocalAuthStore } from '../../src/server/localAuth'
+import { HttpMcp } from '../../src/server/botPlugins/httpMcp'
+import { createWorkspaceToolLease } from '../../src/server/workspaceToolLease'
+
+let home: string, runtime: ApplicationRuntime, cookie: string, csrf: string, failedInventory: boolean
+let requests: Array<{ url: string; body: any; method: string }>, accounts: any[], sessions: Map<string, string>
+const principal = (id: string) => ({ id, username: id, role: id === 'owner' ? 'admin' as const : 'user' as const, enabled: true, mustChangePassword: false, createdAt: 1, updatedAt: 1 })
+class TestAuth extends LocalAuthStore {
+  override current(ctx: Koa.Context) { return principal(ctx.get('x-test-user') || 'owner') }
+  override require(ctx: Koa.Context) { return this.current(ctx) }
+  override requireAdmin(ctx: Koa.Context) { const user = this.current(ctx); if (user.role !== 'admin') ctx.throw(403); return user }
+  override isUserActive() { return true }
+  override isAdminActive(id: string) { return id === 'owner' }
+  override canUseSource() { return true }
+}
+const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
+const mcpFetch: typeof fetch = async (input, init) => {
+  const url = String(input), body = JSON.parse(String(init?.body || '{}')), method = init?.method ?? 'GET'
+  requests.push({ url, body, method })
+  if (url.includes('/tool_router/session') && method === 'POST' && !url.endsWith('/link')) {
+    const id = 'trs_' + randomUUID(); sessions.set(id, body.user_id)
+    return response({ session_id: id, config: { user_id: body.user_id }, mcp: { url: `https://backend.composio.dev/tool_router/${id}/mcp` } }, 201)
+  }
+  if (url.endsWith('/link')) {
+    const sessionId = /session\/([^/]+)/.exec(url)![1]!, user = sessions.get(sessionId)
+    accounts.push({ id: 'ca_' + randomUUID(), user_id: user, toolkit: { slug: body.toolkit }, alias: body.alias, status: 'ACTIVE' })
+    return response({ redirect_url: 'https://connect.composio.dev/link/fixture' })
+  }
+  if (url.includes('/connected_accounts?')) {
+    if (failedInventory) return response({ error: 'fixture failure' }, 503)
+    return response({ items: accounts.filter(a => a.user_id === new URL(url).searchParams.get('user_ids')) })
+  }
+  if (url.includes('/connected_accounts/') && method === 'DELETE') { const id = new URL(url).pathname.split('/').at(-1); accounts = accounts.filter(a => a.id !== id); return response({ ok: true }) }
+  if (url.includes('/toolkits?')) return response({ items: [{ slug: 'gmail', name: 'Gmail', meta: { description: '邮件' } }] })
+  if (body.method === 'notifications/initialized') return new Response(null, { status: 202 })
+  const result = body.method === 'initialize' ? { protocolVersion: '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'fixture', version: '1' } }
+    : body.method === 'tools/list' ? { tools: [{ name: 'fixture_echo', description: 'Echo an explicit value', inputSchema: { type: 'object', properties: { value: { type: 'string' } } } }] }
+      : { content: [{ type: 'text', text: String(body.params?.arguments?.value) }], structuredContent: { value: body.params?.arguments?.value } }
+  return response({ jsonrpc: '2.0', id: body.id, result })
+}
+const api = (method: 'get' | 'post' | 'put' | 'patch' | 'delete', path: string, owner = 'owner') => request(runtime.app.callback())[method]('/api/app/bot-tools' + path)
+  .set('Host', '127.0.0.1:15300').set('Origin', 'http://127.0.0.1:15300').set('Cookie', cookie).set('x-csrf-token', csrf).set('x-test-user', owner)
+beforeEach(async () => {
+  home = mkdtempSync(join(tmpdir(), 'yaoyao-plugin-test-')); failedInventory = false; requests = []; accounts = []; sessions = new Map()
+  const config = loadServerConfig({ HERMES_YAOYAO_HOME: home, HERMES_YAOYAO_UPSTREAM: 'http://127.0.0.1:19119' })
+  runtime = createApplication({ config, auth: new TestAuth(home, false), pluginFetch: mcpFetch, fetchImpl: (async () => response({ ok: true, auth_required: false, profiles: [] })) as typeof fetch })
+  const boot = await request(runtime.app.callback()).get('/api/app/bootstrap').set('Host', '127.0.0.1:15300')
+  cookie = (boot.headers['set-cookie'] as unknown as string[]).map(s => s.split(';')[0]).join('; ')
+  csrf = boot.body.csrfToken
+})
+afterEach(async () => { runtime.close(); await new Promise(r => setTimeout(r, 20)); rmSync(home, { recursive: true, force: true }) })
+async function addPlugin(agentIds: string[] = []) {
+  const added = await api('post', '/mcp').send({ name: '测试服务', transport: 'http', url: 'https://mcp.example.test/mcp', headers: { Authorization: 'Bearer fixture-secret' }, agentIds })
+  expect(added.status, JSON.stringify(added.body)).toBe(201)
+  return added.body.plugin
+}
+async function configure(owner = 'owner') {
+  const result = await api('put', '/settings', owner).send({ apiKey: 'ak_fixture_not_real', revision: 0 })
+  expect(result.status, JSON.stringify(result.body)).toBe(200)
+}
+
+describe('Bot-only plugin management', () => {
+  it('keeps MCP credentials encrypted and disabled until a successful explicit test', async () => {
+    const plugin = await addPlugin()
+    expect(plugin).toMatchObject({ enabled: false, headerKeys: ['Authorization'] }); expect(plugin.testedAt).toBeUndefined()
+    expect(JSON.stringify(plugin)).not.toContain('fixture-secret')
+    expect(JSON.stringify(runtime.workspace.list('owner', 'bot-mcp-plugin'))).not.toContain('fixture-secret')
+    expect((await api('patch', '/mcp/' + plugin.id).send({ enabled: true, revision: 1 })).status).toBe(409)
+    const tested = await api('post', '/mcp/' + plugin.id + '/test').send({})
+    expect(tested.status, JSON.stringify(tested.body)).toBe(200); expect(tested.body.plugin.toolCount).toBe(1)
+    const enabled = await api('patch', '/mcp/' + plugin.id).send({ enabled: true, revision: 1 })
+    expect(enabled.body.plugin.enabled).toBe(true)
+    const updated = await api('put', '/mcp/' + plugin.id).send({ revision: 2, definition: { name: '测试服务', transport: 'http', url: plugin.url, headers: { Authorization: true } } })
+    expect(updated.status).toBe(200); expect(updated.body.plugin.enabled).toBe(false); expect(updated.body.plugin.testedAt).toBeUndefined()
+    await api('post', '/mcp/' + plugin.id + '/test').send({}).expect(200)
+  })
+  it('enforces owner, Bot grants, admin-only MCP and CSRF boundaries', async () => {
+    const plugin = await addPlugin(), foreign = runtime.workspace.createAgent('member', { name: '其他账号', profile: 'default' })
+    await api('get', '/mcp', 'member').expect(200).expect(r => expect(r.body.plugins).toEqual([]))
+    await api('post', '/mcp', 'member').send({ name: 'test', transport: 'stdio', command: process.execPath }).expect(403)
+    await api('patch', '/mcp/' + plugin.id).send({ agentIds: [foreign.id], revision: 1 }).expect(404)
+    await api('delete', '/mcp/' + plugin.id, 'member').expect(403)
+    await request(runtime.app.callback()).post('/api/app/bot-tools/mcp').set('Host', '127.0.0.1:15300').set('Origin', 'http://127.0.0.1:15300').send({}).expect(403)
+    await api('post', '/mcp').send({ name: 'unsafe', transport: 'http', url: 'file:///etc/passwd' }).expect(400)
+    await api('post', '/mcp').send({ name: 'unsafe', transport: 'http', url: 'https://mcp.example.test', headers: { Host: 'internal' } }).expect(400)
+    await api('post', '/mcp').send({ name: 'unsafe', transport: 'stdio', command: process.execPath, env: { constructor: true } }).expect(400)
+  })
+  it('tests a real stdio subprocess without inheriting application secrets', async () => {
+    const code = `require('node:readline').createInterface({input:process.stdin}).on('line',line=>{const r=JSON.parse(line);if(!r.id)return;const result=r.method==='initialize'?{protocolVersion:'2025-06-18',capabilities:{tools:{}}}:{tools:[{name:'environment_check',description:process.env.HERMES_TEST_SECRET?'leaked':'isolated',inputSchema:{type:'object'}}]};process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:r.id,result})+'\\n')})`
+    process.env.HERMES_TEST_SECRET = 'do-not-inherit'
+    try {
+      const added = await api('post', '/mcp').send({ name: 'stdio fixture', transport: 'stdio', command: process.execPath, args: ['-e', code] })
+      expect(added.status).toBe(201)
+      const tested = await api('post', '/mcp/' + added.body.plugin.id + '/test').send({})
+      expect(tested.status, JSON.stringify(tested.body)).toBe(200)
+      expect(tested.body.tools[0].description).toBe('isolated')
+    } finally { delete process.env.HERMES_TEST_SECRET }
+  })
+  it('calls an authorized plugin through the native per-turn tool bridge and revokes it immediately', async () => {
+    const agent = runtime.workspace.createAgent('owner', { name: '插件 Bot', profile: 'default' })
+    const plugin = await addPlugin([agent.id])
+    await api('post', '/mcp/' + plugin.id + '/test').send({}).expect(200)
+    await api('patch', '/mcp/' + plugin.id).send({ enabled: true, revision: 1 }).expect(200)
+    let binding: any
+    const target: any = { url: new URL('http://127.0.0.1:19119'), session: { request: async (path: string, options: any) => {
+      if (path.endsWith('/bind')) binding = options.body
+      return { status: 200, body: Buffer.from(JSON.stringify(path.endsWith('/capabilities') ? { version: 1, ready: true, native_tools: true, in_process: true } : { ok: true, native_tools: true })) }
+    } } }
+    const controller = new AbortController()
+    const tools = await runtime.workspacePlugins.open('owner', agent, target, controller.signal, () => {})
+    const originalToolId = tools.catalog()[0]!.id
+    const lease = await createWorkspaceToolLease({ target, profile: 'default', workId: randomUUID(), signal: controller.signal, session: () => ({ runtimeId: 'live-fixture', storedId: 'stored-fixture' }), assertActive() {}, catalog: () => tools.catalog(), call: (id, args) => tools.call(id, args), onFailure() {} })
+    try {
+      await lease.bind()
+      const bridge = async (path: string, body: unknown) => (await fetch(binding.bridge_url + path, { method: 'POST', headers: { Authorization: 'Bearer ' + binding.token, 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).json()
+      const catalog: any = await bridge('/tools/list', {})
+      expect(catalog.tools).toHaveLength(1)
+      expect(JSON.stringify(catalog)).not.toContain('fixture-secret')
+      const result: any = await bridge('/tools/call', { toolId: catalog.tools[0].id, arguments: { value: 'native bridge verified' }, callId: randomUUID() })
+      expect(result.structuredContent.value).toBe('native bridge verified')
+      await api('patch', '/mcp/' + plugin.id).send({ enabled: false, revision: 2 }).expect(200)
+      expect(tools.catalog()).toEqual([])
+      const count = requests.length
+      await expect(tools.call(originalToolId, {})).rejects.toMatchObject({ code: 'plugin_grant_revoked' })
+      expect(requests).toHaveLength(count)
+    } finally { controller.abort(); await tools.dispose(); await lease.dispose() }
+    const notGranted = runtime.workspace.createAgent('owner', { name: '未授权 Bot', profile: 'default' })
+    expect(runtime.workspacePlugins.selected('owner', notGranted)).toBe(false)
+  })
+})
+
+describe('account-owned connected applications', () => {
+  it('supports catalog, OAuth links, multiple accounts, Bot grants and scoped disconnect', async () => {
+    await configure(); await configure('member')
+    const ownerSettings = runtime.workspace.list('owner', 'bot-plugin-settings')
+    expect(JSON.stringify(ownerSettings)).not.toContain('ak_fixture')
+    await api('get', '/apps/catalog').expect(200).expect(r => expect(r.body.cards[0].slug).toBe('gmail'))
+    await api('post', '/apps/gmail/authorize').send({}).expect(200)
+    await api('post', '/apps/gmail/authorize', 'member').send({}).expect(200)
+    const first = (await api('get', '/apps')).body.connections[0]
+    await api('post', '/apps/gmail/authorize').send({}).expect(409)
+    await api('post', '/apps/gmail/authorize').send({ alias: '工作' }).expect(200)
+    expect((await api('get', '/apps')).body.connections[0].accounts).toHaveLength(2)
+    const foreign = (await api('get', '/apps', 'member')).body.connections[0].accounts[0].id
+    await api('delete', '/apps/gmail/accounts/' + foreign).expect(404)
+    const agent = runtime.workspace.createAgent('owner', { name: '应用 Bot', profile: 'default' })
+    await api('put', '/apps/gmail/agents').send({ revision: 0, agentIds: [agent.id] }).expect(200)
+    expect(runtime.workspacePlugins.selected('owner', agent)).toBe(true)
+    await api('delete', '/apps/gmail/accounts/' + first.accounts[0].id).expect(200)
+    expect((await api('get', '/apps')).body.connections[0].accounts).toHaveLength(1)
+    expect((await api('get', '/apps', 'member')).body.connections[0].accounts).toHaveLength(1)
+  })
+  it('reports failed inventory reads without falsely reporting all apps disconnected', async () => {
+    await configure(); await api('post', '/apps/gmail/authorize').send({}).expect(200)
+    failedInventory = true
+    const result = await api('get', '/apps')
+    expect(result.status).toBe(502); expect(result.body.connections).toBeUndefined()
+  })
+  it('creates per-Bot toolkit-allowlisted sessions and keeps their URLs and headers server-side', async () => {
+    await configure()
+    const id = randomUUID(), signal = new AbortController().signal
+    const one = await runtime.workspacePlugins.apps.sessionFor('owner', id, ['gmail'], signal)
+    const two = await runtime.workspacePlugins.apps.sessionFor('owner', id, ['gmail'], signal)
+    expect(one.url).toBe(two.url)
+    const created = requests.filter(r => r.url.endsWith('/tool_router/session')).at(-1)!.body
+    expect(created.toolkits).toEqual({ enable: ['gmail'] })
+    expect(created.manage_connections.enable).toBe(false)
+    expect(JSON.stringify(runtime.workspace.list('owner', 'bot-app-session'))).not.toContain(one.url)
+    await api('put', '/settings').send({ revision: 0, apiKey: 'ak_other' }).expect(409)
+  })
+})
+
+it('accepts SSE MCP responses, preserves tool results and disables redirect following', async () => {
+  const options: any[] = []
+  const client = new HttpMcp('https://mcp.example.test/mcp', {}, (async (_url, init) => {
+    options.push(init); const frame = JSON.parse(String(init?.body))
+    if (!frame.id) return new Response(null, { status: 202 })
+    const result = frame.method === 'initialize' ? { protocolVersion: '2025-06-18' } : { content: [{ type: 'text', text: 'SSE result' }], structuredContent: { ok: true } }
+    return new Response('data: ' + JSON.stringify({ jsonrpc: '2.0', id: frame.id, result }) + '\n\n', { headers: { 'content-type': 'text/event-stream', 'mcp-session-id': 'session-fixture' } })
+  }) as typeof fetch)
+  await client.init(); expect((await client.callTool('test', {})).structuredContent).toEqual({ ok: true })
+  expect(options.every(o => o.redirect === 'error')).toBe(true)
+  expect(options.at(-1).headers['Mcp-Session-Id']).toBe('session-fixture')
+  client.dispose()
+})

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini