Browse Source

按机器人保留浏览器会话并完善接管与空闲回收

Samien 1 tháng trước cách đây
mục cha
commit
47f025a0e7

+ 10 - 6
docs/managed-browser.md

@@ -15,12 +15,12 @@
      "browser": {
        "enabled": true,
        "maxSessions": 3,
-       "idleTimeoutMs": 300000
+       "idleTimeoutMs": 1800000
      }
    }
    ```
 
-   保留现有配置其他字段,再让 Runner 加载更新后的配置。Mac App 内置节点会在配套服务更新重启后使用原加密配置;仅更新另一台客户端,不能更新服务器上的旧进程。`computers` 与 `browser` 可分别配置,单独启用浏览器不要求 VM Worker。`maxSessions` 范围 1–8,默认 3;空闲回收范围 60 秒–2 小时,默认 5 分钟。
+   保留现有配置其他字段,再让 Runner 加载更新后的配置。Mac App 内置节点会在配套服务更新重启后使用原加密配置;仅更新另一台客户端,不能更新服务器上的旧进程。`computers` 与 `browser` 可分别配置,单独启用浏览器不要求 VM Worker。`maxSessions` 范围 1–8,默认 3;空闲回收范围 60 秒–2 小时,默认 30 分钟。显式配置的空闲时间继续生效。
 
 2. 管理员在 Bot 工具设置中打开“托管浏览器”。Bot 的来源与 Profile 必须在 Runner 和账号权限范围内,Hermes 本轮工具桥也必须可用。
 3. Bot 首次调用打开浏览器,或用户在聊天卡片/电脑面板点击“接管浏览器”时,节点检查配套 Chromium 和沙箱启动。缺少环境时自动安装并显示阶段与当前组件的下载进度,完成后继续打开同一浏览器。单纯查看状态不会下载安装。
@@ -42,12 +42,15 @@ node .runner-build/node_modules/playwright/cli.js install chromium
 
 ## 会话与接管
 
-- 聊天卡片由服务端实际浏览器操作生成,保存安装状态、页面标题和去掉认证信息、查询参数及片段的地址;隐藏工具详情或刷新聊天后仍可见。点击卡片在现有电脑面板直接接管,macOS 使用现有独立控制窗口。卡片不解析模型输出中的控制地址或 HTML。已结束的卡片只保留历史,不自动重新打开旧任务。
-- 普通 Bot 的 Chromium 资料按账号和 Bot 隔离,保存在 Runner 数据目录的 `managed-browser/profiles/`,关闭页面后保留登录资料。临时助手使用临时资料,关闭后删除。
+- 聊天卡片由服务端实际浏览器操作生成,保存安装状态、页面标题和去掉认证信息、查询参数及片段的地址;隐藏工具详情或刷新聊天后仍可见。点击卡片在现有电脑面板直接接管,macOS 使用现有独立控制窗口。卡片不解析模型输出中的控制地址或 HTML。普通 Bot 的页面进入空闲保留状态后,卡片仍可再次接管;接管会取得新的人工授权,不会重启已结束的模型任务。已主动关闭、回收或切换来源的会话仅保留历史信息。
+- 普通 Bot 的 Chromium 会话按账号和 Bot 隔离。任务正常结束后保留同一浏览器上下文、标签页和当前页面内容;登录资料保存在 Runner 数据目录的 `managed-browser/profiles/`,即使上下文关闭也继续保留。临时助手使用临时资料,任务结束仍关闭上下文并删除临时资料。
 - Bot 浏览器与本机浏览器、VM 内浏览器不共享 Cookie、页面或资料目录。一个 Bot 的资料同时只允许一个进程写入。
 - 人工接管与 Agent 使用同一个 Chromium 上下文。接管先等待当前操作收束并切换控制代次;Agent 的后续浏览器调用等待交还。交还后模型获得备注,并重新读取网页。
 - 人工控制凭据有效期 30 秒,由控制页面持续续期;失效后拒绝旧输入,不自动让 Agent 恢复操作。多页面查看不会使控制者的近期画面失效;输入请求按编号去重,动作完成后旧画面不可再次用于新输入。
-- 任务结束时关闭浏览器;人工仍持有控制权时保留到交还。任务完成前应导出所需下载,关闭会清理临时下载。持久资料继续保留。
+- 普通 Bot 任务正常结束时,若没有人工正在控制,就暂停页面联网并进入空闲保留状态。人工仍在控制时继续使用同一页面;交还时有任务则交回任务,没有任务则进入空闲保留状态。空闲保留使用独立的会话授权,不延长已结束任务或旧人工凭据的权限。
+- 进入空闲保留状态会更新控制代次、使旧页面引用和输入画面失效,并阻断新请求与已有网络连接。下一轮任务或新的人工接管通过授权后恢复联网。页面进程仍在,页面自身的 JavaScript 并不因此完全冻结。
+- 默认空闲 30 分钟后关闭上下文。单纯查看状态、截图轮询和页面观察不会延长保留时间;主动关闭会立即回收。保留的上下文仍受节点会话数量限制。下载文件只在上下文存续期间临时保留,需要的结果应及时导出到对话附件。
+- 取消仍在执行的浏览器命令时,为阻止迟到操作,Runner 可能立即关闭上下文;不承诺在途取消后还能保留页面。下次可重新打开浏览器,持久登录资料继续保留。
 - 断线、撤权、节点/Profile 改变会使旧授权失效。切换执行来源时,新来源先确认关闭遗留上下文再操作;不同节点之间不自动搬运登录资料。
 
 ## 工具与文件
@@ -64,6 +67,7 @@ node .runner-build/node_modules/playwright/cli.js install chromium
 
 - 未启用时不会给 Hermes 增加浏览器工具,也不改变原电脑后端选择;旧客户端保存工具配置会保留新增开关。
 - 原 `computer_*`、`desktop_*`、`cloud_computer_*` 工具继续走自己的执行路径。浏览器不可用时不会隐式切换环境重做。
+- 空闲会话保留需要 Web 和 Runner 支持对应能力。旧 Runner 不能接收未知的保留命令;升级前应按旧行为关闭上下文,不把失败伪装为已保留页面。Cookie 等持久资料不因此删除。新旧节点混用时,以实际节点能力为准。
 - 共享 VM 中取消一个 holder,只取消该 holder 的命令。Container 和 Compose 均按操作编号取消进程组,其他 holder 的任务继续运行。
 - 工具操作保存意图和有界回执;相同调用编号不会再次执行已提交操作。不确定结果要求先核对,不能盲目重放。
 - Container 命令取消无需更换 VM 镜像;Compose 桌面需同步发布更新后的 `deploy/computer/compose_bridge.py`(含 `cancel` 协议)。升级 Compose 时先结束运行任务,再按原部署流程更新桌面镜像;不要只更新 Runner 而保留旧 bridge。
@@ -94,7 +98,7 @@ npm run desktop:test
 YAOYAO_BROWSER_SMOKE=1 npx vitest run tests/runner/browserSmoke.test.ts tests/server/managedBrowserSmoke.test.ts
 ```
 
-这两项覆盖真实页面操作、上传下载、登录资料恢复,以及 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 的性能基准数字。
 
 首次安装和聊天卡片验收:
 

+ 2 - 0
docs/runner.md

@@ -51,6 +51,8 @@ macOS App 可直接通过菜单栏“执行节点 → 导入节点配置…”
 - Gateway RPC 使用固定允许列表;会话操作必须属于自己的通道。HTTP 仅允许必要的只读查询。文件导出要求真实路径位于 `artifactRoots`,留空拒绝导出。
 - 单个只读结果目前最多 8 MiB;大型产物的分块传输仍待实现。待传命令有数量和总字节限制。
 - 断线后的在途操作可能结果不确定。系统沿用原会话核对,不在另一个节点重放原任务。已完成会话切换节点后带入近期文字记录;历史附件路径需要重新核实。
+- 托管浏览器通过节点能力协商使用会话保留协议。普通 Bot 的持久上下文在正常任务结束或无任务的人工交还后暂停联网,下一轮使用或接管取得新授权后恢复;独立会话授权只保留资源,不继续授予已结束任务的操作权限。默认空闲回收为 30 分钟,状态和截图轮询不会续期。
+- 不支持保留协议的旧 Runner 继续按原方式关闭上下文,不发送其不认识的保留命令。临时会话、主动关闭、断线、权限撤销仍清理上下文;为取消在途命令也可能立即回收。持久资料与 VM 隔离策略保持独立。
 
 ## 验收
 

+ 5 - 3
src/client/components/messages/ManagedBrowserCard.vue

