@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.
- package/README.md +21 -7
- package/package.json +1 -1
- package/server/VERSION +1 -1
- package/server/agent_server.py +4913 -660
- package/server/agentsdock_chats.py +2 -1
- package/server/chat_mailbox.py +23 -6
- package/server/claude_history_repair.py +45 -4
- package/server/claude_model_catalog.py +200 -0
- package/server/claude_sdk_client.py +63 -24
- package/server/claude_side_question.py +1 -8
- package/server/codex_app_server.py +78 -30
- package/server/codex_auth.py +119 -0
- package/server/codex_provider.py +1 -1
- package/server/codex_side_question.py +154 -57
- package/server/cursor_agent_client.py +27 -1
- package/server/cursor_history.py +219 -0
- package/server/cursor_provider_mcp.py +290 -0
- package/server/execution_durability.py +10 -6
- package/server/execution_preparation.py +6 -2
- package/server/install.sh +281 -78
- package/server/instances.sh +15 -0
- package/server/interactive_chat_native.py +4 -3
- package/server/interactive_chat_projection.py +6 -19
- package/server/interactive_chat_share_routes.py +71 -45
- package/server/interactive_chat_share_web.py +2 -2
- package/server/interactive_chat_shares.py +6 -11
- package/server/local_session_ownership.py +232 -0
- package/server/opencode_agent_client.py +653 -0
- package/server/provider_commands.py +579 -0
- package/server/provider_usage.py +241 -0
- package/server/public_chat_shares.py +60 -62
- package/server/public_chat_transcript.py +9 -31
- package/server/server_instances.py +931 -0
- package/server/shared_chat_video_stream.py +21 -2
- package/server/shared_chat_videos.py +54 -24
- package/server/side_questions.py +493 -10
- package/server/uninstall.sh +95 -17
- package/server/update_preparation.py +4 -1
- 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
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
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
|
-
|
|
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,
|
package/server/codex_auth.py
CHANGED
|
@@ -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:
|
package/server/codex_provider.py
CHANGED
|
@@ -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
|
|