@agentsdock/server 1.0.7-beta.5 → 1.0.8
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 +20 -6
- package/package.json +1 -1
- package/server/VERSION +1 -1
- package/server/agent_server.py +5283 -698
- 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 +90 -30
- package/server/codex_auth.py +119 -0
- package/server/codex_history_repair.py +79 -14
- package/server/codex_provider.py +160 -77
- package/server/codex_side_question.py +154 -57
- package/server/cursor_agent_client.py +27 -1
- package/server/cursor_api_key.py +64 -0
- 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 +282 -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 +685 -0
- package/server/provider_commands.py +579 -0
- package/server/provider_connections.py +665 -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/pyproject.toml +1 -0
- 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/title_generation.py +4 -1
- package/server/uninstall.sh +95 -17
- package/server/update_preparation.py +4 -1
- package/server/update_runner.py +16 -0
- package/server/uv.lock +3 -1
|
@@ -354,6 +354,12 @@ class CodexAppServerTurn:
|
|
|
354
354
|
# Native turn identities are retained only for this owner's lifetime.
|
|
355
355
|
# Delayed duplicate starts must not resurrect one of its finished turns.
|
|
356
356
|
_completed_turn_ids: set[str] = field(default_factory=set)
|
|
357
|
+
# Unlike turn_id, this identity does not follow native goal/child turns.
|
|
358
|
+
# History repair needs the turn that accepted the original user input.
|
|
359
|
+
initial_turn_id: str = field(init=False, default="")
|
|
360
|
+
|
|
361
|
+
def __post_init__(self) -> None:
|
|
362
|
+
self.initial_turn_id = self.turn_id
|
|
357
363
|
|
|
358
364
|
async def next_notification(self, timeout: float | None = None) -> dict[str, Any]:
|
|
359
365
|
return await self._subscription.next_notification(timeout)
|
|
@@ -498,6 +504,8 @@ class CodexAppServerTurn:
|
|
|
498
504
|
f"provisional turn already bound to {self.turn_id}, not {resolved}"
|
|
499
505
|
)
|
|
500
506
|
self.turn_id = resolved
|
|
507
|
+
if not self.initial_turn_id:
|
|
508
|
+
self.initial_turn_id = resolved
|
|
501
509
|
if not self._retain_thread_stream:
|
|
502
510
|
self._subscription.turn_id = resolved
|
|
503
511
|
|
|
@@ -562,6 +570,7 @@ class CodexAppServerClient:
|
|
|
562
570
|
process_factory: ProcessFactory | None = None,
|
|
563
571
|
before_start: Callable[[], Awaitable[Any]] | None = None,
|
|
564
572
|
server_request_handler: ServerRequestHandler | None = None,
|
|
573
|
+
notification_guard: Callable[[dict[str, Any]], bool] | None = None,
|
|
565
574
|
initialize_params: dict[str, Any] | None = None,
|
|
566
575
|
on_process_started: ProcessLifecycleHook | None = None,
|
|
567
576
|
on_process_exited: ProcessLifecycleHook | None = None,
|
|
@@ -599,6 +608,7 @@ class CodexAppServerClient:
|
|
|
599
608
|
# arbitrary/recycled PIDs, so never derive a signal target from them.
|
|
600
609
|
self._owns_spawned_process_groups = process_factory is None and os.name == "posix"
|
|
601
610
|
self._server_request_handler = server_request_handler or decline_server_request
|
|
611
|
+
self._notification_guard = notification_guard
|
|
602
612
|
self._initialize_params = initialize_params or {
|
|
603
613
|
"clientInfo": {
|
|
604
614
|
"name": "agents_server",
|
|
@@ -635,6 +645,8 @@ class CodexAppServerClient:
|
|
|
635
645
|
self._thread_names: dict[str, str] = {}
|
|
636
646
|
self._subscriptions: set[CodexAppServerSubscription] = set()
|
|
637
647
|
self._notification_handlers: set[NotificationHandler] = set()
|
|
648
|
+
self._account_usage_handlers: set[NotificationHandler] = set()
|
|
649
|
+
self._account_epoch = 0
|
|
638
650
|
self._callback_tasks: set[asyncio.Task[Any]] = set()
|
|
639
651
|
self._callback_tails: dict[
|
|
640
652
|
tuple[NotificationHandler, str],
|
|
@@ -1401,10 +1413,19 @@ class CodexAppServerClient:
|
|
|
1401
1413
|
# Authentication notifications are not chat events. Do not expose
|
|
1402
1414
|
# account metadata or upstream login errors to event subscribers.
|
|
1403
1415
|
if method in {"account/login/completed", "account/updated"}:
|
|
1416
|
+
self._account_epoch += 1
|
|
1417
|
+
self._dispatch_notification_handlers(
|
|
1418
|
+
{"method": "account/changed", "params": {}},
|
|
1419
|
+
tuple(self._account_usage_handlers), ordered=False,
|
|
1420
|
+
)
|
|
1404
1421
|
return
|
|
1405
1422
|
params = notification.get("params")
|
|
1406
1423
|
if not isinstance(params, dict):
|
|
1407
1424
|
return
|
|
1425
|
+
if method == "account/rateLimits/updated":
|
|
1426
|
+
self._dispatch_notification_handlers(
|
|
1427
|
+
notification, tuple(self._account_usage_handlers), ordered=False,
|
|
1428
|
+
)
|
|
1408
1429
|
|
|
1409
1430
|
if method == "serverRequest/resolved":
|
|
1410
1431
|
request_id = params.get("requestId")
|
|
@@ -1438,6 +1459,8 @@ class CodexAppServerClient:
|
|
|
1438
1459
|
# keeping this id current is required for both event routing and
|
|
1439
1460
|
# turn/interrupt to target the work that is actually running.
|
|
1440
1461
|
if not active_turn.turn_id or method == "turn/started":
|
|
1462
|
+
if not active_turn.initial_turn_id:
|
|
1463
|
+
active_turn.initial_turn_id = turn_id
|
|
1441
1464
|
active_turn.turn_id = turn_id
|
|
1442
1465
|
if method == "turn/started":
|
|
1443
1466
|
active_turn._completed = False
|
|
@@ -1457,35 +1480,14 @@ class CodexAppServerClient:
|
|
|
1457
1480
|
"at": time.time(),
|
|
1458
1481
|
})
|
|
1459
1482
|
|
|
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
|
-
)
|
|
1483
|
+
handlers = tuple(self._notification_handlers)
|
|
1484
|
+
try:
|
|
1485
|
+
allowed = self._notification_guard is None or self._notification_guard(notification)
|
|
1486
|
+
except Exception:
|
|
1487
|
+
allowed = False
|
|
1488
|
+
if not allowed:
|
|
1489
|
+
handlers = ()
|
|
1490
|
+
self._dispatch_notification_handlers(notification, handlers)
|
|
1489
1491
|
|
|
1490
1492
|
# Synchronous handlers (including the scoped fork watcher) must see
|
|
1491
1493
|
# whether this identity was known *before* this notification. Record a
|
|
@@ -1516,6 +1518,54 @@ class CodexAppServerClient:
|
|
|
1516
1518
|
include_thread_subscription=True,
|
|
1517
1519
|
)
|
|
1518
1520
|
|
|
1521
|
+
@property
|
|
1522
|
+
def account_epoch(self) -> int:
|
|
1523
|
+
return self._account_epoch
|
|
1524
|
+
|
|
1525
|
+
def add_account_usage_handler(self, handler: NotificationHandler) -> None:
|
|
1526
|
+
"""Observe rate limits/auth invalidation in native receive order.
|
|
1527
|
+
|
|
1528
|
+
Synchronous observers update their cache inline; any returned async
|
|
1529
|
+
invalidation broadcast is tracked without postponing later cache edits.
|
|
1530
|
+
Authentication payloads are never passed to these handlers.
|
|
1531
|
+
"""
|
|
1532
|
+
self._account_usage_handlers.add(handler)
|
|
1533
|
+
|
|
1534
|
+
def _dispatch_notification_handlers(
|
|
1535
|
+
self, notification: dict[str, Any], handlers: tuple[NotificationHandler, ...],
|
|
1536
|
+
*, ordered: bool = True,
|
|
1537
|
+
) -> None:
|
|
1538
|
+
thread_id, _turn_id = _notification_scope(notification)
|
|
1539
|
+
for handler in handlers:
|
|
1540
|
+
owner = (handler, thread_id or "")
|
|
1541
|
+
previous = self._callback_tails.get(owner) if ordered else None
|
|
1542
|
+
if previous is None:
|
|
1543
|
+
try:
|
|
1544
|
+
result = handler(notification)
|
|
1545
|
+
except Exception:
|
|
1546
|
+
continue
|
|
1547
|
+
if not inspect.isawaitable(result):
|
|
1548
|
+
# Preserve the original inline contract for synchronous
|
|
1549
|
+
# caches consumed by immediately following server requests.
|
|
1550
|
+
continue
|
|
1551
|
+
task = asyncio.ensure_future(result)
|
|
1552
|
+
else:
|
|
1553
|
+
task = asyncio.create_task(
|
|
1554
|
+
self._invoke_notification_handler(
|
|
1555
|
+
handler,
|
|
1556
|
+
notification,
|
|
1557
|
+
previous,
|
|
1558
|
+
)
|
|
1559
|
+
)
|
|
1560
|
+
self._callback_tails[owner] = task
|
|
1561
|
+
self._callback_tasks.add(task)
|
|
1562
|
+
task.add_done_callback(
|
|
1563
|
+
lambda completed, owner=owner: self._finish_callback_task(
|
|
1564
|
+
owner,
|
|
1565
|
+
completed,
|
|
1566
|
+
)
|
|
1567
|
+
)
|
|
1568
|
+
|
|
1519
1569
|
async def _invoke_notification_handler(
|
|
1520
1570
|
self,
|
|
1521
1571
|
handler: NotificationHandler,
|
|
@@ -1530,6 +1580,8 @@ class CodexAppServerClient:
|
|
|
1530
1580
|
# notification already received for the same handler.
|
|
1531
1581
|
pass
|
|
1532
1582
|
try:
|
|
1583
|
+
if self._notification_guard is not None and not self._notification_guard(notification):
|
|
1584
|
+
return
|
|
1533
1585
|
result = handler(notification)
|
|
1534
1586
|
except Exception:
|
|
1535
1587
|
return
|
|
@@ -1749,7 +1801,11 @@ class CodexAppServerClient:
|
|
|
1749
1801
|
isinstance(exc, CodexAppServerDisconnected)
|
|
1750
1802
|
and exc.request_sent
|
|
1751
1803
|
)
|
|
1752
|
-
|
|
1804
|
+
# Ephemeral forks disappear with their owning process. After
|
|
1805
|
+
# explicit close there can be no late child left to delete;
|
|
1806
|
+
# durable forks still require their usual ambiguity cleanup.
|
|
1807
|
+
closing_ephemeral = payload.get("ephemeral") is True and self._closing
|
|
1808
|
+
if ambiguous and not closing_ephemeral:
|
|
1753
1809
|
cleanup = asyncio.create_task(
|
|
1754
1810
|
self._delete_late_fork_child(late_children)
|
|
1755
1811
|
)
|
|
@@ -2628,6 +2684,7 @@ class CodexAppServerClient:
|
|
|
2628
2684
|
# continue before its delayed start response is read.
|
|
2629
2685
|
# Acceptance of that exact completed ID is proven; leave
|
|
2630
2686
|
# the handle bound to the newer live turn, not the reply.
|
|
2687
|
+
provisional.initial_turn_id = turn_id
|
|
2631
2688
|
return provisional
|
|
2632
2689
|
raise CodexAppServerProtocolError(
|
|
2633
2690
|
"turn/start response did not match its early notifications",
|
|
@@ -2635,6 +2692,7 @@ class CodexAppServerClient:
|
|
|
2635
2692
|
safe_to_retry=False,
|
|
2636
2693
|
)
|
|
2637
2694
|
provisional.turn_id = turn_id
|
|
2695
|
+
provisional.initial_turn_id = turn_id
|
|
2638
2696
|
if not retain_thread_stream:
|
|
2639
2697
|
provisional._subscription.turn_id = turn_id
|
|
2640
2698
|
return provisional
|
|
@@ -2696,6 +2754,7 @@ class CodexAppServerManager:
|
|
|
2696
2754
|
process_factory: ProcessFactory | None = None,
|
|
2697
2755
|
before_start: Callable[[], Awaitable[Any]] | None = None,
|
|
2698
2756
|
server_request_handler: ServerRequestHandler | None = None,
|
|
2757
|
+
notification_guard: Callable[[dict[str, Any]], bool] | None = None,
|
|
2699
2758
|
initialize_params: dict[str, Any] | None = None,
|
|
2700
2759
|
on_process_started: ProcessLifecycleHook | None = None,
|
|
2701
2760
|
on_process_exited: ProcessLifecycleHook | None = None,
|
|
@@ -2715,6 +2774,7 @@ class CodexAppServerManager:
|
|
|
2715
2774
|
process_factory=process_factory,
|
|
2716
2775
|
before_start=before_start,
|
|
2717
2776
|
server_request_handler=server_request_handler,
|
|
2777
|
+
notification_guard=notification_guard,
|
|
2718
2778
|
initialize_params=initialize_params,
|
|
2719
2779
|
on_process_started=on_process_started,
|
|
2720
2780
|
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:
|
|
@@ -157,7 +157,7 @@ def _native_stamp(path: Path) -> tuple[int, int, int, int]:
|
|
|
157
157
|
|
|
158
158
|
|
|
159
159
|
def _native_records(path: Path, expected: tuple[int, int, int, int], budget: _NativeReadBudget,
|
|
160
|
-
*, end: int | None = None):
|
|
160
|
+
*, end: int | None = None, provider_source: bool = False):
|
|
161
161
|
"""Stream one fixed prefix; tool volume does not consume retained-key budget.
|
|
162
162
|
|
|
163
163
|
A source proof needs only the prefix ending at its newest checkpoint. Never
|
|
@@ -181,7 +181,14 @@ def _native_records(path: Path, expected: tuple[int, int, int, int], budget: _Na
|
|
|
181
181
|
if not line or len(line) > NATIVE_PROOF_LINE_BYTES or not line.endswith(b"\n"):
|
|
182
182
|
raise _Unproven()
|
|
183
183
|
offset += len(line)
|
|
184
|
-
|
|
184
|
+
try:
|
|
185
|
+
record = json.loads(line)
|
|
186
|
+
except (ValueError, UnicodeDecodeError):
|
|
187
|
+
if not provider_source:
|
|
188
|
+
raise
|
|
189
|
+
# The provider importer skips malformed records. Their exact
|
|
190
|
+
# bytes still participate in the checkpoint digest below.
|
|
191
|
+
record = {}
|
|
185
192
|
if not isinstance(record, dict):
|
|
186
193
|
raise _Unproven()
|
|
187
194
|
yield record, offset, line
|
|
@@ -229,6 +236,20 @@ def _public_assistant_item_id(event: dict) -> str | None:
|
|
|
229
236
|
return item_id if public and isinstance(item_id, str) and 0 < len(item_id) <= 256 else None
|
|
230
237
|
|
|
231
238
|
|
|
239
|
+
def _native_owned_turn_ids(event: dict) -> set[str]:
|
|
240
|
+
"""One logical run can retain its initial input across native continuations."""
|
|
241
|
+
return {value for field in ("provider_turn_id", "provider_initial_turn_id")
|
|
242
|
+
if isinstance(value := event.get(field), str) and 0 < len(value) <= 256}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _native_assistant_item_owners(assistant_items: dict, completed_runs: set[str]) -> dict:
|
|
246
|
+
owners: dict = {}
|
|
247
|
+
for run, item_id, body_key in assistant_items:
|
|
248
|
+
if run in completed_runs:
|
|
249
|
+
owners.setdefault((item_id, body_key), set()).add(run)
|
|
250
|
+
return owners
|
|
251
|
+
|
|
252
|
+
|
|
232
253
|
@dataclass
|
|
233
254
|
class _SourceBudget:
|
|
234
255
|
bytes_remaining: int
|
|
@@ -783,11 +804,12 @@ def _prove_native_replays(session_id: str, provider_id: str, events: Path, sourc
|
|
|
783
804
|
candidates.append(target)
|
|
784
805
|
elif event.get("imported") is not True and event.get("backend") in (None, "codex"):
|
|
785
806
|
if event.get("type") == "turn_finished" and event.get("backend") == "codex" and event.get("transport") == "app-server":
|
|
786
|
-
thread
|
|
787
|
-
if isinstance(thread, str) and _PROVIDER_ID.fullmatch(thread)
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
807
|
+
thread = event.get("provider_thread_id")
|
|
808
|
+
if isinstance(thread, str) and _PROVIDER_ID.fullmatch(thread):
|
|
809
|
+
for turn in _native_owned_turn_ids(event):
|
|
810
|
+
owned = owners.setdefault((thread, turn), set())
|
|
811
|
+
retained += run not in owned
|
|
812
|
+
owned.add(run)
|
|
791
813
|
kind = "user" if event.get("type") == "turn_started" else "assistant" if event.get("type") in ("assistant_text", "reasoning_summary", "turn_finished") else None
|
|
792
814
|
body = event.get("prompt") if kind == "user" else event.get("result_text") if event.get("type") == "turn_finished" else event.get("text")
|
|
793
815
|
if kind and isinstance(body, str) and body and len(body) <= 4 * 1024 * 1024:
|
|
@@ -868,10 +890,19 @@ def _prove_native_source(thread: str, source: Path, root: Path, batches: dict, c
|
|
|
868
890
|
compactions, candidate_assistants = {}, set()
|
|
869
891
|
compaction_tracker = CodexCompactionSummaryTracker(thread)
|
|
870
892
|
delivery_bodies, delivery_source_ids = {}, {}
|
|
893
|
+
completed_runs = {run for (owner_thread, _turn), runs in owners.items()
|
|
894
|
+
if owner_thread == thread for run in runs}
|
|
895
|
+
assistant_item_owners = _native_assistant_item_owners({
|
|
896
|
+
(run, item_id, body_key): event
|
|
897
|
+
for (run, kind, body_key), events in native.items() if kind == "assistant"
|
|
898
|
+
for event in events if (item_id := _public_assistant_item_id(event)) is not None
|
|
899
|
+
}, completed_runs)
|
|
900
|
+
assistant_owner_evidence: dict = {}
|
|
901
|
+
assistant_source_turns: dict = {}
|
|
871
902
|
allowed_header_owners, header_parents = {thread}, {}
|
|
872
903
|
context_turn = None
|
|
873
904
|
retained = 0
|
|
874
|
-
for record, offset, line in _native_records(source, stamp, budget, end=max(wanted)):
|
|
905
|
+
for record, offset, line in _native_records(source, stamp, budget, end=max(wanted), provider_source=True):
|
|
875
906
|
payload = record.get("payload")
|
|
876
907
|
if offset == len(line) or record.get("type") == "session_meta":
|
|
877
908
|
if record.get("type") != "session_meta" or not isinstance(payload, dict):
|
|
@@ -922,6 +953,14 @@ def _prove_native_source(thread: str, source: Path, root: Path, batches: dict, c
|
|
|
922
953
|
or not isinstance(timestamp, str) or not 0 < len(timestamp) <= 64):
|
|
923
954
|
continue
|
|
924
955
|
key = (turn, item["kind"], _text_key(item["text"]), item.get("source_text_sha256"))
|
|
956
|
+
if origin and item["kind"] == "assistant" and item.get("source_text_sha256") is None:
|
|
957
|
+
item_key = (origin["event_id"], _text_key(_native_assistant_text(item["text"])))
|
|
958
|
+
if item_key in assistant_item_owners:
|
|
959
|
+
assistant_source_turns.setdefault(item_key, set()).add(turn)
|
|
960
|
+
assistant_owner_evidence.setdefault(turn, []).append((offset, item_key))
|
|
961
|
+
retained += 1
|
|
962
|
+
if retained > MAX_KEYS:
|
|
963
|
+
raise _Unproven()
|
|
925
964
|
if origin and item["kind"] == "user" and any(run in deliveries.starts for run in owners.get((thread, turn), ())):
|
|
926
965
|
ids = delivery_source_ids.setdefault(turn, set())
|
|
927
966
|
if len(ids) < 2:
|
|
@@ -968,7 +1007,14 @@ def _prove_native_source(thread: str, source: Path, root: Path, batches: dict, c
|
|
|
968
1007
|
if len(matches) != 1:
|
|
969
1008
|
continue
|
|
970
1009
|
key = next(iter(matches))
|
|
971
|
-
source_ids
|
|
1010
|
+
source_ids = canonical.get(key, {})
|
|
1011
|
+
owned_runs = set(owners.get((thread, key[0]), set()))
|
|
1012
|
+
# Older ledgers retained only the final continuation's turn ID. An
|
|
1013
|
+
# exact public assistant item proves the earlier turn belongs to the
|
|
1014
|
+
# same completed native run, without classifying any user wording.
|
|
1015
|
+
for offset, item_key in assistant_owner_evidence.get(key[0], ()):
|
|
1016
|
+
if offset <= end and len(assistant_source_turns[item_key]) == 1:
|
|
1017
|
+
owned_runs.update(assistant_item_owners[item_key])
|
|
972
1018
|
# Compaction owns a particular source item and timestamp. Another
|
|
973
1019
|
# genuine answer with the same body in this turn is a distinct item.
|
|
974
1020
|
timestamp_origins = [origin for origin in source_ids.values() if origin["timestamp"] == timestamp]
|
|
@@ -1177,11 +1223,11 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
|
|
|
1177
1223
|
or not 0 < len(run) <= 256 or run.startswith("import_")):
|
|
1178
1224
|
continue
|
|
1179
1225
|
if (event.get("type") == "turn_finished" and event.get("backend") == "codex"
|
|
1180
|
-
and event.get("transport") == "app-server" and event.get("provider_thread_id") == provider_id
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1226
|
+
and event.get("transport") == "app-server" and event.get("provider_thread_id") == provider_id):
|
|
1227
|
+
for turn_id in _native_owned_turn_ids(event):
|
|
1228
|
+
owned = owners.setdefault(turn_id, set())
|
|
1229
|
+
owner_count += run not in owned
|
|
1230
|
+
owned.add(run)
|
|
1185
1231
|
kind = "user" if event.get("type") == "turn_started" else "assistant" if event.get("type") in ("assistant_text", "reasoning_summary", "turn_finished") else None
|
|
1186
1232
|
body = event.get("prompt") if kind == "user" else event.get("result_text") if event.get("type") == "turn_finished" else event.get("text")
|
|
1187
1233
|
wake_hash = _native_mailbox_wake_hash(event) if kind == "user" else None
|
|
@@ -1198,6 +1244,25 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
|
|
|
1198
1244
|
assistant_items[(run, item_id, key[1])] = event["id"]
|
|
1199
1245
|
if native_count + owner_count + len(assistant_items) + deliveries.count > MAX_KEYS:
|
|
1200
1246
|
raise _Unproven()
|
|
1247
|
+
assistant_item_owners = _native_assistant_item_owners(
|
|
1248
|
+
assistant_items, {run for runs in owners.values() for run in runs},
|
|
1249
|
+
)
|
|
1250
|
+
assistant_source_turns: dict = {}
|
|
1251
|
+
for item in items:
|
|
1252
|
+
budget.check()
|
|
1253
|
+
origin = item.get("provider_origin")
|
|
1254
|
+
if (item.get("kind") != "assistant" or not isinstance(item.get("text"), str)
|
|
1255
|
+
or item.get("source_text_sha256") is not None or not isinstance(origin, dict)
|
|
1256
|
+
or origin.get("provider") != "codex" or origin.get("kind") != "assistant"
|
|
1257
|
+
or origin.get("session_id", provider_id) != provider_id
|
|
1258
|
+
or not isinstance(origin.get("turn_id"), str) or not 0 < len(origin["turn_id"]) <= 256):
|
|
1259
|
+
continue
|
|
1260
|
+
item_key = (origin.get("event_id"), _text_key(_native_assistant_text(item["text"])))
|
|
1261
|
+
if item_key in assistant_item_owners:
|
|
1262
|
+
assistant_source_turns.setdefault(item_key, set()).add(origin["turn_id"])
|
|
1263
|
+
for item_key, turns in assistant_source_turns.items():
|
|
1264
|
+
if len(turns) == 1:
|
|
1265
|
+
owners.setdefault(next(iter(turns)), set()).update(assistant_item_owners[item_key])
|
|
1201
1266
|
delivery_proofs = _prove_pending_async_deliveries(
|
|
1202
1267
|
session_id, provider_id, items, source_path, root, sync_checkpoint, parse_item,
|
|
1203
1268
|
previous_seq, owners, deliveries, budget,
|