Răsfoiți Sursa

完善浏览器环境自动安装与聊天卡片接管

Samien 1 lună în urmă
părinte
comite
0b1604c5ab

+ 28 - 18
docs/managed-browser.md

@@ -8,22 +8,7 @@
 
 
 默认关闭。升级代码不会自动启动 Chromium,也不会改变已有 Bot 的虚拟机、云电脑或本机电脑设置。
 默认关闭。升级代码不会自动启动 Chromium,也不会改变已有 Bot 的虚拟机、云电脑或本机电脑设置。
 
 
-1. 在运行 Runner 的同一系统账号下安装配套 Chromium。源码运行执行:
-
-   ```sh
-   npm ci
-   npx playwright install chromium
-   ```
-
-   独立 Runner 分发包需复制整个 `.runner-build/` 目录,再在目标主机执行:
-
-   ```sh
-   node .runner-build/node_modules/playwright/cli.js install chromium
-   ```
-
-   Linux 还需安装 Chromium 的系统依赖,可用对应 CLI 的 `install-deps chromium`。Chromium 启用自身沙箱;用非 root 账号运行 Runner,不添加 `--no-sandbox`。浏览器二进制、登录资料和缓存不随程序包分发。没有安装配套二进制的节点不会声明浏览器可用。
-
-2. 在执行节点配置中启用“托管浏览器”,或给现有 `runner.json` 添加:
+1. 在执行节点配置中启用“托管浏览器”,或给现有 `runner.json` 添加:
 
 
    ```json
    ```json
    {
    {
@@ -37,11 +22,27 @@
 
 
    保留现有配置其他字段,再让 Runner 加载更新后的配置。`computers` 与 `browser` 可分别配置,单独启用浏览器不要求 VM Worker。`maxSessions` 范围 1–8,默认 3;空闲回收范围 60 秒–2 小时,默认 5 分钟。
    保留现有配置其他字段,再让 Runner 加载更新后的配置。`computers` 与 `browser` 可分别配置,单独启用浏览器不要求 VM Worker。`maxSessions` 范围 1–8,默认 3;空闲回收范围 60 秒–2 小时,默认 5 分钟。
 
 
-3. 管理员在 Bot 工具设置中打开“托管浏览器”。Bot 的来源与 Profile 必须在 Runner 和账号权限范围内,Hermes 本轮工具桥也必须可用。
-4. 在 Bot 电脑面板选择“托管浏览器”。任务按需打开;人工点击接管也可打开,单纯查看状态不会启动 Chromium。
+2. 管理员在 Bot 工具设置中打开“托管浏览器”。Bot 的来源与 Profile 必须在 Runner 和账号权限范围内,Hermes 本轮工具桥也必须可用。
+3. Bot 首次调用打开浏览器,或用户在聊天卡片/电脑面板点击“接管浏览器”时,节点检查配套 Chromium 和沙箱启动。缺少环境时自动安装并显示阶段与当前组件的下载进度,完成后继续打开同一浏览器。单纯查看状态不会下载安装。
+
+## 环境自动准备
+
+浏览器二进制按需下载,不随应用包分发。开启浏览器的节点即使尚未安装 Chromium,也会声明“可准备”能力;界面区分未安装、检查中、安装中、就绪和失败。安装使用应用锁定版本的 Playwright CLI,不调用另一版本的全局命令。一次节点安装由多个已授权使用者共享;断线、关闭节点或所有安装授权失效会取消安装,不会取消其他仍有授权的使用者。
+
+安装完成前必须通过真实的 Chromium 沙箱启动检查。Linux 缺少系统依赖时,仅在节点已经以 root 运行的情况下调用配套 `install-deps chromium`;普通账号会得到明确依赖提示,需要管理员先补齐系统依赖。安装过程不自动提权,不添加 `--no-sandbox`。生产 Runner 应以支持沙箱的非 root 账号运行。失败保留原因,由“重试并接管浏览器”明确发起下一次安装;不会无限重复下载安装。
+
+离线预装仍可使用同一分发包内的 CLI:
+
+```sh
+# 源码部署
+node node_modules/playwright/cli.js install chromium
+# 独立 Runner:先复制完整 .runner-build 目录
+node .runner-build/node_modules/playwright/cli.js install chromium
+```
 
 
 ## 会话与接管
 ## 会话与接管
 
 
+- 聊天卡片由服务端实际浏览器操作生成,保存安装状态、页面标题和去掉认证信息、查询参数及片段的地址;隐藏工具详情或刷新聊天后仍可见。点击卡片在现有电脑面板直接接管,macOS 使用现有独立控制窗口。卡片不解析模型输出中的控制地址或 HTML。已结束的卡片只保留历史,不自动重新打开旧任务。
 - 普通 Bot 的 Chromium 资料按账号和 Bot 隔离,保存在 Runner 数据目录的 `managed-browser/profiles/`,关闭页面后保留登录资料。临时助手使用临时资料,关闭后删除。
 - 普通 Bot 的 Chromium 资料按账号和 Bot 隔离,保存在 Runner 数据目录的 `managed-browser/profiles/`,关闭页面后保留登录资料。临时助手使用临时资料,关闭后删除。
 - Bot 浏览器与本机浏览器、VM 内浏览器不共享 Cookie、页面或资料目录。一个 Bot 的资料同时只允许一个进程写入。
 - Bot 浏览器与本机浏览器、VM 内浏览器不共享 Cookie、页面或资料目录。一个 Bot 的资料同时只允许一个进程写入。
 - 人工接管与 Agent 使用同一个 Chromium 上下文。接管先等待当前操作收束并切换控制代次;Agent 的后续浏览器调用等待交还。交还后模型获得备注,并重新读取网页。
 - 人工接管与 Agent 使用同一个 Chromium 上下文。接管先等待当前操作收束并切换控制代次;Agent 的后续浏览器调用等待交还。交还后模型获得备注,并重新读取网页。
@@ -94,3 +95,12 @@ YAOYAO_BROWSER_SMOKE=1 npx vitest run tests/runner/browserSmoke.test.ts tests/se
 ```
 ```
 
 
 这两项覆盖真实页面操作、上传下载、登录资料恢复,以及 HTTP Runner 领取/准入/结果/授权检查、无 VM 浏览器握手、人工接管与交还。HTTP 测试使用 Hermes fixture,不代表真实模型自主选择工具的评测。macOS Chromium 和临时 Docker 命令取消已实测;Linux Chromium 沙箱与 Windows 安装仍需目标平台验证,尚无与完整 VM 的性能基准数字。
 这两项覆盖真实页面操作、上传下载、登录资料恢复,以及 HTTP Runner 领取/准入/结果/授权检查、无 VM 浏览器握手、人工接管与交还。HTTP 测试使用 Hermes fixture,不代表真实模型自主选择工具的评测。macOS Chromium 和临时 Docker 命令取消已实测;Linux Chromium 沙箱与 Windows 安装仍需目标平台验证,尚无与完整 VM 的性能基准数字。
+
+首次安装和聊天卡片验收:
+
+```sh
+YAOYAO_BROWSER_INSTALL_SMOKE=1 NODE_OPTIONS=--no-experimental-webstorage npx vitest run tests/runner/browserInstallationSmoke.test.ts --maxWorkers=1
+YAOYAO_BROWSER_UI_SMOKE=1 NODE_OPTIONS=--no-experimental-webstorage npx vitest run tests/client/managedBrowserCardSmoke.test.ts --maxWorkers=1
+```
+
+首次安装测试在独立 Node 进程和全新临时浏览器缓存中实际下载,验证 missing → installing → ready,再打开浏览器截图,结束后删除临时缓存。聊天 UI 测试使用真实 Chromium、隔离服务与浏览器接口 fixture,覆盖结构卡刷新保留、准备后接管、交还、375px、暗色与横屏;该 UI fixture 不代表真实 Runner 安装测试。

+ 30 - 0
src/client/components/messages/ManagedBrowserCard.vue

@@ -0,0 +1,30 @@
+<script setup lang="ts">
+import { computed } from 'vue'
+import AppIcon from '@/components/common/AppIcon.vue'
+import type { ManagedBrowserCard } from '@shared/managedBrowser'
+const props = defineProps<{ card: ManagedBrowserCard; readOnly?: boolean }>()
+const emit = defineEmits<{ open: [card: ManagedBrowserCard] }>()
+const status = computed(() => ({ preparing: '正在准备', ready: '可接管', active: '浏览器已打开', closed: '会话已结束', failed: '需要重试' })[props.card.status])
+const progress = computed(() => {
+  const value = props.card.installation?.progress
+  return typeof value === 'number' && Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : undefined
+})
+</script>
+<template>
+  <section class="managed-browser-card" aria-label="托管浏览器会话">
+    <div class="browser-card-heading"><AppIcon name="globe" :size="18" aria-hidden="true"/><strong>托管浏览器</strong><span class="browser-card-state" :class="{ failed: card.status === 'failed' }">{{ status }}</span></div>
+    <strong class="browser-card-title">{{ card.title || `${card.agentName}的浏览器` }}</strong>
+    <p v-if="card.url" class="browser-card-url">{{ card.url }}</p>
+    <p v-if="card.message || card.installation?.message" role="status">{{ card.message || card.installation?.message }}</p>
+    <progress v-if="card.status === 'preparing'" :value="progress" max="100" aria-label="浏览器准备进度"/>
+    <p v-if="card.installation?.error && card.installation.error !== card.message" class="browser-card-error" role="alert">{{ card.installation.error }}</p>
+    <button v-if="!readOnly && card.status !== 'closed'" type="button" @click="emit('open', card)">{{ card.status === 'failed' ? '重试并接管浏览器' : '接管浏览器' }}<AppIcon name="external" :size="14" aria-hidden="true"/></button>
+    <small v-if="card.status !== 'closed'">接管后暂停机器人的浏览器操作;交还后恢复仍在运行的任务。</small>
+  </section>
+</template>
+<style scoped>
+.managed-browser-card{box-sizing:border-box;display:grid;gap:10px;width:420px;max-width:100%;min-width:0;padding:16px;margin:6px 0;border:1px solid var(--line);border-radius:12px;background:var(--surface-soft);color:var(--text-primary)}
+.browser-card-heading{display:flex;align-items:center;gap:8px;min-width:0;font-size:13px}.browser-card-state{margin-left:auto;font-size:12px;color:var(--text-secondary);text-align:right}.browser-card-title{font-size:15px;line-height:1.5;overflow-wrap:anywhere}.managed-browser-card p{margin:0;font-size:13px;line-height:1.6;overflow-wrap:anywhere;color:var(--text-secondary)}.managed-browser-card .browser-card-url{font-size:12px}.managed-browser-card small{font-size:12px;line-height:1.6;color:var(--text-secondary)}.managed-browser-card .failed,.managed-browser-card .browser-card-error{color:var(--danger)}
+.managed-browser-card progress{width:100%;height:6px;accent-color:var(--accent)}.managed-browser-card button{display:flex;align-items:center;justify-content:center;gap:8px;min-height:44px;padding:10px 14px;border:1px solid var(--line);border-radius:8px;font:inherit;font-size:13px;font-weight:600;background:var(--surface);color:var(--text-primary);cursor:pointer}.managed-browser-card button:hover{background:var(--surface-hover)}.managed-browser-card button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.managed-browser-card button:active{background:var(--surface-raised)}
+@media(max-width:480px){.managed-browser-card{width:100%;padding:12px}.browser-card-heading{flex-wrap:wrap}}
+</style>

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

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

+ 60 - 20
src/client/components/workspace/ComputerPanel.vue

@@ -2,6 +2,7 @@
 import type { ComputerBackend, ManagedBrowserState } from '@shared/managedBrowser'
 import type { ComputerBackend, ManagedBrowserState } from '@shared/managedBrowser'
 import {computed,onBeforeUnmount,onMounted,ref,watch} from 'vue'
 import {computed,onBeforeUnmount,onMounted,ref,watch} from 'vue'
 import {apiRequest} from '@/api/client'
 import {apiRequest} from '@/api/client'
+import {browserCanPrepare,prepareManagedBrowser} from '@/utils/managedBrowser'
 import BrowserToolbar from './BrowserToolbar.vue'
 import BrowserToolbar from './BrowserToolbar.vue'
 import type {BotBrowserState,DesktopEnvironmentState} from '@shared/desktopEnvironment'
 import type {BotBrowserState,DesktopEnvironmentState} from '@shared/desktopEnvironment'
 import {createUuid} from '@/utils/id'
 import {createUuid} from '@/utils/id'
@@ -14,6 +15,9 @@ const backend=ref(props.backend),host=ref(props.host??(props.backend==='desktop'
 const state=ref<ComputerControlStatus>({mode:'off'}),frame=ref<ComputerFrame>(),ticket=ref<{controlId:string;token:string}>()
 const state=ref<ComputerControlStatus>({mode:'off'}),frame=ref<ComputerFrame>(),ticket=ref<{controlId:string;token:string}>()
 const controlsOpen=ref(false),actualSize=ref(false),browserState=ref<BotBrowserState>()
 const controlsOpen=ref(false),actualSize=ref(false),browserState=ref<BotBrowserState>()
 const managedBrowser=ref<ManagedBrowserState>()
 const managedBrowser=ref<ManagedBrowserState>()
+const preparing=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))
 const selectedAgent=computed(()=>props.agents.find(agent=>agent.id===selected.value))
 const error=ref(''),busy=ref(false),text=ref(''),notes=ref(''),connectionOK=ref(false)
 const error=ref(''),busy=ref(false),text=ref(''),notes=ref(''),connectionOK=ref(false)
 let takeRequestId:string|undefined
 let takeRequestId:string|undefined
@@ -29,7 +33,7 @@ const targets=computed(()=>{
   if(envs.desktop)for(const h of info?.hosts??[])list.push({key:'desktop:'+h.id,label:(h.id==='local'?'服务器':'电脑')+' · '+(h.name||'未命名'),backend:'desktop',host:h.id,disabled:!h.online})
   if(envs.desktop)for(const h of info?.hosts??[])list.push({key:'desktop:'+h.id,label:(h.id==='local'?'服务器':'电脑')+' · '+(h.name||'未命名'),backend:'desktop',host:h.id,disabled:!h.online})
   if(envs.cloud)list.push({key:'cloud',label:'云端 · Grok Bot'+(info?.cloudConfigured?'':' · 未连接'),backend:'cloud',disabled:!info?.cloudConfigured})
   if(envs.cloud)list.push({key:'cloud',label:'云端 · Grok Bot'+(info?.cloudConfigured?'':' · 未连接'),backend:'cloud',disabled:!info?.cloudConfigured})
   if(envs.vm)list.push({key:'vm',label:'本地虚拟机'+(info?.vmEnabled?'':' · 未启用'),backend:'vm',disabled:!info?.vmEnabled})
   if(envs.vm)list.push({key:'vm',label:'本地虚拟机'+(info?.vmEnabled?'':' · 未启用'),backend:'vm',disabled:!info?.vmEnabled})
-  if(envs.managedBrowser)list.push({key:'managed-browser',label:'托管浏览器'+(info?.managedBrowser?.available?'':' · 不可用'),backend:'managed-browser',disabled:!info?.managedBrowser?.available})
+  if(envs.managedBrowser){const browser=info?.managedBrowser;list.push({key:'managed-browser',label:'托管浏览器'+(browser?.available?'':browserCanPrepare(browser)?' · '+(browser?.installation?.status==='failed'?'准备失败':'待准备'):' · 不可用'),backend:'managed-browser',disabled:!browserCanPrepare(browser)})}
   return list
   return list
 })
 })
 const currentKey=computed(()=>targets.value.find(t=>t.backend===backend.value&&(t.host??'')===host.value)?.key??'')
 const currentKey=computed(()=>targets.value.find(t=>t.backend===backend.value&&(t.host??'')===host.value)?.key??'')
@@ -49,6 +53,7 @@ async function loadTargets(){
   ])
   ])
   if(closed||selectedId!==selected.value)return
   if(closed||selectedId!==selected.value)return
   envTargets.value={hosts:((desk as DesktopEnvironmentState|undefined)?.hosts??[]).filter(h=>h.id==='local'?settings.serverComputer:settings.scriptMachine),cloudConfigured:!!(cloud as {configured?:boolean}|undefined)?.configured,vmEnabled:!!(vm as {enabled?:boolean}|undefined)?.enabled,managedBrowser:browser}
   envTargets.value={hosts:((desk as DesktopEnvironmentState|undefined)?.hosts??[]).filter(h=>h.id==='local'?settings.serverComputer:settings.scriptMachine),cloudConfigured:!!(cloud as {configured?:boolean}|undefined)?.configured,vmEnabled:!!(vm as {enabled?:boolean}|undefined)?.enabled,managedBrowser:browser}
