@agentsdock/server 1.0.7-beta.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +21 -7
  2. package/package.json +1 -1
  3. package/server/VERSION +1 -1
  4. package/server/agent_server.py +4913 -660
  5. package/server/agentsdock_chats.py +2 -1
  6. package/server/chat_mailbox.py +23 -6
  7. package/server/claude_history_repair.py +45 -4
  8. package/server/claude_model_catalog.py +200 -0
  9. package/server/claude_sdk_client.py +63 -24
  10. package/server/claude_side_question.py +1 -8
  11. package/server/codex_app_server.py +78 -30
  12. package/server/codex_auth.py +119 -0
  13. package/server/codex_provider.py +1 -1
  14. package/server/codex_side_question.py +154 -57
  15. package/server/cursor_agent_client.py +27 -1
  16. package/server/cursor_history.py +219 -0
  17. package/server/cursor_provider_mcp.py +290 -0
  18. package/server/execution_durability.py +10 -6
  19. package/server/execution_preparation.py +6 -2
  20. package/server/install.sh +281 -78
  21. package/server/instances.sh +15 -0
  22. package/server/interactive_chat_native.py +4 -3
  23. package/server/interactive_chat_projection.py +6 -19
  24. package/server/interactive_chat_share_routes.py +71 -45
  25. package/server/interactive_chat_share_web.py +2 -2
  26. package/server/interactive_chat_shares.py +6 -11
  27. package/server/local_session_ownership.py +232 -0
  28. package/server/opencode_agent_client.py +653 -0
  29. package/server/provider_commands.py +579 -0
  30. package/server/provider_usage.py +241 -0
  31. package/server/public_chat_shares.py +60 -62
  32. package/server/public_chat_transcript.py +9 -31
  33. package/server/server_instances.py +931 -0
  34. package/server/shared_chat_video_stream.py +21 -2
  35. package/server/shared_chat_videos.py +54 -24
  36. package/server/side_questions.py +493 -10
  37. package/server/uninstall.sh +95 -17
  38. package/server/update_preparation.py +4 -1
  39. package/server/update_runner.py +16 -0
@@ -562,6 +562,7 @@ class CodexAppServerClient:
562
562
  process_factory: ProcessFactory | None = None,
563
563
  before_start: Callable[[], Awaitable[Any]] | None = None,
564
564
  server_request_handler: ServerRequestHandler | None = None,
565
+ notification_guard: Callable[[dict[str, Any]], bool] | None = None,
565
566
  initialize_params: dict[str, Any] | None = None,
566
567
  on_process_started: ProcessLifecycleHook | None = None,
567
568
  on_process_exited: ProcessLifecycleHook | None = None,
@@ -599,6 +600,7 @@ class CodexAppServerClient:
599
600
  # arbitrary/recycled PIDs, so never derive a signal target from them.
600
601
  self._owns_spawned_process_groups = process_factory is None and os.name == "posix"
601
602
  self._server_request_handler = server_request_handler or decline_server_request
