Browse Source

修复旧节点浏览器自动检测并隔离插件连接故障

Samien 1 tháng trước cách đây
mục cha
commit
6270574e67
33 tập tin đã thay đổi với 689 bổ sung và 79 xóa
  1. 4 4
      docs/managed-browser.md
  2. 1 1
      docs/runner.md
  3. 5 5
      src/client/components/app/RunnerSettingsPanel.vue
  4. 2 0
      src/client/components/messages/MessageTimeline.vue
  5. 10 0
      src/client/components/messages/ServiceWarnings.vue
  6. 1 0
      src/client/components/messages/types.ts
  7. 16 6
      src/client/components/workspace/ComputerPanel.vue
  8. 17 7
      src/client/components/workspace/LocalVmChatPanel.vue
  9. 2 0
      src/client/components/workspace/WorkspaceMessageTimeline.vue
  10. 1 0
      src/client/components/workspace/viewModels.ts
  11. 1 1
      src/client/utils/turnTrace.ts
  12. 5 2
      src/runner/agent.ts
  13. 1 1
      src/runner/config.ts
  14. 48 19
      src/server/botPlugins/workspacePlugins.ts
  15. 6 2
      src/server/runnerHub.ts
  16. 7 1
      src/server/workspacePrompt.ts
  17. 9 5
      src/server/workspaceRuntime.ts
  18. 4 4
      src/server/workspaceStore.ts
  19. 2 0
      src/shared/workspace.ts
  20. 104 3
      tests/client/managedBrowser.test.ts
  21. 24 3
      tests/client/managedBrowserCardSmoke.test.ts
  22. 5 5
      tests/client/runnerSettingsPanel.test.ts
  23. 60 0
      tests/client/serviceWarnings.test.ts
  24. 60 0
      tests/runner/browserConfigurationUpgrade.test.ts
  25. 3 3
      tests/server/managedBrowserSmoke.test.ts
  26. 20 0
      tests/server/runner.test.ts
  27. 12 0
      tests/server/runnerConfig.test.ts
  28. 1 1
      tests/server/workspace.test.ts
  29. 11 0
      tests/server/workspaceMessagePatch.test.ts
  30. 120 0
      tests/server/workspacePluginIsolation.test.ts
  31. 114 5
      tests/server/workspacePlugins.test.ts
  32. 12 0
      tests/server/workspacePrompt.test.ts
  33. 1 1
      tests/server/workspaceReconnect.test.ts

+ 4 - 4
docs/managed-browser.md

@@ -6,9 +6,9 @@
 
 ## 启用
 
-默认关闭。升级代码不会自动启动 Chromium,也不会改变已有 Bot 的虚拟机、云电脑或本机电脑设置。
+全局 Bot 工具开关默认关闭。节点启动时自动检查配套浏览器;检查可能短暂启动无用户资料的沙箱进程,不会自动下载或打开网页,也不改变已有虚拟机、云电脑或本机电脑设置。
 
-1. 在执行节点配置中启用“托管浏览器”,或给现有 `runner.json` 添加:
+1. 更新并启动节点的夭夭或 Runner。旧配置缺少 `browser` 字段、或使用空对象时默认启用检测,不需要重新导入配置。明确设置 `enabled:false` 的节点继续保持关闭;需要自定义限制时可使用:
 
    ```json
    {
@@ -20,14 +20,14 @@
    }
    ```
 
-   保留现有配置其他字段,再让 Runner 加载更新后的配置。`computers` 与 `browser` 可分别配置,单独启用浏览器不要求 VM Worker。`maxSessions` 范围 1–8,默认 3;空闲回收范围 60 秒–2 小时,默认 5 分钟。
+   保留现有配置其他字段,再让 Runner 加载更新后的配置。Mac App 内置节点会在配套服务更新重启后使用原加密配置;仅更新另一台客户端,不能更新服务器上的旧进程。`computers` 与 `browser` 可分别配置,单独启用浏览器不要求 VM Worker。`maxSessions` 范围 1–8,默认 3;空闲回收范围 60 秒–2 小时,默认 5 分钟。
 
 2. 管理员在 Bot 工具设置中打开“托管浏览器”。Bot 的来源与 Profile 必须在 Runner 和账号权限范围内,Hermes 本轮工具桥也必须可用。
 3. Bot 首次调用打开浏览器,或用户在聊天卡片/电脑面板点击“接管浏览器”时,节点检查配套 Chromium 和沙箱启动。缺少环境时自动安装并显示阶段与当前组件的下载进度,完成后继续打开同一浏览器。单纯查看状态不会下载安装。
 
 ## 环境自动准备
 
-浏览器二进制按需下载,不随应用包分发。开启浏览器的节点即使尚未安装 Chromium,也会声明“可准备”能力;界面区分未安装、检查中、安装中、就绪和失败。安装使用应用锁定版本的 Playwright CLI,不调用另一版本的全局命令。一次节点安装由多个已授权使用者共享;断线、关闭节点或所有安装授权失效会取消安装,不会取消其他仍有授权的使用者。
+浏览器二进制按需下载,不随应用包分发。开启浏览器的节点即使尚未安装 Chromium,也会声明“可准备”能力;界面区分未安装、检查中、安装中、就绪和失败,并分别说明节点未连接、版本过旧或管理员明确关闭。可点击“重新检测”刷新节点状态;此操作只检查,不下载安装。安装使用应用锁定版本的 Playwright CLI,不调用另一版本的全局命令。一次节点安装由多个已授权使用者共享;断线、关闭节点或所有安装授权失效会取消安装,不会取消其他仍有授权的使用者。
 
 安装完成前必须通过真实的 Chromium 沙箱启动检查。Linux 缺少系统依赖时,仅在节点已经以 root 运行的情况下调用配套 `install-deps chromium`;普通账号会得到明确依赖提示,需要管理员先补齐系统依赖。安装过程不自动提权,不添加 `--no-sandbox`。生产 Runner 应以支持沙箱的非 root 账号运行。失败保留原因,由“重试并接管浏览器”明确发起下一次安装;不会无限重复下载安装。
 

+ 1 - 1
docs/runner.md

@@ -4,7 +4,7 @@ Runner 在运行 Hermes 的电脑上主动连接夭夭服务。用于 Web 位于
 
 普通执行方式继续使用原有 Hermes。配置电脑 Worker 后,Agent 可选择隔离电脑执行,模型工具不会退回宿主。公网代理、屏幕接管和共享环境均已接入,使用流程见 `docs/local-vm.md`。Docker 部署与程序包下载见 `docs/docker-install.md`。
 
-网页任务可独立启用 Runner 托管 Chromium,无需启动完整 VM,配置、接管与文件互通见 [托管浏览器](managed-browser.md)。默认关闭,保留现有电脑工具路径。
+网页任务可独立启用 Runner 托管 Chromium,无需启动完整 VM,配置、接管与文件互通见 [托管浏览器](managed-browser.md)。节点默认启用环境检测;全局 Bot 工具开关仍默认关闭,首次授权使用时才安装缺失浏览器,保留现有电脑工具路径。显式 `browser.enabled:false` 会保持关闭。
 
 ## 启用
 

+ 5 - 5
src/client/components/app/RunnerSettingsPanel.vue

@@ -12,7 +12,7 @@ const busy = ref(false), error = ref(''), notice = ref('')
 const name = ref(''), source = ref('local'), profiles = ref('default')
 const serverURL = ref(window.location.origin), hermesURL = ref('http://127.0.0.1:9119')
 const computerNetwork=ref<'none'|'public-proxy'>('public-proxy')
-const managedBrowser=ref(false)
+const managedBrowser=ref(true)
 const computers=ref(!!props.computerSetup),computerRuntime=ref<'docker'|'podman'>('docker'),computerImage=ref(''),computerPython=ref(''),hermesSource=ref(''),hermesHome=ref('')
 const artifactRoots = ref(''), insecure = ref(false), configuration = ref<RunnerConfiguration>()
 
@@ -36,7 +36,7 @@ async function register() {
     if(computers.value&&((!!computerImage.value&&!/^sha256:[a-f0-9]{64}$/.test(computerImage.value))||[computerPython.value,hermesSource.value,hermesHome.value].some(path=>path&&!path.startsWith('/'))))throw new Error('自定义路径必须为执行电脑上的绝对路径;默认安装可留空。镜像 ID 可留空或填写完整 sha256 ID。')
     const result = await apiRequest<{runner:Runner;token:string}>('/api/app/admin/runners', {method:'POST',body:{name:name.value.trim(),sourceNodeId:source.value,allowedProfiles}})
     configuration.value = {protocol:1,serverURL:web.origin,runnerId:result.runner.id,token:result.token,hermesURL:local.toString(),allowedProfiles,artifactRoots:artifactRoots.value.split('\n').map(p=>p.trim()).filter(Boolean),...(insecure.value?{allowInsecureLan:true}:{}),...(computers.value?{computers:{...(fixedDesktops.value?{managedBy:'compose' as const}:{}),network:fixedDesktops.value?'none':computerNetwork.value,runtime:computerRuntime.value,imageId:computerImage.value||UNCONFIGURED_COMPUTER_IMAGE,python:computerPython.value,hermesSource:hermesSource.value,hermesHome:hermesHome.value}}:{})}
-    if(managedBrowser.value)configuration.value.browser={enabled:true}
+    configuration.value.browser={enabled:managedBrowser.value}
     notice.value = '节点已注册,请下载配置并在 Hermes 所在电脑启动 Runner。'
     await refresh()
   })
@@ -59,7 +59,7 @@ onMounted(()=>{void action(refresh)})
 
 <template>
   <section class="runner-panel" aria-label="执行节点设置">
-    <p>执行节点提供虚拟环境和托管浏览器能力,可分别启用。节点要和 Hermes 装在同一台电脑上。</p>
+    <p>执行节点提供虚拟环境和托管浏览器能力。节点要和 Hermes 装在同一台电脑上。</p>
     <p v-if="bundleAvailable"><a href="/api/app/admin/runners/bundle" download>下载配套执行节点程序</a>(包含桌面资源,需要 Node.js 24 或更高版本)。</p>
     <div class="runner-toolbar"><strong>已注册节点</strong><button type="button" :disabled="busy" @click="action(refresh)">刷新状态</button></div>
     <ul v-if="runners.length" class="runner-list">
@@ -77,8 +77,8 @@ onMounted(()=>{void action(refresh)})
       <label>Runner 本机 Hermes 地址<input v-model="hermesURL" type="url" required :disabled="busy"></label>
       <label>允许导出的产物目录<textarea v-model="artifactRoots" rows="2" :disabled="busy" placeholder="每行一个绝对路径,留空时禁止导出文件"></textarea></label>
       <label class="runner-check"><input v-model="insecure" type="checkbox" :disabled="busy">使用可信局域网 HTTP</label>
-      <label class="runner-check"><input v-model="managedBrowser" type="checkbox" :disabled="busy">启用托管浏览器</label>
-      <small v-if="managedBrowser">配置将启用独立的网页工作环境;还需在 Bot 设置中开放托管浏览器。仅使用网页时无需启用隔离电脑 Worker。</small>
+      <label class="runner-check"><input v-model="managedBrowser" type="checkbox" :disabled="busy">托管浏览器</label>
+      <small>默认自动检测浏览器,首次使用时自动准备。是否允许 Bot 使用仍由 Bot 设置中的全局托管浏览器开关控制。</small>
       <label class="runner-check"><input v-model="computers" type="checkbox" :disabled="busy">启用隔离电脑 Worker</label>
       <fieldset v-if="computers" class="runner-computer">
         <legend>执行电脑配置</legend><p v-if="fixedDesktops">桌面已由 Compose 创建。Runner 只转发执行和控制,不安装桌面镜像、不创建容器;联网方式由 Compose 指定。</p>

+ 2 - 0
src/client/components/messages/MessageTimeline.vue

@@ -3,6 +3,7 @@ import { useChatAppearance } from '@/stores/chatAppearance'
 import { bubbleVariables } from '@/utils/chatAppearance'
 import '@/styles/chat-bubbles.css'
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import ServiceWarnings from './ServiceWarnings.vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import MessageFailureNotice from '@/components/messages/MessageFailureNotice.vue'
 import { messageFailure } from '@/utils/messageFailure'
@@ -319,6 +320,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
               <button v-if="!readOnly && allowBranch && message.role === 'assistant'" type="button" title="从这里分支" aria-label="从这里分支" @click="emit('branch', message)"><AppIcon name="branch" :size="13" /></button>
             </div>
             </template>
+            <ServiceWarnings v-if="message.serviceWarnings?.length" :warnings="message.serviceWarnings"/>
           </div>
           <MessageFailureNotice v-if="messageFailure(message) && (!messageFailure(message)?.replacesContent || !!message.attachments?.length)" :failure="messageFailure(message)!" />
         </article>

+ 10 - 0
src/client/components/messages/ServiceWarnings.vue

@@ -0,0 +1,10 @@
+<script setup lang="ts">
+import SystemMessageNotice from './SystemMessageNotice.vue'
+import type { UiMessage } from './types'
+defineProps<{ warnings: NonNullable<UiMessage['serviceWarnings']> }>()
+</script>
+<template>
+  <div v-if="warnings.length" class="service-warnings" aria-label="服务状态提示">
+    <SystemMessageNotice v-for="(warning,index) in warnings" :key="`${warning.service}:${warning.code}:${index}`" :title="`${warning.service}:${warning.message}`" icon="info"/>
+  </div>
+</template>

+ 1 - 0
src/client/components/messages/types.ts