+  if(backend.value==='managed-browser'){managedBrowser.value=browser;browserState.value=browser}
 }
 }
 async function switchTarget(key:string){
 async function switchTarget(key:string){
   const next=targets.value.find(t=>t.key===key)
   const next=targets.value.find(t=>t.key===key)
@@ -57,6 +62,7 @@ async function switchTarget(key:string){
     try{await post('/giveback',{...ticket.value,notes:''})}catch(cause){error.value=cause instanceof Error?cause.message:'请先交还当前电脑';return}
     try{await post('/giveback',{...ticket.value,notes:''})}catch(cause){error.value=cause instanceof Error?cause.message:'请先交还当前电脑';return}
   }
   }
   backend.value=next.backend;host.value=next.host??''
   backend.value=next.backend;host.value=next.host??''
+  preparation?.abort()
   clearTimeout(clickTimer);pendingClick=undefined;browserState.value=undefined;managedBrowser.value=undefined
   clearTimeout(clickTimer);pendingClick=undefined;browserState.value=undefined;managedBrowser.value=undefined
   inputs.length=0;connectionOK.value=false;generation++;takeRequestId=undefined;frame.value=undefined;ticket.value=undefined;state.value={mode:'off'};error.value=''
   inputs.length=0;connectionOK.value=false;generation++;takeRequestId=undefined;frame.value=undefined;ticket.value=undefined;state.value={mode:'off'};error.value=''
   await refresh()
   await refresh()
@@ -66,25 +72,28 @@ const query=()=>{const q=new URLSearchParams();if(backend.value)q.set('backend',
 async function post(path:string,body:unknown){return apiRequest<any>(endpoint()+path+query(),{method:'POST',body:body as JsonValue})}
 async function post(path:string,body:unknown){return apiRequest<any>(endpoint()+path+query(),{method:'POST',body:body as JsonValue})}
 async function refresh(){
 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)return
-  polling=true;const current=++generation,base=endpoint()
+  polling=true;const current=++generation,base=endpoint(),targetQuery=query(),id=selected.value
   try{
   try{
-    const status=await apiRequest<ComputerControlStatus>(base+query())
+    if(backend.value==='managed-browser'){
+      const browser=await apiRequest<ManagedBrowserState>(`/api/app/agents/${encodeURIComponent(id)}/managed-browser`)
+      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}
+    }
+    const status=await apiRequest<ComputerControlStatus>(base+targetQuery)
     if(current!==generation||closed)return
     if(current!==generation||closed)return
     state.value=status
     state.value=status
     connectionOK.value=true
     connectionOK.value=true
     if(ticket.value&&status.controlId!==ticket.value.controlId&&status.mode!=='pausing')ticket.value=undefined
     if(ticket.value&&status.controlId!==ticket.value.controlId&&status.mode!=='pausing')ticket.value=undefined
     if(mine.value&&Date.now()-lastRenew>8000){await post('/renew',ticket.value);lastRenew=Date.now()}
     if(mine.value&&Date.now()-lastRenew>8000){await post('/renew',ticket.value);lastRenew=Date.now()}
-    if(status.backend==='managed-browser'){
-      const browser=await apiRequest<ManagedBrowserState>(`/api/app/agents/${encodeURIComponent(selected.value)}/managed-browser`)
-      if(current!==generation||closed)return
-      managedBrowser.value=browser;browserState.value=browser
-    }else if(status.backend==='browser'){
+    if(status.backend==='browser'){
       const browser=await apiRequest<BotBrowserState>(`/api/app/agents/${encodeURIComponent(selected.value)}/browser`)
       const browser=await apiRequest<BotBrowserState>(`/api/app/agents/${encodeURIComponent(selected.value)}/browser`)
       if(current!==generation||closed)return
       if(current!==generation||closed)return
       browserState.value=browser;managedBrowser.value=undefined
       browserState.value=browser;managedBrowser.value=undefined
-    }else {browserState.value=undefined;managedBrowser.value=undefined}
+    }else if(status.backend!=='managed-browser'){browserState.value=undefined;managedBrowser.value=undefined}
     if(status.mode!=='off'&&(!['browser','managed-browser'].includes(status.backend??'')||browserState.value?.open)){
     if(status.mode!=='off'&&(!['browser','managed-browser'].includes(status.backend??'')||browserState.value?.open)){
-      const image=await apiRequest<ComputerFrame>(base+'/frame'+query())
+      const image=await apiRequest<ComputerFrame>(base+'/frame'+targetQuery)
       if(current===generation&&!closed)frame.value=image
       if(current===generation&&!closed)frame.value=image
     }else frame.value=undefined
     }else frame.value=undefined
     error.value=''
     error.value=''
@@ -94,9 +103,31 @@ async function refresh(){
 }
 }
 async function run(action:()=>Promise<void>){
 async function run(action:()=>Promise<void>){
   if(busy.value)return;generation++;busy.value=true;error.value=''
   if(busy.value)return;generation++;busy.value=true;error.value=''
-  try{await action()}catch(cause){error.value=cause instanceof Error?cause.message:'电脑操作失败'}finally{busy.value=false;await refresh();void flushInputs()}
+  try{await action()}catch(cause){if(!closed)error.value=cause instanceof Error?cause.message:'电脑操作失败'}finally{busy.value=false;if(!closed){await refresh();void flushInputs()}}
 }
 }
-function take(){return run(async()=>{if(!targetAvailable.value)throw new Error('所选电脑不可用,请重新选择');takeRequestId??=createUuid();const value=await post('/take',{requestId:takeRequestId});ticket.value={controlId:value.controlId,token:value.token};state.value=value;lastRenew=0})}
+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()
+  preparation=controller
+  try{
+    if(target==='managed-browser'){
+      preparing.value=!managedBrowser.value?.available
+      await prepareManagedBrowser(id,managedBrowser.value,controller.signal,browser=>{
+        if(!closed&&id===selected.value&&target===backend.value){managedBrowser.value=browser;browserState.value=browser;if(envTargets.value)envTargets.value.managedBrowser=browser}
+      })
+    }
+    controller.signal.throwIfAborted()
+    if(closed||id!==selected.value||target!==backend.value)return
+    preparing.value=false;takeRequestId??=createUuid()
+    // Do not abort a submitted takeover: retain its receipt so a late response
+    // can be returned to the exact original target after this view closes.
+    const value=await apiRequest<any>(base+'/take'+targetQuery,{method:'POST',body:{requestId:takeRequestId}})
+    const credentials={controlId:value.controlId,token:value.token}
+    if(closed||controller.signal.aborted||id!==selected.value||target!==backend.value){await apiRequest(base+'/giveback'+targetQuery,{method:'POST',body:{...credentials,notes:''}});return}
+    ticket.value=credentials;state.value=value;lastRenew=0
+  }catch(cause){if(!controller.signal.aborted)throw cause}
+  finally{if(preparation===controller){preparation=undefined;preparing.value=false}}
+})}
 const inputs:ComputerInput[]=[];let flushing=false,pendingClick:ComputerInput|undefined,clickTimer:ReturnType<typeof setTimeout>|undefined
 const inputs:ComputerInput[]=[];let flushing=false,pendingClick:ComputerInput|undefined,clickTimer:ReturnType<typeof setTimeout>|undefined
 function send(action:ComputerInput){
 function send(action:ComputerInput){
   if(!canSend.value||closed)return
   if(!canSend.value||closed)return
@@ -144,10 +175,11 @@ function click(event:MouseEvent){
   else {if(pendingClick){const old=pendingClick;pendingClick=undefined;clearTimeout(clickTimer);send(old)}pendingClick=action;clickTimer=setTimeout(()=>{const next=pendingClick;pendingClick=undefined;if(next)send(next)},180)}
   else {if(pendingClick){const old=pendingClick;pendingClick=undefined;clearTimeout(clickTimer);send(old)}pendingClick=action;clickTimer=setTimeout(()=>{const next=pendingClick;pendingClick=undefined;if(next)send(next)},180)}
 }
 }
 async function giveBack(){if(ticket.value){await post('/giveback',{...ticket.value,notes:notes.value});ticket.value=undefined;takeRequestId=undefined;notes.value=''}}
 async function giveBack(){if(ticket.value){await post('/giveback',{...ticket.value,notes:notes.value});ticket.value=undefined;takeRequestId=undefined;notes.value=''}}
-async function close(){if(busy.value)return;await run(giveBack);if(!ticket.value){closed=true;if(!props.embedded&&!props.standalone)dialog.value?.close();emit('close')}}
+async function close(){if(preparing.value){preparation?.abort();closed=true;dialog.value?.close?.();emit('close');return}if(busy.value)return;await run(giveBack);if(!ticket.value){closed=true;if(!props.embedded&&!props.standalone)dialog.value?.close();emit('close')}}
 async function fullscreen(){try{if(document.fullscreenElement)await document.exitFullscreen();else await dialog.value?.requestFullscreen()}catch{error.value='无法进入全屏,请使用窗口最大化'}}
 async function fullscreen(){try{if(document.fullscreenElement)await document.exitFullscreen();else await dialog.value?.requestFullscreen()}catch{error.value='无法进入全屏,请使用窗口最大化'}}
 async function cycle(){await refresh();if(!closed)timer=setTimeout(()=>void cycle(),1200)}
 async function cycle(){await refresh();if(!closed)timer=setTimeout(()=>void cycle(),1200)}
 watch(selected,()=>{
 watch(selected,()=>{
+  preparation?.abort()
   clearTimeout(clickTimer);pendingClick=undefined;browserState.value=undefined;managedBrowser.value=undefined
   clearTimeout(clickTimer);pendingClick=undefined;browserState.value=undefined;managedBrowser.value=undefined
   inputs.length=0;connectionOK.value=false;generation++;takeRequestId=undefined;frame.value=undefined;ticket.value=undefined;state.value={mode:'off'};error.value=''
   inputs.length=0;connectionOK.value=false;generation++;takeRequestId=undefined;frame.value=undefined;ticket.value=undefined;state.value={mode:'off'};error.value=''
   backend.value=props.backend;host.value=props.host??(props.backend==='desktop'?'local':'');envTargets.value=undefined
   backend.value=props.backend;host.value=props.host??(props.backend==='desktop'?'local':'');envTargets.value=undefined
@@ -164,16 +196,16 @@ onMounted(async()=>{
  }catch(cause){error.value=cause instanceof Error?cause.message:'无法读取电脑列表'}
  }catch(cause){error.value=cause instanceof Error?cause.message:'无法读取电脑列表'}
  if(!closed)void cycle()
  if(!closed)void cycle()
 })
 })
-onBeforeUnmount(()=>{inputs.length=0;pendingClick=undefined;clearTimeout(clickTimer);closed=true;generation++;clearTimeout(timer)})
-defineExpose({take,close,releaseControl:async()=>{await run(giveBack);if(ticket.value)throw new Error(error.value||'请先交还控制权')},hasControl:()=>!!ticket.value})
+onBeforeUnmount(()=>{preparation?.abort();if(ticket.value)void post('/giveback',{...ticket.value,notes:notes.value}).catch(()=>{});inputs.length=0;pendingClick=undefined;clearTimeout(clickTimer);closed=true;generation++;clearTimeout(timer)})
+defineExpose({take,close,releaseControl:async()=>{if(preparing.value){preparation?.abort();return}if(busy.value)throw new Error('请等待当前操作完成');await run(giveBack);if(ticket.value)throw new Error(error.value||'请先交还控制权')},hasControl:()=>!!ticket.value})
 </script>
 </script>
 <template>
 <template>
   <Teleport to="body" :disabled="embedded||standalone">
   <Teleport to="body" :disabled="embedded||standalone">
     <component :is="embedded||standalone?'section':'dialog'" ref="dialog" class="computer-panel" :class="{embedded,standalone}" :aria-label="targets.find(t=>t.key===currentKey)?.label||'电脑桌面'" @cancel.prevent="close">
     <component :is="embedded||standalone?'section':'dialog'" ref="dialog" class="computer-panel" :class="{embedded,standalone}" :aria-label="targets.find(t=>t.key===currentKey)?.label||'电脑桌面'" @cancel.prevent="close">
       <header>
       <header>
-        <div><strong>{{state.backend==='grok'?'Grok Bot 云端电脑':state.backend==='local'?(state.hostName??'服务器桌面'):state.backend==='managed-browser'?'托管浏览器':state.backend==='browser'?'浏览器 · '+state.hostName:'本地虚拟机'}}</strong><small>{{ labels[state.mode] }}</small></div>
+        <div><strong>{{backend==='managed-browser'?'托管浏览器':state.backend==='grok'?'Grok Bot 云端电脑':state.backend==='local'?(state.hostName??'服务器桌面'):state.backend==='browser'?'浏览器 · '+state.hostName:'本地虚拟机'}}</strong><small>{{ preparing?'正在准备浏览器':labels[state.mode] }}</small></div>
         <select v-if="targets.length>1" :value="currentKey" aria-label="切换电脑环境" :disabled="!!ticket||busy" @change="switchTarget(($event.target as HTMLSelectElement).value)"><option v-for="target in targets" :key="target.key" :value="target.key" :disabled="target.disabled">{{ target.label }}</option></select>
         <select v-if="targets.length>1" :value="currentKey" aria-label="切换电脑环境" :disabled="!!ticket||busy" @change="switchTarget(($event.target as HTMLSelectElement).value)"><option v-for="target in targets" :key="target.key" :value="target.key" :disabled="target.disabled">{{ target.label }}</option></select>
-        <select v-if="agents.length>1" v-model="selected" aria-label="选择电脑" :disabled="!!ticket"><option v-for="agent in agents" :key="agent.id" :value="agent.id">{{ agent.name }}</option></select>
+        <select v-if="agents.length>1" v-model="selected" aria-label="选择电脑" :disabled="!!ticket||busy"><option v-for="agent in agents" :key="agent.id" :value="agent.id">{{ agent.name }}</option></select>
         <span v-else-if="!embedded">{{ agents[0]?.name }}</span>
         <span v-else-if="!embedded">{{ agents[0]?.name }}</span>
         <button v-if="embedded&&!ticket" :disabled="busy||state.mode==='human'" @click="emit('controlRequest')">接管电脑</button>
         <button v-if="embedded&&!ticket" :disabled="busy||state.mode==='human'" @click="emit('controlRequest')">接管电脑</button>
         <button v-if="embedded&&mine" @click="controlsOpen=!controlsOpen">键盘与操作</button>
         <button v-if="embedded&&mine" @click="controlsOpen=!controlsOpen">键盘与操作</button>
@@ -183,12 +215,18 @@ defineExpose({take,close,releaseControl:async()=>{await run(giveBack);if(ticket.
           <button type="button" :aria-pressed="actualSize" @click="actualSize=!actualSize">{{actualSize?'适应窗口':'原始大小'}}</button>
           <button type="button" :aria-pressed="actualSize" @click="actualSize=!actualSize">{{actualSize?'适应窗口':'原始大小'}}</button>
           <button type="button" @click="fullscreen">全屏</button>
           <button type="button" @click="fullscreen">全屏</button>
         </template>
         </template>
-        <button v-if="!embedded" class="return-control" type="button" :disabled="busy" @click="close">{{ ticket ? '交还并关闭' : '关闭' }}</button>
+        <button v-if="!embedded" class="return-control" type="button" :disabled="busy&&!preparing" @click="close">{{ ticket ? '交还并关闭' : '关闭' }}</button>
       </header>
       </header>
       <BrowserToolbar v-if="state.backend==='browser'||state.backend==='managed-browser'" :state="browserState" :enabled="controllable" @action="browserAction"/>
       <BrowserToolbar v-if="state.backend==='browser'||state.backend==='managed-browser'" :state="browserState" :enabled="controllable" @action="browserAction"/>
+      <section v-if="installation&&installation.status!=='ready'" class="browser-installation" :aria-busy="preparing">
+        <p role="status">{{ installation.message }}</p>
+        <progress v-if="installation.status==='installing'||installation.status==='checking'" :value="installation.progress" max="100" aria-label="浏览器准备进度"/>
+        <small v-if="preparing">关闭窗口后仍会继续准备。</small>
+        <p v-if="installation.error" role="alert">{{ installation.error }}</p>
+      </section>
       <div class="computer-screen" :class="{controlling:canSend,'actual-size':actualSize}">
       <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">
         <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>{{ state.mode==='off' ? (backend==='managed-browser'?'浏览器尚未打开。接管后可打开网页。':'打开电脑后可查看桌面。') : '正在读取电脑画面…' }}</p>
+        <p v-else>{{ preparing?'准备完成后会自动接管,无需再次点击。':state.mode==='off' ? (backend==='managed-browser'?'浏览器尚未打开。接管后可打开网页。':'打开电脑后可查看桌面。') : '正在读取电脑画面…' }}</p>
         <span v-if="frame&&(!mine||frame.generation!==state.generation)" class="view-label">{{mine?'正在同步画面':state.mode==='off'?'上次画面':'仅查看'}}</span>
         <span v-if="frame&&(!mine||frame.generation!==state.generation)" class="view-label">{{mine?'正在同步画面':state.mode==='off'?'上次画面':'仅查看'}}</span>
       </div>
       </div>
       <p v-if="error||state.error" class="computer-error" role="alert">{{ error||state.error }}</p>
       <p v-if="error||state.error" class="computer-error" role="alert">{{ error||state.error }}</p>
@@ -199,7 +237,8 @@ defineExpose({take,close,releaseControl:async()=>{await run(giveBack);if(ticket.
         <p v-if="state.backend==='managed-browser'">当前是托管浏览器,与电脑桌面和虚拟机分别管理;网页操作无需启动虚拟机。</p>
         <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>
         <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>
         <p v-if="!ticket">查看不会输入任何内容。接管后机器人会暂停,已有操作结束后才允许人工操作。</p>
-        <button v-if="!ticket&&state.mode!=='human'" type="button" :disabled="busy||!targetAvailable" @click="embedded?emit('controlRequest'):take()">{{ state.mode==='off'?'打开并控制':'接管电脑' }}</button>
+        <button v-if="!ticket&&!targetAvailable" 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">
         <template v-if="mine">
 
 
           <section v-if="controlsOpen" class="manual-input">
           <section v-if="controlsOpen" class="manual-input">
@@ -217,6 +256,7 @@ defineExpose({take,close,releaseControl:async()=>{await run(giveBack);if(ticket.
   </Teleport>
   </Teleport>
 </template>
 </template>
 <style scoped>
 <style scoped>
+.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.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}
 .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}
 .computer-panel.standalone{display:flex;flex-direction:column;width:100vw;height:100dvh;max-height:none;margin:0;border:0;border-radius:0;overflow:hidden}
 .computer-panel.standalone{display:flex;flex-direction:column;width:100vw;height:100dvh;max-height:none;margin:0;border:0;border-radius:0;overflow:hidden}

+ 5 - 3
src/client/components/workspace/LocalVmChatPanel.vue

@@ -2,6 +2,7 @@
 import type { ComputerBackend, ManagedBrowserState } from '@shared/managedBrowser'
 import type { ComputerBackend, ManagedBrowserState } from '@shared/managedBrowser'
 import {ref,computed,watch,onBeforeUnmount} from 'vue'
 import {ref,computed,watch,onBeforeUnmount} from 'vue'
 import {apiRequest} from '@/api/client'
 import {apiRequest} from '@/api/client'
+import {browserCanPrepare} from '@/utils/managedBrowser'
 import AppIcon from '@/components/common/AppIcon.vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import type {DesktopEnvironmentState} from '@shared/desktopEnvironment'
 import type {DesktopEnvironmentState} from '@shared/desktopEnvironment'
 import GrokAuthPanel from './GrokAuthPanel.vue'
 import GrokAuthPanel from './GrokAuthPanel.vue'
@@ -57,7 +58,7 @@ const previewEmptyText=computed(()=>{
  if(!target)return previewKey.value?'所选电脑已不可用,请重新选择':'暂无已开放的电脑'
  if(!target)return previewKey.value?'所选电脑已不可用,请重新选择':'暂无已开放的电脑'
  if(target.backend==='desktop'){const host=native.value?.hosts?.find(h=>h.id===target.host);return !host?.online?'这台电脑已离线':host.local.ready?'正在读取电脑画面…':'请完成这台电脑的屏幕控制授权'}
  if(target.backend==='desktop'){const host=native.value?.hosts?.find(h=>h.id===target.host);return !host?.online?'这台电脑已离线':host.local.ready?'正在读取电脑画面…':'请完成这台电脑的屏幕控制授权'}
  if(target.backend==='cloud')return cloud.value?.configured?(cloud.value?.running?'正在连接桌面…':'打开云端电脑后可查看桌面'):'尚未连接 Grok Bot 账号'
  if(target.backend==='cloud')return cloud.value?.configured?(cloud.value?.running?'正在连接桌面…':'打开云端电脑后可查看桌面'):'尚未连接 Grok Bot 账号'
- if(target.backend==='managed-browser')return !managedBrowser.value?.available?(managedBrowser.value?.reason||'托管浏览器执行节点不可用'):managedBrowser.value.open?'正在读取浏览器画面…':'浏览器尚未打开,接管后可打开网页'
+ if(target.backend==='managed-browser')return !managedBrowser.value?.available?(managedBrowser.value?.installation?.message||managedBrowser.value?.reason||'托管浏览器执行节点不可用'):managedBrowser.value.open?'正在读取浏览器画面…':'浏览器尚未打开,接管后可打开网页'
  if(!state.value?.enabled)return state.value?.fixedCapacity?'请先连接下方的共享桌面':'未使用电脑'
  if(!state.value?.enabled)return state.value?.fixedCapacity?'请先连接下方的共享桌面':'未使用电脑'
  if(state.value.problem)return state.value.problem
  if(state.value.problem)return state.value.problem
  if(!state.value.image)return '本地虚拟机尚未准备'
  if(!state.value.image)return '本地虚拟机尚未准备'
@@ -155,8 +156,9 @@ onBeforeUnmount(()=>{closed=true;revision++;previewRevision++;clearTimeout(timer
     <section v-if="computers.managedBrowser" class="managed-browser-option" aria-label="托管浏览器">
     <section v-if="computers.managedBrowser" class="managed-browser-option" aria-label="托管浏览器">
       <strong>托管浏览器</strong>
       <strong>托管浏览器</strong>
       <p>网页操作使用独立浏览器,虚拟机按任务需要单独启动。查看状态不会打开网页。</p>
       <p>网页操作使用独立浏览器,虚拟机按任务需要单独启动。查看状态不会打开网页。</p>
-      <p v-if="!managedBrowser?.available" class="hint">{{managedBrowser?.reason||'执行节点尚未提供浏览器能力,请检查节点配置与连接。'}}</p>
-      <button class="primary" :disabled="busy||!managedBrowser?.available" @click="openDesktop('managed-browser')">{{managedBrowser?.open?'接管浏览器':'打开并接管浏览器'}}</button>
+      <p v-if="!managedBrowser?.available" class="hint" role="status">{{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>
     </section>
     </section>
     <template v-if="showDesktop">
     <template v-if="showDesktop">
       <p>服务器是运行夭夭服务的电脑;电脑是连接这台服务器的 Mac 或 Windows 电脑。两者都可改名。「本机」跟你当前发消息所在的电脑走。要文件用文件工具,要命令用 shell;只有需要看窗口或点按时才截图。</p>
       <p>服务器是运行夭夭服务的电脑;电脑是连接这台服务器的 Mac 或 Windows 电脑。两者都可改名。「本机」跟你当前发消息所在的电脑走。要文件用文件工具,要命令用 shell;只有需要看窗口或点按时才截图。</p>

+ 5 - 1
src/client/components/workspace/WorkspaceMessageTimeline.vue

@@ -15,6 +15,7 @@ import TypingIndicator from '@/components/common/TypingIndicator.vue'
 import InteractionCard from '../messages/InteractionCard.vue'
 import InteractionCard from '../messages/InteractionCard.vue'
 import WorkspaceApprovalCard from './WorkspaceApprovalCard.vue'
 import WorkspaceApprovalCard from './WorkspaceApprovalCard.vue'
 import TaskResultNotice from './TaskResultNotice.vue'
 import TaskResultNotice from './TaskResultNotice.vue'
+import ManagedBrowserCard from '../messages/ManagedBrowserCard.vue'
 import MarkdownContent from '../messages/MarkdownContent.vue'
 import MarkdownContent from '../messages/MarkdownContent.vue'
 import ToolTrace from '../messages/ToolTrace.vue'
 import ToolTrace from '../messages/ToolTrace.vue'
 import TurnTrace from '../messages/TurnTrace.vue'
 import TurnTrace from '../messages/TurnTrace.vue'
@@ -96,6 +97,7 @@ const emit = defineEmits<{
   previewFile: [file: UiLocalFileLink]
   previewFile: [file: UiLocalFileLink]
   approvalChoice: [choice: 'once' | 'deny' | 'auto']
   approvalChoice: [choice: 'once' | 'deny' | 'auto']
   clarify: [text: string]
   clarify: [text: string]
+  browserControl: [card: NonNullable<UiMessage['browserCard']>]
 }>()
 }>()
 
 
 const scroller = ref<HTMLElement | null>(null)
 const scroller = ref<HTMLElement | null>(null)
@@ -349,11 +351,13 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
             <span class="communication-caption__name">{{ message.communication.peerName }}</span>
             <span class="communication-caption__name">{{ message.communication.peerName }}</span>
           </div>
           </div>
           <div class="message__body">
           <div class="message__body">
+            <div v-if="message.browserCard && showAssistantIdentity" class="message__meta"><strong>{{ message.author || message.browserCard.agentName }}</strong><time>{{ formatTime(message.createdAt) }}</time></div>
+            <ManagedBrowserCard v-if="message.browserCard" :card="message.browserCard" :read-only="readOnly" @open="emit('browserControl', $event)"/>
             <MessageFailureNotice v-if="messageFailure(message)?.replacesContent && !message.attachments?.length" :failure="messageFailure(message)!" />
             <MessageFailureNotice v-if="messageFailure(message)?.replacesContent && !message.attachments?.length" :failure="messageFailure(message)!" />
             <TaskResultNotice v-else-if="message.role === 'system' && message.taskReference" :content="message.content" :task-reference="message.taskReference" />
             <TaskResultNotice v-else-if="message.role === 'system' && message.taskReference" :content="message.content" :task-reference="message.taskReference" />
             <MessageSystemEvent v-else-if="systemMessageNotice(message)" :message="message" />
             <MessageSystemEvent v-else-if="systemMessageNotice(message)" :message="message" />
             <template v-else>
             <template v-else>
-            <div v-if="!message.communication && (message.role !== 'assistant' || showAssistantIdentity)" class="message__meta">
+            <div v-if="!message.browserCard && !message.communication && (message.role !== 'assistant' || showAssistantIdentity)" class="message__meta">
               <strong><AppIcon v-if="message.isRemoteAgent" class="message__remote-agent" name="globe" :size="12" />{{ message.role === 'user' ? '你' : message.author || message.profile || (message.role === 'assistant' ? '机器人' : '系统') }}</strong>
               <strong><AppIcon v-if="message.isRemoteAgent" class="message__remote-agent" name="globe" :size="12" />{{ message.role === 'user' ? '你' : message.author || message.profile || (message.role === 'assistant' ? '机器人' : '系统') }}</strong>
               <span v-if="message.metadata" class="message__execution">{{ message.metadata }}</span>
               <span v-if="message.metadata" class="message__execution">{{ message.metadata }}</span>
               <time>{{ formatTime(message.createdAt) }}</time>
               <time>{{ formatTime(message.createdAt) }}</time>

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

@@ -389,6 +389,7 @@ export function workspaceMessagesToUi(messages: import('@shared/workspace').Work
   return messages.filter(message => message.visible !== false).map(message => ({
   return messages.filter(message => message.visible !== false).map(message => ({
     id: message.id, role: message.role, author: message.agentName,
     id: message.id, role: message.role, author: message.agentName,
     communication: message.communication,
     communication: message.communication,
+    browserCard: message.browserCard,
     taskReference: message.taskReference,
     taskReference: message.taskReference,
     error: message.error, runId: message.runId,
     error: message.error, runId: message.runId,
     profile: message.agentId, createdAt: message.createdAt,
     profile: message.agentId, createdAt: message.createdAt,

+ 32 - 0
src/client/utils/managedBrowser.ts

@@ -0,0 +1,32 @@
+import { apiRequest } from '@/api/client'
+import type { ManagedBrowserState } from '@shared/managedBrowser'
+
+export function browserCanPrepare(state?: ManagedBrowserState): boolean {
+  return !!state?.enabled && (!!state.available || !!state.installation)
+}
+
+/** Preparing installs the runtime only; taking control remains a separate action. */
+export async function prepareManagedBrowser(agentId: string, initial: ManagedBrowserState | undefined, signal: AbortSignal, update: (state: ManagedBrowserState) => void): Promise<void> {
+  const path = `/api/app/agents/${encodeURIComponent(agentId)}/managed-browser`
+  let state = initial ?? await apiRequest<ManagedBrowserState>(path, { signal })
+  signal.throwIfAborted()
+  if (!browserCanPrepare(state)) throw new Error(state.reason || '托管浏览器不可用,请检查执行节点与授权')
+  if (!state.available && state.installation?.status !== 'installing') {
+    state = await apiRequest<ManagedBrowserState>(path + '/prepare', { method: 'POST', body: { retry: state.installation?.status === 'failed' }, signal })
+  }
+  const started = Date.now()
+  for (;;) {
+    signal.throwIfAborted(); update(state)
+    if (state.available) return
+    if (state.installation?.status === 'failed') throw new Error(state.installation.error || state.installation.message || '浏览器准备失败,请重试')
+    if (!browserCanPrepare(state)) throw new Error(state.reason || '执行节点已不可用,请重新连接后重试')
+    if (Date.now() - started > 10 * 60_000) throw new Error('浏览器仍在准备中,可稍后重新接管查看进度')
+    await new Promise<void>((resolve, reject) => {
+      const abort = () => { clearTimeout(timer); reject(signal.reason ?? new DOMException('Aborted', 'AbortError')) }
+      const timer = setTimeout(() => { signal.removeEventListener('abort', abort); resolve() }, 1000)
+      signal.addEventListener('abort', abort, { once: true })
+      if (signal.aborted) abort()
+    })
+    state = await apiRequest<ManagedBrowserState>(path, { signal })
+  }
+}

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

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

+ 13 - 0
src/client/views/ConversationsView.vue

@@ -240,8 +240,20 @@ const CREATE_TASK_VALUE='__create_task__'
 function toggleDock(view:'computer'|'inspector'){dockView.value=dockView.value===view?undefined:view}
 function toggleDock(view:'computer'|'inspector'){dockView.value=dockView.value===view?undefined:view}
 async function openComputer(agent:Agent,backend?:ComputerBackend,host?:string){
 async function openComputer(agent:Agent,backend?:ComputerBackend,host?:string){
   if(window.yaoyaoDesktop?.openComputer){try{await window.yaoyaoDesktop.openComputer(agent.id,{backend,host})}catch(cause){error.value=cause instanceof Error?cause.message:'无法打开电脑窗口'}return}
   if(window.yaoyaoDesktop?.openComputer){try{await window.yaoyaoDesktop.openComputer(agent.id,{backend,host})}catch(cause){error.value=cause instanceof Error?cause.message:'无法打开电脑窗口'}return}
+  if(computerOpen.value&&desktopViewer.value){
+    if(desktopAgent.value?.id===agent.id&&desktopBackend.value===backend&&desktopHost.value===(host??''))return
+    try{await desktopViewer.value.releaseControl();computerOpen.value=false;await nextTick()}catch(cause){error.value=cause instanceof Error?cause.message:'请先交还当前电脑';return}
+  }
   desktopAgent.value=agent;desktopBackend.value=backend;desktopHost.value=host??'';computerOpen.value=true
   desktopAgent.value=agent;desktopBackend.value=backend;desktopHost.value=host??'';computerOpen.value=true
 }
 }
+async function openBrowserCard(card:NonNullable<UiMessage['browserCard']>){
+  // Only the structured server message supplies this target; markdown links and
+  // model-authored text never become computer-control instructions.
+  if(!messages.value.some(message=>message.browserCard?.id===card.id&&message.browserCard.agentId===card.agentId&&message.browserCard.status!=='closed'))return
+  const agent=agents.value.find(agent=>agent.id===card.agentId&&!agent.archived)
+  if(!agent){error.value='此浏览器所属机器人已不可用,或你已没有访问权限';return}
+  await openComputer(agent,'managed-browser')
+}
 async function leaveComputer(){
 async function leaveComputer(){
   if(twoDesktops.value&&vmWorkspace.value&&!await vmWorkspace.value.close())return false
   if(twoDesktops.value&&vmWorkspace.value&&!await vmWorkspace.value.close())return false
   if(computerOpen.value&&desktopViewer.value){try{await desktopViewer.value.releaseControl();computerOpen.value=false}catch{return false}}
   if(computerOpen.value&&desktopViewer.value){try{await desktopViewer.value.releaseControl();computerOpen.value=false}catch{return false}}
@@ -1028,6 +1040,7 @@ onBeforeUnmount(() => {
         :interaction-busy="!!respondingInteractionId" :interaction-error="interactionError"
         :interaction-busy="!!respondingInteractionId" :interaction-error="interactionError"
         :approval-agent-name="agents.find(agent => agent.id === interactions[0]?.agentId)?.name"
         :approval-agent-name="agents.find(agent => agent.id === interactions[0]?.agentId)?.name"
         @load-older="loadOlder" @quote="quoted = $event" @preview="openPreview" @preview-file="openPreview"
         @load-older="loadOlder" @quote="quoted = $event" @preview="openPreview" @preview-file="openPreview"
+        @browser-control="openBrowserCard"
         @approval-choice="interactions[0] && respond(interactions[0], $event)"
         @approval-choice="interactions[0] && respond(interactions[0], $event)"
         @clarify="interactions[0] && respond(interactions[0], $event)">
         @clarify="interactions[0] && respond(interactions[0], $event)">
         <template #header-leading><button v-if="active" class="workspace-list-back icon-button" aria-label="返回 Bot 列表" @click="router.push('/conversations')"><AppIcon name="chevron-left" /></button></template>
         <template #header-leading><button v-if="active" class="workspace-list-back icon-button" aria-label="返回 Bot 列表" @click="router.push('/conversations')"><AppIcon name="chevron-left" /></button></template>

+ 3 - 2
src/runner/agent.ts

@@ -60,9 +60,10 @@ export class RunnerAgent {
   private async api(path:string,body?:unknown):Promise<any> {
   private async api(path:string,body?:unknown):Promise<any> {
     const url=new URL(`/api/runner/v1/${this.config.runnerId}/${path}`,this.config.serverURL)
     const url=new URL(`/api/runner/v1/${this.config.runnerId}/${path}`,this.config.serverURL)
     if(url.hostname==='localhost')url.hostname='127.0.0.1'
     if(url.hostname==='localhost')url.hostname='127.0.0.1'
+    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,{
     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: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?.available?',managed-browser-v1':''),...(this.serverEpoch?{'x-runner-epoch':this.serverEpoch}:{}),'Content-Type':'application/json',...(path==='poll'&&!this.connected?{'x-runner-reset':'1'}:{})},
+      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'}:{})},
       ...(body===undefined?{}:{body:JSON.stringify(body)}),
       ...(body===undefined?{}:{body:JSON.stringify(body)}),
     })
     })
     if(!response.ok){
     if(!response.ok){

+ 131 - 0
src/runner/browser/installation.ts

@@ -0,0 +1,131 @@
+import {spawn,type ChildProcess} from 'node:child_process'
+import {existsSync} from 'node:fs'
+import {createRequire} from 'node:module'
+import {dirname,join} from 'node:path'
+import type {BrowserInstallation} from '../../shared/managedBrowser.js'
+
+export interface BrowserInstallOptions {
+  signal:AbortSignal
+  onUpdate:(update:Pick<BrowserInstallation,'message'|'progress'>)=>void
+}
+export interface BrowserInstaller {
+  probe:()=>Promise<boolean>
+  install:(options:BrowserInstallOptions)=>Promise<void>
+}
+interface ChromiumProbe {
+  executablePath:()=>string
+  launch:(options:{headless:boolean;chromiumSandbox:boolean;timeout:number})=>Promise<{close:()=>Promise<void>}>
+}
+export interface BrowserInstallerDependencies {
+  spawn?:typeof spawn
+  chromium?:()=>Promise<ChromiumProbe>
+  cliPath?:()=>string
+  platform?:NodeJS.Platform
+  getuid?:()=>number
+  execPath?:string
+  env?:NodeJS.ProcessEnv
+  timeoutMs?:number
+}
+export class BrowserInstallationError extends Error {
+  constructor(readonly code:string,message:string){super(message);this.name='BrowserInstallationError'}
+}
+const failure=(code:string,message:string)=>new BrowserInstallationError(code,message)
+/** Never return CLI output: it can contain authenticated mirrors and personal paths. */
+export function browserInstallationFailure(error:unknown):string {
+  return error instanceof BrowserInstallationError?error.message:'浏览器准备失败,请检查节点网络和磁盘空间后重试'
+}
+const dependencyFailure=(message:string)=>/Host system is missing dependencies|Missing libraries:|error while loading shared libraries|cannot open shared object file/i.test(message)
+const sandboxFailure=(message:string)=>/Chromium sandboxing failed|No usable sandbox|SUID sandbox|Running as root without --no-sandbox|Failed to move to new namespace|Failed to unshare|Operation not permitted.*sandbox|sandbox.*Operation not permitted/i.test(message)
+
+/** Installs exactly the Chromium revision shipped with this Runner's Playwright. */
+export function createBrowserInstaller(dependencies:BrowserInstallerDependencies={}):BrowserInstaller {
+  const spawnChild=dependencies.spawn??spawn,platform=dependencies.platform??process.platform
+  const chromium=dependencies.chromium??(async()=> (await import('playwright')).chromium)
+  const cliPath=dependencies.cliPath??(()=>join(dirname(createRequire(import.meta.url).resolve('playwright/package.json')),'cli.js'))
+  const env:NodeJS.ProcessEnv={...(dependencies.env??process.env),ELECTRON_RUN_AS_NODE:'1',CI:'1',DEBIAN_FRONTEND:'noninteractive'}
+  // Do not inherit debugging or Node preload options into the installer child.
+  delete env.NODE_OPTIONS;delete env.NODE_V8_COVERAGE;delete env.PWDEBUG
+  async function runCLI(args:string[],signal:AbortSignal,onUpdate:BrowserInstallOptions['onUpdate']){
+    signal.throwIfAborted()
+    await new Promise<void>((resolve,reject)=>{
+      let child:ChildProcess,tail='',line='',killTimer:ReturnType<typeof setTimeout>|undefined,settled=false
+      const finish=(error?:unknown)=>{
+        if(settled)return;settled=true;signal.removeEventListener('abort',abort)
+        // The CLI may exit before a downloader grandchild; terminate its entire
+        // process group before reporting cancellation as complete.
+        if(signal.aborted&&platform!=='win32')kill(true)
+        if(killTimer)clearTimeout(killTimer)
+        if(error)reject(error);else resolve()
+      }
+      const kill=(hard=false)=>{
+        if(!child.pid)return
+        if(platform==='win32'){
+          // taskkill is non-interactive, and the PID comes from our own child.
+          const killer=spawnChild('taskkill',['/pid',String(child.pid),'/t','/f'],{stdio:'ignore',windowsHide:true});killer.on('error',()=>child.kill())
+        }else{try{process.kill(-child.pid,hard?'SIGKILL':'SIGTERM')}catch{child.kill(hard?'SIGKILL':'SIGTERM')}}
+      }
+      const abort=()=>{kill();if(!settled){killTimer=setTimeout(()=>kill(true),2000);killTimer.unref()}}
+      try{child=spawnChild(dependencies.execPath??process.execPath,[cliPath(),...args],{shell:false,stdio:['ignore','pipe','pipe'],detached:platform!=='win32',windowsHide:true,env})}
+      catch(error){finish(error);return}
+      const collect=(chunk:Buffer|string)=>{
+        const text=String(chunk);tail=(tail+text).slice(-16384);line=(line+text).slice(-4096)
+        for(const match of line.matchAll(/\b(\d{1,3})%/g)){
+          const progress=Number(match[1]);if(progress<=100)onUpdate({message:`正在下载浏览器组件(当前组件 ${progress}%)`,progress})
+        }
+        const end=Math.max(line.lastIndexOf('\n'),line.lastIndexOf('\r'));if(end>=0)line=line.slice(end+1)
+      }
+      child.stdout?.on('data',collect);child.stderr?.on('data',collect)
+      child.once('error',error=>finish(signal.aborted?signal.reason:error))
+      child.once('close',code=>finish(signal.aborted?signal.reason:code===0?undefined:new Error(tail||'Browser installer failed')))
+      signal.addEventListener('abort',abort,{once:true});if(signal.aborted)abort()
+    })
+  }
+  async function smoke(signal:AbortSignal){
+    signal.throwIfAborted()
+    const launch=(async()=>{
+      const engine=await chromium();signal.throwIfAborted()
+      const browser=await engine.launch({headless:true,chromiumSandbox:true,timeout:30000})
+      await browser.close();signal.throwIfAborted()
+    })()
+    let abort:()=>void=()=>{}
+    const cancelled=new Promise<never>((_,reject)=>{abort=()=>reject(signal.reason);signal.addEventListener('abort',abort,{once:true});if(signal.aborted)abort()})
+    try{await Promise.race([launch,cancelled])}
+    finally{signal.removeEventListener('abort',abort)}
+  }
+  return {
+    async probe(){
+      if(!existsSync((await chromium()).executablePath()))return false
+      try{await smoke(AbortSignal.timeout(30000));return true}catch{return false}
+    },
+    async install({signal,onUpdate}){
+      const controller=new AbortController()
+      const cancel=()=>controller.abort(failure('browser_install_cancelled','浏览器准备已取消,请重新发起准备'))
+      signal.addEventListener('abort',cancel,{once:true});if(signal.aborted)cancel()
+      const timer=setTimeout(()=>controller.abort(failure('browser_install_timeout','浏览器准备超过 8 分钟,请检查节点网络后重试')),dependencies.timeoutMs??8*60000);timer.unref()
+      try{
+        controller.signal.throwIfAborted()
+        onUpdate({message:'正在安装配套 Chromium 浏览器'})
+        await runCLI(['install','chromium'],controller.signal,onUpdate)
+        onUpdate({message:'正在验证 Chromium 沙箱启动'})
+        try{await smoke(controller.signal)}catch(error){
+          controller.signal.throwIfAborted()
+          const message=String(error)
+          if(platform==='linux'&&dependencyFailure(message)){
+            if((dependencies.getuid??process.getuid)?.()!==0)throw failure('browser_install_dependencies','节点缺少 Chromium 系统库,请管理员在节点使用配套 Playwright 执行 install-deps chromium 后重试')
+            onUpdate({message:'正在安装 Chromium 所需系统库'})
+            await runCLI(['install-deps','chromium'],controller.signal,onUpdate)
+            onUpdate({message:'正在重新验证 Chromium 沙箱启动'})
+            await smoke(controller.signal)
+          }else throw error
+        }
+        controller.signal.throwIfAborted()
+      }catch(error){
+        if(controller.signal.aborted)throw controller.signal.reason
+        if(error instanceof BrowserInstallationError)throw error
+        if(sandboxFailure(String(error)))throw failure('browser_install_sandbox','节点不支持 Chromium 安全沙箱,请管理员检查非 root 运行用户、用户命名空间和系统沙箱配置后重试')
+        if(dependencyFailure(String(error)))throw failure('browser_install_dependencies','节点缺少 Chromium 系统库,请管理员在节点使用配套 Playwright 执行 install-deps chromium 后重试')
+        throw failure('browser_install_failed','浏览器准备失败,请检查节点网络、磁盘空间和 Chromium 运行环境后重试')
+      }finally{clearTimeout(timer);signal.removeEventListener('abort',cancel)}
+    },
+  }
+}

+ 96 - 12
src/runner/managedBrowser.ts

@@ -1,36 +1,46 @@
 import {join} from 'node:path'
 import {join} from 'node:path'
-import {existsSync} from 'node:fs'
 import {z} from 'zod'
 import {z} from 'zod'
 import {BrowserRuntime,type BrowserScope,type BrowserUpload,type BrowserOperation} from './browser/runtime.js'
 import {BrowserRuntime,type BrowserScope,type BrowserUpload,type BrowserOperation} from './browser/runtime.js'
 import {HttpError} from '../server/errors.js'
 import {HttpError} from '../server/errors.js'
 import type {RunnerConfiguration} from '../shared/runner.js'
 import type {RunnerConfiguration} from '../shared/runner.js'
+import type {BrowserInstallation} from '../shared/managedBrowser.js'
+import {createBrowserInstaller,browserInstallationFailure,type BrowserInstaller} from './browser/installation.js'
 
 
 const scopeSchema=z.object({ownerKey:z.string().regex(/^[a-f0-9]{64}$/),environmentId:z.string().uuid(),profile:z.enum(['persistent','temporary'])}).strict()
 const scopeSchema=z.object({ownerKey:z.string().regex(/^[a-f0-9]{64}$/),environmentId:z.string().uuid(),profile:z.enum(['persistent','temporary'])}).strict()
 const key=(scope:BrowserScope)=>JSON.stringify([scope.ownerKey,scope.environmentId])
 const key=(scope:BrowserScope)=>JSON.stringify([scope.ownerKey,scope.environmentId])
+interface InstallationJob {controller:AbortController;holders:Map<string,()=>Promise<void>>;promise:Promise<void>}
 
 
 /** Uses the existing outbound Runner transport; no browser/CDP port is published. */
 /** Uses the existing outbound Runner transport; no browser/CDP port is published. */
 export class RunnerBrowser {
 export class RunnerBrowser {
   available=false
   available=false
+  installation:BrowserInstallation={status:'checking',message:'正在检查节点浏览器',updatedAt:Date.now()}
+  private readonly enabled:boolean
+  private readonly installer:BrowserInstaller
+  private installationJob?:InstallationJob
+  private stopped=false
   readonly ready:Promise<void>
   readonly ready:Promise<void>
   readonly runtime:BrowserRuntime
   readonly runtime:BrowserRuntime
   private uploads=new Map<string,BrowserUpload>()
   private uploads=new Map<string,BrowserUpload>()
   private downloads=new Map<string,{file:Awaited<ReturnType<BrowserRuntime['readDownload']>>;at:number}>()
   private downloads=new Map<string,{file:Awaited<ReturnType<BrowserRuntime['readDownload']>>;at:number}>()
   private active=new Set<AbortController>()
   private active=new Set<AbortController>()
   private scopes=new Map<string,{scope:BrowserScope;at:number}>()
   private scopes=new Map<string,{scope:BrowserScope;at:number}>()
-  constructor(config:NonNullable<RunnerConfiguration['browser']>,home:string,private readonly check:(scope:BrowserScope,grantId?:string)=>Promise<void>,dependencies:{runtime?:BrowserRuntime;probe?:()=>Promise<boolean>}={}){
+  constructor(config:NonNullable<RunnerConfiguration['browser']>,home:string,private readonly check:(scope:BrowserScope,grantId?:string)=>Promise<void>,dependencies:{runtime?:BrowserRuntime;probe?:()=>Promise<boolean>;installer?:BrowserInstaller}={}){
+    this.enabled=config.enabled===true;this.installer=dependencies.installer??createBrowserInstaller()
     this.runtime=dependencies.runtime??new BrowserRuntime({root:join(home,'managed-browser'),enabled:config.enabled,maxSessions:config.maxSessions,
     this.runtime=dependencies.runtime??new BrowserRuntime({root:join(home,'managed-browser'),enabled:config.enabled,maxSessions:config.maxSessions,
       authorize:scope=>check(scope),resolveUpload:async(scope,id)=>{const file=this.uploads.get(key(scope)+':'+id);if(!file)throw new HttpError(403,'上传文件未获本次授权','browser_upload_forbidden');return file}})
       authorize:scope=>check(scope),resolveUpload:async(scope,id)=>{const file=this.uploads.get(key(scope)+':'+id);if(!file)throw new HttpError(403,'上传文件未获本次授权','browser_upload_forbidden');return file}})
-    this.ready=(async()=>{if(config.enabled){if(dependencies.probe)this.available=await dependencies.probe();else{const {chromium}=await import('playwright');this.available=existsSync(chromium.executablePath())}}})().catch(()=>{})
+    this.ready=Promise.resolve().then(async()=>{
+      if(this.enabled)this.available=await (dependencies.probe??this.installer.probe)()
+      if(this.stopped){this.available=false;return}
+      if(this.available||this.installation.status!=='failed')this.installation={status:this.available?'ready':'missing',message:this.available?'节点浏览器已就绪':this.enabled?'节点浏览器尚未就绪,请准备浏览器':'节点未启用托管浏览器',updatedAt:Date.now()}
+    }).catch(()=>{this.available=false;this.installation={status:'failed',message:'节点浏览器检查失败',error:'无法检查节点浏览器,请重新准备',updatedAt:Date.now()}})
     this.idleTimeoutMs=config.idleTimeoutMs??300000
     this.idleTimeoutMs=config.idleTimeoutMs??300000
   }
   }
   private readonly idleTimeoutMs:number
   private readonly idleTimeoutMs:number
   async invoke(raw:unknown,expiresAt:number,profileAllowed:(profile:string)=>void){
   async invoke(raw:unknown,expiresAt:number,profileAllowed:(profile:string)=>void){
-    await this.ready
-    if(!this.available)throw new HttpError(409,'托管浏览器未就绪,请在 Runner 安装配套 Chromium','browser_unavailable')
-    const p=z.object({scope:scopeSchema,profile:z.string(),grantId:z.string().uuid(),op:z.enum(['status','open','execute','pause','resume','close','download']),operation:z.unknown().optional(),downloadId:z.string().uuid().optional(),offset:z.number().int().min(0).optional(),upload:z.object({id:z.string().uuid(),name:z.string().max(240),mimeType:z.string().max(200),data:z.string().max(36*1024*1024)}).strict().optional()}).strict().parse(raw)
+    const p=z.object({scope:scopeSchema,profile:z.string(),grantId:z.string().uuid(),op:z.enum(['status','prepare','open','execute','pause','resume','close','download']),retry:z.boolean().optional(),operation:z.unknown().optional(),downloadId:z.string().uuid().optional(),offset:z.number().int().min(0).optional(),upload:z.object({id:z.string().uuid(),name:z.string().max(240),mimeType:z.string().max(200),data:z.string().max(36*1024*1024)}).strict().optional()}).strict().parse(raw)
     profileAllowed(p.profile)
     profileAllowed(p.profile)
     const controller=new AbortController();this.active.add(controller)
     const controller=new AbortController();this.active.add(controller)
-    const authorize=async()=>{controller.signal.throwIfAborted();if(Date.now()>=expiresAt)throw new HttpError(410,'浏览器命令已过期','browser_command_expired');await this.check(p.scope,p.grantId)}
+    const authorize=async()=>{controller.signal.throwIfAborted();if(Date.now()>=expiresAt)throw new HttpError(410,'浏览器命令已过期','browser_command_expired');await this.check(p.scope,p.grantId);controller.signal.throwIfAborted();if(Date.now()>=expiresAt)throw new HttpError(410,'浏览器命令已过期','browser_command_expired')}
     // Network/download activity uses the long-lived task/control grant, not the
     // Network/download activity uses the long-lived task/control grant, not the
     // transport command's 30-second deadline.
     // transport command's 30-second deadline.
     const context={authorize:()=>this.check(p.scope,p.grantId),signal:controller.signal}
     const context={authorize:()=>this.check(p.scope,p.grantId),signal:controller.signal}
@@ -38,19 +48,30 @@ export class RunnerBrowser {
     const timer=setInterval(()=>{if(checking)return;checking=true;void authorize().catch(error=>controller.abort(error)).finally(()=>{checking=false})},2000);timer.unref()
     const timer=setInterval(()=>{if(checking)return;checking=true;void authorize().catch(error=>controller.abort(error)).finally(()=>{checking=false})},2000);timer.unref()
     const uploadKey=p.upload?key(p.scope)+':'+p.upload.id:undefined
     const uploadKey=p.upload?key(p.scope)+':'+p.upload.id:undefined
     try{
     try{
-      await authorize();this.scopes.set(key(p.scope),{scope:p.scope,at:Date.now()})
+      if(!['status','prepare','close'].includes(p.op))await this.waitForCheck(controller.signal)
+      await authorize()
+      if(this.stopped)throw new HttpError(410,'Runner 已关闭','browser_unavailable')
+      if((!this.available&&!['status','prepare','close'].includes(p.op))||(p.op==='prepare'&&!this.enabled))throw new HttpError(409,'托管浏览器未就绪,请先准备浏览器','browser_unavailable')
+      if(this.available&&p.op!=='prepare')this.scopes.set(key(p.scope),{scope:p.scope,at:Date.now()})
       if(p.upload){
       if(p.upload){
         const buffer=Buffer.from(p.upload.data,'base64')
         const buffer=Buffer.from(p.upload.data,'base64')
         if(buffer.length>25*1024*1024||buffer.toString('base64')!==p.upload.data)throw new HttpError(413,'上传文件无效或超过 25 MiB','browser_upload_limit')
         if(buffer.length>25*1024*1024||buffer.toString('base64')!==p.upload.data)throw new HttpError(413,'上传文件无效或超过 25 MiB','browser_upload_limit')
         this.uploads.set(uploadKey!,{name:p.upload.name,mimeType:p.upload.mimeType,buffer})
         this.uploads.set(uploadKey!,{name:p.upload.name,mimeType:p.upload.mimeType,buffer})
       }
       }
       let result:unknown
       let result:unknown
-      if(p.op==='status')result=await this.runtime.status(p.scope,context)
+      if(p.op==='status'||p.op==='prepare'){
+        if(p.op==='prepare')this.prepare(p.scope,p.grantId,p.retry===true)
+        // Preparation only reports installation metadata, including when another
+        // holder finishes setup before this request arrives. A subsequent status
+        // call uses its own view/task authorization to read the actual session.
+        const state=p.op==='status'&&this.enabled&&this.available?await this.runtime.status(p.scope,context):{open:false,generation:1,profile:p.scope.profile,tabs:[],downloads:[]}
+        result={...state,enabled:this.enabled,available:this.available,installation:{...this.installation}}
+      }
       else if(p.op==='open')result=await this.runtime.open(p.scope,context)
       else if(p.op==='open')result=await this.runtime.open(p.scope,context)
       else if(p.op==='execute')result=await this.runtime.execute(p.scope,p.operation as BrowserOperation,context)
       else if(p.op==='execute')result=await this.runtime.execute(p.scope,p.operation as BrowserOperation,context)
       else if(p.op==='pause')result=await this.runtime.pause(p.scope,context)
       else if(p.op==='pause')result=await this.runtime.pause(p.scope,context)
       else if(p.op==='resume')result=await this.runtime.resume(p.scope,context)
       else if(p.op==='resume')result=await this.runtime.resume(p.scope,context)
-      else if(p.op==='close'){result=await this.runtime.close(p.scope,context);this.clearDownloads(p.scope);this.scopes.delete(key(p.scope))}
+      else if(p.op==='close'){this.releaseInstaller(p.scope,p.grantId);result=this.enabled&&this.available?await this.runtime.close(p.scope,context):{open:false,generation:1,profile:p.scope.profile,tabs:[],downloads:[]};this.clearDownloads(p.scope);this.scopes.delete(key(p.scope))}
       else{
       else{
         if(!p.downloadId)throw new HttpError(400,'缺少下载编号','browser_download_invalid')
         if(!p.downloadId)throw new HttpError(400,'缺少下载编号','browser_download_invalid')
         for(const [id,cached] of this.downloads)if(cached.at<Date.now()-60000)this.downloads.delete(id)
         for(const [id,cached] of this.downloads)if(cached.at<Date.now()-60000)this.downloads.delete(id)
@@ -68,6 +89,69 @@ export class RunnerBrowser {
       await authorize();return result
       await authorize();return result
     }finally{clearInterval(timer);this.active.delete(controller);if(uploadKey)this.uploads.delete(uploadKey)}
     }finally{clearInterval(timer);this.active.delete(controller);if(uploadKey)this.uploads.delete(uploadKey)}
   }
   }
+  private async waitForCheck(signal:AbortSignal){
+    signal.throwIfAborted()
+    let abort:()=>void=()=>{}
+    try{
+      await Promise.race([this.ready,new Promise<never>((_,reject)=>{
+        abort=()=>reject(signal.reason);signal.addEventListener('abort',abort,{once:true})
+        if(signal.aborted)abort()
+      })])
+      signal.throwIfAborted()
+    }finally{signal.removeEventListener('abort',abort)}
+  }
+  private prepare(scope:BrowserScope,grantId:string,retry:boolean){
+    if(this.available)return
+    const holder=JSON.stringify([key(scope),grantId]),authorize=()=>this.check(scope,grantId)
+    if(this.installationJob){
+      // Cancellation is irreversible; wait for that job to settle before retrying.
+      if(!this.installationJob.controller.signal.aborted)this.installationJob.holders.set(holder,authorize)
+      return
+    }
+    if(this.installation.status==='failed'&&!retry)return
+    const job:InstallationJob={controller:new AbortController(),holders:new Map([[holder,authorize]]),promise:Promise.resolve()}
+    this.installationJob=job
+    if(this.installation.status!=='checking')this.installation={status:'installing',message:'正在准备节点浏览器',updatedAt:Date.now()}
+    let checking=false
+    const recheck=async()=>{
+      if(checking)return;checking=true
+      try{
+        await Promise.all([...job.holders].map(async([id,check])=>{
+          // A stalled authorization request must not retain an installer forever.
+          let timeout:ReturnType<typeof setTimeout>|undefined
+          try{await Promise.race([check(),new Promise<never>((_,reject)=>{timeout=setTimeout(()=>reject(new Error('authorization timeout')),5000);timeout.unref()})])}
+          catch{if(job.holders.get(id)===check)job.holders.delete(id)}finally{if(timeout)clearTimeout(timeout)}
+        }))
+        if(!job.holders.size)job.controller.abort()
+      }finally{checking=false}
+    }
+    const timer=setInterval(()=>{void recheck()},2000);timer.unref()
+    job.promise=(async()=>{
+      try{
+        // An authorized preparation can arrive while startup is still probing.
+        // It must return promptly and never install until that check completes.
+        await this.waitForCheck(job.controller.signal)
+        if(this.available)return
+        this.installation={status:'installing',message:'正在准备节点浏览器',updatedAt:Date.now()}
+        await this.installer.install({signal:job.controller.signal,onUpdate:update=>{
+          if(this.installationJob===job&&!job.controller.signal.aborted)this.installation={status:'installing',...update,updatedAt:Date.now()}
+        }})
+        // Recheck every holder at completion, including when a periodic check is in flight.
+        while(checking)await new Promise(resolve=>setTimeout(resolve,10))
+        await recheck();job.controller.signal.throwIfAborted()
+        if(this.stopped)throw new Error('Runner closed')
+        this.available=true;this.installation={status:'ready',message:'节点浏览器已就绪',updatedAt:Date.now()}
+      }catch(error){
+        this.available=false
+        const message=job.controller.signal.aborted?'浏览器准备已取消,请重新发起准备':browserInstallationFailure(error)
+        this.installation={status:'failed',message,error:message,updatedAt:Date.now()}
+      }finally{clearInterval(timer);if(this.installationJob===job)this.installationJob=undefined}
+    })()
+  }
+  private releaseInstaller(scope:BrowserScope,grantId:string){
+    const job=this.installationJob;if(!job)return
+    job.holders.delete(JSON.stringify([key(scope),grantId]));if(!job.holders.size)job.controller.abort()
+  }
   private clearDownloads(scope:BrowserScope){for(const id of this.downloads.keys())if(id.startsWith(key(scope)+':'))this.downloads.delete(id)}
   private clearDownloads(scope:BrowserScope){for(const id of this.downloads.keys())if(id.startsWith(key(scope)+':'))this.downloads.delete(id)}
   async sweep(){
   async sweep(){
     for(const [id,item] of this.scopes){
     for(const [id,item] of this.scopes){
@@ -75,6 +159,6 @@ export class RunnerBrowser {
       if(!allowed||Date.now()-item.at>this.idleTimeoutMs){this.scopes.delete(id);this.clearDownloads(item.scope);await this.runtime.revoke(item.scope)}
       if(!allowed||Date.now()-item.at>this.idleTimeoutMs){this.scopes.delete(id);this.clearDownloads(item.scope);await this.runtime.revoke(item.scope)}
     }
     }
   }
   }
-  async disconnect(){for(const controller of this.active)controller.abort();await Promise.allSettled([...this.scopes.values()].map(item=>this.runtime.revoke(item.scope)));this.scopes.clear();this.downloads.clear();this.uploads.clear()}
-  async shutdown(){this.available=false;await this.disconnect();await this.runtime.shutdown()}
+  async disconnect(){this.installationJob?.controller.abort();for(const controller of this.active)controller.abort();await Promise.allSettled([...this.scopes.values()].map(item=>this.runtime.revoke(item.scope)));this.scopes.clear();this.downloads.clear();this.uploads.clear();await this.installationJob?.promise}
+  async shutdown(){this.stopped=true;this.available=false;await this.disconnect();await this.runtime.shutdown()}
 }
 }

+ 91 - 10
src/server/managedBrowsers.ts

@@ -10,12 +10,14 @@ import type {RunnerHub} from './runnerHub.js'
 import type {WorkspaceAgent} from '../shared/workspace.js'
 import type {WorkspaceAgent} from '../shared/workspace.js'
 import type {ComputerFrame,ComputerControlStatus} from '../shared/computerControl.js'
 import type {ComputerFrame,ComputerControlStatus} from '../shared/computerControl.js'
 import type {BrowserScope,BrowserState,BrowserUpload} from '../runner/browser/types.js'
 import type {BrowserScope,BrowserState,BrowserUpload} from '../runner/browser/types.js'
+import type {BrowserInstallation,ManagedBrowserCard,ManagedBrowserState} from '../shared/managedBrowser.js'
 import {ToolOperationJournal} from './toolOperationJournal.js'
 import {ToolOperationJournal} from './toolOperationJournal.js'
 
 
 const digest=(value:string)=>createHash('sha256').update(value).digest('hex')
 const digest=(value:string)=>createHash('sha256').update(value).digest('hex')
 const credentials=z.object({controlId:z.string().uuid(),token:z.string().min(32).max(256)}).passthrough()
 const credentials=z.object({controlId:z.string().uuid(),token:z.string().min(32).max(256)}).passthrough()
-interface Grant {id:string;role:'task'|'human'|'view';resource:Resource;expiresAt:number;authVersion:number;authorize():void;signal?:AbortSignal;requestId?:string;sealedToken?:string;tokenHash?:string}
-interface Resource {owner:string;agent:WorkspaceAgent;runnerId:string;scope:BrowserScope;state:BrowserState;task?:Grant;human?:Grant;paused:boolean;resetRequired?:boolean;resetting?:Promise<void>;taking?:Promise<void>;closing?:Promise<unknown>;frame?:ComputerFrame;frames?:Map<string,Omit<ComputerFrame,'data'>>;notes?:string}
+type RuntimeState=BrowserState&{available?:boolean;installation?:BrowserInstallation}
+interface Grant {id:string;role:'task'|'human'|'view'|'prepare';resource:Resource;expiresAt:number;authVersion:number;authorize():void;signal?:AbortSignal;requestId?:string;sealedToken?:string;tokenHash?:string}
+interface Resource {owner:string;agent:WorkspaceAgent;runnerId:string;scope:BrowserScope;state:RuntimeState;task?:Grant;human?:Grant;preparation?:Grant;notify?:()=>void;paused:boolean;retired?:boolean;resetRequired?:boolean;resetting?:Promise<void>;taking?:Promise<void>;closing?:Promise<unknown>;frame?:ComputerFrame;frames?:Map<string,Omit<ComputerFrame,'data'>>;notes?:string}
 export interface BrowserTurn {
 export interface BrowserTurn {
   call(name:string,args:unknown,callId?:string):Promise<unknown>
   call(name:string,args:unknown,callId?:string):Promise<unknown>
   close():Promise<void>
   close():Promise<void>
@@ -24,6 +26,8 @@ export interface BrowserTurnOptions {
   workId:string;signal:AbortSignal;authorize():void
   workId:string;signal:AbortSignal;authorize():void
   publish(name:string,bytes:Buffer):Promise<unknown>
   publish(name:string,bytes:Buffer):Promise<unknown>
   upload(fileId:string):Promise<BrowserUpload>
   upload(fileId:string):Promise<BrowserUpload>
+  /** Return false when persistence failed so the next observation can retry. */
+  onCard?(card:ManagedBrowserCard):boolean|void
   toVm?(file:BrowserUpload,path:string,overwrite:boolean):Promise<unknown>
   toVm?(file:BrowserUpload,path:string,overwrite:boolean):Promise<unknown>
   fromVm?(path:string):Promise<BrowserUpload>
   fromVm?(path:string):Promise<BrowserUpload>
 }
 }
@@ -55,6 +59,7 @@ export class ManagedBrowsers {
       // Source changes revoke every old holder. The old Runner observes the
       // Source changes revoke every old holder. The old Runner observes the
       // revoked grant; the current Runner must confirm a close before reuse.
       // revoked grant; the current Runner must confirm a close before reuse.
       for(const [id,grant] of this.grants)if(grant.resource===resource)this.grants.delete(id)
       for(const [id,grant] of this.grants)if(grant.resource===resource)this.grants.delete(id)
+      resource.retired=true;resource.task=undefined;resource.human=undefined;resource.paused=false;resource.notify?.()
       resetRequired=true
       resetRequired=true
       this.resources.delete(key);resource=undefined
       this.resources.delete(key);resource=undefined
     }
     }
@@ -82,10 +87,11 @@ export class ManagedBrowsers {
     const matches=(g:Grant)=>g.resource.runnerId===runnerId&&g.resource.scope.ownerKey===s.ownerKey&&g.resource.scope.environmentId===s.environmentId&&g.resource.scope.profile===s.profile
     const matches=(g:Grant)=>g.resource.runnerId===runnerId&&g.resource.scope.ownerKey===s.ownerKey&&g.resource.scope.environmentId===s.environmentId&&g.resource.scope.profile===s.profile
     const valid=(g:Grant)=>{try{this.check(g);return true}catch{return false}}
     const valid=(g:Grant)=>{try{this.check(g);return true}catch{return false}}
     if(grantId){const g=this.grants.get(grantId);return !!g&&matches(g)&&valid(g)}
     if(grantId){const g=this.grants.get(grantId);return !!g&&matches(g)&&valid(g)}
-    return [...this.grants.values()].some(g=>matches(g)&&valid(g))
+    return [...this.grants.values()].some(g=>g.role!=='prepare'&&matches(g)&&valid(g))
   }
   }
   private async request(grant:Grant,op:string,data:Record<string,unknown>={}){
   private async request(grant:Grant,op:string,data:Record<string,unknown>={}){
     this.check(grant)
     this.check(grant)
+    if(grant.role==='task'&&!['status','close','prepare','resume'].includes(op))await this.waitForControl(grant)
     const r=grant.resource
     const r=grant.resource
     if(r.resetRequired){
     if(r.resetRequired){
       r.resetting??=(async()=>{
       r.resetting??=(async()=>{
@@ -96,9 +102,21 @@ export class ManagedBrowsers {
       this.check(grant)
       this.check(grant)
     }
     }
     const result=await this.hub.browser(r.owner,r.agent,{scope:r.scope,grantId:grant.id,op,...data},()=>this.check(grant))
     const result=await this.hub.browser(r.owner,r.agent,{scope:r.scope,grantId:grant.id,op,...data},()=>this.check(grant))
-    if(result&&typeof result.open==='boolean'&&Number.isInteger(result.generation))r.state=result
+    if(result&&typeof result.open==='boolean'&&Number.isInteger(result.generation)){r.state=result;r.notify?.()}
     return result
     return result
   }
   }
+  private async waitForControl(g:Grant){
+    while(g.resource.paused){
+      this.check(g)
+      await new Promise<void>((resolve,reject)=>{
+        const stop=()=>{clearTimeout(timer);reject(new HttpError(410,'浏览器任务已停止','browser_authorization_revoked'))}
+        const timer=setTimeout(()=>{g.signal?.removeEventListener('abort',stop);resolve()},250)
+        g.signal?.addEventListener('abort',stop,{once:true})
+        if(g.signal?.aborted){g.signal.removeEventListener('abort',stop);stop()}
+      })
+    }
+    this.check(g)
+  }
   private async view<T>(r:Resource,action:(g:Grant)=>Promise<T>){
   private async view<T>(r:Resource,action:(g:Grant)=>Promise<T>){
     const grant=this.grant(r,'view',()=>{},undefined,Date.now()+30000)
     const grant=this.grant(r,'view',()=>{},undefined,Date.now()+30000)
     try{return await action(grant)}finally{this.grants.delete(grant.id)}
     try{return await action(grant)}finally{this.grants.delete(grant.id)}
@@ -108,9 +126,41 @@ export class ManagedBrowsers {
   }
   }
   async state(owner:string,id:string){
   async state(owner:string,id:string){
     const enabled=readHostTools(this.store.home).managedBrowser===true
     const enabled=readHostTools(this.store.home).managedBrowser===true
-    try{const r=this.resource(owner,id);await this.view(r,g=>this.request(g,'status'));return {enabled,available:true,...r.state}}
+    try{const r=this.resource(owner,id);await this.view(r,g=>this.request(g,'status'));this.finishPreparation(r);return this.publicState(r)}
     catch(error){return {enabled,available:false,reason:error instanceof Error?error.message:'浏览器不可用',open:false,generation:0,profile:'persistent',tabs:[],downloads:[]}}
     catch(error){return {enabled,available:false,reason:error instanceof Error?error.message:'浏览器不可用',open:false,generation:0,profile:'persistent',tabs:[],downloads:[]}}
   }
   }
+  private publicState(r:Resource):ManagedBrowserState{return {enabled:true,...r.state,available:r.state.available??true,...(r.state.available===false?{reason:r.state.installation?.message??'浏览器环境尚未就绪'}:{})}}
+  private finishPreparation(r:Resource){
+    if(r.preparation&&['ready','failed'].includes(r.state.installation?.status??'')){this.grants.delete(r.preparation.id);r.preparation=undefined}
+  }
+  async prepare(owner:string,id:string,retry=false){
+    const r=this.resource(owner,id)
+    await this.view(r,g=>this.request(g,'status'))
+    if(r.state.available!==false){this.finishPreparation(r);return this.publicState(r)}
+    if(r.preparation){try{this.check(r.preparation)}catch{this.grants.delete(r.preparation.id);r.preparation=undefined}}
+    const g=r.preparation??=this.grant(r,'prepare',()=>{},undefined,Date.now()+600000)
+    try{await this.request(g,'prepare',{retry});this.finishPreparation(r);return this.publicState(r)}
+    catch(error){if(r.preparation===g){this.grants.delete(g.id);r.preparation=undefined}throw error}
+  }
+  private async ensureReady(g:Grant){
+    await this.request(g,'status')
+    if(g.resource.state.available!==false)return
+    await this.request(g,'prepare')
+    const deadline=Date.now()+540000
+    while(g.resource.state.available===false){
+      this.check(g)
+      const installation=g.resource.state.installation
+      if(installation?.status==='failed')throw new HttpError(409,installation.error||installation.message,'browser_install_failed')
+      if(Date.now()>=deadline)throw new HttpError(504,'浏览器环境准备超时,请在卡片中检查状态后重试','browser_install_timeout')
+      await new Promise<void>((resolve,reject)=>{
+        const stop=()=>{clearTimeout(timer);reject(new HttpError(410,'浏览器任务已停止','browser_authorization_revoked'))}
+        const timer=setTimeout(()=>{g.signal?.removeEventListener('abort',stop);resolve()},750)
+        g.signal?.addEventListener('abort',stop,{once:true})
+        if(g.signal?.aborted){g.signal.removeEventListener('abort',stop);stop()}
+      })
+      await this.request(g,'status')
+    }
+  }
   private token(owner:string,id:string,value:unknown){
   private token(owner:string,id:string,value:unknown){
     const input=parse(credentials,value),grant=this.grants.get(input.controlId)
     const input=parse(credentials,value),grant=this.grants.get(input.controlId)
     if(!grant||grant.role!=='human'||grant.resource.owner!==owner||grant.resource.agent.id!==id||!grant.tokenHash||!timingSafeEqual(Buffer.from(grant.tokenHash,'hex'),Buffer.from(digest(input.token),'hex')))throw new HttpError(403,'需要当前浏览器控制凭据','browser_control_forbidden')
     if(!grant||grant.role!=='human'||grant.resource.owner!==owner||grant.resource.agent.id!==id||!grant.tokenHash||!timingSafeEqual(Buffer.from(grant.tokenHash,'hex'),Buffer.from(digest(input.token),'hex')))throw new HttpError(403,'需要当前浏览器控制凭据','browser_control_forbidden')
@@ -133,14 +183,32 @@ export class ManagedBrowsers {
     return {name,mimeType,buffer}
     return {name,mimeType,buffer}
   }
   }
   openTurn(owner:string,agent:WorkspaceAgent,options:BrowserTurnOptions):BrowserTurn{
   openTurn(owner:string,agent:WorkspaceAgent,options:BrowserTurnOptions):BrowserTurn{
-    let grant:Grant|undefined,finished=false
+    let grant:Grant|undefined,finished=false,failure:string|undefined,lastCard='',lastTitle:string|undefined,lastUrl:string|undefined,listener:(()=>void)|undefined
+    const notify=(r:Resource)=>{
+      if(!options.onCard)return
+      const tab=r.state.tabs.find(tab=>tab.active)
+      let url:string|undefined
+      try{const parsed=new URL(tab?.url??'');if(['https:','http:'].includes(parsed.protocol)){parsed.username='';parsed.password='';parsed.search='';parsed.hash='';url=parsed.href.slice(0,2000)}}catch{}
+      if(tab){lastTitle=tab.title.slice(0,240);lastUrl=url}
+      if((r.retired||finished&&!r.human)&&r.notify===listener)r.notify=undefined
+      const status:ManagedBrowserCard['status']=r.retired?'closed':failure?'failed':finished&&!r.human?'closed':r.state.available===false?'preparing':r.state.open?'active':'ready'
+      const {updatedAt:_updated,...installation}=r.state.installation??{updatedAt:0}
+      const body={id:options.workId,agentId:agent.id,agentName:agent.name,status,title:lastTitle,url:lastUrl,
+        message:r.retired?'浏览器执行来源已改变,本轮会话已结束':failure??(status==='closed'?'本轮浏览器操作已结束':r.human?(finished?'人工控制中,交还后结束本轮会话':'人工控制中,可交还机器人继续'):r.paused?'接管已过期,请重新接管后交还机器人':r.state.installation?.message),installation:r.state.installation}
+      const signature=JSON.stringify({...body,installation})
+      if(signature===lastCard)return
+      // Chat persistence is an observer; a deleted history row or storage error
+      // must never block control handoff, authorization cleanup, or the sweep.
+      try{if(options.onCard({...body,updatedAt:Date.now()})!==false)lastCard=signature}catch{}
+    }
     const get=()=>{
     const get=()=>{
       options.authorize();options.signal.throwIfAborted()
       options.authorize();options.signal.throwIfAborted()
       if(finished)throw new HttpError(410,'浏览器任务已结束','browser_authorization_revoked')
       if(finished)throw new HttpError(410,'浏览器任务已结束','browser_authorization_revoked')
       if(!grant){
       if(!grant){
         const r=this.resource(owner,agent.id)
         const r=this.resource(owner,agent.id)
         if(r.closing||r.task)throw new HttpError(409,'当前 Bot 浏览器正由另一个任务使用','browser_busy')
         if(r.closing||r.task)throw new HttpError(409,'当前 Bot 浏览器正由另一个任务使用','browser_busy')
-        grant=this.grant(r,'task',options.authorize,options.signal);r.task=grant
+        r.notify?.()
+        grant=this.grant(r,'task',options.authorize,options.signal);r.task=grant;listener=()=>notify(r);r.notify=listener
       }
       }
       this.check(grant);return grant
       this.check(grant);return grant
     }
     }
@@ -149,17 +217,21 @@ export class ManagedBrowsers {
       if(!grant)return
       if(!grant)return
       const g=grant,r=g.resource;this.grants.delete(g.id);if(r.task===g)r.task=undefined
       const g=grant,r=g.resource;this.grants.delete(g.id);if(r.task===g)r.task=undefined
       if(!r.human){r.paused=false;r.closing=this.view(r,view=>this.request(view,'close')).catch(()=>{});try{await r.closing}finally{r.closing=undefined}}
       if(!r.human){r.paused=false;r.closing=this.view(r,view=>this.request(view,'close')).catch(()=>{});try{await r.closing}finally{r.closing=undefined}}
+      notify(r)
     }
     }
     const abort=()=>{void close()};options.signal.addEventListener('abort',abort,{once:true})
     const abort=()=>{void close()};options.signal.addEventListener('abort',abort,{once:true})
     return {close,call:async(name,raw,callId)=>{
     return {close,call:async(name,raw,callId)=>{
       const g=get(),r=g.resource
       const g=get(),r=g.resource
+      notify(r)
+      try{
       // Hold the current tool call while the user operates this exact session.
       // Hold the current tool call while the user operates this exact session.
-      while(r.paused){this.check(g);await new Promise<void>((resolve,reject)=>{const abort=()=>{clearTimeout(timer);reject(new HttpError(410,'浏览器任务已停止','browser_authorization_revoked'))};const timer=setTimeout(()=>{options.signal.removeEventListener('abort',abort);resolve()},250);options.signal.addEventListener('abort',abort,{once:true})})}
+      await this.waitForControl(g)
       const args=z.record(z.string(),z.unknown()).parse(raw??{})
       const args=z.record(z.string(),z.unknown()).parse(raw??{})
       const result=await this.journal.execute(owner,options.workId,callId,name,args,()=>this.check(g),async()=>{
       const result=await this.journal.execute(owner,options.workId,callId,name,args,()=>this.check(g),async()=>{
         if(name==='managed_browser_state')return this.request(g,'status')
         if(name==='managed_browser_state')return this.request(g,'status')
         if(name==='managed_browser_open'){
         if(name==='managed_browser_open'){
           const body=z.object({url:z.string().optional()}).strict().parse(args)
           const body=z.object({url:z.string().optional()}).strict().parse(args)
+          await this.ensureReady(g)
           await this.request(g,'open')
           await this.request(g,'open')
           if(body.url)await this.request(g,'execute',{operation:{generation:r.state.generation,operationId:callId??randomUUID(),action:{kind:'navigate',url:body.url}}})
           if(body.url)await this.request(g,'execute',{operation:{generation:r.state.generation,operationId:callId??randomUUID(),action:{kind:'navigate',url:body.url}}})
           return this.request(g,'status')
           return this.request(g,'status')
@@ -182,22 +254,28 @@ export class ManagedBrowsers {
         else if(name==='managed_browser_action')action=z.object({action:z.record(z.string(),z.unknown())}).strict().parse(args).action
         else if(name==='managed_browser_action')action=z.object({action:z.record(z.string(),z.unknown())}).strict().parse(args).action
         else throw new HttpError(403,'本轮未授权此浏览器工具','browser_tool_forbidden')
         else throw new HttpError(403,'本轮未授权此浏览器工具','browser_tool_forbidden')
         const value=await this.request(g,'execute',{operation:{generation:r.state.generation,operationId:callId??randomUUID(),action},...(upload?{upload}:{})})
         const value=await this.request(g,'execute',{operation:{generation:r.state.generation,operationId:callId??randomUUID(),action},...(upload?{upload}:{})})
+        if(name==='managed_browser_action'&&!['snapshot','screenshot','downloads','state'].includes(String((action as {kind?:unknown})?.kind)))await this.request(g,'status')
         if(value?.mimeType==='image/png'&&typeof value.data==='string')return {content:[{type:'image',mimeType:'image/png',data:value.data},{type:'text',text:JSON.stringify({width:value.width,height:value.height,generation:value.generation})}]}
         if(value?.mimeType==='image/png'&&typeof value.data==='string')return {content:[{type:'image',mimeType:'image/png',data:value.data},{type:'text',text:JSON.stringify({width:value.width,height:value.height,generation:value.generation})}]}
         return value
         return value
       })
       })
+      failure=undefined;notify(r)
       if(r.notes){const notes=r.notes;r.notes=undefined;return {result,humanNote:notes,instruction:'用户刚交还浏览器;重新读取页面后继续。'}}
       if(r.notes){const notes=r.notes;r.notes=undefined;return {result,humanNote:notes,instruction:'用户刚交还浏览器;重新读取页面后继续。'}}
       return result
       return result
+      }catch(error){failure=error instanceof Error?error.message:'浏览器操作失败';notify(r);throw error}
     }}
     }}
   }
   }
   router(){
   router(){
     const router=new Router(),matches=(ctx:{query:Record<string,unknown>})=>ctx.query.backend==='managed-browser'
     const router=new Router(),matches=(ctx:{query:Record<string,unknown>})=>ctx.query.backend==='managed-browser'
     router.get('/api/app/agents/:id/managed-browser',async ctx=>{const owner=this.auth.require(ctx).id;this.store.require(owner,'agent',ctx.params.id);ctx.set('Cache-Control','no-store');ctx.body=await this.state(owner,ctx.params.id)})
     router.get('/api/app/agents/:id/managed-browser',async ctx=>{const owner=this.auth.require(ctx).id;this.store.require(owner,'agent',ctx.params.id);ctx.set('Cache-Control','no-store');ctx.body=await this.state(owner,ctx.params.id)})
+    router.post('/api/app/agents/:id/managed-browser/prepare',async ctx=>{const owner=this.auth.require(ctx).id,body=parse(z.object({retry:z.boolean().default(false)}).strict(),(ctx.request as any).body??{});ctx.body=await this.prepare(owner,ctx.params.id,body.retry)})
     router.get('/api/app/agents/:id/computer',async(ctx,next)=>{if(!matches(ctx))return next();const r=this.resource(this.auth.require(ctx).id,ctx.params.id);await this.view(r,g=>this.request(g,'status'));ctx.body=this.controlStatus(r)})
     router.get('/api/app/agents/:id/computer',async(ctx,next)=>{if(!matches(ctx))return next();const r=this.resource(this.auth.require(ctx).id,ctx.params.id);await this.view(r,g=>this.request(g,'status'));ctx.body=this.controlStatus(r)})
     router.get('/api/app/agents/:id/computer/frame',async(ctx,next)=>{if(!matches(ctx))return next();const r=this.resource(this.auth.require(ctx).id,ctx.params.id);const shot=await this.view(r,g=>this.request(g,'execute',{operation:{generation:r.state.generation,operationId:randomUUID(),action:{kind:'screenshot'}}}));r.frame={id:shot.frameId,generation:shot.generation,data:shot.data,width:shot.width,height:shot.height,capturedAt:shot.createdAt};r.frames??=new Map();const {data:_data,...metadata}=r.frame;r.frames.set(metadata.id,metadata);for(const [id,frame] of r.frames)if(frame.capturedAt<Date.now()-30000||r.frames.size>32)r.frames.delete(id);ctx.set('Cache-Control','no-store');ctx.body=r.frame})
     router.get('/api/app/agents/:id/computer/frame',async(ctx,next)=>{if(!matches(ctx))return next();const r=this.resource(this.auth.require(ctx).id,ctx.params.id);const shot=await this.view(r,g=>this.request(g,'execute',{operation:{generation:r.state.generation,operationId:randomUUID(),action:{kind:'screenshot'}}}));r.frame={id:shot.frameId,generation:shot.generation,data:shot.data,width:shot.width,height:shot.height,capturedAt:shot.createdAt};r.frames??=new Map();const {data:_data,...metadata}=r.frame;r.frames.set(metadata.id,metadata);for(const [id,frame] of r.frames)if(frame.capturedAt<Date.now()-30000||r.frames.size>32)r.frames.delete(id);ctx.set('Cache-Control','no-store');ctx.body=r.frame})
     router.post('/api/app/agents/:id/computer/take',async(ctx,next)=>{
     router.post('/api/app/agents/:id/computer/take',async(ctx,next)=>{
       if(!matches(ctx))return next()
       if(!matches(ctx))return next()
       const r=this.resource(this.auth.require(ctx).id,ctx.params.id),body=parse(z.object({requestId:z.string().uuid()}).strict(),(ctx.request as any).body)
       const r=this.resource(this.auth.require(ctx).id,ctx.params.id),body=parse(z.object({requestId:z.string().uuid()}).strict(),(ctx.request as any).body)
       if(r.closing)await r.closing
       if(r.closing)await r.closing
+      await this.view(r,g=>this.request(g,'status'))
+      if(r.state.available===false)throw new HttpError(409,'浏览器环境尚未就绪,请先等待自动准备完成','browser_preparing')
       if(r.human&&r.human.expiresAt>Date.now()){
       if(r.human&&r.human.expiresAt>Date.now()){
         this.check(r.human)
         this.check(r.human)
         if(r.human.requestId!==body.requestId)throw new HttpError(409,'浏览器已由另一页面接管','browser_control_busy')
         if(r.human.requestId!==body.requestId)throw new HttpError(409,'浏览器已由另一页面接管','browser_control_busy')
@@ -207,9 +285,9 @@ export class ManagedBrowsers {
       }
       }
       const token=randomBytes(32).toString('base64url'),g=this.grant(r,'human',()=>{},undefined,Date.now()+30000)
       const token=randomBytes(32).toString('base64url'),g=this.grant(r,'human',()=>{},undefined,Date.now()+30000)
       Object.assign(g,{requestId:body.requestId,tokenHash:digest(token),sealedToken:this.nodes.seal(token)});r.human=g;r.paused=true
       Object.assign(g,{requestId:body.requestId,tokenHash:digest(token),sealedToken:this.nodes.seal(token)});r.human=g;r.paused=true
-      r.taking=(async()=>{await this.request(g,'pause');await this.request(g,'open');r.frame=undefined;r.frames?.clear()})()
+      r.taking=(async()=>{await this.request(g,'pause');await this.request(g,'open');r.frame=undefined;r.frames?.clear();r.notify?.()})()
       try{await r.taking;r.taking=undefined;ctx.body={...this.controlStatus(r),controlId:g.id,token,expiresAt:g.expiresAt}}
       try{await r.taking;r.taking=undefined;ctx.body={...this.controlStatus(r),controlId:g.id,token,expiresAt:g.expiresAt}}
-      catch(error){this.grants.delete(g.id);if(r.human===g)r.human=undefined;throw error}
+      catch(error){this.grants.delete(g.id);if(r.human===g)r.human=undefined;r.notify?.();throw error}
       finally{r.taking=undefined}
       finally{r.taking=undefined}
     })
     })
     router.post('/api/app/agents/:id/computer/renew',(ctx,next)=>{if(!matches(ctx))return next();const g=this.token(this.auth.require(ctx).id,ctx.params.id,(ctx.request as any).body);g.expiresAt=Date.now()+30000;ctx.body={expiresAt:g.expiresAt}})
     router.post('/api/app/agents/:id/computer/renew',(ctx,next)=>{if(!matches(ctx))return next();const g=this.token(this.auth.require(ctx).id,ctx.params.id,(ctx.request as any).body);g.expiresAt=Date.now()+30000;ctx.body={expiresAt:g.expiresAt}})
@@ -244,6 +322,7 @@ export class ManagedBrowsers {
       // Keep the manual grant alive until the runner confirms the generation handoff.
       // Keep the manual grant alive until the runner confirms the generation handoff.
       await this.request(r.task??g,'resume');r.notes=body.notes;r.human=undefined;r.paused=false;r.frame=undefined;r.frames?.clear();this.grants.delete(g.id)
       await this.request(r.task??g,'resume');r.notes=body.notes;r.human=undefined;r.paused=false;r.frame=undefined;r.frames?.clear();this.grants.delete(g.id)
       if(!r.task){r.closing=this.view(r,view=>this.request(view,'close'));try{await r.closing}finally{r.closing=undefined}}
       if(!r.task){r.closing=this.view(r,view=>this.request(view,'close'));try{await r.closing}finally{r.closing=undefined}}
+      r.notify?.()
       ctx.body=this.controlStatus(r)
       ctx.body=this.controlStatus(r)
     })
     })
     return router
     return router
@@ -252,6 +331,8 @@ export class ManagedBrowsers {
     for(const r of this.resources.values()){
     for(const r of this.resources.values()){
       if(r.human){let valid=true;try{this.check(r.human)}catch{valid=false}if(!valid){this.grants.delete(r.human.id);r.human=undefined;r.paused=true;r.frame=undefined}}
       if(r.human){let valid=true;try{this.check(r.human)}catch{valid=false}if(!valid){this.grants.delete(r.human.id);r.human=undefined;r.paused=true;r.frame=undefined}}
       if(r.task){try{this.check(r.task)}catch{this.grants.delete(r.task.id);r.task=undefined}}
       if(r.task){try{this.check(r.task)}catch{this.grants.delete(r.task.id);r.task=undefined}}
+      if(r.preparation){try{this.check(r.preparation)}catch{this.grants.delete(r.preparation.id);r.preparation=undefined}}
+      r.notify?.()
     }
     }
   }
   }
   close(){this.closed=true;clearInterval(this.timer);this.grants.clear();this.resources.clear()}
   close(){this.closed=true;clearInterval(this.timer);this.grants.clear();this.resources.clear()}

+ 3 - 3
src/server/runnerHub.ts

@@ -224,7 +224,7 @@ export class RunnerHub {
   }
   }
   browserRunner(owner:string,agent:import('../shared/workspace.js').WorkspaceAgent){
   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))
     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.includes('managed-browser-v1'))throw new HttpError(409,'执行节点尚未启用托管浏览器或未安装配套 Chromium','browser_unavailable')
+    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.allowedProfiles.includes(agent.profile)||!this.auth.canUseSource(owner,agent.nodeId,agent.profile))throw new HttpError(403,'浏览器 Profile 未授权','browser_profile_forbidden')
     if(!record.allowedProfiles.includes(agent.profile)||!this.auth.canUseSource(owner,agent.nodeId,agent.profile))throw new HttpError(403,'浏览器 Profile 未授权','browser_profile_forbidden')
     return record
     return record
   }
   }
@@ -272,7 +272,7 @@ export class RunnerHub {
         retired.add(previous.instance);this.retired.set(record.id,retired);this.disconnect(record.id);previous=undefined
         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')
       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'].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'].includes(value));this.online.set(record.id,state)
       ctx.set('Cache-Control','no-store')
       ctx.set('Cache-Control','no-store')
       if(match[2]==='poll') {
       if(match[2]==='poll') {
         if(ctx.method!=='GET')throw new HttpError(405,'仅允许 GET','method_not_allowed')
         if(ctx.method!=='GET')throw new HttpError(405,'仅允许 GET','method_not_allowed')
@@ -306,7 +306,7 @@ export class RunnerHub {
         if(operation==='acquire'&&(!valid()||!active())){await this.composeDesktops.call(id,'release',{...result,cancel:true}).catch(()=>{});throw new HttpError(410,'桌面操作已取消','computer_control_expired')}
         if(operation==='acquire'&&(!valid()||!active())){await this.composeDesktops.call(id,'release',{...result,cancel:true}).catch(()=>{});throw new HttpError(410,'桌面操作已取消','computer_control_expired')}
         ctx.body=result;return
         ctx.body=result;return
       }
       }
-      if(match[2]==='browser-check'){ctx.body={allowed:state.features.includes('managed-browser-v1')&&this.browserAllowed(record.id,body.scope,typeof body.grantId==='string'?body.grantId:undefined)};return}
+      if(match[2]==='browser-check'){ctx.body={allowed:state.features.some(feature=>['managed-browser-v1','managed-browser-setup-v1'].includes(feature))&&this.browserAllowed(record.id,body.scope,typeof body.grantId==='string'?body.grantId:undefined)};return}
       if(match[2]==='local-vm-check'){ctx.body={allowed:typeof body.id==='string'&&this.localVmAllowed(body.id,record.id)};return}
       if(match[2]==='local-vm-check'){ctx.body={allowed:typeof body.id==='string'&&this.localVmAllowed(body.id,record.id)};return}
       if(match[2]==='control-check'){ctx.body={allowed:typeof body.controlId==='string'&&this.controlAllowed(body.controlId,record.id)};return}
       if(match[2]==='control-check'){ctx.body={allowed:typeof body.controlId==='string'&&this.controlAllowed(body.controlId,record.id)};return}
       if(match[2]==='artifact'){
       if(match[2]==='artifact'){

+ 12 - 3
src/server/workspaceRuntime.ts

@@ -442,14 +442,14 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           if (!resultMessage.content.trim() && current.status === 'failed') resultMessage.content = `执行失败:${current.error}`
           if (!resultMessage.content.trim() && current.status === 'failed') resultMessage.content = `执行失败:${current.error}`
         } else {
         } else {
           if (root.discussion && /^(?:\(?pass\)?\.?|\[\[YAOYAO_NO_REPLY_V1\]\])$/i.test(resultMessage.content.trim())) resultMessage.content = ''
           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
+          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
           current.silent = silent
           current.silent = silent
           if (current.replyMode === 'automatic') {
           if (current.replyMode === 'automatic') {
             resultMessage.content = resultMessage.content.replace(/\[\[YAOYAO_[A-Z0-9_]*(?:\]\])?/g, '').trim()
             resultMessage.content = resultMessage.content.replace(/\[\[YAOYAO_[A-Z0-9_]*(?:\]\])?/g, '').trim()
             resultMessage.reasoning = resultMessage.reasoning.replace(/\[\[YAOYAO_[A-Z0-9_]*(?:\]\])?/g, '')
             resultMessage.reasoning = resultMessage.reasoning.replace(/\[\[YAOYAO_[A-Z0-9_]*(?:\]\])?/g, '')
           }
           }
-          if (current.requiredReply && !silent && !resultMessage.content) resultMessage.content = HOST_FALLBACK
-          if (!silent && !resultMessage.content && !resultMessage.attachments.length && !resultMessage.tools.length) {
+          if (current.requiredReply && !silent && !resultMessage.content && !resultMessage.browserCard) resultMessage.content = HOST_FALLBACK
+          if (!silent && !resultMessage.content && !resultMessage.attachments.length && !resultMessage.tools.length && !resultMessage.browserCard) {
             current.status = 'failed'; current.error = '未返回有效回复'; resultMessage.content = '执行失败:未返回有效回复'
             current.status = 'failed'; current.error = '未返回有效回复'; resultMessage.content = '执行失败:未返回有效回复'
           } else { current.status = 'complete'; current.error = undefined }
           } else { current.status = 'complete'; current.error = undefined }
           resultMessage.visible = !silent
           resultMessage.visible = !silent
@@ -842,6 +842,15 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
               assertActive()
               assertActive()
               if(toolId.startsWith('managed_browser_')&&environment.tools.managedBrowser&&this.managedBrowsers){
               if(toolId.startsWith('managed_browser_')&&environment.tools.managedBrowser&&this.managedBrowsers){
                 browserTurn??=this.managedBrowsers.openTurn(owner,agent,{workId:run.id,signal:toolController.signal,authorize:assertActive,
                 browserTurn??=this.managedBrowsers.openTurn(owner,agent,{workId:run.id,signal:toolController.signal,authorize:assertActive,
+                  onCard:card=>{
+                    try{
+                      // Human control can outlive this turn and its deleted history.
+                      const saved=this.store.get<Message>(owner,'message',resultMessage.id)
+                      if(!saved)return
+                      resultMessage.browserCard=card;resultMessage.visible=true
+                      this.store.saveMessage(owner,settled?{...saved,browserCard:card,visible:true}:resultMessage)
+                    }catch{return false /* Card persistence must not interrupt browser cleanup. */}
+                  },
                   publish:async(name,bytes)=>{assertActive();const file=this.publishArtifact(owner,resultMessage,agent,name,bytes);if(!resultMessage.attachments.some(item=>item.id===file.id))resultMessage.attachments.push(file);this.store.saveMessage(owner,resultMessage);return {...file,url:`/api/app/files/${file.id}/download`}},
                   publish:async(name,bytes)=>{assertActive();const file=this.publishArtifact(owner,resultMessage,agent,name,bytes);if(!resultMessage.attachments.some(item=>item.id===file.id))resultMessage.attachments.push(file);this.store.saveMessage(owner,resultMessage);return {...file,url:`/api/app/files/${file.id}/download`}},
                   upload:fileId=>browserOwnedFile(this.store,this.nodes,this.uploads,owner,fileId),
                   upload:fileId=>browserOwnedFile(this.store,this.nodes,this.uploads,owner,fileId),
                   ...(dispatchedVm?{toVm:(file:BrowserUpload,path:string,overwrite:boolean)=>browserVmFile(action=>callVm('__file_transfer',action),path,assertActive,file,overwrite,Math.min(25,globals.fileTransferMaxMiB)*1024*1024),fromVm:async(path:string)=>await browserVmFile(action=>callVm('__file_transfer',action),path,assertActive,undefined,false,Math.min(25,globals.fileTransferMaxMiB)*1024*1024) as BrowserUpload}:{})})
                   ...(dispatchedVm?{toVm:(file:BrowserUpload,path:string,overwrite:boolean)=>browserVmFile(action=>callVm('__file_transfer',action),path,assertActive,file,overwrite,Math.min(25,globals.fileTransferMaxMiB)*1024*1024),fromVm:async(path:string)=>await browserVmFile(action=>callVm('__file_transfer',action),path,assertActive,undefined,false,Math.min(25,globals.fileTransferMaxMiB)*1024*1024) as BrowserUpload}:{})})

+ 4 - 4
src/server/workspaceStore.ts

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

+ 22 - 0
src/shared/managedBrowser.ts

@@ -2,11 +2,33 @@ import type { BotBrowserState } from './desktopEnvironment.js'
 
 
 export type ComputerBackend = 'desktop' | 'cloud' | 'vm' | 'managed-browser'
 export type ComputerBackend = 'desktop' | 'cloud' | 'vm' | 'managed-browser'
 
 
+export interface BrowserInstallation {
+  status: 'checking' | 'missing' | 'installing' | 'ready' | 'failed'
+  message: string
+  progress?: number
+  error?: string
+  updatedAt: number
+}
+
+/** Created by the server for a real browser tool session, never parsed from model text. */
+export interface ManagedBrowserCard {
+  id: string
+  agentId: string
+  agentName: string
+  status: 'preparing' | 'ready' | 'active' | 'closed' | 'failed'
+  title?: string
+  url?: string
+  message?: string
+  installation?: BrowserInstallation
+  updatedAt: number
+}
+
 /** Read-only browser facts; fetching this state never opens a browser. */
 /** Read-only browser facts; fetching this state never opens a browser. */
 export interface ManagedBrowserState extends BotBrowserState {
 export interface ManagedBrowserState extends BotBrowserState {
   enabled: boolean
   enabled: boolean
   available: boolean
   available: boolean
   reason?: string
   reason?: string
+  installation?: BrowserInstallation
   generation: number
   generation: number
   downloads: { id: string; name: string; size: number }[]
   downloads: { id: string; name: string; size: number }[]
 }
 }

+ 1 - 0
src/shared/workspace.ts

@@ -179,6 +179,7 @@ export interface WorkspaceFile {
   sender: 'user' | 'agent'
   sender: 'user' | 'agent'
 }
 }
 export interface WorkspaceMessage {
 export interface WorkspaceMessage {
+  browserCard?: import('./managedBrowser.js').ManagedBrowserCard
   communication?: WorkspaceCommunication
   communication?: WorkspaceCommunication
   peerMessageId?: string
   peerMessageId?: string
   revision?: number
   revision?: number

+ 93 - 1
tests/client/managedBrowser.test.ts

@@ -40,7 +40,7 @@ beforeEach(()=>{
     throw new Error(`Unexpected request ${path}`)
     throw new Error(`Unexpected request ${path}`)
   })
   })
 })
 })
-afterEach(()=>{vi.restoreAllMocks();vi.clearAllMocks()})
+afterEach(()=>{vi.useRealTimers();vi.restoreAllMocks();vi.clearAllMocks()})
 
 
 it.each([false,undefined])('keeps managed browsing absent with an off or older settings response (%s)',async(value)=>{
 it.each([false,undefined])('keeps managed browsing absent with an off or older settings response (%s)',async(value)=>{
   enabled=value
   enabled=value
@@ -129,3 +129,95 @@ it('accepts managed browser native viewer links but rejects a host mixed into th
   const invalid=mount(Viewer,{global:{stubs:{ComputerPanel:true}}})
   const invalid=mount(Viewer,{global:{stubs:{ComputerPanel:true}}})
   try{await flushPromises();expect(invalid.text()).toContain('所选电脑无效');expect(invalid.findComponent(ComputerPanel).exists()).toBe(false)}finally{invalid.unmount()}
   try{await flushPromises();expect(invalid.text()).toContain('所选电脑无效');expect(invalid.findComponent(ComputerPanel).exists()).toBe(false)}finally{invalid.unmount()}
 })
 })
+
+it('keeps a missing browser selectable and prepares it once before automatic takeover',async()=>{
+  vi.useFakeTimers()
+  state.available=false;state.installation={status:'missing',message:'首次使用会自动准备 Chromium',updatedAt:1}
+  const original=request.getMockImplementation()!
+  request.mockImplementation(async(path,options)=>{
+    if(path.endsWith('/managed-browser/prepare')){state.installation={status:'installing',message:'正在下载 Chromium 45%',progress:45,updatedAt:2};return structuredClone(state) as any}
+    return original(path,options)
+  })
+  const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true,autoTake:true},global:{stubs}})
+  try{
+    await flushPromises()
+    expect(wrapper.get('option[value="managed-browser"]').attributes('disabled')).toBeUndefined()
+    expect(wrapper.text()).toContain('正在下载 Chromium 45%')
+    expect(wrapper.get('progress').attributes('value')).toBe('45')
+    expect(request.mock.calls.filter(([path])=>path.endsWith('/prepare'))).toHaveLength(1)
+    expect(request.mock.calls.some(([path])=>path.includes('/computer/take'))).toBe(false)
+    state.available=true;state.installation={status:'ready',message:'浏览器已准备',updatedAt:3}
+    await vi.advanceTimersByTimeAsync(1000);await flushPromises()
+    expect(request.mock.calls.filter(([path])=>path.includes('/computer/take'))).toHaveLength(1)
+    expect(wrapper.text()).toContain('人工控制中')
+    expect(request.mock.calls.filter(([path])=>path.endsWith('/prepare'))).toHaveLength(1)
+  }finally{wrapper.unmount()}
+})
+
+it('observes an existing installation without restarting it and cancels waiting when closed',async()=>{
+  vi.useFakeTimers()
+  state.available=false;state.installation={status:'installing',message:'正在下载 Chromium',updatedAt:1}
+  const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true,autoTake:true},global:{stubs}})
+  try{
+    await flushPromises()
+    expect(request.mock.calls.some(([path])=>path.endsWith('/prepare'))).toBe(false)
+    expect(wrapper.get('.return-control').attributes('disabled')).toBeUndefined()
+    await wrapper.get('.return-control').trigger('click');await flushPromises()
+    expect(wrapper.emitted('close')).toHaveLength(1)
+    const calls=request.mock.calls.length
+    state.available=true;state.installation={status:'ready',message:'就绪',updatedAt:2}
+    await vi.advanceTimersByTimeAsync(5000);await flushPromises()
+    expect(request.mock.calls).toHaveLength(calls)
+    expect(request.mock.calls.some(([path])=>path.includes('/computer/take'))).toBe(false)
+  }finally{wrapper.unmount()}
+})
+
+it('shows installation failure with an explicit retry and resumes takeover after recovery',async()=>{
+  vi.useFakeTimers()
+  state.available=false;state.installation={status:'missing',message:'待准备',updatedAt:1}
+  let attempts=0
+  const original=request.getMockImplementation()!
+  request.mockImplementation(async(path,options)=>{
+    if(path.endsWith('/prepare')){
+      attempts++
+      state.installation=attempts===1?{status:'failed',message:'下载失败',error:'节点网络不可用',updatedAt:2}:{status:'ready',message:'已准备',updatedAt:3}
+      state.available=attempts>1
+      return structuredClone(state) as any
+    }
+    return original(path,options)
+  })
+  const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true,autoTake:true},global:{stubs}})
+  try{
+    await flushPromises()
+    expect(wrapper.text()).toContain('节点网络不可用')
+    expect(attempts).toBe(1)
+    await wrapper.findAll('button').find(button=>button.text()==='重试并接管浏览器')!.trigger('click');await flushPromises()
+    expect(request.mock.calls.filter(([path])=>path.endsWith('/prepare')).at(-1)?.[1]?.body).toEqual({retry:true})
+    expect(attempts).toBe(2)
+    expect(wrapper.text()).toContain('人工控制中')
+  }finally{wrapper.unmount()}
+})
+
+it('returns a takeover receipt that arrives after its viewer unmounts to the original browser',async()=>{
+  let resolve!:(value:unknown)=>void
+  const original=request.getMockImplementation()!
+  request.mockImplementation((path,options)=>path.includes('/computer/take')?new Promise(done=>{resolve=done}):original(path,options))
+  const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true,autoTake:true},global:{stubs}})
+  await flushPromises();wrapper.unmount()
+  resolve({mode:'human',controlId:'late-control',token:'late-token',generation:4});await flushPromises()
+  expect(request).toHaveBeenCalledWith('/api/app/agents/bot/computer/giveback?backend=managed-browser',{method:'POST',body:{controlId:'late-control',token:'late-token',notes:''}})
+})
+
+it('exposes preparation in the side panel while read-only viewing never installs the browser',async()=>{
+  state.available=false;state.installation={status:'missing',message:'首次接管将准备浏览器',updatedAt:1}
+  const wrapper=mount(Preview,{props:{agents:[agent],isAdmin:true,active:true},global:{stubs}})
+  try{
+    await flushPromises()
+    const browser=wrapper.get('[aria-label="托管浏览器"]')
+    expect(browser.text()).toContain('首次接管将准备浏览器')
+    expect(browser.get('button').attributes('disabled')).toBeUndefined()
+    expect(request.mock.calls.some(([path])=>path.endsWith('/prepare'))).toBe(false)
+    await browser.get('button').trigger('click')
+    expect(wrapper.emitted('desktop')?.at(-1)).toEqual([agent,'managed-browser',undefined])
+  }finally{wrapper.unmount()}
+})

+ 93 - 0
tests/client/managedBrowserCardSmoke.test.ts

@@ -0,0 +1,93 @@
+// @vitest-environment node
+import { expect, it } from 'vitest'
+import { chromium, expect as browserExpect } from '@playwright/test'
+import { mkdtemp, mkdir, rm } from 'node:fs/promises'
+import { spawn } from 'node:child_process'
+import { createServer } from 'node:net'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+
+async function freePort(){const server=createServer();await new Promise<void>(done=>server.listen(0,'127.0.0.1',done));const port=(server.address() as {port:number}).port;await new Promise<void>(done=>server.close(()=>done()));return port}
+
+// Uses a built client and isolated authentication fixture; no model, VM or
+// installation is started. Run after `vite build` with YAOYAO_BROWSER_UI_SMOKE=1.
+it.runIf(process.env.YAOYAO_BROWSER_UI_SMOKE==='1')('opens the structured chat card in the existing native/Web viewer and fits small screens',async()=>{
+  const root=resolve(import.meta.dirname,'../..'),home=await mkdtemp(join(tmpdir(),'yaoyao-browser-ui-'))
+  const port=await freePort(),upstream=await freePort(),origin=`http://127.0.0.1:${port}`
+  const server=spawn(process.execPath,['--import','tsx','tests/fixtures/workspace-server.ts'],{cwd:root,env:{...process.env,WORKSPACE_FIXTURE_HOME:home,WORKSPACE_FIXTURE_TEAM_TOOLS:'1',WORKSPACE_FIXTURE_PORT:String(port),WORKSPACE_FIXTURE_UPSTREAM_PORT:String(upstream)},stdio:'ignore'})
+  let browser:Awaited<ReturnType<typeof chromium.launch>>|undefined
+  try{
+    await browserExpect.poll(async()=>{try{return(await fetch(origin+'/healthz')).status}catch{return 0}},{timeout:30000}).toBe(200)
+    browser=await chromium.launch();const page=await browser.newPage({viewport:{width:1100,height:850}})
+    await page.goto(origin+'/conversations')
+    await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture')
+    await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass')
+    await page.getByRole('button',{name:'登录',exact:true}).click()
+    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
+    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}
+    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}}))
+    const image=await page.evaluate(()=>{const canvas=document.createElement('canvas');canvas.width=1000;canvas.height=650;const ctx=canvas.getContext('2d')!;ctx.fillStyle='#f4f6f8';ctx.fillRect(0,0,1000,650);ctx.fillStyle='#27313f';ctx.font='32px sans-serif';ctx.fillText('Browser control preview',50,95);ctx.font='20px sans-serif';ctx.fillText('The same page continues after handoff.',50,145);return canvas.toDataURL('image/png').split(',')[1]})
+    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.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}})
+      if(route.request().method()==='POST'){
+        const op=path.split('/').at(-1)!;calls.push({op,body:route.request().postDataJSON()})
+        if(op==='take'){expect(available).toBe(true);human=true}
+        if(op==='giveback')human=false
+      }
+      return route.fulfill({json:{mode:human?'human':'off',backend:'managed-browser',hostName:'托管浏览器',generation:4,canResume:true,...(human?{controlId:'control',token:'fixture-token'}:{})}})
+    })
+    await page.goto(origin+'/conversations/'+seed.conversationId)
+    const element=page.getByRole('region',{name:'托管浏览器会话'})
+    await browserExpect(element).toContainText('产品文档 · 浏览器接管验证')
+    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()
+    expect(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth)).toBe(false)
+    await page.screenshot({path:join(output,'chat-375.png')})
+    // The native bridge receives exactly the trusted card's Bot and backend.
+    await page.evaluate(()=>{(window as any).__native=[];(window as any).yaoyaoDesktop={openComputer:async(...args:unknown[])=>{(window as any).__native.push(args);return true}}})
+    await element.getByRole('button',{name:'接管浏览器',exact:true}).click()
+    await browserExpect.poll(()=>page.evaluate(()=>(window as any).__native)).toEqual([[seed.agentId,{backend:'managed-browser'}]])
+    await page.evaluate(()=>delete (window as any).yaoyaoDesktop)
+    await element.getByRole('button',{name:'接管浏览器',exact:true}).click()
+    const panel=page.locator('dialog.computer-panel')
+    await browserExpect(panel.getByRole('status')).toContainText('正在下载 Chromium')
+    expect(calls.filter(call=>call.op==='prepare')).toHaveLength(1)
+    expect(calls.some(call=>call.op==='take')).toBe(false)
+    await page.screenshot({path:join(output,'preparing-375.png')})
+    available=true
+    await browserExpect(panel).toContainText('人工控制中')
+    await browserExpect(panel.locator('.computer-screen img')).toBeVisible()
+    expect(calls.filter(call=>call.op==='take')).toHaveLength(1)
+    expect(await panel.evaluate(el=>el.scrollWidth>el.clientWidth)).toBe(false)
+    await page.screenshot({path:join(output,'control-375.png')})
+    await panel.getByRole('button',{name:'交还并关闭',exact:true}).click()
+    await browserExpect(panel).toHaveCount(0)
+    expect(calls.filter(call=>call.op==='giveback')).toHaveLength(1)
+    expect(page.url()).toContain('/conversations/'+seed.conversationId)
+    await page.reload();await browserExpect(element).toContainText('产品文档 · 浏览器接管验证')
+    await page.emulateMedia({reducedMotion:'reduce',colorScheme:'dark'})
+    await browserExpect(page.locator('html')).toHaveClass(/dark/)
+    await page.screenshot({path:join(output,'chat-dark-375.png')})
+    await page.setViewportSize({width:812,height:375})
+    await element.getByRole('button',{name:'接管浏览器',exact:true}).scrollIntoViewIfNeeded()
+    expect(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth)).toBe(false)
+    await page.screenshot({path:join(output,'chat-landscape-dark.png')})
+  }finally{
+    await browser?.close();server.kill('SIGTERM')
+    await new Promise<void>(done=>{if(server.exitCode!==null)return done();const timer=setTimeout(()=>{server.kill('SIGKILL');done()},3000);server.once('exit',()=>{clearTimeout(timer);done()})})
+    await rm(home,{recursive:true,force:true})
+  }
+},60000)

+ 67 - 0
tests/client/managedBrowserCards.test.ts

@@ -0,0 +1,67 @@
+import { afterEach, expect, it, vi } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
+import { createPinia } from 'pinia'
+import Card from '@/components/messages/ManagedBrowserCard.vue'
+import Timeline from '@/components/workspace/WorkspaceMessageTimeline.vue'
+import { workspaceMessagesToUi } from '@/components/workspace/viewModels'
+import { WorkspaceTranscriptStore } from '@/components/workspace/transcriptStore'
+import { buildMessageTimelineRows } from '@/utils/turnTrace'
+import type { ManagedBrowserCard } from '@shared/managedBrowser'
+import type { WorkspaceMessage } from '@shared/workspace'
+
+const card:ManagedBrowserCard={id:'browser-session',agentId:'bot',agentName:'竹儿',status:'active',title:'真实网页标题',url:'https://example.com/path',updatedAt:1}
+const message:WorkspaceMessage={id:'browser-message',conversationId:'chat',agentId:'bot',agentName:'竹儿',role:'assistant',content:'',reasoning:'',status:'complete',seq:1,createdAt:1,tools:[{id:'tool',name:'managed_browser_open',status:'tool.complete'}],attachments:[],browserCard:card}
+afterEach(()=>{vi.restoreAllMocks();vi.unstubAllGlobals()})
+
+it('preserves a structured browser card through history serialization and trace aggregation',()=>{
+  const restored=JSON.parse(JSON.stringify(message)) as WorkspaceMessage
+  const [ui]=workspaceMessagesToUi([restored])
+  expect(ui?.browserCard).toEqual(card)
+  const rows=buildMessageTimelineRows([ui!])
+  expect(rows.map(row=>row.kind)).toEqual(['trace','message'])
+  expect(rows[1]?.kind==='message'&&rows[1].message.browserCard).toEqual(card)
+  expect(workspaceMessagesToUi([{...message,browserCard:undefined,content:JSON.stringify({browserCard:card})}])[0]?.browserCard).toBeUndefined()
+})
+
+it('renders browser cards with tools hidden and emits only their structured control target',async()=>{
+  vi.stubGlobal('ResizeObserver',class {observe(){}disconnect(){}})
+  const wrapper=mount(Timeline,{props:{identity:'chat',messages:workspaceMessagesToUi([message]),showTools:false},global:{plugins:[createPinia()],stubs:{AppIcon:true,AgentAvatar:true,MarkdownContent:true}}})
+  try{
+    await flushPromises()
+    expect(wrapper.text()).toContain('真实网页标题')
+    expect(wrapper.text()).toContain('https://example.com/path')
+    expect(wrapper.find('.turn-trace').exists()).toBe(false)
+    await wrapper.get('.managed-browser-card button').trigger('click')
+    expect(wrapper.emitted('browserControl')).toEqual([[card]])
+    const finished={...message,browserCard:{...card,status:'closed' as const,updatedAt:2}}
+    await wrapper.setProps({messages:workspaceMessagesToUi([finished])})
+    expect(wrapper.text()).toContain('会话已结束')
+    expect(wrapper.find('.managed-browser-card button').exists()).toBe(false)
+  }finally{wrapper.unmount()}
+})
+
+it('updates a card from server events and preserves it across a fresh transcript snapshot',()=>{
+  const conversation={id:'chat',kind:'direct',memberIds:['bot'],lastSeq:1} as any
+  const store=new WorkspaceTranscriptStore()
+  store.hydrate({agents:[],conversations:[conversation],details:[{conversation,messages:[message],cursor:1,hasOlder:false,run:null,interactions:[],context:null}],cursor:1})
+  const changed={...message,browserCard:{...card,status:'preparing' as const,installation:{status:'installing' as const,message:'下载中 60%',progress:60,updatedAt:2},updatedAt:2}}
+  store.apply({type:'message.changed',seq:2,conversationId:'chat',data:changed})
+  expect(workspaceMessagesToUi(store.get('chat')!.messages)[0]?.browserCard).toEqual(changed.browserCard)
+  const snapshot=JSON.parse(JSON.stringify(store.get('chat')))
+  const refreshed=new WorkspaceTranscriptStore()
+  refreshed.hydrate({agents:[],conversations:[conversation],details:[snapshot],cursor:2})
+  expect(workspaceMessagesToUi(refreshed.get('chat')!.messages)[0]?.browserCard?.installation?.progress).toBe(60)
+})
+
+it('shows real installation progress, errors and a keyboard-accessible retry',async()=>{
+  const wrapper=mount(Card,{props:{card:{...card,status:'preparing',installation:{status:'installing',message:'下载中 45%',progress:45,updatedAt:1}}},global:{stubs:{AppIcon:true}}})
+  try{
+    expect(wrapper.get('progress').attributes('value')).toBe('45')
+    expect(wrapper.get('[role="status"]').text()).toBe('下载中 45%')
+    await wrapper.setProps({card:{...card,status:'failed',installation:{status:'failed',message:'安装失败',error:'空间不足',updatedAt:2}}})
+    expect(wrapper.get('[role="alert"]').text()).toBe('空间不足')
+    expect(wrapper.get('button').text()).toBe('重试并接管浏览器')
+    await wrapper.setProps({readOnly:true})
+    expect(wrapper.find('button').exists()).toBe(false)
+  }finally{wrapper.unmount()}
+})

+ 93 - 0
tests/runner/browserInstallation.test.ts

@@ -0,0 +1,93 @@
+// @vitest-environment node
+import {EventEmitter} from 'node:events'
+import {PassThrough} from 'node:stream'
+import {afterEach,describe,expect,it,vi} from 'vitest'
+import {browserInstallationFailure,createBrowserInstaller,type BrowserInstallerDependencies} from '../../src/runner/browser/installation.js'
+
+function fixture(options:Partial<BrowserInstallerDependencies>={}){
+  const children:(EventEmitter&{stdout:PassThrough;stderr:PassThrough;pid:number;kill:ReturnType<typeof vi.fn>})[]=[]
+  const spawn=vi.fn(()=>{
+    const child=Object.assign(new EventEmitter(),{stdout:new PassThrough(),stderr:new PassThrough(),pid:999999999,kill:vi.fn()})
+    child.kill.mockImplementation(()=>{queueMicrotask(()=>child.emit('close',null));return true});children.push(child);return child
+  })
+  const close=vi.fn(async()=>{}),launch=vi.fn(async()=>({close})),onUpdate=vi.fn()
+  const installer=createBrowserInstaller({spawn:spawn as unknown as BrowserInstallerDependencies['spawn'],chromium:async()=>({executablePath:()=>process.execPath,launch}),cliPath:()=>'/fixed/playwright/cli.js',execPath:'/fixed/node',platform:'darwin',env:{HOME:'/private/person',TOKEN:'secret',NODE_OPTIONS:'--inspect',PWDEBUG:'1'},...options})
+  const controller=new AbortController(),run=()=>installer.install({signal:controller.signal,onUpdate})
+  return {children,spawn,close,launch,onUpdate,installer,controller,run}
+}
+afterEach(()=>{vi.useRealTimers();vi.restoreAllMocks()})
+
+describe('Runner Chromium installer',()=>{
+  it('runs the matching fixed CLI in Node mode and waits for an actual sandbox launch',async()=>{
+    const f=fixture(),result=f.run()
+    expect(f.spawn).toHaveBeenCalledWith('/fixed/node',['/fixed/playwright/cli.js','install','chromium'],expect.objectContaining({shell:false,detached:true,env:expect.objectContaining({ELECTRON_RUN_AS_NODE:'1',CI:'1'})}))
+    expect(f.spawn.mock.calls[0]![2]?.env).not.toHaveProperty('NODE_OPTIONS')
+    f.children[0]!.stdout.write('Downloading from https://user:secret@mirror.invalid/private/person\n|████ | 50% of 12 MiB\n')
+    expect(f.onUpdate).toHaveBeenCalledWith({message:'正在下载浏览器组件(当前组件 50%)',progress:50})
+    f.children[0]!.emit('close',0);await result
+    expect(f.launch).toHaveBeenCalledWith({headless:true,chromiumSandbox:true,timeout:30000});expect(f.close).toHaveBeenCalledOnce()
+    expect(JSON.stringify(f.onUpdate.mock.calls)).not.toMatch(/secret|private\/person|mirror/)
+  })
+  it('checks executable and sandbox readiness rather than only accepting an existing binary',async()=>{
+    const f=fixture();expect(await f.installer.probe()).toBe(true)
+    f.launch.mockRejectedValueOnce(new Error('headless shell missing'))
+    expect(await f.installer.probe()).toBe(false)
+  })
+  it('never invokes install-deps as a user that would trigger sudo or su',async()=>{
+    const f=fixture({platform:'linux',getuid:()=>1000});f.launch.mockRejectedValueOnce(new Error('Host system is missing dependencies to run browsers'))
+    const result=f.run();f.children[0]!.emit('close',0)
+    await expect(result).rejects.toMatchObject({code:'browser_install_dependencies'})
+    expect(f.spawn).toHaveBeenCalledTimes(1)
+  })
+  it('installs missing Linux system libraries only as root, then checks the sandbox again',async()=>{
+    const f=fixture({platform:'linux',getuid:()=>0});f.launch.mockRejectedValueOnce(new Error('error while loading shared libraries: libX.so'))
+    const result=f.run();f.children[0]!.emit('close',0)
+    await vi.waitFor(()=>expect(f.children).toHaveLength(2))
+    expect(f.spawn.mock.calls[1]![1]).toEqual(['/fixed/playwright/cli.js','install-deps','chromium'])
+    f.children[1]!.emit('close',0);await result
+    expect(f.launch).toHaveBeenCalledTimes(2);expect(f.close).toHaveBeenCalledOnce()
+  })
+  it('reports sandbox failure without disabling it or exposing raw diagnostics',async()=>{
+    const f=fixture();f.launch.mockRejectedValueOnce(new Error('Chromium sandboxing failed! /private/person use --no-sandbox token=secret'))
+    const result=f.run();f.children[0]!.emit('close',0)
+    const error=await result.catch(error=>error)
+    expect(error.code).toBe('browser_install_sandbox')
+    expect(browserInstallationFailure(error)).not.toMatch(/private|secret|--no-sandbox/)
+    expect(f.spawn).toHaveBeenCalledTimes(1)
+  })
+  it('kills the installer on cancellation and on the bounded overall deadline',async()=>{
+    const f=fixture();const result=f.run().catch(error=>error);f.controller.abort()
+    expect((await result).code).toBe('browser_install_cancelled');expect(f.children[0]!.kill).toHaveBeenCalled()
+    vi.useFakeTimers();const timeout=fixture({timeoutMs:1000}),expired=timeout.run().catch(error=>error)
+    await vi.advanceTimersByTimeAsync(1100)
+    expect((await expired).code).toBe('browser_install_timeout');expect(timeout.children[0]!.kill).toHaveBeenCalled()
+  })
+  it('kills remaining process-group children even when the CLI parent exits first',async()=>{
+    const kill=vi.spyOn(process,'kill').mockImplementation(()=>true)
+    const f=fixture(),result=f.run().catch(error=>error);f.controller.abort()
+    expect(kill).toHaveBeenCalledWith(-999999999,'SIGTERM')
+    f.children[0]!.emit('close',null);await result
+    expect(kill).toHaveBeenCalledWith(-999999999,'SIGKILL')
+  })
+  it('cancels a pending sandbox launch and closes any browser that arrives late',async()=>{
+    const f=fixture();let complete!:(value:{close:typeof f.close})=>void
+    f.launch.mockImplementationOnce(()=>new Promise(resolve=>{complete=resolve}))
+    const result=f.run().catch(error=>error);f.children[0]!.emit('close',0)
+    await vi.waitFor(()=>expect(f.launch).toHaveBeenCalled())
+    f.controller.abort();expect((await result).code).toBe('browser_install_cancelled')
+    complete({close:f.close});await vi.waitFor(()=>expect(f.close).toHaveBeenCalled())
+  })
+  it('also honors cancellation while the probe browser is closing',async()=>{
+    const f=fixture();let finish!:()=>void
+    f.close.mockImplementationOnce(()=>new Promise(resolve=>{finish=resolve}))
+    const result=f.run().catch(error=>error);f.children[0]!.emit('close',0)
+    await vi.waitFor(()=>expect(f.close).toHaveBeenCalled());f.controller.abort()
+    expect((await result).code).toBe('browser_install_cancelled');finish()
+  })
+  it('bounds diagnostics and does not expose CLI paths or credentials on failure',async()=>{
+    const f=fixture(),result=f.run().catch(error=>error)
+    f.children[0]!.stderr.write('x'.repeat(100000)+' /private/person https://token:secret@mirror.invalid');f.children[0]!.emit('close',1)
+    const error=await result;expect(error.code).toBe('browser_install_failed')
+    expect(browserInstallationFailure(error)).not.toMatch(/private|secret|mirror/);expect(f.launch).not.toHaveBeenCalled()
+  })
+})

+ 72 - 0
tests/runner/browserInstallationSmoke.test.ts

@@ -0,0 +1,72 @@
+// @vitest-environment node
+import {expect,it} from 'vitest'
+import {execFile} from 'node:child_process'
+import {mkdtemp,realpath,rm} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join,resolve} from 'node:path'
+import {createRequire} from 'node:module'
+import {pathToFileURL} from 'node:url'
+import {promisify} from 'node:util'
+
+/** Opt-in real first-install test. It downloads the pinned Chromium into a fresh
+ * temporary cache in a separate Node process, then removes that cache.
+ * YAOYAO_BROWSER_INSTALL_SMOKE=1 npx vitest run tests/runner/browserInstallationSmoke.test.ts */
+it.skipIf(process.env.YAOYAO_BROWSER_INSTALL_SMOKE!=='1')('prepares an empty node browser cache, downloads Chromium and opens a sandboxed Runner session',async()=>{
+  const root=await realpath(await mkdtemp(join(tmpdir(),'yaoyao-browser-install-smoke-')))
+  const require=createRequire(import.meta.url)
+  const script=`
+    import {randomUUID} from 'node:crypto';
+    import {existsSync} from 'node:fs';
+    import {join} from 'node:path';
+    import {RunnerBrowser} from ${JSON.stringify(pathToFileURL(resolve('src/runner/managedBrowser.ts')).href)};
+    import {createBrowserInstaller} from ${JSON.stringify(pathToFileURL(resolve('src/runner/browser/installation.ts')).href)};
+    const report=value=>process.stdout.write(JSON.stringify(value)+'\\n');
+    const installer=createBrowserInstaller();
+    const originalInstall=installer.install;
+    installer.install=options=>originalInstall({...options,onUpdate:update=>{report({event:'progress',...update});options.onUpdate(update)}});
+    const grants=new Map();
+    const check=async(_scope,id)=>{if(id?grants.has(id):[...grants.values()].some(role=>role!=='prepare'))return;throw new Error('browser session has no active grant')};
+    const browser=new RunnerBrowser({enabled:true},process.env.YAOYAO_INSTALL_SMOKE_HOME,check,{installer});
+    process.once('SIGTERM',()=>void browser.shutdown().finally(()=>process.exit(1)));
+    const scope={ownerKey:'a'.repeat(64),environmentId:randomUUID(),profile:'temporary'},setupGrant=randomUUID(),taskGrant=randomUUID();
+    const invoke=async(op,extra={})=>{
+      const role=op==='status'?'view':op==='prepare'?'prepare':'task',grantId=role==='view'?randomUUID():role==='prepare'?setupGrant:taskGrant;
+      grants.set(grantId,role);
+      try{return await browser.invoke({scope,profile:'default',grantId,op,...extra},Date.now()+30000,()=>{})}
+      finally{if(role==='view')grants.delete(grantId)}
+    };
+    try {
+      await browser.ready;
+      if(browser.available||existsSync(process.env.PLAYWRIGHT_BROWSERS_PATH))throw new Error('fixture cache is not empty');
+      report({event:'initial',...await invoke('status')});
+      report({event:'prepared',...await invoke('prepare')});
+      let state;
+      for(;;){
+        state=await invoke('status');
+        if(state.installation.status==='ready')break;
+        if(state.installation.status==='failed')throw new Error(state.installation.message);
+        await new Promise(resolve=>setTimeout(resolve,200));
+      }
+      grants.delete(setupGrant);
+      if(!await installer.probe())throw new Error('post-install sandbox probe failed');
+      const opened=await invoke('open');
+      const frame=await invoke('execute',{operation:{generation:opened.generation,operationId:'installation-smoke',action:{kind:'screenshot'}}});
+      if(Buffer.from(frame.data,'base64').subarray(1,4).toString()!=='PNG')throw new Error('missing browser frame');
+      const {chromium}=await import('playwright');
+      report({event:'ready',available:state.available,status:state.installation.status,open:opened.open,mimeType:frame.mimeType,executableInTemporaryCache:chromium.executablePath().startsWith(process.env.PLAYWRIGHT_BROWSERS_PATH)});
+      await invoke('close');
+    }finally{await browser.shutdown()}
+  `
+  try{
+    const {stdout}=await promisify(execFile)(process.execPath,['--import',require.resolve('tsx'),'--input-type=module','--eval',script],{
+      cwd:resolve('.'),env:{...process.env,PLAYWRIGHT_BROWSERS_PATH:join(root,'browsers'),YAOYAO_INSTALL_SMOKE_HOME:join(root,'runner'),ELECTRON_RUN_AS_NODE:'1'},timeout:9*60000,maxBuffer:1024*1024,
+    })
+    const events=stdout.trim().split('\n').map(line=>JSON.parse(line))
+    expect(events.find(event=>event.event==='initial')).toMatchObject({available:false,installation:{status:'missing'}})
+    expect(events.find(event=>event.event==='prepared')).toMatchObject({available:false,installation:{status:'installing'}})
+    expect(events.filter(event=>event.event==='progress').map(event=>event.message)).toContain('正在验证 Chromium 沙箱启动')
+    expect(events.find(event=>event.event==='ready')).toMatchObject({available:true,status:'ready',open:true,mimeType:'image/png',executableInTemporaryCache:true})
+    // Keep the evidence useful without exposing the temporary cache's personal path.
+    console.info('fresh-install smoke evidence:',JSON.stringify(events.filter(event=>['progress','ready'].includes(event.event))))
+  }finally{await rm(root,{recursive:true,force:true})}
+},10*60000)

+ 158 - 2
tests/runner/managedBrowserTransport.test.ts

@@ -2,14 +2,15 @@
 import {afterEach,describe,expect,it,vi} from 'vitest'
 import {afterEach,describe,expect,it,vi} from 'vitest'
 import {createHash,randomUUID} from 'node:crypto'
 import {createHash,randomUUID} from 'node:crypto'
 import {RunnerBrowser} from '../../src/runner/managedBrowser.js'
 import {RunnerBrowser} from '../../src/runner/managedBrowser.js'
+import type {BrowserInstaller} from '../../src/runner/browser/installation.js'
 import type {BrowserRuntime,BrowserCallContext,BrowserScope} from '../../src/runner/browser/runtime.js'
 import type {BrowserRuntime,BrowserCallContext,BrowserScope} from '../../src/runner/browser/runtime.js'
 
 
 const scope:BrowserScope={ownerKey:'a'.repeat(64),environmentId:randomUUID(),profile:'persistent'},grantId=randomUUID()
 const scope:BrowserScope={ownerKey:'a'.repeat(64),environmentId:randomUUID(),profile:'persistent'},grantId=randomUUID()
 const scopes:RunnerBrowser[]=[]
 const scopes:RunnerBrowser[]=[]
-function fixture(config:{enabled:boolean;idleTimeoutMs?:number}={enabled:true}){
+function fixture(config:{enabled:boolean;idleTimeoutMs?:number}={enabled:true},installer?:BrowserInstaller){
   const runtime={status:vi.fn(async()=>({open:false,generation:1,tabs:[],downloads:[]})),open:vi.fn(async()=>({open:true,generation:1,tabs:[],downloads:[]})),execute:vi.fn(async()=>({ok:true})),pause:vi.fn(),resume:vi.fn(),close:vi.fn(),readDownload:vi.fn(),revoke:vi.fn(async()=>{}),shutdown:vi.fn(async()=>{})}
   const runtime={status:vi.fn(async()=>({open:false,generation:1,tabs:[],downloads:[]})),open:vi.fn(async()=>({open:true,generation:1,tabs:[],downloads:[]})),execute:vi.fn(async()=>({ok:true})),pause:vi.fn(),resume:vi.fn(),close:vi.fn(),readDownload:vi.fn(),revoke:vi.fn(async()=>{}),shutdown:vi.fn(async()=>{})}
   const check=vi.fn(async(_scope:BrowserScope,_grant?:string)=>{}),probe=vi.fn(async()=>true)
   const check=vi.fn(async(_scope:BrowserScope,_grant?:string)=>{}),probe=vi.fn(async()=>true)
-  const browser=new RunnerBrowser(config,'/unused-browser-test',check,{runtime:runtime as unknown as BrowserRuntime,probe});scopes.push(browser)
+  const browser=new RunnerBrowser(config,'/unused-browser-test',check,{runtime:runtime as unknown as BrowserRuntime,probe,installer});scopes.push(browser)
   const profileAllowed=vi.fn((_profile:string)=>{})
   const profileAllowed=vi.fn((_profile:string)=>{})
   const invoke=(value:Record<string,unknown>,expiresAt=Date.now()+30000)=>browser.invoke({scope,profile:'default',grantId,...value},expiresAt,profileAllowed)
   const invoke=(value:Record<string,unknown>,expiresAt=Date.now()+30000)=>browser.invoke({scope,profile:'default',grantId,...value},expiresAt,profileAllowed)
   return {browser,runtime,check,probe,profileAllowed,invoke}
   return {browser,runtime,check,probe,profileAllowed,invoke}
@@ -84,3 +85,158 @@ describe('managed browser Runner transport',()=>{
     expect(f.runtime.revoke).toHaveBeenCalledWith(scope)
     expect(f.runtime.revoke).toHaveBeenCalledWith(scope)
   })
   })
 })
 })
+
+function installationFixture(){
+  let complete!:()=>void,fail!:(error:Error)=>void,signal!:AbortSignal
+  const install=vi.fn(async(options:Parameters<BrowserInstaller['install']>[0])=>{
+    signal=options.signal
+    await new Promise<void>((resolve,reject)=>{complete=resolve;fail=reject;signal.addEventListener('abort',()=>reject(signal.reason),{once:true})})
+  })
+  const installer={probe:vi.fn(async()=>false),install},f=fixture({enabled:true},installer)
+  f.probe.mockResolvedValue(false)
+  return {...f,installer,complete:()=>complete(),fail:(error:Error)=>fail(error),signal:()=>signal}
+}
+
+describe('managed browser installation transport',()=>{
+  it('returns authorized setup state when missing and keeps close idempotent',async()=>{
+    const f=installationFixture()
+    await expect(f.invoke({op:'status'})).resolves.toMatchObject({open:false,available:false,installation:{status:'missing'}})
+    await expect(f.invoke({op:'close'})).resolves.toMatchObject({open:false,tabs:[],downloads:[]})
+    await expect(f.invoke({op:'open'})).rejects.toMatchObject({code:'browser_unavailable'})
+    expect(f.installer.install).not.toHaveBeenCalled()
+    f.check.mockRejectedValueOnce(new Error('foreign grant'))
+    await expect(f.invoke({op:'prepare'})).rejects.toThrow('foreign grant')
+    expect(f.installer.install).not.toHaveBeenCalled()
+  })
+  it('prepares with the real Runtime after a view grant ends without authorizing a browser session',async()=>{
+    vi.useFakeTimers()
+    const viewGrant=randomUUID(),prepareGrant=randomUUID(),grants=new Map<string,'view'|'prepare'>([[viewGrant,'view']])
+    const check=vi.fn(async(_scope:BrowserScope,grant?:string)=>{
+      if(grant?grants.has(grant):[...grants.values()].some(role=>role!=='prepare'))return
+      throw new Error('browser session has no active grant')
+    })
+    let complete!:()=>void,signal!:AbortSignal
+    const installer:BrowserInstaller={probe:async()=>false,install:async options=>{
+      signal=options.signal
+      await new Promise<void>((resolve,reject)=>{complete=resolve;signal.addEventListener('abort',()=>reject(signal.reason),{once:true})})
+    }}
+    // Use the production BrowserRuntime and its default scope-only authorization.
+    const browser=new RunnerBrowser({enabled:true},'/unused-no-browser-session',check,{installer});scopes.push(browser)
+    const readState=vi.spyOn(browser.runtime,'status')
+    const invoke=(op:string,grant:string)=>browser.invoke({scope,profile:'default',grantId:grant,op},Date.now()+30000,()=>{})
+    await expect(invoke('status',viewGrant)).resolves.toMatchObject({available:false,installation:{status:'missing'}})
+    grants.delete(viewGrant);grants.set(prepareGrant,'prepare')
+    await expect(invoke('prepare',prepareGrant)).resolves.toMatchObject({open:false,tabs:[],downloads:[],available:false,installation:{status:'installing'}})
+    expect(readState).not.toHaveBeenCalled()
+    await vi.advanceTimersByTimeAsync(2100);expect(signal.aborted).toBe(false)
+    // Installing a binary never grants permission to use or retain a page.
+    await expect(browser.runtime.status(scope)).rejects.toThrow('no active grant')
+    await expect(browser.runtime.open(scope)).rejects.toThrow('no active grant')
+    complete();await vi.advanceTimersByTimeAsync(20);expect(browser.available).toBe(true)
+    readState.mockClear()
+    // Another holder can finish setup between the server's view and prepare RPC.
+    await expect(invoke('prepare',prepareGrant)).resolves.toMatchObject({open:false,tabs:[],available:true,installation:{status:'ready'}})
+    expect(readState).not.toHaveBeenCalled()
+    await expect(invoke('status',prepareGrant)).rejects.toThrow('no active grant')
+    grants.set(viewGrant,'view')
+    await expect(invoke('status',viewGrant)).resolves.toMatchObject({open:false,available:true,installation:{status:'ready'}})
+    grants.delete(viewGrant)
+    await expect(browser.runtime.status(scope)).rejects.toThrow('no active grant')
+  })
+  it('does not refresh the idle lease of an existing session when prepare finds it ready',async()=>{
+    vi.useFakeTimers();const f=fixture({enabled:true,idleTimeoutMs:60000})
+    await f.invoke({op:'open'});await vi.advanceTimersByTimeAsync(55000)
+    await expect(f.invoke({op:'prepare'})).resolves.toMatchObject({open:false,available:true,installation:{status:'ready'}})
+    expect(f.runtime.status).not.toHaveBeenCalled()
+    await vi.advanceTimersByTimeAsync(6000);await f.browser.sweep()
+    expect(f.runtime.revoke).toHaveBeenCalledWith(scope)
+  })
+  it('returns checking immediately and queues one preparation behind a slow startup probe',async()=>{
+    vi.useFakeTimers();const f=installationFixture();let finishProbe!:(value:boolean)=>void
+    f.probe.mockImplementationOnce(()=>new Promise(resolve=>{finishProbe=resolve}))
+    await expect(f.invoke({op:'status'},Date.now()+1000)).resolves.toMatchObject({available:false,installation:{status:'checking'}})
+    await expect(f.invoke({op:'prepare'},Date.now()+1000)).resolves.toMatchObject({available:false,installation:{status:'checking'}})
+    await f.invoke({op:'prepare'})
+    await vi.advanceTimersByTimeAsync(31000)
+    expect(f.installer.install).not.toHaveBeenCalled()
+    await expect(f.invoke({op:'status'})).resolves.toMatchObject({installation:{status:'checking'}})
+    finishProbe(false);await vi.advanceTimersByTimeAsync(20)
+    expect(f.installer.install).toHaveBeenCalledTimes(1)
+    expect(f.signal().aborted).toBe(false)
+    f.complete();await vi.advanceTimersByTimeAsync(20);expect(f.browser.available).toBe(true)
+  })
+  it('does not install when a slow probe finds an existing browser or after disconnect',async()=>{
+    const found=installationFixture();let finishFound!:(value:boolean)=>void
+    found.probe.mockImplementationOnce(()=>new Promise(resolve=>{finishFound=resolve}))
+    await expect(found.invoke({op:'prepare'})).resolves.toMatchObject({installation:{status:'checking'}})
+    finishFound(true);await found.browser.ready
+    await vi.waitFor(()=>expect(found.browser.available).toBe(true))
+    expect(found.installer.install).not.toHaveBeenCalled()
+    const disconnected=installationFixture();let finishDisconnected!:(value:boolean)=>void
+    disconnected.probe.mockImplementationOnce(()=>new Promise(resolve=>{finishDisconnected=resolve}))
+    await disconnected.invoke({op:'prepare'});await disconnected.browser.disconnect()
+    expect(disconnected.browser.installation.status).toBe('failed')
+    finishDisconnected(false);await disconnected.browser.ready
+    expect(disconnected.browser.installation.status).toBe('failed')
+    expect(disconnected.installer.install).not.toHaveBeenCalled()
+  })
+  it('cancels an open request that is still waiting for the startup check',async()=>{
+    const f=installationFixture();let finishProbe!:(value:boolean)=>void
+    f.probe.mockImplementationOnce(()=>new Promise(resolve=>{finishProbe=resolve}))
+    const result=f.invoke({op:'open'}).catch(error=>error)
+    await f.invoke({op:'status'});await f.browser.disconnect()
+    expect(await result).toBeInstanceOf(Error)
+    finishProbe(true);await f.browser.ready;expect(f.runtime.open).not.toHaveBeenCalled()
+  })
+  it('deduplicates preparation and retains the grant beyond an individual RPC deadline',async()=>{
+    vi.useFakeTimers();const f=installationFixture()
+    await expect(f.invoke({op:'prepare'},Date.now()+1000)).resolves.toMatchObject({available:false,installation:{status:'installing'}})
+    await f.invoke({op:'prepare'})
+    expect(f.installer.install).toHaveBeenCalledTimes(1)
+    await vi.advanceTimersByTimeAsync(4000)
+    expect(f.signal().aborted).toBe(false);expect(f.check).toHaveBeenLastCalledWith(scope,grantId)
+    f.complete();await vi.advanceTimersByTimeAsync(20)
+    await expect(f.invoke({op:'status'})).resolves.toMatchObject({available:true,installation:{status:'ready'}})
+  })
+  it('keeps other authorized holders when one grant is revoked',async()=>{
+    vi.useFakeTimers();const f=installationFixture(),otherGrant=randomUUID()
+    await f.invoke({op:'prepare'});await f.invoke({op:'prepare',grantId:otherGrant})
+    f.check.mockImplementation(async(_scope,grant)=>{if(grant===grantId)throw new Error('revoked')})
+    await vi.advanceTimersByTimeAsync(2100);expect(f.signal().aborted).toBe(false)
+    f.complete();await vi.advanceTimersByTimeAsync(20)
+    expect(f.browser.available).toBe(true)
+  })
+  it('cancels when all holders lose authorization and requires an explicit retry',async()=>{
+    vi.useFakeTimers();const f=installationFixture();await f.invoke({op:'prepare'})
+    f.check.mockRejectedValue(new Error('revoked'));await vi.advanceTimersByTimeAsync(2100)
+    expect(f.signal().aborted).toBe(true);expect(f.browser.installation.status).toBe('failed')
+    f.check.mockResolvedValue(undefined);await f.invoke({op:'prepare'})
+    expect(f.installer.install).toHaveBeenCalledTimes(1)
+    await f.invoke({op:'prepare',retry:true});expect(f.installer.install).toHaveBeenCalledTimes(2)
+    f.complete();await vi.advanceTimersByTimeAsync(20);expect(f.browser.available).toBe(true)
+  })
+  it('closing one holder does not cancel another holder, while disconnect cancels the job',async()=>{
+    const f=installationFixture(),otherGrant=randomUUID()
+    await f.invoke({op:'prepare'});await f.invoke({op:'prepare',grantId:otherGrant});await f.invoke({op:'close'})
+    expect(f.signal().aborted).toBe(false)
+    await f.browser.disconnect();expect(f.signal().aborted).toBe(true);expect(f.browser.available).toBe(false)
+    expect(f.browser.installation.status).toBe('failed')
+  })
+  it('does not start a late preparation after disconnect interrupts authorization',async()=>{
+    const f=installationFixture();let authorize!:()=>void
+    f.check.mockImplementationOnce(()=>new Promise(resolve=>{authorize=resolve}))
+    const result=f.invoke({op:'prepare'}).catch(error=>error)
+    await vi.waitFor(()=>expect(f.check).toHaveBeenCalled());await f.browser.disconnect()
+    authorize();expect(await result).toBeInstanceOf(Error)
+    expect(f.installer.install).not.toHaveBeenCalled()
+  })
+  it('sanitizes failure details and never runs installation while disabled',async()=>{
+    const f=installationFixture();await f.invoke({op:'prepare'});await vi.waitFor(()=>expect(f.installer.install).toHaveBeenCalled());f.fail(new Error('token=secret /Users/personal'))
+    await vi.waitFor(()=>expect(f.browser.installation.status).toBe('failed'))
+    expect(JSON.stringify(f.browser.installation)).not.toMatch(/secret|personal/)
+    const disabled=fixture({enabled:false},f.installer)
+    await expect(disabled.invoke({op:'status'})).resolves.toMatchObject({enabled:false,available:false,installation:{status:'missing'}})
+    await expect(disabled.invoke({op:'prepare'})).rejects.toMatchObject({code:'browser_unavailable'})
+    expect(f.installer.install).toHaveBeenCalledTimes(1)
+  })
+})

+ 138 - 2
tests/server/managedBrowserSmoke.test.ts

@@ -7,8 +7,9 @@ import {mkdtempSync,realpathSync,rmSync} from 'node:fs'
 import {tmpdir} from 'node:os'
 import {tmpdir} from 'node:os'
 import {join} from 'node:path'
 import {join} from 'node:path'
 import {randomUUID} from 'node:crypto'
 import {randomUUID} from 'node:crypto'
+import {WebSocketServer,type WebSocket} from 'ws'
 import {WorkspaceStore} from '../../src/server/workspaceStore'
 import {WorkspaceStore} from '../../src/server/workspaceStore'
-import {LocalAuthStore} from '../../src/server/localAuth'
+import {LocalAuthStore,UpstreamServiceSession} from '../../src/server/localAuth'
 import {WorkspaceNodes,type GatewayTarget} from '../../src/server/workspaceGateway'
 import {WorkspaceNodes,type GatewayTarget} from '../../src/server/workspaceGateway'
 import {loadServerConfig} from '../../src/server/config'
 import {loadServerConfig} from '../../src/server/config'
 import {saveHostTools} from '../../src/server/hostToolSettings'
 import {saveHostTools} from '../../src/server/hostToolSettings'
@@ -16,13 +17,18 @@ import {RunnerHub} from '../../src/server/runnerHub'
 import {RunnerAgent} from '../../src/runner/agent'
 import {RunnerAgent} from '../../src/runner/agent'
 import {ManagedBrowsers,type BrowserTurn} from '../../src/server/managedBrowsers'
 import {ManagedBrowsers,type BrowserTurn} from '../../src/server/managedBrowsers'
 import {HttpError} from '../../src/server/errors'
 import {HttpError} from '../../src/server/errors'
+import {UpstreamClient} from '../../src/server/upstream'
+import {UploadStore} from '../../src/server/uploads'
+import {WorkspaceRuntime} from '../../src/server/workspaceRuntime'
 import type {RunnerConfiguration} from '../../src/shared/runner'
 import type {RunnerConfiguration} from '../../src/shared/runner'
+import type {WorkspaceConversation,WorkspaceMessage,WorkspaceRun} from '../../src/shared/workspace'
 
 
 class FixtureAuth extends LocalAuthStore {
 class FixtureAuth extends LocalAuthStore {
   version=1
   version=1
+  allowed=true
   override isUserActive(){return true}
   override isUserActive(){return true}
   override pushAuthorizationVersion(){return this.version}
   override pushAuthorizationVersion(){return this.version}
-  override canUseSource(){return true}
+  override canUseSource(){return this.allowed}
   override require(){return {id:'owner',username:'fixture',role:'admin'} as ReturnType<LocalAuthStore['require']>}
   override require(){return {id:'owner',username:'fixture',role:'admin'} as ReturnType<LocalAuthStore['require']>}
 }
 }
 const listen=(server:Server)=>new Promise<string>(resolve=>server.listen(0,'127.0.0.1',()=>resolve(`http://127.0.0.1:${(server.address() as {port:number}).port}`)))
 const listen=(server:Server)=>new Promise<string>(resolve=>server.listen(0,'127.0.0.1',()=>resolve(`http://127.0.0.1:${(server.address() as {port:number}).port}`)))
@@ -107,3 +113,133 @@ it.skipIf(process.env.YAOYAO_BROWSER_SMOKE!=='1')('uses a browser-only Runner th
     store.close();rmSync(home,{recursive:true,force:true})
     store.close();rmSync(home,{recursive:true,force:true})
   }
   }
 },60000)
 },60000)