603
+ self._notification_guard = notification_guard
602
604
  self._initialize_params = initialize_params or {
603
605
  "clientInfo": {
604
606
  "name": "agents_server",
@@ -635,6 +637,8 @@ class CodexAppServerClient:
635
637
  self._thread_names: dict[str, str] = {}
636
638
  self._subscriptions: set[CodexAppServerSubscription] = set()
637
639
  self._notification_handlers: set[NotificationHandler] = set()
640
+ self._account_usage_handlers: set[NotificationHandler] = set()
641
+ self._account_epoch = 0
638
642
  self._callback_tasks: set[asyncio.Task[Any]] = set()
639
643
  self._callback_tails: dict[
640
644
  tuple[NotificationHandler, str],
@@ -1401,10 +1405,19 @@ class CodexAppServerClient:
1401
1405
  # Authentication notifications are not chat events. Do not expose
1402
1406
  # account metadata or upstream login errors to event subscribers.
1403
1407
  if method in {"account/login/completed", "account/updated"}:
1408
+ self._account_epoch += 1
1409
+ self._dispatch_notification_handlers(
1410
+ {"method": "account/changed", "params": {}},
1411
+ tuple(self._account_usage_handlers), ordered=False,
1412
+ )
1404
1413
  return
1405
1414
  params = notification.get("params")
1406
1415
  if not isinstance(params, dict):
1407
1416
  return
1417
+ if method == "account/rateLimits/updated":
1418
+ self._dispatch_notification_handlers(
1419
+ notification, tuple(self._account_usage_handlers), ordered=False,
1420
+ )
1408
1421
 
1409
1422
  if method == "serverRequest/resolved":
1410
1423
  request_id = params.get("requestId")
@@ -1457,35 +1470,14 @@ class CodexAppServerClient:
1457
1470
  "at": time.time(),
1458
1471
  })
1459
1472
 
1460
- for handler in tuple(self._notification_handlers):
1461
- owner = (handler, thread_id or "")
1462
- previous = self._callback_tails.get(owner)
1463
- if previous is None:
1464
- try:
1465
- result = handler(notification)
1466
- except Exception:
1467
- continue
1468
- if not inspect.isawaitable(result):
1469
- # Preserve the original inline contract for synchronous
1470
- # caches consumed by immediately following server requests.
1471
- continue
1472
- task = asyncio.ensure_future(result)
1473
- else:
1474
- task = asyncio.create_task(
1475
- self._invoke_notification_handler(
1476
- handler,
1477
- notification,
1478
- previous,
1479
- )
1480
- )
1481
- self._callback_tails[owner] = task
1482
- self._callback_tasks.add(task)
1483
- task.add_done_callback(
1484
- lambda completed, owner=owner: self._finish_callback_task(
1485
- owner,
1486
- completed,
1487
- )
1488
- )
1473
+ handlers = tuple(self._notification_handlers)
1474
+ try:
1475
+ allowed = self._notification_guard is None or self._notification_guard(notification)
1476
+ except Exception:
1477
+ allowed = False
1478
+ if not allowed:
1479
+ handlers = ()
1480
+ self._dispatch_notification_handlers(notification, handlers)
1489
1481
 
1490
1482
  # Synchronous handlers (including the scoped fork watcher) must see
1491
1483
  # whether this identity was known *before* this notification. Record a
@@ -1516,6 +1508,54 @@ class CodexAppServerClient:
1516
1508
  include_thread_subscription=True,
1517
1509
  )
1518
1510
 
1511
+ @property
1512
+ def account_epoch(self) -> int:
1513
+ return self._account_epoch
1514
+
1515
+ def add_account_usage_handler(self, handler: NotificationHandler) -> None:
1516
+ """Observe rate limits/auth invalidation in native receive order.
1517
+
1518
+ Synchronous observers update their cache inline; any returned async
1519
+ invalidation broadcast is tracked without postponing later cache edits.
1520
+ Authentication payloads are never passed to these handlers.
1521
+ """
1522
+ self._account_usage_handlers.add(handler)
1523
+
1524
+ def _dispatch_notification_handlers(
1525
+ self, notification: dict[str, Any], handlers: tuple[NotificationHandler, ...],
1526
+ *, ordered: bool = True,
1527
+ ) -> None:
1528
+ thread_id, _turn_id = _notification_scope(notification)
1529
+ for handler in handlers:
1530
+ owner = (handler, thread_id or "")
1531
+ previous = self._callback_tails.get(owner) if ordered else None
1532
+ if previous is None:
1533
+ try:
1534
+ result = handler(notification)
1535
+ except Exception:
1536
+ continue
1537
+ if not inspect.isawaitable(result):
1538
+ # Preserve the original inline contract for synchronous
1539
+ # caches consumed by immediately following server requests.
1540
+ continue
1541
+ task = asyncio.ensure_future(result)
1542
+ else:
1543
+ task = asyncio.create_task(
1544
+ self._invoke_notification_handler(
1545
+ handler,
1546
+ notification,
1547
+ previous,
1548
+ )
1549
+ )
1550
+ self._callback_tails[owner] = task
1551
+ self._callback_tasks.add(task)
1552
+ task.add_done_callback(
1553
+ lambda completed, owner=owner: self._finish_callback_task(
1554
+ owner,
1555
+ completed,
1556
+ )
1557
+ )
1558
+
1519
1559
  async def _invoke_notification_handler(
1520
1560
  self,
1521
1561
  handler: NotificationHandler,
@@ -1530,6 +1570,8 @@ class CodexAppServerClient:
1530
1570
  # notification already received for the same handler.
1531
1571
  pass
1532
1572
  try:
1573
+ if self._notification_guard is not None and not self._notification_guard(notification):
1574
+ return
1533
1575
  result = handler(notification)
1534
1576
  except Exception:
1535
1577
  return
@@ -1749,7 +1791,11 @@ class CodexAppServerClient:
1749
1791
  isinstance(exc, CodexAppServerDisconnected)
1750
1792
  and exc.request_sent
1751
1793
  )
