Sfoglia il codice sorgente

修复 Bot 会话记忆参数与 Hermes 接口不兼容

移除 session.create 不支持的记忆参数,将独立记忆配置放入工具桥的会话授权,并通过 Runner 显式传递和校验。工具桥 1.2.1 在模型发言前关闭该会话的 Profile 记忆注入与写入,保留其他会话和 Profile 配置。补充真实 Hermes 的 WorkspaceRuntime 首次发言、续聊和记忆隔离全链路验证,明确 Dashboard 重启提示。
Samien 1 mese fa
parent
commit
6bd32362d6

+ 4 - 2
docs/local-vm.md

@@ -42,12 +42,12 @@
 - 每个 Profile 单独显示安装版本、实际加载版本和状态。缺少后台入口时,先安装或更新默认 Profile,再安装命名 Profile。
 - “安装并启用”只启用选中的工具桥及其工具集,保留其他插件、禁用项和模型配置。每次安装保留原插件与配置备份,界面可查看备份位置。
 - 检查会比较插件文件指纹与 Hermes 启动时加载的指纹;同版本修复后未重启也会显示“待重启”,不会仅凭安装文件判断就绪。
-- 安装不会自动重启 Hermes。请在空闲时重启,再点击“重新检查”。正在执行任务时暂不可安装。
+- 安装不会自动重启 Hermes。请在空闲时重启 Dashboard 服务(默认 9119),再点击“重新检查”。正在执行任务时暂不可安装。
 - 本机安装用于当前服务可访问的本机 Hermes。Docker 可显式映射对应 Hermes 数据目录以安装工具桥,详见 [Docker 目录映射](docker-install.md#映射-hermes-目录以安装工具桥);其他远程节点支持状态检查,需在对应节点安装。
 
 管理员 API:`GET /api/app/admin/hermes-bridge` 与 `POST /api/app/admin/hermes-bridge/install`,安装参数仅为 `profile` 和可选的 `enable`;不接受客户端传入目录、命令或 Python 路径。
 
-需要 Runner 的 `hermes-computer-v2` 能力,以及每个使用中的 Profile 安装工具桥 **1.2.0**,能力响应包含 `computer_runtime_version: 2`。旧插件会明确提示更新,不回退启动 Worker。
+需要 Runner 的 `hermes-computer-v2` 和 `workspace-memory-bind-v1` 能力,以及每个使用中的 Profile 安装工具桥 **1.2.1**,能力响应包含 `computer_runtime_version: 2`。旧插件会明确提示更新,不回退启动 Worker。
 
 插件源码位于 `integrations/hermes-bots-bridge`,并随 Runner 和桌面服务打包。使用 Hermes 的 Python 安装,例如:
 
@@ -105,3 +105,5 @@ iOS/Android 没有全局虚拟桌面清单、镜像设置或执行环境选择
 ## Docker Web 的固定桌面
 
 Docker Web 使用配套 `compose.desktops.yaml` 时,桌面由 Compose 预先创建。数量固定为部署清单中的数量,全部作为已有共享桌面供 Agent 选择。页面和 API 都不提供数量修改、镜像准备或桌面增删;每个桌面独立保留工作数据。此模式不会改变 macOS 本机的动态虚拟机流程。部署步骤见 [Docker 部署](docker-install.md)。
+
+Bot 独立记忆通过工具桥的 `/bind` 授权设置。`session.create` 只使用 Hermes 公开参数;首次发言和恢复会话均先绑定独立记忆,再提交提示。工具桥只调整该会话 Agent 的记忆注入、记忆工具和后台复盘,Profile 配置及其他会话不受影响。

+ 3 - 1
integrations/hermes-bots-bridge/README.md

@@ -1,6 +1,6 @@
 # 夭夭 Bot 的 Hermes 工具桥
 
-这是运行在 Hermes 内的用户插件,版本 1.2.0。它连接 Yaoyao 每轮授权的电脑、团队和应用工具,不修改 Hermes 核心。
+这是运行在 Hermes 内的用户插件,版本 1.2.1。它连接 Yaoyao 每轮授权的电脑、团队和应用工具,不修改 Hermes 核心。
 
 ## Hermes 托管虚拟机会话
 
@@ -57,3 +57,5 @@ HTTP 使用 Hermes 自身认证。绑定身份必须与 WebSocket 的真实身
 真实测试使用已安装的 Tirith 检查器,默认查找 Hermes 主目录的 `bin/tirith`,也可通过 `YAOYAO_TIRITH_BIN` 指定,避免临时环境下载依赖造成超时。隔离模式关闭 YOLO,实际接收审批 RPC 并逐次回复 `once`;模型响应与授权文件使用测试数据。
 
 CLI 增加 `--check` 可只读检查默认及命名 Profile;`--enable` 明确启用所选插件及工具集,仅移除这个工具桥的显式禁用项。默认安装行为继续保留禁用设置。
+
+1.2.1 将 Bot 记忆隔离放在受认证的 `/bind` 授权中(`workspace_memory: true`),在首个模型请求前关闭该会话的 Profile 记忆注入、原生记忆工具及后台复盘。不会向 Hermes 的严格 `session.create` 接口发送自定义记忆字段,也不修改 Profile 配置。安装后需重启 Dashboard 服务(默认 9119),消息网关的重启不负责重载这份插件。

+ 48 - 8
integrations/hermes-bots-bridge/bridge_runtime.py

@@ -26,6 +26,7 @@ from pathlib import Path
 from typing import Any
 
 VERSION = 1
+PLUGIN_VERSION = "1.2.1"
 def _plugin_fingerprint():
     root = Path(__file__).parent
     digest = hashlib.sha256()
@@ -74,6 +75,8 @@ class Binding:
     catalog_fingerprint: str = ""
     sampling: dict = field(default_factory=dict)
     computer_policy: dict | None = None
+    workspace_memory: bool = False
+    profile_memory_tools: set = field(default_factory=set)
 
 
 def register_context(ctx):
@@ -87,6 +90,8 @@ def register_context(ctx):
 
 
 def _refresh_agent_tools(agent, binding):
+    if binding.workspace_memory:
+        _isolate_profile_memory(agent, binding)
     from model_tools import get_tool_definitions, get_toolset_for_tool
     if not hasattr(agent, "_yaoyao_base_toolsets"):
         agent._yaoyao_base_toolsets = getattr(agent, "enabled_toolsets", None)
@@ -329,14 +334,14 @@ def capabilities(profile: str = "default") -> dict:
             if not {"yaoyao_tools", "yaoyao_call"} <= names:
                 raise BridgeError(f"Profile {profile} 尚未安装或启用夭夭工具桥,请检查该 Profile 的插件与工具集设置", 503, "profile_tools_unavailable")
             return {"version": VERSION, "ready": True, "in_process": True, "profile": profile, "native_tools": True,
-                    "computer_runtime_version": 2, "plugin_version": "1.2.0", "plugin_fingerprint": PLUGIN_FINGERPRINT,
-                    "memory_isolation": True, "memory_extraction": True}
+                    "computer_runtime_version": 2, "plugin_version": PLUGIN_VERSION, "plugin_fingerprint": PLUGIN_FINGERPRINT,
+                    "memory_isolation": True, "memory_isolation_transport": "bridge-bind-v1", "memory_extraction": True}
         finally:
             reset_hermes_home_override(context)
     except Exception as exc:
         reason = str(exc) if isinstance(exc, BridgeError) else "无法确认 Hermes 插件接口兼容性"
         return {"version": VERSION, "ready": False, "in_process": in_process, "profile": profile, "reason": reason,
-                "plugin_version": "1.2.0", "plugin_fingerprint": PLUGIN_FINGERPRINT}
+                "plugin_version": PLUGIN_VERSION, "plugin_fingerprint": PLUGIN_FINGERPRINT}
 
 
 def _string(body: dict, key: str, maximum: int = 512) -> str:
@@ -431,6 +436,9 @@ def _purge(server) -> None:
 
 
 def bind(owner: tuple[str, str], body: dict, *, wait_seconds: float = 20) -> dict:
+    workspace_memory = body.get("workspace_memory", False)
+    if type(workspace_memory) is not bool:
+        raise BridgeError("记忆隔离设置格式无效", 400, "invalid_memory_policy")
     policy = body.get("computer_policy")
     if policy is not None and (not isinstance(policy, dict) or set(policy) != {"mode", "hostAccess"}
                                or policy.get("mode") not in {"isolated", "profile"}
@@ -484,18 +492,20 @@ def bind(owner: tuple[str, str], body: dict, *, wait_seconds: float = 20) -> dic
             raise BridgeError("此会话尚未加载工具桥,请启用 yaoyao_bot_bridge 工具集并新建会话", 409, "tools_unavailable")
         current = _bindings.get(sid)
         if current and current.generation == generation:
-            if current.session is not session or current.agent is not agent or current.owner != owner or current.profile != profile or current.bridge_url != url or current.computer_policy != policy or not hmac.compare_digest(current.token, token):
+            if current.session is not session or current.agent is not agent or current.owner != owner or current.profile != profile or current.bridge_url != url or current.computer_policy != policy or current.workspace_memory != workspace_memory or not hmac.compare_digest(current.token, token):
                 raise BridgeError("不能更改现有轮次的工具授权", 409, "generation_conflict")
             current.expires_at = max(current.expires_at, expiry)
             current.stored_session_id = stored
             return {"ok": True, "generation": generation, "expires_at": current.expires_at,
-                    "native_tools": bool(current.toolset), "computer_runtime_version": 2}
+                    "native_tools": bool(current.toolset), "computer_runtime_version": 2, "workspace_memory": current.workspace_memory}
         previous = _generations.get(sid)
         seen = previous[1] if previous and previous[0] is session else set()
         if generation in seen:
             raise BridgeError("该轮次授权已撤销", 409, "generation_revoked")
         if session.get("running"):
             raise BridgeError("Hermes 会话正在运行,不能更换轮次授权", 409, "session_busy")
+        if session.get("_yaoyao_workspace_memory") and not workspace_memory:
+            raise BridgeError("此 Bot 会话必须继续使用独立记忆,请新建会话后更改模式", 409, "memory_policy_conflict")
         if len(seen) >= 4096:
             raise BridgeError("请恢复会话以更新工具桥授权记录", 409, "session_renewal_required")
         seen.add(generation)
@@ -504,6 +514,10 @@ def bind(owner: tuple[str, str], body: dict, *, wait_seconds: float = 20) -> dic
             _retire(current)
         binding = Binding(sid, stored, profile, home, generation, url, token, expiry, owner, session, agent)
         binding.computer_policy = dict(policy) if policy else None
+        binding.workspace_memory = workspace_memory
+        if workspace_memory:
+            _isolate_profile_memory(agent, binding)
+            session["_yaoyao_workspace_memory"] = True
         if policy:
             # Private runtime state survives unbind so a stale tool call cannot
             # fall back to the unrestricted native Profile after revocation.
@@ -517,7 +531,7 @@ def bind(owner: tuple[str, str], body: dict, *, wait_seconds: float = 20) -> dic
                 _retire(binding)
                 raise
         return {"ok": True, "generation": generation, "expires_at": expiry, "native_tools": bool(binding.toolset),
-                "computer_runtime_version": 2}
+                "computer_runtime_version": 2, "workspace_memory": binding.workspace_memory}
 
 
 def unbind(owner: tuple[str, str], body: dict) -> dict:
@@ -743,6 +757,30 @@ def call_tool(args: dict, **kwargs) -> str | dict:
 
 # Native Hermes remains the only owner of Profile configuration, skills and
 # authenticated integrations. The Runner never opens those files itself.
+def _isolate_profile_memory(agent, binding):
+    """Apply the existing agent controls before any prompt, without editing Profile config.
+
+    session.create is Hermes' strict public contract; memory isolation belongs
+    to this authenticated session binding instead of invented RPC parameters.
+    """
+    required = ("_memory_store", "_memory_manager", "_memory_enabled", "_user_profile_enabled", "skip_background_review")
+    if not all(hasattr(agent, name) for name in required) or not callable(getattr(agent, "_invalidate_system_prompt", None)):
+        raise BridgeError("当前 Hermes 不支持工具桥的会话记忆隔离", 409, "memory_isolation_unavailable")
+    manager = agent._memory_manager
+    binding.profile_memory_tools.add("memory")
+    if manager:
+        binding.profile_memory_tools.update(manager.get_all_tool_names())
+    agent._memory_store = None
+    agent._memory_manager = None
+    agent._memory_enabled = False
+    agent._user_profile_enabled = False
+    agent.skip_background_review = True
+    agent.disabled_toolsets = list(dict.fromkeys([*(agent.disabled_toolsets or []), "memory"]))
+    if manager:
+        manager.shutdown_all()
+    agent._invalidate_system_prompt()
+
+
 def computer_directive(session_id=None, tool_name=None, args=None, **kwargs):
     try:
         server = _server()