+
+/** The model is a deterministic Hermes fixture, but its private tool bridge,
+ * WorkspaceRuntime, Runner transport, grants and Chromium are all real.
+ * example.com supplies a public page because production browsing rejects LAN URLs. */
+it.skipIf(process.env.YAOYAO_BROWSER_SMOKE!=='1')('persists live Bot browser cards through the real workspace tool bridge and human handoff',async()=>{
+  const home=realpathSync(mkdtempSync(join(tmpdir(),'yaoyao-browser-workspace-smoke-'))),store=new WorkspaceStore(home),auth=new FixtureAuth(home),uploads=new UploadStore(home)
+  saveHostTools(home,{managedBrowser:true,vm:false,scriptMachine:false,serverComputer:false,cloud:false})
+  const bindings:any[]=[],rpc:{method:string;params:any}[]=[],messages:WorkspaceMessage[]=[],requests:string[]=[],checks:boolean[]=[]
+  let socket:WebSocket|undefined,runtimeId='',sessionRunning=false
+  const hermes=createServer(async(req,res)=>{
+    let body='';for await(const part of req)body+=part
+    const payload=body?JSON.parse(body):{},path=new URL(req.url!,'http://fixture').pathname
+    if(path.endsWith('/bind'))bindings.push(payload)
+    const result=path==='/api/auth/ws-ticket'?{ticket:'fixture'}:path.endsWith('/capabilities')?{version:1,ready:true,in_process:true,native_tools:true}:path==='/api/profiles'?{profiles:[{name:'default'}]}:{ok:true,native_tools:true,terminal:{cwd:home}}
+    res.setHeader('Content-Type','application/json');res.end(JSON.stringify(result))
+  })
+  const hermesURL=await listen(hermes),ws=new WebSocketServer({server:hermes})
+  const emit=(type:string,payload:unknown)=>socket?.send(JSON.stringify({method:'event',params:{type,session_id:runtimeId,profile:'default',payload}}))
+  ws.on('connection',connected=>{
+    socket=connected;connected.send(JSON.stringify({method:'event',params:{type:'gateway.ready'}}))
+    connected.on('message',raw=>{
+      const request=JSON.parse(String(raw));rpc.push(request);let result:any={ok:true}
+      if(request.method==='session.create'||request.method==='session.resume'){
+        runtimeId=randomUUID();result={session_id:runtimeId,stored_session_id:randomUUID(),running:false,info:{profile_name:'default',cwd:home}}
+      }else if(request.method==='prompt.submit')sessionRunning=true
+      else if(request.method==='session.active_list')result={sessions:[{id:runtimeId,status:sessionRunning?'working':'idle'}]}
+      else if(request.method==='session.usage')result={context_used:30,context_max:32000}
+      else if(request.method==='session.interrupt'){sessionRunning=false;emit('message.complete',{text:'',status:'interrupted'})}
+      connected.send(JSON.stringify({id:request.id,result}))
+    })
+  })
+  const client=new UpstreamClient(new URL(hermesURL)),target:GatewayTarget={url:new URL(hermesURL),client,session:new UpstreamServiceSession(client,()=>undefined)}
+  const nodes=new WorkspaceNodes(store,loadServerConfig({HERMES_YAOYAO_HOME:home}),target)
+  nodes.sourceAllowed=()=>auth.canUseSource()
+  const hub=new RunnerHub(store,auth,target),service=new ManagedBrowsers(store,auth,nodes,hub),runtime=new WorkspaceRuntime(store,nodes,uploads,()=>auth.isUserActive(),()=>auth.version)
+  runtime.managedBrowsers=service
+  hub.browserAllowed=(runnerId,scope,grantId)=>service.allowed(runnerId,scope,grantId)
+  nodes.runnerTarget=(owner,nodeId,computer)=>hub.target(owner,nodeId,computer)
+  const stopObserving=store.observe((_owner,event)=>{if(event.type==='message.changed')messages.push(structuredClone(event.data as WorkspaceMessage))})
+  const registered=hub.enroll('owner',{name:'真实浏览器任务节点',allowedProfiles:['default']})
+  const app=new Koa()
+  app.use(async(ctx,next)=>{
+    requests.push(ctx.path)
+    try{await next()}catch(error){ctx.status=error instanceof HttpError?error.status:500;ctx.body={code:(error as any).code,error:error instanceof Error?error.message:'Failed'}}
+    if(ctx.path.endsWith('/browser-check'))checks.push((ctx.body as any)?.allowed===true)
+  })
+  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 runner=new RunnerAgent(config,home),controller=new AbortController(),running=runner.run(controller.signal);void running.catch(()=>{})
+  const ui=async(path:string,body?:unknown,status=200)=>{
+    const response=await fetch(serverURL+path,{method:body===undefined?'GET':'POST',headers:{'Content-Type':'application/json'},...(body===undefined?{}:{body:JSON.stringify(body)})}),value=await response.json() as any
+    expect(response.status,JSON.stringify(value)).toBe(status);return value
+  }
+  const bridge=async(path:string,body:unknown,token=bindings.at(-1)?.token)=>{
+    const binding=bindings.at(-1),response=await fetch(binding.bridge_url+path,{method:'POST',headers:{Authorization:`Bearer ${token}`,'Content-Type':'application/json'},body:JSON.stringify(body)})
+    return {status:response.status,body:await response.json() as any}
+  }
+  let rootRun:WorkspaceRun|undefined
+  try{
+    await vi.waitFor(()=>expect(hub.summary(hub.records()[0]!).features).toContain('managed-browser-v1'),{timeout:35000,interval:50})
+    expect(hub.summary(hub.records()[0]!).features).not.toContain('computer-worker-v1')
+    const agent=store.createAgent('owner',{name:'真实网页任务',profile:'default'}),base=`/api/app/agents/${agent.id}`
+    const conversation=store.list<WorkspaceConversation>('owner','conversation').find(value=>value.memberIds[0]===agent.id)!
+    auth.allowed=false
+    await ui(base+'/managed-browser/prepare',{},403)
+    auth.allowed=true
+    const prepared=await ui(base+'/managed-browser/prepare',{})
+    expect(prepared.installation.status).toBe('ready');expect(prepared.open).toBe(false)
+    rootRun=runtime.send('owner',conversation.id,{requestId:randomUUID(),content:'打开网页,等待我接管,然后继续原任务。'})
+    await vi.waitFor(()=>expect(rpc.some(call=>call.method==='prompt.submit')).toBe(true),{timeout:10000})
+    expect(bindings).toHaveLength(1)
+    expect((await bridge('/tools/list',{},'invalid-token')).status).toBe(401)
+    const catalog=await bridge('/tools/list',{})
+    expect(catalog.status).toBe(200)
+    expect(catalog.body.tools.map((tool:any)=>tool.name)).toContain('managed_browser_open')
+    expect(catalog.body.tools.map((tool:any)=>tool.name)).not.toContain('computer_shell')
+    const tool=async(name:string,args:unknown,callId=randomUUID())=>{
+      const entry=catalog.body.tools.find((tool:any)=>tool.name===name);expect(entry).toBeDefined()
+      const reply=await bridge('/tools/call',{toolId:entry.id,arguments:args,callId})
+      expect(reply.status).toBe(200);expect(reply.body.isError,JSON.stringify(reply.body)).not.toBe(true)
+      return reply.body.structuredContent??reply.body
+    }
+    const opened=await tool('managed_browser_open',{url:'https://example.com/'})
+    const tab=opened.tabs.find((tab:any)=>tab.active)
+    expect(tab.url).toBe('https://example.com/');expect(tab.title).toContain('Example Domain')
+    const live=store.list<WorkspaceMessage>('owner','message').find(message=>message.browserCard?.status==='active')!
+    expect(live.browserCard).toMatchObject({agentId:agent.id,agentName:agent.name,title:tab.title,url:tab.url,status:'active'})
+    expect(live.status).toBe('streaming');expect(live.content).toBe('');expect(live.visible).toBe(true)
+    expect(messages.filter(message=>message.browserCard).map(message=>message.browserCard!.status)).toEqual(expect.arrayContaining(['ready','active']))
+    const shot=await tool('managed_browser_action',{action:{kind:'screenshot'}})
+    expect(shot.content[0].mimeType).toBe('image/png')
+    const taken=await ui(base+'/computer/take?backend=managed-browser',{requestId:randomUUID()}),credentials={controlId:taken.controlId,token:taken.token}
+    expect(taken).toMatchObject({mode:'human',canResume:true})
+    const frame=await ui(base+'/computer/frame?backend=managed-browser')
+    await ui(base+'/computer/input?backend=managed-browser',{...credentials,requestId:randomUUID(),generation:frame.generation,frameId:frame.id,action:{kind:'key',key:'Tab'}})
+    let settled=false
+    const pending=tool('managed_browser_state',{}).finally(()=>{settled=true})
+    await new Promise(resolve=>setTimeout(resolve,30));expect(settled).toBe(false)
+    await ui(base+'/computer/giveback?backend=managed-browser',{...credentials,notes:'检查完成,请继续'})
+    const resumed=await pending
+    expect(resumed.humanNote).toBe('检查完成,请继续')
+    expect(resumed.result.tabs.find((page:any)=>page.active)).toMatchObject({id:tab.id,title:tab.title,url:tab.url})
+    expect(resumed.result.generation).toBeGreaterThan(taken.generation)
+    // Force real network traffic after giveback, exercising the proxy's current
+    // task grant instead of the revoked human grant that used to break reload.
+    await tool('managed_browser_action',{action:{kind:'reload'}})
+    expect((await tool('managed_browser_state',{})).tabs[0]).toMatchObject({id:tab.id,title:tab.title,url:tab.url})
+    sessionRunning=false;emit('message.complete',{text:'',status:'complete'})
+    await vi.waitFor(()=>expect(store.require<WorkspaceRun>('owner','run',rootRun!.id).status).toBe('complete'),{timeout:5000})
+    await vi.waitFor(()=>expect(store.require<WorkspaceMessage>('owner','message',live.id).browserCard?.status).toBe('closed'))
+    const saved=store.require<WorkspaceMessage>('owner','message',live.id)
+    expect(saved).toMatchObject({visible:true,status:'complete',content:'',browserCard:{status:'closed',title:tab.title,url:tab.url}})
+    const history=new WorkspaceStore(home)
+    try{expect(history.require<WorkspaceMessage>('owner','message',live.id).browserCard).toEqual(saved.browserCard)}finally{history.close()}
+    expect(await ui(base+'/managed-browser')).toMatchObject({open:false,available:true})
+    for(const endpoint of ['poll','admit','result','browser-check'])expect(requests.some(path=>path.endsWith('/'+endpoint))).toBe(true)
+    expect(checks.length).toBeGreaterThan(10)
+    expect(rpc.filter(call=>call.method==='prompt.submit')).toHaveLength(1)
+  }finally{
+    auth.allowed=true
+    if(rootRun)await runtime.stop('owner',rootRun.id).catch(()=>{})
+    runtime.close();stopObserving()
+    controller.abort();await running.catch(()=>{})
+    service.close();hub.close();nodes.close();client.close();uploads.close()
+    for(const connection of ws.clients)connection.terminate()
+    await new Promise<void>(done=>ws.close(()=>done()));await close(web);await close(hermes)
+    store.close();rmSync(home,{recursive:true,force:true})
+  }
+},60000)

