Sfoglia il codice sorgente

关闭隔离 Bot 的自动上下文文件加载

Samien 1 mese fa
parent
commit
d65d58f68b

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

@@ -1,6 +1,6 @@
 # 夭夭 Bot 的 Hermes 工具桥
 
-这是运行在 Hermes 内的用户插件,版本 1.2.1。它连接 Yaoyao 每轮授权的电脑、团队和应用工具,不修改 Hermes 核心。
+这是运行在 Hermes 内的用户插件,版本 1.2.2。它连接 Yaoyao 每轮授权的电脑、团队和应用工具,不修改 Hermes 核心。
 
 ## Hermes 托管虚拟机会话
 
@@ -9,6 +9,8 @@ Yaoyao 的虚拟机模式和本机协作模式都使用真实 Hermes Profile 会
 - `computer_runtime_version: 2` 表示插件支持托管会话及权限策略。
 - `computer_policy: {mode: "isolated" | "profile", hostAccess: boolean}` 由可信 Runner 在绑定时发送,模型不能修改。同一轮续租不能变更策略。
 - 隔离模式默认使用虚拟机工具。本机终端、文件、浏览器、代码执行及本机文件传输通过 Hermes 的 `pre_tool_call` 审批机制。Profile 原有的禁用项继续有效。
+- 隔离模式在每次绑定时关闭 SOUL.md、AGENTS.md、CLAUDE.md、.cursorrules 等上下文文件的自动加载,并清除系统提示词缓存;恢复会话和子代理继承同一策略。本机协作模式及普通 Hermes 会话保持原行为。
+- 该策略保留 Profile 的模型、认证、技能加载和工具能力,以及 Bot 自己的设定、记忆和 Session 历史;不删除或改写 Profile 文件。显式读取文件仍遵循 Hermes 的工具权限,旧历史中已经出现的内容不会自动删除。
 - Skill 发现、读取、写入审核和学习由 Hermes 原生工具处理。依赖授权资源的步骤在 Hermes 完成;普通脚本和输入文件可显式传给 Linux 虚拟机。
 - 停止、过期、换 Agent 或解绑后,旧电脑会话的原生工具与桥接工具均拒绝继续调用;普通 Profile 会话不受影响。
 
@@ -59,3 +61,5 @@ HTTP 使用 Hermes 自身认证。绑定身份必须与 WebSocket 的真实身
 CLI 增加 `--check` 可只读检查默认及命名 Profile;`--enable` 明确启用所选插件及工具集,仅移除这个工具桥的显式禁用项。默认安装行为继续保留禁用设置。
 
 1.2.1 将 Bot 记忆隔离放在受认证的 `/bind` 授权中(`workspace_memory: true`),在首个模型请求前关闭该会话的 Profile 记忆注入、原生记忆工具及后台复盘。不会向 Hermes 的严格 `session.create` 接口发送自定义记忆字段,也不修改 Profile 配置。安装后需重启 Dashboard 服务(默认 9119),消息网关的重启不负责重载这份插件。
+
+1.2.2 对 `computer_policy.mode: isolated` 的会话应用 Hermes 现有的 `skip_context_files=True`、`load_soul_identity=False`,关闭自动上下文文件加载。策略只在工具桥内部应用,不增加 `session.create` 参数;`isolated_context_files: true` 表示已加载的插件支持此行为。

+ 22 - 2
integrations/hermes-bots-bridge/bridge_runtime.py

@@ -26,7 +26,7 @@ from pathlib import Path
 from typing import Any
 
 VERSION = 1
-PLUGIN_VERSION = "1.2.1"
+PLUGIN_VERSION = "1.2.2"
 def _plugin_fingerprint():
     root = Path(__file__).parent
     digest = hashlib.sha256()
@@ -92,6 +92,7 @@ def register_context(ctx):
 def _refresh_agent_tools(agent, binding):
     if binding.workspace_memory:
         _isolate_profile_memory(agent, binding)
+    _isolate_profile_context(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)
@@ -335,7 +336,8 @@ def capabilities(profile: str = "default") -> dict:
                 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": PLUGIN_VERSION, "plugin_fingerprint": PLUGIN_FINGERPRINT,
-                    "memory_isolation": True, "memory_isolation_transport": "bridge-bind-v1", "memory_extraction": True}
+                    "memory_isolation": True, "memory_isolation_transport": "bridge-bind-v1", "memory_extraction": True,
+                    "isolated_context_files": True}
         finally:
             reset_hermes_home_override(context)
     except Exception as exc:
