agentlink-cli 0.1.6__tar.gz → 0.1.7__tar.gz
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.
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/PKG-INFO +1 -1
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/claude/sdk_adapter.py +29 -5
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/codex/adapter.py +23 -8
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/control.py +31 -6
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/local/common.py +31 -6
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/local/file_ops.py +55 -10
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/local/ops.py +4 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/local/shell.py +106 -25
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/local/terminal.py +14 -3
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/local_runtime.py +17 -6
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/runtime.py +258 -22
- agentlink_cli-0.1.7/connector/sync_state.py +318 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/version.py +1 -1
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/pyproject.toml +1 -1
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_connector_runtime.py +295 -5
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_terminal_backend.py +115 -1
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_version.py +1 -1
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/uv.lock +1 -1
- agentlink_cli-0.1.6/connector/sync_state.py +0 -155
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/.gitignore +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/README.md +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/__init__.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/acp/__init__.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/acp/adapter.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/acp/config_options.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/acp/discovery.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/acp/manifest.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/acp/manifests/__init__.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/acp/manifests/codebuddy.json +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/acp/manifests/cursor.json +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/acp/manifests/gemini.json +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/acp/manifests/grok_build.json +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/acp/reducer.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/acp/rpc.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/adapter.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/attachments.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/capabilities.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/claude/__init__.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/claude/history_adapter.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/claude/normalized.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/claude/normalizers.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/claude/path_utils.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/claude/preferences.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/claude/timeline_identity.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/claude/timeline_reducer.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/claude/trust.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/cli.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/codex/__init__.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/codex/collaboration.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/codex/history.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/codex/reducer.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/codex/rpc.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/json_rpc.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/launch.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/local/__init__.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/local_ops.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/logging.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/perf.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/protocol.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/registry.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/time.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/run.sh +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/__init__.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/acp/__init__.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/acp/fake_agent/__init__.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/acp/fake_agent/__main__.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/acp/test_acp_adapter_fake.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/acp/test_acp_reducer_golden.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/acp/test_config_options.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/contract/__init__.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/contract/helpers.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_claude_history_adapter.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_claude_preferences.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_claude_sdk_adapter.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_claude_timeline_parity.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_claude_trust.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_codex_adapter.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_connector_capabilities.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_connector_cli.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_connector_control.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_connector_json_rpc.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_connector_logging.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_launch_command.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_local_runtime.py +0 -0
- {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_perf.py +0 -0
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import base64
|
|
5
5
|
import hashlib
|
|
6
|
+
import inspect
|
|
6
7
|
import json
|
|
7
8
|
import re
|
|
8
9
|
import secrets
|
|
@@ -10,18 +11,19 @@ from collections.abc import Awaitable, Callable
|
|
|
10
11
|
from dataclasses import dataclass, field
|
|
11
12
|
from typing import Any
|
|
12
13
|
|
|
13
|
-
from connector.logging import logger
|
|
14
|
-
|
|
15
|
-
from connector.attachments import attachment_target
|
|
16
14
|
from connector.adapter import NotificationSink
|
|
15
|
+
from connector.attachments import attachment_target
|
|
17
16
|
from connector.claude.history_adapter import ClaudeHistoryAdapter
|
|
18
17
|
from connector.claude.normalized import NormalizedClaudeEvent
|
|
19
18
|
from connector.claude.normalizers import ClaudeLiveNormalizer
|
|
20
|
-
from connector.claude.timeline_reducer import
|
|
19
|
+
from connector.claude.timeline_reducer import (
|
|
20
|
+
ClaudeTimelineReducer,
|
|
21
|
+
is_task_event_tool_name,
|
|
22
|
+
)
|
|
21
23
|
from connector.launch import LaunchTarget, launch_target
|
|
24
|
+
from connector.logging import logger
|
|
22
25
|
from connector.time import utc_now
|
|
23
26
|
|
|
24
|
-
|
|
25
27
|
AttachmentDownloader = Callable[[str, str], Awaitable[tuple[bytes, str, str]]]
|
|
26
28
|
"""(session_id, file_id) -> (data, original_name, media_type)"""
|
|
27
29
|
|
|
@@ -97,6 +99,28 @@ class ClaudeSdkAdapter:
|
|
|
97
99
|
def apply_history_sync_state(self, state: list[dict[str, Any]]) -> None:
|
|
98
100
|
self.history_adapter.apply_history_sync_state(state)
|
|
99
101
|
|
|
102
|
+
async def close(self) -> None:
|
|
103
|
+
tasks = [
|
|
104
|
+
runtime.active_task
|
|
105
|
+
for runtime in self._sessions.values()
|
|
106
|
+
if runtime.active_task is not None and not runtime.active_task.done()
|
|
107
|
+
]
|
|
108
|
+
for runtime in self._sessions.values():
|
|
109
|
+
runtime.interrupted = True
|
|
110
|
+
for pending in runtime.pending_approvals.values():
|
|
111
|
+
if not pending.future.done():
|
|
112
|
+
pending.future.set_result("cancelled")
|
|
113
|
+
client = runtime.client
|
|
114
|
+
if client is not None:
|
|
115
|
+
disconnect = getattr(client, "disconnect", None)
|
|
116
|
+
if callable(disconnect):
|
|
117
|
+
result = disconnect()
|
|
118
|
+
if inspect.isawaitable(result):
|
|
119
|
+
await result
|
|
120
|
+
for task in tasks:
|
|
121
|
+
task.cancel()
|
|
122
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
123
|
+
|
|
100
124
|
async def create_session(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
101
125
|
session_id = (
|
|
102
126
|
_optional_string(params.get("sessionId"))
|
|
@@ -1,24 +1,29 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
-
from collections.abc import Awaitable, Callable
|
|
5
|
-
from dataclasses import dataclass, field
|
|
6
|
-
from datetime import UTC, datetime
|
|
7
4
|
import hashlib
|
|
8
5
|
import json
|
|
9
6
|
import time
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
10
|
from typing import Any
|
|
11
11
|
|
|
12
|
-
from connector.logging import logger
|
|
13
|
-
|
|
14
12
|
from connector.attachments import attachment_target
|
|
15
|
-
from connector.codex.
|
|
16
|
-
|
|
13
|
+
from connector.codex.collaboration import (
|
|
14
|
+
collaboration_mode_payload,
|
|
15
|
+
normalize_collaboration_modes,
|
|
16
|
+
)
|
|
17
|
+
from connector.codex.reducer import (
|
|
18
|
+
CODEX_APPROVAL_METHODS,
|
|
19
|
+
ReductionResult,
|
|
20
|
+
TimelineReducer,
|
|
21
|
+
)
|
|
17
22
|
from connector.codex.rpc import JsonRpcStdioClient
|
|
23
|
+
from connector.logging import logger
|
|
18
24
|
from connector.sync_state import SyncStateStore
|
|
19
25
|
from connector.time import utc_now
|
|
20
26
|
|
|
21
|
-
|
|
22
27
|
AttachmentDownloader = Callable[[str, str], Awaitable[tuple[bytes, str, str]]]
|
|
23
28
|
"""(session_id, file_id) -> (data, original_name, media_type)"""
|
|
24
29
|
|
|
@@ -115,6 +120,16 @@ class CodexAdapter:
|
|
|
115
120
|
self._existing_thread_sync_markers.clear()
|
|
116
121
|
self._existing_thread_names.clear()
|
|
117
122
|
|
|
123
|
+
async def close(self) -> None:
|
|
124
|
+
tasks = [task for task in self._history_sync_tasks.values() if not task.done()]
|
|
125
|
+
for task in tasks:
|
|
126
|
+
task.cancel()
|
|
127
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
128
|
+
self._history_sync_tasks.clear()
|
|
129
|
+
if self.rpc is not None:
|
|
130
|
+
await self.rpc.close()
|
|
131
|
+
self._started = False
|
|
132
|
+
|
|
118
133
|
def forget_persisted_sync_state(self, connector_id: str) -> None:
|
|
119
134
|
self.forget_sync_state()
|
|
120
135
|
if self.sync_state_store is not None:
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import math
|
|
4
5
|
from collections.abc import Awaitable, Callable
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from typing import Any
|
|
@@ -15,8 +16,11 @@ from connector.local_runtime import (
|
|
|
15
16
|
write_runtime,
|
|
16
17
|
)
|
|
17
18
|
from connector.logging import logger
|
|
18
|
-
from connector.runtime import
|
|
19
|
-
|
|
19
|
+
from connector.runtime import (
|
|
20
|
+
BackendRpcClient,
|
|
21
|
+
ConnectorAuthenticationError,
|
|
22
|
+
ConnectorConfig,
|
|
23
|
+
)
|
|
20
24
|
|
|
21
25
|
ControlNotifier = Callable[[str, Any], Awaitable[None]]
|
|
22
26
|
|
|
@@ -254,10 +258,10 @@ def config_from_params(params: Any) -> ConnectorConfig:
|
|
|
254
258
|
server_url=server_url,
|
|
255
259
|
connector_id=connector_id,
|
|
256
260
|
connector_token=connector_token,
|
|
257
|
-
heartbeat_seconds=
|
|
258
|
-
reconnect_seconds=
|
|
259
|
-
sync_existing_on_connect=
|
|
260
|
-
sync_interval_seconds=
|
|
261
|
+
heartbeat_seconds=positive_number_param(params, "heartbeatSeconds", 20),
|
|
262
|
+
reconnect_seconds=positive_number_param(params, "reconnectSeconds", 3),
|
|
263
|
+
sync_existing_on_connect=bool_param(params, "syncExistingOnConnect", True),
|
|
264
|
+
sync_interval_seconds=positive_number_param(params, "syncIntervalSeconds", 30),
|
|
261
265
|
state_db_path=params.get("stateDbPath") if isinstance(params.get("stateDbPath"), str) else None,
|
|
262
266
|
)
|
|
263
267
|
|
|
@@ -296,3 +300,24 @@ def float_param(params: Any, key: str, default: float) -> float:
|
|
|
296
300
|
return float(params.get(key, default))
|
|
297
301
|
except (TypeError, ValueError):
|
|
298
302
|
return default
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def bool_param(params: Any, key: str, default: bool) -> bool:
|
|
306
|
+
if not isinstance(params, dict):
|
|
307
|
+
return default
|
|
308
|
+
value = params.get(key, default)
|
|
309
|
+
if not isinstance(value, bool):
|
|
310
|
+
raise TypeError(f"{key} must be a boolean")
|
|
311
|
+
return value
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def positive_number_param(params: Any, key: str, default: float) -> float:
|
|
315
|
+
if not isinstance(params, dict):
|
|
316
|
+
return default
|
|
317
|
+
value = params.get(key, default)
|
|
318
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
319
|
+
raise TypeError(f"{key} must be a finite positive number")
|
|
320
|
+
parsed = float(value)
|
|
321
|
+
if not math.isfinite(parsed) or parsed <= 0:
|
|
322
|
+
raise ValueError(f"{key} must be a finite positive number")
|
|
323
|
+
return parsed
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import time
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
4
5
|
from pathlib import Path
|
|
5
|
-
from typing import Any
|
|
6
|
-
|
|
6
|
+
from typing import Any
|
|
7
7
|
|
|
8
8
|
MAX_DIR_ENTRIES = 500
|
|
9
9
|
MAX_OUTPUT_CHARS = 64_000
|
|
10
|
+
# UTF-8 uses at most four bytes per Unicode code point. Retaining this many
|
|
11
|
+
# bytes guarantees that the first MAX_OUTPUT_CHARS decoded characters are
|
|
12
|
+
# available while keeping subprocess output memory bounded.
|
|
13
|
+
MAX_OUTPUT_BYTES = MAX_OUTPUT_CHARS * 4
|
|
10
14
|
MAX_READ_TEXT_BYTES = 4 * 1024 * 1024
|
|
15
|
+
MAX_COMMAND_CHARS = 64 * 1024
|
|
16
|
+
MAX_SHELL_TIMEOUT_MS = 60 * 60 * 1000
|
|
11
17
|
|
|
12
18
|
Notify = Callable[[str, dict[str, Any]], Awaitable[None]]
|
|
13
19
|
|
|
@@ -18,6 +24,14 @@ class StaleFileError(Exception):
|
|
|
18
24
|
code = "stale"
|
|
19
25
|
|
|
20
26
|
|
|
27
|
+
class WorkspaceBoundaryError(ValueError):
|
|
28
|
+
code = "workspace_boundary"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ResourceLimitError(RuntimeError):
|
|
32
|
+
code = "resource_limit"
|
|
33
|
+
|
|
34
|
+
|
|
21
35
|
def workspace_root(params: dict[str, Any]) -> Path:
|
|
22
36
|
raw_root = params.get("root") or params.get("cwd")
|
|
23
37
|
if not isinstance(raw_root, str) or not raw_root.strip():
|
|
@@ -26,10 +40,18 @@ def workspace_root(params: dict[str, Any]) -> Path:
|
|
|
26
40
|
|
|
27
41
|
|
|
28
42
|
def resolve_path(root: Path, raw_path: str) -> Path:
|
|
29
|
-
|
|
43
|
+
if raw_path.startswith("~"):
|
|
44
|
+
raise WorkspaceBoundaryError("path must stay within the workspace")
|
|
45
|
+
root = root.resolve(strict=False)
|
|
46
|
+
path = Path(raw_path)
|
|
30
47
|
if not path.is_absolute():
|
|
31
48
|
path = root / path
|
|
32
|
-
|
|
49
|
+
resolved = path.resolve(strict=False)
|
|
50
|
+
try:
|
|
51
|
+
resolved.relative_to(root)
|
|
52
|
+
except ValueError as exc:
|
|
53
|
+
raise WorkspaceBoundaryError("path must stay within the workspace") from exc
|
|
54
|
+
return resolved
|
|
33
55
|
|
|
34
56
|
|
|
35
57
|
def nearest_existing_dir(path: Path, *, fallback: Path | None = None) -> Path:
|
|
@@ -102,6 +124,9 @@ def shell_result(
|
|
|
102
124
|
start: float,
|
|
103
125
|
stdout: bytes,
|
|
104
126
|
stderr: bytes,
|
|
127
|
+
*,
|
|
128
|
+
stdout_bytes_truncated: bool = False,
|
|
129
|
+
stderr_bytes_truncated: bool = False,
|
|
105
130
|
) -> dict[str, Any]:
|
|
106
131
|
stdout_text, stdout_truncated = decode_output(stdout)
|
|
107
132
|
stderr_text, stderr_truncated = decode_output(stderr)
|
|
@@ -113,6 +138,6 @@ def shell_result(
|
|
|
113
138
|
"durationMs": int((time.monotonic() - start) * 1000),
|
|
114
139
|
"stdout": stdout_text,
|
|
115
140
|
"stderr": stderr_text,
|
|
116
|
-
"stdoutTruncated": stdout_truncated,
|
|
117
|
-
"stderrTruncated": stderr_truncated,
|
|
141
|
+
"stdoutTruncated": stdout_truncated or stdout_bytes_truncated,
|
|
142
|
+
"stderrTruncated": stderr_truncated or stderr_bytes_truncated,
|
|
118
143
|
}
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import asyncio
|
|
4
4
|
import hashlib
|
|
5
|
+
import mimetypes
|
|
6
|
+
import os
|
|
7
|
+
import stat
|
|
5
8
|
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
6
11
|
from typing import Any
|
|
7
12
|
|
|
8
13
|
from connector.local.common import (
|
|
@@ -43,12 +48,12 @@ class FileOps:
|
|
|
43
48
|
path = resolve_path(root, required_string(params, "path"))
|
|
44
49
|
if not path.is_file():
|
|
45
50
|
raise FileNotFoundError(f"file not found: {path}")
|
|
46
|
-
|
|
51
|
+
size, digest = await asyncio.to_thread(_file_metadata, path)
|
|
47
52
|
return {
|
|
48
53
|
"path": str(path),
|
|
49
54
|
"name": path.name,
|
|
50
|
-
"size":
|
|
51
|
-
"sha256":
|
|
55
|
+
"size": size,
|
|
56
|
+
"sha256": digest,
|
|
52
57
|
"mediaType": mimetypes.guess_type(path.name)[0] or "application/octet-stream",
|
|
53
58
|
}
|
|
54
59
|
|
|
@@ -79,7 +84,7 @@ class FileOps:
|
|
|
79
84
|
f"file changed on disk (expected sha256={if_match}, found sha256={current_hash or 'none'})"
|
|
80
85
|
)
|
|
81
86
|
data = content.encode(content_encoding)
|
|
82
|
-
|
|
87
|
+
await asyncio.to_thread(_atomic_write, path, data)
|
|
83
88
|
return {
|
|
84
89
|
"path": str(path),
|
|
85
90
|
"encoding": "utf8",
|
|
@@ -96,16 +101,15 @@ class FileOps:
|
|
|
96
101
|
if not isinstance(raw_max, int):
|
|
97
102
|
raise ValueError("maxBytes must be an integer")
|
|
98
103
|
max_bytes = min(max(raw_max, 1), MAX_READ_TEXT_BYTES)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
truncated = len(full) > max_bytes
|
|
104
|
+
size, digest, clipped = await asyncio.to_thread(_read_text_metadata, path, max_bytes)
|
|
105
|
+
truncated = size > max_bytes
|
|
102
106
|
binary = b"\x00" in clipped
|
|
103
107
|
content = "" if binary else clipped.decode("utf-8", errors="replace")
|
|
104
108
|
return {
|
|
105
109
|
"path": str(path),
|
|
106
110
|
"name": path.name,
|
|
107
|
-
"size":
|
|
108
|
-
"sha256":
|
|
111
|
+
"size": size,
|
|
112
|
+
"sha256": digest,
|
|
109
113
|
"encoding": "utf8",
|
|
110
114
|
"content": content,
|
|
111
115
|
"truncated": truncated,
|
|
@@ -142,3 +146,44 @@ class FileOps:
|
|
|
142
146
|
"entries": entries,
|
|
143
147
|
"truncated": len(entries) >= MAX_DIR_ENTRIES,
|
|
144
148
|
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _file_metadata(path: Path) -> tuple[int, str]:
|
|
152
|
+
digest = hashlib.sha256()
|
|
153
|
+
size = 0
|
|
154
|
+
with path.open("rb") as stream:
|
|
155
|
+
while chunk := stream.read(1024 * 1024):
|
|
156
|
+
size += len(chunk)
|
|
157
|
+
digest.update(chunk)
|
|
158
|
+
return size, digest.hexdigest()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _read_text_metadata(path: Path, max_bytes: int) -> tuple[int, str, bytes]:
|
|
162
|
+
digest = hashlib.sha256()
|
|
163
|
+
size = 0
|
|
164
|
+
clipped = bytearray()
|
|
165
|
+
with path.open("rb") as stream:
|
|
166
|
+
while chunk := stream.read(1024 * 1024):
|
|
167
|
+
size += len(chunk)
|
|
168
|
+
digest.update(chunk)
|
|
169
|
+
if len(clipped) < max_bytes:
|
|
170
|
+
clipped.extend(chunk[: max_bytes - len(clipped)])
|
|
171
|
+
return size, digest.hexdigest(), bytes(clipped)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _atomic_write(path: Path, data: bytes) -> None:
|
|
175
|
+
existing_mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else None
|
|
176
|
+
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
177
|
+
try:
|
|
178
|
+
if existing_mode is not None:
|
|
179
|
+
os.chmod(temporary, existing_mode)
|
|
180
|
+
with os.fdopen(fd, "wb") as stream:
|
|
181
|
+
stream.write(data)
|
|
182
|
+
stream.flush()
|
|
183
|
+
os.fsync(stream.fileno())
|
|
184
|
+
os.replace(temporary, path)
|
|
185
|
+
finally:
|
|
186
|
+
try:
|
|
187
|
+
os.unlink(temporary)
|
|
188
|
+
except FileNotFoundError:
|
|
189
|
+
pass
|
|
@@ -78,6 +78,10 @@ class LocalOps:
|
|
|
78
78
|
async def terminal_snapshot(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
79
79
|
return await self.terminal.snapshot(params)
|
|
80
80
|
|
|
81
|
+
async def aclose(self) -> None:
|
|
82
|
+
await self.shell.aclose()
|
|
83
|
+
await self.terminal.aclose()
|
|
84
|
+
|
|
81
85
|
|
|
82
86
|
def create_local_ops(
|
|
83
87
|
notify: Notify | None = None,
|
|
@@ -9,7 +9,11 @@ from pathlib import Path
|
|
|
9
9
|
from typing import Any
|
|
10
10
|
|
|
11
11
|
from connector.local.common import (
|
|
12
|
+
MAX_COMMAND_CHARS,
|
|
13
|
+
MAX_OUTPUT_BYTES,
|
|
14
|
+
MAX_SHELL_TIMEOUT_MS,
|
|
12
15
|
Notify,
|
|
16
|
+
ResourceLimitError,
|
|
13
17
|
required_int,
|
|
14
18
|
required_string,
|
|
15
19
|
resolve_path,
|
|
@@ -17,11 +21,15 @@ from connector.local.common import (
|
|
|
17
21
|
workspace_root,
|
|
18
22
|
)
|
|
19
23
|
|
|
24
|
+
MAX_SHELL_PROCESSES = 8
|
|
25
|
+
|
|
20
26
|
|
|
21
27
|
class ShellBackend:
|
|
22
28
|
def __init__(self, notify: Notify | None = None) -> None:
|
|
23
29
|
self.notify = notify
|
|
24
30
|
self._shell_tasks: dict[str, dict[str, Any]] = {}
|
|
31
|
+
self._process_slots = asyncio.Semaphore(MAX_SHELL_PROCESSES)
|
|
32
|
+
self._closing = False
|
|
25
33
|
|
|
26
34
|
async def exec(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
27
35
|
root = workspace_root(params)
|
|
@@ -29,21 +37,26 @@ class ShellBackend:
|
|
|
29
37
|
if not cwd.is_dir():
|
|
30
38
|
raise NotADirectoryError(f"cwd not found: {cwd}")
|
|
31
39
|
command = required_string(params, "command")
|
|
32
|
-
timeout_ms = required_int(params, "timeoutMs")
|
|
33
|
-
if timeout_ms <= 0:
|
|
34
|
-
raise ValueError("timeoutMs must be positive")
|
|
40
|
+
timeout_ms = _validated_request(command, required_int(params, "timeoutMs"))
|
|
35
41
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
timed_out = True
|
|
43
|
-
await self._terminate_process(process)
|
|
44
|
-
stdout, stderr = await process.communicate()
|
|
42
|
+
async with self._process_slots:
|
|
43
|
+
start = time.monotonic()
|
|
44
|
+
process = await self._create_process(cwd, command)
|
|
45
|
+
stdout, stderr, timed_out, stdout_truncated, stderr_truncated = (
|
|
46
|
+
await self._collect_process(process, timeout_ms)
|
|
47
|
+
)
|
|
45
48
|
|
|
46
|
-
return shell_result(
|
|
49
|
+
return shell_result(
|
|
50
|
+
cwd,
|
|
51
|
+
command,
|
|
52
|
+
process.returncode,
|
|
53
|
+
timed_out,
|
|
54
|
+
start,
|
|
55
|
+
stdout,
|
|
56
|
+
stderr,
|
|
57
|
+
stdout_bytes_truncated=stdout_truncated,
|
|
58
|
+
stderr_bytes_truncated=stderr_truncated,
|
|
59
|
+
)
|
|
47
60
|
|
|
48
61
|
async def task_start(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
49
62
|
root = workspace_root(params)
|
|
@@ -53,11 +66,11 @@ class ShellBackend:
|
|
|
53
66
|
task_id = required_string(params, "taskId")
|
|
54
67
|
session_id = required_string(params, "sessionId")
|
|
55
68
|
command = required_string(params, "command")
|
|
56
|
-
timeout_ms = required_int(params, "timeoutMs")
|
|
57
|
-
if timeout_ms <= 0:
|
|
58
|
-
raise ValueError("timeoutMs must be positive")
|
|
69
|
+
timeout_ms = _validated_request(command, required_int(params, "timeoutMs"))
|
|
59
70
|
if task_id in self._shell_tasks:
|
|
60
71
|
raise ValueError(f"shell task already exists: {task_id}")
|
|
72
|
+
if self._closing or len(self._shell_tasks) >= MAX_SHELL_PROCESSES:
|
|
73
|
+
raise ResourceLimitError("too many active shell tasks")
|
|
61
74
|
|
|
62
75
|
record: dict[str, Any] = {"process": None, "cancelled": False}
|
|
63
76
|
background = asyncio.create_task(
|
|
@@ -106,15 +119,23 @@ class ShellBackend:
|
|
|
106
119
|
stderr = b""
|
|
107
120
|
process: asyncio.subprocess.Process | None = None
|
|
108
121
|
try:
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
stdout, stderr
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
122
|
+
async with self._process_slots:
|
|
123
|
+
process = await self._create_process(cwd, command)
|
|
124
|
+
record["process"] = process
|
|
125
|
+
stdout, stderr, timed_out, stdout_truncated, stderr_truncated = (
|
|
126
|
+
await self._collect_process(process, timeout_ms)
|
|
127
|
+
)
|
|
128
|
+
result = shell_result(
|
|
129
|
+
cwd,
|
|
130
|
+
command,
|
|
131
|
+
process.returncode,
|
|
132
|
+
timed_out,
|
|
133
|
+
start,
|
|
134
|
+
stdout,
|
|
135
|
+
stderr,
|
|
136
|
+
stdout_bytes_truncated=stdout_truncated,
|
|
137
|
+
stderr_bytes_truncated=stderr_truncated,
|
|
138
|
+
)
|
|
118
139
|
await self._notify(
|
|
119
140
|
"shell.task.completed",
|
|
120
141
|
{"taskId": task_id, "sessionId": session_id, "status": "completed", "result": result},
|
|
@@ -136,6 +157,58 @@ class ShellBackend:
|
|
|
136
157
|
finally:
|
|
137
158
|
self._shell_tasks.pop(task_id, None)
|
|
138
159
|
|
|
160
|
+
async def _collect_process(
|
|
161
|
+
self, process: asyncio.subprocess.Process, timeout_ms: int
|
|
162
|
+
) -> tuple[bytes, bytes, bool, bool, bool]:
|
|
163
|
+
async def read_limited(stream: asyncio.StreamReader | None) -> tuple[bytes, bool]:
|
|
164
|
+
if stream is None:
|
|
165
|
+
return b"", False
|
|
166
|
+
retained = bytearray()
|
|
167
|
+
truncated = False
|
|
168
|
+
while chunk := await stream.read(64 * 1024):
|
|
169
|
+
if len(retained) < MAX_OUTPUT_BYTES:
|
|
170
|
+
remaining = MAX_OUTPUT_BYTES - len(retained)
|
|
171
|
+
retained.extend(chunk[:remaining])
|
|
172
|
+
truncated = truncated or len(chunk) > remaining
|
|
173
|
+
else:
|
|
174
|
+
truncated = True
|
|
175
|
+
return bytes(retained), truncated
|
|
176
|
+
|
|
177
|
+
stdout_task = asyncio.create_task(read_limited(process.stdout))
|
|
178
|
+
stderr_task = asyncio.create_task(read_limited(process.stderr))
|
|
179
|
+
timed_out = False
|
|
180
|
+
try:
|
|
181
|
+
await asyncio.wait_for(process.wait(), timeout=timeout_ms / 1000)
|
|
182
|
+
except TimeoutError:
|
|
183
|
+
timed_out = True
|
|
184
|
+
await self._terminate_process(process)
|
|
185
|
+
except asyncio.CancelledError:
|
|
186
|
+
try:
|
|
187
|
+
await self._terminate_process(process)
|
|
188
|
+
finally:
|
|
189
|
+
stdout_task.cancel()
|
|
190
|
+
stderr_task.cancel()
|
|
191
|
+
await asyncio.gather(stdout_task, stderr_task, return_exceptions=True)
|
|
192
|
+
raise
|
|
193
|
+
(stdout, stdout_truncated), (stderr, stderr_truncated) = await asyncio.gather(
|
|
194
|
+
stdout_task, stderr_task
|
|
195
|
+
)
|
|
196
|
+
return stdout, stderr, timed_out, stdout_truncated, stderr_truncated
|
|
197
|
+
|
|
198
|
+
async def aclose(self) -> None:
|
|
199
|
+
self._closing = True
|
|
200
|
+
records = list(self._shell_tasks.values())
|
|
201
|
+
for record in records:
|
|
202
|
+
await self._terminate_process(record.get("process"))
|
|
203
|
+
task = record.get("background")
|
|
204
|
+
if isinstance(task, asyncio.Task):
|
|
205
|
+
task.cancel()
|
|
206
|
+
await asyncio.gather(
|
|
207
|
+
*(record["background"] for record in records if isinstance(record.get("background"), asyncio.Task)),
|
|
208
|
+
return_exceptions=True,
|
|
209
|
+
)
|
|
210
|
+
self._shell_tasks.clear()
|
|
211
|
+
|
|
139
212
|
async def _create_process(self, cwd: Path, command: str) -> asyncio.subprocess.Process:
|
|
140
213
|
raise NotImplementedError
|
|
141
214
|
|
|
@@ -223,3 +296,11 @@ class WindowsShellBackend(ShellBackend):
|
|
|
223
296
|
except TimeoutError:
|
|
224
297
|
process.kill()
|
|
225
298
|
await process.wait()
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _validated_request(command: str, timeout_ms: int) -> int:
|
|
302
|
+
if len(command) > MAX_COMMAND_CHARS:
|
|
303
|
+
raise ResourceLimitError("command is too large")
|
|
304
|
+
if timeout_ms <= 0 or timeout_ms > MAX_SHELL_TIMEOUT_MS:
|
|
305
|
+
raise ValueError(f"timeoutMs must be between 1 and {MAX_SHELL_TIMEOUT_MS}")
|
|
306
|
+
return timeout_ms
|
|
@@ -7,13 +7,18 @@ import os
|
|
|
7
7
|
import signal
|
|
8
8
|
import sys
|
|
9
9
|
import time
|
|
10
|
+
from collections.abc import Awaitable, Callable
|
|
10
11
|
from datetime import UTC, datetime
|
|
11
12
|
from pathlib import Path
|
|
12
|
-
from collections.abc import Awaitable, Callable
|
|
13
13
|
from typing import Any
|
|
14
14
|
|
|
15
|
-
from connector.local.common import
|
|
16
|
-
|
|
15
|
+
from connector.local.common import (
|
|
16
|
+
Notify,
|
|
17
|
+
nearest_existing_dir,
|
|
18
|
+
required_string,
|
|
19
|
+
resolve_path,
|
|
20
|
+
workspace_root,
|
|
21
|
+
)
|
|
17
22
|
|
|
18
23
|
TerminalOutput = Callable[[str, dict[str, Any]], Awaitable[None]]
|
|
19
24
|
TERMINAL_SCROLLBACK_MAX_BYTES = 512 * 1024
|
|
@@ -191,6 +196,12 @@ class TerminalBackend:
|
|
|
191
196
|
self._forget_terminal(terminal_id)
|
|
192
197
|
return {"terminalId": terminal_id, "closed": True}
|
|
193
198
|
|
|
199
|
+
async def aclose(self) -> None:
|
|
200
|
+
records = list(self._terminals.values())
|
|
201
|
+
for record in records:
|
|
202
|
+
await self._kill_terminal(record)
|
|
203
|
+
self._forget_terminal(record["id"])
|
|
204
|
+
|
|
194
205
|
async def rename(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
195
206
|
self._gc()
|
|
196
207
|
terminal_id = required_string(params, "terminalId")
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
5
|
import sys
|
|
6
|
+
import tempfile
|
|
6
7
|
from dataclasses import dataclass
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
@@ -66,7 +67,9 @@ def write_runtime(path: str | Path, config: ConnectorConfig, *, kind: str, pid:
|
|
|
66
67
|
|
|
67
68
|
runtime_file = Path(path)
|
|
68
69
|
runtime_file.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
-
runtime_file.
|
|
70
|
+
if runtime_file.is_symlink():
|
|
71
|
+
raise ValueError("connector runtime path must not be a symlink")
|
|
72
|
+
payload = (
|
|
70
73
|
json.dumps(
|
|
71
74
|
{
|
|
72
75
|
"pid": int(pid if pid is not None else os.getpid()),
|
|
@@ -77,13 +80,21 @@ def write_runtime(path: str | Path, config: ConnectorConfig, *, kind: str, pid:
|
|
|
77
80
|
},
|
|
78
81
|
indent=2,
|
|
79
82
|
)
|
|
80
|
-
+ "\n"
|
|
81
|
-
encoding="utf-8",
|
|
83
|
+
+ "\n"
|
|
82
84
|
)
|
|
85
|
+
fd, temporary = tempfile.mkstemp(prefix=f".{runtime_file.name}.", dir=runtime_file.parent)
|
|
83
86
|
try:
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
+
os.chmod(temporary, 0o600)
|
|
88
|
+
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
89
|
+
stream.write(payload)
|
|
90
|
+
stream.flush()
|
|
91
|
+
os.fsync(stream.fileno())
|
|
92
|
+
os.replace(temporary, runtime_file)
|
|
93
|
+
finally:
|
|
94
|
+
try:
|
|
95
|
+
os.unlink(temporary)
|
|
96
|
+
except FileNotFoundError:
|
|
97
|
+
pass
|
|
87
98
|
return runtime_file
|
|
88
99
|
|
|
89
100
|
|