verify.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. """Verify Profile routing against real Hermes with a local fake model endpoint.
  2. Run with the Hermes virtualenv Python; no production config or source is changed.
  3. Use --baseline to reproduce the unpatched metadata/environment override defects.
  4. """
  5. import argparse
  6. import asyncio
  7. import importlib.util
  8. import json
  9. import os
  10. import shutil
  11. import socket
  12. import subprocess
  13. import tempfile
  14. import threading
  15. import time
  16. import uuid
  17. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  18. from pathlib import Path
  19. import requests
  20. import websockets
  21. REPO = Path(__file__).resolve().parents[3]
  22. PLUGIN = REPO / 'integrations/hermes-bots-bridge'
  23. def main():
  24. parser = argparse.ArgumentParser(description=__doc__)
  25. parser.add_argument('--source', type=Path, default=Path.home() / '.hermes/hermes-agent')
  26. parser.add_argument('--baseline', action='store_true')
  27. parser.add_argument('--no-env-seed', action='store_true', help='Isolate the default-Profile metadata leak')
  28. args = parser.parse_args()
  29. source = args.source.resolve()
  30. root = Path(tempfile.mkdtemp(prefix='yaoyao-profile-routing-'))
  31. overlay = root / 'source'; overlay.mkdir()
  32. # Copy Python packages: native bootstrap modules resolve their own path and
  33. # prepend that root. Symlinking them would silently load the installed code.
  34. for item in source.iterdir():
  35. if item.is_dir() and (item / '__init__.py').is_file():
  36. shutil.copytree(item, overlay / item.name, ignore=shutil.ignore_patterns('__pycache__'))
  37. elif item.is_file() and item.suffix == '.py':
  38. shutil.copy2(item, overlay / item.name)
  39. else:
  40. (overlay / item.name).symlink_to(item, target_is_directory=item.is_dir())
  41. home = root / 'home'
  42. seen_models = []
  43. class Handler(BaseHTTPRequestHandler):
  44. def log_message(self, *args):
  45. pass
  46. def do_GET(self):
  47. self.send_response(200); self.end_headers()
  48. self.wfile.write(json.dumps({'tools': [{'id': 'fixture_ping', 'name': 'fixture_ping',
  49. 'description': 'Return fixture proof', 'inputSchema': {'type': 'object', 'properties': {}}}]}
  50. if self.path == '/tools/list' else {'data': [{'id': 'fixture-default'}]}).encode())
  51. def do_POST(self):
  52. body = json.loads(self.rfile.read(int(self.headers.get('Content-Length', 0))))
  53. if self.path == '/tools/list':
  54. return self.do_GET()
  55. seen_models.append(body.get('model'))
  56. message = {'role': 'assistant', 'content': 'fixture reply'}
  57. self.send_response(200)
  58. if body.get('stream'):
  59. self.send_header('Content-Type', 'text/event-stream'); self.end_headers()
  60. chunk = {'id': 'fixture', 'object': 'chat.completion.chunk',
  61. 'choices': [{'index': 0, 'delta': message, 'finish_reason': 'stop'}]}
  62. self.wfile.write(('data: ' + json.dumps(chunk) + '\n\ndata: [DONE]\n\n').encode())
  63. else:
  64. self.send_header('Content-Type', 'application/json'); self.end_headers()
  65. self.wfile.write(json.dumps({'id': 'fixture', 'choices': [{'index': 0,
  66. 'message': message, 'finish_reason': 'stop'}],
  67. 'usage': {'prompt_tokens': 100, 'completion_tokens': 5, 'total_tokens': 105}}).encode())
  68. http = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
  69. threading.Thread(target=http.serve_forever, daemon=True).start()
  70. model_url = f'http://127.0.0.1:{http.server_port}'
  71. spec = importlib.util.spec_from_file_location('installer', REPO / 'scripts/install-hermes-bridge.py')
  72. installer = importlib.util.module_from_spec(spec); spec.loader.exec_module(installer)
  73. for profile, model in [('default', 'fixture-default'), ('yaoer', 'grok-4.6')]:
  74. selected = home if profile == 'default' else home / 'profiles' / profile
  75. (selected / 'workspace').mkdir(parents=True)
  76. config = {'model': {'default': model, 'provider': 'custom', 'base_url': model_url + '/v1',
  77. 'api_key': 'fixture', 'context_length': 65536},
  78. 'terminal': {'cwd': str(selected / 'workspace')},
  79. 'plugins': {'enabled': ['yaoyao-bot-bridge']}, 'toolsets': ['yaoyao_bot_bridge'],
  80. 'tools': {'tool_search': {'enabled': 'off'}}, 'dashboard': {'turn_isolation': False},
  81. 'compression': {'enabled': False},
  82. 'memory': {'memory_enabled': False, 'user_profile_enabled': False}}
  83. (selected / 'config.yaml').write_text(json.dumps(config))
  84. (selected / '.env').write_text('')
  85. installer.install(home, profile, PLUGIN, repair_source=None if args.baseline else overlay)
  86. with socket.socket() as sock:
  87. sock.bind(('127.0.0.1', 0)); port = sock.getsockname()[1]
  88. env = {'PATH': os.environ['PATH'], 'HOME': str(root / 'os-home'), 'LANG': 'en_US.UTF-8',
  89. 'HERMES_HOME': str(home), 'PYTHONPATH': str(overlay), 'HERMES_MODEL': 'gpt-5.6-terra',
  90. 'HERMES_DASHBOARD_SESSION_TOKEN': 'fixture-profile-token-123456789',
  91. 'NO_PROXY': '127.0.0.1,localhost', 'PYTHONDONTWRITEBYTECODE': '1'}
  92. if args.no_env_seed:
  93. env.pop('HERMES_MODEL')
  94. log = (root / 'hermes.log').open('w')
  95. child = subprocess.Popen([str(source / 'venv/bin/python'), '-m', 'hermes_cli.main', 'serve',
  96. '--host', '127.0.0.1', '--port', str(port), '--skip-build', '--isolated'],
  97. env=env, cwd=root, stdout=log, stderr=log)
  98. base = f'http://127.0.0.1:{port}'
  99. client = requests.Session(); client.trust_env = False
  100. client.headers['X-Hermes-Session-Token'] = env['HERMES_DASHBOARD_SESSION_TOKEN']
  101. def request(path, body=None):
  102. response = client.get(base + path, timeout=30) if body is None else client.post(base + path, json=body, timeout=30)
  103. response.raise_for_status()
  104. return response.json()
  105. async def rpc(ws, method, params):
  106. key = str(uuid.uuid4())
  107. await ws.send(json.dumps({'jsonrpc': '2.0', 'id': key, 'method': method, 'params': params}))
  108. while True:
  109. frame = json.loads(await asyncio.wait_for(ws.recv(), 30))
  110. if frame.get('id') == key:
  111. assert 'error' not in frame, frame
  112. return frame['result']
  113. async def verify():
  114. results = []
  115. for profile, expected in [('default', 'fixture-default'), ('yaoer', 'grok-4.6')]:
  116. await asyncio.to_thread(request, '/api/config?profile=' + profile)
  117. caps = await asyncio.to_thread(request, '/api/plugins/yaoyao-bot-bridge/capabilities?profile=' + profile)
  118. assert caps['ready'], caps
  119. async with websockets.connect(f'ws://127.0.0.1:{port}/api/ws?token=' + env['HERMES_DASHBOARD_SESSION_TOKEN'],
  120. origin=base, proxy=None) as ws:
  121. while 'gateway.ready' not in await asyncio.wait_for(ws.recv(), 30):
  122. pass
  123. created = await rpc(ws, 'session.create', {'profile': profile, 'source': 'yaoyao_workspace', 'close_on_disconnect': False})
  124. stored = created.get('stored_session_id') or created['session_key']
  125. resumed = await rpc(ws, 'session.resume', {'profile': profile, 'session_id': stored,
  126. 'omit_messages': True, 'close_on_disconnect': False})
  127. sid = resumed['session_id']; generation = str(uuid.uuid4())
  128. start = time.monotonic()
  129. bound = await asyncio.to_thread(request, '/api/plugins/yaoyao-bot-bridge/bind', {
  130. 'session_id': sid, 'stored_session_id': stored, 'profile': profile, 'generation': generation,
  131. 'bridge_url': model_url, 'token': 'a' * 40, 'expires_at': time.time() * 1000 + 60000,
  132. 'native_tools': True, 'workspace_memory': True})
  133. assert bound['ok'], bound
  134. elapsed = round(time.monotonic() - start, 3)
  135. offset = len(seen_models)
  136. await ws.send(json.dumps({'jsonrpc': '2.0', 'id': 'prompt', 'method': 'prompt.submit',
  137. 'params': {'session_id': sid, 'text': 'Hello'}}))
  138. while True:
  139. frame = json.loads(await asyncio.wait_for(ws.recv(), 30))
  140. event = frame.get('params', {}).get('type')
  141. assert event != 'error' and 'error' not in frame, frame
  142. if event == 'message.complete':
  143. break
  144. models = seen_models[offset:]
  145. expected_model = 'gpt-5.6-terra' if args.baseline and not args.no_env_seed else expected
  146. expected_info = ('fixture-default' if args.no_env_seed else 'gpt-5.6-terra') if args.baseline else expected
  147. assert models and all(model == expected_model for model in models), models
  148. assert created['info']['model'] == expected_info, created
  149. assert resumed['info']['model'] == expected_info, resumed
  150. results.append({'profile': profile, 'configured': expected, 'create': created['info']['model'],
  151. 'resume': resumed['info']['model'], 'requests': models, 'bindSeconds': elapsed})
  152. await asyncio.to_thread(request, '/api/plugins/yaoyao-bot-bridge/unbind', {'session_id': sid, 'generation': generation})
  153. print(json.dumps({'baseline': args.baseline, 'environmentSeed': not args.no_env_seed, 'results': results}, ensure_ascii=False, indent=2), flush=True)
  154. try:
  155. deadline = time.monotonic() + 30
  156. while time.monotonic() < deadline:
  157. if child.poll() is not None:
  158. raise RuntimeError('Hermes exited; inspect ' + str(root / 'hermes.log'))
  159. try:
  160. if client.get(base + '/api/status', timeout=1).status_code == 200:
  161. break
  162. except requests.RequestException:
  163. pass
  164. time.sleep(.2)
  165. else:
  166. raise TimeoutError('Hermes startup exceeded 30 seconds')
  167. asyncio.run(verify())
  168. finally:
  169. child.terminate()
  170. try:
  171. child.wait(timeout=5)
  172. except subprocess.TimeoutExpired:
  173. child.kill(); child.wait()
  174. http.shutdown(); http.server_close(); log.close(); client.close()
  175. print('Fixture logs: ' + str(root), flush=True)
  176. if __name__ == '__main__':
  177. main()