@@ -752,7 +790,7 @@ def computer_directive(session_id=None, tool_name=None, args=None, **kwargs):
         inherited = _children.get(session_id)
         sessions = [inherited[0].session] if inherited else [s for sid, s in server._sessions.items()
             if session_id in {sid, s.get("session_key"), getattr(s.get("agent"), "session_id", None)}]
-        managed = next((s for s in sessions if s.get("_yaoyao_computer_policy")), None)
+        managed = next((s for s in sessions if s.get("_yaoyao_computer_policy") or s.get("_yaoyao_workspace_memory")), None)
     if managed is None:
         return None
     try:
@@ -760,8 +798,10 @@ def computer_directive(session_id=None, tool_name=None, args=None, **kwargs):
     except BridgeError:
         return {"action": "block", "message": "电脑会话授权已经结束,请在当前 Bot 中发起新一轮。"}
     policy = binding.computer_policy
+    if binding.workspace_memory and tool_name in binding.profile_memory_tools:
+        return {"action": "block", "message": "此 Bot 使用独立记忆,请使用本轮授权的 workspace 记忆工具。"}
     if not policy:
-        return {"action": "block", "message": "当前轮次缺少电脑权限声明。"}
+        return None if binding.workspace_memory else {"action": "block", "message": "当前轮次缺少电脑权限声明。"}
     if policy["mode"] == "profile" or policy["hostAccess"]:
         return None
     # Skill discovery, vision, and authenticated services retain the Profile's

+ 1 - 1
integrations/hermes-bots-bridge/plugin.yaml

@@ -1,4 +1,4 @@
 name: yaoyao-bot-bridge
-version: "1.2.0"
+version: "1.2.1"
 description: "夭夭 Bot 每轮授权的电脑、浏览器、手机、团队与应用工具桥"
 author: "Samien"

+ 25 - 0
integrations/hermes-bots-bridge/tests/test_computer_policy.py

@@ -97,6 +97,31 @@ class ComputerPolicyTests(unittest.TestCase):
             self.session['running'] = False
             with self.assertRaises(bridge.BridgeError): bridge.computer_file(self.binding.owner, body)
 
+    def test_workspace_memory_uses_agent_controls_without_mutating_another_agent(self):
+        stopped = []
+        other = SimpleNamespace(_memory_enabled=True)
+        self.agent._memory_store = object()
+        self.agent._memory_manager = SimpleNamespace(get_all_tool_names=lambda: {'provider_remember'}, shutdown_all=lambda: stopped.append(True))
+        self.agent._memory_enabled = self.agent._user_profile_enabled = True
+        self.agent.skip_background_review = False
+        self.agent.disabled_toolsets = ['terminal']
+        invalidated = []
+        self.agent._invalidate_system_prompt = lambda: invalidated.append(True)
+        self.binding.workspace_memory = True
+        self.session['_yaoyao_workspace_memory'] = True
+        bridge._isolate_profile_memory(self.agent, self.binding)
+        self.assertIsNone(self.agent._memory_store)
+        self.assertIsNone(self.agent._memory_manager)
+        self.assertFalse(self.agent._memory_enabled)
+        self.assertFalse(self.agent._user_profile_enabled)
+        self.assertTrue(self.agent.skip_background_review)
+        self.assertEqual(self.agent.disabled_toolsets, ['terminal', 'memory'])
+        self.assertEqual(stopped, [True]); self.assertEqual(invalidated, [True])
+        self.assertTrue(other._memory_enabled)
+        for tool in ('memory', 'provider_remember'):
+            self.assertEqual(bridge.computer_directive(session_id='stored', tool_name=tool, args={})['action'], 'block')
+        self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name='skill_view', args={}))
+
 
 if __name__ == '__main__':
     unittest.main()

+ 1 - 1
scripts/install-hermes-bridge.py

@@ -118,7 +118,7 @@ def install(root, profile, source, enable=False):
         if staged.exists():
             shutil.rmtree(staged)
     return {'profile': profile, 'installed': str(target), 'backup': str(backup), 'disabled': disabled,
-            'message': '已更新,保留显式禁用设置' if disabled else '已更新并启用;空闲时重新加载 Hermes 后生效'}
+            'message': '已更新,保留显式禁用设置' if disabled else '已更新并启用;空闲时重启 Hermes Dashboard 服务后生效'}
 
 
 def main():

+ 1 - 1
src/client/components/app/HermesBridgePanel.vue

@@ -63,7 +63,7 @@ onBeforeUnmount(()=>{disposed=true;++generation;clearTimeout(timer)})
       </div>
     </template>
     <div v-if="notice" class="result" role="status"><p>{{ notice }}</p><details v-if="backup"><summary>查看备份位置</summary><code>{{ backup }}</code></details></div>
-    <p class="footnote">安装前会备份原插件与配置。安装完成后,请在空闲时重启 Hermes,再点击重新检查。</p>
+    <p class="footnote">安装前会备份原插件与配置。安装完成后,请在空闲时重启 Hermes Dashboard 服务,再点击重新检查。</p>
   </section>
 </template>
 

+ 3 - 2
src/runner/agent.ts

