test_computer_policy.py 29 KB

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