compose_bridge.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  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 selectors
  9. import signal
  10. import socketserver
  11. import subprocess
  12. import threading
  13. import time
  14. import uuid
  15. from skill_bundle import install as install_skill
  16. DESKTOP_ID = os.environ.get('YAOYAO_COMPOSE_DESKTOP_ID')
  17. ROOT = Path('/run/yaoyao-private')
  18. BRIDGE = ROOT / 'bridge'
  19. SOCKET = BRIDGE / 'desktop.sock'
  20. INSTANCE = secrets.token_hex(24)
  21. PID1 = None
  22. RESET = BRIDGE / 'reset.json'
  23. LOCK = threading.RLock()
  24. LEASE = None
  25. RESTARTING = False
  26. DRIVER = '/usr/local/libexec/openmausbot/cua-driver'
  27. CUA_SOCKET = '/run/user/1000/openmausbot-cua.sock'
  28. BASE_ENV = {'PATH': '/opt/venv/bin:/usr/local/bin:/usr/bin:/bin', 'DISPLAY': ':1'}
  29. ENV = {**BASE_ENV, 'HOME': '/home/cua', 'USER': 'cua'}
  30. def reset_desktop():
  31. global RESTARTING, LEASE
  32. with LOCK:
  33. if RESTARTING:
  34. return
  35. RESTARTING = True
  36. LEASE = None
  37. RESET.write_text(json.dumps({'pid1': PID1}))
  38. # Compose restarts the same service and retains its workspace volume.
  39. threading.Timer(0.1, lambda: os.kill(1, signal.SIGTERM)).start()
  40. def current(body):
  41. if RESTARTING or not LEASE or body.get('token') != LEASE['token'] or body.get('instance') != INSTANCE or LEASE['expires'] <= time.monotonic():
  42. raise ValueError('桌面控制授权已失效')
  43. return LEASE
  44. def start(argv, cwd, user='cua'):
  45. # The bridge itself runs as root; commands run in the explicitly selected
  46. # guest account without acquiring additional container capabilities.
  47. wrapper = 'import os,sys;os.chdir(sys.argv[1]);os.execvpe(sys.argv[2],sys.argv[2:],os.environ)'
  48. uid, environment = (0, {**BASE_ENV, 'HOME': '/root', 'USER': 'root'}) if user == 'root' else (1000, ENV)
  49. 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)
  50. def stop_command(process):
  51. # Every command owns a session/process group. Keep its leader unreaped until
  52. # this signal, so its PID cannot be reused for an unrelated process group.
  53. with process.__dict__.setdefault('_yaoyao_lock', threading.RLock()):
  54. if process.returncode is not None:
  55. return
  56. try:
  57. os.killpg(process.pid, signal.SIGKILL)
  58. except ProcessLookupError:
  59. pass
  60. process.wait(timeout=5)
  61. def collect(process, data, timeout):
  62. # communicate() buffers without a limit; drain both pipes and feed stdin
  63. # together so output and input backpressure cannot deadlock one another.
  64. streams = selectors.DefaultSelector()
  65. output = {'stdout': bytearray(), 'stderr': bytearray()}
  66. pending = memoryview(data or b'')
  67. deadline = time.monotonic() + timeout
  68. size = 0
  69. try:
  70. for pipe, name in ((process.stdout, 'stdout'), (process.stderr, 'stderr')):
  71. os.set_blocking(pipe.fileno(), False)
  72. streams.register(pipe, selectors.EVENT_READ, name)
  73. if pending:
  74. os.set_blocking(process.stdin.fileno(), False)
  75. streams.register(process.stdin, selectors.EVENT_WRITE, 'stdin')
  76. else:
  77. process.stdin.close()
  78. while streams.get_map():
  79. remaining = deadline - time.monotonic()
  80. if remaining <= 0:
  81. raise ValueError('桌面命令超时')
  82. for key, _ in streams.select(remaining):
  83. try:
  84. if key.data == 'stdin':
  85. try:
  86. written = os.write(key.fd, pending[:65536])
  87. pending = pending[written:]
  88. except BrokenPipeError:
  89. pending = pending[:0]
  90. if not pending:
  91. streams.unregister(key.fileobj)
  92. key.fileobj.close()
  93. else:
  94. chunk = os.read(key.fd, 65536)
  95. if not chunk:
  96. streams.unregister(key.fileobj)
  97. key.fileobj.close()
  98. else:
  99. size += len(chunk)
  100. if size > 8 * 1024 * 1024:
  101. raise ValueError('桌面命令输出超过限制')
  102. output[key.data].extend(chunk)
  103. except BlockingIOError:
  104. pass
  105. while True:
  106. remaining = deadline - time.monotonic()
  107. if remaining <= 0:
  108. raise ValueError('桌面命令超时')
  109. with process.__dict__.setdefault('_yaoyao_lock', threading.RLock()):
  110. try:
  111. process.wait(timeout=min(.05, remaining))
  112. break
  113. except subprocess.TimeoutExpired:
  114. pass
  115. return {**{name: value.decode('utf-8', 'replace') for name, value in output.items()}, 'exitCode': process.returncode}
  116. except Exception:
  117. try:
  118. stop_command(process)
  119. except Exception:
  120. # Only an unconfirmed cleanup requires fencing the whole desktop.
  121. reset_desktop()
  122. raise ValueError('尚未确认桌面命令已停止')
  123. raise
  124. finally:
  125. streams.close()
  126. for pipe in (process.stdin, process.stdout, process.stderr):
  127. pipe.close()
  128. def run(argv, cwd='/home/cua/workspace', data=None, timeout=30, user='cua'):
  129. return collect(start(argv, cwd, user), data, timeout)
  130. def operation_id(body):
  131. value = body.get('operationId')
  132. if not isinstance(value, str) or str(uuid.UUID(value)) != value:
  133. raise ValueError('桌面命令标识无效')
  134. return value
  135. def operations(lease):
  136. entries = lease.setdefault('operations', {})
  137. now = time.monotonic()
  138. for key, value in list(entries.items()):
  139. if value.get('finished', now) + 120 < now:
  140. del entries[key]
  141. return entries
  142. class Handler(http.server.BaseHTTPRequestHandler):
  143. def log_message(self, *args):
  144. pass
  145. def do_POST(self):
  146. global LEASE
  147. try:
  148. length = int(self.headers.get('Content-Length', '0'))
  149. if length > 36 * 1024 * 1024:
  150. raise ValueError('请求过大')
  151. body = json.loads(self.rfile.read(length) or b'{}')
  152. if body.get('desktopId') != DESKTOP_ID:
  153. raise ValueError('桌面身份不匹配')
  154. op = self.path
  155. if op == '/health':
  156. ready = False
  157. if not RESTARTING:
  158. try:
  159. result = run([DRIVER, 'call', 'health_report', '{}', '--socket', CUA_SOCKET], timeout=5)
  160. ready = result['exitCode'] == 0
  161. except Exception:
  162. pass
  163. result = {'id': DESKTOP_ID, 'protocol': 1, 'instance': INSTANCE, 'ready': ready, 'features': ['skills-v1', 'command-cancel-v1']}
  164. elif op == '/acquire':
  165. with LOCK:
  166. if RESTARTING:
  167. raise ValueError('桌面正在重置')
  168. if LEASE:
  169. if body.get('requestId') != LEASE['requestId'] or body.get('owner') != LEASE['owner']:
  170. raise ValueError('共享桌面正在使用')
  171. current({'token': LEASE['token'], 'instance': INSTANCE})
  172. else:
  173. if not isinstance(body.get('requestId'), str) or not isinstance(body.get('owner'), str):
  174. raise ValueError('缺少桌面授权身份')
  175. LEASE = {'requestId': body['requestId'], 'owner': body['owner'], 'token': secrets.token_hex(32), 'expires': time.monotonic() + 90, 'active': 0}
  176. result = {'token': LEASE['token'], 'instance': INSTANCE}
  177. elif op == '/renew':
  178. with LOCK:
  179. current(body)['expires'] = time.monotonic() + 90
  180. result = {'ok': True}
  181. elif op == '/release':
  182. with LOCK:
  183. current(body)
  184. if LEASE['active'] and not body.get('cancel'):
  185. raise ValueError('共享桌面仍有未结束操作')
  186. LEASE = None
  187. result = {'ok': True}
  188. if body.get('cancel'):
  189. reset_desktop()
  190. elif op == '/frame':
  191. path = '/tmp/yaoyao-compose-' + secrets.token_hex(12) + '.png'
  192. try:
  193. frame = run([DRIVER, 'call', 'get_desktop_state', '{}', '--socket', CUA_SOCKET, '--screenshot-out-file', path], timeout=10)
  194. if frame['exitCode'] != 0:
  195. raise ValueError('桌面画面尚未就绪')
  196. data = Path(path).read_bytes()
  197. if data[1:4] != b'PNG':
  198. raise ValueError('桌面画面无效')
  199. 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)}
  200. finally:
  201. Path(path).unlink(missing_ok=True)
  202. elif op == '/skills-install':
  203. with LOCK:
  204. current(body)
  205. result = install_skill(body.get('bundle', {}))
  206. current(body)
  207. elif op == '/cancel':
  208. key = operation_id(body)
  209. with LOCK:
  210. entries = operations(current(body))
  211. command = entries.get(key)
  212. if command is None:
  213. if len(entries) >= 4096:
  214. raise ValueError('桌面操作过多,请稍后重试')
  215. # Cancellation may arrive before execute on another
  216. # connection. The tombstone prevents that late launch.
  217. command = entries[key] = {'finished': time.monotonic()}
  218. command['cancelled'] = True
  219. process = command.get('process')
  220. if process is not None:
  221. try:
  222. stop_command(process)
  223. except Exception:
  224. raise ValueError('尚未确认桌面命令已停止')
  225. result = {'ok': True, 'stopped': True, 'operationId': key}
  226. elif op == '/execute':
  227. argv = body.get('argv')
  228. 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:
  229. raise ValueError('桌面命令无效')
  230. cwd = body.get('cwd', '/home/cua/workspace')
  231. if not isinstance(cwd, str) or (cwd != '/home/cua/workspace' and not cwd.startswith('/home/cua/workspace/')) or '..' in Path(cwd).parts:
  232. raise ValueError('Compose 桌面工作目录必须位于 /home/cua/workspace')
  233. user = body.get('user', 'cua')
  234. if user not in ('cua', 'root'):
  235. raise ValueError('桌面命令用户无效')
  236. data = base64.b64decode(body['input'], validate=True) if body.get('input') else None
  237. timeout = min(60, max(1, body.get('timeout', 30000)/1000))
  238. # Older runners can still execute without operationId; only
  239. # callers supplying an ID can use targeted cancellation.
  240. key = operation_id(body) if 'operationId' in body else str(uuid.uuid4())
  241. with LOCK:
  242. lease = current(body)
  243. entries = operations(lease)
  244. if key in entries:
  245. raise ValueError('桌面命令已取消或已执行')
  246. if len(entries) >= 4096:
  247. raise ValueError('桌面操作过多,请稍后重试')
  248. # Spawn while holding the lease lock, then release the lock
  249. # before waiting so revocation can stop the container.
  250. process = start(argv, cwd, user)
  251. command = entries[key] = {'process': process, 'cancelled': False}
  252. lease['active'] += 1
  253. try:
  254. result = collect(process, data, timeout)
  255. with LOCK:
  256. current(body)
  257. if command['cancelled']:
  258. raise ValueError('桌面命令已取消')
  259. finally:
  260. with LOCK:
  261. command.pop('process', None)
  262. command['finished'] = time.monotonic()
  263. if LEASE is lease:
  264. lease['active'] -= 1
  265. else:
  266. raise ValueError('桌面数量和创建删除由 Compose 管理')
  267. payload = json.dumps(result).encode()
  268. self.send_response(200)
  269. except Exception as exc:
  270. code = 'computer_busy' if str(exc) in ('共享桌面正在使用','共享桌面仍有未结束操作') else 'compose_desktop_error'
  271. payload = json.dumps({'error': str(exc), 'code': code}, ensure_ascii=False).encode()
  272. self.send_response(409)
  273. self.send_header('Content-Type','application/json')
  274. self.send_header('X-Yaoyao-Desktop-Id',DESKTOP_ID)
  275. self.send_header('Content-Length',str(len(payload)))
  276. self.end_headers()
  277. try:
  278. self.wfile.write(payload)
  279. except BrokenPipeError:
  280. pass
  281. class Server(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
  282. daemon_threads = True
  283. def expire():
  284. while True:
  285. time.sleep(5)
  286. with LOCK:
  287. expired = LEASE and LEASE['expires'] <= time.monotonic()
  288. if expired:
  289. reset_desktop()
  290. def main():
  291. global PID1
  292. if not DESKTOP_ID:
  293. return
  294. ROOT.mkdir(exist_ok=True)
  295. ROOT.chmod(0o700)
  296. BRIDGE.mkdir(exist_ok=True)
  297. BRIDGE.chmod(0o755)
  298. PID1 = Path('/proc/1/stat').read_text().split()[21]
  299. if RESET.exists() and json.loads(RESET.read_text()).get('pid1') == PID1:
  300. raise SystemExit(1) # A new bridge process alone cannot fence old programs.
  301. RESET.unlink(missing_ok=True)
  302. SOCKET.unlink(missing_ok=True)
  303. server = Server(str(SOCKET), Handler)
  304. SOCKET.chmod(0o600)
  305. os.chown(SOCKET,1000,1000)
  306. threading.Thread(target=expire,daemon=True).start()
  307. server.serve_forever()
  308. if __name__ == '__main__':
  309. main()