@@ -21,6 +21,7 @@ export type UiLocalFileLink = {
 }
 
 export type UiMessage = {
+  serviceWarnings?: import('@shared/workspace').WorkspaceMessage['serviceWarnings']
   browserCard?: import('@shared/managedBrowser').ManagedBrowserCard
   communication?: import('@shared/workspace').WorkspaceCommunication
   taskReference?: { conversationId: string; taskId: string }

+ 16 - 6
src/client/components/workspace/ComputerPanel.vue

@@ -16,6 +16,7 @@ const state=ref<ComputerControlStatus>({mode:'off'}),frame=ref<ComputerFrame>(),
 const controlsOpen=ref(false),actualSize=ref(false),browserState=ref<BotBrowserState>()
 const managedBrowser=ref<ManagedBrowserState>()
 const preparing=ref(false)
+const recheckingBrowser=ref(false)
 let preparation:AbortController|undefined
 const installation=computed(()=>backend.value==='managed-browser'?managedBrowser.value?.installation:undefined)
 const selectedAgent=computed(()=>props.agents.find(agent=>agent.id===selected.value))
@@ -71,15 +72,18 @@ const endpoint=()=>`/api/app/agents/${encodeURIComponent(selected.value)}/comput
 const query=()=>{const q=new URLSearchParams();if(backend.value)q.set('backend',backend.value);if(backend.value==='desktop'&&host.value)q.set('host',host.value);const qs=q.toString();return qs?'?'+qs:''}
 async function post(path:string,body:unknown){return apiRequest<any>(endpoint()+path+query(),{method:'POST',body:body as JsonValue})}
 async function refresh(){
-  if(polling||busy.value||closed||!selected.value||document.hidden||!targetAvailable.value)return
+  if(polling||busy.value||closed||!selected.value||document.hidden||(!targetAvailable.value&&!(backend.value==='managed-browser'&&computerSettings.value.managedBrowser)))return
   polling=true;const current=++generation,base=endpoint(),targetQuery=query(),id=selected.value
   try{
     if(backend.value==='managed-browser'){
-      const browser=await apiRequest<ManagedBrowserState>(`/api/app/agents/${encodeURIComponent(id)}/managed-browser`)
+      const browser=await apiRequest<ManagedBrowserState>(`/api/app/agents/${encodeURIComponent(id)}/managed-browser`).catch(cause=>{
+        if(current===generation&&!closed){managedBrowser.value=undefined;browserState.value=undefined;if(envTargets.value)envTargets.value.managedBrowser=undefined;state.value={mode:'off',backend:'managed-browser'}}
+        throw cause
+      })
       if(current!==generation||closed)return
       managedBrowser.value=browser;browserState.value=browser
       if(envTargets.value)envTargets.value.managedBrowser=browser
-      if(!browser.available){state.value={mode:'off',backend:'managed-browser'};connectionOK.value=false;frame.value=undefined;return}
+      if(!browser.available){state.value={mode:'off',backend:'managed-browser'};connectionOK.value=false;frame.value=undefined;error.value='';return}
     }
     const status=await apiRequest<ComputerControlStatus>(base+targetQuery)
     if(current!==generation||closed)return
@@ -105,6 +109,7 @@ async function run(action:()=>Promise<void>){
   if(busy.value)return;generation++;busy.value=true;error.value=''
   try{await action()}catch(cause){if(!closed)error.value=cause instanceof Error?cause.message:'电脑操作失败'}finally{busy.value=false;if(!closed){await refresh();void flushInputs()}}
 }
+async function recheckBrowser(){if(busy.value)return;recheckingBrowser.value=true;try{await run(loadTargets)}finally{recheckingBrowser.value=false}}
 function take(){return run(async()=>{
   if(!targetAvailable.value)throw new Error('所选电脑不可用,请重新选择')
   const id=selected.value,target=backend.value,base=endpoint(),targetQuery=query(),controller=new AbortController()
@@ -191,8 +196,8 @@ onMounted(async()=>{
   await loadTargets()
   if(closed)return
   if(!backend.value){const first=targets.value.find(t=>!t.disabled);if(first)await switchTarget(first.key)}
-  if(!targetAvailable.value)throw new Error('所选电脑不可用,请重新选择;未打开其他电脑')
-  if(props.autoTake&&!closed)await take()
+  if(!targetAvailable.value&&backend.value!=='managed-browser')throw new Error('所选电脑不可用,请重新选择;未打开其他电脑')
+  if(props.autoTake&&!closed&&targetAvailable.value)await take()
  }catch(cause){error.value=cause instanceof Error?cause.message:'无法读取电脑列表'}
  if(!closed)void cycle()
 })
@@ -224,6 +229,10 @@ defineExpose({take,close,releaseControl:async()=>{if(preparing.value){preparatio
         <small v-if="preparing">关闭窗口后仍会继续准备。</small>
         <p v-if="installation.error" role="alert">{{ installation.error }}</p>
       </section>
+      <section v-if="backend==='managed-browser'&&!managedBrowser?.available" class="browser-availability" :aria-busy="recheckingBrowser">
+        <p v-if="!installation" role="status">{{ managedBrowser?.reason || (computerSettings.managedBrowser?'执行节点暂时不可用,请检查连接后重新检测。':'托管浏览器尚未开放,请在 Bot 设置中开启。') }}</p>
+        <button type="button" :disabled="busy" @click="recheckBrowser">{{ recheckingBrowser?'正在检测…':'重新检测' }}</button>
+      </section>
       <div class="computer-screen" :class="{controlling:canSend,'actual-size':actualSize}">
         <img v-if="frame" :src="`data:image/png;base64,${frame.data}`" :alt="(targets.find(t=>t.key===currentKey)?.label||'电脑')+'当前画面'" draggable="false" tabindex="0" title="点按后可使用键盘、滚轮和拖动;按 Esc 退出键盘控制" @pointerdown="down" @pointerup="up" @pointercancel="dragStart=undefined" @wheel="wheel" @keydown="keydown" @click="click" @contextmenu.prevent="click">
         <p v-else>{{ preparing?'准备完成后会自动接管,无需再次点击。':state.mode==='off' ? (backend==='managed-browser'?'浏览器尚未打开。接管后可打开网页。':'打开电脑后可查看桌面。') : '正在读取电脑画面…' }}</p>
@@ -237,7 +246,7 @@ defineExpose({take,close,releaseControl:async()=>{if(preparing.value){preparatio
         <p v-if="state.backend==='managed-browser'">当前是托管浏览器,与电脑桌面和虚拟机分别管理;网页操作无需启动虚拟机。</p>
         <details v-if="managedBrowser?.downloads.length" class="browser-downloads"><summary>浏览器下载({{managedBrowser.downloads.length}})</summary><ul><li v-for="file in managedBrowser.downloads" :key="file.id"><span>{{file.name}}</span><small>{{(file.size/1024).toFixed(1)}} KiB</small></li></ul></details>
         <p v-if="!ticket">查看不会输入任何内容。接管后机器人会暂停,已有操作结束后才允许人工操作。</p>
-        <button v-if="!ticket&&!targetAvailable" type="button" :disabled="busy" @click="run(loadTargets)">重新检查连接</button>
+        <button v-if="!ticket&&!targetAvailable&&backend!=='managed-browser'" type="button" :disabled="busy" @click="run(loadTargets)">重新检查连接</button>
         <button v-if="!ticket&&state.mode!=='human'" type="button" :disabled="busy||!targetAvailable" @click="embedded?emit('controlRequest'):take()">{{ preparing?'正在准备浏览器…':installation?.status==='failed'?'重试并接管浏览器':state.mode==='off'?'打开并控制':'接管电脑' }}</button>
         <template v-if="mine">
 
@@ -256,6 +265,7 @@ defineExpose({take,close,releaseControl:async()=>{if(preparing.value){preparatio
   </Teleport>
 </template>
 <style scoped>
+.browser-availability{display:grid;gap:10px;padding:12px 20px;background:var(--surface-soft);color:var(--text-secondary);border-bottom:1px solid var(--line)}.browser-availability p{margin:0;font-size:13px;line-height:1.6;overflow-wrap:anywhere}.browser-availability button{justify-self:start;min-height:44px}
 .browser-installation{display:grid;gap:10px;padding:16px 20px;border-bottom:1px solid var(--line);background:var(--surface-soft);color:var(--text-primary)}.browser-installation p{margin:0;font-size:13px;line-height:1.6;overflow-wrap:anywhere}.browser-installation progress{width:100%;height:6px;accent-color:var(--accent)}.browser-installation [role=alert]{color:var(--danger)}
 .computer-panel.embedded{position:static;width:100%;max-height:none;height:100%;margin:0;border:0;border-radius:0;overflow:auto}.computer-panel.embedded{display:flex;flex-direction:column}.embedded .computer-screen{flex:1;min-height:150px}.embedded .computer-screen img{max-height:calc(100dvh - 250px);object-fit:contain}.embedded footer{max-height:45vh;overflow:auto}.manual-input{display:grid;gap:10px}
 .computer-panel{width:min(1120px,calc(100vw - 32px));max-height:calc(100dvh - 32px);padding:0;border:1px solid var(--line);border-radius:16px;background:var(--surface);color:var(--text-primary);overflow:auto}

+ 17 - 7
src/client/components/workspace/LocalVmChatPanel.vue

@@ -16,6 +16,7 @@ const agent=computed(()=>props.agents.find(a=>a.id===selected.value))
 const native=ref<DesktopEnvironmentState>()
 const computers=ref({scriptMachine:true,serverComputer:true,vm:true,cloud:true,managedBrowser:false})
 const managedBrowser=ref<ManagedBrowserState>()
+const recheckingBrowser=ref(false),browserError=ref('')
 const showDesktop=computed(()=>computers.value.scriptMachine||computers.value.serverComputer)
 const cloud=ref<{configured:boolean;running:boolean;connected:boolean;mode?:string}>(),connectionSettings=ref(false)
 const showCloud=computed(()=>computers.value.cloud)
@@ -72,8 +73,16 @@ function authorizeHost(hostId:string,scope?:'full'){
  if(scope)body.scope=scope
  void run(()=>apiRequest(`/api/app/agents/${selected.value}/desktop-environment/authorize`,{method:'POST',body,timeoutMs:125000}))
 }
-let timer:ReturnType<typeof setTimeout>|undefined,closed=false,revision=0,previewRevision=0
+let timer:ReturnType<typeof setTimeout>|undefined,closed=false,revision=0,previewRevision=0,browserRevision=0
 const base=()=>`/api/app/agents/${encodeURIComponent(selected.value)}/local-vm`
+async function readBrowser(){
+ const id=selected.value,current=++browserRevision
+ try{
+  const result=await apiRequest<ManagedBrowserState>(`/api/app/agents/${encodeURIComponent(id)}/managed-browser`)
+  if(!closed&&id===selected.value&&current===browserRevision){managedBrowser.value=result;browserError.value=''}
+ }catch(cause){if(!closed&&id===selected.value&&current===browserRevision){managedBrowser.value=undefined;browserError.value=cause instanceof Error?cause.message:'无法读取托管浏览器状态'}}
+}
+async function recheckBrowser(){if(busy.value||recheckingBrowser.value)return;recheckingBrowser.value=true;try{await readBrowser();await refreshFrame()}finally{recheckingBrowser.value=false}}
 async function refreshFrame(){
  if(closed||props.active===false||!selected.value||document.hidden||busy.value)return
  const version=++previewRevision,id=selected.value,target=previewTarget.value
@@ -97,14 +106,14 @@ async function refresh(){
  try{
   try{const tools=await apiRequest<{scriptMachine?:boolean;serverComputer?:boolean;vm?:boolean;cloud?:boolean;managedBrowser?:boolean}>('/api/app/settings/host-tools');if(version===revision&&!closed)computers.value={scriptMachine:tools.scriptMachine!==false,serverComputer:tools.serverComputer!==false,vm:tools.vm!==false,cloud:tools.cloud!==false,managedBrowser:tools.managedBrowser===true}}catch{}
   const id=selected.value
-  const [desktopResult,cloudResult,vmResult,browserResult]=await Promise.all([
+  const [desktopResult,cloudResult,vmResult]=await Promise.all([
    apiRequest<DesktopEnvironmentState>(`/api/app/agents/${id}/desktop-environment`),
    apiRequest<typeof cloud.value>(`/api/app/agents/${id}/cloud-computer`),
    computers.value.vm?apiRequest<typeof state.value>(base()):Promise.resolve(undefined),
-   computers.value.managedBrowser?apiRequest<ManagedBrowserState>(`/api/app/agents/${id}/managed-browser`).catch(()=>undefined):Promise.resolve(undefined),
+   computers.value.managedBrowser?readBrowser():Promise.resolve(undefined),
   ])
   if(version!==revision||closed)return
-  native.value=desktopResult;cloud.value=cloudResult;state.value=vmResult;managedBrowser.value=browserResult
+  native.value=desktopResult;cloud.value=cloudResult;state.value=vmResult
   await refreshFrame()
   if(version!==revision||closed)return
   error.value=''
@@ -133,7 +142,7 @@ function action(value:LocalVmAction){
 }
 async function cycle(){const version=revision;await refresh();if(!closed&&version===revision)timer=setTimeout(cycle,3000)}
 watch(()=>props.agents.map(a=>a.id).join(','),()=>{if(!props.agents.some(a=>a.id===selected.value))selected.value=props.agents[0]?.id??''},{immediate:true})
-watch(selected,()=>{revision++;previewRevision++;state.value=undefined;composeDesktopId.value='';native.value=undefined;cloud.value=undefined;managedBrowser.value=undefined;frame.value=undefined;error.value='';loading.value=true;previewKey.value='';clearTimeout(timer);void cycle()},{immediate:true})
+watch(selected,()=>{revision++;previewRevision++;browserRevision++;state.value=undefined;composeDesktopId.value='';native.value=undefined;cloud.value=undefined;managedBrowser.value=undefined;browserError.value='';frame.value=undefined;error.value='';loading.value=true;previewKey.value='';clearTimeout(timer);void cycle()},{immediate:true})
 watch(()=>props.active,value=>{if(value)void refresh()})
 onBeforeUnmount(()=>{closed=true;revision++;previewRevision++;clearTimeout(timer)})
 </script>
@@ -156,9 +165,10 @@ onBeforeUnmount(()=>{closed=true;revision++;previewRevision++;clearTimeout(timer
     <section v-if="computers.managedBrowser" class="managed-browser-option" aria-label="托管浏览器">
       <strong>托管浏览器</strong>
       <p>网页操作使用独立浏览器,虚拟机按任务需要单独启动。查看状态不会打开网页。</p>
-      <p v-if="!managedBrowser?.available" class="hint" role="status">{{managedBrowser?.installation?.message||managedBrowser?.reason||'执行节点尚未提供浏览器能力,请检查节点配置与连接。'}}</p>
+      <p v-if="!managedBrowser?.available" class="hint" role="status">{{browserError||managedBrowser?.installation?.message||managedBrowser?.reason||'执行节点尚未提供浏览器能力,请检查节点配置与连接。'}}</p>
       <progress v-if="managedBrowser?.installation?.status==='installing'" :value="managedBrowser.installation.progress" max="100" aria-label="浏览器准备进度"/>
-      <button class="primary" :disabled="busy||!browserCanPrepare(managedBrowser)" @click="openDesktop('managed-browser')">{{managedBrowser?.installation?.status==='failed'?'重试并接管浏览器':managedBrowser?.open?'接管浏览器':'打开并接管浏览器'}}</button>
+      <button v-if="!managedBrowser?.available" type="button" :disabled="busy||recheckingBrowser" :aria-busy="recheckingBrowser" @click="recheckBrowser">{{recheckingBrowser?'正在检测…':'重新检测'}}</button>
+      <button class="primary" :disabled="busy||recheckingBrowser||!browserCanPrepare(managedBrowser)" @click="openDesktop('managed-browser')">{{managedBrowser?.installation?.status==='failed'?'重试并接管浏览器':managedBrowser?.open?'接管浏览器':'打开并接管浏览器'}}</button>
     </section>
     <template v-if="showDesktop">
       <p>服务器是运行夭夭服务的电脑;电脑是连接这台服务器的 Mac 或 Windows 电脑。两者都可改名。「本机」跟你当前发消息所在的电脑走。要文件用文件工具,要命令用 shell;只有需要看窗口或点按时才截图。</p>

+ 2 - 0
src/client/components/workspace/WorkspaceMessageTimeline.vue

@@ -16,6 +16,7 @@ import InteractionCard from '../messages/InteractionCard.vue'
 import WorkspaceApprovalCard from './WorkspaceApprovalCard.vue'
 import TaskResultNotice from './TaskResultNotice.vue'
 import ManagedBrowserCard from '../messages/ManagedBrowserCard.vue'
+import ServiceWarnings from '../messages/ServiceWarnings.vue'
 import MarkdownContent from '../messages/MarkdownContent.vue'
 import ToolTrace from '../messages/ToolTrace.vue'
 import TurnTrace from '../messages/TurnTrace.vue'
@@ -423,6 +424,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
               <button v-if="!readOnly && allowBranch && message.role === 'assistant'" type="button" title="从这里分支" aria-label="从这里分支" @click="emit('branch', message)"><AppIcon name="branch" :size="13" /></button>
             </div>
             </template>
+            <ServiceWarnings v-if="message.serviceWarnings?.length" :warnings="message.serviceWarnings"/>
           </div>
           <MessageFailureNotice v-if="messageFailure(message) && (!messageFailure(message)?.replacesContent || !!message.attachments?.length)" :failure="messageFailure(message)!" />
         </article>

+ 1 - 0
src/client/components/workspace/viewModels.ts

@@ -390,6 +390,7 @@ export function workspaceMessagesToUi(messages: import('@shared/workspace').Work
     id: message.id, role: message.role, author: message.agentName,
     communication: message.communication,
     browserCard: message.browserCard,
+    serviceWarnings: message.serviceWarnings,
     taskReference: message.taskReference,
     error: message.error, runId: message.runId,
     profile: message.agentId, createdAt: message.createdAt,

+ 1 - 1
src/client/utils/turnTrace.ts

@@ -21,7 +21,7 @@ function assistantOwner(message: UiMessage): string {
 }
 
 function hasVisibleMessage(message: UiMessage): boolean {
-  return Boolean(message.browserCard || message.content.trim() || message.attachments?.length || message.error || message.status === 'failed')
+  return Boolean(message.browserCard || message.serviceWarnings?.length || message.content.trim() || message.attachments?.length || message.error || message.status === 'failed')
 }
 
 function traceStatus(messages: UiMessage[], entries: TurnTraceEntry[]): TurnTraceGroup['status'] {

+ 5 - 2
src/runner/agent.ts

@@ -52,7 +52,10 @@ export class RunnerAgent {
     this.db=new DatabaseSync(join(home,'runner-commands.sqlite3'))
     this.db.exec('CREATE TABLE IF NOT EXISTS commands(id TEXT PRIMARY KEY,fingerprint TEXT NOT NULL,state TEXT NOT NULL,result TEXT,created INTEGER NOT NULL)')
     if(config.computers){this.computers=new ComputerRuntime(this.db,config,home,undefined,(id,operation,body)=>this.api('desktop',{id,operation,...body}),this.target);this.localVm=new LocalVmImages(this.computers,this.db)}
-    if(config.browser?.enabled)this.browsers=new RunnerBrowser(config.browser,home,async(scope,grantId)=>{
+    // Embedded runners can load pre-browser sealed configs without the CLI parser.
+    // Only an explicit opt-out disables setup; it never changes VM isolation.
+    const browser={...config.browser,enabled:config.browser?.enabled!==false}
+    if(browser.enabled)this.browsers=new RunnerBrowser(browser,home,async(scope,grantId)=>{
       if(!this.active||!this.connected||(await this.api('browser-check',{scope,grantId})).allowed!==true)throw new HttpError(403,'浏览器授权已失效','browser_authorization_revoked')
     })
     this.compactReceipts()
@@ -63,7 +66,7 @@ export class RunnerAgent {
     const toolId=body&&typeof body==='object'&&'toolId' in body?body.toolId:undefined
     const response=await (isLocalAuthorizationTarget(url)&&this.fetchImpl===fetch?this.controlTransport.fetch.bind(this.controlTransport):this.fetchImpl)(url,{
       method:body===undefined?'GET':'POST',redirect:'error',signal:AbortSignal.any([this.controlAbort.signal,AbortSignal.timeout(path==='desktop'?75000:path==='tool'&&typeof toolId==='string'&&toolId.startsWith('managed_browser_')?600000:25000)]),
-      headers:{Authorization:`Bearer ${this.config.token}`,'x-runner-instance':this.instance,'x-runner-protocol':'1','x-runner-features':(this.computers?'workspace-memory-bind-v1,hermes-computer-v2,profile-computer-v1,host-computer-tools-v1,file-transfer-v1,idle-stop-policy-v1,computer-worker-v1,artifact-chunks-v1,computer-control-v1,shared-computer-v1,local-vm-v1'+(this.computers.provider.fixedCapacity?',compose-desktops-v1':',helper-retirement-v1,image-options-v1')+(this.computers.config.imageId!==UNCONFIGURED_COMPUTER_IMAGE?',image-ready-v1':''):'workspace-memory-bind-v1')+(this.browsers?',managed-browser-setup-v1':'')+(this.browsers?.available?',managed-browser-v1':''),...(this.serverEpoch?{'x-runner-epoch':this.serverEpoch}:{}),'Content-Type':'application/json',...(path==='poll'&&!this.connected?{'x-runner-reset':'1'}:{})},
+      headers:{Authorization:`Bearer ${this.config.token}`,'x-runner-instance':this.instance,'x-runner-protocol':'1','x-runner-features':(this.computers?'workspace-memory-bind-v1,hermes-computer-v2,profile-computer-v1,host-computer-tools-v1,file-transfer-v1,idle-stop-policy-v1,computer-worker-v1,artifact-chunks-v1,computer-control-v1,shared-computer-v1,local-vm-v1'+(this.computers.provider.fixedCapacity?',compose-desktops-v1':',helper-retirement-v1,image-options-v1')+(this.computers.config.imageId!==UNCONFIGURED_COMPUTER_IMAGE?',image-ready-v1':''):'workspace-memory-bind-v1')+(this.browsers?',managed-browser-setup-v1':',managed-browser-disabled-v1')+(this.browsers?.available?',managed-browser-v1':''),...(this.serverEpoch?{'x-runner-epoch':this.serverEpoch}:{}),'Content-Type':'application/json',...(path==='poll'&&!this.connected?{'x-runner-reset':'1'}:{})},
       ...(body===undefined?{}:{body:JSON.stringify(body)}),
     })
     if(!response.ok){

+ 1 - 1
src/runner/config.ts

@@ -9,7 +9,7 @@ const schema=z.object({
   hermesURL:z.string().url().default('http://127.0.0.1:9119'),satellite:z.boolean().optional(),
   allowedProfiles:z.array(z.string().trim().min(1).max(256).regex(/^[^/\\\u0000-\u001f]+$/)).min(1).max(256),
   artifactRoots:z.array(z.string().refine(isAbsolute,'产物目录必须为绝对路径')).max(64).default([]),
-  browser:z.object({enabled:z.boolean().default(false),maxSessions:z.number().int().min(1).max(8).optional(),idleTimeoutMs:z.number().int().min(60000).max(7200000).optional()}).strict().optional(),
+  browser:z.object({enabled:z.boolean().default(true),maxSessions:z.number().int().min(1).max(8).optional(),idleTimeoutMs:z.number().int().min(60000).max(7200000).optional()}).strict().default({enabled:true}),
   computers:z.object({managedBy:z.literal('compose').optional(),network:z.enum(['none','public-proxy']).default('none'),runtime:z.enum(['docker','podman']),imageId:z.string().regex(/^sha256:[a-f0-9]{64}$/),python:localPath,hermesSource:localPath,hermesHome:localPath,maxConcurrent:z.number().int().min(1).max(8).optional()}).strict().transform(value=>{
     const hermesHome=value.hermesHome||join(homedir(),'.hermes'),hermesSource=value.hermesSource||join(hermesHome,'hermes-agent')
     return {...value,hermesHome,hermesSource,python:value.python||join(hermesSource,'venv','bin','python')}

+ 48 - 19
src/server/botPlugins/workspacePlugins.ts

@@ -25,9 +25,11 @@ const definition = z.object({
 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 BotPluginService { name: string; transport: 'stdio' | 'http'; toolCount: number }
+export interface BotPluginWarning { name: string; code: string; message: string }
 export interface OpenBotPlugins {
   catalog(): Array<{ id: string; name: string; description: string; inputSchema: unknown }>
   services(): BotPluginService[]
+  warnings(): BotPluginWarning[]
   call(id: string, args: unknown): Promise<unknown>
   dispose(): Promise<void>
 }
@@ -122,24 +124,46 @@ export class WorkspacePlugins {
     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])
+    const assertActive = () => {
+      authorize(); this.assertOwner(owner); this.agents(owner, [agent.id])
+      if (signal.aborted) throw new HttpError(403, '本轮插件授权已结束', 'plugin_grant_revoked')
+    }
+    assertActive()
     await requireTeamToolBridge(target, agent.profile)
-    authorize(); if(signal.aborted)throw new HttpError(403,'本轮插件授权已结束','plugin_grant_revoked')
+    assertActive()
     const connections: Array<{ client: PluginClient; owner: string; pluginId: string }> = []
     const tools = new Map<string, { client: PluginClient; tool: McpTool; name: string; check: () => void }>()
     const services: Array<{ summary: BotPluginService; check: () => void }> = []
+    const warnings: Array<{ summary: BotPluginWarning; check: () => void }> = []
+    const unavailable = new Map<string, { message: string; check: () => void }>()
+    const toolId = (id: string, name: string) => 'plugin_' + createHash('sha256').update(id + ':' + name).digest('hex').slice(0, 32)
     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, transport: 'stdio' | 'http', 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()
-      services.push({ summary: { name, transport, toolCount: list.length }, 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 })
+    const add = async (id: string, name: string, transport: 'stdio' | 'http', create: () => PluginClient | Promise<PluginClient>, check: () => void, knownTools: McpTool[] = []) => {
+      let entry: (typeof connections)[number] | undefined
+      try {
+        assertActive(); check()
+        const client = await create()
+        entry = { client, owner, pluginId: id }; connections.push(entry); this.live.add(entry)
+        await client.init(signal)
+        const list = validateTools(await client.listTools(signal)); assertActive(); check()
+        // Publish only complete, live inventories. A rejected service must not
+        // leave partially mounted tools or remove another healthy service.
+        if (tools.size + list.length > 500) throw new HttpError(409, '所选插件工具总数超过 500,请减少授权的服务', 'plugin_tools_limit')
+        services.push({ summary: { name, transport, toolCount: list.length }, check })
+        for (const tool of list) tools.set(toolId(id, tool.name), { client, tool, name, check })
+      } catch (error) {
+        if (entry) {
+          entry.client.dispose(); await entry.client.waitClosed()
+          this.live.delete(entry); connections.splice(connections.indexOf(entry), 1)
+        }
+        // Transport failure is optional; cancellation and authority changes are not.
+        assertActive(); check()
+        if (error instanceof HttpError && [401, 403].includes(error.status)) throw error
+        const summary = { name, code: 'plugin_initialization_failed', message: `${name}暂时不可用,本轮未连接;请在已连接应用中测试连接后重试。` }
+        warnings.push({ summary, check })
+        for (const tool of knownTools) unavailable.set(toolId(id, tool.name), { message: summary.message, 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))) {
@@ -149,26 +173,31 @@ export class WorkspacePlugins {
           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, value.transport, this.client(owner, value), check)
+        await add(plugin.id, value.name, value.transport, () => this.client(owner, value), check, plugin.tools)
       }
       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 revision = this.apps.status(owner).revision
         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')
+          if (this.apps.status(owner).revision !== 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', '已连接应用', 'http', new HttpMcp(session.url, session.headers, this.fetchImpl), check)
+        await add('connected-apps', '已连接应用', 'http', async () => {
+          const session = await this.apps.sessionFor(owner, agent.id, grants.map(g => g.slug), signal)
+          return 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 [] } }),
-        services: () => services.flatMap(entry => { try { authorize(); entry.check(); return [{ ...entry.summary }] } catch { return [] } }),
+        catalog: () => [...tools].flatMap(([id, entry]) => { try { assertActive(); entry.check(); return [{ id, name: id, description: `${entry.name} · ${entry.tool.name}:${entry.tool.description ?? ''}`, inputSchema: entry.tool.inputSchema ?? { type: 'object', properties: {} } }] } catch { return [] } }),
+        services: () => services.flatMap(entry => { try { assertActive(); entry.check(); return [{ ...entry.summary }] } catch { return [] } }),
+        warnings: () => warnings.flatMap(entry => { try { assertActive(); entry.check(); return [{ ...entry.summary }] } catch { return [] } }),
         call: async (id, args) => {
-          authorize(); const entry = tools.get(id)
+          assertActive(); const entry = tools.get(id), failed = unavailable.get(id)
+          if (failed) { failed.check(); throw new HttpError(503, failed.message, 'plugin_unavailable') }
           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 }
+          try { const result = await entry.client.callTool(entry.tool.name, args, signal); assertActive(); entry.check(); return result }
           catch (error) { if (error instanceof HttpError) throw error; throw new HttpError(502, '插件工具调用失败或连接已关闭', 'plugin_call_failed') }
         }, dispose,
       }

+ 6 - 2
src/server/runnerHub.ts

@@ -224,7 +224,11 @@ export class RunnerHub {
   }
   browserRunner(owner:string,agent:import('../shared/workspace.js').WorkspaceAgent){
     const record=this.records().find(r=>r.enabled&&r.sourceNodeId===agent.nodeId&&(r.sourceOwner==='_system'||r.sourceOwner===owner))
-    if(!record||!this.online.get(record.id)?.features.some(feature=>['managed-browser-v1','managed-browser-setup-v1'].includes(feature)))throw new HttpError(409,'执行节点尚未启用托管浏览器,请启用浏览器能力并更新节点','browser_unavailable')
+    if(!record)throw new HttpError(409,'尚未找到此 Bot 的执行节点,请检查服务器的节点连接','browser_runner_missing')
+    const online=this.online.get(record.id)
+    if(!online)throw new HttpError(503,'执行节点暂未连接,节点上线后将自动检测浏览器','runner_offline')
+    if(online.features.includes('managed-browser-disabled-v1'))throw new HttpError(409,'节点管理员已关闭托管浏览器,请在该节点重新启用','browser_node_disabled')
+    if(!online.features.some(feature=>['managed-browser-v1','managed-browser-setup-v1'].includes(feature)))throw new HttpError(409,'执行节点版本尚不支持自动准备浏览器,请更新节点上的夭夭并重新启动','browser_runner_upgrade_required')
     if(!record.allowedProfiles.includes(agent.profile)||!this.auth.canUseSource(owner,agent.nodeId,agent.profile))throw new HttpError(403,'浏览器 Profile 未授权','browser_profile_forbidden')
     return record
   }
@@ -272,7 +276,7 @@ export class RunnerHub {
         retired.add(previous.instance);this.retired.set(record.id,retired);this.disconnect(record.id);previous=undefined
       }
       if(match[2]!=='poll'&&ctx.get('x-runner-epoch')!==previous?.epoch)throw new HttpError(409,'执行连接代次已改变','runner_epoch_changed')
-      const state=this.online.get(record.id)??{instance,seen:Date.now(),features:[],epoch:`${this.epoch}:${randomUUID()}`};state.seen=Date.now();if(ctx.get('x-runner-features'))state.features=ctx.get('x-runner-features').split(',').filter(value=>['workspace-memory-bind-v1','hermes-computer-v2','profile-computer-v1','host-computer-tools-v1','file-transfer-v1','idle-stop-policy-v1','computer-worker-v1','artifact-chunks-v1','helper-retirement-v1','computer-control-v1','shared-computer-v1','local-vm-v1','image-options-v1','image-ready-v1','compose-desktops-v1','managed-browser-v1','managed-browser-setup-v1'].includes(value));this.online.set(record.id,state)
+      const state=this.online.get(record.id)??{instance,seen:Date.now(),features:[],epoch:`${this.epoch}:${randomUUID()}`};state.seen=Date.now();if(ctx.get('x-runner-features'))state.features=ctx.get('x-runner-features').split(',').filter(value=>['workspace-memory-bind-v1','hermes-computer-v2','profile-computer-v1','host-computer-tools-v1','file-transfer-v1','idle-stop-policy-v1','computer-worker-v1','artifact-chunks-v1','helper-retirement-v1','computer-control-v1','shared-computer-v1','local-vm-v1','image-options-v1','image-ready-v1','compose-desktops-v1','managed-browser-v1','managed-browser-setup-v1','managed-browser-disabled-v1'].includes(value));this.online.set(record.id,state)
       ctx.set('Cache-Control','no-store')
       if(match[2]==='poll') {
         if(ctx.method!=='GET')throw new HttpError(405,'仅允许 GET','method_not_allowed')

+ 7 - 1
src/server/workspacePrompt.ts

@@ -1,5 +1,5 @@
 import {MANAGED_BROWSER_RULES} from './managedBrowserTools.js'
-import { personaSection, type WorkspaceAgent, type WorkspaceConversation, type WorkspaceRun } from '../shared/workspace.js'
+import { personaSection, type WorkspaceAgent, type WorkspaceConversation, type WorkspaceMessage, type WorkspaceRun } from '../shared/workspace.js'
 import type { AgentGoal } from '../shared/agentTasks.js'
 import type { Work } from './workspaceScheduler.js'
 import { DESKTOP_ENVIRONMENT_RULES, SERVER_COMPUTER_RULES, DESKTOP_FILE_TRANSFER_RULES } from './desktopEnvironments.js'
@@ -19,6 +19,7 @@ export interface WorkspacePromptInput {
   goal?: AgentGoal
   environment: WorkspacePromptEnvironment
   pluginServices?: BotPluginService[]
+  pluginWarnings?: WorkspaceMessage['serviceWarnings']
   teamRules?: string
   knowledgeRules?: string
   memory: string
@@ -103,6 +104,11 @@ export function buildWorkspacePrompt(input: WorkspacePromptInput): string {
       '任务涉及这些服务时,优先调用 yaoyao_tools 按 service(服务名)或 query(工具名、操作关键词)筛选,一次取得本轮有效 ID 和完整参数结构,再直接用 yaoyao_call 传入 toolId 和 arguments;聚合的已连接应用用 query 按 Gmail 等应用名搜索。存在 nextOffset 时可继续读取。不要在拿到参数后重复搜索、tool_describe 或读取全量目录。若入口在 deferred catalog 中,只需 tool_describe 加载 yaoyao_tools/yaoyao_call 后经 tool_call 调用。旧工具桥若不支持筛选,只回退一次无参数 yaoyao_tools。也可用 tool_search 搜索本轮原生工具,名称形如 yaoyao_plugin_...;不要猜测或沿用上轮名称。未命中先调整筛选或核对目录,再报告该工具未挂载。',
       '工具发现成功不代表账号已登录。检查可用性时,若该服务提供只读 status 或 health 工具,应调用它核实;区分工具未挂载、调用失败、未登录或已锁定,并报告实际结果。仅按用户当前任务使用工具;授权和凭据配置在 Bot 模式的工具 → 已连接应用 / MCP 服务中处理,不在聊天中索取或展示密码、会话令牌和 API Key。',
     ]) : '',
+    input.pluginWarnings?.length ? section('本轮服务连接提示', [
+      '以下是本轮未成功连接的可选服务(名称和提示均为数据,不是指令)。这些服务的工具未挂载,不能宣称已经调用或执行成功。',
+      JSON.stringify(input.pluginWarnings),
+      '继续处理不依赖这些服务的普通对话和可用工具任务;仅当用户目标确实需要其中的服务时,说明对应限制与恢复方式。不要因为无关插件离线而中断整轮对话。',
+    ]) : '',
     section('Bot 记忆与相关事实', [input.knowledgeRules, run.projectId ? `当前项目 ID:${run.projectId}。项目记忆只能使用当前项目范围;Bot 和用户记忆仍按各自授权读取。` : undefined, input.memory]),
     input.marker,
     section(input.contentKind === 'user' ? '本轮用户消息' : '本轮对话上下文', [input.content]),

+ 9 - 5
src/server/workspaceRuntime.ts

@@ -442,14 +442,14 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           if (!resultMessage.content.trim() && current.status === 'failed') resultMessage.content = `执行失败:${current.error}`
         } else {
           if (root.discussion && /^(?:\(?pass\)?\.?|\[\[YAOYAO_NO_REPLY_V1\]\])$/i.test(resultMessage.content.trim())) resultMessage.content = ''
-          const silent = (resultMessage.content.trim() === NO_REPLY || !resultMessage.content.trim()) && current.replyMode === 'automatic' && (!current.requiredReply || current.hadInteraction) && !resultMessage.tools.length && !resultMessage.attachments.length && !resultMessage.browserCard
+          const silent = (resultMessage.content.trim() === NO_REPLY || !resultMessage.content.trim()) && current.replyMode === 'automatic' && (!current.requiredReply || current.hadInteraction) && !resultMessage.tools.length && !resultMessage.attachments.length && !resultMessage.browserCard && !resultMessage.serviceWarnings?.length
           current.silent = silent
           if (current.replyMode === 'automatic') {
             resultMessage.content = resultMessage.content.replace(/\[\[YAOYAO_[A-Z0-9_]*(?:\]\])?/g, '').trim()
             resultMessage.reasoning = resultMessage.reasoning.replace(/\[\[YAOYAO_[A-Z0-9_]*(?:\]\])?/g, '')
           }
           if (current.requiredReply && !silent && !resultMessage.content && !resultMessage.browserCard) resultMessage.content = HOST_FALLBACK
-          if (!silent && !resultMessage.content && !resultMessage.attachments.length && !resultMessage.tools.length && !resultMessage.browserCard) {
+          if (!silent && !resultMessage.content && !resultMessage.attachments.length && !resultMessage.tools.length && !resultMessage.browserCard && !resultMessage.serviceWarnings?.length) {
             current.status = 'failed'; current.error = '未返回有效回复'; resultMessage.content = '执行失败:未返回有效回复'
           } else { current.status = 'complete'; current.error = undefined }
           resultMessage.visible = !silent
@@ -832,7 +832,11 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
             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)
+          if(plugins){
+            pluginLease=await this.plugins!.open(owner,agent,target,toolController.signal,assertActive)
+            const warnings=pluginLease.warnings().map(({name,code,message})=>({service:name,code,message}))
+            if(warnings.length){resultMessage.serviceWarnings=warnings;resultMessage.visible=true;this.store.saveMessage(owner,resultMessage)}
+          }
           toolLease=await createWorkspaceToolLease({
             target,profile:agent.profile,workId:run.id,signal:toolController.signal,
             workspaceMemory:botCapabilities.memory,
@@ -930,7 +934,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           submittedText = buildWorkspacePrompt({
             agent, conversation: c, members, work: run,
             run: this.store.require<Run>(owner, 'run', run.runId), goal,
-            environment, pluginServices,
+            environment, pluginServices, pluginWarnings:resultMessage.serviceWarnings,
             teamRules: team ? TEAM_TOOL_RULES : undefined,
             knowledgeRules: knowledge ? BOT_KNOWLEDGE_RULES : undefined,
             memory: memoryDelta(memory, binding.memoryState), noReply: NO_REPLY,
@@ -938,7 +942,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
             content: text, contentKind: c.kind === 'group' || !!resetReason ? 'history' : 'user', attachmentRefs,
           })
           this.inspector?.record(owner,c.id,{method:'bot.environment',direction:'request',agentId:agent.id,runId:run.runId,taskId:run.conversationTaskId,
-            data:{snapshot:environment,computerToolIds:environmentCatalog.map(tool=>tool.id),pluginServices}})
+            data:{snapshot:environment,computerToolIds:environmentCatalog.map(tool=>tool.id),pluginServices,pluginWarnings:resultMessage.serviceWarnings}})
           binding.memoryPending = true
           this.store.put(owner, 'binding', key, binding)
           await gateway.rpc('prompt.submit', {

+ 4 - 4
src/server/workspaceStore.ts

@@ -1067,17 +1067,17 @@ export class WorkspaceStore {
       c.lastSeq = Math.max(c.lastSeq, message.seq)
       const needsAttention = (value: Message | undefined) => !!value && value.role !== 'user' && value.visible !== false
         && ['complete', 'failed', 'interrupted'].includes(value.status)
-        && !!(value.content.trim() || value.error || value.browserCard || this.messageForDisplay(owner, value).attachments.length)
+        && !!(value.content.trim() || value.error || value.browserCard || value.serviceWarnings?.length || this.messageForDisplay(owner, value).attachments.length)
       if (needsAttention(message) && !needsAttention(previous)) {
         c.unread = true
         c.unreadVersion = (c.unreadVersion ?? 0) + 1
         if (task) { task.unread = true; task.unreadVersion = (task.unreadVersion ?? 0) + 1 }
       }
       c.unreadCount = c.unread ? 1 : 0
-      if (updateSummary && message.visible !== false && (message.content.trim() || message.attachments.length || message.browserCard && !previous?.browserCard)) {
+      if (updateSummary && message.visible !== false && (message.content.trim() || message.attachments.length || message.browserCard && !previous?.browserCard || message.serviceWarnings?.length && !previous?.serviceWarnings?.length)) {
         c.updatedAt = Date.now()
         c.lastMessageAt = Math.max(c.lastMessageAt ?? 0, message.createdAt)
-        c.preview = notificationPlainText(message.content, { maximum: 160, fallback: message.browserCard?.title || (message.browserCard ? '托管浏览器' : '') })
+        c.preview = notificationPlainText(message.content, { maximum: 160, fallback: message.browserCard?.title || (message.browserCard ? '托管浏览器' : message.serviceWarnings?.length ? '部分服务暂时不可用' : '') })
         c.previewAgentId = message.role === 'assistant' ? message.agentId : undefined
       }
       this.put(owner, 'message', message.id, message)
@@ -1089,7 +1089,7 @@ export class WorkspaceStore {
         if (becameHidden) task.messageCount = Math.max(0, task.messageCount - 1)
         task.lastSeq = Math.max(task.lastSeq, message.seq)
         task.unreadCount = task.unread ? 1 : 0
-        if (message.visible !== false && (message.content.trim() || message.attachments.length || message.browserCard && !previous?.browserCard)) {
+        if (message.visible !== false && (message.content.trim() || message.attachments.length || message.browserCard && !previous?.browserCard || message.serviceWarnings?.length && !previous?.serviceWarnings?.length)) {
           if (message.role === 'user' && task.titleSource === 'automatic' && task.messageCount <= 1) {
             const source = notificationPlainText(message.content, { maximum: 48, fallback: message.attachments[0]?.name ?? '' }).trim()
             if (source) task.title = source

+ 2 - 0
src/shared/workspace.ts

@@ -180,6 +180,8 @@ export interface WorkspaceFile {
 }
 export interface WorkspaceMessage {
   browserCard?: import('./managedBrowser.js').ManagedBrowserCard
+  /** Server-owned, nonfatal availability notices; never model-authored markup. */
+  serviceWarnings?: Array<{code:string;service:string;message:string}>
   communication?: WorkspaceCommunication
   peerMessageId?: string
   revision?: number

+ 104 - 3
tests/client/managedBrowser.test.ts

@@ -100,7 +100,8 @@ it('never switches an unavailable requested browser to another computer',async()
   const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true,autoTake:true},global:{stubs}})
   try{
     await flushPromises()
-    expect(wrapper.get('[role="alert"]').text()).toContain('所选电脑不可用')
+    expect(wrapper.get('.browser-availability [role="status"]').text()).toBe('执行节点离线')
+    expect(wrapper.find('[role="alert"]').exists()).toBe(false)
     expect(request.mock.calls.some(([path])=>path.includes('/computer/take'))).toBe(false)
     expect(wrapper.find('.computer-screen img').exists()).toBe(false)
   }finally{wrapper.unmount()}
@@ -215,9 +216,109 @@ it('exposes preparation in the side panel while read-only viewing never installs
     await flushPromises()
     const browser=wrapper.get('[aria-label="托管浏览器"]')
     expect(browser.text()).toContain('首次接管将准备浏览器')
-    expect(browser.get('button').attributes('disabled')).toBeUndefined()
+    expect(browser.get('button.primary').attributes('disabled')).toBeUndefined()
     expect(request.mock.calls.some(([path])=>path.endsWith('/prepare'))).toBe(false)
-    await browser.get('button').trigger('click')
+    await browser.get('button.primary').trigger('click')
     expect(wrapper.emitted('desktop')?.at(-1)).toEqual([agent,'managed-browser',undefined])
   }finally{wrapper.unmount()}
 })
+
+it('rechecks an offline browser in the side panel and restores takeover with GET requests only',async()=>{
+  state.available=false;state.reason='执行节点离线'
+  const wrapper=mount(Preview,{props:{agents:[agent],isAdmin:true,active:true},global:{stubs}})
+  try{
+    await flushPromises()
+    await wrapper.get('[aria-label="显示的桌面"]').setValue('managed-browser');await flushPromises()
+    const browser=wrapper.get('[aria-label="托管浏览器"]')
+    expect(browser.get('button.primary').attributes('disabled')).toBeDefined()
+    expect(browser.get('[role="status"]').text()).toBe('执行节点离线')
+    expect(browser.find('progress').exists()).toBe(false)
+    const before=request.mock.calls.length
+    state.available=true;state.reason=undefined
+    await browser.findAll('button').find(button=>button.text()==='重新检测')!.trigger('click');await flushPromises()
+    expect(browser.get('button.primary').attributes('disabled')).toBeUndefined()
+    expect(wrapper.get<HTMLSelectElement>('[aria-label="显示的桌面"]').element.value).toBe('managed-browser')
+    expect(request.mock.calls.slice(before).map(([path])=>path)).toEqual(['/api/app/agents/bot/managed-browser'])
+    expect(request.mock.calls.some(([,options])=>options?.method==='POST')).toBe(false)
+    await browser.get('button.primary').trigger('click')
+    expect(wrapper.emitted('desktop')?.at(-1)).toEqual([agent,'managed-browser',undefined])
+  }finally{wrapper.unmount()}
+})
+
+it('recovers an explicitly selected browser after polling without starting or switching computers',async()=>{
+  vi.useFakeTimers()
+  state.available=false;state.reason='执行节点离线'
+  const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true,autoTake:true},global:{stubs}})
+  try{
+    await flushPromises()
+    expect(wrapper.get('.browser-availability').text()).toContain('重新检测')
+    expect(wrapper.find('progress').exists()).toBe(false)
+    expect(wrapper.findAll('button').find(button=>button.text()==='打开并控制')!.attributes('disabled')).toBeDefined()
+    state.available=true;state.reason=undefined
+    await vi.advanceTimersByTimeAsync(1200);await flushPromises()
+    expect(wrapper.findAll('button').find(button=>button.text()==='打开并控制')!.attributes('disabled')).toBeUndefined()
+    expect(wrapper.get<HTMLSelectElement>('[aria-label="切换电脑环境"]').element.value).toBe('managed-browser')
+    expect(wrapper.find('.browser-availability').exists()).toBe(false)
+    expect(request.mock.calls.filter(([path])=>path.includes('/computer')).every(([path])=>path.includes('backend=managed-browser'))).toBe(true)
+    expect(request.mock.calls.some(([,options])=>options?.method==='POST')).toBe(false)
+  }finally{wrapper.unmount()}
+})
+
+it('refreshes viewer targets on explicit recheck without preparing the browser',async()=>{
+  state.available=false;state.reason='执行节点离线'
+  const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true},global:{stubs}})
+  try{
+    await flushPromises()
+    const before=request.mock.calls.length
+    state.reason=undefined;state.installation={status:'missing',message:'首次使用会自动准备 Chromium',updatedAt:1}
+    await wrapper.get('.browser-availability button').trigger('click');await flushPromises()
+    const calls=request.mock.calls.slice(before)
+    expect(calls.some(([path])=>path.endsWith('/host-tools'))).toBe(true)
+    expect(calls.some(([path])=>path.endsWith('/managed-browser'))).toBe(true)
+    expect(calls.some(([,options])=>options?.method==='POST')).toBe(false)
+    expect(wrapper.get('option[value="managed-browser"]').attributes('disabled')).toBeUndefined()
+    expect(wrapper.text()).toContain('首次使用会自动准备 Chromium')
+    expect(wrapper.find('progress').exists()).toBe(false)
+  }finally{wrapper.unmount()}
+})
+
+it.each(['viewer','side panel'])('clears stale installation progress after a failed status read in the %s',async(view)=>{
+  vi.useFakeTimers()
+  state.available=false;state.installation={status:'installing',message:'正在下载 Chromium 40%',progress:40,updatedAt:1}
+  const original=request.getMockImplementation()!
+  let disconnected=false
+  request.mockImplementation((path,options)=>disconnected&&path.endsWith('/managed-browser')?Promise.reject(new Error('执行节点连接已断开')):original(path,options))
+  const wrapper=view==='viewer'
+    ?mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true},global:{stubs}})
+    :mount(Preview,{props:{agents:[agent],isAdmin:true,active:true},global:{stubs}})
+  try{
+    await flushPromises()
+    expect(wrapper.find('progress').exists()).toBe(true)
+    disconnected=true
+    await vi.advanceTimersByTimeAsync(3000);await flushPromises()
+    expect(wrapper.find('progress').exists()).toBe(false)
+    expect(wrapper.text()).not.toContain('正在下载 Chromium 40%')
+    expect(wrapper.text()).toContain('执行节点连接已断开')
+    expect(wrapper.text()).toContain('重新检测')
+    expect(request.mock.calls.some(([,options])=>options?.method==='POST')).toBe(false)
+  }finally{wrapper.unmount()}
+})
+
+it('keeps a newer explicit browser detection result when an older periodic response arrives',async()=>{
+  state.available=false;state.reason='执行节点离线'
+  let release!:(value:unknown)=>void,count=0
+  const original=request.getMockImplementation()!
+  request.mockImplementation((path,options)=>path.endsWith('/managed-browser')&&++count===1?new Promise(resolve=>{release=resolve}):original(path,options))
+  const wrapper=mount(Preview,{props:{agents:[agent],isAdmin:true,active:true},global:{stubs}})
+  try{
+    await flushPromises()
+    const browser=wrapper.get('[aria-label="托管浏览器"]')
+    state.available=true;state.reason=undefined
+    await browser.findAll('button').find(button=>button.text()==='重新检测')!.trigger('click');await flushPromises()
+    expect(browser.get('button.primary').attributes('disabled')).toBeUndefined()
+    release({...state,available:false,reason:'过期的离线状态'});await flushPromises()
+    expect(browser.get('button.primary').attributes('disabled')).toBeUndefined()
+    expect(browser.text()).not.toContain('过期的离线状态')
+    expect(request.mock.calls.some(([,options])=>options?.method==='POST')).toBe(false)
+  }finally{wrapper.unmount()}
+})

+ 24 - 3
tests/client/managedBrowserCardSmoke.test.ts

@@ -26,10 +26,10 @@ it.runIf(process.env.YAOYAO_BROWSER_UI_SMOKE==='1')('opens the structured chat c
     await browserExpect(page.locator('.desktop-sidebar .sidebar-create-trigger')).toBeVisible()
     const seed=await(await page.request.post(origin+'/__test/computer',{data:{}})).json()
     const calls:{op:string;body?:any}[]=[]
-    let available=false,installing=false,human=false
+    let available=false,installing=false,human=false,online=true,browserReads=0
     const installation=()=>({status:available?'ready':installing?'installing':'missing',message:available?'浏览器已准备':installing?'正在下载 Chromium · 45%':'首次使用将自动准备 Chromium',...(installing&&!available?{progress:45}:{}),updatedAt:Date.now()})
     const card=()=>({id:'real-server-card',agentId:seed.agentId,agentName:'电脑面板验收 1',status:available?'active':'preparing',title:'产品文档 · 浏览器接管验证',url:'https://example.com/docs/browser/session-with-long-title-and-path',installation:installation(),updatedAt:Date.now()})
-    const inject=(data:any)=>{if(data.conversation?.id===seed.conversationId)data.messages=[{id:'browser-message',conversationId:seed.conversationId,agentId:seed.agentId,agentName:'电脑面板验收 1',seq:1,role:'assistant',content:'',reasoning:'',status:'complete',attachments:[],tools:[],browserCard:card(),createdAt:Date.now()}];return data}
+    const inject=(data:any)=>{if(data.conversation?.id===seed.conversationId)data.messages=[{id:'browser-message',conversationId:seed.conversationId,agentId:seed.agentId,agentName:'电脑面板验收 1',seq:1,role:'assistant',content:'',reasoning:'',status:'complete',attachments:[],tools:[],browserCard:card(),createdAt:Date.now()},{id:'service-warning',conversationId:seed.conversationId,agentId:seed.agentId,agentName:'电脑面板验收 1',seq:2,role:'assistant',content:'',reasoning:'',status:'complete',attachments:[],tools:[],serviceWarnings:[{code:'plugin_unavailable',service:'天气插件',message:'MCP 服务暂不可用,本次任务继续使用其余能力。'}],createdAt:Date.now()}];return data}
     await page.route('**/api/app/conversations/'+seed.conversationId+'?*',async route=>{const response=await route.fetch();await route.fulfill({response,json:inject(await response.json())})})
     await page.route('**/api/app/workspace/snapshot*',async route=>{const response=await route.fetch(),data=await response.json();data.details=data.details.map(inject);await route.fulfill({response,json:data})})
     await page.route('**/api/app/settings/host-tools',route=>route.fulfill({json:{scriptMachine:false,serverComputer:false,vm:false,cloud:false,managedBrowser:true}}))
@@ -37,7 +37,7 @@ it.runIf(process.env.YAOYAO_BROWSER_UI_SMOKE==='1')('opens the structured chat c
     await page.route('**/api/app/agents/'+seed.agentId+'/**',async route=>{
       const url=new URL(route.request().url()),path=url.pathname
       if(path.endsWith('/managed-browser/prepare')){calls.push({op:'prepare',body:route.request().postDataJSON()});installing=true}
-      if(path.endsWith('/managed-browser')||path.endsWith('/managed-browser/prepare'))return route.fulfill({json:{enabled:true,available,installation:installation(),open:human,generation:4,profile:'persistent',tabs:human?[{id:'tab',title:'产品文档',url:'https://example.com/docs',active:true}]:[],downloads:[]}})
+      if(path.endsWith('/managed-browser')||path.endsWith('/managed-browser/prepare')){browserReads++;return route.fulfill({json:{enabled:true,available:online&&available,...(online?{installation:installation()}:{reason:'执行节点离线,请恢复节点连接'}),open:human,generation:4,profile:'persistent',tabs:human?[{id:'tab',title:'产品文档',url:'https://example.com/docs',active:true}]:[],downloads:[]}})}
       if(!path.includes('/computer'))return route.continue()
       expect(url.searchParams.get('backend')).toBe('managed-browser')
       if(path.endsWith('/frame'))return route.fulfill({json:{id:'frame',generation:4,width:1000,height:650,data:image}})
@@ -51,6 +51,9 @@ it.runIf(process.env.YAOYAO_BROWSER_UI_SMOKE==='1')('opens the structured chat c
     await page.goto(origin+'/conversations/'+seed.conversationId)
     const element=page.getByRole('region',{name:'托管浏览器会话'})
     await browserExpect(element).toContainText('产品文档 · 浏览器接管验证')
+    const warnings=page.getByLabel('服务状态提示')
+    await browserExpect(warnings.getByRole('status')).toHaveText('天气插件:MCP 服务暂不可用,本次任务继续使用其余能力。')
+    await browserExpect(warnings.getByRole('alert')).toHaveCount(0)
     const output=join(root,'test-results/managed-browser-cards');await mkdir(output,{recursive:true})
     await page.setViewportSize({width:375,height:812})
     await browserExpect(element.getByRole('button',{name:'接管浏览器',exact:true})).toBeVisible()
@@ -77,7 +80,25 @@ it.runIf(process.env.YAOYAO_BROWSER_UI_SMOKE==='1')('opens the structured chat c
     await browserExpect(panel).toHaveCount(0)
     expect(calls.filter(call=>call.op==='giveback')).toHaveLength(1)
     expect(page.url()).toContain('/conversations/'+seed.conversationId)
+    // Offline is a connection state, never a synthetic installation. Rechecking
+    // must recover the same browser with read-only requests before another take.
+    online=false
+    const writesBeforeRecheck=calls.length
+    await element.getByRole('button',{name:'接管浏览器',exact:true}).click()
+    await browserExpect(panel).toContainText('执行节点离线,请恢复节点连接')
+    await browserExpect(panel.getByRole('button',{name:'打开并控制',exact:true})).toBeDisabled()
+    await browserExpect(panel.locator('progress')).toHaveCount(0)
+    await page.screenshot({path:join(output,'offline-recheck-375.png')})
+    online=true
+    const readsBeforeRecheck=browserReads
+    await panel.getByRole('button',{name:'重新检测',exact:true}).click()
+    await browserExpect(panel.getByRole('button',{name:'打开并控制',exact:true})).toBeEnabled()
+    expect(browserReads).toBeGreaterThan(readsBeforeRecheck)
+    expect(calls).toHaveLength(writesBeforeRecheck)
+    await panel.getByRole('button',{name:'关闭',exact:true}).click()
+    await browserExpect(panel).toHaveCount(0)
     await page.reload();await browserExpect(element).toContainText('产品文档 · 浏览器接管验证')
+    await browserExpect(warnings).toContainText('MCP 服务暂不可用,本次任务继续使用其余能力。')
     await page.emulateMedia({reducedMotion:'reduce',colorScheme:'dark'})
     await browserExpect(page.locator('html')).toHaveClass(/dark/)
     await page.screenshot({path:join(output,'chat-dark-375.png')})

+ 5 - 5
tests/client/runnerSettingsPanel.test.ts

@@ -12,7 +12,7 @@ beforeEach(()=>{
 })
 afterEach(()=>{vi.restoreAllMocks();vi.unstubAllGlobals()})
 
-it.each([false,true])('downloads an independently optional browser configuration (enabled=%s)',async(enabled)=>{
+it.each([false,true])('defaults browsers on and preserves an explicit opt-out in downloaded configuration (enabled=%s)',async(enabled)=>{
   let download:Blob|undefined
   vi.stubGlobal('URL',class extends URL {
     static createObjectURL(blob:Blob){download=blob;return 'blob:fixture'}
@@ -22,11 +22,11 @@ it.each([false,true])('downloads an independently optional browser configuration
   const wrapper=mount(RunnerSettingsPanel)
   try{
     await flushPromises()
-    const browser=wrapper.findAll('.runner-check').find(label=>label.text().includes('启用托管浏览器'))!.get<HTMLInputElement>('input')
+    const browser=wrapper.findAll('.runner-check').find(label=>label.text()==='托管浏览器')!.get<HTMLInputElement>('input')
     const computer=wrapper.findAll('.runner-check').find(label=>label.text().includes('隔离电脑 Worker'))!.get<HTMLInputElement>('input')
-    expect(browser.element.checked).toBe(false)
+    expect(browser.element.checked).toBe(true)
     expect(computer.element.checked).toBe(false)
-    if(enabled)await browser.setValue(true)
+    if(!enabled)await browser.setValue(false)
     await wrapper.get('input[placeholder="我的 Mac"]').setValue('Browser Runner')
     await wrapper.findAll('input[type="url"]')[0]!.setValue('http://127.0.0.1:18888')
     await wrapper.get('form').trigger('submit');await flushPromises()
@@ -34,7 +34,7 @@ it.each([false,true])('downloads an independently optional browser configuration
     const text=await new Promise<string>((resolve,reject)=>{const reader=new FileReader();reader.onload=()=>resolve(String(reader.result));reader.onerror=()=>reject(reader.error);reader.readAsText(download!)})
     const config=JSON.parse(text)
     expect(config.computers).toBeUndefined()
-    expect(config.browser).toEqual(enabled?{enabled:true}:undefined)
+    expect(config.browser).toEqual({enabled})
     expect(config.allowedProfiles).toEqual(['default'])
   }finally{wrapper.unmount()}
 })

+ 60 - 0
tests/client/serviceWarnings.test.ts

@@ -0,0 +1,60 @@
+import { afterEach, expect, it, vi } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
+import { createPinia } from 'pinia'
+import WorkspaceTimeline from '@/components/workspace/WorkspaceMessageTimeline.vue'
+import MessageTimeline from '@/components/messages/MessageTimeline.vue'
+import { workspaceMessagesToUi } from '@/components/workspace/viewModels'
+import { WorkspaceTranscriptStore } from '@/components/workspace/transcriptStore'
+import { buildMessageTimelineRows } from '@/utils/turnTrace'
+import type { WorkspaceMessage } from '@shared/workspace'
+
+const warning={code:'plugin_unavailable',service:'天气服务',message:'连接暂不可用,本次任务继续使用其他能力。'}
+const message:WorkspaceMessage={id:'warning-message',conversationId:'chat',agentId:'bot',agentName:'竹儿',role:'assistant',content:'',reasoning:'',status:'complete',seq:1,createdAt:1,tools:[{id:'tool',name:'天气服务',status:'tool.complete'}],attachments:[],serviceWarnings:[warning]}
+afterEach(()=>{vi.restoreAllMocks();vi.unstubAllGlobals()})
+
+it('keeps warning-only messages visible independently of tool trace aggregation and model text',()=>{
+  const [ui]=workspaceMessagesToUi([JSON.parse(JSON.stringify(message))])
+  expect(ui?.serviceWarnings).toEqual([warning])
+  expect(ui?.status).toBe('settled')
+  expect(ui?.error).toBeUndefined()
+  const rows=buildMessageTimelineRows([ui!])
+  expect(rows.map(row=>row.kind)).toEqual(['trace','message'])
+  expect(rows[1]?.kind==='message'&&rows[1].message.serviceWarnings).toEqual([warning])
+  const [textOnly]=workspaceMessagesToUi([{...message,serviceWarnings:undefined,content:JSON.stringify({serviceWarnings:[warning]})}])
+  expect(textOnly?.serviceWarnings).toBeUndefined()
+})
+
+it.each(['workspace','session'])('shows non-blocking, escaped service notices with tools hidden in the %s timeline',async(timeline)=>{
+  vi.stubGlobal('ResizeObserver',class {observe(){}disconnect(){}})
+  const scrollTo=HTMLElement.prototype.scrollTo
+  HTMLElement.prototype.scrollTo=vi.fn()
+  const unsafe={code:'plugin_unavailable',service:'<img src=x onerror="alert(1)">',message:'<script>alert(2)</script> [修复](javascript:alert(3))'}
+  const messages=workspaceMessagesToUi([{...message,serviceWarnings:[warning,unsafe]}])
+  const wrapper=mount(timeline==='workspace'?WorkspaceTimeline:MessageTimeline,{props:{identity:'chat',messages,showTools:false},global:{plugins:[createPinia()],stubs:{AppIcon:true,AgentAvatar:true,MarkdownContent:{props:['content'],template:'<div class="fixture-content">{{content}}</div>'}}}})
+  try{
+    await flushPromises()
+    const notices=wrapper.get('[aria-label="服务状态提示"]')
+    expect(notices.findAll('[role="status"]')).toHaveLength(2)
+    expect(notices.text()).toContain(`${warning.service}:${warning.message}`)
+    expect(notices.text()).toContain(`${unsafe.service}:${unsafe.message}`)
+    expect(notices.find('img,script,a,[role="alert"],details').exists()).toBe(false)
+    expect(wrapper.find('.turn-trace').exists()).toBe(false)
+    expect(wrapper.find('.message-failure').exists()).toBe(false)
+    await wrapper.setProps({messages:workspaceMessagesToUi([{...message,status:'streaming',content:'正在继续处理任务。'}])})
+    expect(wrapper.text()).toContain('正在继续处理任务。')
+    expect(wrapper.get('[aria-label="服务状态提示"]').text()).toContain(warning.message)
+  }finally{wrapper.unmount();HTMLElement.prototype.scrollTo=scrollTo}
+})
+
+it('retains service warnings from live transcript events through a history reload',()=>{
+  const conversation={id:'chat',kind:'direct',memberIds:['bot'],lastSeq:1} as any
+  const store=new WorkspaceTranscriptStore()
+  store.hydrate({agents:[],conversations:[conversation],details:[{conversation,messages:[],cursor:0,hasOlder:false,run:null,interactions:[],context:null}],cursor:0})
+  store.apply({type:'message.changed',seq:1,conversationId:'chat',data:message})
+  expect(workspaceMessagesToUi(store.get('chat')!.messages)[0]?.serviceWarnings).toEqual([warning])
+  const refreshed=new WorkspaceTranscriptStore()
+  refreshed.hydrate({agents:[],conversations:[conversation],details:[JSON.parse(JSON.stringify(store.get('chat')))],cursor:1})
+  const messages=workspaceMessagesToUi(refreshed.get('chat')!.messages)
+  expect(messages[0]?.serviceWarnings).toEqual([warning])
+  expect(buildMessageTimelineRows(messages).some(row=>row.kind==='message'&&row.message.id===message.id)).toBe(true)
+})

+ 60 - 0
tests/runner/browserConfigurationUpgrade.test.ts

@@ -0,0 +1,60 @@
+// @vitest-environment node
+import {afterEach,expect,it,vi} from 'vitest'
+import {EventEmitter} from 'node:events'
+import {mkdtemp,readFile,writeFile,rm} from 'node:fs/promises'
+import {randomUUID,randomBytes,createCipheriv,createDecipheriv} from 'node:crypto'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {parseRunnerConfiguration} from '../../src/runner/config.js'
+import {RunnerAgent} from '../../src/runner/agent.js'
+import type {RunnerConfiguration} from '../../src/shared/runner.js'
+import {DesktopRunnerManager} from '../../desktop/runner-manager.mjs'
+
+const browserConstructed=vi.hoisted(()=>vi.fn())
+vi.mock('../../src/runner/managedBrowser.js',()=>({RunnerBrowser:class {
+  available=false
+  constructor(config:unknown){browserConstructed(config)}
+  async disconnect(){}async shutdown(){}async sweep(){}
+}}))
+const base=()=>({protocol:1 as const,runnerId:randomUUID(),token:randomBytes(32).toString('base64url'),serverURL:'https://fixture.invalid',hermesURL:'http://127.0.0.1:9119',allowedProfiles:['default'],artifactRoots:[]})
+afterEach(()=>vi.clearAllMocks())
+
+it.each([undefined,{}, {enabled:false,maxSessions:2,idleTimeoutMs:120000}])('revalidates an old encrypted desktop configuration on restart and import (%j)',async browser=>{
+  const home=await mkdtemp(join(tmpdir(),'yaoyao-browser-config-upgrade-')),key=randomBytes(32),children:any[]=[]
+  const encrypt=(value:string)=>{const iv=randomBytes(12),cipher=createCipheriv('aes-256-gcm',key,iv),bytes=Buffer.concat([cipher.update(value),cipher.final()]);return Buffer.concat([iv,cipher.getAuthTag(),bytes])}
+  const decrypt=(bytes:Buffer)=>{const cipher=createDecipheriv('aes-256-gcm',key,bytes.subarray(0,12));cipher.setAuthTag(bytes.subarray(12,28));return Buffer.concat([cipher.update(bytes.subarray(28)),cipher.final()]).toString()}
+  const manager=new DesktopRunnerManager({home,encrypt,decrypt,validate:parseRunnerConfiguration,fork:()=>{
+    const child=Object.assign(new EventEmitter(),{messages:[] as any[],postMessage(value:any){this.messages.push(value);if(value.type==='shutdown')queueMicrotask(()=>this.emit('exit',0))},kill(){queueMicrotask(()=>this.emit('exit',0));return true}})
+    children.push(child);queueMicrotask(()=>child.emit('spawn'));return child
+  }})
+  const config={...base(),...(browser?{browser}:{})},encrypted=encrypt(JSON.stringify(config)),expected=browser?.enabled===false?browser:{enabled:true}
+  try{
+    // Existing bytes are the pre-upgrade settings; start must not rewrite them.
+    await writeFile(manager.path,encrypted,{mode:0o600});await manager.start()
+    expect(children[0].messages.find((item:any)=>item.type==='configure').config.browser).toEqual(expected)
+    expect(await readFile(manager.path)).toEqual(encrypted)
+    await manager.stop();await manager.start()
+    expect(children[1].messages.find((item:any)=>item.type==='configure').config.browser).toEqual(expected)
+    const file=join(home,'runner.json');await writeFile(file,JSON.stringify(config),{mode:0o600})
+    await manager.importFile(file)
+    expect(children[2].messages.find((item:any)=>item.type==='configure').config.browser).toEqual(expected)
+    expect((await readFile(manager.path)).includes(config.token)).toBe(false)
+  }finally{await manager.stop();await rm(home,{recursive:true,force:true})}
+})
+
+it.each([undefined,{}, {enabled:false}])('advertises setup for direct/embedded old configurations unless explicitly disabled (%j)',async browser=>{
+  const home=await mkdtemp(join(tmpdir(),'yaoyao-browser-agent-upgrade-'))
+  const fetchImpl=vi.fn(async()=>Response.json({commands:[],epoch:randomUUID()}))
+  const runner=new RunnerAgent({...base(),...(browser?{browser}:{})} as RunnerConfiguration,home,fetchImpl as typeof fetch)
+  try{
+    await (runner as any).api('poll')
+    const headers=new Headers(fetchImpl.mock.calls[0]![1]?.headers)
+    const features=headers.get('x-runner-features')!.split(',')
+    expect(features.includes('managed-browser-setup-v1')).toBe(browser?.enabled!==false)
+    expect(features.includes('managed-browser-disabled-v1')).toBe(browser?.enabled===false)
+    expect(features).not.toContain('managed-browser-v1')
+    expect(features).not.toContain('local-vm-v1')
+    if(browser?.enabled===false)expect(browserConstructed).not.toHaveBeenCalled()
+    else expect(browserConstructed).toHaveBeenCalledWith({enabled:true})
+  }finally{const stop=new AbortController();stop.abort();await runner.run(stop.signal);await rm(home,{recursive:true,force:true})}
+})

+ 3 - 3
tests/server/managedBrowserSmoke.test.ts

@@ -37,7 +37,7 @@ const close=(server:Server)=>new Promise<void>(resolve=>{server.close(()=>resolv
 /** YAOYAO_BROWSER_SMOKE=1 npx vitest run tests/server/managedBrowserSmoke.test.ts
  * Real outbound Runner protocol and Chromium; about:blank avoids website access.
  * The Hermes HTTP fixture never opens a model session or a computer Worker. */
-it.skipIf(process.env.YAOYAO_BROWSER_SMOKE!=='1')('uses a browser-only Runner through authenticated HTTP, UI takeover and the same task session',async()=>{
+it.skipIf(process.env.YAOYAO_BROWSER_SMOKE!=='1')('upgrades a legacy Runner without browser configuration through authenticated HTTP, UI takeover and the same task session',async()=>{
   const home=realpathSync(mkdtempSync(join(tmpdir(),'yaoyao-browser-http-smoke-'))),store=new WorkspaceStore(home),auth=new FixtureAuth(home)
   saveHostTools(home,{managedBrowser:true,vm:false,scriptMachine:false,serverComputer:false,cloud:false})
   let hermesRequests=0
@@ -59,7 +59,7 @@ it.skipIf(process.env.YAOYAO_BROWSER_SMOKE!=='1')('uses a browser-only Runner th
   // after it, matching production router order.
   app.use(hub.middleware());app.use(bodyParser());const router=service.router();app.use(router.routes());app.use(router.allowedMethods())
   const web=createServer(app.callback()),serverURL=await listen(web)
-  const config:RunnerConfiguration={protocol:1,serverURL,runnerId:registered.runner.id,token:registered.token,hermesURL,allowedProfiles:['default'],artifactRoots:[],browser:{enabled:true}}
+  const config:RunnerConfiguration={protocol:1,serverURL,runnerId:registered.runner.id,token:registered.token,hermesURL,allowedProfiles:['default'],artifactRoots:[]}
   const status:string[]=[],runner=new RunnerAgent(config,home,fetch,message=>status.push(message)),controller=new AbortController()
   const running=runner.run(controller.signal);void running.catch(()=>{})
   let task:BrowserTurn|undefined
@@ -74,7 +74,7 @@ it.skipIf(process.env.YAOYAO_BROWSER_SMOKE!=='1')('uses a browser-only Runner th
     await vi.waitFor(()=>expect(status).toContain('执行节点已连接'))
     const features=hub.summary(hub.records()[0]!).features
     expect(features).not.toContain('computer-worker-v1');expect(features).not.toContain('computer-control-v1')
-    expect(config.computers).toBeUndefined()
+    expect(config.computers).toBeUndefined();expect(config.browser).toBeUndefined()
     const agent=store.createAgent('owner',{name:'浏览器任务',profile:'default'}),base=`/api/app/agents/${agent.id}`
     expect(await ui(base+'/managed-browser')).toMatchObject({enabled:true,available:true,open:false})
     const taskController=new AbortController()

+ 20 - 0
tests/server/runner.test.ts

@@ -305,3 +305,23 @@ it('carries workspace memory policy through the Runner to the authenticated Herm
     assertActive:()=>{},catalog:()=>[],call:async()=>({}),onFailure:()=>{}})
   try{await lease.bind();expect(bindings.at(-1)).toMatchObject({workspace_memory:true})}finally{await lease.dispose()}
 })
+
+it('distinguishes automatically supported browsers from explicit node opt-out, old runners and offline nodes',()=>{
+  const agent=store.createAgent('owner',{name:'浏览器节点状态',profile:'default'}),record=hub.records()[0]!
+  const states=(hub as any).online as Map<string,{features:string[]}>,state=states.get(record.id)!,features=state.features
+  try{
+    expect(state.features).toContain('managed-browser-setup-v1')
+    expect(hub.browserRunner('owner',agent).id).toBe(record.id)
+    auth.allowed=false
+    expect(()=>hub.browserRunner('owner',agent)).toThrow('浏览器 Profile 未授权')
+    auth.allowed=true
+    state.features=['managed-browser-disabled-v1']
+    expect(()=>hub.browserRunner('owner',agent)).toThrow('节点管理员已关闭托管浏览器')
+    state.features=[]
+    expect(()=>hub.browserRunner('owner',agent)).toThrow('执行节点版本尚不支持自动准备浏览器')
+    states.delete(record.id)
+    expect(()=>hub.browserRunner('owner',agent)).toThrow('执行节点暂未连接')
+    store.put('_system','runner',record.id,{...record,enabled:false})
+    expect(()=>hub.browserRunner('owner',agent)).toThrow('尚未找到此 Bot 的执行节点')
+  }finally{auth.allowed=true;state.features=features;states.set(record.id,state);store.put('_system','runner',record.id,record)}
+})

+ 12 - 0
tests/server/runnerConfig.test.ts

@@ -39,3 +39,15 @@ it('resolves optional Hermes paths on the execution host while preserving explic
 it('rejects the retired satellite runner',()=>{
   expect(()=>parseRunnerConfiguration({...config,satellite:true,hermesURL:'https://hermes.example',allowInsecureLan:true})).toThrow('卫星执行节点已停用')
 })
+
+
+it('upgrades omitted browser settings while preserving explicit opt-out and VM isolation',()=>{
+  expect(parseRunnerConfiguration(config).browser).toEqual({enabled:true})
+  expect(parseRunnerConfiguration({...config,browser:{}}).browser).toEqual({enabled:true})
+  const browser={enabled:false,maxSessions:2,idleTimeoutMs:120000}
+  const computers={runtime:'docker',imageId:'sha256:'+'a'.repeat(64),network:'none',python:'/fixture/python',hermesSource:'/fixture/source',hermesHome:'/fixture/home'}
+  const disabled=parseRunnerConfiguration({...config,browser,computers})
+  expect(disabled.browser).toEqual(browser);expect(disabled.computers).toEqual(computers)
+  expect(parseRunnerConfiguration({...config,computers}).computers).toEqual(computers)
+  for(const invalid of [{enabled:'false'},{enabled:null},{enabled:true,command:'install'}])expect(()=>parseRunnerConfiguration({...config,browser:invalid})).toThrow()
+})

+ 1 - 1
tests/server/workspace.test.ts

@@ -129,7 +129,7 @@ it('includes only the connected per-turn MCP inventory in the submitted Bot prom
     ? { status: 200, body: Buffer.from(JSON.stringify({ ok: true, version: 1, ready: true, in_process: true, native_tools: true })), headers: new Headers() }
     : request(path, options))
   const services = vi.fn(() => [{ name: 'vaultwarden', transport: 'stdio' as const, toolCount: 59 }])
-  const open = vi.fn(async () => ({ services, catalog: () => [], call: vi.fn(), dispose: async () => {} }))
+  const open = vi.fn(async () => ({ services, warnings: () => [], catalog: () => [], call: vi.fn(), dispose: async () => {} }))
   runtime.plugins = { selected: () => true, open } as unknown as WorkspacePlugins
   const run = runtime.send(owner, conversation.id, { requestId: randomUUID(), content: '查看 vaultwarden mcp 是否正常使用' })
   await vi.waitFor(() => expect(['complete', 'failed']).toContain(store.require<WorkspaceRun>(owner, 'run', run.id).status))

+ 11 - 0
tests/server/workspaceMessagePatch.test.ts

@@ -149,3 +149,14 @@ it('persists card-only messages and replays installation updates as full frames
   expect(store.require<WorkspaceMessage>('owner','message',message.id)).toMatchObject({visible:true,status:'complete',content:'',browserCard:{status:'closed',title:'测试网页'}})
   expect(store.require<WorkspaceConversation>('owner','conversation',message.conversationId).unread).toBe(true)
 })
+
+it('persists nonfatal service warnings independently of reply text and keeps terminal warning-only replies visible',()=>{
+  message.content='';message.visible=true
+  message.serviceWarnings=[{service:'离线服务',code:'plugin_initialization_failed',message:'本轮未连接'}]
+  const cursor=store.cursor('owner');store.saveMessage('owner',message)
+  expect(store.events('owner',cursor).find(e=>e.type==='message.changed')?.data).toMatchObject({serviceWarnings:message.serviceWarnings})
+  expect(store.require<WorkspaceConversation>('owner','conversation',message.conversationId).preview).toBe('部分服务暂时不可用')
+  message.status='complete';store.saveMessage('owner',message)
+  expect(store.require<WorkspaceMessage>('owner','message',message.id)).toMatchObject({visible:true,content:'',status:'complete',serviceWarnings:message.serviceWarnings})
+  expect(store.require<WorkspaceConversation>('owner','conversation',message.conversationId).unread).toBe(true)
+})

+ 120 - 0
tests/server/workspacePluginIsolation.test.ts

@@ -0,0 +1,120 @@
+// @vitest-environment node
+import {expect,it,vi} from 'vitest'
+import {randomUUID} from 'node:crypto'
+import {mkdtempSync,rmSync} from 'node:fs'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import {WebSocketServer} from 'ws'
+import {WorkspaceStore} from '../../src/server/workspaceStore'
+import {WorkspaceRuntime} from '../../src/server/workspaceRuntime'
+import {WorkspaceNodes,type GatewayTarget} from '../../src/server/workspaceGateway'
+import {WorkspacePlugins} from '../../src/server/botPlugins/workspacePlugins'
+import {UploadStore} from '../../src/server/uploads'
+import {loadServerConfig} from '../../src/server/config'
+import {saveHostTools} from '../../src/server/hostToolSettings'
+import type {LocalAuthStore} from '../../src/server/localAuth'
+import type {ManagedBrowsers} from '../../src/server/managedBrowsers'
+import type {WorkspaceConversation,WorkspaceRun} from '../../src/shared/workspace'
+
+const owner='plugin-isolation-user'
+
+async function fixture(failure:'initialize'|'tools/list'){
+  const home=mkdtempSync(join(tmpdir(),'yaoyao-plugin-isolation-'))
+  const store=new WorkspaceStore(home),uploads=new UploadStore(home)
+  saveHostTools(home,{managedBrowser:true,vm:false,serverComputer:false,scriptMachine:false,cloud:false})
+  const server=new WebSocketServer({port:0,host:'127.0.0.1'})
+  await new Promise<void>(resolve=>server.once('listening',resolve))
+  const bindings=new Map<string,{bridge_url:string;token:string}>(),prompts:string[]=[],catalogs:any[][]=[]
+  let integrationError:unknown,offline=false
+  const target={
+    url:new URL(`http://127.0.0.1:${(server.address() as {port:number}).port}`),client:{directAgent:undefined},
+    session:{webSocketCredential:async()=>({name:'ticket',value:'test'}),request:async(path:string,input?:{body?:any})=>{
+      if(path.endsWith('/bind'))bindings.set(input?.body.session_id,input?.body)
+      const value=path.startsWith('/api/plugins/yaoyao-bot-bridge/')?{ok:true,version:1,ready:true,in_process:true,native_tools:true}
+        :path==='/api/config'?{terminal:{}}:{messages:[]}
+      return {status:200,headers:new Headers(),body:Buffer.from(JSON.stringify(value))}
+    }},
+  } as unknown as GatewayTarget
+  const nodes=new WorkspaceNodes(store,loadServerConfig({HERMES_YAOYAO_HOME:home}),target)
+  const runtime=new WorkspaceRuntime(store,nodes,uploads)
+  const browserOpen=vi.fn()
+  runtime.managedBrowsers={available:()=>true,openTurn:browserOpen} as unknown as ManagedBrowsers
+  const response=(body:unknown,status=200)=>new Response(JSON.stringify(body),{status,headers:{'Content-Type':'application/json'}})
+  const pluginFetch:typeof fetch=async(input,init)=>{
+    const frame=JSON.parse(String(init?.body))
+    if(offline&&String(input).includes('offline.example.test')&&frame.method===failure)return response({error:'upstream token must stay private'},503)
+    if(frame.method==='notifications/initialized')return new Response(null,{status:202})
+    const result=frame.method==='initialize'?{protocolVersion:'2025-06-18',capabilities:{tools:{}}}
+      :frame.method==='tools/list'?{tools:[{name:'echo',description:'fixture',inputSchema:{type:'object'}}]}
+      :{content:[{type:'text',text:'healthy tool response'}]}
+    return response({jsonrpc:'2.0',id:frame.id,result})
+  }
+  const auth={isUserActive:()=>true,isAdminActive:()=>true} as unknown as LocalAuthStore
+  const plugins=new WorkspacePlugins(store,nodes,auth,runtime,home,pluginFetch)
+  runtime.plugins=plugins
+  const bot=store.createAgent(owner,{name:'普通聊天 Bot',profile:'default'})
+  for(const [name,url] of [['离线服务','https://offline.example.test/mcp'],['健康服务','https://healthy.example.test/mcp']]){
+    const plugin=plugins.save(owner,{name,transport:'http',url,agentIds:[bot.id]})
+    await plugins.test(owner,plugin.id)
+    const entry=store.require<any>(owner,'bot-mcp-plugin',plugin.id)
+    store.put(owner,'bot-mcp-plugin',plugin.id,{...entry,enabled:true,revision:entry.revision+1})
+  }
+  offline=true
+  server.on('connection',socket=>{
+    socket.send(JSON.stringify({method:'event',params:{type:'gateway.ready'}}))
+    socket.on('message',data=>{
+      const frame=JSON.parse(String(data)),respond=(result:unknown)=>socket.send(JSON.stringify({id:frame.id,result}))
+      if(frame.method==='session.create'||frame.method==='session.resume')respond({session_id:randomUUID(),stored_session_id:randomUUID(),running:false,info:{profile_name:frame.params.profile}})
+      else if(frame.method==='session.cwd.set')respond({cwd:frame.params.cwd})
+      else if(frame.method==='session.usage')respond({context_used:10,context_max:1000})
+      else if(frame.method==='prompt.submit'){
+        prompts.push(frame.params.text);respond({status:'streaming'})
+        void(async()=>{
+          try{
+            const binding=bindings.get(frame.params.session_id)!
+            const reply=await fetch(binding.bridge_url+'/tools/list',{method:'POST',headers:{'Content-Type':'application/json',Authorization:`Bearer ${binding.token}`},body:'{}'})
+            expect(reply.status).toBe(200)
+            catalogs.push((await reply.json() as any).tools)
+          }catch(error){integrationError=error}
+          socket.send(JSON.stringify({method:'event',params:{type:'message.complete',session_id:frame.params.session_id,payload:{text:'你好!',status:'complete'}}}))
+        })()
+      }else respond({ok:true,status:'interrupted'})
+    })
+  })
+  const conversation=store.list<WorkspaceConversation>(owner,'conversation').find(value=>value.kind==='direct'&&value.memberIds[0]===bot.id)!
+  return {store,runtime,conversation,prompts,catalogs,browserOpen,getError:()=>integrationError,
+    async close(){
+      runtime.close();plugins.close();await new Promise(resolve=>setTimeout(resolve,10))
+      for(const client of server.clients)client.terminate()
+      await new Promise<void>(resolve=>server.close(()=>resolve()))
+      nodes.close();uploads.close();store.close();rmSync(home,{recursive:true,force:true})
+    },
+  }
+}
+
+it.each(['initialize','tools/list'] as const)('submits and completes 你好 despite a plugin %s failure, with a visible warning and healthy tool inventory',async failure=>{
+  const f=await fixture(failure)
+  try{
+    const run=f.runtime.send(owner,f.conversation.id,{requestId:randomUUID(),content:'你好'})
+    await vi.waitFor(()=>expect(['complete','failed']).toContain(f.store.require<WorkspaceRun>(owner,'run',run.id).status))
+    const settled=f.store.require<WorkspaceRun>(owner,'run',run.id)
+    expect(settled.status,settled.error).toBe('complete')
+    expect(f.getError()).toBeUndefined()
+    expect(f.prompts).toHaveLength(1)
+    expect(f.prompts[0]).toContain('【本轮用户消息】\n你好')
+    const messages=f.store.messages(owner,f.conversation.id),answer=messages.find(message=>message.role==='assistant')!
+    expect(messages.find(message=>message.role==='user')?.content).toBe('你好')
+    expect(answer).toMatchObject({content:'你好!',status:'complete',visible:true,serviceWarnings:[{
+      code:'plugin_initialization_failed',service:'离线服务',message:expect.stringContaining('不可用'),
+    }]})
+    expect(f.prompts[0]).toContain(JSON.stringify(answer.serviceWarnings))
+    expect(f.prompts[0]).toContain('不能宣称已经调用或执行成功')
+    expect(f.prompts[0]).toContain('继续处理不依赖这些服务的普通对话')
+    expect(JSON.stringify(answer)).not.toContain('upstream token')
+    expect(f.catalogs).toHaveLength(1)
+    const pluginTools=f.catalogs[0]!.filter(tool=>tool.name.startsWith('plugin_'))
+    expect(pluginTools).toHaveLength(1);expect(pluginTools[0].description).toContain('健康服务')
+    expect(f.catalogs[0]!.some(tool=>tool.name==='managed_browser_open')).toBe(true)
+    expect(f.browserOpen).not.toHaveBeenCalled()
+  }finally{await f.close()}
+})