+ 161 - 5
tests/server/managedBrowsers.test.ts

@@ -17,6 +17,7 @@ import {HttpError} from '../../src/server/errors'
 import type {LocalAuthStore} from '../../src/server/localAuth'
 import type {LocalAuthStore} from '../../src/server/localAuth'
 import type {RunnerHub} from '../../src/server/runnerHub'
 import type {RunnerHub} from '../../src/server/runnerHub'
 import type {BrowserScope,BrowserState} from '../../src/runner/browser/types'
 import type {BrowserScope,BrowserState} from '../../src/runner/browser/types'
+import type {BrowserInstallation,ManagedBrowserCard} from '../../src/shared/managedBrowser'
 import {browserOwnedFile,browserVmFile} from '../../src/server/browserFiles'
 import {browserOwnedFile,browserVmFile} from '../../src/server/browserFiles'
 import {UploadStore} from '../../src/server/uploads'
 import {UploadStore} from '../../src/server/uploads'
 import {FileTransferFiles} from '../../src/shared/fileTransferEndpoint.mjs'
 import {FileTransferFiles} from '../../src/shared/fileTransferEndpoint.mjs'
@@ -30,6 +31,7 @@ function fixture(enabled=true){
   const auth={require:(ctx:any)=>({id:ctx.get('x-fixture-owner')||'owner'}),pushAuthorizationVersion:(owner:string)=>versions.get(owner)??1,isUserActive:(owner:string)=>!inactiveOwners.has(owner)} as unknown as LocalAuthStore
   const auth={require:(ctx:any)=>({id:ctx.get('x-fixture-owner')||'owner'}),pushAuthorizationVersion:(owner:string)=>versions.get(owner)??1,isUserActive:(owner:string)=>!inactiveOwners.has(owner)} as unknown as LocalAuthStore
   const nodes=new WorkspaceNodes(store,loadServerConfig({HERMES_YAOYAO_HOME:home}),{} as any)
   const nodes=new WorkspaceNodes(store,loadServerConfig({HERMES_YAOYAO_HOME:home}),{} as any)
   nodes.sourceAllowed=(owner,node,profile)=>!blockedSources.has(`${owner}:${node}:${profile}`)
   nodes.sourceAllowed=(owner,node,profile)=>!blockedSources.has(`${owner}:${node}:${profile}`)
+  let installation:BrowserInstallation|undefined
   const states=new Map<string,BrowserState>(),calls:any[]=[],turns:BrowserTurn[]=[]
   const states=new Map<string,BrowserState>(),calls:any[]=[],turns:BrowserTurn[]=[]
   let runnerId=randomUUID(),downloadReply:(payload:any)=>unknown=()=>{throw new Error('Unexpected download')}
   let runnerId=randomUUID(),downloadReply:(payload:any)=>unknown=()=>{throw new Error('Unexpected download')}
   let beforeReply:((payload:any)=>Promise<void>)|undefined
   let beforeReply:((payload:any)=>Promise<void>)|undefined
@@ -46,7 +48,9 @@ function fixture(enabled=true){
     const state=stateFor(payload.scope)
     const state=stateFor(payload.scope)
     if(beforeReply)await beforeReply(payload)
     if(beforeReply)await beforeReply(payload)
     let result:unknown
     let result:unknown
-    if(payload.op==='open'){
+    if(payload.op==='prepare'){
+      if(installation?.status!=='failed'||payload.retry)installation={status:'installing',message:'正在下载浏览器',updatedAt:Date.now()}
+    }else if(payload.op==='open'){
       if(!state.open){state.open=true;state.generation++;state.tabs=[{id:'tab',title:'Example',url:'about:blank',active:true}]}
       if(!state.open){state.open=true;state.generation++;state.tabs=[{id:'tab',title:'Example',url:'about:blank',active:true}]}
     }else if(payload.op==='pause'||payload.op==='resume')state.generation++
     }else if(payload.op==='pause'||payload.op==='resume')state.generation++
     else if(payload.op==='close'){state.open=false;state.tabs=[];state.generation++}
     else if(payload.op==='close'){state.open=false;state.tabs=[];state.generation++}
@@ -54,10 +58,13 @@ function fixture(enabled=true){
     else if(payload.op==='execute'){
     else if(payload.op==='execute'){
       if(payload.operation.generation!==state.generation)throw new HttpError(409,'代次改变','browser_generation_expired')
       if(payload.operation.generation!==state.generation)throw new HttpError(409,'代次改变','browser_generation_expired')
       if(payload.operation.action.kind==='screenshot')result={frameId:randomUUID(),generation:state.generation,data:'c2NyZWVu',width:800,height:600,createdAt:Date.now()}
       if(payload.operation.action.kind==='screenshot')result={frameId:randomUUID(),generation:state.generation,data:'c2NyZWVu',width:800,height:600,createdAt:Date.now()}
-      else result={ok:true,action:payload.operation.action}
+      else {
+        if(payload.operation.action.kind==='navigate'&&state.tabs[0])state.tabs[0].url=payload.operation.action.url
+        result={ok:true,action:payload.operation.action}
+      }
     }
     }
     authorize()
     authorize()
-    return result??structuredClone(state)
+    return result??structuredClone({...state,...(installation?{available:installation.status==='ready',installation}:{})})
   })} as unknown as RunnerHub
   })} as unknown as RunnerHub
   service=new ManagedBrowsers(store,auth,nodes,hub)
   service=new ManagedBrowsers(store,auth,nodes,hub)
   const app=new Koa()
   const app=new Koa()