1752
- if ambiguous:
1794
+ # Ephemeral forks disappear with their owning process. After
1795
+ # explicit close there can be no late child left to delete;
1796
+ # durable forks still require their usual ambiguity cleanup.
1797
+ closing_ephemeral = payload.get("ephemeral") is True and self._closing
1798
+ if ambiguous and not closing_ephemeral:
1753
1799
  cleanup = asyncio.create_task(
1754
1800
  self._delete_late_fork_child(late_children)
1755
1801
  )
@@ -2696,6 +2742,7 @@ class CodexAppServerManager:
2696
2742
  process_factory: ProcessFactory | None = None,
2697
2743
  before_start: Callable[[], Awaitable[Any]] | None = None,
2698
2744
  server_request_handler: ServerRequestHandler | None = None,
2745
+ notification_guard: Callable[[dict[str, Any]], bool] | None = None,
2699
2746
  initialize_params: dict[str, Any] | None = None,
2700
2747
  on_process_started: ProcessLifecycleHook | None = None,
2701
2748
  on_process_exited: ProcessLifecycleHook | None = None,
@@ -2715,6 +2762,7 @@ class CodexAppServerManager:
2715
2762
  process_factory=process_factory,
2716
2763
  before_start=before_start,
2717
2764
  server_request_handler=server_request_handler,
2765
+ notification_guard=notification_guard,
2718
2766
  initialize_params=initialize_params,
2719
2767
  on_process_started=on_process_started,
2720
2768
  on_process_exited=on_process_exited,
@@ -1,7 +1,17 @@
1
1
  """Read-only native Codex authentication; custom keys belong to codex_provider."""
2
2
  from __future__ import annotations
3
3
 
4
+ import base64
5
+ from dataclasses import dataclass, field
6
+ import hashlib
7
+ import hmac
8
+ import json
9
+ import os
10
+ from pathlib import Path
4
11
  import re
12
+ import secrets
13
+ import stat
14
+ import tomllib
5
15
 
6
16
  from fastapi import APIRouter, HTTPException, Request
7
17
  from fastapi.responses import JSONResponse
@@ -11,6 +21,115 @@ MAX_BODY_BYTES = 8192
11
21
  MAX_API_KEY_CHARS = 4096
12
22
  AUTH_TIMEOUT_SECONDS = 30.0
13
23
  BUSY_MESSAGE = "Wait for Codex chats, goals, queued turns and Side chat requests to finish before changing authentication."
