compose_bridge.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. """Private Compose desktop transport. No network port or Docker socket."""
  2. import base64
  3. import http.server
  4. import json
  5. import os
  6. from pathlib import Path
  7. import secrets
  8. import signal
  9. import socketserver
  10. import subprocess
  11. import threading
  12. import time
  13. from skill_bundle import install as install_skill
  14. DESKTOP_ID = os.environ.get('YAOYAO_COMPOSE_DESKTOP_ID')
  15. if not DESKTOP_ID:
  16. raise SystemExit(0)
  17. ROOT = Path('/run/yaoyao-private')
  18. ROOT.mkdir(exist_ok=True)
  19. ROOT.chmod(0o700)
  20. BRIDGE = ROOT / 'bridge'
  21. BRIDGE.mkdir(exist_ok=True)
  22. BRIDGE.chmod(0o755)
  23. SOCKET = BRIDGE / 'desktop.sock'
  24. INSTANCE = secrets.token_hex(24)
  25. PID1 = Path('/proc/1/stat').read_text().split()[21]
  26. RESET = BRIDGE / 'reset.json'
  27. if RESET.exists() and json.loads(RESET.read_text()).get('pid1') == PID1:
  28. raise SystemExit(1) # A new bridge process alone cannot fence old programs.
  29. RESET.unlink(missing_ok=True)
  30. SOCKET.unlink(missing_ok=True)
  31. LOCK = threading.RLock()
  32. LEASE = None
  33. RESTARTING = False
  34. DRIVER = '/usr/local/libexec/openmausbot/cua-driver'
  35. CUA_SOCKET = '/run/user/1000/openmausbot-cua.sock'
  36. BASE_ENV = {'PATH': '/opt/venv/bin:/usr/local/bin:/usr/bin:/bin', 'DISPLAY': ':1'}
  37. ENV = {**BASE_ENV, 'HOME': '/home/cua', 'USER': 'cua'}
  38. def reset_desktop():
  39. global RESTARTING, LEASE
  40. with LOCK:
  41. if RESTARTING:
  42. return
  43. RESTARTING = True
  44. LEASE = None
  45. RESET.write_text(json.dumps({'pid1': PID1}))
  46. # Compose restarts the same service and retains its workspace volume.
  47. threading.Timer(0.1, lambda: os.kill(1, signal.SIGTERM)).start()
  48. def current(body):
  49. if RESTARTING or not LEASE or body.get('token') != LEASE['token'] or body.get('instance') != INSTANCE or LEASE['expires'] <= time.monotonic():
  50. raise ValueError('桌面控制授权已失效')
  51. return LEASE
  52. def start(argv, cwd, user='cua'):
  53. # The bridge itself runs as root; commands run in the explicitly selected
  54. # guest account without acquiring additional container capabilities.
  55. wrapper = 'import os,sys;os.chdir(sys.argv[1]);os.execvpe(sys.argv[2],sys.argv[2:],os.environ)'
  56. uid, environment = (0, {**BASE_ENV, 'HOME': '/root', 'USER': 'root'}) if user == 'root' else (1000, ENV)
  57. return subprocess.Popen(['python3','-c',wrapper,cwd,*argv], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=environment, user=uid, group=uid, extra_groups=[], start_new_session=True)
  58. def run(argv, cwd='/home/cua/workspace', data=None, timeout=30, user='cua'):
  59. process = start(argv, cwd, user)
  60. try:
  61. stdout, stderr = process.communicate(data, timeout=timeout)
  62. except subprocess.TimeoutExpired:
  63. os.killpg(process.pid, signal.SIGKILL)
  64. process.communicate()
  65. raise ValueError('桌面画面服务尚未就绪')
  66. if len(stdout) + len(stderr) > 8 * 1024 * 1024:
  67. raise ValueError('桌面命令输出超过限制')
  68. return {'stdout': stdout.decode('utf-8', 'replace'), 'stderr': stderr.decode('utf-8', 'replace'), 'exitCode': process.returncode}
  69. class Handler(http.server.BaseHTTPRequestHandler):
  70. def log_message(self, *args):
  71. pass
  72. def do_POST(self):
  73. global LEASE
  74. try:
  75. length = int(self.headers.get('Content-Length', '0'))
  76. if length > 36 * 1024 * 1024:
  77. raise ValueError('请求过大')
  78. body = json.loads(self.rfile.read(length) or b'{}')
  79. if body.get('desktopId') != DESKTOP_ID:
  80. raise ValueError('桌面身份不匹配')
  81. op = self.path
  82. if op == '/health':
  83. ready = False
  84. if not RESTARTING:
  85. try:
  86. result = run([DRIVER, 'call', 'health_report', '{}', '--socket', CUA_SOCKET], timeout=5)
  87. ready = result['exitCode'] == 0
  88. except Exception:
  89. pass
  90. result = {'id': DESKTOP_ID, 'protocol': 1, 'instance': INSTANCE, 'ready': ready, 'features': ['skills-v1']}
  91. elif op == '/acquire':
  92. with LOCK:
  93. if RESTARTING:
  94. raise ValueError('桌面正在重置')
  95. if LEASE:
  96. if body.get('requestId') != LEASE['requestId'] or body.get('owner') != LEASE['owner']:
  97. raise ValueError('共享桌面正在使用')
  98. current({'token': LEASE['token'], 'instance': INSTANCE})
  99. else:
  100. if not isinstance(body.get('requestId'), str) or not isinstance(body.get('owner'), str):
  101. raise ValueError('缺少桌面授权身份')
  102. LEASE = {'requestId': body['requestId'], 'owner': body['owner'], 'token': secrets.token_hex(32), 'expires': time.monotonic() + 90, 'active': 0}
  103. result = {'token': LEASE['token'], 'instance': INSTANCE}
  104. elif op == '/renew':
  105. with LOCK:
  106. current(body)['expires'] = time.monotonic() + 90
  107. result = {'ok': True}
  108. elif op == '/release':
  109. with LOCK:
  110. current(body)
  111. if LEASE['active'] and not body.get('cancel'):
  112. raise ValueError('共享桌面仍有未结束操作')
  113. LEASE = None
  114. result = {'ok': True}
  115. if body.get('cancel'):
  116. reset_desktop()
  117. elif op == '/frame':
  118. path = '/tmp/yaoyao-compose-' + secrets.token_hex(12) + '.png'
  119. try:
  120. frame = run([DRIVER, 'call', 'get_desktop_state', '{}', '--socket', CUA_SOCKET, '--screenshot-out-file', path], timeout=10)
  121. if frame['exitCode'] != 0:
  122. raise ValueError('桌面画面尚未就绪')
  123. data = Path(path).read_bytes()
  124. if data[1:4] != b'PNG':
  125. raise ValueError('桌面画面无效')
  126. result = {'data': base64.b64encode(data).decode(), 'width': int.from_bytes(data[16:20], 'big'), 'height': int.from_bytes(data[20:24], 'big'), 'capturedAt': int(time.time()*1000)}
  127. finally:
  128. Path(path).unlink(missing_ok=True)
  129. elif op == '/skills-install':
  130. with LOCK:
  131. current(body)
  132. result = install_skill(body.get('bundle', {}))
  133. current(body)
  134. elif op == '/execute':
  135. argv = body.get('argv')
  136. if not isinstance(argv, list) or not argv or len(argv) > 64 or any(not isinstance(x, str) or '\0' in x for x in argv) or sum(map(len, argv)) > 65536:
  137. raise ValueError('桌面命令无效')
  138. cwd = body.get('cwd', '/home/cua/workspace')
  139. if not isinstance(cwd, str) or (cwd != '/home/cua/workspace' and not cwd.startswith('/home/cua/workspace/')) or '..' in Path(cwd).parts:
  140. raise ValueError('Compose 桌面工作目录必须位于 /home/cua/workspace')
  141. user = body.get('user', 'cua')
  142. if user not in ('cua', 'root'):
  143. raise ValueError('桌面命令用户无效')
  144. with LOCK:
  145. lease = current(body)
  146. # Spawn while holding the lease lock, then release the lock
  147. # before waiting so revocation can stop the container.
  148. process = start(argv, cwd, user)
  149. lease['active'] += 1
  150. try:
  151. data = base64.b64decode(body['input']) if body.get('input') else None
  152. stdout, stderr = process.communicate(data, timeout=min(60, max(1, body.get('timeout', 30000)/1000)))
  153. if len(stdout) + len(stderr) > 8*1024*1024:
  154. raise ValueError('桌面命令输出超过限制')
  155. with LOCK:
  156. current(body)
  157. result = {'stdout': stdout.decode('utf-8','replace'), 'stderr': stderr.decode('utf-8','replace'), 'exitCode': process.returncode}
  158. except Exception:
  159. reset_desktop()
  160. raise
  161. finally:
  162. with LOCK:
  163. if LEASE is lease:
  164. lease['active'] -= 1
  165. else:
  166. raise ValueError('桌面数量和创建删除由 Compose 管理')
  167. payload = json.dumps(result).encode()
  168. self.send_response(200)
  169. except Exception as exc:
  170. code = 'computer_busy' if str(exc) in ('共享桌面正在使用','共享桌面仍有未结束操作') else 'compose_desktop_error'
  171. payload = json.dumps({'error': str(exc), 'code': code}, ensure_ascii=False).encode()
  172. self.send_response(409)
  173. self.send_header('Content-Type','application/json')
  174. self.send_header('X-Yaoyao-Desktop-Id',DESKTOP_ID)
  175. self.send_header('Content-Length',str(len(payload)))
  176. self.end_headers()
  177. try:
  178. self.wfile.write(payload)
  179. except BrokenPipeError:
  180. pass
  181. class Server(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
  182. daemon_threads = True
  183. def expire():
  184. while True:
  185. time.sleep(5)
  186. with LOCK:
  187. expired = LEASE and LEASE['expires'] <= time.monotonic()
  188. if expired:
  189. reset_desktop()
  190. server = Server(str(SOCKET), Handler)
  191. SOCKET.chmod(0o600)
  192. os.chown(SOCKET,1000,1000)
  193. threading.Thread(target=expire,daemon=True).start()
  194. server.serve_forever()