@@ -71,6 +78,7 @@ function fixture(enabled=true){
     turns.push(value);return {value,controller,publish,upload}
     turns.push(value);return {value,controller,publish,upload}
   }
   }
   return {home,store,nodes,service,agent,base,app,hub,calls,versions,blockedSources,inactiveOwners,turn,
   return {home,store,nodes,service,agent,base,app,hub,calls,versions,blockedSources,inactiveOwners,turn,
+    setInstallation:(status:BrowserInstallation['status'],message='浏览器环境',error?:string)=>{installation={status,message,error,updatedAt:Date.now()}},
     setRunner:(id:string)=>{runnerId=id},runner:()=>runnerId,
     setRunner:(id:string)=>{runnerId=id},runner:()=>runnerId,
     setDownload:(reply:(payload:any)=>unknown)=>{downloadReply=reply},
     setDownload:(reply:(payload:any)=>unknown)=>{downloadReply=reply},
     setBeforeReply:(value:typeof beforeReply)=>{beforeReply=value},
     setBeforeReply:(value:typeof beforeReply)=>{beforeReply=value},
@@ -304,7 +312,8 @@ it('allows a newly authorized profile after the prior browser task has closed',a
 it.each(['profile','runner'] as const)('recovers an active browser after its %s changes and fences the old task and human grant',async(source)=>{
 it.each(['profile','runner'] as const)('recovers an active browser after its %s changes and fences the old task and human grant',async(source)=>{
   const f=fixture()
   const f=fixture()
   try{
   try{
-    const first=f.turn();await first.value.call('managed_browser_open',{},'first-open')
+    const cards:ManagedBrowserCard[]=[]
+    const first=f.turn({onCard:card=>{cards.push(card)}});await first.value.call('managed_browser_open',{},'first-open')
     const oldRunner=f.runner(),oldTask=f.calls[0]
     const oldRunner=f.runner(),oldTask=f.calls[0]
     const take=await request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId:randomUUID()}).expect(200)
     const take=await request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId:randomUUID()}).expect(200)
     const credentials={controlId:take.body.controlId,token:take.body.token}
     const credentials={controlId:take.body.controlId,token:take.body.token}
@@ -317,8 +326,9 @@ it.each(['profile','runner'] as const)('recovers an active browser after its %s
 
 
     const start=f.calls.length,second=f.turn({},'owner',updated)
     const start=f.calls.length,second=f.turn({},'owner',updated)
     await expect(second.value.call('managed_browser_open',{},'new-open')).resolves.toMatchObject({open:true})
     await expect(second.value.call('managed_browser_open',{},'new-open')).resolves.toMatchObject({open:true})
+    expect(cards.at(-1)).toMatchObject({status:'closed',message:'浏览器执行来源已改变,本轮会话已结束'})
     const replacement=f.calls.slice(start)
     const replacement=f.calls.slice(start)
-    expect(replacement.map(call=>call.op)).toEqual(['close','open','status'])
+    expect(replacement.map(call=>call.op)).toEqual(['close','status','open','status'])
     expect(replacement.every(call=>call.runnerId===f.runner())).toBe(true)
     expect(replacement.every(call=>call.runnerId===f.runner())).toBe(true)
     expect(replacement.every(call=>call.grantId!==oldTask.grantId&&call.grantId!==credentials.controlId)).toBe(true)
     expect(replacement.every(call=>call.grantId!==oldTask.grantId&&call.grantId!==credentials.controlId)).toBe(true)
     await request(f.app.callback()).post(controlURL(f.base,'/renew')).send(credentials).expect(403)
     await request(f.app.callback()).post(controlURL(f.base,'/renew')).send(credentials).expect(403)
@@ -379,3 +389,149 @@ it('copies browser bytes through the checked VM endpoint with overwrite and root
     expect((await readFile(path)).toString()).toBe('updated')
     expect((await readFile(path)).toString()).toBe('updated')
   }finally{await endpoint.close();await f.close()}
   }finally{await endpoint.close();await f.close()}
 })
 })
+
+it('prepares a missing environment without granting browser execution, and releases its installation grant when ready',async()=>{
+  const f=fixture()
+  try{
+    f.setInstallation('missing')
+    expect((await request(f.app.callback()).get(f.base+'/managed-browser').expect(200)).body).toMatchObject({enabled:true,available:false,installation:{status:'missing'}})
+    expect(f.calls.some(call=>call.op==='prepare')).toBe(false)
+    await request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId:randomUUID()}).expect(409)
+    expect(f.calls.some(call=>call.op==='open'||call.op==='pause')).toBe(false)
+    const first=await request(f.app.callback()).post(f.base+'/managed-browser/prepare').send({}).expect(200)
+    expect(first.body).toMatchObject({available:false,installation:{status:'installing'}})
+    const setup=f.calls.find(call=>call.op==='prepare')
+    expect(f.service.allowed(f.runner(),setup.scope,setup.grantId)).toBe(true)
+    expect(f.service.allowed(f.runner(),setup.scope)).toBe(false)
+    await request(f.app.callback()).post(f.base+'/managed-browser/prepare').send({}).expect(200)
+    expect(f.calls.filter(call=>call.op==='prepare').every(call=>call.grantId===setup.grantId)).toBe(true)
+    f.setInstallation('ready')
+    expect((await request(f.app.callback()).get(f.base+'/managed-browser').expect(200)).body.available).toBe(true)
+    expect(f.service.allowed(f.runner(),setup.scope,setup.grantId)).toBe(false)
+    await request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId:randomUUID()}).expect(200)
+  }finally{await f.close()}
+})
+
+it('requires explicit installation retry and applies current owner/source permissions to preparation',async()=>{
+  const f=fixture()
+  try{
+    f.setInstallation('failed','下载失败','网络不可用')
+    expect((await request(f.app.callback()).post(f.base+'/managed-browser/prepare').send({}).expect(200)).body.installation.status).toBe('failed')
+    const failed=f.calls.find(call=>call.op==='prepare')
+    expect(f.service.allowed(f.runner(),failed.scope,failed.grantId)).toBe(false)
+    expect((await request(f.app.callback()).post(f.base+'/managed-browser/prepare').send({retry:true}).expect(200)).body.installation.status).toBe('installing')
+    const retry=f.calls.at(-1)
+    expect(retry.retry).toBe(true)
+    expect(f.service.allowed(f.runner(),retry.scope,retry.grantId)).toBe(true)
+    await request(f.app.callback()).post(f.base+'/managed-browser/prepare').set('x-fixture-owner','other').send({retry:true}).expect(404)
+    f.blockedSources.add('owner:local:default')
+    expect(f.service.allowed(f.runner(),retry.scope,retry.grantId)).toBe(false)
+    await request(f.app.callback()).post(f.base+'/managed-browser/prepare').send({retry:true}).expect(403)
+  }finally{await f.close()}
+})
+
+it('publishes preparation and same-session takeover cards, preserving safe page history after completion',async()=>{
+  const f=fixture(),cards:ManagedBrowserCard[]=[],preparing=defer()
+  try{
+    f.setInstallation('missing')
+    const task=f.turn({onCard:card=>{cards.push(structuredClone(card))}})
+    f.setBeforeReply(async payload=>{if(payload.op==='prepare')preparing.resolve()})
+    const opening=task.value.call('managed_browser_open',{url:'https://user:secret@example.com/page?token=private#password'},'open')
+    await preparing.promise
+    await vi.waitFor(()=>expect(cards.at(-1)).toMatchObject({status:'preparing',installation:{status:'installing'}}))
+    expect(f.calls.some(call=>call.op==='open')).toBe(false)
+    f.setInstallation('ready')
+    await opening
+    const active=cards.at(-1)!
+    expect(active).toMatchObject({agentId:f.agent.id,status:'active',title:'Example',url:'https://example.com/page'})
+    expect(JSON.stringify(cards)).not.toMatch(/secret|private|password/)
+    const taskGrant=f.calls.find(call=>call.op==='prepare')
+    const take=await request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId:randomUUID()}).expect(200)
+    expect(cards.at(-1)?.message).toContain('人工控制中')
+    await request(f.app.callback()).post(controlURL(f.base,'/giveback')).send({controlId:take.body.controlId,token:take.body.token}).expect(200)
+    const resume=f.calls.find(call=>call.op==='resume')
+    expect(resume.grantId).toBe(taskGrant.grantId)
+    expect(f.service.allowed(f.runner(),resume.scope,resume.grantId)).toBe(true)
+    await task.value.close()
+    expect(cards.at(-1)).toMatchObject({id:active.id,status:'closed',title:'Example',url:'https://example.com/page'})
+    const count=cards.length
+    await request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId:randomUUID()}).expect(200)
+    expect(cards).toHaveLength(count)
+  }finally{await f.close()}
+})
+
+it('reports installation failure on the card without opening a browser or silently reinstalling',async()=>{
+  const f=fixture(),cards:ManagedBrowserCard[]=[]
+  try{
+    f.setInstallation('failed','浏览器下载失败','无法连接下载服务')
+    const task=f.turn({onCard:card=>{cards.push(card)}})
+    await expect(task.value.call('managed_browser_open',{},'open')).rejects.toMatchObject({code:'browser_install_failed'})
+    expect(cards.at(-1)).toMatchObject({status:'failed',message:'无法连接下载服务',installation:{status:'failed'}})
+    expect(f.calls.find(call=>call.op==='prepare')).not.toHaveProperty('retry',true)
+    expect(f.calls.some(call=>call.op==='open')).toBe(false)
+  }finally{await f.close()}
+})
+
+it('revokes an installing task immediately on cancellation, without late opening',async()=>{
+  const f=fixture(),prepared=defer()
+  try{
+    f.setInstallation('missing')
+    const task=f.turn()
+    f.setBeforeReply(async payload=>{if(payload.op==='prepare')prepared.resolve()})
+    const opening=task.value.call('managed_browser_open',{},'open')
+    const rejected=expect(opening).rejects.toMatchObject({code:'browser_authorization_revoked'})
+    await prepared.promise
+    task.controller.abort()
+    await rejected
+    const setup=f.calls.find(call=>call.op==='prepare')
+    expect(f.service.allowed(f.runner(),setup.scope,setup.grantId)).toBe(false)
+    f.setInstallation('ready')
+    expect(f.calls.some(call=>call.op==='open')).toBe(false)
+  }finally{await f.close()}
+})
+
+it('retains the card through a task ending under human control and closes it only after giveback',async()=>{
+  const f=fixture(),cards:ManagedBrowserCard[]=[]
+  try{
+    const task=f.turn({onCard:card=>{cards.push(card)}})
+    await task.value.call('managed_browser_open',{url:'https://example.com/page'},'open')
+    const take=await request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId:randomUUID()}).expect(200)
+    await task.value.close()
+    expect(cards.at(-1)).toMatchObject({status:'active',message:'人工控制中,交还后结束本轮会话'})
+    await request(f.app.callback()).post(controlURL(f.base,'/giveback')).send({controlId:take.body.controlId,token:take.body.token}).expect(200)
+    expect(cards.at(-1)).toMatchObject({status:'closed',url:'https://example.com/page'})
+  }finally{await f.close()}
+})
+
+it('keeps browser handoff and authorization cleanup working when the card observer throws',async()=>{
+  const f=fixture(),observe=vi.fn(()=>{throw new Error('chat history unavailable')})
+  try{
+    const task=f.turn({onCard:observe})
+    await expect(task.value.call('managed_browser_open',{url:'https://example.com/page'},'open')).resolves.toBeDefined()
+    const taskGrant=f.calls.find(call=>call.op==='open')
+    const take=await request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId:randomUUID()}).expect(200)
+    await expect(task.value.close()).resolves.toBeUndefined()
+    expect(f.service.allowed(f.runner(),taskGrant.scope,taskGrant.grantId)).toBe(false)
+    await expect((f.service as any).sweep()).resolves.toBeUndefined()
+    await request(f.app.callback()).post(controlURL(f.base,'/giveback')).send({controlId:take.body.controlId,token:take.body.token}).expect(200)
+    expect(f.service.allowed(f.runner(),taskGrant.scope)).toBe(false)
+    expect(f.calls.at(-1).op).toBe('close')
+    expect(observe).toHaveBeenCalled()
+  }finally{await f.close()}
+})
+
+it('retries an unchanged card after its persistence observer reports a failed save',async()=>{
+  const f=fixture(),observe=vi.fn(()=>false)
+  try{
+    const task=f.turn({onCard:observe})
+    await task.value.call('managed_browser_state',{},'state')
+    const calls=observe.mock.calls.length
+    await task.value.call('managed_browser_state',{},'state-again')
+    expect(observe.mock.calls.length).toBeGreaterThan(calls)
+    observe.mockReturnValue(true)
+    await task.value.call('managed_browser_state',{},'state-saved')
+    const saved=observe.mock.calls.length
+    await task.value.call('managed_browser_state',{},'unchanged')
+    expect(observe).toHaveBeenCalledTimes(saved)
+  }finally{await f.close()}
+})

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