@@ -4,7 +4,7 @@ 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 status = computed(() => ({ preparing: '正在准备', ready: '可接管', active: '浏览器已打开', idle: '页面已保留', 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
@@ -18,8 +18,10 @@ const progress = computed(() => {
     <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>
+    <button v-if="!readOnly" type="button" @click="emit('open', card)">{{ card.status === 'failed' ? '重试并接管浏览器' : card.status === 'closed' ? '打开此 Bot 的浏览器' : '接管浏览器' }}<AppIcon name="external" :size="14" aria-hidden="true"/></button>
+    <small v-if="card.status === 'closed'">打开此 Bot 当前的浏览器;已关闭或回收的页面不会自动恢复。</small>
+    <small v-else-if="card.status === 'idle'">页面可跨任务继续使用;长时间空闲后可能回收。</small>
+    <small v-else>接管后暂停机器人的浏览器操作;交还后恢复仍在运行的任务。</small>
   </section>
 </template>
 <style scoped>

+ 25 - 4
src/client/components/workspace/ComputerPanel.vue

@@ -17,6 +17,7 @@ const controlsOpen=ref(false),actualSize=ref(false),browserState=ref<BotBrowserS
 const managedBrowser=ref<ManagedBrowserState>()
 const preparing=ref(false)
 const recheckingBrowser=ref(false)
+const closingBrowser=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))
@@ -25,6 +26,7 @@ let takeRequestId:string|undefined
 let timer:ReturnType<typeof setTimeout>|undefined,closed=false,polling=false,lastRenew=0,generation=0
 const labels:Record<ComputerControlStatus['mode'],string>={off:'电脑未运行',idle:'电脑空闲 · 仅查看',agent:'机器人正在操作',pausing:'正在等待当前操作结束',human:'人工控制中',resuming:'正在交还机器人',error:'电脑需要检查'}
 const mine=computed(()=>!!ticket.value&&ticket.value.controlId===state.value.controlId)
+const canCloseBrowser=computed(()=>backend.value==='managed-browser'&&mine.value&&state.value.mode==='human'&&!state.value.canResume)
 const canSend=computed(()=>connectionOK.value&&!!frame.value&&mine.value&&state.value.mode==='human'&&frame.value?.generation===state.value.generation)
 const controllable=computed(()=>canSend.value&&!busy.value)
 const computerSettings=ref({vm:true,cloud:true,desktop:true,managedBrowser:false})
@@ -107,7 +109,8 @@ async function refresh(){
 }
 async function run(action:()=>Promise<void>){
   if(busy.value)return;generation++;busy.value=true;error.value=''
-  try{await action()}catch(cause){if(!closed)error.value=cause instanceof Error?cause.message:'电脑操作失败'}finally{busy.value=false;if(!closed){await refresh();void flushInputs()}}
+  let failure=''
+  try{await action()}catch(cause){failure=cause instanceof Error?cause.message:'电脑操作失败'}finally{busy.value=false;if(!closed){await refresh();if(failure)error.value=failure;void flushInputs()}}
 }
 async function recheckBrowser(){if(busy.value)return;recheckingBrowser.value=true;try{await run(loadTargets)}finally{recheckingBrowser.value=false}}
 function take(){return run(async()=>{
@@ -180,6 +183,18 @@ 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)}
 }
 async function giveBack(){if(ticket.value){await post('/giveback',{...ticket.value,notes:notes.value});ticket.value=undefined;takeRequestId=undefined;notes.value=''}}
+async function closeBrowser(){
+  if(busy.value||!canCloseBrowser.value||!ticket.value)return
+  const id=selected.value,credentials=ticket.value
+  closingBrowser.value=true
+  try{await run(async()=>{
+    clearTimeout(clickTimer);pendingClick=undefined;inputs.length=0
+    const status=await apiRequest<ComputerControlStatus>(`/api/app/agents/${encodeURIComponent(id)}/managed-browser/close`,{method:'POST',body:credentials})
+    if(closed||selected.value!==id||backend.value!=='managed-browser'||ticket.value!==credentials)return
+    state.value=status;ticket.value=undefined;takeRequestId=undefined;notes.value='';frame.value=undefined;connectionOK.value=false;controlsOpen.value=false
+    emit('changed')
+  })}finally{closingBrowser.value=false}
+}
 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 cycle(){await refresh();if(!closed)timer=setTimeout(()=>void cycle(),1200)}
@@ -220,7 +235,7 @@ defineExpose({take,close,releaseControl:async()=>{if(preparing.value){preparatio
           <button type="button" :aria-pressed="actualSize" @click="actualSize=!actualSize">{{actualSize?'适应窗口':'原始大小'}}</button>
           <button type="button" @click="fullscreen">全屏</button>
         </template>
-        <button v-if="!embedded" class="return-control" type="button" :disabled="busy&&!preparing" @click="close">{{ ticket ? '交还并关闭' : '关闭' }}</button>
+        <button v-if="!embedded" class="return-control" type="button" :disabled="busy&&!preparing" @click="close">{{ backend==='managed-browser' ? (ticket ? '交还并收起' : '收起窗口') : (ticket ? '交还并关闭' : '关闭') }}</button>
       </header>
       <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">
@@ -257,14 +272,20 @@ defineExpose({take,close,releaseControl:async()=>{if(preparing.value){preparatio
           <button type="button" :disabled="!controllable||!text" @click="send({kind:'text',text});text=''">输入文字</button>
           </section>
           <label>交还说明<textarea v-model="notes" rows="2" maxlength="4000" placeholder="告诉机器人你完成了什么,以及接下来需要做什么" /></label>
-          <button type="button" :disabled="busy||state.mode!=='human'" @click="run(giveBack)">{{ state.canResume ? '交还机器人并继续' : '结束控制' }}</button>
-          <small>控制只属于当前页面。页面断开后控制权会到期,任务不会自行恢复。</small>
+          <button type="button" :disabled="busy||state.mode!=='human'" @click="run(giveBack)">{{ state.canResume ? '交还机器人并继续' : backend==='managed-browser' ? '结束接管' : '结束控制' }}</button>
+          <small v-if="backend==='managed-browser'">{{ managedBrowser?.supportsRetention===true ? '交还或收起只会释放操作权,页面会保留;长时间空闲后可能回收。' : managedBrowser?.supportsRetention===false ? '此节点需更新后才能跨任务保留页面;任务结束后浏览器会关闭。' : '交还或收起会结束当前接管。' }}</small>
+          <small v-else>控制只属于当前页面。页面断开后控制权会到期,任务不会自行恢复。</small>
         </template>
       </footer>
+      <section v-if="canCloseBrowser" class="browser-session-actions" :aria-busy="closingBrowser">
+        <button type="button" :disabled="busy" @click="closeBrowser">{{ closingBrowser?'正在关闭浏览器…':'关闭浏览器' }}</button>
+        <small>结束当前浏览器会话并关闭标签页。</small>
+      </section>
     </component>
   </Teleport>
 </template>
 <style scoped>
+.browser-session-actions{display:flex;align-items:center;flex-wrap:wrap;gap:12px;padding:12px 20px;border-top:1px solid var(--line)}.browser-session-actions button{min-height:44px;color:var(--danger)}.browser-session-actions small{font-size:12px;line-height:1.6;color:var(--text-secondary)}
 .browser-availability{display:grid;gap:10px;padding:12px 20px;background:var(--surface-soft);color:var(--text-secondary);border-bottom:1px solid var(--line)}.browser-availability p{margin:0;font-size:13px;line-height:1.6;overflow-wrap:anywhere}.browser-availability button{justify-self:start;min-height:44px}
 .browser-installation{display:grid;gap:10px;padding:16px 20px;border-bottom:1px solid var(--line);background:var(--surface-soft);color:var(--text-primary)}.browser-installation p{margin:0;font-size:13px;line-height:1.6;overflow-wrap:anywhere}.browser-installation progress{width:100%;height:6px;accent-color:var(--accent)}.browser-installation [role=alert]{color:var(--danger)}
 .computer-panel.embedded{position:static;width:100%;max-height:none;height:100%;margin:0;border:0;border-radius:0;overflow:auto}.computer-panel.embedded{display:flex;flex-direction:column}.embedded .computer-screen{flex:1;min-height:150px}.embedded .computer-screen img{max-height:calc(100dvh - 250px);object-fit:contain}.embedded footer{max-height:45vh;overflow:auto}.manual-input{display:grid;gap:10px}

+ 1 - 1
src/client/views/ConversationsView.vue

@@ -249,7 +249,7 @@ async function openComputer(agent:Agent,backend?:ComputerBackend,host?:string){
 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
+  if(!messages.value.some(message=>message.browserCard?.id===card.id&&message.browserCard.agentId===card.agentId))return
   const agent=agents.value.find(agent=>agent.id===card.agentId&&!agent.archived)
   if(!agent){error.value='此浏览器所属机器人已不可用,或你已没有访问权限';return}
   await openComputer(agent,'managed-browser')

+ 1 - 1
src/runner/agent.ts

@@ -66,7 +66,7 @@ export class RunnerAgent {
     const toolId=body&&typeof body==='object'&&'toolId' in body?body.toolId:undefined
     const response=await (isLocalAuthorizationTarget(url)&&this.fetchImpl===fetch?this.controlTransport.fetch.bind(this.controlTransport):this.fetchImpl)(url,{
       method:body===undefined?'GET':'POST',redirect:'error',signal:AbortSignal.any([this.controlAbort.signal,AbortSignal.timeout(path==='desktop'?75000:path==='tool'&&typeof toolId==='string'&&toolId.startsWith('managed_browser_')?600000:25000)]),
-      headers:{Authorization:`Bearer ${this.config.token}`,'x-runner-instance':this.instance,'x-runner-protocol':'1','x-runner-features':(this.computers?'workspace-memory-bind-v1,hermes-computer-v2,profile-computer-v1,host-computer-tools-v1,file-transfer-v1,idle-stop-policy-v1,computer-worker-v1,artifact-chunks-v1,computer-control-v1,shared-computer-v1,local-vm-v1'+(this.computers.provider.fixedCapacity?',compose-desktops-v1':',helper-retirement-v1,image-options-v1')+(this.computers.config.imageId!==UNCONFIGURED_COMPUTER_IMAGE?',image-ready-v1':''):'workspace-memory-bind-v1')+(this.browsers?',managed-browser-setup-v1':',managed-browser-disabled-v1')+(this.browsers?.available?',managed-browser-v1':''),...(this.serverEpoch?{'x-runner-epoch':this.serverEpoch}:{}),'Content-Type':'application/json',...(path==='poll'&&!this.connected?{'x-runner-reset':'1'}:{})},
+      headers:{Authorization:`Bearer ${this.config.token}`,'x-runner-instance':this.instance,'x-runner-protocol':'1','x-runner-features':(this.computers?'workspace-memory-bind-v1,hermes-computer-v2,profile-computer-v1,host-computer-tools-v1,file-transfer-v1,idle-stop-policy-v1,computer-worker-v1,artifact-chunks-v1,computer-control-v1,shared-computer-v1,local-vm-v1'+(this.computers.provider.fixedCapacity?',compose-desktops-v1':',helper-retirement-v1,image-options-v1')+(this.computers.config.imageId!==UNCONFIGURED_COMPUTER_IMAGE?',image-ready-v1':''):'workspace-memory-bind-v1')+(this.browsers?',managed-browser-setup-v1,managed-browser-retention-v1':',managed-browser-disabled-v1')+(this.browsers?.available?',managed-browser-v1':''),...(this.serverEpoch?{'x-runner-epoch':this.serverEpoch}:{}),'Content-Type':'application/json',...(path==='poll'&&!this.connected?{'x-runner-reset':'1'}:{})},
       ...(body===undefined?{}:{body:JSON.stringify(body)}),
     })
     if(!response.ok){

+ 17 - 11
src/runner/browser/publicProxy.ts

@@ -10,6 +10,7 @@ export class BrowserPublicProxy {
   private server:Server
   private sockets=new Set<Socket>()
   private stopped=false
+  private connectionEpoch=0
   private authorizationTimer:ReturnType<typeof setInterval>
   readonly username='yaoyao'
   readonly password=randomBytes(32).toString('hex')
@@ -19,31 +20,33 @@ export class BrowserPublicProxy {
     const authenticated=(header:unknown)=>{const actual=Buffer.from(typeof header==='string'?header:'');return actual.length===expected.length&&timingSafeEqual(actual,expected)}
     this.server=createServer(async(req,res)=>{
       if(!authenticated(req.headers['proxy-authorization'])){res.writeHead(407,{'Proxy-Authenticate':'Basic realm="yaoyao"'});res.end();return}
+      const epoch=this.connectionEpoch,assertCurrent=()=>{if(this.stopped||epoch!==this.connectionEpoch||req.socket.destroyed)throw new Error('浏览器网络连接已结束')}
       try{
-        await this.authorize();if(this.stopped)throw new Error('浏览器代理已停止')
+        await this.authorize();assertCurrent()
         const url=publicBrowserURL(req.url??'')
         if(url.protocol!=='http:')throw new Error('HTTPS 必须使用 CONNECT')
         const destination=await publicDestination(url.hostname,Number(url.port||80),this.resolve)
-        await this.authorize();if(this.stopped)throw new Error('浏览器代理已停止')
+        assertCurrent();await this.authorize();assertCurrent()
         const headers:OutgoingHttpHeaders={...req.headers,host:url.host};delete headers['proxy-authorization'];delete headers['proxy-connection']
         const upstream=request({hostname:destination.address,family:destination.family,port:destination.port,method:req.method,path:url.pathname+url.search,headers},response=>{
           res.writeHead(response.statusCode??502,response.headers);response.pipe(res)
         })
-        upstream.on('socket',socket=>this.track(socket));upstream.on('error',()=>{if(!res.headersSent)res.writeHead(502);res.end()})
+        upstream.on('socket',socket=>this.track(socket,epoch));upstream.on('error',()=>{if(!res.headersSent)res.writeHead(502);res.end()})
         req.on('aborted',()=>upstream.destroy());res.on('close',()=>upstream.destroy());req.pipe(upstream)
       }catch{res.writeHead(403);res.end('Public HTTP(S) destinations only')}
     })
     this.server.on('connect',async(req,client,head)=>{
       if(!authenticated(req.headers['proxy-authorization'])){client.end('HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="yaoyao"\r\n\r\n');return}
+      const epoch=this.connectionEpoch,assertCurrent=()=>{if(this.stopped||epoch!==this.connectionEpoch||client.destroyed)throw new Error('浏览器网络连接已结束')}
       try{
-        await this.authorize();if(this.stopped)throw new Error('浏览器代理已停止')
+        await this.authorize();assertCurrent()
         const url=publicBrowserURL('https://'+req.url)
         if(url.pathname!=='/'||url.search||url.hash)throw new Error('无效目标')
         const destination=await publicDestination(url.hostname,Number(url.port||443),this.resolve)
-        await this.authorize();if(this.stopped)throw new Error('浏览器代理已停止')
+        assertCurrent();await this.authorize();assertCurrent()
         const upstream=connect({host:destination.address,family:destination.family,port:destination.port})
-        this.track(upstream)
-        upstream.once('connect',()=>{client.write('HTTP/1.1 200 Connection Established\r\n\r\n');if(head.length)upstream.write(head);client.pipe(upstream);upstream.pipe(client)})
+        this.track(upstream,epoch)
+        upstream.once('connect',()=>{try{assertCurrent()}catch{upstream.destroy();return}client.write('HTTP/1.1 200 Connection Established\r\n\r\n');if(head.length)upstream.write(head);client.pipe(upstream);upstream.pipe(client)})
         upstream.on('error',()=>client.destroy());client.on('error',()=>upstream.destroy());client.on('close',()=>upstream.destroy())
       }catch{client.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')}
     })
@@ -56,11 +59,12 @@ export class BrowserPublicProxy {
     let checking=false
     this.authorizationTimer=setInterval(()=>{
       if(checking||this.stopped||!this.sockets.size)return
-      checking=true;void Promise.resolve().then(()=>this.authorize()).catch(()=>{for(const socket of this.sockets)socket.destroy()}).finally(()=>{checking=false})
+      const epoch=this.connectionEpoch
+      checking=true;void Promise.resolve().then(()=>this.authorize()).catch(()=>{if(epoch===this.connectionEpoch)this.disconnect()}).finally(()=>{checking=false})
     },revalidateIntervalMs);this.authorizationTimer.unref()
   }
-  private track(socket:Socket){
-    if(this.stopped||this.sockets.size>=128){socket.destroy();return}
+  private track(socket:Socket,epoch=this.connectionEpoch){
+    if(this.stopped||epoch!==this.connectionEpoch||this.sockets.size>=128){socket.destroy();return}
     this.sockets.add(socket);socket.setTimeout(120000,()=>socket.destroy());socket.once('close',()=>this.sockets.delete(socket))
   }
   static async start(authorize:()=>void|Promise<void>,resolve?:AddressLookup,revalidateIntervalMs=2000){
@@ -70,9 +74,11 @@ export class BrowserPublicProxy {
     const address=proxy.server.address();if(!address||typeof address==='string')throw new Error('浏览器代理启动失败')
     proxy.serverURL=`http://127.0.0.1:${address.port}`;return proxy
   }
+  /** End existing requests immediately while retaining the authenticated listener. */
+  disconnect(){this.connectionEpoch++;for(const socket of this.sockets)socket.destroy()}
   async close(){
     if(this.stopped)return;this.stopped=true;clearInterval(this.authorizationTimer)
-    for(const socket of this.sockets)socket.destroy()
+    this.disconnect()
     await new Promise<void>(resolve=>this.server.close(()=>resolve()))
   }
 }

+ 21 - 8
src/runner/browser/runtime.ts

@@ -31,14 +31,14 @@ interface Entry {
   operations:Map<string,{fingerprint:string;result:Promise<unknown>;readOnly:boolean}>
   snapshot?:{id:string;page:Page;refs:Map<string,ElementHandle<Element>>}
   downloads:Map<string,BrowserDownload>;pendingDownloads:Set<Promise<void>>;downloadErrors:string[]
-  networkContext?:BrowserCallContext;opening?:Promise<void>
+  networkContext?:BrowserCallContext;opening?:Promise<void>;parked?:boolean
 }
 export interface BrowserRuntimeOptions {
   root:string;enabled?:boolean;launcher?:BrowserLauncher;authorize?:(scope:BrowserScope)=>void|Promise<void>
   resolveUpload?:(scope:BrowserScope,fileId:string)=>Promise<BrowserUpload>
   maxSessions?:number;maxTabs?:number;maxDownloadBytes?:number;maxDownloads?:number;operationTimeoutMs?:number
   /** Injectable transport for tests or a separately isolated deployment. */
-  proxyFactory?:(authorize:()=>Promise<void>)=>Promise<Pick<BrowserPublicProxy,'serverURL'|'username'|'password'|'close'>>
+  proxyFactory?:(authorize:()=>Promise<void>)=>Promise<Pick<BrowserPublicProxy,'serverURL'|'username'|'password'|'close'|'disconnect'>>
 }
 
 /** Runner-owned contexts. All stateful actions for an authorized environment use
@@ -81,9 +81,9 @@ export class BrowserRuntime {
     await this.check(entry,generation,context)
     return this.queue(entry,async()=>{
       await this.check(entry,generation,context)
-      if(entry.context){if(entry.scope.profile!==scope.profile)fail('browser_profile_busy','请先关闭浏览器再切换资料模式');entry.networkContext=context;return this.state(entry)}
-      entry.scope={...scope};entry.networkContext=context
-      if([...this.entries.values()].filter(e=>e.context||e.opening).length>=(this.options.maxSessions??3))fail('browser_capacity','运行中的浏览器数量已达上限')
+      if(entry.context){if(entry.scope.profile!==scope.profile)fail('browser_profile_busy','请先关闭浏览器再切换资料模式');entry.parked=false;entry.networkContext=context;return this.state(entry)}
+      entry.scope={...scope};entry.parked=false;entry.networkContext=context
+      if([...this.entries.values()].filter(e=>e.context||e.opening).length>=(this.options.maxSessions??3))fail('browser_capacity','运行中的浏览器数量已达上限,请先关闭一个闲置 Bot 的浏览器')
       entry.opening=this.launch(entry,generation,context)
       try{await entry.opening;return await this.state(entry)}finally{entry.opening=undefined}
     })
@@ -129,7 +129,12 @@ export class BrowserRuntime {
       const profile=entry.scope.profile==='persistent'?join(profiles,this.key(entry)):await mkdtemp(join(temporary,this.key(entry)+'-'))
       entry.profileDirectory=profile;await this.directory(profile)
       const downloads=join(root,'downloads',this.key(entry));await this.directory(downloads);entry.downloadDirectory=downloads
-      const authorize=async()=>this.check(entry,undefined,entry.networkContext)
+      const authorize=async()=>{
+        const generation=entry.generation,networkContext=entry.networkContext
+        if(entry.parked)fail('browser_parked','浏览器已暂停网络活动,请重新接管或开始新任务')
+        await this.check(entry,generation,networkContext)
+        if(entry.parked)fail('browser_generation_expired','浏览器网络授权已改变')
+      }
       entry.proxy=await (this.options.proxyFactory??BrowserPublicProxy.start)(authorize) as BrowserPublicProxy
       const launcher=this.options.launcher??(await import('playwright')).chromium
       const browser=await launcher.launchPersistentContext(profile,{
@@ -182,6 +187,7 @@ export class BrowserRuntime {
       await this.check(entry,input.generation,context)
       if(!entry.context)fail('browser_closed','请先打开浏览器')
       if(entry.scope.profile!==scope.profile)fail('browser_profile_busy','浏览器资料模式不匹配')
+      if(!readOnly&&entry.parked)fail('browser_parked','请先重新取得浏览器操作权')
       if(!readOnly)entry.networkContext=context
       const abort=()=>{void this.revoke(scope).catch(()=>{})}
       context?.signal?.addEventListener('abort',abort,{once:true})
@@ -281,10 +287,17 @@ export class BrowserRuntime {
   /** Fence old requests immediately, then wait for their bounded actions to drain.
    * The same browser context is retained for manual takeover and login. */
   async pause(scope:BrowserScope,context?:BrowserCallContext){
-    const entry=this.entry(scope);await this.check(entry,undefined,context);entry.generation++;entry.operations.clear();this.invalidate(entry);entry.networkContext=context
+    const entry=this.entry(scope);await this.check(entry,undefined,context);entry.generation++;entry.operations.clear();this.invalidate(entry);entry.parked=false;entry.networkContext=context;entry.proxy?.disconnect()
     return this.queue(entry,async()=>{await this.check(entry,undefined,context);return this.state(entry)})
   }
   async resume(scope:BrowserScope,context?:BrowserCallContext){return this.pause(scope,context)}
+  /** Retain this Bot's page state without retaining a task's network authority. */
+  async park(scope:BrowserScope,context?:BrowserCallContext){
+    const entry=this.entry(scope);await this.check(entry,undefined,context)
+    entry.generation++;entry.operations.clear();this.invalidate(entry)
+    entry.parked=true;entry.networkContext=undefined;entry.proxy?.disconnect()
+    return this.queue(entry,async()=>{await this.check(entry,undefined,context);return this.state(entry)})
+  }
   /** Unlike pause, revoke closes the context immediately to interrupt old I/O. */
   async revoke(scope:BrowserScope){
     const entry=this.entry(scope);entry.generation++;entry.operations.clear();this.invalidate(entry)
@@ -295,7 +308,7 @@ export class BrowserRuntime {
   }
   async close(scope:BrowserScope,context?:BrowserCallContext){const entry=this.entry(scope);await this.check(entry,undefined,context);return this.revoke(scope)}
   private async destroy(entry:Entry){
-    const browser=entry.context;entry.context=undefined;entry.pages.clear();entry.active='';this.invalidate(entry)
+    const browser=entry.context;entry.context=undefined;entry.pages.clear();entry.active='';entry.parked=false;entry.networkContext=undefined;this.invalidate(entry)
     await entry.proxy?.close();entry.proxy=undefined
     if(browser)await browser.close().catch(()=>{})
     await Promise.allSettled([...entry.pendingDownloads])

+ 11 - 3
src/runner/managedBrowser.ts

@@ -33,11 +33,11 @@ export class RunnerBrowser {
       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??1800000
   }
   private readonly idleTimeoutMs:number
   async invoke(raw:unknown,expiresAt:number,profileAllowed:(profile:string)=>void){
-    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)
+    const p=z.object({scope:scopeSchema,profile:z.string(),grantId:z.string().uuid(),op:z.enum(['status','prepare','open','execute','pause','resume','park','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)
     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);controller.signal.throwIfAborted();if(Date.now()>=expiresAt)throw new HttpError(410,'浏览器命令已过期','browser_command_expired')}
@@ -52,7 +52,11 @@ export class RunnerBrowser {
       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()})
+      const action=(p.operation as BrowserOperation|undefined)?.action?.kind
+      const observing=p.op==='status'||p.op==='execute'&&['state','downloads','screenshot','snapshot'].includes(action??'')
+      // Reading an idle preview must not keep a Chromium process alive forever.
+      // Track observations for revocation, but refresh activity only for actual use.
+      if(this.available&&!['prepare','close'].includes(p.op)&&(!observing||!this.scopes.has(key(p.scope))))this.scopes.set(key(p.scope),{scope:p.scope,at:Date.now()})
       if(p.upload){
         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')
@@ -71,6 +75,7 @@ export class RunnerBrowser {
       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==='resume')result=await this.runtime.resume(p.scope,context)
+      else if(p.op==='park')result=await this.runtime.park(p.scope,context)
       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{
         if(!p.downloadId)throw new HttpError(400,'缺少下载编号','browser_download_invalid')
@@ -156,6 +161,9 @@ export class RunnerBrowser {
   async sweep(){
     for(const [id,item] of this.scopes){
       let allowed=true;try{await this.check(item.scope)}catch{allowed=false}
+      // An input or a new controller may have refreshed this scope while the
+      // asynchronous authorization check was in flight. Never revoke its lease.
+      if(this.scopes.get(id)!==item)continue
       if(!allowed||Date.now()-item.at>this.idleTimeoutMs){this.scopes.delete(id);this.clearDownloads(item.scope);await this.runtime.revoke(item.scope)}
     }
   }

+ 127 - 36
src/server/managedBrowsers.ts

@@ -16,11 +16,11 @@ import {ToolOperationJournal} from './toolOperationJournal.js'
 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()
 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}
+interface Grant {id:string;role:'task'|'human'|'view'|'prepare'|'session';resource:Resource;expiresAt:number;authVersion:number;authorize():void;signal?:AbortSignal;parking?:boolean;requestId?:string;sealedToken?:string;tokenHash?:string}
+interface Resource {owner:string;agent:WorkspaceAgent;runnerId:string;runnerEpoch?:string;authVersion:number;scope:BrowserScope;state:RuntimeState;task?:Grant;human?:Grant;session?:Grant;parked?:boolean;nextStatusAt?:number;refreshing?:Promise<void>;preparation?:Grant;notify?:()=>void;paused:boolean;forceClose?:boolean;retired?:boolean;resetRequired?:boolean;resetting?:Promise<void>;transition?:Promise<unknown>;taking?:Promise<void>;closing?:Promise<unknown>;frame?:ComputerFrame;frames?:Map<string,Omit<ComputerFrame,'data'>>;notes?:string}
 export interface BrowserTurn {
   call(name:string,args:unknown,callId?:string):Promise<unknown>
-  close():Promise<void>
+  close(options?:{retain?:boolean}):Promise<void>
 }
 export interface BrowserTurnOptions {
   workId:string;signal:AbortSignal;authorize():void
@@ -55,17 +55,16 @@ export class ManagedBrowsers {
   private resource(owner:string,id:string){
     const agent=this.agent(owner,id),runner=this.hub.browserRunner(owner,agent),key=owner+':'+id
     let resource=this.resources.get(key),resetRequired=false
-    if(resource&&(resource.runnerId!==runner.id||resource.agent.profile!==agent.profile||resource.agent.nodeId!==agent.nodeId)){
+    if(resource&&(resource.retired||resource.authVersion!==(this.auth.pushAuthorizationVersion(owner)??0)||resource.runnerId!==runner.id||resource.runnerEpoch!==runner.browserEpoch||resource.agent.profile!==agent.profile||resource.agent.nodeId!==agent.nodeId)){
       // Source changes revoke every old holder. The old Runner observes the
       // 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)
-      resource.retired=true;resource.task=undefined;resource.human=undefined;resource.paused=false;resource.notify?.()
+      this.retire(resource)
       resetRequired=true
       this.resources.delete(key);resource=undefined
     }
     if(!resource){
       const scope:BrowserScope={ownerKey:digest(owner),environmentId:agent.id,profile:agent.temporaryGoalId?'temporary':'persistent'}
-      resource={owner,agent,runnerId:runner.id,scope,state:{open:false,generation:1,profile:scope.profile,tabs:[],downloads:[]},paused:false,resetRequired}
+      resource={owner,agent,runnerId:runner.id,runnerEpoch:runner.browserEpoch,authVersion:this.auth.pushAuthorizationVersion(owner)??0,scope,state:{open:false,generation:1,profile:scope.profile,tabs:[],downloads:[]},paused:false,resetRequired}
       this.resources.set(key,resource)
     }
     return resource
@@ -77,20 +76,66 @@ export class ManagedBrowsers {
   private check(grant:Grant){
     const r=grant.resource
     if(this.resources.get(r.owner+':'+r.agent.id)!==r||this.grants.get(grant.id)!==grant||grant.expiresAt<=Date.now()||grant.signal?.aborted||!this.auth.isUserActive(r.owner)||(this.auth.pushAuthorizationVersion(r.owner)??0)!==grant.authVersion)throw new HttpError(410,'浏览器操作授权已结束','browser_authorization_revoked')
-    const agent=this.agent(r.owner,r.agent.id)
-    if(agent.nodeId!==r.agent.nodeId||agent.profile!==r.agent.profile||this.hub.browserRunner(r.owner,agent).id!==r.runnerId)throw new HttpError(410,'浏览器执行来源已改变','browser_source_changed')
+    this.checkResource(r)
     grant.authorize()
   }
+  private checkResource(r:Resource){
+    if(r.retired||this.resources.get(r.owner+':'+r.agent.id)!==r||!this.auth.isUserActive(r.owner)||(this.auth.pushAuthorizationVersion(r.owner)??0)!==r.authVersion)throw new HttpError(410,'浏览器操作授权已结束','browser_authorization_revoked')
+    const agent=this.agent(r.owner,r.agent.id),runner=this.hub.browserRunner(r.owner,agent)
+    if(agent.nodeId!==r.agent.nodeId||agent.profile!==r.agent.profile||runner.id!==r.runnerId||runner.browserEpoch!==r.runnerEpoch)throw new HttpError(410,'浏览器执行来源已改变','browser_source_changed')
+  }
+  private retire(r:Resource){
+    for(const [id,g] of this.grants)if(g.resource===r)this.grants.delete(id)
+    r.retired=true;r.task=undefined;r.human=undefined;r.session=undefined;r.preparation=undefined;r.paused=false;r.notify?.()
+  }
+  private dropSession(r:Resource){if(r.session)this.grants.delete(r.session.id);r.session=undefined;r.parked=false}
+  private keepSession(r:Resource){
+    if(!r.forceClose&&r.scope.profile==='persistent'&&this.hub.supportsBrowserRetention(r.owner,r.agent))r.session??=this.grant(r,'session',()=>{})
+  }
+  private transition<T>(r:Resource,action:()=>Promise<T>):Promise<T>{
+    const pending=(r.transition??Promise.resolve()).catch(()=>{}).then(action)
+    r.transition=pending
+    void pending.finally(()=>{if(r.transition===pending)r.transition=undefined}).catch(()=>{})
+    return pending
+  }
+  private async rest(r:Resource,retain:boolean){
+    this.checkResource(r)
+    r.frame=undefined;r.frames?.clear();r.paused=false
+    if(retain&&!r.forceClose&&r.scope.profile==='persistent'&&r.state.open&&this.hub.supportsBrowserRetention(r.owner,r.agent)){
+      const session=r.session??=this.grant(r,'session',()=>{})
+      try{
+        const state=await this.request(session,'park')
+        if(!state||typeof state.open!=='boolean'||!Number.isInteger(state.generation))throw new HttpError(502,'浏览器未确认页面保留','browser_park_unconfirmed')
+        if(r.state.open){r.parked=true;r.nextStatusAt=Date.now()+30000}else this.dropSession(r)
+        return
+      }
+      catch{this.dropSession(r)}
+    }
+    this.dropSession(r)
+    try{await this.view(r,g=>this.request(g,'close'));r.forceClose=false}
+    catch(error){r.resetRequired=true;throw error}
+  }
+  private async activate(g:Grant){
+    while(g.resource.parked){
+      await this.waitForControl(g)
+      await this.transition(g.resource,async()=>{
+        const r=g.resource;this.check(g)
+        if(r.paused||!r.parked)return
+        await this.request(g,'resume');r.parked=false;r.frame=undefined;r.frames?.clear()
+      })
+    }
+  }
   allowed(runnerId:string,scope:unknown,grantId?:string){
     if(!scope||typeof scope!=='object')return false
     const s=scope as BrowserScope
     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}}
-    if(grantId){const g=this.grants.get(grantId);return !!g&&matches(g)&&valid(g)}
+    if(grantId){const g=this.grants.get(grantId);return !!g&&(g.role!=='session'||g.parking===true)&&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>={}){
     this.check(grant)
+    if(grant.role==='session'&&op!=='park')throw new HttpError(403,'保留会话不授权浏览器操作','browser_tool_forbidden')
     if(grant.role==='task'&&!['status','close','prepare','resume'].includes(op))await this.waitForControl(grant)
     const r=grant.resource
     if(r.resetRequired){
@@ -101,9 +146,12 @@ export class ManagedBrowsers {
       await r.resetting
       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;r.notify?.()}
-    return result
+    if(grant.role==='session')grant.parking=true
+    try{
+      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)&&result.generation>=r.state.generation){r.state=result;if(!r.state.open)this.dropSession(r);else if(op==='open'&&(grant.role==='task'||grant.role==='human'))this.keepSession(r);r.notify?.()}
+      return result
+    }finally{grant.parking=false}
   }
   private async waitForControl(g:Grant){
     while(g.resource.paused){
@@ -129,7 +177,7 @@ export class ManagedBrowsers {
     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:[]}}
   }
-  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 publicState(r:Resource):ManagedBrowserState{return {enabled:true,...r.state,supportsRetention:this.hub.supportsBrowserRetention(r.owner,r.agent),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}
   }
@@ -183,49 +231,67 @@ export class ManagedBrowsers {
     return {name,mimeType,buffer}
   }
   openTurn(owner:string,agent:WorkspaceAgent,options:BrowserTurnOptions):BrowserTurn{
-    let grant:Grant|undefined,finished=false,failure:string|undefined,lastCard='',lastTitle:string|undefined,lastUrl:string|undefined,listener:(()=>void)|undefined
+    let grant:Grant|undefined,finished=false,activated=false,closing:Promise<void>|undefined,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'
+      if((r.retired||finished&&!r.human&&!r.session)&&r.notify===listener)r.notify=undefined
+      const status:ManagedBrowserCard['status']=r.retired?'closed':failure?'failed':finished&&!r.human?(r.session&&r.state.open?'idle':'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}
+        message:r.retired?'浏览器执行来源已改变,本轮会话已结束':failure??(status==='idle'?'本轮操作已结束,页面已保留':status==='closed'?'本轮浏览器操作已结束':r.human?(finished?(r.session&&!r.forceClose?'人工控制中,交还后保留页面':'人工控制中,交还后结束本轮会话'):'人工控制中,可交还机器人继续'):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=async()=>{
       options.authorize();options.signal.throwIfAborted()
       if(finished)throw new HttpError(410,'浏览器任务已结束','browser_authorization_revoked')
       if(!grant){
         const r=this.resource(owner,agent.id)
-        if(r.closing||r.task)throw new HttpError(409,'当前 Bot 浏览器正由另一个任务使用','browser_busy')
+        while(r.transition){
+          await r.transition.catch(()=>{});options.authorize();options.signal.throwIfAborted()
+          if(finished)throw new HttpError(410,'浏览器任务已结束','browser_authorization_revoked')
+        }
+        this.checkResource(r)
+        if(r.task)throw new HttpError(409,'当前 Bot 浏览器正由另一个任务使用','browser_busy')
         r.notify?.()
+        if(!r.human)r.forceClose=false
         grant=this.grant(r,'task',options.authorize,options.signal);r.task=grant;listener=()=>notify(r);r.notify=listener
       }
       this.check(grant);return grant
     }
-    const close=async()=>{
-      if(finished)return;finished=true;options.signal.removeEventListener('abort',abort)
-      if(!grant)return
+    const close=(value:{retain?:boolean}={}):Promise<void>=>{
+      if(finished)return closing??Promise.resolve();finished=true;options.signal.removeEventListener('abort',abort)
+      if(!grant)return Promise.resolve()
       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}}
-      notify(r)
+      const retain=value.retain!==false&&!options.signal.aborted
+      if(!retain){r.forceClose=true;this.dropSession(r)}
+      closing=this.transition(r,async()=>{
+        try{
+          this.checkResource(r)
+          if(r.human){try{this.check(r.human);return}catch{this.grants.delete(r.human.id);r.human=undefined}}
+          // A status-only turn must not refresh the retained session's idle deadline.
+          if(retain&&!activated&&r.session&&r.parked)return
+          await this.rest(r,retain)
+        }catch{ /* Revoked resources are fenced and reclaimed by the Runner. */ }
+        finally{notify(r)}
+      }).finally(()=>{if(r.closing===closing)r.closing=undefined})
+      r.closing=closing;return closing
     }
-    const abort=()=>{void close()};options.signal.addEventListener('abort',abort,{once:true})
+    const abort=()=>{void close({retain:false})};options.signal.addEventListener('abort',abort,{once:true})
     return {close,call:async(name,raw,callId)=>{
-      const g=get(),r=g.resource
+      const g=await get(),r=g.resource
       notify(r)
       try{
       // Hold the current tool call while the user operates this exact session.
       await this.waitForControl(g)
+      if(name!=='managed_browser_state'){await this.activate(g);activated=true}
       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()=>{
         if(name==='managed_browser_state')return this.request(g,'status')
@@ -273,7 +339,7 @@ export class ManagedBrowsers {
     router.post('/api/app/agents/:id/computer/take',async(ctx,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)
-      if(r.closing)await r.closing
+      ctx.body=await this.transition(r,async()=>{
       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()){
@@ -281,20 +347,22 @@ export class ManagedBrowsers {
         if(r.human.requestId!==body.requestId)throw new HttpError(409,'浏览器已由另一页面接管','browser_control_busy')
         if(r.taking)await r.taking
         if(!r.human)throw new HttpError(409,'接管未完成,请重新接管','browser_control_expired')
-        ctx.body={...this.controlStatus(r),controlId:r.human.id,token:this.nodes.open(r.human.sealedToken!),expiresAt:r.human.expiresAt};return
+        return {...this.controlStatus(r),controlId:r.human.id,token:this.nodes.open(r.human.sealedToken!),expiresAt:r.human.expiresAt}
       }
+      if(r.human)this.grants.delete(r.human.id)
       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
       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.parked=false;r.taking=undefined;return {...this.controlStatus(r),controlId:g.id,token,expiresAt:g.expiresAt}}
       catch(error){this.grants.delete(g.id);if(r.human===g)r.human=undefined;r.notify?.();throw error}
       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}})
     const input=async(owner:string,id:string,raw:unknown,navigation:boolean)=>{
       const body=parse(credentials.extend({requestId:z.string().uuid(),generation:z.number().int().positive(),frameId:z.string().uuid().optional(),action:z.record(z.string(),z.unknown())}).strict(),raw),g=this.token(owner,id,body),r=g.resource
       return this.journal.execute(owner,g.id,body.requestId,'managed_browser_human_input',{generation:body.generation,frameId:body.frameId,action:body.action,navigation},()=>{this.token(owner,id,body)},async()=>{
-      if(r.taking)throw new HttpError(409,'请等待当前操作结束后接管','browser_control_pending')
+      if(r.taking||r.transition)throw new HttpError(409,'请等待当前操作结束后接管','browser_control_pending')
       if(body.generation!==r.state.generation)throw new HttpError(409,'控制代次已改变,请刷新','browser_generation_expired')
       let action=body.action
       if(!navigation){
@@ -319,19 +387,42 @@ export class ManagedBrowsers {
     router.post('/api/app/agents/:id/computer/giveback',async(ctx,next)=>{
       if(!matches(ctx))return next()
       const owner=this.auth.require(ctx).id,body=parse(credentials.extend({notes:z.string().max(4000).default('')}).strict(),(ctx.request as any).body),g=this.token(owner,ctx.params.id,body),r=g.resource
+      ctx.body=await this.transition(r,async()=>{
+      this.token(owner,ctx.params.id,body)
       // 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)
-      if(!r.task){r.closing=this.view(r,view=>this.request(view,'close'));try{await r.closing}finally{r.closing=undefined}}
+      if(r.task)await this.request(r.task,'resume')
+      else await this.rest(r,true)
+      r.notes=body.notes;r.human=undefined;r.paused=false;r.frame=undefined;r.frames?.clear();this.grants.delete(g.id)
       r.notify?.()
-      ctx.body=this.controlStatus(r)
+      return this.controlStatus(r)
+      })
+    })
+    router.post('/api/app/agents/:id/managed-browser/close',async ctx=>{
+      const owner=this.auth.require(ctx).id,body=parse(credentials,(ctx.request as any).body),g=this.token(owner,ctx.params.id,body),r=g.resource
+      ctx.body=await this.transition(r,async()=>{
+        this.token(owner,ctx.params.id,body)
+        if(r.task)throw new HttpError(409,'机器人任务仍在使用浏览器,请先停止任务','browser_busy')
+        await this.rest(r,false)
+        this.grants.delete(g.id);r.human=undefined;r.paused=false;r.notes=undefined;r.notify?.()
+        return this.controlStatus(r)
+      })
     })
     return router
   }
   private async sweep(){
     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.task){try{this.check(r.task)}catch{this.grants.delete(r.task.id);r.task=undefined}}
+      try{this.checkResource(r)}catch{this.retire(r);continue}
+      if(r.task){try{this.check(r.task)}catch{this.grants.delete(r.task.id);r.task=undefined;r.forceClose=true;this.dropSession(r)}}
+      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=!!r.task;r.frame=undefined;r.frames?.clear()
+        if(!r.task)await this.transition(r,async()=>{if(!r.task&&!r.human)await this.rest(r,true)}).catch(()=>{})
+      }}
       if(r.preparation){try{this.check(r.preparation)}catch{this.grants.delete(r.preparation.id);r.preparation=undefined}}
+      if(r.session&&!r.task&&!r.human&&!r.transition&&!r.refreshing&&Date.now()>=(r.nextStatusAt??0)){
+        r.nextStatusAt=Date.now()+30000
+        r.refreshing=this.transition(r,async()=>{if(!r.task&&!r.human&&r.session)await this.view(r,g=>this.request(g,'status'))})
+          .then(()=>{},()=>this.retire(r)).finally(()=>{r.refreshing=undefined})
+      }
       r.notify?.()
     }
   }

+ 6 - 2
src/server/runnerHub.ts

@@ -230,7 +230,7 @@ export class RunnerHub {
     if(online.features.includes('managed-browser-disabled-v1'))throw new HttpError(409,'节点管理员已关闭托管浏览器,请在该节点重新启用','browser_node_disabled')
     if(!online.features.some(feature=>['managed-browser-v1','managed-browser-setup-v1'].includes(feature)))throw new HttpError(409,'执行节点版本尚不支持自动准备浏览器,请更新节点上的夭夭并重新启动','browser_runner_upgrade_required')
     if(!record.allowedProfiles.includes(agent.profile)||!this.auth.canUseSource(owner,agent.nodeId,agent.profile))throw new HttpError(403,'浏览器 Profile 未授权','browser_profile_forbidden')
-    return record
+    return {...record,browserEpoch:online.epoch}
   }
   async browser(owner:string,agent:import('../shared/workspace.js').WorkspaceAgent,payload:Record<string,unknown>,authorize:()=>void):Promise<any>{
     const record=this.browserRunner(owner,agent),version=this.auth.pushAuthorizationVersion(owner)
@@ -240,6 +240,10 @@ export class RunnerHub {
     if(!valid())throw new HttpError(403,'浏览器授权已失效','browser_authorization_revoked')
     return result
   }
+  supportsBrowserRetention(owner:string,agent:import('../shared/workspace.js').WorkspaceAgent){
+    const runner=this.browserRunner(owner,agent)
+    return this.online.get(runner.id)?.features.includes('managed-browser-retention-v1')===true
+  }
   async retireHelper(owner:string,helper:import('../shared/workspace.js').WorkspaceAgent):Promise<void>{
     if(!helper.temporaryGoalId||!helper.archived||!helper.helperRunnerId)throw new HttpError(403,'只能清理已退役的任务助手','helper_cleanup_forbidden')
     const current=this.store.require<import('../shared/workspace.js').WorkspaceAgent>(owner,'agent',helper.id)
@@ -276,7 +280,7 @@ export class RunnerHub {
         retired.add(previous.instance);this.retired.set(record.id,retired);this.disconnect(record.id);previous=undefined
       }
       if(match[2]!=='poll'&&ctx.get('x-runner-epoch')!==previous?.epoch)throw new HttpError(409,'执行连接代次已改变','runner_epoch_changed')
-      const state=this.online.get(record.id)??{instance,seen:Date.now(),features:[],epoch:`${this.epoch}:${randomUUID()}`};state.seen=Date.now();if(ctx.get('x-runner-features'))state.features=ctx.get('x-runner-features').split(',').filter(value=>['workspace-memory-bind-v1','hermes-computer-v2','profile-computer-v1','host-computer-tools-v1','file-transfer-v1','idle-stop-policy-v1','computer-worker-v1','artifact-chunks-v1','helper-retirement-v1','computer-control-v1','shared-computer-v1','local-vm-v1','image-options-v1','image-ready-v1','compose-desktops-v1','managed-browser-v1','managed-browser-setup-v1','managed-browser-disabled-v1'].includes(value));this.online.set(record.id,state)
+      const state=this.online.get(record.id)??{instance,seen:Date.now(),features:[],epoch:`${this.epoch}:${randomUUID()}`};state.seen=Date.now();if(ctx.get('x-runner-features'))state.features=ctx.get('x-runner-features').split(',').filter(value=>['workspace-memory-bind-v1','hermes-computer-v2','profile-computer-v1','host-computer-tools-v1','file-transfer-v1','idle-stop-policy-v1','computer-worker-v1','artifact-chunks-v1','helper-retirement-v1','computer-control-v1','shared-computer-v1','local-vm-v1','image-options-v1','image-ready-v1','compose-desktops-v1','managed-browser-v1','managed-browser-setup-v1','managed-browser-disabled-v1','managed-browser-retention-v1'].includes(value));this.online.set(record.id,state)
       ctx.set('Cache-Control','no-store')
       if(match[2]==='poll') {
         if(ctx.method!=='GET')throw new HttpError(405,'仅允许 GET','method_not_allowed')

+ 6 - 1
src/server/workspaceRuntime.ts

@@ -410,6 +410,11 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     const finish = (error?: Error) => {
       if (settled) return
       settled = true
+      // End browser task authority before aborting the shared tool lease. A
+      // successful turn parks its Bot's page; cancellation still interrupts I/O.
+      let retainBrowser=false
+      try { retainBrowser=!error&&!this.getWork(owner,run.id).cancelRequested&&!this.store.require<Run>(owner,'run',run.runId).stopRequested } catch { /* Missing/revoked work must not retain a browser. */ }
+      void browserTurn?.close({retain:retainBrowser}).catch(()=>{})
       toolController.abort()
       void toolLease?.dispose()
       void pluginLease?.dispose()
@@ -470,7 +475,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         if (error) rejectTurn(error)
         else resolveTurn(resultMessage)
       } catch (failure) { rejectTurn(failure instanceof Error ? failure : new Error('无法保存执行状态')) }
-      finally { void browserTurn?.close().catch(()=>{}); vmTools?.close(); if (usageCompletion) void usageCompletion.finally(() => gateway.close()); else gateway.close() }
+      finally { vmTools?.close(); if (usageCompletion) void usageCompletion.finally(() => gateway.close()); else gateway.close() }
     }
     const restoreCompleted = async () => {
       const marker = `[yaoyao-run:${run.runId}:${resultMessage.id}]`

+ 2 - 1
src/shared/managedBrowser.ts

@@ -15,7 +15,7 @@ export interface ManagedBrowserCard {
   id: string
   agentId: string
   agentName: string
-  status: 'preparing' | 'ready' | 'active' | 'closed' | 'failed'
+  status: 'preparing' | 'ready' | 'active' | 'idle' | 'closed' | 'failed'
   title?: string
   url?: string
   message?: string
@@ -27,6 +27,7 @@ export interface ManagedBrowserCard {
 export interface ManagedBrowserState extends BotBrowserState {
   enabled: boolean
   available: boolean
+  supportsRetention?: boolean
   reason?: string
   installation?: BrowserInstallation
   generation: number

+ 99 - 6
tests/client/managedBrowser.test.ts

@@ -14,11 +14,11 @@ const request=vi.mocked(apiRequest)
 const agent={id:'bot',name:'竹儿',profile:'default',nodeId:'local'} as any
 const permission={supported:true,authorized:true,screen:true,accessibility:true,ready:true,fullAuthorized:true}
 const stubs={AppIcon:true,GrokAuthPanel:true}
-let enabled:boolean|undefined,state:ManagedBrowserState,controlled:boolean
+let enabled:boolean|undefined,state:ManagedBrowserState,controlled:boolean,canResume:boolean
 beforeEach(()=>{
   vi.spyOn(document,'hidden','get').mockReturnValue(false)
-  route.query={};enabled=true;controlled=false
-  state={enabled:true,available:true,open:false,generation:4,profile:'temporary',tabs:[],downloads:[]}
+  route.query={};enabled=true;controlled=false;canResume=false
+  state={enabled:true,available:true,supportsRetention:true,open:false,generation:4,profile:'temporary',tabs:[],downloads:[]}
   request.mockReset()
   request.mockImplementation(async(path,options)=>{
     const url=new URL(path,'http://fixture'),backend=url.searchParams.get('backend')
@@ -28,14 +28,15 @@ beforeEach(()=>{
     if(url.pathname.endsWith('/cloud-computer'))return {configured:true,running:true,connected:true} as any
     if(url.pathname.endsWith('/local-vm'))return {enabled:true,image:true,container:'running',ready:true} as any
     if(url.pathname.endsWith('/managed-browser'))return structuredClone(state) as any
+    if(url.pathname.endsWith('/managed-browser/close')){controlled=false;state.open=false;state.tabs=[];return {mode:'off',backend:'managed-browser',generation:4} as any}
     if(url.pathname.endsWith('/managed-browser/action'))return {ok:true} as any
     if(url.pathname.endsWith('/frame'))return {id:'frame',data:backend,width:100,height:80,generation:4} as any
     if(url.pathname.includes('/computer')){
       if(options?.method==='POST'&&url.pathname.endsWith('/take')){
-        controlled=true;state.open=true;state.tabs=[{id:'tab',title:'示例页面',url:'https://example.com',active:true}]
+        controlled=true;if(!state.open)state.tabs=[{id:'tab',title:'示例页面',url:'https://example.com',active:true}];state.open=true
       }
       if(options?.method==='POST'&&url.pathname.endsWith('/giveback'))controlled=false
-      return {mode:controlled?'human':backend==='managed-browser'&&!state.open?'off':'idle',backend:backend==='managed-browser'?'managed-browser':'local',hostName:'托管浏览器',generation:4,controlId:controlled?'control':undefined,token:'fixture-token'} as any
+      return {mode:controlled?'human':backend==='managed-browser'&&!state.open?'off':'idle',backend:backend==='managed-browser'?'managed-browser':'local',hostName:'托管浏览器',generation:4,canResume,controlId:controlled?'control':undefined,token:'fixture-token'} as any
     }
     throw new Error(`Unexpected request ${path}`)
   })
@@ -90,11 +91,103 @@ it('requires takeover before navigation and routes browser actions and Linux sho
     expect(request).toHaveBeenCalledWith('/api/app/agents/bot/computer/input?backend=managed-browser',{method:'POST',body:expect.objectContaining({generation:4,frameId:'frame',action:{kind:'key',key:'a',modifiers:['ctrl']}})})
     await wrapper.get('.computer-screen img').trigger('keydown',{key:'c',metaKey:true});await flushPromises()
     expect(request).toHaveBeenCalledWith('/api/app/agents/bot/computer/input?backend=managed-browser',{method:'POST',body:expect.objectContaining({action:{kind:'key',key:'c',modifiers:['ctrl']}})})
-    await wrapper.findAll('button').find(button=>button.text()==='结束控制')!.trigger('click');await flushPromises()
+    await wrapper.findAll('button').find(button=>button.text()==='结束接管')!.trigger('click');await flushPromises()
     expect(request.mock.calls.find(([path])=>path.includes('/giveback'))?.[0]).toContain('backend=managed-browser')
   }finally{wrapper.unmount()}
 })
 
+it('returns control and hides its viewer while keeping the same browser page open',async()=>{
+  state.open=true;state.tabs=[{id:'retained',title:'跨任务保留的页面',url:'https://example.com/retained',active:true}]
+  const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true,autoTake:true},global:{stubs}})
+  try{
+    await flushPromises()
+    await wrapper.get('.return-control').trigger('click');await flushPromises()
+    expect(wrapper.emitted('close')).toHaveLength(1)
+    expect(request.mock.calls.filter(([path])=>path.includes('/giveback'))).toHaveLength(1)
+    expect(request.mock.calls.some(([path])=>path.endsWith('/managed-browser/close'))).toBe(false)
+    expect(state).toMatchObject({open:true,tabs:[{id:'retained'}]})
+  }finally{wrapper.unmount()}
+  const reopened=mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true,autoTake:true},global:{stubs}})
+  try{
+    await flushPromises()
+    expect(reopened.get('[aria-label="浏览器标签页"]').text()).toContain('跨任务保留的页面')
+    expect(state.tabs[0]?.id).toBe('retained')
+  }finally{reopened.unmount()}
+})
+
+it('explicitly closes only the managed browser using the current control receipt',async()=>{
+  const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true},global:{stubs}})
+  try{
+    await flushPromises()
+    expect(wrapper.find('.browser-session-actions').exists()).toBe(false)
+    await wrapper.findAll('button').find(button=>button.text()==='打开并控制')!.trigger('click');await flushPromises()
+    expect(wrapper.get('.return-control').text()).toBe('交还并收起')
+    await wrapper.get('.browser-session-actions button').trigger('click');await flushPromises()
+    expect(request).toHaveBeenCalledWith('/api/app/agents/bot/managed-browser/close',{method:'POST',body:{controlId:'control',token:'fixture-token'}})
+    expect(wrapper.find('.browser-session-actions').exists()).toBe(false)
+    expect(wrapper.find('.computer-screen img').exists()).toBe(false)
+    expect(wrapper.get('.return-control').text()).toBe('收起窗口')
+    expect(wrapper.text()).toContain('浏览器尚未打开')
+    expect(wrapper.emitted('close')).toBeUndefined()
+    expect(request.mock.calls.some(([path,options])=>options?.method==='POST'&&(/local-vm|giveback/.test(path)))).toBe(false)
+    expect(wrapper.emitted('changed')).toHaveLength(1)
+  }finally{wrapper.unmount()}
+})
+
+it('does not allow closing the browser while a bot task can resume',async()=>{
+  canResume=true
+  const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true,autoTake:true},global:{stubs}})
+  try{
+    await flushPromises()
+    expect(wrapper.text()).toContain('人工控制中')
+    expect(wrapper.find('.browser-session-actions').exists()).toBe(false)
+    expect(request.mock.calls.some(([path])=>path.endsWith('/managed-browser/close'))).toBe(false)
+  }finally{wrapper.unmount()}
+})
+
+it.each([
+  [true,'交还或收起只会释放操作权,页面会保留'],
+  [false,'此节点需更新后才能跨任务保留页面;任务结束后浏览器会关闭'],
+  [undefined,'交还或收起会结束当前接管'],
+] as const)('describes retention only when the node reports support (%s)',async(supportsRetention,description)=>{
+  state.supportsRetention=supportsRetention
+  const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true,autoTake:true},global:{stubs}})
+  try{
+    await flushPromises()
+    await wrapper.findAll('button').find(button=>button.text()==='键盘与操作')!.trigger('click')
+    expect(wrapper.get('footer').text()).toContain(description)
+    if(supportsRetention!==true)expect(wrapper.get('footer').text()).not.toContain('页面会保留')
+    expect(request.mock.calls.some(([path])=>path.endsWith('/managed-browser/close'))).toBe(false)
+  }finally{wrapper.unmount()}
+})
+
+it.each(['desktop','cloud','vm'] as const)('does not expose managed browser close for a %s target',async(backend)=>{
+  const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend,standalone:true,autoTake:true},global:{stubs}})
+  try{
+    await flushPromises()
+    expect(wrapper.text()).toContain('人工控制中')
+    expect(wrapper.find('.browser-session-actions').exists()).toBe(false)
+    await wrapper.get('.return-control').trigger('click');await flushPromises()
+    expect(request.mock.calls.some(([path])=>path.endsWith('/managed-browser/close'))).toBe(false)
+  }finally{wrapper.unmount()}
+})
+
+it('retains control when explicit browser close fails so the user can retry or return it',async()=>{
+  const original=request.getMockImplementation()!
+  request.mockImplementation((path,options)=>path.endsWith('/managed-browser/close')?Promise.reject(new Error('浏览器仍被任务使用,请先交还')):original(path,options))
+  const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true,autoTake:true},global:{stubs}})
+  try{
+    await flushPromises()
+    await wrapper.get('.browser-session-actions button').trigger('click');await flushPromises()
+    expect(wrapper.text()).toContain('人工控制中')
+    expect(wrapper.get('.browser-session-actions button').attributes('disabled')).toBeUndefined()
+    expect(wrapper.get('.return-control').text()).toBe('交还并收起')
+    expect(wrapper.emitted('close')).toBeUndefined()
+    expect(state.open).toBe(true)
+    expect(wrapper.get('[role="alert"]').text()).toBe('浏览器仍被任务使用,请先交还')
+  }finally{wrapper.unmount()}
+})
+
 it('never switches an unavailable requested browser to another computer',async()=>{
   state.available=false;state.reason='执行节点离线'
   const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend:'managed-browser',standalone:true,autoTake:true},global:{stubs}})

+ 40 - 9
tests/client/managedBrowserCardSmoke.test.ts

@@ -26,9 +26,10 @@ it.runIf(process.env.YAOYAO_BROWSER_UI_SMOKE==='1')('opens the structured chat c
     await browserExpect(page.locator('.desktop-sidebar .sidebar-create-trigger')).toBeVisible()
     const seed=await(await page.request.post(origin+'/__test/computer',{data:{}})).json()
     const calls:{op:string;body?:any}[]=[]
-    let available=false,installing=false,human=false,online=true,browserReads=0
+    let available=false,installing=false,human=false,online=true,browserReads=0,browserOpen=false,canResume=true
+    let historyStatus:'idle'|'closed'|undefined
     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 card=()=>({id:'real-server-card',agentId:seed.agentId,agentName:'电脑面板验收 1',status:historyStatus??(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()},{id:'service-warning',conversationId:seed.conversationId,agentId:seed.agentId,agentName:'电脑面板验收 1',seq:2,role:'assistant',content:'',reasoning:'',status:'complete',attachments:[],tools:[],serviceWarnings:[{code:'plugin_unavailable',service:'天气插件',message:'MCP 服务暂不可用,本次任务继续使用其余能力。'}],createdAt:Date.now()}];return data}
     await page.route('**/api/app/conversations/'+seed.conversationId+'?*',async route=>{const response=await route.fetch();await route.fulfill({response,json:inject(await response.json())})})
     await page.route('**/api/app/workspace/snapshot*',async route=>{const response=await route.fetch(),data=await response.json();data.details=data.details.map(inject);await route.fulfill({response,json:data})})
@@ -37,16 +38,21 @@ it.runIf(process.env.YAOYAO_BROWSER_UI_SMOKE==='1')('opens the structured chat c
     await page.route('**/api/app/agents/'+seed.agentId+'/**',async route=>{
       const url=new URL(route.request().url()),path=url.pathname
       if(path.endsWith('/managed-browser/prepare')){calls.push({op:'prepare',body:route.request().postDataJSON()});installing=true}
-      if(path.endsWith('/managed-browser')||path.endsWith('/managed-browser/prepare')){browserReads++;return route.fulfill({json:{enabled:true,available:online&&available,...(online?{installation:installation()}:{reason:'执行节点离线,请恢复节点连接'}),open:human,generation:4,profile:'persistent',tabs:human?[{id:'tab',title:'产品文档',url:'https://example.com/docs',active:true}]:[],downloads:[]}})}
+      if(path.endsWith('/managed-browser')||path.endsWith('/managed-browser/prepare')){browserReads++;return route.fulfill({json:{enabled:true,available:online&&available,supportsRetention:true,...(online?{installation:installation()}:{reason:'执行节点离线,请恢复节点连接'}),open:browserOpen,generation:4,profile:'persistent',tabs:browserOpen?[{id:'retained-tab',title:'产品文档',url:'https://example.com/docs',active:true}]:[],downloads:[]}})}
+      if(path.endsWith('/managed-browser/close')){
+        expect(human).toBe(true);expect(canResume).toBe(false)
+        calls.push({op:'close-browser',body:route.request().postDataJSON()});human=false;browserOpen=false;historyStatus='closed'
+        return route.fulfill({json:{mode:'off',backend:'managed-browser',generation:4,canResume:false}})
+      }
       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==='take'){expect(available).toBe(true);human=true;browserOpen=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'}:{})}})
+      return route.fulfill({json:{mode:human?'human':browserOpen?'idle':'off',backend:'managed-browser',hostName:'托管浏览器',generation:4,canResume,...(human?{controlId:'control',token:'fixture-token'}:{})}})
     })
     await page.goto(origin+'/conversations/'+seed.conversationId)
     const element=page.getByRole('region',{name:'托管浏览器会话'})
@@ -74,11 +80,14 @@ it.runIf(process.env.YAOYAO_BROWSER_UI_SMOKE==='1')('opens the structured chat c
     await browserExpect(panel).toContainText('人工控制中')
     await browserExpect(panel.locator('.computer-screen img')).toBeVisible()
     expect(calls.filter(call=>call.op==='take')).toHaveLength(1)
+    await browserExpect(panel.getByRole('button',{name:'关闭浏览器',exact:true})).toHaveCount(0)
     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 panel.getByRole('button',{name:'交还并收起',exact:true}).click()
     await browserExpect(panel).toHaveCount(0)
     expect(calls.filter(call=>call.op==='giveback')).toHaveLength(1)
+    expect(browserOpen).toBe(true)
+    expect(calls.some(call=>call.op==='close-browser')).toBe(false)
     expect(page.url()).toContain('/conversations/'+seed.conversationId)
     // Offline is a connection state, never a synthetic installation. Rechecking
     // must recover the same browser with read-only requests before another take.
@@ -92,18 +101,40 @@ it.runIf(process.env.YAOYAO_BROWSER_UI_SMOKE==='1')('opens the structured chat c
     online=true
     const readsBeforeRecheck=browserReads
     await panel.getByRole('button',{name:'重新检测',exact:true}).click()
-    await browserExpect(panel.getByRole('button',{name:'打开并控制',exact:true})).toBeEnabled()
+    await browserExpect(panel.getByRole('button',{name:'接管电脑',exact:true})).toBeEnabled()
     expect(browserReads).toBeGreaterThan(readsBeforeRecheck)
     expect(calls).toHaveLength(writesBeforeRecheck)
-    await panel.getByRole('button',{name:'关闭',exact:true}).click()
+    await panel.getByRole('button',{name:'收起窗口',exact:true}).click()
     await browserExpect(panel).toHaveCount(0)
+    canResume=false;historyStatus='idle'
     await page.reload();await browserExpect(element).toContainText('产品文档 · 浏览器接管验证')
+    await browserExpect(element).toContainText('页面已保留')
+    await page.screenshot({path:join(output,'idle-375.png')})
+    await element.getByRole('button',{name:'接管浏览器',exact:true}).click()
+    await browserExpect(panel.getByRole('tab',{name:'产品文档',exact:true})).toBeVisible()
+    await browserExpect(panel.getByRole('button',{name:'关闭浏览器',exact:true})).toBeVisible()
+    await page.screenshot({path:join(output,'close-browser-375.png')})
+    await panel.getByRole('button',{name:'关闭浏览器',exact:true}).click()
+    await browserExpect(panel).toContainText('浏览器尚未打开')
+    expect(calls.filter(call=>call.op==='close-browser')).toEqual([{op:'close-browser',body:{controlId:'control',token:'fixture-token'}}])
+    expect(browserOpen).toBe(false)
+    await panel.getByRole('button',{name:'收起窗口',exact:true}).click()
+    await browserExpect(panel).toHaveCount(0)
+    await page.reload()
+    await browserExpect(element).toContainText('会话已结束')
+    await browserExpect(element).toContainText('已关闭或回收的页面不会自动恢复')
+    await element.getByRole('button',{name:'打开此 Bot 的浏览器',exact:true}).click()
+    await browserExpect(panel).toContainText('人工控制中')
+    expect(calls.filter(call=>call.op==='take')).toHaveLength(3)
+    await panel.getByRole('button',{name:'交还并收起',exact:true}).click()
+    await browserExpect(panel).toHaveCount(0)
+    expect(browserOpen).toBe(true)
     await browserExpect(warnings).toContainText('MCP 服务暂不可用,本次任务继续使用其余能力。')
     await page.emulateMedia({reducedMotion:'reduce',colorScheme:'dark'})
     await browserExpect(page.locator('html')).toHaveClass(/dark/)
     await page.screenshot({path:join(output,'chat-dark-375.png')})
     await page.setViewportSize({width:812,height:375})
-    await element.getByRole('button',{name:'接管浏览器',exact:true}).scrollIntoViewIfNeeded()
+    await element.getByRole('button',{name:'打开此 Bot 的浏览器',exact:true}).scrollIntoViewIfNeeded()
     expect(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth)).toBe(false)
     await page.screenshot({path:join(output,'chat-landscape-dark.png')})
   }finally{

+ 21 - 1
tests/client/managedBrowserCards.test.ts

@@ -36,7 +36,27 @@ it('renders browser cards with tools hidden and emits only their structured cont
     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)
+    expect(wrapper.get('.managed-browser-card button').text()).toBe('打开此 Bot 的浏览器')
+    expect(wrapper.text()).toContain('已关闭或回收的页面不会自动恢复')
+    await wrapper.get('.managed-browser-card button').trigger('click')
+    expect(wrapper.emitted('browserControl')?.at(-1)).toEqual([finished.browserCard])
+  }finally{wrapper.unmount()}
+})
+
+it('keeps idle browser cards actionable across tasks and respects read-only history',async()=>{
+  const idle={...card,status:'idle' as const}
+  const wrapper=mount(Card,{props:{card:idle},global:{stubs:{AppIcon:true}}})
+  try{
+    expect(wrapper.text()).toContain('页面已保留')
+    expect(wrapper.text()).toContain('页面可跨任务继续使用')
+    expect(wrapper.text()).toContain('真实网页标题')
+    expect(wrapper.text()).toContain('https://example.com/path')
+    await wrapper.get('button').trigger('click')
+    expect(wrapper.emitted('open')).toEqual([[idle]])
+    await wrapper.setProps({readOnly:true})
+    expect(wrapper.find('button').exists()).toBe(false)
+    await wrapper.setProps({card:{...card,status:'closed'}})
+    expect(wrapper.find('button').exists()).toBe(false)
   }finally{wrapper.unmount()}
 })
 

+ 85 - 5
tests/runner/browserProxyLease.test.ts

@@ -1,17 +1,18 @@
 // @vitest-environment node
 import {expect,it,vi} from 'vitest'
-import {createServer,request} from 'node:http'
-const target=vi.hoisted(()=>({port:0}))
+import {createServer,request,type IncomingMessage,type ServerResponse} from 'node:http'
+const target=vi.hoisted(()=>({port:0,resolve:undefined as undefined|(()=>Promise<void>)}))
 vi.mock('../../src/runner/network/publicDestination.js',async importOriginal=>({
-  ...await importOriginal<object>(),publicDestination:async()=>({host:'fixture.test',address:'127.0.0.1',family:4,port:target.port}),
+  ...await importOriginal<object>(),publicDestination:async()=>{await target.resolve?.();return {host:'fixture.test',address:'127.0.0.1',family:4,port:target.port}},
 }))
 import {BrowserPublicProxy} from '../../src/runner/browser/publicProxy.js'
+const deferred=()=>{let resolve!:()=>void;const promise=new Promise<void>(done=>{resolve=done});return {promise,resolve}}
 
-it('disconnects an existing response stream when its grant expires and allows an authorized reconnect',async()=>{
+it.each(['expired','parked'])('disconnects an existing response stream when %s and allows an authorized reconnect',async mode=>{
   const server=createServer((req,res)=>{res.writeHead(200);if(req.url==='/once')res.end('ok');else res.write('stream-start')})
   await new Promise<void>(resolve=>server.listen(0,'127.0.0.1',resolve));target.port=(server.address() as {port:number}).port
   let allowed=true
-  const proxy=await BrowserPublicProxy.start(async()=>{if(!allowed)throw new Error('grant expired')},undefined,20)
+  const proxy=await BrowserPublicProxy.start(async()=>{if(!allowed)throw new Error('grant expired')},undefined,mode==='parked'?60000:20)
   const headers={'Proxy-Authorization':'Basic '+Buffer.from(`${proxy.username}:${proxy.password}`).toString('base64')}
   try{
     let interrupted!:()=>void;const ended=new Promise<void>(resolve=>{interrupted=resolve})
@@ -20,9 +21,88 @@ it('disconnects an existing response stream when its grant expires and allows an
       req.on('error',reject);req.end()
     })
     allowed=false
+    if(mode==='parked')proxy.disconnect()
     await Promise.race([ended,new Promise((_,reject)=>setTimeout(()=>reject(new Error('expired stream stayed connected')),1000))])
     allowed=true
     const status=await new Promise<number>((resolve,reject)=>{const req=request(proxy.serverURL,{path:'http://fixture.test/once',headers},res=>{res.resume();res.on('end',()=>resolve(res.statusCode!))});req.on('error',reject);req.end()})
     expect(status).toBe(200)
   }finally{await proxy.close();server.closeAllConnections();await new Promise<void>(resolve=>server.close(()=>resolve()))}
 })
+
+it.each([
+  ['http','authorization'],['connect','authorization'],['http','destination'],['connect','destination'],
+] as const)('does not create an upstream for an old %s request resumed after parking during %s',async(protocol,stage)=>{
+  let connections=0,authorizations=0,allowed=true
+  const server=createServer((_req,res)=>res.end('new grant'))
+  server.on('connection',()=>{connections++})
+  await new Promise<void>(resolve=>server.listen(0,'127.0.0.1',resolve));target.port=(server.address() as {port:number}).port
+  const entered=deferred(),release=deferred()
+  let resolveOnce=true
+  if(stage==='destination')target.resolve=async()=>{if(resolveOnce){resolveOnce=false;entered.resolve();await release.promise}}
+  const proxy=await BrowserPublicProxy.start(async()=>{
+    if(!allowed)throw new Error('parked')
+    if(++authorizations===2&&stage==='authorization'){entered.resolve();await release.promise}
+  },undefined,60000)
+  const headers={'Proxy-Authorization':'Basic '+Buffer.from(`${proxy.username}:${proxy.password}`).toString('base64')}
+  const options={path:protocol==='connect'?'fixture.test:443':'http://fixture.test/old-request',method:protocol==='connect'?'CONNECT':'GET',headers,agent:false as const}
+  const stale=request(proxy.serverURL,options)
+  stale.on('error',()=>{});stale.on('response',response=>response.resume());stale.on('connect',(_response,socket)=>socket.destroy())
+  const staleClosed=new Promise<void>(resolve=>stale.once('close',resolve))
+  try{
+    stale.end();await entered.promise
+    allowed=false;proxy.disconnect();await staleClosed
+    // A new task has acquired this Bot's browser before the old async check
+    // returns. Current authorization alone cannot identify the stale request.
+    allowed=true;release.resolve()
+    const result=await new Promise<number>((resolve,reject)=>{
+      const fresh=request(proxy.serverURL,{...options,path:protocol==='connect'?'fixture.test:443':'http://fixture.test/new-request'},response=>{response.resume();response.once('end',()=>resolve(response.statusCode!))})
+      fresh.on('connect',(response,socket)=>{socket.destroy();resolve(response.statusCode!)})
+      fresh.on('error',reject);fresh.end()
+    })
+    expect(result).toBe(200)
+    expect(connections).toBe(1)
+  }finally{
+    release.resolve();target.resolve=undefined;stale.destroy()
+    await proxy.close();server.closeAllConnections();await new Promise<void>(resolve=>server.close(()=>resolve()))
+  }
+})
+
+it('keeps a new stream connected when a previous epoch timer check fails late',async()=>{
+  let freshResponse:ServerResponse|undefined,delayNextCheck=false
+  const server=createServer((req,res)=>{res.writeHead(200);if(req.url==='/new')freshResponse=res;res.write('started')})
+  await new Promise<void>(resolve=>server.listen(0,'127.0.0.1',resolve));target.port=(server.address() as {port:number}).port
+  const entered=deferred()
+  let rejectOld!:(error:Error)=>void
+  const delayed=new Promise<void>((_resolve,reject)=>{rejectOld=reject})
+  void delayed.catch(()=>{})
+  const proxy=await BrowserPublicProxy.start(async()=>{
+    if(delayNextCheck){delayNextCheck=false;entered.resolve();await delayed}
+  },undefined,20)
+  const headers={'Proxy-Authorization':'Basic '+Buffer.from(`${proxy.username}:${proxy.password}`).toString('base64')}
+  const stream=(path:string)=>new Promise<IncomingMessage>((resolve,reject)=>{
+    const req=request(proxy.serverURL,{path:'http://fixture.test'+path,headers,agent:false},res=>{res.on('error',()=>{});res.once('data',()=>resolve(res))})
+    req.on('error',reject);req.end()
+  })
+  try{
+    const old=await stream('/old')
+    delayNextCheck=true;await entered.promise
+    const oldClosed=new Promise<void>(resolve=>old.once('close',resolve))
+    proxy.disconnect();await oldClosed
+    const fresh=await stream('/new')
+    let timer:ReturnType<typeof setTimeout>|undefined
+    const survives=new Promise<string>((resolve,reject)=>{
+      timer=setTimeout(()=>reject(new Error('new stream stopped receiving data')),1000)
+      fresh.once('data',chunk=>{clearTimeout(timer);resolve(String(chunk))})
+      fresh.once('aborted',()=>{clearTimeout(timer);reject(new Error('old timer revoked the new stream'))})
+    })
+    void survives.catch(()=>{})
+    rejectOld(new Error('old task expired'))
+    await new Promise<void>(resolve=>setImmediate(resolve))
+    freshResponse!.write('new epoch survives')
+    expect(await survives).toBe('new epoch survives')
+    fresh.destroy()
+  }finally{
+    rejectOld(new Error('test finished'))
+    await proxy.close();server.closeAllConnections();await new Promise<void>(resolve=>server.close(()=>resolve()))
+  }
+})

+ 58 - 3
tests/runner/browserRuntime.test.ts

@@ -38,9 +38,9 @@ async function fixture(extra:Partial<ConstructorParameters<typeof BrowserRuntime
   const root=await realpath(await mkdtemp(join(tmpdir(),'yaoyao-browser-test-')));roots.push(root)
   const contexts:FakeContext[]=[],directories:string[]=[],options:unknown[]=[]
   const launcher:BrowserLauncher={launchPersistentContext:vi.fn(async(directory,settings)=>{directories.push(directory);options.push(settings);const context=new FakeContext();contexts.push(context);return context as unknown as BrowserContext})}
-  const proxyAuthorize:(()=>Promise<void>)[]=[],proxyClose=vi.fn(async()=>{})
-  const runtime=new BrowserRuntime({root,enabled:true,launcher,proxyFactory:async authorize=>{proxyAuthorize.push(authorize);return {serverURL:'http://127.0.0.1:12345',username:'test',password:'test',close:proxyClose}},...extra})
-  runtimes.push(runtime);return {runtime,launcher,contexts,directories,options,root,proxyAuthorize,proxyClose}
+  const proxyAuthorize:(()=>Promise<void>)[]=[],proxyClose=vi.fn(async()=>{}),proxyDisconnect=vi.fn()
+  const runtime=new BrowserRuntime({root,enabled:true,launcher,proxyFactory:async authorize=>{proxyAuthorize.push(authorize);return {serverURL:'http://127.0.0.1:12345',username:'test',password:'test',close:proxyClose,disconnect:proxyDisconnect}},...extra})
+  runtimes.push(runtime);return {runtime,launcher,contexts,directories,options,root,proxyAuthorize,proxyClose,proxyDisconnect}
 }
 function op(generation:number,operationId:string,action:BrowserOperation['action']):BrowserOperation{return {generation,operationId,action}}
 afterEach(async()=>{for(const runtime of runtimes.splice(0))await runtime.shutdown();for(const root of roots.splice(0))await rm(root,{recursive:true,force:true})})
@@ -72,6 +72,57 @@ describe('Runner browser lifecycle and fencing',()=>{
     await a.runtime.close(scope);await b.runtime.open(scope)
     expect(b.contexts).toHaveLength(1)
   })
+  it('rejects a delayed old network authorization even after the parked page is resumed',async()=>{
+    const f=await fixture(),entered=deferred(),gate=deferred();let delay=false
+    await f.runtime.open(scope,{authorize:async()=>{if(delay){entered.resolve();await gate.promise}}})
+    delay=true
+    const pending=f.proxyAuthorize[0]!().catch(error=>error)
+    await entered.promise
+    await f.runtime.park(scope,{authorize:async()=>{}})
+    await f.runtime.resume(scope,{authorize:async()=>{}})
+    gate.resolve()
+    expect(await pending).toMatchObject({code:'browser_generation_expired'})
+    await expect(f.proxyAuthorize[0]!()).resolves.toBeUndefined()
+    expect(f.contexts[0]!.closed).toBe(false)
+  })
+  it('keeps same-generation network checks valid across consecutive operations and fences handoff connections',async()=>{
+    const f=await fixture(),entered=deferred(),gate=deferred();let delay=false
+    const state=await f.runtime.open(scope,{authorize:async()=>{if(delay){entered.resolve();await gate.promise}}})
+    delay=true
+    const pending=f.proxyAuthorize[0]!()
+    await entered.promise
+    await f.runtime.execute(scope,op(state.generation,'same-controller',{kind:'text',text:'next'}),{authorize:async()=>{}})
+    gate.resolve();await expect(pending).resolves.toBeUndefined()
+    expect(f.proxyDisconnect).not.toHaveBeenCalled()
+    await f.runtime.pause(scope,{authorize:async()=>{}})
+    expect(f.proxyDisconnect).toHaveBeenCalledOnce()
+    expect(f.contexts[0]!.closed).toBe(false)
+  })
+  it('parks the same Bot page while fencing old actions and blocking network until new control',async()=>{
+    const f=await fixture(),task={authorize:vi.fn(async()=>{})},human={authorize:vi.fn(async()=>{})}
+    const first=await f.runtime.open(scope,task)
+    await f.runtime.execute(scope,op(first.generation,'page',{kind:'navigate',url:'https://example.com/form'}),task)
+    const page=f.contexts[0]!.tab,handle=element();page.handles=[handle]
+    await f.runtime.execute(scope,op(first.generation,'refs',{kind:'snapshot'}),task)
+    const other={...scope,environmentId:'bot-b'}
+    await f.runtime.open(other,human)
+    const parked=await f.runtime.park(scope,{authorize:async()=>{}})
+    task.authorize.mockRejectedValue(new Error('old task revoked'))
+    expect(parked).toMatchObject({open:true,tabs:[{url:'https://example.com/form'}]})
+    expect(f.contexts[0]!.tab).toBe(page);expect(f.contexts[0]!.closed).toBe(false)
+    expect(handle.dispose).toHaveBeenCalled();expect(f.proxyDisconnect).toHaveBeenCalledOnce()
+    await expect(f.proxyAuthorize[0]!()).rejects.toMatchObject({code:'browser_parked'})
+    await expect(f.proxyAuthorize[1]!()).resolves.toBeUndefined()
+    await expect(f.runtime.execute(scope,op(first.generation,'late',{kind:'text',text:'old'}))).rejects.toMatchObject({code:'browser_generation_expired'})
+    await expect(f.runtime.execute(scope,op(parked.generation,'idle-mutation',{kind:'text',text:'idle'}))).rejects.toMatchObject({code:'browser_parked'})
+    await expect(f.runtime.execute(scope,op(parked.generation,'preview',{kind:'screenshot'}))).resolves.toMatchObject({mimeType:'image/png'})
+    const next=await f.runtime.resume(scope,human)
+    await expect(f.proxyAuthorize[0]!()).resolves.toBeUndefined()
+    await f.runtime.execute(scope,op(next.generation,'fresh',{kind:'text',text:'new'}),human)
+    expect(page.keyboard.insertText).toHaveBeenCalledExactlyOnceWith('new')
+    expect(f.contexts).toHaveLength(2);expect(next.tabs).toEqual(parked.tabs)
+    await expect(f.runtime.execute(scope,op(next.generation,'expired-grant',{kind:'text',text:'old'}),task)).rejects.toThrow('old task revoked')
+  })
   it('also locks a temporary environment across runtime instances',async()=>{
     const temporary={...scope,profile:'temporary' as const},a=await fixture();await a.runtime.open(temporary)
     const b=await fixture({root:a.root})
@@ -91,7 +142,11 @@ describe('Runner browser lifecycle and fencing',()=>{
     const f=await fixture({maxSessions:2});await f.runtime.open(scope)
     await f.runtime.open({...scope,ownerKey:'account-b'})
     expect(f.directories[0]).not.toBe(f.directories[1])
+    await f.runtime.park(scope)
     await expect(f.runtime.open({...scope,environmentId:'another'})).rejects.toMatchObject({code:'browser_capacity'})
+    expect((await f.runtime.status(scope)).open).toBe(true)
+    await f.runtime.close(scope);await f.runtime.open({...scope,environmentId:'another'})
+    expect(f.contexts[1]!.closed).toBe(false)
   })
   it('deduplicates successful and uncertain operations, rejecting ID conflicts',async()=>{
     const f=await fixture(),state=await f.runtime.open(scope)

+ 9 - 0
tests/runner/browserSmoke.test.ts

@@ -50,7 +50,16 @@ it.skipIf(process.env.YAOYAO_BROWSER_SMOKE!=='1')('real Chromium: DOM refs, take
     await expect(runtime.execute(scope,{generation:oldGeneration,operationId:'stale',action:{kind:'text',text:'stale'}})).rejects.toMatchObject({code:'browser_generation_expired'})
     const login=await snapshot();await execute({kind:'click',snapshotId:login.snapshotId,ref:login.elements.find(e=>e.text==='Login')!.ref})
     expect((await contexts[0]!.cookies()).find(c=>c.name==='login')?.value).toBe('ok')
+    const retained=(await runtime.status(scope)).tabs
+    const parked=await runtime.park(scope,{authorize:async()=>{calls.push('retention')}})
+    expect(parked.open).toBe(true);expect(parked.tabs).toEqual(retained)
+    expect(await page.locator('#name').inputValue()).toBe('before takeover')
+    expect(await page.locator('#result').innerText()).toBe('logged in')
+    expect(contexts).toHaveLength(1)
+    await expect(runtime.execute(scope,{generation:parked.generation,operationId:'idle-input',action:{kind:'text',text:'forbidden'}})).rejects.toMatchObject({code:'browser_parked'})
     generation=(await runtime.resume(scope,{authorize:async()=>{calls.push('task')}})).generation
+    expect((await runtime.status(scope)).tabs).toEqual(retained)
+    expect(await page.locator('#name').inputValue()).toBe('before takeover')
     const upload=await snapshot();await execute({kind:'upload',snapshotId:upload.snapshotId,ref:upload.elements.find(e=>e.type==='file')!.ref,fileId:'attachment-id'})
     await expect.poll(()=>page.locator('#uploaded').innerText()).toBe('controlled upload')
     const download=await snapshot();await execute({kind:'click',snapshotId:download.snapshotId,ref:download.elements.find(e=>e.text==='Download')!.ref})

+ 38 - 1
tests/runner/managedBrowserTransport.test.ts

@@ -8,7 +8,7 @@ import type {BrowserRuntime,BrowserCallContext,BrowserScope} from '../../src/run
 const scope:BrowserScope={ownerKey:'a'.repeat(64),environmentId:randomUUID(),profile:'persistent'},grantId=randomUUID()
 const scopes:RunnerBrowser[]=[]
 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(),park:vi.fn(async()=>({open:true,generation:2,tabs:[],downloads:[]})),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 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)=>{})
@@ -84,6 +84,43 @@ describe('managed browser Runner transport',()=>{
     f.runtime.revoke.mockClear();await f.invoke({op:'open'});f.check.mockRejectedValueOnce(new Error('owner disabled'));await f.browser.sweep()
     expect(f.runtime.revoke).toHaveBeenCalledWith(scope)
   })
+  it('retains idle pages for thirty minutes without extending lifetime on preview polling',async()=>{
+    vi.useFakeTimers();const f=fixture()
+    await f.invoke({op:'open'});await f.invoke({op:'park'})
+    expect(f.runtime.park).toHaveBeenCalledWith(scope,expect.objectContaining({authorize:expect.any(Function)}))
+    await vi.advanceTimersByTimeAsync(10*60000)
+    await f.invoke({op:'status'})
+    await f.invoke({op:'execute',operation:{generation:2,operationId:'preview',action:{kind:'screenshot'}}})
+    await f.browser.sweep();expect(f.runtime.revoke).not.toHaveBeenCalled()
+    await vi.advanceTimersByTimeAsync(20*60000+1)
+    await f.invoke({op:'status'});await f.browser.sweep()
+    expect(f.runtime.revoke).toHaveBeenCalledExactlyOnceWith(scope)
+  })
+  it('refreshes idle time for real input but still revokes a parked scope when permission is removed',async()=>{
+    vi.useFakeTimers();const f=fixture({enabled:true,idleTimeoutMs:60000})
+    await f.invoke({op:'open'});await vi.advanceTimersByTimeAsync(55000)
+    await f.invoke({op:'execute',operation:{generation:1,operationId:'type',action:{kind:'text',text:'active'}}})
+    await vi.advanceTimersByTimeAsync(10000);await f.browser.sweep();expect(f.runtime.revoke).not.toHaveBeenCalled()
+    await f.invoke({op:'park'});f.check.mockRejectedValueOnce(new Error('owner revoked'))
+    await f.browser.sweep();expect(f.runtime.revoke).toHaveBeenCalledWith(scope)
+  })
+  it.each(['open','input'])('does not revoke a newly active record when %s arrives during an old idle sweep',async(action)=>{
+    vi.useFakeTimers();const f=fixture({enabled:true,idleTimeoutMs:60000})
+    await f.invoke({op:'open'});await vi.advanceTimersByTimeAsync(61000)
+    let release!:()=>void,entered!:()=>void
+    const gate=new Promise<void>(resolve=>{release=resolve}),checking=new Promise<void>(resolve=>{entered=resolve})
+    f.check.mockImplementationOnce(async()=>{entered();await gate})
+    const sweeping=f.browser.sweep()
+    try{
+      await checking
+      await f.invoke(action==='open'?{op:'open'}:{op:'execute',operation:{generation:1,operationId:'renewed-input',action:{kind:'text',text:'still active'}}})
+      release();await sweeping
+      expect(f.runtime.revoke).not.toHaveBeenCalled()
+      await f.browser.sweep();expect(f.runtime.revoke).not.toHaveBeenCalled()
+      await vi.advanceTimersByTimeAsync(61000);await f.browser.sweep()
+      expect(f.runtime.revoke).toHaveBeenCalledExactlyOnceWith(scope)
+    }finally{release();await sweeping}
+  })
 })
 
 function installationFixture(){

+ 52 - 8
tests/server/managedBrowserSmoke.test.ts

@@ -26,10 +26,11 @@ import type {WorkspaceConversation,WorkspaceMessage,WorkspaceRun} from '../../sr
 class FixtureAuth extends LocalAuthStore {
   version=1
   allowed=true
+  owner='owner'
   override isUserActive(){return true}
   override pushAuthorizationVersion(){return this.version}
   override canUseSource(){return this.allowed}
-  override require(){return {id:'owner',username:'fixture',role:'admin'} as ReturnType<LocalAuthStore['require']>}
+  override require(){return {id:this.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 close=(server:Server)=>new Promise<void>(resolve=>{server.close(()=>resolve());server.closeAllConnections()})
@@ -63,16 +64,17 @@ it.skipIf(process.env.YAOYAO_BROWSER_SMOKE!=='1')('upgrades a legacy Runner with
   const status:string[]=[],runner=new RunnerAgent(config,home,fetch,message=>status.push(message)),controller=new AbortController()
   const running=runner.run(controller.signal);void running.catch(()=>{})
   let task:BrowserTurn|undefined
-  const ui=async(path:string,body?:unknown)=>{
+  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)})})
     const value=await response.json() as any
-    expect(response.status,JSON.stringify(value)).toBe(200)
+    expect(response.status,JSON.stringify(value)).toBe(status)
     return value
   }
   try{
     await vi.waitFor(()=>expect(hub.summary(hub.records()[0]!).features).toContain('managed-browser-v1'),{timeout:35000,interval:50})
     await vi.waitFor(()=>expect(status).toContain('执行节点已连接'))
     const features=hub.summary(hub.records()[0]!).features
+    expect(features).toContain('managed-browser-retention-v1')
     expect(features).not.toContain('computer-worker-v1');expect(features).not.toContain('computer-control-v1')
     expect(config.computers).toBeUndefined();expect(config.browser).toBeUndefined()
     const agent=store.createAgent('owner',{name:'浏览器任务',profile:'default'}),base=`/api/app/agents/${agent.id}`
@@ -100,12 +102,36 @@ it.skipIf(process.env.YAOYAO_BROWSER_SMOKE!=='1')('upgrades a legacy Runner with
     expect(resumed.humanNote).toBe('可以继续')
     expect(resumed.result.tabs[0].id).toBe(before.tabs[0].id)
     expect(resumed.result.generation).toBeGreaterThan(taken.generation)
-    await task.close();task=undefined
-    expect(await ui(base+'/managed-browser')).toMatchObject({open:false,available:true})
+    await task.close({retain:true})
+    taskController.abort()
+    await expect(task.call('managed_browser_state',{},'finished-task')).rejects.toThrow()
+    task=undefined
+    const retained=await ui(base+'/managed-browser')
+    expect(retained).toMatchObject({open:true,available:true})
+    expect(retained.tabs).toEqual(before.tabs)
+    expect(await ui(base+'/computer?backend=managed-browser')).toMatchObject({mode:'idle',canResume:false})
+    const other=store.createAgent('owner',{name:'另一个浏览器 Bot',profile:'default'})
+    expect(await ui(`/api/app/agents/${other.id}/managed-browser`)).toMatchObject({open:false,tabs:[]})
+    auth.owner='other-owner'
+    await ui(base+'/managed-browser',undefined,404)
+    auth.owner='owner'
+    const retaken=await ui(base+'/computer/take?backend=managed-browser',{requestId:randomUUID()})
+    expect(retaken).toMatchObject({mode:'human',canResume:false})
+    expect(retaken.generation).toBeGreaterThan(retained.generation)
+    const newCredentials={controlId:retaken.controlId,token:retaken.token}
+    expect((await ui(base+'/managed-browser')).tabs).toEqual(before.tabs)
+    await ui(base+'/computer/renew?backend=managed-browser',credentials,403)
+    const idle=await ui(base+'/computer/giveback?backend=managed-browser',newCredentials)
+    expect(idle).toMatchObject({mode:'idle',canResume:false})
+    expect((await ui(base+'/managed-browser')).tabs).toEqual(before.tabs)
+    const closing=await ui(base+'/computer/take?backend=managed-browser',{requestId:randomUUID()})
+    await ui(base+'/managed-browser/close',{controlId:closing.controlId,token:closing.token})
+    expect(await ui(base+'/managed-browser')).toMatchObject({open:false,available:true,tabs:[]})
     for(const endpoint of ['poll','admit','result','browser-check'])expect(requests.some(path=>path.endsWith('/'+endpoint))).toBe(true)
     expect(checks.length).toBeGreaterThan(10);expect(checks.every(Boolean)).toBe(true)
     expect(hermesRequests).toBe(0)
   }finally{
+    auth.owner='owner'
     await task?.close().catch(()=>{})
     controller.abort();await running.catch(()=>{})
     service.close();hub.close();nodes.close()
@@ -174,6 +200,7 @@ it.skipIf(process.env.YAOYAO_BROWSER_SMOKE!=='1')('persists live Bot browser car
   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).toContain('managed-browser-retention-v1')
     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)!
@@ -223,12 +250,29 @@ it.skipIf(process.env.YAOYAO_BROWSER_SMOKE!=='1')('persists live Bot browser car
     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'))
+    await vi.waitFor(()=>expect(store.require<WorkspaceMessage>('owner','message',live.id).browserCard?.status).toBe('idle'))
     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}})
+    expect(saved).toMatchObject({visible:true,status:'complete',content:'',browserCard:{status:'idle',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})
+    const retained=await ui(base+'/managed-browser')
+    expect(retained).toMatchObject({open:true,available:true})
+    expect(retained.tabs.find((page:any)=>page.active)).toMatchObject({id:tab.id,title:tab.title,url:tab.url})
+    // Finishing also disposes this turn's private HTTP tool lease. Retaining
+    // Chromium must not keep the former model's tool endpoint usable.
+    await expect(bridge('/tools/list',{})).rejects.toThrow()
+    const retaken=await ui(base+'/computer/take?backend=managed-browser',{requestId:randomUUID()})
+    expect(retaken).toMatchObject({mode:'human',canResume:false})
+    expect(retaken.generation).toBeGreaterThan(retained.generation)
+    const retakenCredentials={controlId:retaken.controlId,token:retaken.token}
+    const retainedFrame=await ui(base+'/computer/frame?backend=managed-browser')
+    await ui(base+'/computer/input?backend=managed-browser',{...retakenCredentials,requestId:randomUUID(),generation:frame.generation,frameId:frame.id,action:{kind:'key',key:'Tab'}},409)
+    await ui(base+'/computer/input?backend=managed-browser',{...retakenCredentials,requestId:randomUUID(),generation:retainedFrame.generation,frameId:retainedFrame.id,action:{kind:'key',key:'Tab'}})
+    expect(await ui(base+'/computer/giveback?backend=managed-browser',retakenCredentials)).toMatchObject({mode:'idle',canResume:false})
+    expect((await ui(base+'/managed-browser')).tabs.find((page:any)=>page.active)).toMatchObject({id:tab.id,title:tab.title,url:tab.url})
+    const closing=await ui(base+'/computer/take?backend=managed-browser',{requestId:randomUUID()})
+    expect(await ui(base+'/managed-browser/close',{controlId:closing.controlId,token:closing.token})).toMatchObject({mode:'off',canResume:false})
+    expect(await ui(base+'/managed-browser')).toMatchObject({open:false,available:true,tabs:[]})
     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)

+ 248 - 17
tests/server/managedBrowsers.test.ts

@@ -31,17 +31,18 @@ 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 nodes=new WorkspaceNodes(store,loadServerConfig({HERMES_YAOYAO_HOME:home}),{} as any)
   nodes.sourceAllowed=(owner,node,profile)=>!blockedSources.has(`${owner}:${node}:${profile}`)
-  let installation:BrowserInstallation|undefined
+  let installation:BrowserInstallation|undefined,retention=true
   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(),runnerEpoch=randomUUID(),downloadReply:(payload:any)=>unknown=()=>{throw new Error('Unexpected download')}
   let beforeReply:((payload:any)=>Promise<void>)|undefined
+  let afterReply:((payload:any)=>Promise<void>)|undefined
   const stateFor=(scope:BrowserScope)=>{
-    const key=runnerId+':'+JSON.stringify(scope);let state=states.get(key)
+    const key=runnerId+':'+runnerEpoch+':'+JSON.stringify(scope);let state=states.get(key)
     if(!state){state={open:false,generation:1,profile:scope.profile,tabs:[],downloads:[]};states.set(key,state)}
     return state
   }
   let service:ManagedBrowsers
-  const hub={browserRunner:()=>({id:runnerId}),browser:vi.fn(async(owner:string,agent:any,payload:any,authorize:()=>void)=>{
+  const hub={browserRunner:()=>({id:runnerId,browserEpoch:runnerEpoch}),supportsBrowserRetention:()=>retention,browser:vi.fn(async(owner:string,agent:any,payload:any,authorize:()=>void)=>{
     authorize()
     expect(service.allowed(runnerId,payload.scope,payload.grantId)).toBe(true)
     calls.push({owner,agentId:agent.id,runnerId,...structuredClone(payload)})
@@ -52,7 +53,7 @@ function fixture(enabled=true){
       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}]}
-    }else if(payload.op==='pause'||payload.op==='resume')state.generation++
+    }else if(payload.op==='pause'||payload.op==='resume'||payload.op==='park')state.generation++
     else if(payload.op==='close'){state.open=false;state.tabs=[];state.generation++}
     else if(payload.op==='download')result=downloadReply(payload)
     else if(payload.op==='execute'){
@@ -63,8 +64,10 @@ function fixture(enabled=true){
         result={ok:true,action:payload.operation.action}
       }
     }
+    const reply=result??structuredClone({...state,...(installation?{available:installation.status==='ready',installation}:{})})
+    if(afterReply)await afterReply(payload)
     authorize()
-    return result??structuredClone({...state,...(installation?{available:installation.status==='ready',installation}:{})})
+    return reply
   })} as unknown as RunnerHub
   service=new ManagedBrowsers(store,auth,nodes,hub)
   const app=new Koa()
@@ -79,9 +82,13 @@ function fixture(enabled=true){
   }
   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()}},
+    setRetention:(value:boolean)=>{retention=value},
+    runnerClosed:(scope:BrowserScope)=>{const state=stateFor(scope);state.open=false;state.tabs=[];state.generation++},
     setRunner:(id:string)=>{runnerId=id},runner:()=>runnerId,
+    setEpoch:(id:string)=>{runnerEpoch=id},
     setDownload:(reply:(payload:any)=>unknown)=>{downloadReply=reply},
     setBeforeReply:(value:typeof beforeReply)=>{beforeReply=value},
+    setAfterReply:(value:typeof afterReply)=>{afterReply=value},
     async close(){for(const turn of turns)await turn.close();service.close();nodes.close();store.close();rmSync(home,{recursive:true,force:true})}}
 }
 const controlURL=(base:string,action='')=>`${base}/computer${action}?backend=managed-browser`
@@ -205,11 +212,11 @@ it('waits for one confirmed pause before returning duplicate takeover requests',
   }finally{gate.resolve();await f.close()}
 })
 
-it('joins duplicate takeovers that arrived while the previous task was closing',async()=>{
+it('joins duplicate takeovers that arrived while the previous task was parking',async()=>{
   const f=fixture(),gate=defer(),entered=defer(),requestId=randomUUID()
   try{
     const task=f.turn();await task.value.call('managed_browser_open',{},'open')
-    f.setBeforeReply(async payload=>{if(payload.op==='close'){entered.resolve();await gate.promise}})
+    f.setBeforeReply(async payload=>{if(payload.op==='park'){entered.resolve();await gate.promise}})
     const closing=task.value.close();await entered.promise
     const first=request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId}).then(value=>value)
     const second=request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId}).then(value=>value)
@@ -453,10 +460,11 @@ it('publishes preparation and same-session takeover cards, preserving safe page
     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)
+    expect(cards.at(-1)).toMatchObject({id:active.id,status:'idle',title:'Example',url:'https://example.com/page'})
+    const takeAgain=await request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId:randomUUID()}).expect(200)
+    expect(cards.at(-1)).toMatchObject({status:'active',message:'人工控制中,交还后保留页面'})
+    await request(f.app.callback()).post(f.base+'/managed-browser/close').send({controlId:takeAgain.body.controlId,token:takeAgain.body.token}).expect(200)
+    expect(cards.at(-1)).toMatchObject({status:'closed',url:'https://example.com/page'})
   }finally{await f.close()}
 })
 
@@ -490,16 +498,16 @@ it('revokes an installing task immediately on cancellation, without late opening
   }finally{await f.close()}
 })
 
-it('retains the card through a task ending under human control and closes it only after giveback',async()=>{
+it('retains the card through a task ending under human control and parks it 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:'人工控制中,交还后结束本轮会话'})
+    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'})
+    expect(cards.at(-1)).toMatchObject({status:'idle',url:'https://example.com/page'})
   }finally{await f.close()}
 })
 
@@ -514,8 +522,8 @@ it('keeps browser handoff and authorization cleanup working when the card observ
     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(f.service.allowed(f.runner(),taskGrant.scope)).toBe(true)
+    expect(f.calls.at(-1).op).toBe('park')
     expect(observe).toHaveBeenCalled()
   }finally{await f.close()}
 })
@@ -535,3 +543,226 @@ it('retries an unchanged card after its persistence observer reports a failed sa
     expect(observe).toHaveBeenCalledTimes(saved)
   }finally{await f.close()}
 })
+
+it('retains tabs across task boundaries without retaining old task authority or waking on status',async()=>{
+  const f=fixture(),cards:ManagedBrowserCard[]=[]
+  try{
+    const first=f.turn({onCard:card=>{cards.push(card)}})
+    const opened=await first.value.call('managed_browser_open',{url:'https://example.com/session'},'first') as BrowserState
+    const original=f.calls.find(call=>call.op==='open')
+    await first.value.close()
+    const park=f.calls.at(-1)
+    expect(park.op).toBe('park');expect(park.grantId).not.toBe(original.grantId)
+    expect(f.service.allowed(f.runner(),original.scope,original.grantId)).toBe(false)
+    expect(f.service.allowed(f.runner(),park.scope,park.grantId)).toBe(false)
+    expect(f.service.allowed(f.runner(),park.scope)).toBe(true)
+    expect(cards.at(-1)).toMatchObject({status:'idle',url:'https://example.com/session'})
+    const statusTurn=f.turn(),start=f.calls.length
+    expect(await statusTurn.value.call('managed_browser_state',{},'read')).toMatchObject({open:true,tabs:opened.tabs})
+    await statusTurn.value.close()
+    expect(f.calls.slice(start).map(call=>call.op)).toEqual(['status'])
+    const next=f.turn(),before=f.calls.length
+    await next.value.call('managed_browser_snapshot',{},'snapshot')
+    expect(f.calls.slice(before).map(call=>call.op)).toEqual(['resume','execute'])
+    expect(f.calls.at(-1).operation.generation).toBeGreaterThan(opened.generation)
+    expect(f.calls.at(-1).grantId).not.toBe(park.grantId)
+    await expect(first.value.call('managed_browser_state',{},'old')).rejects.toMatchObject({code:'browser_authorization_revoked'})
+    expect((await f.service.state('owner',f.agent.id)).tabs).toEqual(opened.tabs)
+  }finally{await f.close()}
+})
+
+it.each(['temporary','legacy','cancel'] as const)('recycles %s sessions instead of claiming they were retained',async kind=>{
+  const f=fixture(),cards:ManagedBrowserCard[]=[]
+  try{
+    if(kind==='legacy')f.setRetention(false)
+    const agent=kind==='temporary'?{...f.agent,temporaryGoalId:randomUUID()}:f.agent
+    if(kind==='temporary')f.store.put('owner','agent',agent.id,agent)
+    const task=f.turn({onCard:card=>{cards.push(card)}},'owner',agent)
+    await task.value.call('managed_browser_open',{},'open')
+    const opening=f.calls.find(call=>call.op==='open')
+    await task.value.close({retain:kind!=='cancel'})
+    expect(f.calls.at(-1).op).toBe('close')
+    expect(f.calls.some(call=>call.op==='park')).toBe(false)
+    expect(f.service.allowed(f.runner(),opening.scope)).toBe(false)
+    expect(cards.at(-1)?.status).toBe('closed')
+    if(kind==='legacy')expect((await f.service.state('owner',agent.id)).supportsRetention).toBe(false)
+  }finally{await f.close()}
+})
+
+it('keeps an independently controlled browser on task cancellation, then closes on giveback',async()=>{
+  const f=fixture()
+  try{
+    const task=f.turn();await task.value.call('managed_browser_open',{},'open')
+    const take=await request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId:randomUUID()}).expect(200)
+    const body={controlId:take.body.controlId,token:take.body.token},scope=f.calls[0].scope
+    task.controller.abort();await task.value.close()
+    expect(f.service.allowed(f.runner(),scope,take.body.controlId)).toBe(true)
+    await request(f.app.callback()).post(controlURL(f.base,'/giveback')).send(body).expect(200)
+    expect(f.calls.at(-1).op).toBe('close');expect(f.service.allowed(f.runner(),scope)).toBe(false)
+  }finally{await f.close()}
+})
+
+it('allows explicit close only for the current human holder after the task has ended',async()=>{
+  const f=fixture()
+  try{
+    const task=f.turn();await task.value.call('managed_browser_open',{},'open')
+    const take=await request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId:randomUUID()}).expect(200)
+    const body={controlId:take.body.controlId,token:take.body.token},scope=f.calls[0].scope
+    await request(f.app.callback()).post(f.base+'/managed-browser/close').send(body).expect(409)
+    await task.value.close()
+    await request(f.app.callback()).post(f.base+'/managed-browser/close').send({...body,token:'x'.repeat(43)}).expect(403)
+    await request(f.app.callback()).post(f.base+'/managed-browser/close').set('x-fixture-owner','other').send(body).expect(403)
+    const other=f.store.createAgent('owner',{name:'另一个 Bot',profile:'default'})
+    await request(f.app.callback()).post(`/api/app/agents/${other.id}/managed-browser/close`).send(body).expect(403)
+    const result=await request(f.app.callback()).post(f.base+'/managed-browser/close').send(body).expect(200)
+    expect(result.body).toMatchObject({mode:'off',canResume:false})
+    expect(result.body).not.toHaveProperty('controlId')
+    expect(f.service.allowed(f.runner(),scope)).toBe(false)
+    await request(f.app.callback()).post(controlURL(f.base,'/giveback')).send(body).expect(403)
+  }finally{await f.close()}
+})
+
+it.each([false,true])('handles expired human control with task present=%s without reviving its authority',async active=>{
+  const f=fixture()
+  try{
+    const task=active?f.turn():undefined
+    if(task)await task.value.call('managed_browser_open',{},'open')
+    const take=await request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId:randomUUID()}).expect(200),scope=f.calls[0].scope
+    vi.spyOn(Date,'now').mockReturnValue(take.body.expiresAt+1)
+    // The retained scope bridges expiry and the next server sweep; it grants no input.
+    expect(f.service.allowed(f.runner(),scope)).toBe(true)
+    expect(f.service.allowed(f.runner(),scope,take.body.controlId)).toBe(false)
+    await (f.service as any).sweep()
+    const status=await request(f.app.callback()).get(controlURL(f.base)).expect(200)
+    expect(status.body.mode).toBe(active?'pausing':'idle')
+    expect(f.calls.some(call=>call.op==='park')).toBe(!active)
+    expect(f.calls.some(call=>call.op==='resume')).toBe(false)
+    expect((await f.service.state('owner',f.agent.id)).open).toBe(true)
+  }finally{await f.close()}
+})
+
+it.each(['owner','logout','source','global'] as const)('revokes retained sessions and human authority when %s access is removed',async reason=>{
+  const f=fixture(),cards:ManagedBrowserCard[]=[]
+  try{
+    const task=f.turn({onCard:card=>{cards.push(card)}});await task.value.call('managed_browser_open',{},'open');await task.value.close()
+    const scope=f.calls[0].scope
+    if(reason==='owner')f.inactiveOwners.add('owner')
+    else if(reason==='logout')f.versions.set('owner',2)
+    else if(reason==='source')f.blockedSources.add('owner:local:default')
+    else saveHostTools(f.home,{managedBrowser:false})
+    expect(f.service.allowed(f.runner(),scope)).toBe(false)
+    await (f.service as any).sweep()
+    expect(cards.at(-1)?.status).toBe('closed')
+    expect(f.calls.filter(call=>call.op==='park')).toHaveLength(1)
+  }finally{await f.close()}
+})
+
+it('falls back to confirmed close when parking fails',async()=>{
+  const f=fixture(),cards:ManagedBrowserCard[]=[]
+  try{
+    const task=f.turn({onCard:card=>{cards.push(card)}});await task.value.call('managed_browser_open',{},'open')
+    f.setBeforeReply(async payload=>{if(payload.op==='park')throw new Error('park failed')})
+    await task.value.close()
+    expect(f.calls.slice(-2).map(call=>call.op)).toEqual(['park','close'])
+    expect(f.service.allowed(f.runner(),f.calls[0].scope)).toBe(false)
+    expect(cards.at(-1)?.status).toBe('closed')
+  }finally{await f.close()}
+})
+
+it('updates the retained card and drops its grant after Runner idle reclamation',async()=>{
+  const f=fixture(),cards:ManagedBrowserCard[]=[]
+  try{
+    const task=f.turn({onCard:card=>{cards.push(card)}});await task.value.call('managed_browser_open',{},'open');await task.value.close()
+    const scope=f.calls[0].scope,start=f.calls.length
+    f.runnerClosed(scope)
+    vi.spyOn(Date,'now').mockReturnValue(Date.now()+31000)
+    await (f.service as any).sweep()
+    await vi.waitFor(()=>expect(cards.at(-1)?.status).toBe('closed'))
+    expect(f.calls.slice(start).map(call=>call.op)).toEqual(['status'])
+    expect(f.service.allowed(f.runner(),scope)).toBe(false)
+  }finally{await f.close()}
+})
+
+it('parks a status-only turn when its human holder expired before normal completion',async()=>{
+  const f=fixture()
+  try{
+    const first=f.turn();await first.value.call('managed_browser_open',{},'open');await first.value.close()
+    const read=f.turn();await read.value.call('managed_browser_state',{},'read')
+    const take=await request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId:randomUUID()}).expect(200)
+    vi.spyOn(Date,'now').mockReturnValue(take.body.expiresAt+1)
+    await read.value.close()
+    expect(f.calls.filter(call=>call.op==='park')).toHaveLength(2)
+    expect(f.calls.at(-1).op).toBe('park')
+    expect((await request(f.app.callback()).get(controlURL(f.base)).expect(200)).body.mode).toBe('idle')
+  }finally{await f.close()}
+})
+
+it.each(['close','giveback'] as const)('serializes %s against the competing release action and a new takeover',async firstAction=>{
+  const f=fixture(),entered=defer(),gate=defer()
+  try{
+    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 url=(action:'close'|'giveback')=>action==='close'?f.base+'/managed-browser/close':controlURL(f.base,'/giveback')
+    f.setBeforeReply(async payload=>{if(payload.op===(firstAction==='close'?'close':'park')){entered.resolve();await gate.promise}})
+    const first=request(f.app.callback()).post(url(firstAction)).send(credentials).then(value=>value)
+    await entered.promise
+    const other=request(f.app.callback()).post(url(firstAction==='close'?'giveback':'close')).send(credentials).then(value=>value)
+    let taken=false
+    const next=request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId:randomUUID()}).then(value=>{taken=true;return value})
+    await new Promise(resolve=>setTimeout(resolve,20));expect(taken).toBe(false)
+    gate.resolve()
+    expect((await first).status).toBe(200);expect((await other).status).toBe(403)
+    const current=await next
+    expect(current.status).toBe(200);expect(current.body.controlId).not.toBe(credentials.controlId)
+    expect(f.calls.at(-1).op).toBe('open')
+  }finally{gate.resolve();await f.close()}
+})
+
+it('waits for a background retained-state check before admitting the next task',async()=>{
+  const f=fixture(),entered=defer(),gate=defer()
+  try{
+    const first=f.turn();await first.value.call('managed_browser_open',{},'open');await first.value.close()
+    f.setBeforeReply(async payload=>{if(payload.op==='status'){entered.resolve();await gate.promise}})
+    vi.spyOn(Date,'now').mockReturnValue(Date.now()+31000)
+    await (f.service as any).sweep();await entered.promise
+    const next=f.turn(),pending=next.value.call('managed_browser_snapshot',{},'next')
+    gate.resolve()
+    await expect(pending).resolves.toMatchObject({ok:true})
+    expect(f.calls.slice(-2).map(call=>call.op)).toEqual(['resume','execute'])
+  }finally{gate.resolve();await f.close()}
+})
+
+it('ignores a late closed-state reply instead of deleting the newly opened retained session',async()=>{
+  const f=fixture(),entered=defer(),gate=defer();let held=false
+  try{
+    f.setAfterReply(async payload=>{if(payload.op==='status'&&!held){held=true;entered.resolve();await gate.promise}})
+    const oldStatus=f.service.state('owner',f.agent.id);await entered.promise
+    const take=await request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId:randomUUID()}).expect(200)
+    gate.resolve();await oldStatus
+    const current=await f.service.state('owner',f.agent.id)
+    expect(current).toMatchObject({open:true,generation:take.body.generation})
+    await request(f.app.callback()).post(controlURL(f.base,'/giveback')).send({controlId:take.body.controlId,token:take.body.token}).expect(200)
+    expect(f.calls.at(-1).op).toBe('park')
+    expect(f.service.allowed(f.runner(),f.calls[0].scope)).toBe(true)
+  }finally{gate.resolve();await f.close()}
+})
+
+it('accepts a restarted Runner generation after fencing the previous connection epoch',async()=>{
+  const f=fixture()
+  try{
+    const old=f.turn();await old.value.call('managed_browser_open',{},'old')
+    const taskGrant=f.calls.find(call=>call.op==='open')
+    const earlier=await request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId:randomUUID()}).expect(200)
+    await request(f.app.callback()).post(controlURL(f.base,'/giveback')).send({controlId:earlier.body.controlId,token:earlier.body.token}).expect(200)
+    const take=await request(f.app.callback()).post(controlURL(f.base,'/take')).send({requestId:randomUUID()}).expect(200)
+    await old.value.close()
+    f.setEpoch(randomUUID())
+    expect(f.service.allowed(f.runner(),taskGrant.scope,taskGrant.grantId)).toBe(false)
+    expect(f.service.allowed(f.runner(),taskGrant.scope,take.body.controlId)).toBe(false)
+    const start=f.calls.length,newTurn=f.turn(),state=await newTurn.value.call('managed_browser_open',{},'new') as BrowserState
+    expect(f.calls.slice(start).map(call=>call.op)).toEqual(['close','status','open','status'])
+    expect(state.open).toBe(true);expect(state.generation).toBeLessThan(take.body.generation)
+    await newTurn.value.close()
+    expect((await f.service.state('owner',f.agent.id)).open).toBe(true)
+  }finally{await f.close()}
+})

