test_computer_policy.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431
  1. import importlib.util
  2. import json
  3. import sys
  4. import tempfile
  5. import threading
  6. import time
  7. import unittest
  8. import uuid
  9. from contextvars import ContextVar
  10. from pathlib import Path
  11. from types import SimpleNamespace
  12. from unittest.mock import patch
  13. spec = importlib.util.spec_from_file_location('test_yaoyao_bridge', Path(__file__).parents[1] / 'bridge_runtime.py')
  14. bridge = importlib.util.module_from_spec(spec)
  15. sys.modules[spec.name] = bridge
  16. spec.loader.exec_module(bridge)
  17. class ReviewAgent(SimpleNamespace):
  18. def _spawn_background_review_now(self, messages_snapshot, **kwargs):
  19. self.review_calls.append((messages_snapshot, kwargs, bridge._skill_review_scope.get()))
  20. class ComputerPolicyTests(unittest.TestCase):
  21. def setUp(self):
  22. self.temp = tempfile.TemporaryDirectory()
  23. self.home = Path(self.temp.name)
  24. self.profile_scope = ContextVar('fixture_home', default=self.home)
  25. self.agent = ReviewAgent(session_id='stored', enabled_toolsets=['all'], review_calls=[],
  26. skip_context_files=False, load_soul_identity=False,
  27. _background_review_agent=None, _background_review_run=None)
  28. self.session = {'agent': self.agent, 'session_key': 'stored', 'running': True, 'cwd': str(self.home),
  29. '_yaoyao_computer_policy': {'mode': 'isolated', 'hostAccess': False}}
  30. self.server = SimpleNamespace(_sessions={'runtime': self.session}, _sessions_lock=threading.RLock())
  31. self.binding = bridge.Binding('runtime', 'stored', 'default', self.home, 'generation',
  32. 'http://127.0.0.1:12345', 'a' * 40, time.time() * 1000 + 60000,
  33. ('user', 'password'), self.session, self.agent,
  34. computer_policy=dict(self.session['_yaoyao_computer_policy']))
  35. bridge._bindings.clear(); bridge._generations.clear(); bridge._children.clear()
  36. bridge._bindings['runtime'] = self.binding
  37. self.patches = [patch.dict(sys.modules, {
  38. 'agent.redact': SimpleNamespace(redact_sensitive_text=lambda text, **kw: text),
  39. 'hermes_constants': SimpleNamespace(get_hermes_home=self.profile_scope.get,
  40. set_hermes_home_override=self.profile_scope.set, reset_hermes_home_override=self.profile_scope.reset),
  41. }),
  42. patch.object(bridge, '_server', return_value=self.server),
  43. patch.object(bridge, '_check_session_mode'),
  44. patch.object(bridge, '_transport_identity', return_value=self.binding.owner),
  45. patch.object(bridge, '_profile_home', return_value=self.home)]
  46. for p in self.patches: p.start()
  47. def tearDown(self):
  48. for p in reversed(self.patches): p.stop()
  49. self.temp.cleanup()
  50. def test_lazy_binding_starts_native_initialization_without_submitting_a_prompt(self):
  51. self.session.update(agent=None, running=False, agent_error=None)
  52. started = []
  53. def start(sid, session):
  54. started.append(sid)
  55. session['agent'] = self.agent
  56. self.server._start_agent_build = start
  57. body = {'session_id': 'runtime', 'stored_session_id': 'stored', 'profile': 'default',
  58. 'generation': 'lazy-new', 'bridge_url': self.binding.bridge_url, 'token': self.binding.token,
  59. 'expires_at': time.time() * 1000 + 60000}
  60. with patch.object(bridge, '_agent_has_tools', return_value=True), patch.object(bridge, '_isolate_profile_context'):
  61. self.assertTrue(bridge.bind(self.binding.owner, body)['ok'])
  62. self.assertEqual(started, ['runtime'])
  63. def test_failed_initialization_is_reported_immediately_without_exposing_credentials(self):
  64. self.session.update(agent=None, running=False, agent_error='api-key=fixture-secret')
  65. body = {'session_id': 'runtime', 'stored_session_id': 'stored', 'profile': 'default',
  66. 'generation': 'failed-new', 'bridge_url': self.binding.bridge_url, 'token': self.binding.token,
  67. 'expires_at': time.time() * 1000 + 60000}
  68. with self.assertRaises(bridge.BridgeError) as raised:
  69. bridge.bind(self.binding.owner, body)
  70. self.assertEqual(raised.exception.code, 'agent_initialization_failed')
  71. self.assertNotIn('fixture-secret', str(raised.exception))
  72. def test_binding_deadline_includes_lock_contention(self):
  73. held, release = threading.Event(), threading.Event()
  74. def hold():
  75. with bridge._lock:
  76. held.set(); release.wait(2)
  77. worker = threading.Thread(target=hold)
  78. worker.start(); self.assertTrue(held.wait(1))
  79. try:
  80. with self.assertRaises(bridge.BridgeError) as raised:
  81. with bridge._bind_locks(self.server, time.monotonic() + 0.02):
  82. self.fail('contended lock unexpectedly acquired')
  83. self.assertEqual(raised.exception.code, 'binding_busy')
  84. finally:
  85. release.set(); worker.join(1)
  86. available = []
  87. def check_released():
  88. acquired = self.server._sessions_lock.acquire(blocking=False)
  89. available.append(acquired)
  90. if acquired:
  91. self.server._sessions_lock.release()
  92. checker = threading.Thread(target=check_released)
  93. checker.start(); checker.join(1)
  94. self.assertEqual(available, [True])
  95. def test_catalog_callback_wait_is_bounded_and_distinct_from_tool_execution(self):
  96. seen = []
  97. def opening(request, timeout):
  98. seen.append(timeout)
  99. raise TimeoutError('fixture-secret')
  100. opener = SimpleNamespace(open=opening)
  101. with patch.object(bridge.urllib.request, 'build_opener', return_value=opener):
  102. with self.assertRaises(bridge.BridgeError) as raised:
  103. bridge._request(self.binding, '/tools/list', timeout=0.5)
  104. self.assertEqual(seen, [0.5])
  105. self.assertEqual(raised.exception.code, 'bridge_catalog_unreachable')
  106. self.assertNotIn('fixture-secret', str(raised.exception))
  107. def test_native_catalog_uses_binding_profile_and_restores_request_context(self):
  108. current = ContextVar('fixture_profile', default=self.home)
  109. constants = SimpleNamespace(set_hermes_home_override=current.set, reset_hermes_home_override=current.reset)
  110. named = self.home / 'profiles' / 'yaoer'
  111. self.binding.profile_home = named
  112. self.binding.computer_policy = None
  113. self.binding.toolset = 'yaoyao_turn_current'
  114. other_turn = 'yaoyao_turn_other'
  115. scopes = []
  116. def definitions(enabled_toolsets, **kwargs):
  117. scopes.append(current.get())
  118. names = ['named_skill', self.binding.toolset, other_turn] if current.get() == named else ['default_skill']
  119. return [{'function': {'name': name}} for name in names
  120. if enabled_toolsets == ['all'] or name in enabled_toolsets]
  121. model_tools = SimpleNamespace(get_tool_definitions=definitions, get_toolset_for_tool=lambda name: name)
  122. with patch.dict(sys.modules, {'hermes_constants': constants, 'model_tools': model_tools}):
  123. bridge._refresh_agent_tools(self.agent, self.binding)
  124. self.assertEqual(scopes, [named, named])
  125. self.assertEqual(self.agent.valid_tool_names, {'named_skill', self.binding.toolset})
  126. self.assertNotIn(other_turn, self.agent.enabled_toolsets)
  127. self.assertEqual(current.get(), self.home)
  128. def test_catalog_failure_restores_profile_context(self):
  129. current = ContextVar('fixture_profile_failure', default=self.home)
  130. constants = SimpleNamespace(set_hermes_home_override=current.set, reset_hermes_home_override=current.reset)
  131. self.binding.profile_home = self.home / 'profiles' / 'yaoer'
  132. self.binding.computer_policy = None
  133. def fail(**kwargs):
  134. self.assertEqual(current.get(), self.binding.profile_home)
  135. raise RuntimeError('fixture catalog failed')
  136. model_tools = SimpleNamespace(get_tool_definitions=fail, get_toolset_for_tool=lambda name: name)
  137. with patch.dict(sys.modules, {'hermes_constants': constants, 'model_tools': model_tools}):
  138. with self.assertRaisesRegex(RuntimeError, 'fixture catalog failed'):
  139. bridge._refresh_agent_tools(self.agent, self.binding)
  140. self.assertEqual(current.get(), self.home)
  141. def test_filtered_catalog_returns_current_ids_and_schemas_in_one_call(self):
  142. tools = [
  143. {'id': f'team_{i}', 'name': f'plugin_{i}', 'description': f'Gmail · list_{i}:读取邮件',
  144. 'inputSchema': {'type': 'object', 'properties': {'limit': {'type': 'integer'}}}}
  145. for i in range(3)
  146. ] + [{'id': 'other', 'name': 'plugin_other', 'description': 'Vault · status:状态', 'inputSchema': {}}]
  147. with patch.object(bridge, '_request', return_value={'tools': tools, 'warnings': []}) as request:
  148. result = json.loads(bridge.list_tools({'service': 'gmail', 'query': 'list', 'limit': 2}, session_id='stored'))
  149. self.assertEqual(result['tools'], tools[:2])
  150. self.assertEqual(result['total'], 3)
  151. self.assertEqual(result['nextOffset'], 2)
  152. request.assert_called_once_with(self.binding, '/tools/list')
  153. page = json.loads(bridge.list_tools({'service': 'gmail', 'offset': 2, 'limit': 2}, session_id='stored'))
  154. self.assertEqual(page['tools'], tools[2:3])
  155. self.assertIsNone(page['nextOffset'])
  156. self.assertEqual(json.loads(bridge.list_tools({}, session_id='stored'))['tools'], tools)
  157. self.assertEqual(json.loads(bridge.list_tools({'service': 'missing'}, session_id='stored'))['tools'], [])
  158. def test_catalog_filter_cannot_override_identity_or_release_a_revoked_catalog(self):
  159. with patch.object(bridge, '_request') as request:
  160. for args in [{'session_id': 'other'}, {'limit': True}, {'offset': -1}, {'query': []}, {'limit': 1000}]:
  161. self.assertEqual(json.loads(bridge.list_tools(args, session_id='stored'))['code'], 'invalid_request')
  162. request.assert_not_called()
  163. with patch.object(bridge, '_resolve', side_effect=[self.binding, bridge.BridgeError('expired')]), \
  164. patch.object(bridge, '_request', return_value={'tools': [{'id': 'private'}]}):
  165. result = json.loads(bridge.list_tools({'query': 'private'}, session_id='stored'))
  166. self.assertNotIn('tools', result)
  167. def test_task_bound_kanban_tools_need_a_real_task_id_but_listing_remains_available(self):
  168. with patch.dict('os.environ', {}, clear=True):
  169. for name in ['kanban_show', 'kanban_comment', 'kanban_heartbeat']:
  170. result = bridge.computer_directive(session_id='stored', tool_name=name, args={})
  171. self.assertEqual(result['action'], 'block')
  172. self.assertIn('task_id', result['message'])
  173. self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name=name, args={'task_id': 'real-task'}))
  174. self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name='kanban_list', args={}))
  175. self.assertIsNone(bridge.computer_directive(session_id='unmanaged', tool_name='kanban_show', args={}))
  176. with patch.dict('os.environ', {'HERMES_KANBAN_TASK': 'real-task'}):
  177. self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name='kanban_show', args={}))
  178. def test_bot_catalog_removes_false_kanban_worker_guidance_without_disabling_board_tools(self):
  179. self.binding.computer_policy = None
  180. definitions = [{'function': {'name': 'kanban_show'}}]
  181. model_tools = SimpleNamespace(get_tool_definitions=lambda **kw: definitions, get_toolset_for_tool=lambda name: 'kanban')
  182. other = SimpleNamespace(_kanban_worker_guidance='keep other session')
  183. with patch.dict(sys.modules, {'model_tools': model_tools}), patch.dict('os.environ', {}, clear=True):
  184. self.agent._kanban_worker_guidance = 'Call kanban_show first'
  185. bridge._refresh_agent_tools(self.agent, self.binding)
  186. self.assertEqual(self.agent._kanban_worker_guidance, '')
  187. self.assertIn('kanban_show', self.agent.valid_tool_names)
  188. self.assertEqual(other._kanban_worker_guidance, 'keep other session')
  189. with patch.dict(sys.modules, {'model_tools': model_tools}), patch.dict('os.environ', {'HERMES_KANBAN_TASK': 'real-task'}):
  190. self.agent._kanban_worker_guidance = 'real worker guidance'
  191. bridge._refresh_agent_tools(self.agent, self.binding)
  192. self.assertEqual(self.agent._kanban_worker_guidance, 'real worker guidance')
  193. def test_isolated_skills_and_vm_tools_use_native_catalog_but_host_operations_require_approval(self):
  194. for name in ['skills_list', 'skill_view', 'vision_analyze', 'yaoyao_computer_shell_aabbcc']:
  195. self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name=name, args={}))
  196. for name in ['terminal', 'process_manage', 'read_file', 'write_file', 'execute_code', 'delegate_task', 'session_search', 'browser_navigate', 'yaoyao_computer_copy_file_aabbcc']:
  197. result = bridge.computer_directive(session_id='stored', tool_name=name, args={'path': '/authorized.txt'})
  198. self.assertEqual(result['action'], 'approve')
  199. self.assertIn('/authorized.txt', result['message'])
  200. self.binding.computer_policy['hostAccess'] = True
  201. self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name='terminal', args={}))
  202. self.binding.computer_policy.update(mode='profile', hostAccess=False)
  203. self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name='terminal', args={}))
  204. def test_stopped_expired_unbound_and_replaced_sessions_cannot_call_native_tools(self):
  205. for change in ['stop', 'expire', 'unbind', 'replace']:
  206. with self.subTest(change=change):
  207. self.session.update(running=True, agent=self.agent)
  208. self.binding.expires_at = time.time() * 1000 + 60000
  209. bridge._bindings['runtime'] = self.binding
  210. if change == 'stop': self.session['running'] = False
  211. if change == 'expire': self.binding.expires_at = 0
  212. if change == 'unbind': bridge._bindings.clear()
  213. if change == 'replace': self.session['agent'] = SimpleNamespace(session_id='stored')
  214. self.assertEqual(bridge.computer_directive(session_id='runtime', tool_name='skill_view', args={})['action'], 'block')
  215. def test_ordinary_profile_sessions_are_unaffected(self):
  216. self.session.pop('_yaoyao_computer_policy')
  217. self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name='terminal', args={}))
  218. def test_policy_cannot_be_changed_during_renewal(self):
  219. self.session['running'] = False
  220. body = {'session_id': 'runtime', 'stored_session_id': 'stored', 'profile': 'default',
  221. 'generation': 'generation', 'bridge_url': self.binding.bridge_url, 'token': self.binding.token,
  222. 'expires_at': time.time() * 1000 + 60000, 'computer_policy': {'mode': 'profile', 'hostAccess': True}}
  223. with patch.object(bridge, '_agent_has_tools', return_value=True):
  224. with self.assertRaises(bridge.BridgeError) as error:
  225. bridge.bind(self.binding.owner, body)
  226. self.assertEqual(error.exception.code, 'generation_conflict')
  227. def test_transfer_is_owned_by_hermes_and_checks_generation_owner_and_protected_paths(self):
  228. constants = SimpleNamespace(set_hermes_home_override=lambda p: None, reset_hermes_home_override=lambda p: None)
  229. file_tools = SimpleNamespace(_check_sensitive_path=lambda p, t: 'denied' if p.endswith('protected') else None,
  230. _check_protected_instruction_write=lambda p, t: None,
  231. _check_approval_required_write=lambda p, t: None)
  232. safety = SimpleNamespace(get_read_block_error=lambda p: 'denied' if p.endswith('protected') else None)
  233. modules = {'hermes_constants': constants, 'tools.file_tools': file_tools, 'agent.file_safety': safety}
  234. (self.home / 'authorized').write_bytes(b'authorized-input')
  235. body = {'session_id': 'runtime', 'generation': 'generation', 'action': 'read', 'path': 'authorized'}
  236. with patch.dict(sys.modules, modules):
  237. self.assertEqual(bridge.computer_file(self.binding.owner, body)['data'], 'YXV0aG9yaXplZC1pbnB1dA==')
  238. for owner, changed in [(('other', 'password'), body), (self.binding.owner, {**body, 'generation': 'stale'}),
  239. (self.binding.owner, {**body, 'path': 'protected'})]:
  240. with self.assertRaises(bridge.BridgeError): bridge.computer_file(owner, changed)
  241. transfer_id = str(uuid.uuid4())
  242. transfer = {**body, 'action': 'transfer', 'direction': 'read', 'transfer': {'op': 'transfer-read-open', 'transferId': transfer_id, 'path': 'authorized', 'maxBytes': 1024 * 1024}}
  243. metadata = bridge.computer_file(self.binding.owner, transfer)
  244. self.assertTrue(metadata['ok']); self.assertEqual(metadata['size'], 16)
  245. chunk = {**transfer, 'transfer': {'op': 'transfer-read', 'transferId': transfer_id, 'offset': 0}}
  246. self.assertEqual(bridge.computer_file(self.binding.owner, chunk)['data'], 'YXV0aG9yaXplZC1pbnB1dA==')
  247. for changed in [{**chunk, 'generation': 'stale'}, {**chunk, 'path': 'protected'}]:
  248. with self.assertRaises(bridge.BridgeError): bridge.computer_file(self.binding.owner, changed)
  249. bridge.computer_file(self.binding.owner, {**transfer, 'transfer': {'op': 'transfer-abort', 'transferId': transfer_id}})
  250. bridge.computer_file(self.binding.owner, {**body, 'action': 'write', 'path': 'result', 'data': 'cmVzdWx0'})
  251. self.assertEqual((self.home / 'result').read_bytes(), b'result')
  252. staged_id = str(uuid.uuid4())
  253. bridge.computer_file(self.binding.owner, {**body, 'action': 'transfer', 'direction': 'write', 'path': 'pending',
  254. 'transfer': {'op': 'transfer-write-open', 'transferId': staged_id, 'maxBytes': 1024 * 1024, 'size': 0,
  255. 'sha256': 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', 'overwrite': False}})
  256. staged = self.home / ('.yaoyao-transfer-' + staged_id)
  257. self.assertTrue(staged.exists())
  258. bridge.unbind(self.binding.owner, {'session_id': 'runtime', 'generation': 'generation'})
  259. deadline = time.time() + 3
  260. while staged.exists() and time.time() < deadline: time.sleep(0.02)
  261. self.assertFalse(staged.exists())
  262. self.assertFalse((self.home / 'pending').exists())
  263. self.session['running'] = False
  264. with self.assertRaises(bridge.BridgeError): bridge.computer_file(self.binding.owner, body)
  265. def test_workspace_memory_uses_agent_controls_without_mutating_another_agent(self):
  266. stopped = []
  267. other = SimpleNamespace(_memory_enabled=True)
  268. self.agent._memory_store = object()
  269. self.agent._memory_manager = SimpleNamespace(get_all_tool_names=lambda: {'provider_remember'}, shutdown_all=lambda: stopped.append(True))
  270. self.agent._memory_enabled = self.agent._user_profile_enabled = True
  271. self.agent.skip_background_review = False
  272. self.agent.disabled_toolsets = ['terminal']
  273. invalidated = []
  274. self.agent._invalidate_system_prompt = lambda: invalidated.append(True)
  275. self.binding.workspace_memory = True
  276. self.session['_yaoyao_workspace_memory'] = True
  277. bridge._isolate_profile_memory(self.agent, self.binding)
  278. self.assertIsNone(self.agent._memory_store)
  279. self.assertIsNone(self.agent._memory_manager)
  280. self.assertFalse(self.agent._memory_enabled)
  281. self.assertFalse(self.agent._user_profile_enabled)
  282. self.assertFalse(self.agent.skip_background_review)
  283. self.assertEqual(self.agent.disabled_toolsets, ['terminal', 'memory'])
  284. self.assertEqual(stopped, [True]); self.assertEqual(invalidated, [True])
  285. self.assertTrue(other._memory_enabled)
  286. for tool in ('memory', 'provider_remember'):
  287. self.assertEqual(bridge.computer_directive(session_id='stored', tool_name=tool, args={})['action'], 'block')
  288. self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name='skill_view', args={}))
  289. def test_skill_review_is_installed_once_and_never_runs_memory_review(self):
  290. bridge._enable_skill_review(self.agent, self.binding)
  291. wrapped = self.agent._spawn_background_review_now
  292. bridge._enable_skill_review(self.agent, self.binding)
  293. self.assertIs(wrapped, self.agent._spawn_background_review_now)
  294. self.agent._spawn_background_review_now([], review_memory=True)
  295. self.assertEqual(self.agent.review_calls, [])
  296. self.agent._spawn_background_review_now([{'role': 'user', 'content': 'verified work'}], review_memory=True, review_skills=True)
  297. self.assertEqual(len(self.agent.review_calls), 1)
  298. _, kwargs, scope = self.agent.review_calls[0]
  299. self.assertFalse(kwargs['review_memory'])
  300. self.assertTrue(kwargs['review_skills'])
  301. self.assertIs(scope.parent, self.agent)
  302. self.assertIsNone(bridge._skill_review_scope.get())
  303. def test_review_fork_keeps_isolation_and_only_skill_permission_after_turn_unbind(self):
  304. self.agent.skip_context_files = True
  305. self.agent.load_soul_identity = False
  306. self.agent.disabled_toolsets = ['memory']
  307. scope = bridge._SkillReviewScope(self.agent, self.binding, time.monotonic() + 60)
  308. parent = bridge._SkillReviewParent(scope)
  309. parent._background_review_run = SimpleNamespace(cancel_requested=threading.Event())
  310. fork = self.context_agent()
  311. fork._memory_store = fork._memory_manager = object()
  312. fork._memory_enabled = fork._user_profile_enabled = True
  313. tools = [{'function': {'name': name}} for name in ['skills_list', 'skill_view', 'skill_manage', 'memory']]
  314. with patch.dict(sys.modules, {'model_tools': SimpleNamespace(get_tool_definitions=lambda **kw: tools)}):
  315. parent._background_review_agent = fork
  316. self.assertTrue(fork.skip_context_files)
  317. self.assertFalse(fork.load_soul_identity)
  318. self.assertTrue(fork.skip_background_review)
  319. self.assertIsNone(fork._memory_manager)
  320. self.assertIsNone(fork._memory_store)
  321. self.assertFalse(fork._memory_enabled)
  322. self.assertFalse(fork._user_profile_enabled)
  323. self.assertIsNone(fork._cached_system_prompt)
  324. self.assertEqual(fork.valid_tool_names, {'skills_list', 'skill_view', 'skill_manage'})
  325. bridge._bindings.clear()
  326. self.session['running'] = False
  327. constants = SimpleNamespace(get_hermes_home=lambda: self.home)
  328. with patch.dict(sys.modules, {'hermes_constants': constants}):
  329. token = bridge._skill_review_scope.set(scope)
  330. try:
  331. self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name='skill_manage'))
  332. for tool in ['memory', 'provider_remember', 'read_file', 'terminal', 'yaoyao_computer_shell', 'workspace_send_to_agent']:
  333. self.assertEqual(bridge.computer_directive(session_id='stored', tool_name=tool)['action'], 'block')
  334. self.assertEqual(bridge.computer_directive(session_id='other-session', tool_name='skill_manage')['action'], 'block')
  335. constants.get_hermes_home = lambda: self.home / 'other-profile'
  336. self.assertEqual(bridge.computer_directive(session_id='stored', tool_name='skill_manage')['action'], 'block')
  337. constants.get_hermes_home = lambda: self.home
  338. scope.run.cancel_requested.set()
  339. self.assertEqual(bridge.computer_directive(session_id='stored', tool_name='skill_manage')['action'], 'block')
  340. scope.run.cancel_requested.clear()
  341. parent._background_review_agent = None
  342. self.assertEqual(bridge.computer_directive(session_id='stored', tool_name='skill_manage')['action'], 'block')
  343. finally:
  344. bridge._skill_review_scope.reset(token)
  345. self.assertEqual(bridge.computer_directive(session_id='stored', tool_name='skill_manage')['action'], 'block')
  346. def context_agent(self, session_id='stored'):
  347. agent = SimpleNamespace(session_id=session_id, enabled_toolsets=['skills'], disabled_toolsets=[],
  348. skip_context_files=False, load_soul_identity=True,
  349. _cached_system_prompt='previous-profile-context')
  350. agent._invalidate_system_prompt = lambda: setattr(agent, '_cached_system_prompt', None)
  351. return agent
  352. def test_isolated_bind_and_resumed_agent_clear_context_cache_without_changing_history(self):
  353. self.session.update(running=False, history=[{'role': 'user', 'content': 'keep-history'}])
  354. original = self.context_agent('ordinary-session')
  355. body = {'session_id': 'runtime', 'stored_session_id': 'stored', 'profile': 'default',
  356. 'bridge_url': self.binding.bridge_url, 'token': self.binding.token,
  357. 'expires_at': time.time() * 1000 + 60000, 'computer_policy': self.binding.computer_policy}
  358. with patch.object(bridge, '_agent_has_tools', return_value=True):
  359. for generation in ['first-turn', 'resumed-turn']:
  360. agent = self.context_agent()
  361. self.session['agent'] = agent
  362. bridge.bind(self.binding.owner, {**body, 'generation': generation})
  363. self.assertTrue(agent.skip_context_files)
  364. self.assertFalse(agent.load_soul_identity)
  365. self.assertIsNone(agent._cached_system_prompt)
  366. self.assertEqual(agent.enabled_toolsets, ['skills'])
  367. self.assertEqual(self.session['history'], [{'role': 'user', 'content': 'keep-history'}])
  368. bridge.unbind(self.binding.owner, {'session_id': 'runtime', 'generation': generation})
  369. self.assertTrue(agent.skip_context_files)
  370. self.assertFalse(original.skip_context_files)
  371. self.assertTrue(original.load_soul_identity)
  372. def test_isolated_child_inherits_context_policy_and_keeps_skill_tools(self):
  373. child = self.context_agent('child-session')
  374. self.agent._active_children = [child]
  375. tools = [{'function': {'name': name}} for name in ['skills_list', 'skill_view', 'skill_manage']]
  376. model_tools = SimpleNamespace(get_tool_definitions=lambda **kw: tools, get_toolset_for_tool=lambda name: 'skills')
  377. with patch.dict(sys.modules, {'model_tools': model_tools}):
  378. bridge.inherit_child(parent_session_id='stored', child_session_id='child-session')
  379. self.assertTrue(child.skip_context_files)
  380. self.assertFalse(child.load_soul_identity)
  381. self.assertIsNone(child._cached_system_prompt)
  382. self.assertEqual(child.valid_tool_names, {'skills_list', 'skill_view', 'skill_manage'})
  383. def test_context_isolation_preserves_profile_mode_and_requires_supported_agent(self):
  384. for policy in [None, {'mode': 'profile', 'hostAccess': True}]:
  385. agent = self.context_agent()
  386. self.binding.computer_policy = policy
  387. bridge._isolate_profile_context(agent, self.binding)
  388. self.assertFalse(agent.skip_context_files)
  389. self.assertTrue(agent.load_soul_identity)
  390. self.assertEqual(agent._cached_system_prompt, 'previous-profile-context')
  391. self.binding.computer_policy = {'mode': 'isolated', 'hostAccess': False}
  392. with self.assertRaises(bridge.BridgeError) as error:
  393. bridge._isolate_profile_context(SimpleNamespace(), self.binding)
  394. self.assertEqual(error.exception.code, 'context_isolation_unavailable')
  395. if __name__ == '__main__':
  396. unittest.main()