+ 114 - 5
tests/server/workspacePlugins.test.ts

@@ -1,9 +1,9 @@
 // @vitest-environment node
-import { beforeEach, afterEach, describe, it, expect } from 'vitest'
-import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest'
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { randomUUID } from 'node:crypto'
+import { createHash, randomUUID } from 'node:crypto'
 import request from 'supertest'
 import type Koa from 'koa'
 import { createApplication, type ApplicationRuntime } from '../../src/server/app'
@@ -14,6 +14,7 @@ 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>
+let rejectRequest: ((url: string, body: any, init?: RequestInit) => Response | undefined | Promise<Response | undefined>) | undefined
 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') }
@@ -27,6 +28,8 @@ const response = (body: unknown, status = 200) => new Response(JSON.stringify(bo
 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 })
+  const rejected = await rejectRequest?.(url, body, init)
+  if (rejected) return rejected
   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)
@@ -51,14 +54,14 @@ const mcpFetch: typeof fetch = async (input, init) => {
 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()
+  home = mkdtempSync(join(tmpdir(), 'yaoyao-plugin-test-')); failedInventory = false; requests = []; accounts = []; sessions = new Map(); rejectRequest = undefined
   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 }) })
+afterEach(async () => { vi.restoreAllMocks(); 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)
@@ -252,3 +255,109 @@ it('accepts SSE MCP responses, preserves tool results and disables redirect foll
   expect(options.at(-1).headers['Mcp-Session-Id']).toBe('session-fixture')
   client.dispose()
 })
