crabcode 0.1.6__py3-none-win_amd64.whl
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.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +268 -0
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
"""Same-machine transport for messages between independent CrabCode sessions.
|
|
2
|
+
|
|
3
|
+
Each live session publishes a small registry record and listens on a local
|
|
4
|
+
transport: a Unix domain socket on POSIX or loopback TCP on Windows. Registry
|
|
5
|
+
files provide discovery; the transport is the delivery and acknowledgement
|
|
6
|
+
boundary. Message contents never include transcripts or files and are always
|
|
7
|
+
tagged with their originating session.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
from collections import deque
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import secrets
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
import uuid
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Awaitable, Callable, Literal
|
|
24
|
+
from urllib.parse import urlsplit
|
|
25
|
+
|
|
26
|
+
from pydantic import BaseModel, Field
|
|
27
|
+
|
|
28
|
+
from crabcode_core.logging_utils import get_logger
|
|
29
|
+
from crabcode_core.paths import get_config_home
|
|
30
|
+
from crabcode_core.subprocess_utils import is_process_running
|
|
31
|
+
|
|
32
|
+
logger = get_logger(__name__)
|
|
33
|
+
|
|
34
|
+
_PROTOCOL_VERSION = 1
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _now_iso() -> str:
|
|
38
|
+
return datetime.now(timezone.utc).isoformat()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _safe_slug(value: str) -> str:
|
|
42
|
+
cleaned = "".join(ch.lower() if ch.isalnum() else "-" for ch in value)
|
|
43
|
+
return "-".join(part for part in cleaned.split("-") if part)[:48] or "session"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PeerRecord(BaseModel):
|
|
47
|
+
"""Discoverable identity for one live CrabCode session."""
|
|
48
|
+
|
|
49
|
+
version: int = _PROTOCOL_VERSION
|
|
50
|
+
session_id: str = Field(min_length=1)
|
|
51
|
+
name: str = Field(min_length=1)
|
|
52
|
+
cwd: str = Field(min_length=1)
|
|
53
|
+
pid: int = Field(gt=0)
|
|
54
|
+
socket_path: str = Field(min_length=1)
|
|
55
|
+
auth_token: str = Field(min_length=32, repr=False)
|
|
56
|
+
permission_class: Literal["prompting", "bypass"] = "prompting"
|
|
57
|
+
started_at: str = Field(default_factory=_now_iso)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PeerMessage(BaseModel):
|
|
61
|
+
"""Plain-text envelope delivered from one session to another."""
|
|
62
|
+
|
|
63
|
+
version: int = _PROTOCOL_VERSION
|
|
64
|
+
type: Literal["message"] = "message"
|
|
65
|
+
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
66
|
+
from_session_id: str = Field(min_length=1)
|
|
67
|
+
from_name: str = Field(min_length=1)
|
|
68
|
+
from_cwd: str = Field(min_length=1)
|
|
69
|
+
sender_permission_class: Literal["prompting", "bypass"] = "prompting"
|
|
70
|
+
to_session_id: str = Field(min_length=1)
|
|
71
|
+
to_auth_token: str = Field(min_length=32, repr=False)
|
|
72
|
+
text: str = Field(min_length=1)
|
|
73
|
+
sent_at: str = Field(default_factory=_now_iso)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class PeerDelivery(BaseModel):
|
|
77
|
+
"""Acknowledgement returned by the receiving session."""
|
|
78
|
+
|
|
79
|
+
message_id: str
|
|
80
|
+
status: Literal["delivered", "held", "refused", "failed"]
|
|
81
|
+
detail: str = ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
InboundHandler = Callable[[PeerMessage], Awaitable[bool]]
|
|
85
|
+
HoldHandler = Callable[[PeerMessage], Awaitable[PeerDelivery]]
|
|
86
|
+
PermissionClassProvider = Callable[[], Literal["prompting", "bypass"]]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class PeerRuntime:
|
|
90
|
+
"""Publish, discover, and message independent local sessions."""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
*,
|
|
95
|
+
session_id: str,
|
|
96
|
+
cwd: str,
|
|
97
|
+
on_message: InboundHandler,
|
|
98
|
+
on_hold: HoldHandler | None,
|
|
99
|
+
permission_class_provider: PermissionClassProvider,
|
|
100
|
+
name: str | None = None,
|
|
101
|
+
inbound: Literal["auto", "accept", "hold", "refuse"] = "auto",
|
|
102
|
+
registry_root: Path | None = None,
|
|
103
|
+
max_message_size_bytes: int = 10_000,
|
|
104
|
+
connect_timeout_seconds: float = 3.0,
|
|
105
|
+
) -> None:
|
|
106
|
+
if not session_id:
|
|
107
|
+
raise ValueError("session_id is required")
|
|
108
|
+
if inbound not in {"auto", "accept", "hold", "refuse"}:
|
|
109
|
+
raise ValueError(f"Unsupported inbound policy: {inbound}")
|
|
110
|
+
if max_message_size_bytes <= 0:
|
|
111
|
+
raise ValueError("max_message_size_bytes must be greater than zero")
|
|
112
|
+
|
|
113
|
+
self.session_id = session_id
|
|
114
|
+
self.cwd = os.path.abspath(cwd)
|
|
115
|
+
self.name = name or f"{_safe_slug(Path(self.cwd).name)}-{session_id[:6]}"
|
|
116
|
+
self.inbound = inbound
|
|
117
|
+
self._on_message = on_message
|
|
118
|
+
self._on_hold = on_hold
|
|
119
|
+
self._permission_class_provider = permission_class_provider
|
|
120
|
+
self._registry_root = registry_root or get_config_home() / "peers"
|
|
121
|
+
self._max_message_size_bytes = max_message_size_bytes
|
|
122
|
+
self._connect_timeout = connect_timeout_seconds
|
|
123
|
+
self._server: asyncio.AbstractServer | None = None
|
|
124
|
+
self._record: PeerRecord | None = None
|
|
125
|
+
self._registry_path = self._registry_root / (
|
|
126
|
+
hashlib.sha256(session_id.encode()).hexdigest()[:24] + ".json"
|
|
127
|
+
)
|
|
128
|
+
# Darwin limits AF_UNIX paths to roughly 104 bytes. Configured roots
|
|
129
|
+
# (and pytest's per-test directories) can easily exceed that, so keep
|
|
130
|
+
# discovery records there but fall back to a short, user-scoped socket
|
|
131
|
+
# directory when necessary.
|
|
132
|
+
socket_root = self._registry_root
|
|
133
|
+
socket_probe = socket_root / ("x" * 24 + ".sock")
|
|
134
|
+
if os.name != "nt" and len(os.fsencode(socket_probe)) >= 100:
|
|
135
|
+
uid = os.getuid() if hasattr(os, "getuid") else os.getpid()
|
|
136
|
+
temp_root = Path("/private/tmp") if sys.platform == "darwin" else Path("/tmp")
|
|
137
|
+
socket_root = temp_root / f"crabcode-peers-{uid}"
|
|
138
|
+
self._socket_root = socket_root
|
|
139
|
+
socket_key = f"{session_id}:{os.getpid()}:{uuid.uuid4()}"
|
|
140
|
+
self._socket_path = self._socket_root / (
|
|
141
|
+
hashlib.sha256(socket_key.encode()).hexdigest()[:24] + ".sock"
|
|
142
|
+
)
|
|
143
|
+
self._endpoint = str(self._socket_path)
|
|
144
|
+
self._auth_token = secrets.token_urlsafe(32)
|
|
145
|
+
self._close_lock = asyncio.Lock()
|
|
146
|
+
self._seen_message_ids: dict[str, float] = {}
|
|
147
|
+
self._recent_payloads: dict[tuple[str, str], float] = {}
|
|
148
|
+
self._sender_windows: dict[str, deque[float]] = {}
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def record(self) -> PeerRecord | None:
|
|
152
|
+
return self._record.model_copy(deep=True) if self._record else None
|
|
153
|
+
|
|
154
|
+
async def start(self) -> None:
|
|
155
|
+
"""Bind the inbox transport and atomically publish this session."""
|
|
156
|
+
if self._server is not None:
|
|
157
|
+
return
|
|
158
|
+
if os.name != "nt" and not hasattr(asyncio, "start_unix_server"):
|
|
159
|
+
raise RuntimeError("Cross-session messaging requires Unix domain sockets")
|
|
160
|
+
|
|
161
|
+
self._registry_root.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
self._socket_root.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
try:
|
|
164
|
+
os.chmod(self._registry_root, 0o700)
|
|
165
|
+
os.chmod(self._socket_root, 0o700)
|
|
166
|
+
except OSError:
|
|
167
|
+
logger.debug("Could not restrict peer registry permissions", exc_info=True)
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
if os.name == "nt":
|
|
171
|
+
self._server = await asyncio.start_server(
|
|
172
|
+
self._handle_client,
|
|
173
|
+
host="127.0.0.1",
|
|
174
|
+
port=0,
|
|
175
|
+
limit=self._max_message_size_bytes + 16_384,
|
|
176
|
+
)
|
|
177
|
+
address = self._server.sockets[0].getsockname()
|
|
178
|
+
self._endpoint = f"tcp://127.0.0.1:{address[1]}"
|
|
179
|
+
else:
|
|
180
|
+
self._socket_path.unlink(missing_ok=True)
|
|
181
|
+
self._server = await asyncio.start_unix_server(
|
|
182
|
+
self._handle_client,
|
|
183
|
+
path=str(self._socket_path),
|
|
184
|
+
limit=self._max_message_size_bytes + 16_384,
|
|
185
|
+
)
|
|
186
|
+
os.chmod(self._socket_path, 0o600)
|
|
187
|
+
self._endpoint = str(self._socket_path)
|
|
188
|
+
record = PeerRecord(
|
|
189
|
+
session_id=self.session_id,
|
|
190
|
+
name=self.name,
|
|
191
|
+
cwd=self.cwd,
|
|
192
|
+
pid=os.getpid(),
|
|
193
|
+
socket_path=self._endpoint,
|
|
194
|
+
auth_token=self._auth_token,
|
|
195
|
+
permission_class=self._permission_class_provider(),
|
|
196
|
+
)
|
|
197
|
+
self._write_record(record)
|
|
198
|
+
self._record = record
|
|
199
|
+
except BaseException:
|
|
200
|
+
if self._server is not None:
|
|
201
|
+
self._server.close()
|
|
202
|
+
await self._server.wait_closed()
|
|
203
|
+
self._server = None
|
|
204
|
+
if os.name != "nt":
|
|
205
|
+
self._socket_path.unlink(missing_ok=True)
|
|
206
|
+
raise
|
|
207
|
+
|
|
208
|
+
async def close(self) -> None:
|
|
209
|
+
"""Stop accepting messages and remove this runtime's registry entry."""
|
|
210
|
+
async with self._close_lock:
|
|
211
|
+
server = self._server
|
|
212
|
+
self._server = None
|
|
213
|
+
if server is not None:
|
|
214
|
+
server.close()
|
|
215
|
+
await server.wait_closed()
|
|
216
|
+
self._remove_own_record()
|
|
217
|
+
if os.name != "nt":
|
|
218
|
+
self._socket_path.unlink(missing_ok=True)
|
|
219
|
+
self._record = None
|
|
220
|
+
|
|
221
|
+
def close_nowait(self) -> None:
|
|
222
|
+
"""Synchronously make a peer undiscoverable during a session switch."""
|
|
223
|
+
server = self._server
|
|
224
|
+
self._server = None
|
|
225
|
+
if server is not None:
|
|
226
|
+
server.close()
|
|
227
|
+
self._remove_own_record()
|
|
228
|
+
if os.name != "nt":
|
|
229
|
+
self._socket_path.unlink(missing_ok=True)
|
|
230
|
+
self._record = None
|
|
231
|
+
|
|
232
|
+
def list_peers(self) -> list[PeerRecord]:
|
|
233
|
+
"""Return live discoverable peers, pruning records from dead processes."""
|
|
234
|
+
try:
|
|
235
|
+
paths = list(self._registry_root.glob("*.json"))
|
|
236
|
+
except OSError:
|
|
237
|
+
return []
|
|
238
|
+
|
|
239
|
+
peers: list[PeerRecord] = []
|
|
240
|
+
for path in paths:
|
|
241
|
+
try:
|
|
242
|
+
record = PeerRecord.model_validate_json(path.read_text(encoding="utf-8"))
|
|
243
|
+
except Exception:
|
|
244
|
+
logger.debug("Ignoring malformed peer record %s", path, exc_info=True)
|
|
245
|
+
continue
|
|
246
|
+
if record.session_id == self.session_id:
|
|
247
|
+
continue
|
|
248
|
+
if not self._record_is_live(record):
|
|
249
|
+
self._remove_stale_record(path, record)
|
|
250
|
+
continue
|
|
251
|
+
peers.append(record)
|
|
252
|
+
return sorted(peers, key=lambda peer: (peer.name, peer.session_id))
|
|
253
|
+
|
|
254
|
+
async def send(self, target: str, text: str) -> PeerDelivery:
|
|
255
|
+
"""Resolve *target*, deliver plain text, and wait for an acknowledgement."""
|
|
256
|
+
if not isinstance(text, str) or not text.strip():
|
|
257
|
+
return PeerDelivery(message_id="", status="failed", detail="Message text is empty")
|
|
258
|
+
if len(text.encode("utf-8")) > self._max_message_size_bytes:
|
|
259
|
+
return PeerDelivery(
|
|
260
|
+
message_id="",
|
|
261
|
+
status="failed",
|
|
262
|
+
detail=f"Message exceeds {self._max_message_size_bytes} bytes",
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
peer = self.resolve_peer(target)
|
|
267
|
+
except ValueError as exc:
|
|
268
|
+
return PeerDelivery(message_id="", status="failed", detail=str(exc))
|
|
269
|
+
|
|
270
|
+
message = PeerMessage(
|
|
271
|
+
from_session_id=self.session_id,
|
|
272
|
+
from_name=self.name,
|
|
273
|
+
from_cwd=self.cwd,
|
|
274
|
+
sender_permission_class=self._permission_class_provider(),
|
|
275
|
+
to_session_id=peer.session_id,
|
|
276
|
+
to_auth_token=peer.auth_token,
|
|
277
|
+
text=text,
|
|
278
|
+
)
|
|
279
|
+
writer: asyncio.StreamWriter | None = None
|
|
280
|
+
try:
|
|
281
|
+
if peer.socket_path.startswith("tcp://"):
|
|
282
|
+
address = urlsplit(peer.socket_path)
|
|
283
|
+
if address.hostname != "127.0.0.1" or address.port is None:
|
|
284
|
+
raise ValueError("Peer TCP endpoint is not local")
|
|
285
|
+
connection = asyncio.open_connection(address.hostname, address.port)
|
|
286
|
+
else:
|
|
287
|
+
connection = asyncio.open_unix_connection(peer.socket_path)
|
|
288
|
+
reader, writer = await asyncio.wait_for(
|
|
289
|
+
connection,
|
|
290
|
+
timeout=self._connect_timeout,
|
|
291
|
+
)
|
|
292
|
+
assert writer is not None
|
|
293
|
+
writer.write(message.model_dump_json().encode("utf-8") + b"\n")
|
|
294
|
+
await asyncio.wait_for(writer.drain(), timeout=self._connect_timeout)
|
|
295
|
+
raw = await asyncio.wait_for(reader.readline(), timeout=self._connect_timeout)
|
|
296
|
+
if not raw:
|
|
297
|
+
raise ConnectionError("Recipient closed without an acknowledgement")
|
|
298
|
+
payload = json.loads(raw)
|
|
299
|
+
return PeerDelivery(
|
|
300
|
+
message_id=message.id,
|
|
301
|
+
status=payload.get("status", "failed"),
|
|
302
|
+
detail=str(payload.get("detail", "")),
|
|
303
|
+
)
|
|
304
|
+
except Exception as exc:
|
|
305
|
+
self._remove_record_if_matches(peer)
|
|
306
|
+
return PeerDelivery(
|
|
307
|
+
message_id=message.id,
|
|
308
|
+
status="failed",
|
|
309
|
+
detail=f"Could not reach {peer.name}: {exc}",
|
|
310
|
+
)
|
|
311
|
+
finally:
|
|
312
|
+
if writer is not None:
|
|
313
|
+
writer.close()
|
|
314
|
+
try:
|
|
315
|
+
await writer.wait_closed()
|
|
316
|
+
except (BrokenPipeError, ConnectionError, OSError):
|
|
317
|
+
pass
|
|
318
|
+
|
|
319
|
+
def resolve_peer(self, target: str) -> PeerRecord:
|
|
320
|
+
"""Resolve a unique peer by exact name, session ID, or ID prefix."""
|
|
321
|
+
selector = str(target or "").strip()
|
|
322
|
+
if not selector:
|
|
323
|
+
raise ValueError("Target session is required")
|
|
324
|
+
peers = self.list_peers()
|
|
325
|
+
exact_id = [peer for peer in peers if peer.session_id == selector]
|
|
326
|
+
if exact_id:
|
|
327
|
+
return exact_id[0]
|
|
328
|
+
exact_name = [peer for peer in peers if peer.name == selector]
|
|
329
|
+
if len(exact_name) == 1:
|
|
330
|
+
return exact_name[0]
|
|
331
|
+
if len(exact_name) > 1:
|
|
332
|
+
raise ValueError(f"Session name '{selector}' is ambiguous; use a session ID")
|
|
333
|
+
prefix = [peer for peer in peers if peer.session_id.startswith(selector)]
|
|
334
|
+
if len(prefix) == 1:
|
|
335
|
+
return prefix[0]
|
|
336
|
+
if len(prefix) > 1:
|
|
337
|
+
raise ValueError(f"Session ID prefix '{selector}' is ambiguous")
|
|
338
|
+
raise ValueError(f"No live session matches '{selector}'")
|
|
339
|
+
|
|
340
|
+
async def _handle_client(
|
|
341
|
+
self,
|
|
342
|
+
reader: asyncio.StreamReader,
|
|
343
|
+
writer: asyncio.StreamWriter,
|
|
344
|
+
) -> None:
|
|
345
|
+
response: dict[str, str] = {"status": "failed", "detail": "Invalid message"}
|
|
346
|
+
try:
|
|
347
|
+
raw = await asyncio.wait_for(reader.readline(), timeout=self._connect_timeout)
|
|
348
|
+
if not raw or len(raw) > self._max_message_size_bytes + 16_384:
|
|
349
|
+
raise ValueError("Message frame is empty or too large")
|
|
350
|
+
payload = json.loads(raw)
|
|
351
|
+
message = PeerMessage.model_validate(payload)
|
|
352
|
+
if message.version != _PROTOCOL_VERSION:
|
|
353
|
+
raise ValueError(f"Unsupported peer protocol version {message.version}")
|
|
354
|
+
if message.to_session_id != self.session_id:
|
|
355
|
+
raise ValueError("Message was addressed to a different session")
|
|
356
|
+
if not secrets.compare_digest(message.to_auth_token, self._auth_token):
|
|
357
|
+
raise ValueError("Invalid inbox authentication token")
|
|
358
|
+
if len(message.text.encode("utf-8")) > self._max_message_size_bytes:
|
|
359
|
+
raise ValueError("Message text exceeds the configured byte limit")
|
|
360
|
+
throttle_reason = self._throttle_reason(message)
|
|
361
|
+
if throttle_reason:
|
|
362
|
+
response = {"status": "refused", "detail": throttle_reason}
|
|
363
|
+
else:
|
|
364
|
+
decision = self._inbound_decision(message.sender_permission_class)
|
|
365
|
+
if decision == "refuse":
|
|
366
|
+
response = {
|
|
367
|
+
"status": "refused",
|
|
368
|
+
"detail": "Receiving session's inbound policy refused the message",
|
|
369
|
+
}
|
|
370
|
+
elif decision == "hold":
|
|
371
|
+
if self._on_hold is None:
|
|
372
|
+
response = {
|
|
373
|
+
"status": "refused",
|
|
374
|
+
"detail": "Receiving session cannot request inbound approval",
|
|
375
|
+
}
|
|
376
|
+
else:
|
|
377
|
+
delivery = await self._on_hold(message)
|
|
378
|
+
response = {
|
|
379
|
+
"status": delivery.status,
|
|
380
|
+
"detail": delivery.detail,
|
|
381
|
+
}
|
|
382
|
+
elif await self._on_message(message):
|
|
383
|
+
response = {"status": "delivered", "detail": ""}
|
|
384
|
+
else:
|
|
385
|
+
response = {"status": "failed", "detail": "Receiving queue is unavailable"}
|
|
386
|
+
except Exception as exc:
|
|
387
|
+
response = {"status": "failed", "detail": str(exc)}
|
|
388
|
+
finally:
|
|
389
|
+
try:
|
|
390
|
+
writer.write(json.dumps(response, separators=(",", ":")).encode("utf-8") + b"\n")
|
|
391
|
+
await writer.drain()
|
|
392
|
+
except (BrokenPipeError, ConnectionError, OSError):
|
|
393
|
+
pass
|
|
394
|
+
writer.close()
|
|
395
|
+
try:
|
|
396
|
+
await writer.wait_closed()
|
|
397
|
+
except (BrokenPipeError, ConnectionError, OSError):
|
|
398
|
+
pass
|
|
399
|
+
|
|
400
|
+
def _inbound_decision(
|
|
401
|
+
self,
|
|
402
|
+
sender_class: Literal["prompting", "bypass"],
|
|
403
|
+
) -> Literal["accept", "hold", "refuse"]:
|
|
404
|
+
if self.inbound == "accept":
|
|
405
|
+
return "accept"
|
|
406
|
+
if self.inbound == "hold":
|
|
407
|
+
return "hold"
|
|
408
|
+
if self.inbound == "refuse":
|
|
409
|
+
return "refuse"
|
|
410
|
+
return (
|
|
411
|
+
"accept"
|
|
412
|
+
if sender_class == self._permission_class_provider()
|
|
413
|
+
else "hold"
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
def _throttle_reason(self, message: PeerMessage) -> str:
|
|
417
|
+
"""Bound loops and identical repeats before they reach the model queue."""
|
|
418
|
+
now = asyncio.get_running_loop().time()
|
|
419
|
+
cutoff = now - 60.0
|
|
420
|
+
self._seen_message_ids = {
|
|
421
|
+
message_id: seen_at
|
|
422
|
+
for message_id, seen_at in self._seen_message_ids.items()
|
|
423
|
+
if seen_at >= cutoff
|
|
424
|
+
}
|
|
425
|
+
self._recent_payloads = {
|
|
426
|
+
key: seen_at
|
|
427
|
+
for key, seen_at in self._recent_payloads.items()
|
|
428
|
+
if seen_at >= now - 2.0
|
|
429
|
+
}
|
|
430
|
+
if message.id in self._seen_message_ids:
|
|
431
|
+
return "Duplicate message ID"
|
|
432
|
+
|
|
433
|
+
window = self._sender_windows.setdefault(message.from_session_id, deque())
|
|
434
|
+
while window and window[0] < cutoff:
|
|
435
|
+
window.popleft()
|
|
436
|
+
if len(window) >= 30:
|
|
437
|
+
return "Sender rate limit exceeded"
|
|
438
|
+
|
|
439
|
+
payload_key = (message.from_session_id, message.text)
|
|
440
|
+
if payload_key in self._recent_payloads:
|
|
441
|
+
return "Identical repeated message"
|
|
442
|
+
|
|
443
|
+
self._seen_message_ids[message.id] = now
|
|
444
|
+
self._recent_payloads[payload_key] = now
|
|
445
|
+
window.append(now)
|
|
446
|
+
return ""
|
|
447
|
+
|
|
448
|
+
def _write_record(self, record: PeerRecord) -> None:
|
|
449
|
+
temp_path: str | None = None
|
|
450
|
+
try:
|
|
451
|
+
with tempfile.NamedTemporaryFile(
|
|
452
|
+
mode="w",
|
|
453
|
+
encoding="utf-8",
|
|
454
|
+
dir=self._registry_root,
|
|
455
|
+
prefix=f".{self._registry_path.stem}.",
|
|
456
|
+
suffix=".tmp",
|
|
457
|
+
delete=False,
|
|
458
|
+
) as handle:
|
|
459
|
+
temp_path = handle.name
|
|
460
|
+
handle.write(record.model_dump_json())
|
|
461
|
+
handle.flush()
|
|
462
|
+
os.fsync(handle.fileno())
|
|
463
|
+
os.chmod(temp_path, 0o600)
|
|
464
|
+
os.replace(temp_path, self._registry_path)
|
|
465
|
+
temp_path = None
|
|
466
|
+
finally:
|
|
467
|
+
if temp_path is not None:
|
|
468
|
+
try:
|
|
469
|
+
os.unlink(temp_path)
|
|
470
|
+
except OSError:
|
|
471
|
+
pass
|
|
472
|
+
|
|
473
|
+
def _remove_own_record(self) -> None:
|
|
474
|
+
try:
|
|
475
|
+
current = PeerRecord.model_validate_json(
|
|
476
|
+
self._registry_path.read_text(encoding="utf-8")
|
|
477
|
+
)
|
|
478
|
+
except Exception:
|
|
479
|
+
return
|
|
480
|
+
if current.socket_path == self._endpoint:
|
|
481
|
+
self._registry_path.unlink(missing_ok=True)
|
|
482
|
+
|
|
483
|
+
@staticmethod
|
|
484
|
+
def _record_is_live(record: PeerRecord) -> bool:
|
|
485
|
+
if (
|
|
486
|
+
not record.socket_path.startswith("tcp://")
|
|
487
|
+
and not Path(record.socket_path).exists()
|
|
488
|
+
):
|
|
489
|
+
return False
|
|
490
|
+
return is_process_running(record.pid)
|
|
491
|
+
|
|
492
|
+
def _remove_stale_record(self, path: Path, record: PeerRecord) -> None:
|
|
493
|
+
try:
|
|
494
|
+
path.unlink(missing_ok=True)
|
|
495
|
+
if not record.socket_path.startswith("tcp://"):
|
|
496
|
+
socket_path = Path(record.socket_path)
|
|
497
|
+
allowed_roots = {
|
|
498
|
+
self._registry_root.resolve(),
|
|
499
|
+
self._socket_root.resolve(),
|
|
500
|
+
}
|
|
501
|
+
if socket_path.parent.resolve() in allowed_roots:
|
|
502
|
+
socket_path.unlink(missing_ok=True)
|
|
503
|
+
except OSError:
|
|
504
|
+
logger.debug("Could not prune stale peer %s", record.session_id, exc_info=True)
|
|
505
|
+
|
|
506
|
+
def _remove_record_if_matches(self, peer: PeerRecord) -> None:
|
|
507
|
+
path = self._registry_root / (
|
|
508
|
+
hashlib.sha256(peer.session_id.encode()).hexdigest()[:24] + ".json"
|
|
509
|
+
)
|
|
510
|
+
try:
|
|
511
|
+
current = PeerRecord.model_validate_json(path.read_text(encoding="utf-8"))
|
|
512
|
+
except Exception:
|
|
513
|
+
return
|
|
514
|
+
if current.socket_path == peer.socket_path:
|
|
515
|
+
try:
|
|
516
|
+
path.unlink(missing_ok=True)
|
|
517
|
+
except OSError:
|
|
518
|
+
pass
|