@@ -125,3 +125,27 @@ it('flushes the last paused-stream preview without creating an extra message rev
   expect(store.require<WorkspaceConversation>('owner', 'conversation', message.conversationId).preview).toContain('暂停前的最后文字')
   expect(store.require<WorkspaceConversation>('owner', 'conversation', message.conversationId).preview).toContain('暂停前的最后文字')
   expect(store.require<WorkspaceMessage>('owner', 'message', 'm').revision).toBe(revision)
   expect(store.require<WorkspaceMessage>('owner', 'message', 'm').revision).toBe(revision)
 })
 })
+
+it('persists card-only messages and replays installation updates as full frames without losing the card on text patches',()=>{
+  message.content='';message.visible=true
+  message.browserCard={id:'browser-turn',agentId:'bot',agentName:'浏览器 Bot',status:'preparing',installation:{status:'installing',message:'下载中',progress:20,updatedAt:1},updatedAt:1}
+  let cursor=store.cursor('owner')
+  store.saveMessage('owner',message)
+  expect(store.events('owner',cursor).find(e=>e.type==='message.changed')?.data).toMatchObject({browserCard:{installation:{progress:20}}})
+  expect(store.require<WorkspaceConversation>('owner','conversation',message.conversationId).preview).toContain('托管浏览器')
+  const folder=new WorkspaceMessageReconciler();folder.remember(structuredClone(message))
+  cursor=store.cursor('owner')
+  message.browserCard={...message.browserCard,status:'active',title:'测试网页',url:'https://example.com/',installation:{status:'ready',message:'已就绪',updatedAt:2},updatedAt:2}
+  store.saveMessage('owner',message)
+  const events=store.events('owner',cursor)
+  expect(events.some(e=>e.type==='message.patch')).toBe(false)
+  for(const e of events)folder.normalize(e)
+  cursor=store.cursor('owner');append('浏览器已打开')
+  const patched=folder.normalize(store.events('owner',cursor).find(e=>e.type==='message.patch')!)
+  expect(patched.data).toMatchObject({content:'浏览器已打开',browserCard:{status:'active',title:'测试网页'}})
+  message.content='';message.status='complete';message.browserCard={...message.browserCard,status:'closed'}
+  store.saveMessage('owner',message)
+  store.close();store=new WorkspaceStore(home,{messagePatches:true})
+  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)
+})

