test_computer_policy.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. import importlib.util
  2. import json
  3. import sys
  4. import tempfile
  5. import threading
  6. import time
  7. import unittest
  8. from contextvars import ContextVar
  9. from pathlib import Path
  10. from types import SimpleNamespace
  11. from unittest.mock import patch
  12. spec = importlib.util.spec_from_file_location('test_yaoyao_bridge', Path(__file__).parents[1] / 'bridge_runtime.py')
  13. bridge = importlib.util.module_from_spec(spec)
  14. sys.modules[spec.name] = bridge
  15. spec.loader.exec_module(bridge)
  16. class ReviewAgent(SimpleNamespace):
  17. def _spawn_background_review_now(self, messages_snapshot, **kwargs):
  18. self.review_calls.append((messages_snapshot, kwargs, bridge._skill_review_scope.get()))
  19. class ComputerPolicyTests(unittest.TestCase):
  20. def setUp(self):
  21. self.temp = tempfile.TemporaryDirectory()
  22. self.home = Path(self.temp.name)
  23. self.profile_scope = ContextVar('fixture_home', default=self.home)
  24. self.agent = ReviewAgent(session_id='stored', enabled_toolsets=['all'], review_calls=[],
  25. skip_context_files=False, load_soul_identity=False,
  26. _background_review_agent=None, _background_review_run=None)
  27. self.session = {'agent': self.agent, 'session_key': 'stored', 'running': True, 'cwd': str(self.home),
  28. '_yaoyao_computer_policy': {'mode': 'isolated', 'hostAccess': False}}
  29. self.server = SimpleNamespace(_sessions={'runtime': self.session}, _sessions_lock=threading.RLock())
  30. self.binding = bridge.Binding('runtime', 'stored', 'default', self.home, 'generation',
  31. 'http://127.0.0.1:12345', 'a' * 40, time.time() * 1000 + 60000,
  32. ('user', 'password'), self.session, self.agent,
  33. computer_policy=dict(self.session['_yaoyao_computer_policy']))
  34. bridge._bindings.clear(); bridge._generations.clear(); bridge._children.clear()
  35. bridge._bindings['runtime'] = self.binding
  36. self.patches = [patch.dict(sys.modules, {
  37. 'agent.redact': SimpleNamespace(redact_sensitive_text=lambda text, **kw: text),
  38. 'hermes_constants': SimpleNamespace(get_hermes_home=self.profile_scope.get,
  39. set_hermes_home_override=self.profile_scope.set, reset_hermes_home_override=self.profile_scope.reset),
  40. }),
  41. patch.object(bridge, '_server', return_value=self.server),
  42. patch.object(bridge, '_check_session_mode'),
  43. patch.object(bridge, '_transport_identity', return_value=self.binding.owner),
  44. patch.object(bridge, '_profile_home', return_value=self.home)]
  45. for p in self.patches: p.start()
  46. def tearDown(self):
  47. for p in reversed(self.patches): p.stop()
  48. self.temp.cleanup()
  49. def test_native_catalog_uses_binding_profile_and_restores_request_context(self):
  50. current = ContextVar('fixture_profile', default=self.home)
  51. constants = SimpleNamespace(set_hermes_home_override=current.set, reset_hermes_home_override=current.reset)
  52. named = self.home / 'profiles' / 'yaoer'
  53. self.binding.profile_home = named
  54. self.binding.computer_policy = None
  55. self.binding.toolset = 'yaoyao_turn_current'
  56. other_turn = 'yaoyao_turn_other'
  57. scopes = []
  58. def definitions(enabled_toolsets, **kwargs):
  59. scopes.append(current.get())
  60. names = ['named_skill', self.binding.toolset, other_turn] if current.get() == named else ['default_skill']
  61. return [{'function': {'name': name}} for name in names
  62. if enabled_toolsets == ['all'] or name in enabled_toolsets]
  63. model_tools = SimpleNamespace(get_tool_definitions=definitions, get_toolset_for_tool=lambda name: name)
  64. with patch.dict(sys.modules, {'hermes_constants': constants, 'model_tools': model_tools}):
  65. bridge._refresh_agent_tools(self.agent, self.binding)
  66. self.assertEqual(scopes, [named, named])
  67. self.assertEqual(self.agent.valid_tool_names, {'named_skill', self.binding.toolset})
  68. self.assertNotIn(other_turn, self.agent.enabled_toolsets)
  69. self.assertEqual(current.get(), self.home)
  70. def test_catalog_failure_restores_profile_context(self):
  71. current = ContextVar('fixture_profile_failure', default=self.home)
  72. constants = SimpleNamespace(set_hermes_home_override=current.set, reset_hermes_home_override=current.reset)
  73. self.binding.profile_home = self.home / 'profiles' / 'yaoer'
  74. self.binding.computer_policy = None
  75. def fail(**kwargs):
  76. self.assertEqual(current.get(), self.binding.profile_home)
  77. raise RuntimeError('fixture catalog failed')
  78. model_tools = SimpleNamespace(get_tool_definitions=fail, get_toolset_for_tool=lambda name: name)
  79. with patch.dict(sys.modules, {'hermes_constants': constants, 'model_tools': model_tools}):
  80. with self.assertRaisesRegex(RuntimeError, 'fixture catalog failed'):
  81. bridge._refresh_agent_tools(self.agent, self.binding)
  82. self.assertEqual(current.get(), self.home)
  83. def test_isolated_skills_and_vm_tools_use_native_catalog_but_host_operations_require_approval(self):
  84. for name in ['skills_list', 'skill_view', 'vision_analyze', 'yaoyao_computer_shell_aabbcc']:
  85. self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name=name, args={}))
  86. for name in ['terminal', 'process_manage', 'read_file', 'write_file', 'execute_code', 'delegate_task', 'session_search', 'browser_navigate', 'yaoyao_computer_copy_file_aabbcc']:
  87. result = bridge.computer_directive(session_id='stored', tool_name=name, args={'path': '/authorized.txt'})
  88. self.assertEqual(result['action'], 'approve')
  89. self.assertIn('/authorized.txt', result['message'])
  90. self.binding.computer_policy['hostAccess'] = True
  91. self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name='terminal', args={}))
  92. self.binding.computer_policy.update(mode='profile', hostAccess=False)
  93. self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name='terminal', args={}))
  94. def test_stopped_expired_unbound_and_replaced_sessions_cannot_call_native_tools(self):
  95. for change in ['stop', 'expire', 'unbind', 'replace']:
  96. with self.subTest(change=change):
  97. self.session.update(running=True, agent=self.agent)
  98. self.binding.expires_at = time.time() * 1000 + 60000
  99. bridge._bindings['runtime'] = self.binding
  100. if change == 'stop': self.session['running'] = False
  101. if change == 'expire': self.binding.expires_at = 0
  102. if change == 'unbind': bridge._bindings.clear()
  103. if change == 'replace': self.session['agent'] = SimpleNamespace(session_id='stored')
  104. self.assertEqual(bridge.computer_directive(session_id='runtime', tool_name='skill_view', args={})['action'], 'block')
  105. def test_ordinary_profile_sessions_are_unaffected(self):
  106. self.session.pop('_yaoyao_computer_policy')
  107. self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name='terminal', args={}))
  108. def test_policy_cannot_be_changed_during_renewal(self):
  109. self.session['running'] = False
  110. body = {'session_id': 'runtime', 'stored_session_id': 'stored', 'profile': 'default',
  111. 'generation': 'generation', 'bridge_url': self.binding.bridge_url, 'token': self.binding.token,
  112. 'expires_at': time.time() * 1000 + 60000, 'computer_policy': {'mode': 'profile', 'hostAccess': True}}
  113. with patch.object(bridge, '_agent_has_tools', return_value=True):
  114. with self.assertRaises(bridge.BridgeError) as error:
  115. bridge.bind(self.binding.owner, body)
  116. self.assertEqual(error.exception.code, 'generation_conflict')
  117. def test_transfer_is_owned_by_hermes_and_checks_generation_owner_and_protected_paths(self):
  118. constants = SimpleNamespace(set_hermes_home_override=lambda p: None, reset_hermes_home_override=lambda p: None)
  119. file_tools = SimpleNamespace(_check_sensitive_path=lambda p, t: 'denied' if p.endswith('protected') else None,
  120. _check_protected_instruction_write=lambda p, t: None,
  121. _check_approval_required_write=lambda p, t: None)
  122. safety = SimpleNamespace(get_read_block_error=lambda p: 'denied' if p.endswith('protected') else None)
  123. modules = {'hermes_constants': constants, 'tools.file_tools': file_tools, 'agent.file_safety': safety}
  124. (self.home / 'authorized').write_bytes(b'authorized-input')
  125. body = {'session_id': 'runtime', 'generation': 'generation', 'action': 'read', 'path': 'authorized'}
  126. with patch.dict(sys.modules, modules):
  127. self.assertEqual(bridge.computer_file(self.binding.owner, body)['data'], 'YXV0aG9yaXplZC1pbnB1dA==')
  128. for owner, changed in [(('other', 'password'), body), (self.binding.owner, {**body, 'generation': 'stale'}),
  129. (self.binding.owner, {**body, 'path': 'protected'})]:
  130. with self.assertRaises(bridge.BridgeError): bridge.computer_file(owner, changed)
  131. bridge.computer_file(self.binding.owner, {**body, 'action': 'write', 'path': 'result', 'data': 'cmVzdWx0'})
  132. self.assertEqual((self.home / 'result').read_bytes(), b'result')
  133. self.session['running'] = False
  134. with self.assertRaises(bridge.BridgeError): bridge.computer_file(self.binding.owner, body)
  135. def test_workspace_memory_uses_agent_controls_without_mutating_another_agent(self):
  136. stopped = []
  137. other = SimpleNamespace(_memory_enabled=True)
  138. self.agent._memory_store = object()
  139. self.agent._memory_manager = SimpleNamespace(get_all_tool_names=lambda: {'provider_remember'}, shutdown_all=lambda: stopped.append(True))
  140. self.agent._memory_enabled = self.agent._user_profile_enabled = True
  141. self.agent.skip_background_review = False
  142. self.agent.disabled_toolsets = ['terminal']
  143. invalidated = []
  144. self.agent._invalidate_system_prompt = lambda: invalidated.append(True)
  145. self.binding.workspace_memory = True
  146. self.session['_yaoyao_workspace_memory'] = True
  147. bridge._isolate_profile_memory(self.agent, self.binding)
  148. self.assertIsNone(self.agent._memory_store)
  149. self.assertIsNone(self.agent._memory_manager)
  150. self.assertFalse(self.agent._memory_enabled)
  151. self.assertFalse(self.agent._user_profile_enabled)
  152. self.assertFalse(self.agent.skip_background_review)
  153. self.assertEqual(self.agent.disabled_toolsets, ['terminal', 'memory'])
  154. self.assertEqual(stopped, [True]); self.assertEqual(invalidated, [True])
  155. self.assertTrue(other._memory_enabled)
  156. for tool in ('memory', 'provider_remember'):
  157. self.assertEqual(bridge.computer_directive(session_id='stored', tool_name=tool, args={})['action'], 'block')
  158. self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name='skill_view', args={}))
  159. def test_skill_review_is_installed_once_and_never_runs_memory_review(self):
  160. bridge._enable_skill_review(self.agent, self.binding)
  161. wrapped = self.agent._spawn_background_review_now
  162. bridge._enable_skill_review(self.agent, self.binding)
  163. self.assertIs(wrapped, self.agent._spawn_background_review_now)
  164. self.agent._spawn_background_review_now([], review_memory=True)
  165. self.assertEqual(self.agent.review_calls, [])
  166. self.agent._spawn_background_review_now([{'role': 'user', 'content': 'verified work'}], review_memory=True, review_skills=True)
  167. self.assertEqual(len(self.agent.review_calls), 1)
  168. _, kwargs, scope = self.agent.review_calls[0]
  169. self.assertFalse(kwargs['review_memory'])
  170. self.assertTrue(kwargs['review_skills'])
  171. self.assertIs(scope.parent, self.agent)
  172. self.assertIsNone(bridge._skill_review_scope.get())
  173. def test_review_fork_keeps_isolation_and_only_skill_permission_after_turn_unbind(self):
  174. self.agent.skip_context_files = True
  175. self.agent.load_soul_identity = False
  176. self.agent.disabled_toolsets = ['memory']
  177. scope = bridge._SkillReviewScope(self.agent, self.binding, time.monotonic() + 60)
  178. parent = bridge._SkillReviewParent(scope)
  179. parent._background_review_run = SimpleNamespace(cancel_requested=threading.Event())
  180. fork = self.context_agent()
  181. fork._memory_store = fork._memory_manager = object()
  182. fork._memory_enabled = fork._user_profile_enabled = True
  183. tools = [{'function': {'name': name}} for name in ['skills_list', 'skill_view', 'skill_manage', 'memory']]
  184. with patch.dict(sys.modules, {'model_tools': SimpleNamespace(get_tool_definitions=lambda **kw: tools)}):
  185. parent._background_review_agent = fork
  186. self.assertTrue(fork.skip_context_files)
  187. self.assertFalse(fork.load_soul_identity)
  188. self.assertTrue(fork.skip_background_review)
  189. self.assertIsNone(fork._memory_manager)
  190. self.assertIsNone(fork._memory_store)
  191. self.assertFalse(fork._memory_enabled)
  192. self.assertFalse(fork._user_profile_enabled)
  193. self.assertIsNone(fork._cached_system_prompt)
  194. self.assertEqual(fork.valid_tool_names, {'skills_list', 'skill_view', 'skill_manage'})
  195. bridge._bindings.clear()
  196. self.session['running'] = False
  197. constants = SimpleNamespace(get_hermes_home=lambda: self.home)
  198. with patch.dict(sys.modules, {'hermes_constants': constants}):
  199. token = bridge._skill_review_scope.set(scope)
  200. try:
  201. self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name='skill_manage'))
  202. for tool in ['memory', 'provider_remember', 'read_file', 'terminal', 'yaoyao_computer_shell', 'workspace_send_to_agent']:
  203. self.assertEqual(bridge.computer_directive(session_id='stored', tool_name=tool)['action'], 'block')
  204. self.assertEqual(bridge.computer_directive(session_id='other-session', tool_name='skill_manage')['action'], 'block')
  205. constants.get_hermes_home = lambda: self.home / 'other-profile'
  206. self.assertEqual(bridge.computer_directive(session_id='stored', tool_name='skill_manage')['action'], 'block')
  207. constants.get_hermes_home = lambda: self.home
  208. scope.run.cancel_requested.set()
  209. self.assertEqual(bridge.computer_directive(session_id='stored', tool_name='skill_manage')['action'], 'block')
  210. scope.run.cancel_requested.clear()
  211. parent._background_review_agent = None
  212. self.assertEqual(bridge.computer_directive(session_id='stored', tool_name='skill_manage')['action'], 'block')
  213. finally:
  214. bridge._skill_review_scope.reset(token)
  215. self.assertEqual(bridge.computer_directive(session_id='stored', tool_name='skill_manage')['action'], 'block')
  216. def context_agent(self, session_id='stored'):
  217. agent = SimpleNamespace(session_id=session_id, enabled_toolsets=['skills'], disabled_toolsets=[],
  218. skip_context_files=False, load_soul_identity=True,
  219. _cached_system_prompt='previous-profile-context')
  220. agent._invalidate_system_prompt = lambda: setattr(agent, '_cached_system_prompt', None)
  221. return agent
  222. def test_isolated_bind_and_resumed_agent_clear_context_cache_without_changing_history(self):
  223. self.session.update(running=False, history=[{'role': 'user', 'content': 'keep-history'}])
  224. original = self.context_agent('ordinary-session')
  225. body = {'session_id': 'runtime', 'stored_session_id': 'stored', 'profile': 'default',
  226. 'bridge_url': self.binding.bridge_url, 'token': self.binding.token,
  227. 'expires_at': time.time() * 1000 + 60000, 'computer_policy': self.binding.computer_policy}
  228. with patch.object(bridge, '_agent_has_tools', return_value=True):
  229. for generation in ['first-turn', 'resumed-turn']:
  230. agent = self.context_agent()
  231. self.session['agent'] = agent
  232. bridge.bind(self.binding.owner, {**body, 'generation': generation})
  233. self.assertTrue(agent.skip_context_files)
  234. self.assertFalse(agent.load_soul_identity)
  235. self.assertIsNone(agent._cached_system_prompt)
  236. self.assertEqual(agent.enabled_toolsets, ['skills'])
  237. self.assertEqual(self.session['history'], [{'role': 'user', 'content': 'keep-history'}])
  238. bridge.unbind(self.binding.owner, {'session_id': 'runtime', 'generation': generation})
  239. self.assertTrue(agent.skip_context_files)
  240. self.assertFalse(original.skip_context_files)
  241. self.assertTrue(original.load_soul_identity)
  242. def test_isolated_child_inherits_context_policy_and_keeps_skill_tools(self):
  243. child = self.context_agent('child-session')
  244. self.agent._active_children = [child]
  245. tools = [{'function': {'name': name}} for name in ['skills_list', 'skill_view', 'skill_manage']]
  246. model_tools = SimpleNamespace(get_tool_definitions=lambda **kw: tools, get_toolset_for_tool=lambda name: 'skills')
  247. with patch.dict(sys.modules, {'model_tools': model_tools}):
  248. bridge.inherit_child(parent_session_id='stored', child_session_id='child-session')
  249. self.assertTrue(child.skip_context_files)
  250. self.assertFalse(child.load_soul_identity)
  251. self.assertIsNone(child._cached_system_prompt)
  252. self.assertEqual(child.valid_tool_names, {'skills_list', 'skill_view', 'skill_manage'})
  253. def test_context_isolation_preserves_profile_mode_and_requires_supported_agent(self):
  254. for policy in [None, {'mode': 'profile', 'hostAccess': True}]:
  255. agent = self.context_agent()
  256. self.binding.computer_policy = policy
  257. bridge._isolate_profile_context(agent, self.binding)
  258. self.assertFalse(agent.skip_context_files)
  259. self.assertTrue(agent.load_soul_identity)
  260. self.assertEqual(agent._cached_system_prompt, 'previous-profile-context')
  261. self.binding.computer_policy = {'mode': 'isolated', 'hostAccess': False}
  262. with self.assertRaises(bridge.BridgeError) as error:
  263. bridge._isolate_profile_context(SimpleNamespace(), self.binding)
  264. self.assertEqual(error.exception.code, 'context_isolation_unavailable')
  265. if __name__ == '__main__':
  266. unittest.main()