main
py 1,201 lines 36.8 KB
Raw
1 from __future__ import annotations
2
3 import asyncio
4 import base64
5 import binascii
6 import copy
7 import json
8 import threading
9 import time
10 from dataclasses import dataclass, field
11 from typing import Any
12
13
14 @dataclass
15 class PendingFileOperation:
16 sid: str
17 loop: asyncio.AbstractEventLoop
18 future: asyncio.Future[dict[str, Any]]
19 context_id: str | None = None
20 chunk_count: int | None = None
21 chunks: dict[int, bytes] = field(default_factory=dict)
22
23
24 @dataclass
25 class PendingExecOperation:
26 sid: str
27 loop: asyncio.AbstractEventLoop
28 future: asyncio.Future[dict[str, Any]]
29 context_id: str | None = None
30
31
32 @dataclass
33 class PendingComputerUseOperation:
34 sid: str
35 loop: asyncio.AbstractEventLoop
36 future: asyncio.Future[dict[str, Any]]
37 context_id: str | None = None
38
39
40 @dataclass
41 class PendingBrowserOperation:
42 sid: str
43 loop: asyncio.AbstractEventLoop
44 future: asyncio.Future[dict[str, Any]]
45 context_id: str | None = None
46
47
48 @dataclass
49 class PendingGatewayControl:
50 sid: str
51 loop: asyncio.AbstractEventLoop
52 future: asyncio.Future[dict[str, Any]]
53
54
55 @dataclass(frozen=True)
56 class RemoteTreeSnapshot:
57 sid: str
58 payload: dict[str, Any]
59 updated_at: float
60
61
62 @dataclass(frozen=True)
63 class ComputerUseMetadata:
64 supported: bool
65 enabled: bool
66 trust_mode: str
67 status: str
68 last_error: str
69 restore_token_present: bool
70 artifact_root: str
71 backend_id: str
72 backend_family: str
73 features: tuple[str, ...]
74 contract_version: int
75 capabilities: dict[str, Any]
76 support_reason: str
77 updated_at: float
78
79
80 @dataclass(frozen=True)
81 class HostBrowserMetadata:
82 supported: bool
83 can_prepare: bool
84 enabled: bool
85 status: str
86 browser_family: str
87 profile_label: str
88 profile_path: str
89 cdp_endpoint: str
90 browser_id: str
91 browser_label: str
92 available_browsers: tuple[dict[str, Any], ...]
93 content_helper_sha256: str
94 features: tuple[str, ...]
95 support_reason: str
96 updated_at: float
97
98
99 @dataclass(frozen=True)
100 class RemoteFileMetadata:
101 enabled: bool
102 write_enabled: bool
103 mode: str
104 updated_at: float
105
106
107 @dataclass(frozen=True)
108 class RemoteExecMetadata:
109 enabled: bool
110 updated_at: float
111
112
113 @dataclass(frozen=True)
114 class LauncherGatewayMetadata:
115 gateway_id: str
116 host_label: str
117 state: str
118 master_enabled: bool
119 scopes: dict[str, bool]
120 status: dict[str, Any]
121 updated_at: float
122
123
124 _context_subscriptions: dict[str, set[str]] = {}
125 _sid_contexts: dict[str, set[str]] = {}
126 _pending_file_ops: dict[str, PendingFileOperation] = {}
127 _pending_exec_ops: dict[str, PendingExecOperation] = {}
128 _pending_computer_use_ops: dict[str, PendingComputerUseOperation] = {}
129 _pending_browser_ops: dict[str, PendingBrowserOperation] = {}
130 _pending_gateway_controls: dict[str, PendingGatewayControl] = {}
131 _remote_tree_snapshots: dict[str, RemoteTreeSnapshot] = {}
132 _sid_computer_use_metadata: dict[str, ComputerUseMetadata] = {}
133 _sid_host_browser_metadata: dict[str, HostBrowserMetadata] = {}
134 _sid_remote_file_metadata: dict[str, RemoteFileMetadata] = {}
135 _sid_remote_exec_metadata: dict[str, RemoteExecMetadata] = {}
136 _sid_launcher_gateway_metadata: dict[str, LauncherGatewayMetadata] = {}
137 _replaced_gateway_sids: set[str] = set()
138 _state_lock = threading.RLock()
139
140
141 def register_sid(sid: str) -> None:
142 with _state_lock:
143 _replaced_gateway_sids.discard(sid)
144 _sid_contexts.setdefault(sid, set())
145
146
147 def unregister_sid(sid: str) -> set[str]:
148 with _state_lock:
149 contexts = _sid_contexts.pop(sid, set())
150 _remote_tree_snapshots.pop(sid, None)
151 _sid_computer_use_metadata.pop(sid, None)
152 _sid_host_browser_metadata.pop(sid, None)
153 _sid_remote_file_metadata.pop(sid, None)
154 _sid_remote_exec_metadata.pop(sid, None)
155 _sid_launcher_gateway_metadata.pop(sid, None)
156 _replaced_gateway_sids.discard(sid)
157 for context_id in contexts:
158 subscribers = _context_subscriptions.get(context_id)
159 if not subscribers:
160 continue
161 subscribers.discard(sid)
162 if not subscribers:
163 _context_subscriptions.pop(context_id, None)
164 return contexts
165
166
167 def subscribe_sid_to_context(sid: str, context_id: str) -> None:
168 with _state_lock:
169 _sid_contexts.setdefault(sid, set()).add(context_id)
170 _context_subscriptions.setdefault(context_id, set()).add(sid)
171
172
173 def unsubscribe_sid_from_context(sid: str, context_id: str) -> None:
174 with _state_lock:
175 contexts = _sid_contexts.get(sid)
176 if contexts is not None:
177 contexts.discard(context_id)
178 if not contexts:
179 _sid_contexts.pop(sid, None)
180
181 subscribers = _context_subscriptions.get(context_id)
182 if subscribers is not None:
183 subscribers.discard(sid)
184 if not subscribers:
185 _context_subscriptions.pop(context_id, None)
186
187
188 def subscribed_contexts_for_sid(sid: str) -> set[str]:
189 with _state_lock:
190 return set(_sid_contexts.get(sid, set()))
191
192
193 def subscribed_sids_for_context(context_id: str) -> set[str]:
194 with _state_lock:
195 return set(_context_subscriptions.get(context_id, set()))
196
197
198 def connected_sids() -> set[str]:
199 with _state_lock:
200 return set(_sid_contexts.keys())
201
202
203 _GATEWAY_STATES = {
204 "connecting",
205 "connected",
206 "paused",
207 "needs_action",
208 "error",
209 "disconnected",
210 }
211 _GATEWAY_SCOPE_KEYS = ("files", "file_write", "code_execution", "browser", "computer_use")
212
213
214 def _bounded_gateway_status(value: Any, *, depth: int = 0) -> Any:
215 if isinstance(value, str):
216 return value[:2048]
217 if isinstance(value, (bool, int, float)) or value is None:
218 return value
219 if depth >= 5:
220 return None
221 if isinstance(value, dict):
222 result: dict[str, Any] = {}
223 for key, item in list(value.items())[:64]:
224 result[str(key)[:80]] = _bounded_gateway_status(item, depth=depth + 1)
225 return result
226 if isinstance(value, (list, tuple)):
227 return [
228 _bounded_gateway_status(item, depth=depth + 1)
229 for item in list(value)[:64]
230 ]
231 return str(value)[:2048]
232
233
234 def store_sid_launcher_gateway_metadata(
235 sid: str,
236 payload: dict[str, Any],
237 ) -> LauncherGatewayMetadata | None:
238 """Store a validated Launcher gateway declaration for one connector socket."""
239 if str(payload.get("kind", "") or "").strip().lower() != "launcher":
240 clear_sid_launcher_gateway_metadata(sid)
241 return None
242 try:
243 version = int(payload.get("version") or 0)
244 except (TypeError, ValueError):
245 version = 0
246 gateway_id = str(payload.get("id", "") or "").strip()[:128]
247 if version != 1 or not gateway_id:
248 clear_sid_launcher_gateway_metadata(sid)
249 return None
250
251 raw_scopes = payload.get("scopes")
252 scopes = {
253 key: bool(
254 raw_scopes.get(key, raw_scopes.get("files") if key == "file_write" else False)
255 ) if isinstance(raw_scopes, dict) else False
256 for key in _GATEWAY_SCOPE_KEYS
257 }
258 if not scopes["files"]:
259 scopes["file_write"] = False
260 if not scopes["file_write"]:
261 scopes["code_execution"] = False
262 master_enabled = bool(payload.get("master_enabled", True))
263 state = str(payload.get("state", "connected") or "").strip().lower()
264 if state not in _GATEWAY_STATES:
265 state = "connected" if master_enabled else "paused"
266 if not master_enabled and state not in {"error", "needs_action", "disconnected"}:
267 state = "paused"
268 status_value = payload.get("status")
269 status = _bounded_gateway_status(status_value) if isinstance(status_value, dict) else {}
270 metadata = LauncherGatewayMetadata(
271 gateway_id=gateway_id,
272 host_label=str(payload.get("host_label", "") or "").strip()[:128],
273 state=state,
274 master_enabled=master_enabled,
275 scopes=scopes,
276 status=status,
277 updated_at=time.time(),
278 )
279 with _state_lock:
280 if sid in _replaced_gateway_sids:
281 return None
282 for other_sid, other in list(_sid_launcher_gateway_metadata.items()):
283 if other_sid != sid and other.gateway_id == gateway_id:
284 _sid_launcher_gateway_metadata.pop(other_sid, None)
285 _replaced_gateway_sids.add(other_sid)
286 _sid_launcher_gateway_metadata[sid] = metadata
287 return metadata
288
289
290 def clear_sid_launcher_gateway_metadata(sid: str) -> None:
291 with _state_lock:
292 _sid_launcher_gateway_metadata.pop(sid, None)
293
294
295 def launcher_gateway_metadata_for_sid(sid: str) -> dict[str, Any] | None:
296 with _state_lock:
297 metadata = _sid_launcher_gateway_metadata.get(sid)
298 if metadata is None:
299 return None
300 return _launcher_gateway_metadata_dict(metadata, sid=sid)
301
302
303 def _launcher_gateway_metadata_dict(
304 metadata: LauncherGatewayMetadata,
305 *,
306 sid: str | None = None,
307 ) -> dict[str, Any]:
308 result = {
309 "version": 1,
310 "kind": "launcher",
311 "id": metadata.gateway_id,
312 "host_label": metadata.host_label,
313 "state": metadata.state,
314 "master_enabled": metadata.master_enabled,
315 "scopes": dict(metadata.scopes),
316 "status": copy.deepcopy(metadata.status),
317 "updated_at": metadata.updated_at,
318 }
319 if sid is not None:
320 result["sid"] = sid
321 return result
322
323
324 def _active_launcher_gateways_locked() -> list[tuple[str, LauncherGatewayMetadata]]:
325 return sorted(
326 (
327 (sid, metadata)
328 for sid, metadata in _sid_launcher_gateway_metadata.items()
329 if sid in _sid_contexts and sid not in _replaced_gateway_sids
330 ),
331 key=lambda item: item[1].updated_at,
332 reverse=True,
333 )
334
335
336 def _active_launcher_gateway_sid_locked() -> str | None:
337 gateways = _active_launcher_gateways_locked()
338 if len({metadata.gateway_id for _sid, metadata in gateways}) != 1:
339 return None
340 return gateways[0][0] if gateways else None
341
342
343 def active_launcher_gateway_sid() -> str | None:
344 with _state_lock:
345 return _active_launcher_gateway_sid_locked()
346
347
348 def launcher_gateway_status() -> dict[str, Any]:
349 with _state_lock:
350 gateways = _active_launcher_gateways_locked()
351 distinct_ids = {metadata.gateway_id for _sid, metadata in gateways}
352 rows = [
353 _launcher_gateway_metadata_dict(metadata)
354 for _sid, metadata in gateways
355 ]
356 if not rows:
357 return {
358 "state": "disconnected",
359 "connected": False,
360 "multiple_hosts": False,
361 "gateway": None,
362 "gateways": [],
363 }
364 if len(distinct_ids) > 1:
365 return {
366 "state": "multiple_hosts",
367 "connected": False,
368 "multiple_hosts": True,
369 "gateway": None,
370 "gateways": rows,
371 "error": "Multiple Launcher hosts are connected; host tools are disabled.",
372 }
373 gateway = rows[0]
374 return {
375 "state": gateway["state"],
376 "connected": gateway["state"] not in {"disconnected", "error"},
377 "multiple_hosts": False,
378 "gateway": gateway,
379 "gateways": rows,
380 }
381
382
383 def _candidate_sids_for_context_locked(context_id: str) -> list[str]:
384 context_sids = sorted(_context_subscriptions.get(context_id, set()))
385 context_set = set(context_sids)
386 gateway_sid = _active_launcher_gateway_sid_locked()
387 gateway_sids = [gateway_sid] if gateway_sid and gateway_sid not in context_set else []
388 global_sids = sorted(
389 sid
390 for sid in _sid_contexts
391 if sid not in context_set
392 and sid not in _sid_launcher_gateway_metadata
393 and sid not in _replaced_gateway_sids
394 )
395 return context_sids + gateway_sids + global_sids
396
397
398 def remote_tool_sids_for_context(context_id: str) -> list[str]:
399 """Return connected CLI candidates, preferring clients subscribed to context_id."""
400 with _state_lock:
401 return _candidate_sids_for_context_locked(context_id)
402
403
404 def store_remote_tree_snapshot(
405 sid: str,
406 payload: dict[str, Any],
407 ) -> RemoteTreeSnapshot:
408 snapshot = RemoteTreeSnapshot(
409 sid=sid,
410 payload=dict(payload),
411 updated_at=time.time(),
412 )
413 with _state_lock:
414 _remote_tree_snapshots[sid] = snapshot
415 return snapshot
416
417
418 def clear_remote_tree_snapshot(sid: str) -> None:
419 with _state_lock:
420 _remote_tree_snapshots.pop(sid, None)
421
422
423 def latest_remote_tree_for_context(
424 context_id: str,
425 *,
426 max_age_seconds: float = 90.0,
427 ) -> dict[str, Any] | None:
428 now = time.time()
429 with _state_lock:
430 candidates = _candidate_sids_for_context_locked(context_id)
431 context_sids = set(_context_subscriptions.get(context_id, set()))
432 snapshot_groups = [
433 [_remote_tree_snapshots[sid] for sid in candidates if sid in context_sids and sid in _remote_tree_snapshots],
434 [_remote_tree_snapshots[sid] for sid in candidates if sid not in context_sids and sid in _remote_tree_snapshots],
435 ]
436
437 for snapshots in snapshot_groups:
438 snapshots.sort(key=lambda item: item.updated_at, reverse=True)
439 for snapshot in snapshots:
440 if max_age_seconds > 0 and now - snapshot.updated_at > max_age_seconds:
441 continue
442 payload = dict(snapshot.payload)
443 payload["sid"] = snapshot.sid
444 payload["updated_at"] = snapshot.updated_at
445 return payload
446 return None
447
448
449 def select_target_sid(context_id: str) -> str | None:
450 with _state_lock:
451 subscribers = _context_subscriptions.get(context_id, set())
452 if not subscribers:
453 return None
454 return sorted(subscribers)[0]
455
456
457 def store_sid_remote_file_metadata(sid: str, payload: dict[str, Any]) -> RemoteFileMetadata:
458 write_enabled = bool(payload.get("write_enabled"))
459 mode = str(payload.get("mode", "") or "").strip().lower()
460 if mode not in {"read_only", "read_write"}:
461 mode = "read_write" if write_enabled else "read_only"
462 metadata = RemoteFileMetadata(
463 enabled=bool(payload.get("enabled", True)),
464 write_enabled=write_enabled,
465 mode=mode,
466 updated_at=time.time(),
467 )
468 with _state_lock:
469 _sid_remote_file_metadata[sid] = metadata
470 return metadata
471
472
473 def clear_sid_remote_file_metadata(sid: str) -> None:
474 with _state_lock:
475 _sid_remote_file_metadata.pop(sid, None)
476
477
478 def remote_file_metadata_for_sid(sid: str) -> dict[str, Any] | None:
479 with _state_lock:
480 metadata = _sid_remote_file_metadata.get(sid)
481 if metadata is None:
482 return None
483 return {
484 "enabled": metadata.enabled,
485 "write_enabled": metadata.write_enabled,
486 "mode": metadata.mode,
487 "updated_at": metadata.updated_at,
488 }
489
490
491 def select_remote_file_target_sid(context_id: str, *, require_writes: bool = False) -> str | None:
492 with _state_lock:
493 for sid in _candidate_sids_for_context_locked(context_id):
494 metadata = _sid_remote_file_metadata.get(sid)
495 if metadata is None:
496 continue
497 if not metadata.enabled:
498 continue
499 if require_writes and not metadata.write_enabled:
500 continue
501 return sid
502 return None
503
504
505 def store_sid_remote_exec_metadata(sid: str, payload: dict[str, Any]) -> RemoteExecMetadata:
506 metadata = RemoteExecMetadata(
507 enabled=bool(payload.get("enabled")),
508 updated_at=time.time(),
509 )
510 with _state_lock:
511 _sid_remote_exec_metadata[sid] = metadata
512 return metadata
513
514
515 def clear_sid_remote_exec_metadata(sid: str) -> None:
516 with _state_lock:
517 _sid_remote_exec_metadata.pop(sid, None)
518
519
520 def remote_exec_metadata_for_sid(sid: str) -> dict[str, Any] | None:
521 with _state_lock:
522 metadata = _sid_remote_exec_metadata.get(sid)
523 if metadata is None:
524 return None
525 return {
526 "enabled": metadata.enabled,
527 "updated_at": metadata.updated_at,
528 }
529
530
531 def select_remote_exec_target_sid(context_id: str, *, require_writes: bool = False) -> str | None:
532 with _state_lock:
533 for sid in _candidate_sids_for_context_locked(context_id):
534 metadata = _sid_remote_exec_metadata.get(sid)
535 if metadata is None:
536 continue
537 if metadata.enabled:
538 if require_writes:
539 file_metadata = _sid_remote_file_metadata.get(sid)
540 if file_metadata is None or (
541 not file_metadata.enabled or not file_metadata.write_enabled
542 ):
543 continue
544 return sid
545 return None
546
547
548 def store_sid_computer_use_metadata(sid: str, payload: dict[str, Any]) -> ComputerUseMetadata:
549 features_value = payload.get("features")
550 if isinstance(features_value, (list, tuple)):
551 features = tuple(str(item).strip() for item in features_value if str(item).strip())
552 else:
553 features = ()
554 capabilities_value = payload.get("capabilities")
555 capabilities = copy.deepcopy(capabilities_value) if isinstance(capabilities_value, dict) else {}
556 try:
557 contract_version = int(payload.get("contract_version") or 0)
558 except (TypeError, ValueError):
559 contract_version = 0
560 metadata = ComputerUseMetadata(
561 supported=bool(payload.get("supported")),
562 enabled=bool(payload.get("supported")) and bool(payload.get("enabled")),
563 trust_mode=str(payload.get("trust_mode", "") or "").strip(),
564 status=str(payload.get("status", "") or "").strip(),
565 last_error=str(payload.get("last_error", "") or "").strip(),
566 restore_token_present=bool(payload.get("restore_token_present")),
567 artifact_root=str(payload.get("artifact_root", "") or "").strip(),
568 backend_id=str(payload.get("backend_id", "") or "").strip(),
569 backend_family=str(payload.get("backend_family", "") or "").strip(),
570 features=features,
571 contract_version=contract_version,
572 capabilities=capabilities,
573 support_reason=str(payload.get("support_reason", "") or "").strip(),
574 updated_at=time.time(),
575 )
576 with _state_lock:
577 _sid_computer_use_metadata[sid] = metadata
578 return metadata
579
580
581 def clear_sid_computer_use_metadata(sid: str) -> None:
582 with _state_lock:
583 _sid_computer_use_metadata.pop(sid, None)
584
585
586 def computer_use_metadata_for_sid(sid: str) -> dict[str, Any] | None:
587 with _state_lock:
588 metadata = _sid_computer_use_metadata.get(sid)
589 if metadata is None:
590 return None
591 return {
592 "supported": metadata.supported,
593 "enabled": metadata.enabled,
594 "trust_mode": metadata.trust_mode,
595 "status": metadata.status,
596 "last_error": metadata.last_error,
597 "restore_token_present": metadata.restore_token_present,
598 "artifact_root": metadata.artifact_root,
599 "backend_id": metadata.backend_id,
600 "backend_family": metadata.backend_family,
601 "features": list(metadata.features),
602 "contract_version": metadata.contract_version,
603 "capabilities": copy.deepcopy(metadata.capabilities),
604 "support_reason": metadata.support_reason,
605 "updated_at": metadata.updated_at,
606 }
607
608
609 def store_sid_host_browser_metadata(sid: str, payload: dict[str, Any]) -> HostBrowserMetadata:
610 features_value = payload.get("features")
611 if isinstance(features_value, (list, tuple)):
612 features = tuple(str(item).strip() for item in features_value if str(item).strip())
613 else:
614 features = ()
615 support_reason = str(payload.get("support_reason", "") or "").strip()
616 metadata = HostBrowserMetadata(
617 supported=bool(payload.get("supported")),
618 can_prepare=_host_browser_can_prepare(payload, features=features, support_reason=support_reason),
619 enabled=bool(payload.get("supported")) and bool(payload.get("enabled")),
620 status=str(payload.get("status", "") or "").strip(),
621 browser_family=str(payload.get("browser_family", "") or "").strip(),
622 profile_label=str(payload.get("profile_label", "") or "").strip(),
623 profile_path=str(payload.get("profile_path", "") or "").strip(),
624 cdp_endpoint=str(payload.get("cdp_endpoint", "") or "").strip(),
625 browser_id=str(payload.get("browser_id", payload.get("browser_selection", "")) or "").strip(),
626 browser_label=str(payload.get("browser_label", "") or "").strip(),
627 available_browsers=_normalize_available_host_browsers(payload.get("available_browsers")),
628 content_helper_sha256=str(payload.get("content_helper_sha256", "") or "").strip().lower(),
629 features=features,
630 support_reason=support_reason,
631 updated_at=time.time(),
632 )
633 with _state_lock:
634 _sid_host_browser_metadata[sid] = metadata
635 return metadata
636
637
638 def _host_browser_can_prepare(
639 payload: dict[str, Any],
640 *,
641 features: tuple[str, ...],
642 support_reason: str,
643 ) -> bool:
644 if "can_prepare" in payload:
645 return bool(payload.get("can_prepare"))
646 if "ensure" not in features:
647 return False
648 reason = support_reason.lower()
649 return (
650 "python playwright" in reason
651 or "a0-controlled local profile" in reason
652 or "chrome-a0" in reason
653 or "remote debugging" in reason
654 )
655
656
657 def _normalize_available_host_browsers(value: Any) -> tuple[dict[str, Any], ...]:
658 if not isinstance(value, (list, tuple)):
659 return ()
660 browsers: list[dict[str, Any]] = []
661 for item in value:
662 if not isinstance(item, dict):
663 continue
664 browser_id = str(item.get("id", item.get("browser_id", item.get("selection", ""))) or "").strip()
665 family = str(item.get("family", item.get("browser_family", "")) or "").strip()
666 label = str(item.get("label", item.get("name", "")) or "").strip()
667 cdp_endpoint = str(item.get("cdp_endpoint", "") or "").strip()
668 status = str(item.get("status", "") or "").strip()
669 enabled = bool(item.get("enabled", True))
670 if not any((browser_id, family, label, cdp_endpoint)):
671 continue
672 browsers.append({
673 "id": browser_id or family or cdp_endpoint,
674 "family": family,
675 "label": label or family or browser_id or cdp_endpoint,
676 "cdp_endpoint": cdp_endpoint,
677 "status": status,
678 "enabled": enabled,
679 })
680 return tuple(browsers)
681
682
683 def clear_sid_host_browser_metadata(sid: str) -> None:
684 with _state_lock:
685 _sid_host_browser_metadata.pop(sid, None)
686
687
688 def host_browser_metadata_for_sid(sid: str) -> dict[str, Any] | None:
689 with _state_lock:
690 metadata = _sid_host_browser_metadata.get(sid)
691 if metadata is None:
692 return None
693 return {
694 "supported": metadata.supported,
695 "can_prepare": metadata.can_prepare,
696 "enabled": metadata.enabled,
697 "status": metadata.status,
698 "browser_family": metadata.browser_family,
699 "profile_label": metadata.profile_label,
700 "profile_path": metadata.profile_path,
701 "cdp_endpoint": metadata.cdp_endpoint,
702 "browser_id": metadata.browser_id,
703 "browser_label": metadata.browser_label,
704 "available_browsers": copy.deepcopy(list(metadata.available_browsers)),
705 "content_helper_sha256": metadata.content_helper_sha256,
706 "features": list(metadata.features),
707 "support_reason": metadata.support_reason,
708 "updated_at": metadata.updated_at,
709 }
710
711
712 def select_host_browser_target_sid(context_id: str) -> str | None:
713 with _state_lock:
714 fallback: str | None = None
715 for sid in _candidate_sids_for_context_locked(context_id):
716 metadata = _sid_host_browser_metadata.get(sid)
717 if not metadata:
718 continue
719 if not metadata.supported:
720 continue
721 if metadata.enabled and metadata.status in {"ready", "active"}:
722 return sid
723 if metadata.enabled and fallback is None:
724 fallback = sid
725 return fallback
726
727
728 def select_host_browser_candidate_sid(context_id: str) -> str | None:
729 with _state_lock:
730 fallback: str | None = None
731 for sid in _candidate_sids_for_context_locked(context_id):
732 metadata = _sid_host_browser_metadata.get(sid)
733 if not metadata or not (metadata.supported or metadata.can_prepare):
734 continue
735 if metadata.enabled and metadata.status in {"ready", "active"}:
736 return sid
737 if metadata.enabled and fallback is None:
738 fallback = sid
739 elif fallback is None:
740 fallback = sid
741 return fallback
742
743
744 def host_browser_metadata_for_context(context_id: str) -> list[dict[str, Any]]:
745 with _state_lock:
746 candidates = _candidate_sids_for_context_locked(context_id)
747 rows: list[dict[str, Any]] = []
748 for sid in candidates:
749 metadata = host_browser_metadata_for_sid(sid)
750 if metadata is not None:
751 metadata["sid"] = sid
752 rows.append(metadata)
753 return rows
754
755
756 def all_host_browser_metadata() -> list[dict[str, Any]]:
757 with _state_lock:
758 items = sorted(_sid_host_browser_metadata.items())
759 rows: list[dict[str, Any]] = []
760 for sid, metadata in items:
761 rows.append(
762 {
763 "sid": sid,
764 "supported": metadata.supported,
765 "enabled": metadata.enabled,
766 "status": metadata.status,
767 "browser_family": metadata.browser_family,
768 "profile_label": metadata.profile_label,
769 "profile_path": metadata.profile_path,
770 "cdp_endpoint": metadata.cdp_endpoint,
771 "browser_id": metadata.browser_id,
772 "browser_label": metadata.browser_label,
773 "available_browsers": copy.deepcopy(list(metadata.available_browsers)),
774 "content_helper_sha256": metadata.content_helper_sha256,
775 "features": list(metadata.features),
776 "support_reason": metadata.support_reason,
777 "updated_at": metadata.updated_at,
778 }
779 )
780 return rows
781
782
783 def select_computer_use_target_sid(context_id: str) -> str | None:
784 with _state_lock:
785 for sid in _candidate_sids_for_context_locked(context_id):
786 metadata = _sid_computer_use_metadata.get(sid)
787 if metadata and metadata.supported and metadata.enabled:
788 return sid
789 return None
790
791
792 def store_pending_file_op(
793 op_id: str,
794 *,
795 sid: str,
796 future: asyncio.Future[dict[str, Any]],
797 loop: asyncio.AbstractEventLoop,
798 context_id: str | None = None,
799 ) -> None:
800 with _state_lock:
801 _pending_file_ops[op_id] = PendingFileOperation(
802 sid=sid,
803 loop=loop,
804 future=future,
805 context_id=context_id,
806 )
807
808
809 def clear_pending_file_op(op_id: str) -> None:
810 with _state_lock:
811 _pending_file_ops.pop(op_id, None)
812
813
814 def resolve_pending_file_op(
815 op_id: str,
816 *,
817 sid: str,
818 payload: dict[str, Any],
819 ) -> bool:
820 if payload.get("chunked") is True:
821 return _resolve_pending_file_chunk(op_id, sid=sid, payload=payload)
822 return _resolve_pending(_pending_file_ops, op_id, sid=sid, payload=payload)
823
824
825 def _resolve_pending_file_chunk(
826 op_id: str,
827 *,
828 sid: str,
829 payload: dict[str, Any],
830 ) -> bool:
831 error = _validate_file_chunk_payload(payload)
832 if error:
833 return _fail_pending(
834 _pending_file_ops,
835 op_id,
836 sid=sid,
837 error=f"Invalid chunked file operation result: {error}",
838 )
839
840 chunk_index = int(payload["chunk_index"])
841 chunk_count = int(payload["chunk_count"])
842 encoded = str(payload.get("data") or "")
843 try:
844 chunk = base64.b64decode(encoded.encode("ascii"), validate=True)
845 except (UnicodeEncodeError, binascii.Error) as exc:
846 return _fail_pending(
847 _pending_file_ops,
848 op_id,
849 sid=sid,
850 error=f"Invalid chunked file operation result: {exc}",
851 )
852
853 with _state_lock:
854 pending = _pending_file_ops.get(op_id)
855 if pending is None or pending.sid != sid:
856 return False
857
858 if pending.chunk_count is None:
859 pending.chunk_count = chunk_count
860 elif pending.chunk_count != chunk_count:
861 _pending_file_ops.pop(op_id, None)
862 pending.loop.call_soon_threadsafe(
863 _set_future_result,
864 pending.future,
865 {
866 "op_id": op_id,
867 "ok": False,
868 "error": "Invalid chunked file operation result: chunk_count changed",
869 },
870 )
871 return True
872
873 pending.chunks[chunk_index] = chunk
874 if len(pending.chunks) < chunk_count:
875 return True
876
877 ordered = [pending.chunks[index] for index in range(chunk_count)]
878 _pending_file_ops.pop(op_id, None)
879
880 try:
881 assembled = b"".join(ordered).decode("utf-8")
882 result = json.loads(assembled)
883 if not isinstance(result, dict):
884 raise ValueError("decoded result is not an object")
885 except Exception as exc:
886 result = {
887 "op_id": op_id,
888 "ok": False,
889 "error": f"Invalid chunked file operation result: {exc}",
890 }
891
892 pending.loop.call_soon_threadsafe(_set_future_result, pending.future, result)
893 return True
894
895
896 def _validate_file_chunk_payload(payload: dict[str, Any]) -> str:
897 if payload.get("encoding") != "json+base64":
898 return "encoding must be json+base64"
899
900 try:
901 chunk_index = int(payload.get("chunk_index"))
902 chunk_count = int(payload.get("chunk_count"))
903 except (TypeError, ValueError):
904 return "chunk_index and chunk_count must be integers"
905
906 if chunk_count <= 0:
907 return "chunk_count must be positive"
908 if chunk_index < 0 or chunk_index >= chunk_count:
909 return "chunk_index out of range"
910 if not isinstance(payload.get("data"), str):
911 return "data must be a string"
912 return ""
913
914
915 def fail_pending_file_op(
916 op_id: str,
917 *,
918 sid: str | None = None,
919 error: str,
920 ) -> bool:
921 return _fail_pending(_pending_file_ops, op_id, sid=sid, error=error)
922
923
924 def fail_pending_file_ops_for_sid(sid: str, *, error: str) -> None:
925 _fail_pending_for_sid(_pending_file_ops, sid=sid, error=error)
926
927
928 def store_pending_exec_op(
929 op_id: str,
930 *,
931 sid: str,
932 future: asyncio.Future[dict[str, Any]],
933 loop: asyncio.AbstractEventLoop,
934 context_id: str | None = None,
935 ) -> None:
936 with _state_lock:
937 _pending_exec_ops[op_id] = PendingExecOperation(
938 sid=sid,
939 loop=loop,
940 future=future,
941 context_id=context_id,
942 )
943
944
945 def clear_pending_exec_op(op_id: str) -> None:
946 with _state_lock:
947 _pending_exec_ops.pop(op_id, None)
948
949
950 def resolve_pending_exec_op(
951 op_id: str,
952 *,
953 sid: str,
954 payload: dict[str, Any],
955 ) -> bool:
956 return _resolve_pending(_pending_exec_ops, op_id, sid=sid, payload=payload)
957
958
959 def fail_pending_exec_op(
960 op_id: str,
961 *,
962 sid: str | None = None,
963 error: str,
964 ) -> bool:
965 return _fail_pending(_pending_exec_ops, op_id, sid=sid, error=error)
966
967
968 def fail_pending_exec_ops_for_sid(sid: str, *, error: str) -> None:
969 _fail_pending_for_sid(_pending_exec_ops, sid=sid, error=error)
970
971
972 def store_pending_computer_use_op(
973 op_id: str,
974 *,
975 sid: str,
976 future: asyncio.Future[dict[str, Any]],
977 loop: asyncio.AbstractEventLoop,
978 context_id: str | None = None,
979 ) -> None:
980 with _state_lock:
981 _pending_computer_use_ops[op_id] = PendingComputerUseOperation(
982 sid=sid,
983 loop=loop,
984 future=future,
985 context_id=context_id,
986 )
987
988
989 def clear_pending_computer_use_op(op_id: str) -> None:
990 with _state_lock:
991 _pending_computer_use_ops.pop(op_id, None)
992
993
994 def resolve_pending_computer_use_op(
995 op_id: str,
996 *,
997 sid: str,
998 payload: dict[str, Any],
999 ) -> bool:
1000 return _resolve_pending(_pending_computer_use_ops, op_id, sid=sid, payload=payload)
1001
1002
1003 def fail_pending_computer_use_op(
1004 op_id: str,
1005 *,
1006 sid: str | None = None,
1007 error: str,
1008 ) -> bool:
1009 return _fail_pending(_pending_computer_use_ops, op_id, sid=sid, error=error)
1010
1011
1012 def fail_pending_computer_use_ops_for_sid(sid: str, *, error: str) -> None:
1013 _fail_pending_for_sid(_pending_computer_use_ops, sid=sid, error=error)
1014
1015
1016 def store_pending_browser_op(
1017 op_id: str,
1018 *,
1019 sid: str,
1020 future: asyncio.Future[dict[str, Any]],
1021 loop: asyncio.AbstractEventLoop,
1022 context_id: str | None = None,
1023 ) -> None:
1024 with _state_lock:
1025 _pending_browser_ops[op_id] = PendingBrowserOperation(
1026 sid=sid,
1027 loop=loop,
1028 future=future,
1029 context_id=context_id,
1030 )
1031
1032
1033 def clear_pending_browser_op(op_id: str) -> None:
1034 with _state_lock:
1035 _pending_browser_ops.pop(op_id, None)
1036
1037
1038 def resolve_pending_browser_op(
1039 op_id: str,
1040 *,
1041 sid: str,
1042 payload: dict[str, Any],
1043 ) -> bool:
1044 return _resolve_pending(_pending_browser_ops, op_id, sid=sid, payload=payload)
1045
1046
1047 def fail_pending_browser_op(
1048 op_id: str,
1049 *,
1050 sid: str | None = None,
1051 error: str,
1052 ) -> bool:
1053 return _fail_pending(_pending_browser_ops, op_id, sid=sid, error=error)
1054
1055
1056 def fail_pending_browser_ops_for_sid(sid: str, *, error: str) -> None:
1057 _fail_pending_for_sid(_pending_browser_ops, sid=sid, error=error)
1058
1059
1060 def store_pending_gateway_control(
1061 request_id: str,
1062 *,
1063 sid: str,
1064 future: asyncio.Future[dict[str, Any]],
1065 loop: asyncio.AbstractEventLoop,
1066 ) -> None:
1067 with _state_lock:
1068 _pending_gateway_controls[request_id] = PendingGatewayControl(
1069 sid=sid,
1070 loop=loop,
1071 future=future,
1072 )
1073
1074
1075 def clear_pending_gateway_control(request_id: str) -> None:
1076 with _state_lock:
1077 _pending_gateway_controls.pop(request_id, None)
1078
1079
1080 def resolve_pending_gateway_control(
1081 request_id: str,
1082 *,
1083 sid: str,
1084 payload: dict[str, Any],
1085 ) -> bool:
1086 gateway = payload.get("gateway")
1087 if isinstance(gateway, dict):
1088 stored = store_sid_launcher_gateway_metadata(sid, gateway)
1089 if stored is not None:
1090 active = stored.master_enabled and stored.state != "disconnected"
1091 files_enabled = active and stored.scopes["files"]
1092 writes_enabled = files_enabled and stored.scopes["file_write"]
1093 store_sid_remote_file_metadata(
1094 sid,
1095 {
1096 "enabled": files_enabled,
1097 "write_enabled": writes_enabled,
1098 "mode": "read_write" if writes_enabled else "read_only",
1099 },
1100 )
1101 store_sid_remote_exec_metadata(
1102 sid,
1103 {"enabled": active and stored.scopes["code_execution"]},
1104 )
1105 return _resolve_pending(_pending_gateway_controls, request_id, sid=sid, payload=payload)
1106
1107
1108 def fail_pending_gateway_controls_for_sid(sid: str, *, error: str) -> None:
1109 _fail_pending_for_sid(_pending_gateway_controls, sid=sid, error=error)
1110
1111
1112 def _resolve_pending(
1113 registry: dict[
1114 str,
1115 PendingFileOperation
1116 | PendingExecOperation
1117 | PendingComputerUseOperation
1118 | PendingBrowserOperation
1119 | PendingGatewayControl,
1120 ],
1121 op_id: str,
1122 *,
1123 sid: str,
1124 payload: dict[str, Any],
1125 ) -> bool:
1126 with _state_lock:
1127 pending = registry.get(op_id)
1128 if pending is None or pending.sid != sid:
1129 return False
1130 registry.pop(op_id, None)
1131
1132 pending.loop.call_soon_threadsafe(_set_future_result, pending.future, dict(payload))
1133 return True
1134
1135
1136 def _fail_pending(
1137 registry: dict[
1138 str,
1139 PendingFileOperation
1140 | PendingExecOperation
1141 | PendingComputerUseOperation
1142 | PendingBrowserOperation
1143 | PendingGatewayControl,
1144 ],
1145 op_id: str,
1146 *,
1147 sid: str | None,
1148 error: str,
1149 ) -> bool:
1150 with _state_lock:
1151 pending = registry.get(op_id)
1152 if pending is None:
1153 return False
1154 if sid is not None and pending.sid != sid:
1155 return False
1156 registry.pop(op_id, None)
1157
1158 pending.loop.call_soon_threadsafe(
1159 _set_future_result,
1160 pending.future,
1161 {"op_id": op_id, "ok": False, "error": error},
1162 )
1163 return True
1164
1165
1166 def _fail_pending_for_sid(
1167 registry: dict[
1168 str,
1169 PendingFileOperation
1170 | PendingExecOperation
1171 | PendingComputerUseOperation
1172 | PendingBrowserOperation
1173 | PendingGatewayControl,
1174 ],
1175 *,
1176 sid: str,
1177 error: str,
1178 ) -> None:
1179 with _state_lock:
1180 matches = [
1181 (op_id, pending)
1182 for op_id, pending in registry.items()
1183 if pending.sid == sid
1184 ]
1185 for op_id, _pending in matches:
1186 registry.pop(op_id, None)
1187
1188 for op_id, pending in matches:
1189 pending.loop.call_soon_threadsafe(
1190 _set_future_result,
1191 pending.future,
1192 {"op_id": op_id, "ok": False, "error": error},
1193 )
1194
1195
1196 def _set_future_result(
1197 future: asyncio.Future[dict[str, Any]],
1198 payload: dict[str, Any],
1199 ) -> None:
1200 if not future.done():
1201 future.set_result(payload)