+
+const pluginTarget: any = { url: new URL('http://127.0.0.1:19119'), session: { request: async () => ({
+  status: 200, body: Buffer.from(JSON.stringify({ version: 1, ready: true, native_tools: true, in_process: true })),
+}) } }
+async function enablePlugin(agentId: string, name: string, url: string) {
+  const added = await api('post', '/mcp').send({ name, transport: 'http', url, agentIds: [agentId] }).expect(201)
+  await api('post', '/mcp/' + added.body.plugin.id + '/test').send({}).expect(200)
+  await api('patch', '/mcp/' + added.body.plugin.id).send({ enabled: true, revision: 1 }).expect(200)
+  return added.body.plugin.id as string
+}
+
+it.each(['initialize', 'tools/list'])('isolates a failed %s without discarding healthy MCP services or claiming failed tools worked', async method => {
+  const agent = runtime.workspace.createAgent('owner', { name: '可选插件 Bot', profile: 'default' })
+  const badId = await enablePlugin(agent.id, '离线服务', 'https://offline.example.test/mcp')
+  await enablePlugin(agent.id, '健康服务', 'https://healthy.example.test/mcp')
+  rejectRequest = (url, body) => url.includes('offline.example.test') && body.method === method ? response({ error: 'Bearer fixture-secret' }, 503) : undefined
+  const disposed = vi.spyOn(HttpMcp.prototype, 'dispose')
+  const lease = await runtime.workspacePlugins.open('owner', agent, pluginTarget, new AbortController().signal, () => {})
+  try {
+    expect(lease.services()).toEqual([{ name: '健康服务', transport: 'http', toolCount: 1 }])
+    expect(lease.warnings()).toEqual([{ name: '离线服务', code: 'plugin_initialization_failed', message: expect.stringContaining('不可用') }])
+    expect(JSON.stringify(lease.warnings())).not.toContain('fixture-secret')
+    expect(lease.catalog()).toHaveLength(1)
+    expect(lease.catalog()[0]!.description).toContain('健康服务')
+    expect((await lease.call(lease.catalog()[0]!.id, { value: 'healthy' }) as any).structuredContent.value).toBe('healthy')
+    const count = requests.length, failedTool = 'plugin_' + createHash('sha256').update(badId + ':fixture_echo').digest('hex').slice(0, 32)
+    await expect(lease.call(failedTool, {})).rejects.toMatchObject({ code: 'plugin_unavailable' })
+    expect(requests).toHaveLength(count)
+    expect(disposed).toHaveBeenCalledOnce()
+    await api('patch', '/mcp/' + badId).send({ enabled: false, revision: 2 }).expect(200)
+    expect(lease.warnings()).toEqual([])
+    await expect(lease.call(failedTool, {})).rejects.toMatchObject({ code: 'plugin_grant_revoked' })
+  } finally { await lease.dispose() }
+})
+
+it('returns an unavailable-app warning when connected-app session creation fails, preserving healthy MCP tools', async () => {
+  const agent = runtime.workspace.createAgent('owner', { name: '应用故障隔离', profile: 'default' })
+  await enablePlugin(agent.id, '健康服务', 'https://healthy.example.test/mcp')
+  await configure()
+  await api('put', '/apps/gmail/agents').send({ revision: 0, agentIds: [agent.id] }).expect(200)
+  rejectRequest = url => url.endsWith('/tool_router/session') ? response({ error: 'upstream unavailable' }, 503) : undefined
+  const lease = await runtime.workspacePlugins.open('owner', agent, pluginTarget, new AbortController().signal, () => {})
+  try {
+    expect(lease.services()).toEqual([{ name: '健康服务', transport: 'http', toolCount: 1 }])
+    expect(lease.warnings()).toEqual([{ name: '已连接应用', code: 'plugin_initialization_failed', message: expect.stringContaining('不可用') }])
+    expect(lease.catalog()).toHaveLength(1)
+    expect((await lease.call(lease.catalog()[0]!.id, { value: 'still connected' }) as any).structuredContent.value).toBe('still connected')
+  } finally { await lease.dispose() }
+})
+
+it('returns an empty, explicitly degraded lease when every optional plugin is offline', async () => {
+  const agent = runtime.workspace.createAgent('owner', { name: '普通聊天', profile: 'default' })
+  await enablePlugin(agent.id, '离线服务', 'https://offline.example.test/mcp')
+  rejectRequest = (_url, body) => body.method === 'initialize' ? response({}, 503) : undefined
+  const lease = await runtime.workspacePlugins.open('owner', agent, pluginTarget, new AbortController().signal, () => {})
+  try {
+    expect(lease.services()).toEqual([]); expect(lease.catalog()).toEqual([])
+    expect(lease.warnings()).toHaveLength(1)
+    await expect(lease.call('plugin_unknown', {})).rejects.toMatchObject({ code: 'plugin_tool_not_found' })
+  } finally { await lease.dispose() }
+})
+
+it('does not turn cancellation or a revoked plugin grant into a degraded lease', async () => {
+  const agent = runtime.workspace.createAgent('owner', { name: '撤权 Bot', profile: 'default' })
+  const id = await enablePlugin(agent.id, '受控服务', 'https://controlled.example.test/mcp')
+  const controller = new AbortController()
+  rejectRequest = (_url, body) => {
+    if (body.method === 'initialize') { controller.abort(); throw new Error('cancelled transport') }
+    return undefined
+  }
+  await expect(runtime.workspacePlugins.open('owner', agent, pluginTarget, controller.signal, () => {})).rejects.toMatchObject({ code: 'plugin_grant_revoked' })
+  rejectRequest = (_url, body) => {
+    if (body.method === 'initialize') {
+      const current = runtime.workspace.require<any>('owner', 'bot-mcp-plugin', id)
+      runtime.workspace.put('owner', 'bot-mcp-plugin', id, { ...current, enabled: false, revision: current.revision + 1 })
+      return response({}, 503)
+    }
+    return undefined
+  }
+  await expect(runtime.workspacePlugins.open('owner', agent, pluginTarget, new AbortController().signal, () => {})).rejects.toMatchObject({ code: 'plugin_grant_revoked' })
+})
+
+it('reaps a failed stdio initialization process while keeping a healthy service available', async () => {
+  const agent = runtime.workspace.createAgent('owner', { name: '进程故障隔离', profile: 'default' })
+  const script = join(home, 'degraded-plugin.cjs'), pidFile = join(home, 'degraded-plugin.pid'), failFile = join(home, 'fail-list')
+  writeFileSync(script, `const fs=require('node:fs');fs.writeFileSync(process.argv[2],String(process.pid));
+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:fs.existsSync(process.argv[3])?[{name:'duplicate'},{name:'duplicate'}]:[{name:'stdio_tool',inputSchema:{type:'object'}}]};
+  process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:r.id,result})+'\\n');
+});`)
+  const added = await api('post', '/mcp').send({ name: '故障进程', transport: 'stdio', command: process.execPath, args: [script, pidFile, failFile], agentIds: [agent.id] }).expect(201)
+  await api('post', '/mcp/' + added.body.plugin.id + '/test').send({}).expect(200)
+  await api('patch', '/mcp/' + added.body.plugin.id).send({ enabled: true, revision: 1 }).expect(200)
+  await enablePlugin(agent.id, '健康服务', 'https://healthy.example.test/mcp')
+  writeFileSync(failFile, 'fail')
+  const lease = await runtime.workspacePlugins.open('owner', agent, pluginTarget, new AbortController().signal, () => {})
+  try {
+    expect(lease.services()).toEqual([{ name: '健康服务', transport: 'http', toolCount: 1 }])
+    expect(lease.warnings()[0]).toMatchObject({ name: '故障进程', code: 'plugin_initialization_failed' })
+    const pid = Number(readFileSync(pidFile, 'utf8'))
+    expect(pid).toBeGreaterThan(0)
+    expect(() => process.kill(pid, 0)).toThrow()
+  } finally { await lease.dispose() }
+})