24
+ HANDOFF_MESSAGE = (
25
+ "Refreshing Codex sign-in. Wait for this chat's running work, approvals, "
26
+ "goals and background terminals to finish, then retry. Your message has "
27
+ "not been sent to Codex."
28
+ )
29
+ _REVISION_KEY = secrets.token_bytes(32)
30
+ _MAX_AUTH_BYTES = 128 * 1024
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class LoginRevision:
35
+ # Process-private change detection only, never authentication evidence.
36
+ # Do not put paths, account IDs, credentials or digests in repr/logs/API.
37
+ value: bytes = field(repr=False)
38
+
39
+
40
+ def _bounded_file(path: Path) -> bytes:
41
+ fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
42
+ with os.fdopen(fd, "rb") as stream:
43
+ before = os.fstat(stream.fileno())
44
+ if not stat.S_ISREG(before.st_mode) or before.st_size > _MAX_AUTH_BYTES:
45
+ raise ValueError("unsupported credential file")
46
+ raw = stream.read(_MAX_AUTH_BYTES + 1)
47
+ after = os.fstat(stream.fileno())
48
+ if len(raw) > _MAX_AUTH_BYTES or (before.st_mtime_ns, before.st_size) != (after.st_mtime_ns, after.st_size):
49
+ raise ValueError("credential file changed during read")
50
+ return raw
51
+
52
+
53
+ def _native_login_config(env: dict[str, str], cwd: str) -> tuple[Path, dict]:
54
+ home = env.get("HOME")
55
+ location = env.get("CODEX_HOME")
56
+ if not location and not home:
57
+ raise ValueError("native home unavailable")
58
+ if location and location.startswith("~/"):
59
+ if not home:
60
+ raise ValueError("native home unavailable")
61
+ location = str(Path(home) / location[2:])
62
+ root = Path(location) if location else Path(home) / ".codex"
63
+ if not root.is_absolute():
64
+ root = Path(cwd) / root
65
+ try:
66
+ config = tomllib.loads(_bounded_file(root / "config.toml").decode())
67
+ except FileNotFoundError:
68
+ config = {}
69
+ return root, config
70
+
71
+
72
+ def native_login_handoff_supported(env: dict[str, str], *, cwd: str) -> bool:
73
+ try:
74
+ _, config = _native_login_config(env, cwd)
75
+ # A process-only login cannot be recovered by starting a replacement.
76
+ # A malformed config must not retire an otherwise working generation.
77
+ return config.get("cli_auth_credentials_store", "file") in {"file", "keyring", "auto"}
78
+ except (OSError, ValueError, TypeError, RecursionError):
79
+ return False
80
+
81
+
82
+ def native_login_revision(env: dict[str, str], *, cwd: str) -> LoginRevision | None:
83
+ """Conservative file-store signal; native Codex still owns all credentials.
84
+
85
+ Ignore rotating access/refresh tokens and last_refresh. OIDC auth_time is
86
+ stable across refresh but changes on an interactive authentication. Without
87
+ it we can detect an account switch, not prove a same-account re-login;
88
+ Recheck CLIs provides an explicit handoff for that case and keyring stores.
89
+ Missing/malformed files are not evidence of a new login. No secret survives
90
+ this bounded read except a process-keyed comparison digest.
91
+ """
92
+ try:
93
+ root, config = _native_login_config(env, cwd)
94
+ if config.get("cli_auth_credentials_store", "file") != "file":
95
+ return None # Never infer keyring/auto/ephemeral state from a leftover file.
96
+ if any(env.get(key) for key in ("CODEX_ACCESS_TOKEN", "CODEX_API_KEY", "OPENAI_API_KEY", "OPENAI_IDENTITY_TOKEN_FILE")):
97
+ return None
98
+ data = json.loads(_bounded_file(root / "auth.json"))
99
+ if not isinstance(data, dict):
100
+ return None
101
+ key = data.get("OPENAI_API_KEY")
102
+ if isinstance(key, str) and key:
103
+ identity = ("apiKey", key)
104
+ else:
105
+ tokens = data.get("tokens")
106
+ if not isinstance(tokens, dict) or data.get("auth_mode") not in (None, "chatgpt"):
107
+ return None
108
+ encoded = tokens.get("id_token")
109
+ if not isinstance(encoded, str) or len(encoded) > 32768:
110
+ return None
111
+ parts = encoded.split(".")
112
+ if len(parts) != 3:
113
+ return None
114
+ claims = json.loads(base64.urlsafe_b64decode(parts[1] + "=" * (-len(parts[1]) % 4)))
115
+ if not isinstance(claims, dict):
116
+ return None
117
+ details = claims.get("https://api.openai.com/auth") or {}
118
+ if not isinstance(details, dict):
119
+ return None
120
+ account = tokens.get("account_id") or details.get("chatgpt_account_id")
121
+ subject = claims.get("sub")
122
+ if not isinstance(account, str) or not account or not isinstance(subject, str) or not subject:
123
+ return None
124
+ auth_time = claims.get("auth_time")
125
+ if auth_time is not None and (isinstance(auth_time, bool) or not isinstance(auth_time, int)):
126
+ return None
127
+ # Unverified claims are a change hint, never permission/account proof.
128
+ identity = ("chatgpt", account, subject, auth_time)
129
+ raw = json.dumps((str(root.resolve()), identity), separators=(",", ":")).encode()
130
+ return LoginRevision(hmac.new(_REVISION_KEY, raw, hashlib.sha256).digest())
131
+ except (OSError, ValueError, TypeError, RecursionError):
132
+ return None
14
133
 
15
134
 
16
135
  def capability(*, available: bool) -> dict:
@@ -21,7 +21,7 @@ from fastapi.responses import JSONResponse
21
21
 
22
22
  from codex_auth import validate_api_key
23
23
  from codex_app_server import CodexAppServerManager, decline_server_request
24
- from codex_side_question import isolated_config, _verify_protocol
24
+ from codex_side_question import isolated_config, _verify_isolated_protocol as _verify_protocol
25
25
  from side_questions import isolated_environment, run_isolated_command
26
26
 
27
27