test_computer_policy.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  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_lazy_binding_starts_native_initialization_without_submitting_a_prompt(self):
  50. self.session.update(agent=None, running=False, agent_error=None)
  51. started = []
  52. def start(sid, session):
  53. started.append(sid)
  54. session['agent'] = self.agent
  55. self.server._start_agent_build = start
  56. body = {'session_id': 'runtime', 'stored_session_id': 'stored', 'profile': 'default',
  57. 'generation': 'lazy-new', 'bridge_url': self.binding.bridge_url, 'token': self.binding.token,
  58. 'expires_at': time.time() * 1000 + 60000}
  59. with patch.object(bridge, '_agent_has_tools', return_value=True), patch.object(bridge, '_isolate_profile_context'):
  60. self.assertTrue(bridge.bind(self.binding.owner, body)['ok'])
  61. self.assertEqual(started, ['runtime'])
  62. def test_failed_initialization_is_reported_immediately_without_exposing_credentials(self):
  63. self.session.update(agent=None, running=False, agent_error='api-key=fixture-secret')
  64. body = {'session_id': 'runtime', 'stored_session_id': 'stored', 'profile': 'default',
  65. 'generation': 'failed-new', 'bridge_url': self.binding.bridge_url, 'token': self.binding.token,
  66. 'expires_at': time.time() * 1000 + 60000}
  67. with self.assertRaises(bridge.BridgeError) as raised:
  68. bridge.bind(self.binding.owner, body)
  69. self.assertEqual(raised.exception.code, 'agent_initialization_failed')
  70. self.assertNotIn('fixture-secret', str(raised.exception))
  71. def test_binding_deadline_includes_lock_contention(self):
  72. held, release = threading.Event(), threading.Event()
  73. def hold():
  74. with bridge._lock:
  75. held.set(); release.wait(2)
  76. worker = threading.Thread(target=hold)
  77. worker.start(); self.assertTrue(held.wait(1))
  78. try:
  79. with self.assertRaises(bridge.BridgeError) as raised:
  80. with bridge._bind_locks(self.server, time.monotonic() + 0.02):
  81. self.fail('contended lock unexpectedly acquired')
  82. self.assertEqual(raised.exception.code, 'binding_busy')
  83. finally:
  84. release.set(); worker.join(1)
  85. available = []
  86. def check_released():
  87. acquired = self.server._sessions_lock.acquire(blocking=False)
  88. available.append(acquired)
  89. if acquired:
  90. self.server._sessions_lock.release()
  91. checker = threading.Thread(target=check_released)
  92. checker.start(); checker.join(1)
  93. self.assertEqual(available, [True])
  94. def test_catalog_callback_wait_is_bounded_and_distinct_from_tool_execution(self):
  95. seen = []
  96. def opening(request, timeout):
  97. seen.append(timeout)
  98. raise TimeoutError('fixture-secret')
  99. opener = SimpleNamespace(open=opening)
  100. with patch.object(bridge.urllib.request, 'build_opener', return_value=opener):
  101. with self.assertRaises(bridge.BridgeError) as raised:
  102. bridge._request(self.binding, '/tools/list', timeout=0.5)
  103. self.assertEqual(seen, [0.5])
  104. self.assertEqual(raised.exception.code, 'bridge_catalog_unreachable')
  105. self.assertNotIn('fixture-secret', str(raised.exception))
  106. def test_native_catalog_uses_binding_profile_and_restores_request_context(self):
  107. current = ContextVar('fixture_profile', default=self.home)
  108. constants = SimpleNamespace(set_hermes_home_override=current.set, reset_hermes_home_override=current.reset)
  109. named = self.home / 'profiles' / 'yaoer'
  110. self.binding.profile_home = named
  111. self.binding.computer_policy = None
  112. self.binding.toolset = 'yaoyao_turn_current'
  113. other_turn = 'yaoyao_turn_other'
  114. scopes = []
  115. def definitions(enabled_toolsets, **kwargs):
  116. scopes.append(current.get())
  117. names = ['named_skill', self.binding.toolset, other_turn] if current.get() == named else ['default_skill']
  118. return [{'function': {'name': name}} for name in names
  119. if enabled_toolsets == ['all'] or name in enabled_toolsets]
  120. model_tools = SimpleNamespace(get_tool_definitions=definitions, get_toolset_for_tool=lambda name: name)
  121. with patch.dict(sys.modules, {'hermes_constants': constants, 'model_tools': model_tools}):
  122. bridge._refresh_agent_tools(self.agent, self.binding)
  123. self.assertEqual(scopes, [named, named])
  124. self.assertEqual(self.agent.valid_tool_names, {'named_skill', self.binding.toolset})
  125. self.assertNotIn(other_turn, self.agent.enabled_toolsets)
  126. self.assertEqual(current.get(), self.home)
  127. def test_catalog_failure_restores_profile_context(self):
  128. current = ContextVar('fixture_profile_failure', default=self.home)
  129. constants = SimpleNamespace(set_hermes_home_override=current.set, reset_hermes_home_override=current.reset)
  130. self.binding.profile_home = self.home / 'profiles' / 'yaoer'
  131. self.binding.computer_policy = None
  132. def fail(**kwargs):
  133. self.assertEqual(current.get(), self.binding.profile_home)
  134. raise RuntimeError('fixture catalog failed')
  135. model_tools = SimpleNamespace(get_tool_definitions=fail, get_toolset_for_tool=lambda name: name)
  136. with patch.dict(sys.modules, {'hermes_constants': constants, 'model_tools': model_tools}):
  137. with self.assertRaisesRegex(RuntimeError, 'fixture catalog failed'):
  138. bridge._refresh_agent_tools(self.agent, self.binding)
  139. self.assertEqual(current.get(), self.home)
  140. def test_isolated_skills_and_vm_tools_use_native_catalog_but_host_operations_require_approval(self):
  141. for name in ['skills_list', 'skill_view', 'vision_analyze', 'yaoyao_computer_shell_aabbcc']:
  142. self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name=name, args={}))
  143. for name in ['terminal', 'process_manage', 'read_file', 'write_file', 'execute_code', 'delegate_task', 'session_search', 'browser_navigate', 'yaoyao_computer_copy_file_aabbcc']:
  144. result = bridge.computer_directive(session_id='stored', tool_name=name, args={'path': '/authorized.txt'})
  145. self.assertEqual(result['action'], 'approve')
  146. self.assertIn('/authorized.txt', result['message'])
  147. self.binding.computer_policy['hostAccess'] = True
  148. self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name='terminal', args={}))
  149. self.binding.computer_policy.update(mode='profile', hostAccess=False)
  150. self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name='terminal', args={}))
  151. def test_stopped_expired_unbound_and_replaced_sessions_cannot_call_native_tools(self):
  152. for change in ['stop', 'expire', 'unbind', 'replace']:
  153. with self.subTest(change=change):
  154. self.session.update(running=True, agent=self.agent)
  155. self.binding.expires_at = time.time() * 1000 + 60000
  156. bridge._bindings['runtime'] = self.binding
  157. if change == 'stop': self.session['running'] = False
  158. if change == 'expire': self.binding.expires_at = 0
  159. if change == 'unbind': bridge._bindings.clear()
  160. if change == 'replace': self.session['agent'] = SimpleNamespace(session_id='stored')
  161. self.assertEqual(bridge.computer_directive(session_id='runtime', tool_name='skill_view', args={})['action'], 'block')
  162. def test_ordinary_profile_sessions_are_unaffected(self):
  163. self.session.pop('_yaoyao_computer_policy')
  164. self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name='terminal', args={}))
  165. def test_policy_cannot_be_changed_during_renewal(self):
  166. self.session['running'] = False
  167. body = {'session_id': 'runtime', 'stored_session_id': 'stored', 'profile': 'default',
  168. 'generation': 'generation', 'bridge_url': self.binding.bridge_url, 'token': self.binding.token,
  169. 'expires_at': time.time() * 1000 + 60000, 'computer_policy': {'mode': 'profile', 'hostAccess': True}}
  170. with patch.object(bridge, '_agent_has_tools', return_value=True):
  171. with self.assertRaises(bridge.BridgeError) as error:
  172. bridge.bind(self.binding.owner, body)
  173. self.assertEqual(error.exception.code, 'generation_conflict')
  174. def test_transfer_is_owned_by_hermes_and_checks_generation_owner_and_protected_paths(self):
  175. constants = SimpleNamespace(set_hermes_home_override=lambda p: None, reset_hermes_home_override=lambda p: None)
  176. file_tools = SimpleNamespace(_check_sensitive_path=lambda p, t: 'denied' if p.endswith('protected') else None,
  177. _check_protected_instruction_write=lambda p, t: None,
  178. _check_approval_required_write=lambda p, t: None)
  179. safety = SimpleNamespace(get_read_block_error=lambda p: 'denied' if p.endswith('protected') else None)
  180. modules = {'hermes_constants': constants, 'tools.file_tools': file_tools, 'agent.file_safety': safety}
  181. (self.home / 'authorized').write_bytes(b'authorized-input')
  182. body = {'session_id': 'runtime', 'generation': 'generation', 'action': 'read', 'path': 'authorized'}
  183. with patch.dict(sys.modules, modules):
  184. self.assertEqual(bridge.computer_file(self.binding.owner, body)['data'], 'YXV0aG9yaXplZC1pbnB1dA==')
  185. for owner, changed in [(('other', 'password'), body), (self.binding.owner, {**body, 'generation': 'stale'}),
  186. (self.binding.owner, {**body, 'path': 'protected'})]:
  187. with self.assertRaises(bridge.BridgeError): bridge.computer_file(owner, changed)
  188. bridge.computer_file(self.binding.owner, {**body, 'action': 'write', 'path': 'result', 'data': 'cmVzdWx0'})
  189. self.assertEqual((self.home / 'result').read_bytes(), b'result')
  190. self.session['running'] = False
  191. with self.assertRaises(bridge.BridgeError): bridge.computer_file(self.binding.owner, body)
  192. def test_workspace_memory_uses_agent_controls_without_mutating_another_agent(self):
  193. stopped = []
  194. other = SimpleNamespace(_memory_enabled=True)
  195. self.agent._memory_store = object()
  196. self.agent._memory_manager = SimpleNamespace(get_all_tool_names=lambda: {'provider_remember'}, shutdown_all=lambda: stopped.append(True))
  197. self.agent._memory_enabled = self.agent._user_profile_enabled = True
  198. self.agent.skip_background_review = False
  199. self.agent.disabled_toolsets = ['terminal']
  200. invalidated = []
  201. self.agent._invalidate_system_prompt = lambda: invalidated.append(True)
  202. self.binding.workspace_memory = True
  203. self.session['_yaoyao_workspace_memory'] = True
  204. bridge._isolate_profile_memory(self.agent, self.binding)
  205. self.assertIsNone(self.agent._memory_store)
  206. self.assertIsNone(self.agent._memory_manager)
  207. self.assertFalse(self.agent._memory_enabled)
  208. self.assertFalse(self.agent._user_profile_enabled)
  209. self.assertFalse(self.agent.skip_background_review)
  210. self.assertEqual(self.agent.disabled_toolsets, ['terminal', 'memory'])
  211. self.assertEqual(stopped, [True]); self.assertEqual(invalidated, [True])
  212. self.assertTrue(other._memory_enabled)
  213. for tool in ('memory', 'provider_remember'):
  214. self.assertEqual(bridge.computer_directive(session_id='stored', tool_name=tool, args={})['action'], 'block')
  215. self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name='skill_view', args={}))
  216. def test_skill_review_is_installed_once_and_never_runs_memory_review(self):
  217. bridge._enable_skill_review(self.agent, self.binding)
  218. wrapped = self.agent._spawn_background_review_now
  219. bridge._enable_skill_review(self.agent, self.binding)
  220. self.assertIs(wrapped, self.agent._spawn_background_review_now)
  221. self.agent._spawn_background_review_now([], review_memory=True)
  222. self.assertEqual(self.agent.review_calls, [])
  223. self.agent._spawn_background_review_now([{'role': 'user', 'content': 'verified work'}], review_memory=True, review_skills=True)
  224. self.assertEqual(len(self.agent.review_calls), 1)
  225. _, kwargs, scope = self.agent.review_calls[0]
  226. self.assertFalse(kwargs['review_memory'])
  227. self.assertTrue(kwargs['review_skills'])
  228. self.assertIs(scope.parent, self.agent)
  229. self.assertIsNone(bridge._skill_review_scope.get())
  230. def test_review_fork_keeps_isolation_and_only_skill_permission_after_turn_unbind(self):
  231. self.agent.skip_context_files = True
  232. self.agent.load_soul_identity = False
  233. self.agent.disabled_toolsets = ['memory']
  234. scope = bridge._SkillReviewScope(self.agent, self.binding, time.monotonic() + 60)
  235. parent = bridge._SkillReviewParent(scope)
  236. parent._background_review_run = SimpleNamespace(cancel_requested=threading.Event())
  237. fork = self.context_agent()
  238. fork._memory_store = fork._memory_manager = object()
  239. fork._memory_enabled = fork._user_profile_enabled = True
  240. tools = [{'function': {'name': name}} for name in ['skills_list', 'skill_view', 'skill_manage', 'memory']]
  241. with patch.dict(sys.modules, {'model_tools': SimpleNamespace(get_tool_definitions=lambda **kw: tools)}):
  242. parent._background_review_agent = fork
  243. self.assertTrue(fork.skip_context_files)
  244. self.assertFalse(fork.load_soul_identity)
  245. self.assertTrue(fork.skip_background_review)
  246. self.assertIsNone(fork._memory_manager)
  247. self.assertIsNone(fork._memory_store)
  248. self.assertFalse(fork._memory_enabled)
  249. self.assertFalse(fork._user_profile_enabled)
  250. self.assertIsNone(fork._cached_system_prompt)
  251. self.assertEqual(fork.valid_tool_names, {'skills_list', 'skill_view', 'skill_manage'})
  252. bridge._bindings.clear()
  253. self.session['running'] = False
  254. constants = SimpleNamespace(get_hermes_home=lambda: self.home)
  255. with patch.dict(sys.modules, {'hermes_constants': constants}):
  256. token = bridge._skill_review_scope.set(scope)
  257. try:
  258. self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name='skill_manage'))
  259. for tool in ['memory', 'provider_remember', 'read_file', 'terminal', 'yaoyao_computer_shell', 'workspace_send_to_agent']:
  260. self.assertEqual(bridge.computer_directive(session_id='stored', tool_name=tool)['action'], 'block')
  261. self.assertEqual(bridge.computer_directive(session_id='other-session', tool_name='skill_manage')['action'], 'block')
  262. constants.get_hermes_home = lambda: self.home / 'other-profile'
  263. self.assertEqual(bridge.computer_directive(session_id='stored', tool_name='skill_manage')['action'], 'block')
  264. constants.get_hermes_home = lambda: self.home
  265. scope.run.cancel_requested.set()
  266. self.assertEqual(bridge.computer_directive(session_id='stored', tool_name='skill_manage')['action'], 'block')
  267. scope.run.cancel_requested.clear()
  268. parent._background_review_agent = None
  269. self.assertEqual(bridge.computer_directive(session_id='stored', tool_name='skill_manage')['action'], 'block')
  270. finally:
  271. bridge._skill_review_scope.reset(token)
  272. self.assertEqual(bridge.computer_directive(session_id='stored', tool_name='skill_manage')['action'], 'block')
  273. def context_agent(self, session_id='stored'):
  274. agent = SimpleNamespace(session_id=session_id, enabled_toolsets=['skills'], disabled_toolsets=[],
  275. skip_context_files=False, load_soul_identity=True,
  276. _cached_system_prompt='previous-profile-context')
  277. agent._invalidate_system_prompt = lambda: setattr(agent, '_cached_system_prompt', None)
  278. return agent
  279. def test_isolated_bind_and_resumed_agent_clear_context_cache_without_changing_history(self):
  280. self.session.update(running=False, history=[{'role': 'user', 'content': 'keep-history'}])
  281. original = self.context_agent('ordinary-session')
  282. body = {'session_id': 'runtime', 'stored_session_id': 'stored', 'profile': 'default',
  283. 'bridge_url': self.binding.bridge_url, 'token': self.binding.token,
  284. 'expires_at': time.time() * 1000 + 60000, 'computer_policy': self.binding.computer_policy}
  285. with patch.object(bridge, '_agent_has_tools', return_value=True):
  286. for generation in ['first-turn', 'resumed-turn']:
  287. agent = self.context_agent()
  288. self.session['agent'] = agent
  289. bridge.bind(self.binding.owner, {**body, 'generation': generation})
  290. self.assertTrue(agent.skip_context_files)
  291. self.assertFalse(agent.load_soul_identity)
  292. self.assertIsNone(agent._cached_system_prompt)
  293. self.assertEqual(agent.enabled_toolsets, ['skills'])
  294. self.assertEqual(self.session['history'], [{'role': 'user', 'content': 'keep-history'}])
  295. bridge.unbind(self.binding.owner, {'session_id': 'runtime', 'generation': generation})
  296. self.assertTrue(agent.skip_context_files)
  297. self.assertFalse(original.skip_context_files)
  298. self.assertTrue(original.load_soul_identity)
  299. def test_isolated_child_inherits_context_policy_and_keeps_skill_tools(self):
  300. child = self.context_agent('child-session')
  301. self.agent._active_children = [child]
  302. tools = [{'function': {'name': name}} for name in ['skills_list', 'skill_view', 'skill_manage']]
  303. model_tools = SimpleNamespace(get_tool_definitions=lambda **kw: tools, get_toolset_for_tool=lambda name: 'skills')
  304. with patch.dict(sys.modules, {'model_tools': model_tools}):
  305. bridge.inherit_child(parent_session_id='stored', child_session_id='child-session')
  306. self.assertTrue(child.skip_context_files)
  307. self.assertFalse(child.load_soul_identity)
  308. self.assertIsNone(child._cached_system_prompt)
  309. self.assertEqual(child.valid_tool_names, {'skills_list', 'skill_view', 'skill_manage'})
  310. def test_context_isolation_preserves_profile_mode_and_requires_supported_agent(self):
  311. for policy in [None, {'mode': 'profile', 'hostAccess': True}]:
  312. agent = self.context_agent()
  313. self.binding.computer_policy = policy
  314. bridge._isolate_profile_context(agent, self.binding)
  315. self.assertFalse(agent.skip_context_files)
  316. self.assertTrue(agent.load_soul_identity)
  317. self.assertEqual(agent._cached_system_prompt, 'previous-profile-context')
  318. self.binding.computer_policy = {'mode': 'isolated', 'hostAccess': False}
  319. with self.assertRaises(bridge.BridgeError) as error:
  320. bridge._isolate_profile_context(SimpleNamespace(), self.binding)
  321. self.assertEqual(error.exception.code, 'context_isolation_unavailable')
  322. if __name__ == '__main__':
  323. unittest.main()