@@ -55,7 +55,7 @@ export class RunnerAgent {
     if(url.hostname==='localhost')url.hostname='127.0.0.1'
     const response=await (isLocalAuthorizationTarget(url)&&this.fetchImpl===fetch?this.controlTransport.fetch.bind(this.controlTransport):this.fetchImpl)(url,{
       method:body===undefined?'GET':'POST',redirect:'error',signal:AbortSignal.any([this.controlAbort.signal,AbortSignal.timeout(path==='desktop'?75000:25000)]),
-      headers:{Authorization:`Bearer ${this.config.token}`,'x-runner-instance':this.instance,'x-runner-protocol':'1','x-runner-features':this.computers?'hermes-computer-v2,profile-computer-v1,host-computer-tools-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':''):'',...(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,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.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){
@@ -229,11 +229,12 @@ export class RunnerAgent {
       const controller=new AbortController()
       const virtual=source[1].gateway
       if(virtual instanceof ComputerGateway){
-        virtual.installTeamLease(leaseId,p.catalog as any[],async(toolId,args,callId)=>(await this.api('tool',{leaseId,toolId,arguments:args,callId})).result)
+        virtual.installTeamLease(leaseId,p.catalog as any[],async(toolId,args,callId)=>(await this.api('tool',{leaseId,toolId,arguments:args,callId})).result,p.workspaceMemory===true)
         const lease={bind:async()=>{},dispose:async()=>{virtual.removeTeamLease(leaseId)}}
         this.leases.set(leaseId,{lease,controller,session,connectionId:source[0],profile:String(p.profile)});return {ok:true}
       }
       const lease=await createWorkspaceToolLease({target:this.target,profile:String(p.profile),workId:String(p.workId),session:()=>session,signal:controller.signal,
+        workspaceMemory:p.workspaceMemory===true,
         assertActive:()=>{if(!this.connected||controller.signal.aborted)throw new Error('工具授权已失效')},
         catalog:()=>p.catalog as any[],call:async(toolId,args,callId)=>(await this.api('tool',{leaseId,toolId,arguments:args,callId:callId??randomUUID()})).result,
         onFailure:()=>{controller.abort()},

+ 3 - 3
src/runner/worker/gateway.ts

@@ -166,7 +166,7 @@ export class ComputerGateway {
   private skillSession?:ProfileSkillSession
   private get hermesManaged(){return this.meta.hermesRuntime===true}
   private get rules(){return this.hermesManaged?`${this.meta.profileSession?PROFILE_COMPUTER_RULES:ISOLATED_COMPUTER_RULES}\n${HERMES_COMPUTER_RULES}`:`${PROFILE_COMPUTER_RULES}\n${SKILL_RULES}`}
-  private team?:{id:string;catalog:WorkerTool[];call(name:string,args:Record<string,unknown>,callId:string):Promise<unknown>}
+  private team?:{id:string;catalog:WorkerTool[];call(name:string,args:Record<string,unknown>,callId:string):Promise<unknown>;workspaceMemory:boolean}
   constructor(readonly runtime:ComputerRuntime,readonly meta:ComputerTarget,readonly workId:string,readonly check:()=>Promise<void>,readonly publish:(body:Record<string,unknown>)=>Promise<any>,readonly profileTarget?:GatewayTarget){ }
   async connect(){this.runtime.assertTarget(this.meta);await this.runtime.ready;await this.authorized();if(this.hermesManaged&&!this.profileTarget)throw new HttpError(409,'缺少 Hermes 会话连接,不能回退到独立 Worker。','computer_profile_unavailable')}
   private async authorized(){try{await this.check();this.guard()}catch(error){const stopping=this.interrupt().catch(()=>{});if(!this.profileSession)await stopping;if(!this.closed)this.onDisconnect();throw error}}
@@ -264,7 +264,7 @@ export class ComputerGateway {
         const call=this.tool(live,name,args,randomUUID())
         live.toolCalls.add(call);void call.finally(()=>live.toolCalls.delete(call)).catch(()=>{})
         return call
-      },this.hermesManaged?{mode:this.meta.profileSession?'profile':'isolated',hostAccess:this.meta.hostAccess===true}:undefined)
+      },this.hermesManaged?{mode:this.meta.profileSession?'profile':'isolated',hostAccess:this.meta.hostAccess===true}:undefined,()=>this.team?.workspaceMemory===true)
     this.profileSession=native
     native.onStoredId=id=>{session.profileSessionId=id;this.runtime.save(session)}
     native.onDisconnect=()=>{void this.interrupt().finally(()=>{if(!this.closed)this.onDisconnect()}).catch(()=>{})}
@@ -311,7 +311,7 @@ export class ComputerGateway {
       }
       void worker.exited.then(()=>{if(!live.paused&&!live.received&&live.running&&!this.closed)void this.lost(live)})
   }
-  installTeamLease(id:string,catalog:WorkerTool[],call:(name:string,args:Record<string,unknown>,callId:string)=>Promise<unknown>){this.team={id,catalog,call}}
+  installTeamLease(id:string,catalog:WorkerTool[],call:(name:string,args:Record<string,unknown>,callId:string)=>Promise<unknown>,workspaceMemory=false){this.team={id,catalog,call,workspaceMemory}}
   removeTeamLease(id:string){if(this.team?.id===id)this.team=undefined}
   private async tool(live:Live,name:string,args:unknown,id:string):Promise<unknown>{
     if(live.paused)throw new HttpError(409,'电脑正在等待人工操作','computer_paused')

+ 3 - 3
src/runner/worker/profileSession.ts

@@ -18,7 +18,7 @@ export class ProfileComputerSession {
   constructor(readonly target:GatewayTarget,readonly profile:string,readonly workId:string,
     readonly authorize:()=>void,readonly catalog:()=>WorkerTool[],
     readonly call:(name:string,args:unknown,id:string)=>Promise<unknown>,
-    readonly computerPolicy?: {mode:'isolated'|'profile';hostAccess:boolean}){
+    readonly computerPolicy?: {mode:'isolated'|'profile';hostAccess:boolean},readonly workspaceMemory:()=>boolean=()=>false){
     this.gateway=new WorkspaceGateway(target)
     this.gateway.onDisconnect=()=>{if(!this.closed)this.onDisconnect()}
     this.gateway.onEvent=frame=>{
@@ -35,7 +35,7 @@ export class ProfileComputerSession {
       const response=await this.target.session.request('/api/plugins/yaoyao-bot-bridge/capabilities',{search:new URLSearchParams({profile:this.profile}),cache:'reload'})
       let capability:any;try{capability=JSON.parse(response.body.toString())}catch{}
       if(response.status!==200||capability?.ready!==true||capability?.computer_runtime_version!==2)
-        throw new HttpError(409,'请安装新版夭夭 Hermes 工具桥并重新加载,当前插件尚不支持 Hermes 托管虚拟机会话。','computer_bridge_upgrade_required')
+        throw new HttpError(409,'请更新夭夭工具桥并重启 Hermes Dashboard 服务,当前服务尚未加载托管虚拟机会话能力。','computer_bridge_upgrade_required')
     }
     await this.gateway.connect()
     const {session_id:_virtual,recoverOnly:_recover,...options}=params
@@ -53,7 +53,7 @@ export class ProfileComputerSession {
     if(!this.toolLease){
       await requireTeamToolBridge(this.target,this.profile)
       this.toolLease=await createWorkspaceToolLease({target:this.target,profile:this.profile,workId:this.workId,
-        session:()=>({runtimeId:this.runtimeId,storedId:this.storedId}),signal:this.controller.signal,computerPolicy:this.computerPolicy,
+        session:()=>({runtimeId:this.runtimeId,storedId:this.storedId}),signal:this.controller.signal,computerPolicy:this.computerPolicy,workspaceMemory:this.workspaceMemory(),
         assertActive:()=>{this.controller.signal.throwIfAborted();this.authorize()},
         catalog:()=>this.catalog().map(tool=>({...tool,id:tool.id??tool.name})),
         call:async(name,args,id)=>{

+ 2 - 2
src/server/hermesBridge.ts

@@ -81,7 +81,7 @@ export class HermesBridgeManager {
         else if(!connected||liveStatus===401||liveStatus===403){result.state='unavailable';result.message='插件文件已安装,但无法验证 Hermes 加载状态;请先检查上方连接和权限'}
         else if(ready&&live?.plugin_fingerprint===local.fingerprint){result.state='ready';result.message='Hermes 已加载当前工具桥,可以使用'}
         else if(live?.plugin_fingerprint===local.fingerprint&&!ready){result.state='disabled';result.message=typeof live.reason==='string'?live.reason.slice(0,300):'Hermes 尚未启用此 Profile 的工具桥,请检查工具集设置'}
-        else {result.state='restart-required';result.message='插件文件已就位;请在空闲时重启 Hermes 后重新检查'}
+        else {result.state='restart-required';result.message='插件文件已就位;请在空闲时重启 Hermes Dashboard 服务后重新检查'}
       }else if(ready){result.state='ready';result.message='Hermes 工具桥已就绪'}
       else if(live?.computer_runtime_version&&live.computer_runtime_version!==2){result.state='outdated';result.message='Hermes 已加载的工具桥版本需要更新'}
       else if(typeof live?.reason==='string')result.message=live.reason.slice(0,300)
@@ -117,6 +117,6 @@ export class HermesBridgeManager {
       if(error instanceof HttpError)throw error
       throw new HttpError(409,'工具桥安装失败,请检查目录权限后重新检查状态。','hermes_bridge_install_failed')
     }finally{this.installing=undefined}
-    return {profile:parsed.data.profile,backup:result.backup,message:result.disabled?'插件文件已更新,保留了禁用设置。':'工具桥已安装并启用。请在空闲时重启 Hermes,然后重新检查。',status:await this.status()}
+    return {profile:parsed.data.profile,backup:result.backup,message:result.disabled?'插件文件已更新,保留了禁用设置。':'工具桥已安装并启用。请在空闲时重启 Hermes Dashboard 服务,然后重新检查。',status:await this.status()}
   }
 }

+ 3 - 2
src/server/runnerHub.ts

@@ -183,12 +183,13 @@ export class RunnerHub {
         },
         lease:async(options)=>{
           requireComputer();requireProfile(options.profile);options.assertActive()
+          if(options.workspaceMemory&&!this.online.get(record.id)?.features.includes('workspace-memory-bind-v1'))throw new HttpError(409,'请更新配套 Runner,以支持工具桥会话的独立记忆。','workspace_memory_runner_required')
           const id=randomUUID();this.leases.set(id,{runnerId:record.id,input:options,calls:new Map()})
           let disposed=false
           const dispose=async()=>{if(disposed)return;disposed=true;this.leases.delete(id);options.signal.removeEventListener('abort',abort);await this.request(record.id,'lease.close',{leaseId:id}).catch(()=>{})}
           const abort=()=>{void dispose()};options.signal.addEventListener('abort',abort,{once:true})
           try {
-            await this.request(record.id,'lease.create',{leaseId:id,profile:options.profile,workId:options.workId,session:options.session(),catalog:options.catalog()})
+            await this.request(record.id,'lease.create',{leaseId:id,profile:options.profile,workId:options.workId,session:options.session(),catalog:options.catalog(),workspaceMemory:options.workspaceMemory===true})
             options.assertActive()
             if(options.signal.aborted)throw new Error('执行已停止')
             return {bind:async()=>{options.assertActive();await this.request(record.id,'lease.bind',{leaseId:id,session:options.session()});options.assertActive()},dispose} satisfies WorkspaceToolLease
@@ -248,7 +249,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=>['hermes-computer-v2','profile-computer-v1','host-computer-tools-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'].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','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'].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')

+ 1 - 1
src/server/workspaceRuntime.ts

@@ -572,7 +572,6 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
             ...(cwd ? { cwd } : {}),
             hidden: true,
             room_plumbing: true,
-            ...(botCapabilities.memory ? { skip_memory: true, workspace_memory: true } : {}),
             close_on_disconnect: false,
           })
       runtimeId = String(opened.session_id ?? '')
@@ -675,6 +674,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           if(plugins)pluginLease=await this.plugins!.open(owner,agent,target,toolController.signal,assertActive)
           toolLease=await createWorkspaceToolLease({
             target,profile:agent.profile,workId:run.id,signal:toolController.signal,
+            workspaceMemory:botCapabilities.memory,
             session:()=>({runtimeId,storedId:binding!.storedId}),assertActive,
             catalog:()=>[...(team?this.teamTools.catalog(owner,run.id):[]),...(knowledge?this.knowledgeTools.catalog(owner,run.id,botCapabilities.memory):[]),...(cloud?GROK_COMPUTER_TOOLS:[]),...(desktop?DESKTOP_ENVIRONMENT_TOOLS.filter(t=>desktop==='browser'||t.id!=='desktop_browser'):[]),...(pluginLease?.catalog()??[])],
             call:async(toolId,args)=>{assertActive();return knowledge&&this.knowledgeTools.handles(toolId)?this.knowledgeTools.call(owner,run.id,toolId,args,botCapabilities.memory):toolId.startsWith('plugin_')&&pluginLease?pluginLease.call(toolId,args):toolId.startsWith('desktop_')?this.desktopEnvironments!.call(owner,agent.id,toolId,args,toolController.signal,assertActive,desktop!,desktopEpoch):toolId.startsWith('cloud_computer_')?this.cloud!.call(owner,agent.id,toolId,args,toolController.signal):this.teamTools.call(owner,run.id,toolId,args)},

+ 5 - 1
src/server/workspaceToolLease.ts

@@ -51,6 +51,7 @@ export interface LeaseInput {
   call(toolId: string, args: unknown, callId?:string): Promise<unknown>
   onFailure(error: Error): void
   computerPolicy?: { mode: 'isolated' | 'profile'; hostAccess: boolean }
+  workspaceMemory?: boolean
 }
 
 /** A private loopback listener; no browser credentials or authority in prompts. */
@@ -168,7 +169,10 @@ export async function createWorkspaceToolLease(input: LeaseInput): Promise<Works
         const current = input.session()
         const result = await bridgeRequest(input.target, '/bind', { native_tools: true, session_id: current.runtimeId,
           stored_session_id: current.storedId, profile: input.profile, generation, bridge_url: url, token, expires_at: expiresAt,
-          ...(input.computerPolicy ? {computer_policy: input.computerPolicy} : {}) })
+          ...(input.computerPolicy ? {computer_policy: input.computerPolicy} : {}),
+          ...(input.workspaceMemory ? {workspace_memory: true} : {}) })
+        if(input.workspaceMemory&&result.workspace_memory!==true)
+          throw new HttpError(409,'请更新工具桥并重启 Hermes Dashboard 服务,以启用 Bot 会话的独立记忆。','workspace_memory_bridge_required')
         if (input.computerPolicy && result.computer_runtime_version !== 2)
           throw new HttpError(409, '请更新 Hermes 的夭夭工具桥,当前版本尚未接管隔离电脑会话。', 'computer_bridge_upgrade_required')
         if (!bound && result.native_tools !== true)

+ 95 - 0
tests/live/workspaceMemoryBridge.live.test.ts

@@ -0,0 +1,95 @@
+// @vitest-environment node
+import {it,expect} from 'vitest'
+import {createServer} from 'node:http'
+import {spawn} from 'node:child_process'
+import {mkdtemp,mkdir,writeFile,readFile,cp,rm} from 'node:fs/promises'
+import {join,resolve} from 'node:path'
+import {tmpdir} from 'node:os'
+import {randomUUID} from 'node:crypto'
+import {DatabaseSync} from 'node:sqlite'
+import {WorkspaceStore} from '../../src/server/workspaceStore'
+import {WorkspaceRuntime} from '../../src/server/workspaceRuntime'
+import {UploadStore} from '../../src/server/uploads'
+import {UpstreamClient} from '../../src/server/upstream'
+import {UpstreamServiceSession} from '../../src/server/localAuth'
+import {ComputerRuntime,ComputerGateway} from '../../src/runner/worker/gateway'
+import type {WorkspaceNodes,GatewayTarget} from '../../src/server/workspaceGateway'
+
+const available=['YAOYAO_HERMES_PYTHON','YAOYAO_HERMES_SOURCE','YAOYAO_COMPUTER_IMAGE'].every(key=>!!process.env[key])
+it.skipIf(!available)('runs the production WorkspaceRuntime create, bind, prompt and resume chain with isolated memory on real Hermes',async()=>{
+  const home=await mkdtemp(join(tmpdir(),'yaoyao-memory-bridge-live-')),webHome=join(home,'web'),python=process.env.YAOYAO_HERMES_PYTHON!,source=process.env.YAOYAO_HERMES_SOURCE!
+  const profileMemory='PROFILE_PRIVATE_MEMORY_MUST_NOT_REACH_BOT',botMemory='BOT_SCOPED_MEMORY_MUST_REACH_MODEL'
+  await mkdir(join(home,'memories'));await mkdir(join(home,'plugins'))
+  await writeFile(join(home,'memories','MEMORY.md'),profileMemory)
+  await writeFile(join(home,'memories','USER.md'),'PROFILE_PRIVATE_USER_MUST_NOT_REACH_BOT')
+  await writeFile(join(home,'.env'),'')
+  await cp(resolve('integrations/hermes-bots-bridge'),join(home,'plugins','yaoyao-bot-bridge'),{recursive:true})
+  const modelCalls:any[]=[]
+  const model=createServer(async(req,res)=>{
+    res.setHeader('Content-Type','application/json')
+    if(req.method==='GET'){res.end(JSON.stringify({data:[{id:'memory-fixture-model',context_length:64000}]}));return}
+    let raw='';for await(const chunk of req)raw+=chunk
+    const input=JSON.parse(raw);modelCalls.push(input)
+    const message={role:'assistant',content:'BOT_MEMORY_BRIDGE_OK'}
+    if(input.stream){res.setHeader('Content-Type','text/event-stream');res.end('data: '+JSON.stringify({id:randomUUID(),object:'chat.completion.chunk',model:'memory-fixture-model',choices:[{index:0,delta:message,finish_reason:'stop'}]})+'\n\ndata: [DONE]\n\n')}
+    else res.end(JSON.stringify({id:randomUUID(),object:'chat.completion',model:'memory-fixture-model',choices:[{index:0,message,finish_reason:'stop'}],usage:{prompt_tokens:20,completion_tokens:5,total_tokens:25}}))
+  })
+  await new Promise<void>(done=>model.listen(0,'127.0.0.1',done))
+  const modelURL=`http://127.0.0.1:${(model.address() as {port:number}).port}/v1`
+  const config={model:{provider:'custom',default:'memory-fixture-model',base_url:modelURL,api_key:'fixture',context_length:64000},terminal:{cwd:home},memory:{memory_enabled:true,user_profile_enabled:true},plugins:{enabled:['yaoyao-bot-bridge']},tools:{tool_search:{enabled:'off'}},dashboard:{turn_isolation:false},compression:{enabled:false}}
+  await writeFile(join(home,'config.yaml'),JSON.stringify(config))
+  const reservation=createServer();await new Promise<void>(done=>reservation.listen(0,'127.0.0.1',done))
+  const port=(reservation.address() as {port:number}).port;await new Promise<void>(done=>reservation.close(()=>done()))
+  const url=new URL(`http://127.0.0.1:${port}`)
+  let logs=''
+  const child=spawn(python,['-m','hermes_cli.main','serve','--host','127.0.0.1','--port',String(port),'--skip-build','--isolated'],{cwd:home,env:{PATH:process.env.PATH,HOME:process.env.HOME,HERMES_HOME:home,PYTHONPATH:source,HERMES_YOLO_MODE:'1',HERMES_DASHBOARD_SESSION_TOKEN:'fixture-memory-bridge-local-token',NO_PROXY:'127.0.0.1,localhost'},stdio:['ignore','pipe','pipe']})
+  child.stdout.on('data',c=>{logs=(logs+String(c)).slice(-8000)});child.stderr.on('data',c=>{logs=(logs+String(c)).slice(-8000)})
+  const client=new UpstreamClient(url),session=new UpstreamServiceSession(client,()=>undefined),target={url,client,session}
+  const store=new WorkspaceStore(webHome),uploads=new UploadStore(webHome),db=new DatabaseSync(':memory:')
+  const computer=new ComputerRuntime(db,{protocol:1,runnerId:randomUUID(),serverURL:url.href,hermesURL:url.href,token:'fixture',allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:process.env.YAOYAO_COMPUTER_IMAGE!,python:'/must-not-run',hermesSource:'/must-not-read',hermesHome:'/must-not-read',network:'none'}},webHome,resolve('src/runner/worker/hermes_worker.py'),undefined,target)
+  const owner='fixture-owner',agent=store.createAgent(owner,{name:'production-pipeline-fixture',profile:'default',execution:'computer',computer:'vm',vmExecution:'worker'}),gateways:ComputerGateway[]=[],grants:boolean[]=[],rpc:any[]=[]
+  let current:ComputerGateway
+  const bridged:GatewayTarget={...target,runner:{id:computer.config.runtime,computer:true,hermesComputer:true,
+    open:async(onEvent,onDisconnect,scope)=>{
+      current=new ComputerGateway(computer,{agentId:agent.id,environmentId:agent.id,ownerKey:owner,hermesRuntime:true},scope!.workId,async()=>scope!.authorize(),async()=>({}),target)
+      gateways.push(current);current.onEvent=onEvent;current.onDisconnect=onDisconnect;await current.connect()
+      const gateway=current
+      return {rpc:(method,params)=>{rpc.push({method,params});return gateway.rpc(method,params)},close:()=>{void gateway.close()} }
+    },
+    lease:async(input)=>{
+      const gateway=current;grants.push(input.workspaceMemory===true)
+      gateway.installTeamLease(input.workId,input.catalog(),async(name,args,id)=>input.call(name,args,id),input.workspaceMemory===true)
+      return {bind:async()=>{},dispose:async()=>gateway.removeTeamLease(input.workId)}
+    },
+  }}
+  const nodes={requireSource(){},target:()=>bridged,targetForAgent:()=>bridged} as unknown as WorkspaceNodes
+  const runtime=new WorkspaceRuntime(store,nodes,uploads)
+  try{
+    await expect.poll(async()=>{if(child.exitCode!==null)throw new Error(logs);try{return(await fetch(new URL('/api/status',url))).status}catch{return 0}},{timeout:45000}).toBe(200)
+    runtime.knowledge.writeMemory(owner,{requestId:randomUUID(),scope:'agent',agentId:agent.id,content:botMemory,tier:'profile'})
+    const conversation=store.list<any>(owner,'conversation').find(c=>c.memberIds[0]===agent.id)!
+    for(const content of ['验证首次发言','验证恢复会话']){
+      const run=runtime.send(owner,conversation.id,{requestId:randomUUID(),content})
+      await expect.poll(()=>store.require<any>(owner,'run',run.id).status,{timeout:45000}).toMatch(/^(complete|failed|interrupted)$/)
+      const saved=store.require<any>(owner,'run',run.id);expect(saved.status,saved.error+'\n'+logs).toBe('complete')
+      expect(store.list<any>(owner,'message').find(m=>m.runId===run.id&&m.role==='assistant')?.content).toBe('BOT_MEMORY_BRIDGE_OK')
+    }
+    expect(grants).toEqual([true,true])
+    expect(rpc.filter(r=>r.method==='session.create')).toHaveLength(1)
+    expect(rpc.filter(r=>r.method==='session.resume')).toHaveLength(1)
+    for(const row of rpc.filter(r=>r.method==='session.create')){
+      expect(row.params).not.toHaveProperty('skip_memory');expect(row.params).not.toHaveProperty('workspace_memory')
+      expect(row.params).toMatchObject({hidden:true,room_plumbing:true,source:'yaoyao_workspace'})
+    }
+    const sent=JSON.stringify(modelCalls)
+    expect(sent).toContain(botMemory);expect(sent).not.toContain(profileMemory);expect(sent).not.toContain('PROFILE_PRIVATE_USER_MUST_NOT_REACH_BOT')
+    expect(modelCalls.flatMap(c=>c.tools??[]).some(t=>t.function?.name==='memory')).toBe(false)
+    expect(await readFile(join(home,'memories','MEMORY.md'),'utf8')).toBe(profileMemory)
+    expect(JSON.parse(await readFile(join(home,'config.yaml'),'utf8')).memory).toEqual(config.memory)
+  }finally{
+    runtime.close();await Promise.all(gateways.map(g=>g.close().catch(()=>{})));await computer.controls.close();await computer.pool.close()
+    db.close();uploads.close();store.close();client.close()
+    child.kill('SIGTERM');await new Promise<void>(done=>{if(child.exitCode!==null){done();return}const timer=setTimeout(()=>child.kill('SIGKILL'),5000);child.once('exit',()=>{clearTimeout(timer);done()})})
+    await new Promise<void>(done=>{model.close(()=>done());model.closeAllConnections()});await rm(home,{recursive:true,force:true})
+  }
+},120000)

+ 14 - 1
tests/server/profileComputer.test.ts

@@ -23,7 +23,7 @@ async function fixture(hermesManaged=false,bridgeVersion=2){
     if(req.url?.endsWith('/bind'))bindings.push(JSON.parse(body))
     if(req.url?.endsWith('/computer-file')){res.setHeader('Content-Type','application/json');const data=JSON.parse(body);res.end(JSON.stringify(data.action==='read'?{ok:true,data:Buffer.from('hermes-authorized-file').toString('base64')}:{ok:true}));return}
     res.setHeader('Content-Type','application/json')
-    res.end(JSON.stringify(req.url==='/api/auth/ws-ticket'?{ticket:'test-ticket'}:req.url?.split('?')[0]?.endsWith('/capabilities')?{version:1,ready:true,in_process:true,native_tools:true,computer_runtime_version:bridgeVersion}:{ok:true,native_tools:true,computer_runtime_version:bridgeVersion}))
+    res.end(JSON.stringify(req.url==='/api/auth/ws-ticket'?{ticket:'test-ticket'}:req.url?.split('?')[0]?.endsWith('/capabilities')?{version:1,ready:true,in_process:true,native_tools:true,computer_runtime_version:bridgeVersion}:{ok:true,native_tools:true,computer_runtime_version:bridgeVersion,workspace_memory:body?JSON.parse(body).workspace_memory===true:false}))
   })
   await new Promise<void>(resolve=>server.listen(0,'127.0.0.1',resolve))
   const url=new URL(`http://127.0.0.1:${(server.address() as {port:number}).port}`)
@@ -194,3 +194,16 @@ it('rejects an old Hermes plugin before prompt submission and never falls back t
     expect(f.calls.some(c=>c.method==='prompt.submit')).toBe(false)
   }finally{await f.close()}
 })
+
+
+it('sends workspace memory policy through the private bridge instead of session.create',async()=>{
+  const f=await fixture(true)
+  try{
+    const g=f.gateway();await g.connect();const session=await g.rpc('session.create',{profile:'default',hidden:true,room_plumbing:true})
+    g.installTeamLease('memory',[],async()=>({}),true)
+    await g.rpc('prompt.submit',{session_id:session.session_id,text:'Use scoped memory'})
+    expect(f.calls.find(call=>call.method==='session.create')?.params).not.toHaveProperty('skip_memory')
+    expect(f.calls.find(call=>call.method==='session.create')?.params).not.toHaveProperty('workspace_memory')
+    expect(f.bindings.at(-1)).toMatchObject({workspace_memory:true})
+  }finally{await f.close()}
+})

+ 10 - 1
tests/server/runner.test.ts

@@ -38,7 +38,7 @@ beforeEach(async()=>{
     let body='';for await(const part of req)body+=part
     if(req.url?.endsWith('/bind'))bindings.push(JSON.parse(body))
     res.setHeader('Content-Type','application/json')
-    res.end(JSON.stringify(req.url==='/api/auth/ws-ticket'?{ticket:'fixture'}:req.url==='/api/profiles'?{profiles:['default',{name:'writer'},{name:'secret'}]}:req.url?.endsWith('/capabilities')?{version:1,ready:true,in_process:true,native_tools:true}:{ok:true,native_tools:true,terminal:{cwd:'/fixture/workspace'}}))
+    res.end(JSON.stringify(req.url==='/api/auth/ws-ticket'?{ticket:'fixture'}:req.url==='/api/profiles'?{profiles:['default',{name:'writer'},{name:'secret'}]}:req.url?.endsWith('/capabilities')?{version:1,ready:true,in_process:true,native_tools:true}:{ok:true,native_tools:true,workspace_memory:body?JSON.parse(body).workspace_memory===true:false,terminal:{cwd:'/fixture/workspace'}}))
   })
   const hermesURL=await listen(hermes)
   ws=new WebSocketServer({server:hermes})
@@ -284,3 +284,12 @@ it('keeps only stop operations available on an existing channel after task permi
   expect(await gateway.rpc('session.interrupt',{session_id:session.session_id})).toEqual({ok:true})
   expect(rpc.filter(call=>call.method==='prompt.submit')).toHaveLength(0)
 })
+
+
+it('carries workspace memory policy through the Runner to the authenticated Hermes binding',async()=>{
+  const {session}=await channel(),abort=new AbortController()
+  const lease=await createWorkspaceToolLease({target,profile:'default',workId:'memory-work',workspaceMemory:true,
+    session:()=>({runtimeId:session.session_id,storedId:session.stored_session_id}),signal:abort.signal,
+    assertActive:()=>{},catalog:()=>[],call:async()=>({}),onFailure:()=>{}})
+  try{await lease.bind();expect(bindings.at(-1)).toMatchObject({workspace_memory:true})}finally{await lease.dispose()}
+})

+ 16 - 0
tests/server/workspace.test.ts

@@ -1498,3 +1498,19 @@ it('migrates an old VM conversation into Hermes once, carries prior messages, an
   expect(requests.filter(r=>r.method==='session.resume')[0].params.session_id).toBe(migrated.storedId)
   expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(3)
 })
+
+
+it('keeps memory flags out of session.create and grants scoped memory through the bridge',async()=>{
+  const a=agent('独立记忆'),c=direct(a.id),target=nodes.target(owner,'local'),request=target.session.request.bind(target.session),bindings:any[]=[]
+  vi.spyOn(target.session,'request').mockImplementation(async(path,options)=>{
+    if(path.endsWith('/capabilities'))return {status:200,headers:new Headers(),body:Buffer.from(JSON.stringify({ready:true,native_tools:true,in_process:true,memory_isolation:true}))}
+    if(path.endsWith('/bind')){bindings.push(options?.body);return {status:200,headers:new Headers(),body:Buffer.from(JSON.stringify({ok:true,native_tools:true,workspace_memory:true}))}}
+    if(path.endsWith('/unbind'))return {status:200,headers:new Headers(),body:Buffer.from('{"ok":true}')}
+    return request(path,options)
+  })
+  await finished(runtime.send(owner,c.id,{requestId:randomUUID(),content:'验证完整创建链路'}).id)
+  const create=requests.find(r=>r.method==='session.create')!
+  expect(create.params).not.toHaveProperty('skip_memory')
+  expect(create.params).not.toHaveProperty('workspace_memory')
+  expect(bindings).toContainEqual(expect.objectContaining({workspace_memory:true}))
+})