+ 140 - 0
tests/server/workspaceRuntimeBrowserCards.test.ts

@@ -0,0 +1,140 @@
+// @vitest-environment node
+import {afterEach,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 type {WorkspaceNodes} from '../../src/server/workspaceGateway'
+import {UploadStore} from '../../src/server/uploads'
+import {saveHostTools} from '../../src/server/hostToolSettings'
+import type {ManagedBrowsers,BrowserTurnOptions} from '../../src/server/managedBrowsers'
+import type {ManagedBrowserCard} from '../../src/shared/managedBrowser'
+import type {WorkspaceConversation,WorkspaceMessage,WorkspaceRun} from '../../src/shared/workspace'
+
+const owner='browser-card-user'
+afterEach(()=>vi.restoreAllMocks())
+
+async function fixture(group=false){
+  const home=mkdtempSync(join(tmpdir(),'yaoyao-runtime-browser-card-'))
+  const store=new WorkspaceStore(home),uploads=new UploadStore(home)
+  saveHostTools(home,{managedBrowser:true,vm:false,serverComputer:false,scriptMachine: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}>()
+  let toolError:unknown,options:BrowserTurnOptions|undefined,card:ManagedBrowserCard|undefined
+  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)
+        return {status:200,headers:new Headers(),body:Buffer.from(JSON.stringify(path.startsWith('/api/plugins/yaoyao-bot-bridge/')
+          ? {ok:true,version:1,ready:true,in_process:true,native_tools:true}
+          : path==='/api/config'?{terminal:{}}:{messages:[]}))}
+      },
+    },
+  }
+  const nodes={requireSource:()=>{},target:()=>target} as unknown as WorkspaceNodes
+  const runtime=new WorkspaceRuntime(store,nodes,uploads)
+  runtime.managedBrowsers={available:()=>true,openTurn:(_owner,agent,input)=>{
+    options=input
+    card={id:input.workId,agentId:agent.id,agentName:agent.name,status:'active',title:'浏览器页面',url:'https://example.com/page',updatedAt:Date.now()}
+    return {close:async()=>{},call:async()=>{input.authorize();input.onCard?.(card!);return {ok:true}}}
+  }} as ManagedBrowsers
+  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'){
+        respond({status:'streaming'})
+        void(async()=>{
+          try{
+            const binding=bindings.get(frame.params.session_id)!
+            const toolRequest=async(path:string,body:unknown)=>{
+              const response=await fetch(binding.bridge_url+path,{method:'POST',headers:{'Content-Type':'application/json',Authorization:`Bearer ${binding.token}`},body:JSON.stringify(body)})
+              expect(response.status).toBe(200);return response.json() as Promise<any>
+            }
+            const catalog=await toolRequest('/tools/list',{}),tool=catalog.tools.find((entry:any)=>entry.name==='managed_browser_open')
+            expect(tool).toBeDefined()
+            const result=await toolRequest('/tools/call',{toolId:tool.id,arguments:{url:'https://example.com/page'},callId:'open-browser'})
+            expect(result).toMatchObject({structuredContent:{ok:true}})
+          }catch(error){toolError=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 bot=store.createAgent(owner,{name:'浏览器 Bot',profile:'default'})
+  const conversation=group
+    ? store.createGroup(owner,{name:'浏览器群',memberIds:[bot.id,store.createAgent(owner,{name:'群成员',profile:'default'}).id],administratorId:bot.id})
+    : store.list<WorkspaceConversation>(owner,'conversation').find(value=>value.kind==='direct'&&value.memberIds[0]===bot.id)!
+  return {store,runtime,conversation,
+    async run(){
+      const run=runtime.send(owner,conversation.id,{requestId:randomUUID(),content:'打开网页'})
+      await vi.waitFor(()=>expect(['complete','failed']).toContain(store.require<WorkspaceRun>(owner,'run',run.id).status))
+      expect(toolError).toBeUndefined()
+      const settled=store.require<WorkspaceRun>(owner,'run',run.id)
+      expect(settled.status,settled.error).toBe('complete')
+      const message=store.list<WorkspaceMessage>(owner,'message').find(value=>value.browserCard?.id===card?.id)!
+      expect(message).toBeDefined()
+      return message
+    },
+    update(){options!.onCard?.({...card!,status:'closed',message:'本轮浏览器操作已结束',updatedAt:Date.now()})},
+    async close(){
+      runtime.close();await new Promise(resolve=>setTimeout(resolve,10))
+      for(const client of server.clients)client.terminate()
+      await new Promise<void>(resolve=>server.close(()=>resolve()))
+      uploads.close();store.close();rmSync(home,{recursive:true,force:true})
+    },
+  }
+}
+
+it('persists a card-only native browser tool reply and accepts later human-session updates',async()=>{
+  const f=await fixture()
+  try{
+    const message=await f.run()
+    expect(message).toMatchObject({content:'',status:'complete',visible:true,browserCard:{status:'active',title:'浏览器页面'}})
+    expect(message.attachments).toEqual([]);expect(message.tools).toEqual([])
+    f.update()
+    expect(f.store.require<WorkspaceMessage>(owner,'message',message.id)).toMatchObject({content:'',status:'complete',browserCard:{status:'closed'}})
+    f.store.saveMessage(owner,{...f.store.require<WorkspaceMessage>(owner,'message',message.id),content:'后来保存的消息内容'})
+    f.update()
+    expect(f.store.require<WorkspaceMessage>(owner,'message',message.id).content).toBe('后来保存的消息内容')
+  }finally{await f.close()}
+})
+
+it('does not recreate a deleted task or its messages from a later browser card update',async()=>{
+  const f=await fixture(true)
+  try{
+    const message=await f.run(),taskId=message.conversationTaskId!
+    expect(taskId).toBeTruthy()
+    f.store.deleteTask(owner,f.conversation.id,taskId)
+    const replacementTasks=f.store.tasks(owner,f.conversation.id)
+    const save=vi.spyOn(f.store,'saveMessage')
+    expect(()=>f.update()).not.toThrow()
+    expect(save).not.toHaveBeenCalled()
+    expect(f.store.get(owner,'message',message.id)).toBeUndefined()
+    expect(f.store.get(owner,'conversation-task',taskId)).toBeUndefined()
+    expect(f.store.tasks(owner,f.conversation.id)).toEqual(replacementTasks)
+  }finally{await f.close()}
+})
+
+it('isolates a card persistence failure from browser cleanup and allows a later update',async()=>{
+  const f=await fixture()
+  try{
+    const message=await f.run()
+    const save=vi.spyOn(f.store,'saveMessage').mockImplementationOnce(()=>{throw new Error('card storage unavailable')})
+    expect(()=>f.update()).not.toThrow()
+    expect(save).toHaveBeenCalledOnce()
+    expect(f.store.require<WorkspaceMessage>(owner,'message',message.id).browserCard?.status).toBe('active')
+    f.update()
+    expect(f.store.require<WorkspaceMessage>(owner,'message',message.id).browserCard?.status).toBe('closed')
+  }finally{await f.close()}
+})