+ 12 - 0
tests/server/workspacePrompt.test.ts

@@ -144,3 +144,15 @@ describe('Bot prompt composition', () => {
     expect(text).not.toContain('computer_export')
   })
 })
+
+it('describes failed optional services separately without replacing the user request or promising unavailable tools',()=>{
+  const value=input()
+  value.pluginWarnings=[{service:'故障 MCP',code:'plugin_initialization_failed',message:'本轮暂时不可用'}]
+  const prompt=buildWorkspacePrompt(value)
+  expect(prompt).toContain('【本轮服务连接提示】')
+  expect(prompt).toContain(JSON.stringify(value.pluginWarnings))
+  expect(prompt).toContain('不要因为无关插件离线而中断整轮对话')
+  expect(prompt).toContain('不能宣称已经调用或执行成功')
+  expect(prompt).toContain('【本轮用户消息】\n'+value.content)
+  expect(prompt).not.toContain('已完成本轮连接和工具发现的服务')
+})

+ 1 - 1
tests/server/workspaceReconnect.test.ts

@@ -46,7 +46,7 @@ beforeEach(async () => {
   runtime = new WorkspaceRuntime(store, nodes, uploads, () => authorized)
   pluginDispose = vi.fn(async () => {})
   pluginCall = vi.fn(async () => ({message: '插件仍可使用'}))
-  runtime.plugins = {selected: () => true, open: async () => ({services: () => [],
+  runtime.plugins = {selected: () => true, open: async () => ({services: () => [], warnings: () => [],
     catalog: () => [{id: 'plugin_fixture', name: 'plugin_fixture', description: '回归测试', inputSchema: {type: 'object'}}],
     call: pluginCall, dispose: pluginDispose,
   })} as unknown as WorkspacePlugins