@agentsdock/server 1.0.10 → 1.0.11-beta.12
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/NOTICE +7 -0
- package/package.json +1 -1
- package/server/NOTICE +7 -0
- package/server/VERSION +1 -1
- package/server/agent_server.py +1658 -498
- package/server/agentsdock_jobs.py +2 -2
- package/server/claude_history_repair.py +46 -61
- package/server/codex_auth.py +76 -7
- package/server/codex_history_repair.py +203 -38
- package/server/cursor_process_guard.py +67 -4
- package/server/cursor_provider_mcp.py +2 -0
- package/server/execution_durability.py +87 -11
- package/server/execution_install.py +5 -1
- package/server/gemini_acp_client.py +432 -0
- package/server/gemini_api.py +128 -0
- package/server/gemini_content.py +102 -0
- package/server/gemini_history.py +293 -0
- package/server/gemini_import.py +126 -0
- package/server/gemini_permissions.py +47 -0
- package/server/gemini_runner.py +493 -0
- package/server/gemini_runtime.py +322 -0
- package/server/install.sh +26 -2
- package/server/interactive_chat_controls.py +9 -3
- package/server/interactive_chat_native.py +1 -1
- package/server/interactive_chat_share_routes.py +2 -2
- package/server/interactive_chat_share_web.py +2 -2
- package/server/local_session_ownership.py +1 -0
- package/server/npm/agentsdock/QR-NOTICES.txt +56 -0
- package/server/npm/agentsdock/pair.cjs +123 -0
- package/server/npm/agentsdock/qr-renderer.cjs +15 -0
- package/server/provider_commands.py +65 -0
- package/server/provider_connections.py +39 -10
- package/server/public_chat_share_routes.py +8 -6
- package/server/public_chat_shares.py +17 -7
- package/server/server_instances.py +217 -15
- package/server/shared_chat_analytics_web.py +5 -0
- package/server/title_generation.py +60 -1
- package/server/transcript_reader.py +334 -0
- package/server/update_runner.py +4 -2
|
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|
|
7
7
|
|
|
8
8
|
from bisect import bisect_right
|
|
9
9
|
from collections import Counter, OrderedDict
|
|
10
|
+
from contextlib import contextmanager
|
|
10
11
|
from dataclasses import dataclass, field
|
|
11
12
|
import hashlib
|
|
12
13
|
import hmac
|
|
@@ -26,6 +27,8 @@ from claude_history_repair import (
|
|
|
26
27
|
)
|
|
27
28
|
|
|
28
29
|
|
|
30
|
+
from transcript_reader import jsonl_records
|
|
31
|
+
|
|
29
32
|
MAX_PRIOR_SOURCE_PATHS = 2
|
|
30
33
|
MAX_AGGREGATE_SOURCE_BYTES = 96 * 1024 * 1024
|
|
31
34
|
MAX_AGGREGATE_SOURCE_RECORDS = 100_000
|
|
@@ -34,7 +37,9 @@ _PROVIDER_ID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9
|
|
|
34
37
|
_NATIVE_LEADING_DECORATION_RE = re.compile(
|
|
35
38
|
r"(?m)^[ \t]*(?:(?::[A-Za-z0-9_+\-]+:|[\U0001F300-\U0001FAFF\u2600-\u27BF]\ufe0f?)[ \t]*)+"
|
|
36
39
|
)
|
|
37
|
-
|
|
40
|
+
# Bound retained JSON; opaque provider payload bytes are streamed.
|
|
41
|
+
NATIVE_PROOF_LINE_BYTES = 32 * 1024 * 1024
|
|
42
|
+
NATIVE_PROOF_TEXT_CHARS = 4 * 1024 * 1024
|
|
38
43
|
NATIVE_PROOF_SECONDS = 30.0
|
|
39
44
|
_COMPACTION_PREFIX = (
|
|
40
45
|
"Another language model started to solve this problem and produced a summary of its thinking process. "
|
|
@@ -70,7 +75,7 @@ class CodexCompactionSummaryTracker:
|
|
|
70
75
|
and isinstance(metadata, dict) and metadata.get("content_item_kinds") == ["unknown"]
|
|
71
76
|
and isinstance(content, list) and len(content) == 1 and isinstance(content[0], dict)
|
|
72
77
|
and content[0].get("type") == "output_text" and isinstance(content[0].get("text"), str)
|
|
73
|
-
and 0 < len(content[0]["text"]) <=
|
|
78
|
+
and 0 < len(content[0]["text"]) <= NATIVE_PROOF_TEXT_CHARS):
|
|
74
79
|
self.pending = {"origin": origin, "text": content[0]["text"], "response_id": None}
|
|
75
80
|
return None
|
|
76
81
|
pending = self.pending
|
|
@@ -133,6 +138,11 @@ class CodexNativeHistoryProofUnavailable(ValueError):
|
|
|
133
138
|
"""Incomplete evidence must defer import, not become a new human message."""
|
|
134
139
|
|
|
135
140
|
|
|
141
|
+
class CodexNativeHistoryProofCapacityExceeded(CodexNativeHistoryProofUnavailable):
|
|
142
|
+
"""The same unchanged input cannot fit; require changed input or manual retry."""
|
|
143
|
+
deterministic = True
|
|
144
|
+
|
|
145
|
+
|
|
136
146
|
@dataclass
|
|
137
147
|
class _NativeReadBudget:
|
|
138
148
|
cancelled: Callable[[], bool] | None = None
|
|
@@ -174,24 +184,20 @@ def _native_records(path: Path, expected: tuple[int, int, int, int], budget: _Na
|
|
|
174
184
|
info = os.fstat(stream.fileno())
|
|
175
185
|
if not stat.S_ISREG(info.st_mode) or (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns) != expected:
|
|
176
186
|
raise _Unproven()
|
|
177
|
-
|
|
178
|
-
while offset < boundary:
|
|
187
|
+
def cancelled():
|
|
179
188
|
budget.check()
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
record
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if not isinstance(record, dict):
|
|
193
|
-
raise _Unproven()
|
|
194
|
-
yield record, offset, line
|
|
189
|
+
return False
|
|
190
|
+
try:
|
|
191
|
+
for record, offset, raw in jsonl_records(stream, end=boundary,
|
|
192
|
+
strict=not provider_source, project=provider_source,
|
|
193
|
+
max_retained_bytes=NATIVE_PROOF_LINE_BYTES, cancelled=cancelled):
|
|
194
|
+
if record is None and not raw.malformed:
|
|
195
|
+
raise _Unproven() # Valid non-object JSON cannot substantiate native proof.
|
|
196
|
+
yield record or {}, offset, raw
|
|
197
|
+
except CodexNativeHistoryProofUnavailable:
|
|
198
|
+
raise
|
|
199
|
+
except ValueError as exc:
|
|
200
|
+
raise _Unproven() from exc
|
|
195
201
|
info = os.fstat(stream.fileno())
|
|
196
202
|
if (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns) != expected:
|
|
197
203
|
raise _Unproven()
|
|
@@ -436,7 +442,7 @@ def _prove_source(provider_id: str, source: Path, root: Path,
|
|
|
436
442
|
elif event.get("type") == "session_meta":
|
|
437
443
|
if not isinstance(payload, dict) or payload.get("id") != provider_id:
|
|
438
444
|
raise _Unproven()
|
|
439
|
-
|
|
445
|
+
line.update_hash(digest)
|
|
440
446
|
for expected in wanted.get(offset, ()):
|
|
441
447
|
if hmac.compare_digest(digest.hexdigest(), expected):
|
|
442
448
|
verified.add((offset, expected))
|
|
@@ -654,7 +660,7 @@ _ASYNC_DELIVERY_WRAPPER = re.compile(
|
|
|
654
660
|
|
|
655
661
|
def _async_delivery_body(text: str) -> tuple[str, str, int] | None:
|
|
656
662
|
"""Parse one complete legacy wire shape; text alone never proves delivery."""
|
|
657
|
-
if len(text) >
|
|
663
|
+
if len(text) > NATIVE_PROOF_TEXT_CHARS:
|
|
658
664
|
return None
|
|
659
665
|
match = _ASYNC_DELIVERY_WRAPPER.fullmatch(text)
|
|
660
666
|
if not match:
|
|
@@ -662,6 +668,71 @@ def _async_delivery_body(text: str) -> tuple[str, str, int] | None:
|
|
|
662
668
|
return match[1], _text_key(match[2]), len(match[2])
|
|
663
669
|
|
|
664
670
|
|
|
671
|
+
@dataclass(frozen=True)
|
|
672
|
+
class _ExchangeDeliveryBody:
|
|
673
|
+
kind: str
|
|
674
|
+
ordinal: int
|
|
675
|
+
max_legs: int
|
|
676
|
+
body_key: str
|
|
677
|
+
body_chars: int
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
_EXCHANGE_DELIVERY_HEADER = re.compile(
|
|
681
|
+
r"\A\[AgentsDock delivery kind=(instruction|request|reply|final_result) "
|
|
682
|
+
r"leg=([1-9][0-9]{0,5})/([1-9][0-9]{0,5}) origin=(?:user|route|auto)"
|
|
683
|
+
r"(?: from=[^\[\]\r\n]{1,240})?\]\n"
|
|
684
|
+
)
|
|
685
|
+
_EXCHANGE_DELIVERY_FOOTERS = frozenset({
|
|
686
|
+
"",
|
|
687
|
+
"reply: use the respond command in the provider-authority block only if a reply or follow-up is needed.",
|
|
688
|
+
"reply: optional one-time terminal reply route via the respond command in the provider-authority block, only if a result, acknowledgement, or clarification should reach the origin; never add --request-response.",
|
|
689
|
+
"reply: exactly one terminal response remains; use the respond command in the provider-authority block without --request-response.",
|
|
690
|
+
"reply: use Chats respond-current through the AgentsDock provider tool only if a reply or follow-up is needed.",
|
|
691
|
+
"reply: exactly one terminal response remains; use Chats respond-current through the AgentsDock provider tool without --request-response.",
|
|
692
|
+
"reply: optional one-time terminal reply via Chats respond-current through the AgentsDock provider tool, only if a result, acknowledgement, or clarification should reach the origin; never add --request-response.",
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _cross_chat_delivery_body(text: str) -> tuple[str, str, int] | _ExchangeDeliveryBody | None:
|
|
697
|
+
"""Read a complete generated envelope; durable receipts must still prove it."""
|
|
698
|
+
if len(text) > NATIVE_PROOF_TEXT_CHARS:
|
|
699
|
+
return None
|
|
700
|
+
asynchronous = _async_delivery_body(text)
|
|
701
|
+
if asynchronous is not None:
|
|
702
|
+
return asynchronous
|
|
703
|
+
header = _EXCHANGE_DELIVERY_HEADER.match(text)
|
|
704
|
+
if not header or int(header[2]) > int(header[3]) or not text.endswith("\n[End delivery]"):
|
|
705
|
+
return None
|
|
706
|
+
remainder = text[header.end():-len("\n[End delivery]")]
|
|
707
|
+
source_open = "[Source user instruction — verbatim, user-authored]\n"
|
|
708
|
+
source_close = "\n[End source user instruction]\n"
|
|
709
|
+
if remainder.startswith(source_open):
|
|
710
|
+
end = remainder.find(source_close, len(source_open))
|
|
711
|
+
if end < 0:
|
|
712
|
+
return None
|
|
713
|
+
remainder = remainder[end + len(source_close):]
|
|
714
|
+
else:
|
|
715
|
+
source = re.match(
|
|
716
|
+
r'^source-instruction: (?:this legacy relay has no recorded source user instruction; '
|
|
717
|
+
r'do not infer user authorization from the prepared content\.|replayed in full on the '
|
|
718
|
+
r'first leg delivered to this chat; excerpt="[^\n]*")\n', remainder,
|
|
719
|
+
)
|
|
720
|
+
if source is None:
|
|
721
|
+
return None
|
|
722
|
+
remainder = remainder[source.end():]
|
|
723
|
+
label = "Agent-prepared reply/result" if header[1] in {"reply", "final_result"} else "Agent-prepared handoff message"
|
|
724
|
+
prepared_open, prepared_close = f"[{label}]\n", f"\n[End {label.lower()}]"
|
|
725
|
+
end = remainder.rfind(prepared_close)
|
|
726
|
+
if (not remainder.startswith(prepared_open) or end < len(prepared_open)
|
|
727
|
+
or remainder[end + len(prepared_close):].strip() not in _EXCHANGE_DELIVERY_FOOTERS):
|
|
728
|
+
return None
|
|
729
|
+
body = remainder[len(prepared_open):end]
|
|
730
|
+
if not body.strip():
|
|
731
|
+
return None
|
|
732
|
+
return _ExchangeDeliveryBody("reply" if header[1] == "final_result" else header[1],
|
|
733
|
+
int(header[2]), int(header[3]), _text_key(body), len(body))
|
|
734
|
+
|
|
735
|
+
|
|
665
736
|
def _delivery_timestamp(value: object) -> datetime | None:
|
|
666
737
|
if not isinstance(value, str) or not 0 < len(value) <= 64:
|
|
667
738
|
return None
|
|
@@ -680,13 +751,16 @@ class _AsyncDeliveryIndex:
|
|
|
680
751
|
receipts: dict = field(default_factory=dict)
|
|
681
752
|
status_queues: dict = field(default_factory=dict)
|
|
682
753
|
status_body_keys: set = field(default_factory=set)
|
|
754
|
+
exchange_receipts: dict = field(default_factory=dict)
|
|
683
755
|
steered: set = field(default_factory=set)
|
|
684
756
|
count: int = 0
|
|
685
757
|
|
|
686
758
|
def observe(self, event: dict) -> None:
|
|
687
759
|
event_type = event.get("type")
|
|
688
760
|
if (event_type not in {"turn_queued", "turn_started", "turn_finished", "turn_steered", "turn_stopped", "turn_queue_run_now",
|
|
689
|
-
"chat_conversation_message_received", "chat_conversation_message_started", "chat_conversation_message_delivered"
|
|
761
|
+
"chat_conversation_message_received", "chat_conversation_message_started", "chat_conversation_message_delivered",
|
|
762
|
+
"cross_chat_exchange_leg_registered", "cross_chat_exchange_leg_received",
|
|
763
|
+
"cross_chat_exchange_leg_started", "cross_chat_exchange_leg_delivered", "cross_chat_exchange_leg_failed"}
|
|
690
764
|
or event.get("imported") is True or event.get("forked") is True
|
|
691
765
|
or event.get("backend") not in (None, "codex")):
|
|
692
766
|
return
|
|
@@ -726,16 +800,17 @@ class _AsyncDeliveryIndex:
|
|
|
726
800
|
"source_session_id", "target_session_id", "source_title", "target_run_id", "conversation_mode",
|
|
727
801
|
"kind", "action", "handoff_action", "handoff_body_chars", "handoff_body_sha256",
|
|
728
802
|
"message_revision", "message_edited_by_user", "exchange_id", "cross_chat_exchange_id",
|
|
729
|
-
"cross_chat_exchange_leg_id", "cross_chat_exchange_status", "queued_id")
|
|
803
|
+
"exchange_leg_id", "cross_chat_exchange_leg_id", "exchange_leg_kind", "exchange_ordinal", "exchange_max_legs", "cross_chat_exchange_status", "queued_id")
|
|
730
804
|
record = {key: event.get(key) for key in fields
|
|
731
805
|
if event.get(key) is None or type(event.get(key)) in (int, bool)
|
|
732
806
|
or isinstance(event.get(key), str) and len(event[key]) <= 512}
|
|
733
807
|
record["malformed"] = len(record) != len(fields)
|
|
734
808
|
record["type"] = event_type
|
|
809
|
+
record["human"] = _runtime_human_provenance(event)
|
|
735
810
|
if isinstance(run, str) and 0 < len(run) <= 256 and not run.startswith("import_"):
|
|
736
811
|
if event_type == "turn_started":
|
|
737
812
|
body = event.get("prompt")
|
|
738
|
-
if isinstance(body, str) and 0 < len(body) <=
|
|
813
|
+
if isinstance(body, str) and 0 < len(body) <= NATIVE_PROOF_TEXT_CHARS and not _runtime_human_provenance(event):
|
|
739
814
|
record.update(body_key=_text_key(body), body_chars=len(body))
|
|
740
815
|
self.starts.setdefault(run, []).append(record)
|
|
741
816
|
self.count += 1
|
|
@@ -746,11 +821,16 @@ class _AsyncDeliveryIndex:
|
|
|
746
821
|
and isinstance(envelope, str) and 0 < len(envelope) <= 256):
|
|
747
822
|
self.receipts.setdefault(envelope, []).append(record)
|
|
748
823
|
self.count += 1
|
|
824
|
+
if event_type.startswith("cross_chat_exchange_leg_"):
|
|
825
|
+
exchange, leg = event.get("cross_chat_exchange_id"), event.get("cross_chat_exchange_leg_id")
|
|
826
|
+
if all(isinstance(value, str) and 0 < len(value) <= 256 for value in (exchange, leg)):
|
|
827
|
+
self.exchange_receipts.setdefault((exchange, leg), []).append(record)
|
|
828
|
+
self.count += 1
|
|
749
829
|
if self.count > MAX_KEYS:
|
|
750
830
|
raise _Unproven()
|
|
751
831
|
|
|
752
832
|
def match(self, session_id: str, provider_id: str, run: str, origin: dict,
|
|
753
|
-
body: tuple[str, str, int] | None, source_ids: set, body_key: str = "") -> dict | None:
|
|
833
|
+
body: tuple[str, str, int] | _ExchangeDeliveryBody | None, source_ids: set, body_key: str = "") -> dict | None:
|
|
754
834
|
starts, ends = self.starts.get(run, []), self.ends.get(run, [])
|
|
755
835
|
if len(starts) != 1 or len(ends) != 1 or run in self.steered:
|
|
756
836
|
return None
|
|
@@ -780,6 +860,8 @@ class _AsyncDeliveryIndex:
|
|
|
780
860
|
return _native_event_identity(start)
|
|
781
861
|
if body is None:
|
|
782
862
|
return None
|
|
863
|
+
if isinstance(body, _ExchangeDeliveryBody):
|
|
864
|
+
return self.match_exchange(session_id, provider_id, run, origin, body, source_ids, start, end)
|
|
783
865
|
envelope = start.get("cross_chat_envelope_id")
|
|
784
866
|
if (start.get("malformed") or end.get("malformed")
|
|
785
867
|
or not isinstance(envelope, str) or not envelope or start.get("backend") != "codex"
|
|
@@ -831,6 +913,58 @@ class _AsyncDeliveryIndex:
|
|
|
831
913
|
return None
|
|
832
914
|
return _native_event_identity(start)
|
|
833
915
|
|
|
916
|
+
def match_exchange(self, session_id: str, provider_id: str, run: str, origin: dict,
|
|
917
|
+
body: _ExchangeDeliveryBody, source_ids: set, start: dict, end: dict) -> dict | None:
|
|
918
|
+
exchange, leg = start.get("cross_chat_exchange_id"), start.get("cross_chat_exchange_leg_id")
|
|
919
|
+
if (start.get("malformed") or end.get("malformed") or start.get("human") or end.get("human")
|
|
920
|
+
or not all(isinstance(value, str) and value for value in (exchange, leg, start.get("source_session_id")))
|
|
921
|
+
or start.get("source_session_id") == session_id or start.get("target_session_id") != session_id
|
|
922
|
+
or start.get("backend") != "codex" or end.get("backend") != "codex" or end.get("transport") != "app-server"
|
|
923
|
+
or start.get("exchange_id") != exchange or start.get("exchange_leg_id") != leg
|
|
924
|
+
or end.get("provider_thread_id") != provider_id or end.get("provider_turn_id") != origin.get("turn_id")
|
|
925
|
+
or type(end.get("exit_code")) is not int or end["exit_code"] != 0
|
|
926
|
+
or end.get("stopped") not in (None, False) or end.get("is_error") not in (None, False)
|
|
927
|
+
or any(end.get(key) != start.get(key) for key in (
|
|
928
|
+
"source_session_id", "target_session_id", "exchange_id", "cross_chat_exchange_id",
|
|
929
|
+
"exchange_leg_id", "cross_chat_exchange_leg_id"))
|
|
930
|
+
or any(row.get("conversation_mode") or row.get("cross_chat_envelope_id")
|
|
931
|
+
or row.get("message_edited_by_user") or row.get("message_revision") for row in (start, end))
|
|
932
|
+
or source_ids != {origin.get("event_id")} or not origin.get("event_id")
|
|
933
|
+
or type(start.get("seq")) is not int or type(end.get("seq")) is not int or start["seq"] >= end["seq"]
|
|
934
|
+
or not isinstance(start.get("id"), str) or not start["id"]):
|
|
935
|
+
return None
|
|
936
|
+
times = [_delivery_timestamp(value) for value in (start.get("ts"), origin.get("timestamp"), end.get("ts"))]
|
|
937
|
+
if any(value is None for value in times) or not times[0] <= times[1] <= times[2]:
|
|
938
|
+
return None
|
|
939
|
+
stages = set()
|
|
940
|
+
for receipt in self.exchange_receipts.get((exchange, leg), []):
|
|
941
|
+
if (receipt.get("malformed") or receipt.get("human")
|
|
942
|
+
or receipt.get("exchange_id") != exchange or receipt.get("exchange_leg_id") != leg
|
|
943
|
+
or receipt.get("source_session_id") != start["source_session_id"] or receipt.get("target_session_id") != session_id
|
|
944
|
+
or receipt.get("exchange_leg_kind") != body.kind or type(receipt.get("exchange_ordinal")) is not int
|
|
945
|
+
or receipt["exchange_ordinal"] != body.ordinal or type(receipt.get("exchange_max_legs")) is not int
|
|
946
|
+
or receipt["exchange_max_legs"] != body.max_legs or receipt.get("handoff_body_sha256") != body.body_key
|
|
947
|
+
or type(receipt.get("handoff_body_chars")) is not int or receipt["handoff_body_chars"] != body.body_chars
|
|
948
|
+
or type(receipt.get("seq")) is not int):
|
|
949
|
+
return None
|
|
950
|
+
stage = receipt["type"]
|
|
951
|
+
if stage in {"cross_chat_exchange_leg_registered", "cross_chat_exchange_leg_received"}:
|
|
952
|
+
if receipt["seq"] >= start["seq"] or receipt.get("target_run_id") not in (None, run):
|
|
953
|
+
return None
|
|
954
|
+
elif receipt.get("target_run_id") != run:
|
|
955
|
+
return None
|
|
956
|
+
elif stage == "cross_chat_exchange_leg_started" and not start["seq"] < receipt["seq"] < end["seq"]:
|
|
957
|
+
return None
|
|
958
|
+
elif stage in {"cross_chat_exchange_leg_delivered", "cross_chat_exchange_leg_failed"} and receipt["seq"] <= end["seq"]:
|
|
959
|
+
return None
|
|
960
|
+
stages.add(stage)
|
|
961
|
+
# A failed exchange can still have successfully delivered the input
|
|
962
|
+
# (for example an empty final reply). The successful native turn above
|
|
963
|
+
# proves input ownership; the terminal receipt must not erase it.
|
|
964
|
+
if not {"cross_chat_exchange_leg_received", "cross_chat_exchange_leg_started"} <= stages:
|
|
965
|
+
return None
|
|
966
|
+
return _native_event_identity(start)
|
|
967
|
+
|
|
834
968
|
|
|
835
969
|
def _prove_native_replays(session_id: str, provider_id: str, events: Path, source: Path | None,
|
|
836
970
|
root: Path, parse_item: Callable[[dict], dict | None],
|
|
@@ -850,7 +984,7 @@ def _prove_native_replays(session_id: str, provider_id: str, events: Path, sourc
|
|
|
850
984
|
continue
|
|
851
985
|
deliveries.observe(event)
|
|
852
986
|
if len(candidates) + len(batches) + len(terminals) + retained + deliveries.count > MAX_KEYS:
|
|
853
|
-
raise
|
|
987
|
+
raise CodexNativeHistoryProofCapacityExceeded("native history proof key budget exceeded")
|
|
854
988
|
run = event.get("run_id")
|
|
855
989
|
if not isinstance(run, str) or not 0 < len(run) <= 256:
|
|
856
990
|
continue
|
|
@@ -884,7 +1018,7 @@ def _prove_native_replays(session_id: str, provider_id: str, events: Path, sourc
|
|
|
884
1018
|
native.setdefault((run, kind, wake_hash), []).append(_native_event_identity(event))
|
|
885
1019
|
retained += 1
|
|
886
1020
|
if len(candidates) + len(batches) + len(terminals) + retained + deliveries.count > MAX_KEYS:
|
|
887
|
-
raise
|
|
1021
|
+
raise CodexNativeHistoryProofCapacityExceeded("native history proof key budget exceeded")
|
|
888
1022
|
if not candidates:
|
|
889
1023
|
return _NativeProof(provider_id)
|
|
890
1024
|
groups = {}
|
|
@@ -987,7 +1121,7 @@ def _prove_native_source(thread: str, source: Path, root: Path, batches: dict, c
|
|
|
987
1121
|
raise _Unproven()
|
|
988
1122
|
if parent is not None:
|
|
989
1123
|
allowed_header_owners.add(parent)
|
|
990
|
-
|
|
1124
|
+
line.update_hash(digest)
|
|
991
1125
|
for expected in wanted.get(offset, ()):
|
|
992
1126
|
if hmac.compare_digest(digest.hexdigest(), expected):
|
|
993
1127
|
verified.add((offset, expected))
|
|
@@ -1050,7 +1184,7 @@ def _prove_native_source(thread: str, source: Path, root: Path, batches: dict, c
|
|
|
1050
1184
|
if (isinstance(full_text, str) and full_text
|
|
1051
1185
|
and _text_key(" ".join(full_text.split())) == key[3]):
|
|
1052
1186
|
full_user_keys[key] = _text_key(full_text)
|
|
1053
|
-
if item["kind"] == "user" and (body :=
|
|
1187
|
+
if item["kind"] == "user" and (body := _cross_chat_delivery_body(item["text"])):
|
|
1054
1188
|
delivery_bodies[key] = body
|
|
1055
1189
|
if item["kind"] == "assistant":
|
|
1056
1190
|
cleaned = _native_assistant_text(item["text"])
|
|
@@ -1146,18 +1280,33 @@ def _prove_native_source(thread: str, source: Path, root: Path, batches: dict, c
|
|
|
1146
1280
|
return proofs
|
|
1147
1281
|
|
|
1148
1282
|
|
|
1283
|
+
@contextmanager
|
|
1284
|
+
def _native_prepare_lock(lock, budget):
|
|
1285
|
+
# The budget starts before waiting, not after another chat releases it.
|
|
1286
|
+
while True:
|
|
1287
|
+
budget.check()
|
|
1288
|
+
if lock.acquire(timeout=min(0.05, max(0, budget.deadline - time.monotonic()))):
|
|
1289
|
+
break
|
|
1290
|
+
try:
|
|
1291
|
+
budget.check()
|
|
1292
|
+
yield
|
|
1293
|
+
finally:
|
|
1294
|
+
lock.release()
|
|
1295
|
+
|
|
1296
|
+
|
|
1149
1297
|
class CodexNativeHistoryRepairCache(CodexGoalHistoryRepairCache):
|
|
1150
1298
|
"""An explicit history boundary prepares proof; per-event projection does no IO."""
|
|
1151
1299
|
def prepare(self, session_id: str, provider_id: str, events_path: Path, source_path: Path | None,
|
|
1152
1300
|
root: Path, parse_item: Callable[[dict], dict | None], *,
|
|
1153
1301
|
cancelled: Callable[[], bool] | None = None, deadline: float | None = None) -> bool:
|
|
1154
|
-
|
|
1302
|
+
budget = _NativeReadBudget(cancelled, deadline)
|
|
1303
|
+
with _native_prepare_lock(self._prepare_lock, budget):
|
|
1155
1304
|
with self._lock:
|
|
1156
1305
|
previous = self._proofs.get(session_id)
|
|
1157
1306
|
if previous and previous.provider_id == provider_id:
|
|
1158
1307
|
return False
|
|
1159
1308
|
self._preparing, self._cancelled = session_id, False
|
|
1160
|
-
budget =
|
|
1309
|
+
budget.cancelled = lambda: self._cancelled or bool(cancelled and cancelled())
|
|
1161
1310
|
try:
|
|
1162
1311
|
proof = _prove_native_replays(
|
|
1163
1312
|
session_id, provider_id, events_path, source_path, root, parse_item,
|
|
@@ -1167,6 +1316,8 @@ class CodexNativeHistoryRepairCache(CodexGoalHistoryRepairCache):
|
|
|
1167
1316
|
except (OSError, ValueError, TypeError, KeyError, RuntimeError) as exc:
|
|
1168
1317
|
with self._lock:
|
|
1169
1318
|
self._preparing = None
|
|
1319
|
+
if isinstance(exc, CodexNativeHistoryProofCapacityExceeded):
|
|
1320
|
+
raise
|
|
1170
1321
|
# Unavailable proof is neither a negative answer nor a prepared
|
|
1171
1322
|
# cache entry. The explicit worker decides when to retry it.
|
|
1172
1323
|
raise CodexNativeHistoryProofUnavailable("Codex native history proof is incomplete") from exc
|
|
@@ -1238,7 +1389,7 @@ def _prove_pending_native_inputs(session_id: str, provider_id: str, items: list[
|
|
|
1238
1389
|
or not isinstance(origin, dict) or origin.get("provider") != "codex" or origin.get("kind") != "user"
|
|
1239
1390
|
or origin.get("session_id", provider_id) != provider_id
|
|
1240
1391
|
or not (item.get("source_text_sha256") is not None
|
|
1241
|
-
or
|
|
1392
|
+
or _cross_chat_delivery_body(item["text"]) or _text_key(item["text"]) in deliveries.status_body_keys
|
|
1242
1393
|
or _text_key(item["text"]) in steer_keys)):
|
|
1243
1394
|
continue
|
|
1244
1395
|
candidate = _replay_target({"seq": previous_seq + index + 2, "id": f"pending:{index}", "run_id": run,
|
|
@@ -1273,7 +1424,7 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
|
|
|
1273
1424
|
parse_item: Callable[[dict], dict | None] | None = None) -> list[dict]:
|
|
1274
1425
|
"""An existing verified import boundary can omit exact completed native copies.
|
|
1275
1426
|
|
|
1276
|
-
Exact native bodies need only the ledger.
|
|
1427
|
+
Exact native bodies need only the ledger. Cross-chat wrappers and trimmed
|
|
1277
1428
|
user inputs additionally require the caller's source/checkpoint/parser and
|
|
1278
1429
|
one verified source-prefix scan. A preview alone never proves equality.
|
|
1279
1430
|
Missing source context leaves those inputs visible for later read repair.
|
|
@@ -1286,6 +1437,16 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
|
|
|
1286
1437
|
raise _Unproven()
|
|
1287
1438
|
budget = _NativeReadBudget(cancelled, deadline)
|
|
1288
1439
|
stamp = _native_stamp(events)
|
|
1440
|
+
# The import batch defines which bodies can possibly be replayed.
|
|
1441
|
+
# Keep all ownership/ambiguity evidence and validate every journal
|
|
1442
|
+
# record, but do not retain keys for unrelated historical text.
|
|
1443
|
+
candidate_keys = {(item.get("kind"), _text_key(item["text"])) for item in items
|
|
1444
|
+
if isinstance(item.get("text"), str)}
|
|
1445
|
+
source_keys = {(item.get("kind"), item["source_text_sha256"]) for item in items
|
|
1446
|
+
if isinstance(item.get("source_text_sha256"), str)}
|
|
1447
|
+
candidate_keys.update(("assistant", _text_key(_native_assistant_text(item["text"])))
|
|
1448
|
+
for item in items if item.get("kind") == "assistant"
|
|
1449
|
+
and isinstance(item.get("text"), str))
|
|
1289
1450
|
owners, native, assistant_items, wake_keys, native_count = {}, {}, {}, set(), 0
|
|
1290
1451
|
native_inputs, steered_inputs = {}, {}
|
|
1291
1452
|
deliveries = _AsyncDeliveryIndex()
|
|
@@ -1317,9 +1478,10 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
|
|
|
1317
1478
|
kind = "user" if event.get("type") == "turn_started" or steer_turn else "assistant" if event.get("type") in ("assistant_text", "reasoning_summary", "turn_finished") else None
|
|
1318
1479
|
body = event.get("prompt") if kind == "user" else event.get("result_text") if event.get("type") == "turn_finished" else event.get("text")
|
|
1319
1480
|
wake_hash = _native_mailbox_wake_hash(event) if kind == "user" else None
|
|
1320
|
-
if kind and (isinstance(body, str) and body or wake_hash)
|
|
1481
|
+
key = (kind, wake_hash or _text_key(body)) if kind and (isinstance(body, str) and body or wake_hash) else None
|
|
1482
|
+
if key in candidate_keys or (isinstance(body, str) and
|
|
1483
|
+
(kind, _text_key(" ".join(body.split()))) in source_keys):
|
|
1321
1484
|
keys = steered_inputs.setdefault((run, steer_turn), {}) if steer_turn else native.setdefault(run, {})
|
|
1322
|
-
key = (kind, wake_hash or _text_key(body))
|
|
1323
1485
|
native_count += key not in keys
|
|
1324
1486
|
if isinstance(event.get("id"), str) and 0 < len(event["id"]) <= 256:
|
|
1325
1487
|
keys.setdefault(key, event["id"])
|
|
@@ -1331,9 +1493,12 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
|
|
|
1331
1493
|
native_count += 1
|
|
1332
1494
|
if wake_hash:
|
|
1333
1495
|
wake_keys.add((run, wake_hash))
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1496
|
+
# Public assistant identities can prove an earlier goal turn
|
|
1497
|
+
# even when this batch contains only a user follow-up. Retain
|
|
1498
|
+
# that ownership evidence independently of candidate text keys.
|
|
1499
|
+
item_id = _public_assistant_item_id(event)
|
|
1500
|
+
if item_id is not None and key is not None:
|
|
1501
|
+
assistant_items[(run, item_id, key[1])] = _native_event_identity(event)
|
|
1337
1502
|
if native_count + owner_count + len(assistant_items) + deliveries.count > MAX_KEYS:
|
|
1338
1503
|
raise _Unproven()
|
|
1339
1504
|
assistant_item_owners = _native_assistant_item_owners(
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
"""Small parent-death guard for
|
|
1
|
+
"""Small parent-death guard for an owned provider CLI subprocess.
|
|
2
2
|
|
|
3
3
|
AgentsServer launches this helper in its own process group. The helper launches
|
|
4
4
|
Cursor in a child group, forwards inherited stdio unchanged, and terminates the
|
|
5
5
|
entire Cursor tree when its leader exits or AgentsServer disappears abruptly.
|
|
6
6
|
This prevents a detached agent from continuing workspace edits after the turn.
|
|
7
|
+
Gemini opts into verified-cleanup exit codes; existing Cursor callers retain
|
|
8
|
+
their original exit contract. Process sampling is not an OS security sandbox.
|
|
7
9
|
"""
|
|
8
10
|
|
|
9
11
|
from __future__ import annotations
|
|
@@ -25,6 +27,7 @@ TERMINATE_GRACE_SECONDS = 0.5
|
|
|
25
27
|
PROCESS_SNAPSHOT_TIMEOUT_SECONDS = 0.2
|
|
26
28
|
MAX_PROCESS_SNAPSHOT_ROWS = 32_768
|
|
27
29
|
MAX_CAPTURED_DESCENDANTS = 4_096
|
|
30
|
+
READY_NOTICE = b"AGENTSDOCK_PROCESS_GUARD_READY\n"
|
|
28
31
|
|
|
29
32
|
|
|
30
33
|
class _ProcessIdentity(NamedTuple):
|
|
@@ -34,7 +37,7 @@ class _ProcessIdentity(NamedTuple):
|
|
|
34
37
|
started_at: str
|
|
35
38
|
|
|
36
39
|
|
|
37
|
-
def _process_snapshot() -> dict[int, _ProcessIdentity]:
|
|
40
|
+
def _process_snapshot(*, required: bool = False) -> dict[int, _ProcessIdentity]:
|
|
38
41
|
"""Return one bounded POSIX process snapshot with stable-enough identities.
|
|
39
42
|
|
|
40
43
|
``lstart`` is intentionally retained as a PID-reuse fence. Before sending
|
|
@@ -48,6 +51,8 @@ def _process_snapshot() -> dict[int, _ProcessIdentity]:
|
|
|
48
51
|
None,
|
|
49
52
|
)
|
|
50
53
|
if ps_path is None:
|
|
54
|
+
if required:
|
|
55
|
+
raise RuntimeError("Process inspection unavailable")
|
|
51
56
|
return {}
|
|
52
57
|
env = dict(os.environ)
|
|
53
58
|
env["LC_ALL"] = "C"
|
|
@@ -61,13 +66,19 @@ def _process_snapshot() -> dict[int, _ProcessIdentity]:
|
|
|
61
66
|
env=env,
|
|
62
67
|
)
|
|
63
68
|
except (OSError, subprocess.SubprocessError):
|
|
69
|
+
if required:
|
|
70
|
+
raise RuntimeError("Process inspection failed") from None
|
|
64
71
|
return {}
|
|
65
72
|
if result.returncode != 0:
|
|
73
|
+
if required:
|
|
74
|
+
raise RuntimeError("Process inspection failed")
|
|
66
75
|
return {}
|
|
67
76
|
|
|
68
77
|
snapshot: dict[int, _ProcessIdentity] = {}
|
|
69
78
|
for index, raw_line in enumerate(result.stdout.splitlines()):
|
|
70
79
|
if index >= MAX_PROCESS_SNAPSHOT_ROWS:
|
|
80
|
+
if required:
|
|
81
|
+
raise RuntimeError("Process inspection exceeded its limit")
|
|
71
82
|
break
|
|
72
83
|
parts = raw_line.strip().split(None, 3)
|
|
73
84
|
if len(parts) != 4:
|
|
@@ -278,6 +289,14 @@ def _terminate_child_group(
|
|
|
278
289
|
|
|
279
290
|
|
|
280
291
|
def main(argv: list[str]) -> int:
|
|
292
|
+
# Opt-in strict contract for Gemini; existing Cursor callers keep their
|
|
293
|
+
# established return-code behavior. 70 means cleanup was not confirmed.
|
|
294
|
+
verify_cleanup = bool(argv and argv[0] == "--verify-cleanup")
|
|
295
|
+
if verify_cleanup:
|
|
296
|
+
argv = argv[1:]
|
|
297
|
+
ready_notice = bool(verify_cleanup and argv and argv[0] == "--ready-notice")
|
|
298
|
+
if ready_notice:
|
|
299
|
+
argv = argv[1:]
|
|
281
300
|
if len(argv) < 4 or argv[0] != "--parent-pid" or argv[2] != "--":
|
|
282
301
|
return 64
|
|
283
302
|
try:
|
|
@@ -299,6 +318,12 @@ def main(argv: list[str]) -> int:
|
|
|
299
318
|
signal.signal(signal.SIGINT, request_stop)
|
|
300
319
|
signal.signal(signal.SIGHUP, request_stop)
|
|
301
320
|
|
|
321
|
+
if ready_notice:
|
|
322
|
+
# Only the opt-in Gemini owner consumes this first stderr record.
|
|
323
|
+
# Publish it before launching native code, after TERM becomes a request
|
|
324
|
+
# for verified cleanup rather than an unhandled process-killing signal.
|
|
325
|
+
os.write(2, READY_NOTICE)
|
|
326
|
+
|
|
302
327
|
# The server can disappear after spawning this wrapper but before Python
|
|
303
328
|
# reaches Popen. Do not briefly launch an editing agent in that window.
|
|
304
329
|
if stop.is_set() or os.getppid() != expected_parent:
|
|
@@ -307,9 +332,13 @@ def main(argv: list[str]) -> int:
|
|
|
307
332
|
try:
|
|
308
333
|
child = subprocess.Popen(command, start_new_session=True)
|
|
309
334
|
except OSError:
|
|
310
|
-
return
|
|
335
|
+
# Popen did not return a child; it reaps a failed exec itself. Preserve
|
|
336
|
+
# the legacy Cursor code, but do not fence Gemini as though an editing
|
|
337
|
+
# process survived when no provider was launched.
|
|
338
|
+
return 71 if verify_cleanup else 127
|
|
311
339
|
|
|
312
340
|
interrupted = False
|
|
341
|
+
inspection_failed = False
|
|
313
342
|
child_group = child.pid
|
|
314
343
|
tracked_descendants: dict[int, _ProcessIdentity] = {}
|
|
315
344
|
next_descendant_snapshot = 0.0
|
|
@@ -317,7 +346,22 @@ def main(argv: list[str]) -> int:
|
|
|
317
346
|
now = time.monotonic()
|
|
318
347
|
child_alive = child.poll() is None
|
|
319
348
|
if now >= next_descendant_snapshot:
|
|
320
|
-
|
|
349
|
+
try:
|
|
350
|
+
snapshot = _process_snapshot(required=verify_cleanup)
|
|
351
|
+
except RuntimeError:
|
|
352
|
+
# A caller may signal the wrapper's group while its short ps
|
|
353
|
+
# probe is in flight. Retry outside that signal before declaring
|
|
354
|
+
# inspection unavailable; never equate an interrupted ps with
|
|
355
|
+
# an empty process table.
|
|
356
|
+
if stop.is_set():
|
|
357
|
+
try:
|
|
358
|
+
snapshot = _process_snapshot(required=verify_cleanup)
|
|
359
|
+
except RuntimeError:
|
|
360
|
+
inspection_failed = True
|
|
361
|
+
break
|
|
362
|
+
else:
|
|
363
|
+
inspection_failed = True
|
|
364
|
+
break
|
|
321
365
|
child_alive = child.poll() is None
|
|
322
366
|
_track_child_processes(
|
|
323
367
|
child.pid,
|
|
@@ -340,6 +384,25 @@ def main(argv: list[str]) -> int:
|
|
|
340
384
|
child_group,
|
|
341
385
|
tuple(tracked_descendants.values()),
|
|
342
386
|
)
|
|
387
|
+
if verify_cleanup:
|
|
388
|
+
deadline = time.monotonic() + 1.5
|
|
389
|
+
while True:
|
|
390
|
+
try:
|
|
391
|
+
snapshot = _process_snapshot(required=True)
|
|
392
|
+
except RuntimeError:
|
|
393
|
+
return 70
|
|
394
|
+
survivors = _matching_survivors(tuple(tracked_descendants.values()), snapshot)
|
|
395
|
+
group_alive = any(row.process_group == child_group for row in snapshot.values())
|
|
396
|
+
if child.poll() is not None and not survivors and not group_alive:
|
|
397
|
+
break
|
|
398
|
+
if time.monotonic() >= deadline:
|
|
399
|
+
return 70
|
|
400
|
+
time.sleep(POLL_SECONDS)
|
|
401
|
+
if inspection_failed:
|
|
402
|
+
return 70
|
|
403
|
+
# A distinct verified exit contract prevents an unexpected wrapper
|
|
404
|
+
# crash (e.g. Python exit 1) from masquerading as successful cleanup.
|
|
405
|
+
return 143 if interrupted else (0 if child.returncode == 0 else 71)
|
|
343
406
|
return 143 if interrupted else int(child.returncode or 0)
|
|
344
407
|
|
|
345
408
|
|
|
@@ -41,6 +41,7 @@ class CursorToolBroker:
|
|
|
41
41
|
self.server = None
|
|
42
42
|
self.tasks: set[asyncio.Task] = set()
|
|
43
43
|
self.closed = False
|
|
44
|
+
self.definition_requested = asyncio.Event()
|
|
44
45
|
|
|
45
46
|
async def start(self) -> dict[str, str]:
|
|
46
47
|
self.server = await asyncio.start_server(
|
|
@@ -78,6 +79,7 @@ class CursorToolBroker:
|
|
|
78
79
|
or not hmac.compare_digest(request["secret"], self.secret)):
|
|
79
80
|
raise ValueError("provider IPC is forbidden")
|
|
80
81
|
if request.get("method") == "definition":
|
|
82
|
+
self.definition_requested.set()
|
|
81
83
|
result = self.definition
|
|
82
84
|
elif request.get("method") == "call":
|
|
83
85
|
key = request.get("key")
|