+ 14 - 2
tests/server/workspaceRuntimeBrowserCards.test.ts

@@ -24,6 +24,7 @@ async function fixture(group=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}>()
+  const browserClosures:{retain:boolean|undefined;aborted:boolean}[]=[]
   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}`),
@@ -43,7 +44,7 @@ async function fixture(group=false){
   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}}}
+    return {close:async(value?:{retain?:boolean})=>{browserClosures.push({retain:value?.retain,aborted:input.signal.aborted})},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'}}))
@@ -75,7 +76,8 @@ async function fixture(group=false){
   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,
+  return {store,runtime,conversation,browserClosures,
+    get browserOptions(){return options},
     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))
@@ -96,6 +98,16 @@ async function fixture(group=false){
   }
 }
 
+it('retains a successful browser turn before aborting its tools and revokes the finished task authorization',async()=>{
+  const f=await fixture()
+  try{
+    await f.run()
+    expect(f.browserClosures).toEqual([{retain:true,aborted:false}])
+    expect(f.browserOptions!.signal.aborted).toBe(true)
+    expect(()=>f.browserOptions!.authorize()).toThrow('本轮运行已结束')
+  }finally{await f.close()}
+})
+
 it('persists a card-only native browser tool reply and accepts later human-session updates',async()=>{
   const f=await fixture()
   try{