@@ -518,6 +520,7 @@ def bind(owner: tuple[str, str], body: dict, *, wait_seconds: float = 20) -> dic
         if workspace_memory:
             _isolate_profile_memory(agent, binding)
             session["_yaoyao_workspace_memory"] = True
+        _isolate_profile_context(agent, binding)
         if policy:
             # Private runtime state survives unbind so a stale tool call cannot
             # fall back to the unrestricted native Profile after revocation.
@@ -757,6 +760,23 @@ 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_context(agent, binding):
+    """Disable automatic host instruction files only for isolated Bot sessions.
+
+    Apply on every bind/rebuild, including inherited children, before the model
+    runs. Skills have their own loader; history and Profile config stay intact.
+    """
+    if (binding.computer_policy or {}).get("mode") != "isolated":
+        return
+    invalidate = getattr(agent, "_invalidate_system_prompt", None)
+    if not all(hasattr(agent, name) for name in ("skip_context_files", "load_soul_identity")) or not callable(invalidate):
+        raise BridgeError("当前 Hermes 不支持工具桥的会话上下文隔离", 409, "context_isolation_unavailable")
+    agent.skip_context_files = True
+    # This override loads SOUL even when skip_context_files is true.
+    agent.load_soul_identity = False
+    invalidate()
+
+
 def _isolate_profile_memory(agent, binding):
     """Apply the existing agent controls before any prompt, without editing Profile config.
 

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

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

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

@@ -122,6 +122,59 @@ class ComputerPolicyTests(unittest.TestCase):
             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={}))
 
+    def context_agent(self, session_id='stored'):
+        agent = SimpleNamespace(session_id=session_id, enabled_toolsets=['skills'], disabled_toolsets=[],
+                                skip_context_files=False, load_soul_identity=True,
+                                _cached_system_prompt='previous-profile-context')
+        agent._invalidate_system_prompt = lambda: setattr(agent, '_cached_system_prompt', None)
+        return agent
+
+    def test_isolated_bind_and_resumed_agent_clear_context_cache_without_changing_history(self):
+        self.session.update(running=False, history=[{'role': 'user', 'content': 'keep-history'}])
+        original = self.context_agent('ordinary-session')
+        body = {'session_id': 'runtime', 'stored_session_id': 'stored', 'profile': 'default',
+                'bridge_url': self.binding.bridge_url, 'token': self.binding.token,
+                'expires_at': time.time() * 1000 + 60000, 'computer_policy': self.binding.computer_policy}
+        with patch.object(bridge, '_agent_has_tools', return_value=True):
+            for generation in ['first-turn', 'resumed-turn']:
+                agent = self.context_agent()
+                self.session['agent'] = agent
+                bridge.bind(self.binding.owner, {**body, 'generation': generation})
+                self.assertTrue(agent.skip_context_files)
+                self.assertFalse(agent.load_soul_identity)
+                self.assertIsNone(agent._cached_system_prompt)
+                self.assertEqual(agent.enabled_toolsets, ['skills'])
+                self.assertEqual(self.session['history'], [{'role': 'user', 'content': 'keep-history'}])
+                bridge.unbind(self.binding.owner, {'session_id': 'runtime', 'generation': generation})
+                self.assertTrue(agent.skip_context_files)
+        self.assertFalse(original.skip_context_files)
+        self.assertTrue(original.load_soul_identity)
+
+    def test_isolated_child_inherits_context_policy_and_keeps_skill_tools(self):
+        child = self.context_agent('child-session')
+        self.agent._active_children = [child]
+        tools = [{'function': {'name': name}} for name in ['skills_list', 'skill_view', 'skill_manage']]
+        model_tools = SimpleNamespace(get_tool_definitions=lambda **kw: tools, get_toolset_for_tool=lambda name: 'skills')
+        with patch.dict(sys.modules, {'model_tools': model_tools}):
+            bridge.inherit_child(parent_session_id='stored', child_session_id='child-session')
+        self.assertTrue(child.skip_context_files)
+        self.assertFalse(child.load_soul_identity)
+        self.assertIsNone(child._cached_system_prompt)
+        self.assertEqual(child.valid_tool_names, {'skills_list', 'skill_view', 'skill_manage'})
+
+    def test_context_isolation_preserves_profile_mode_and_requires_supported_agent(self):
+        for policy in [None, {'mode': 'profile', 'hostAccess': True}]:
+            agent = self.context_agent()
+            self.binding.computer_policy = policy
+            bridge._isolate_profile_context(agent, self.binding)
+            self.assertFalse(agent.skip_context_files)
+            self.assertTrue(agent.load_soul_identity)
+            self.assertEqual(agent._cached_system_prompt, 'previous-profile-context')
+        self.binding.computer_policy = {'mode': 'isolated', 'hostAccess': False}
+        with self.assertRaises(bridge.BridgeError) as error:
+            bridge._isolate_profile_context(SimpleNamespace(), self.binding)
+        self.assertEqual(error.exception.code, 'context_isolation_unavailable')
+
 
 if __name__ == '__main__':
     unittest.main()

+ 6 - 1
tests/live/profileComputer.live.test.ts

@@ -20,6 +20,7 @@ it.skipIf(!available).each(['profile','isolated'] as const)('runs a real Hermes
  await mkdir(workspace);await mkdir(join(home,'plugins'))
  await cp(resolve('integrations/hermes-bots-bridge'),join(home,'plugins','yaoyao-bot-bridge'),{recursive:true})
  await writeFile(join(workspace,'AGENTS.md'),'PROFILE_CONTEXT_MARKER: use only the fixture files and the assigned test virtual machine.\n')
+ await writeFile(join(home,'SOUL.md'),'PROFILE_SOUL_MARKER: a fixture identity for ordinary Profile sessions.\n')
  const skill=join(home,'skills','authorized-fixture');await mkdir(skill,{recursive:true})
  await writeFile(join(skill,'SKILL.md'),'---\nname: authorized-fixture\ndescription: Read the authorized fixture using the native Hermes environment.\n---\nUse scripts from this directory through Hermes terminal.\n')
  await writeFile(join(workspace,'authorized.txt'),'authorized-profile-file')
@@ -73,7 +74,11 @@ it.skipIf(!available).each(['profile','isolated'] as const)('runs a real Hermes
   const result=await complete;clearTimeout(completionTimer)
   expect(result.text,logs).toBe('本机与虚拟机协作完成');expect(step,JSON.stringify(requests.map(r=>r.tools?.map((t:any)=>t.function.name)))).toBe(6)
   expect(JSON.stringify(results)).toContain('native-skill-authorized-proof');expect(JSON.stringify(results)).toContain('vm-profile-proof');expect(JSON.stringify(results)).toContain('Linux')
-  expect(JSON.stringify(requests.filter(r=>r.tools?.length).map(r=>r.messages))).toContain('PROFILE_CONTEXT_MARKER')
+  const modelMessages=JSON.stringify(requests.filter(r=>r.tools?.length).map(r=>r.messages))
+  for(const marker of ['PROFILE_CONTEXT_MARKER','PROFILE_SOUL_MARKER']){
+    if(mode==='profile')expect(modelMessages).toContain(marker)
+    else expect(modelMessages).not.toContain(marker)
+  }
   expect(JSON.stringify(requests.filter(r=>r.tools?.length).map(r=>r.messages))).toContain(image)
   expect(JSON.stringify(results)).toContain('authorized-profile-file')
   expect(await readFile(join(workspace,'result.txt'),'utf8')).toBe('authorized-profile-filevm-profile-proof')

+ 11 - 1
tests/live/workspaceMemoryBridge.live.test.ts

@@ -16,12 +16,14 @@ 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()=>{
+it.skipIf(!available)('runs the production WorkspaceRuntime create, bind, prompt and resume chain with isolated memory and context files 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')
+  const contextFiles={'SOUL.md':'PROFILE_SOUL_MUST_NOT_REACH_BOT','AGENTS.md':'HOST_AGENTS_MUST_NOT_REACH_BOT','CLAUDE.md':'HOST_CLAUDE_MUST_NOT_REACH_BOT','.cursorrules':'HOST_CURSOR_RULES_MUST_NOT_REACH_BOT'}
+  for(const [file,marker] of Object.entries(contextFiles))await writeFile(join(home,file),marker)
   await writeFile(join(home,'.env'),'')
   await cp(resolve('integrations/hermes-bots-bridge'),join(home,'plugins','yaoyao-bot-bridge'),{recursive:true})
   const modelCalls:any[]=[]
@@ -83,6 +85,14 @@ it.skipIf(!available)('runs the production WorkspaceRuntime create, bind, prompt
     }
     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')
+    for(const [file,marker] of Object.entries(contextFiles)){
+      expect(sent).not.toContain(marker)
+      expect(await readFile(join(home,file),'utf8')).toBe(marker)
+    }
+    const resumed=modelCalls.find(c=>JSON.stringify(c.messages).includes('验证恢复会话'))
+    expect(JSON.stringify(resumed?.messages)).toContain('验证首次发言')
+    expect(JSON.stringify(resumed?.messages)).toContain('BOT_MEMORY_BRIDGE_OK')
+    expect(sent).toContain('production-pipeline-fixture')
     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)