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.
Files changed (85) hide show
  1. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/PKG-INFO +1 -1
  2. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/claude/sdk_adapter.py +29 -5
  3. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/codex/adapter.py +23 -8
  4. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/control.py +31 -6
  5. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/local/common.py +31 -6
  6. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/local/file_ops.py +55 -10
  7. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/local/ops.py +4 -0
  8. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/local/shell.py +106 -25
  9. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/local/terminal.py +14 -3
  10. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/local_runtime.py +17 -6
  11. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/runtime.py +258 -22
  12. agentlink_cli-0.1.7/connector/sync_state.py +318 -0
  13. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/version.py +1 -1
  14. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/pyproject.toml +1 -1
  15. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_connector_runtime.py +295 -5
  16. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_terminal_backend.py +115 -1
  17. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_version.py +1 -1
  18. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/uv.lock +1 -1
  19. agentlink_cli-0.1.6/connector/sync_state.py +0 -155
  20. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/.gitignore +0 -0
  21. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/README.md +0 -0
  22. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/__init__.py +0 -0
  23. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/acp/__init__.py +0 -0
  24. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/acp/adapter.py +0 -0
  25. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/acp/config_options.py +0 -0
  26. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/acp/discovery.py +0 -0
  27. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/acp/manifest.py +0 -0
  28. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/acp/manifests/__init__.py +0 -0
  29. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/acp/manifests/codebuddy.json +0 -0
  30. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/acp/manifests/cursor.json +0 -0
  31. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/acp/manifests/gemini.json +0 -0
  32. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/acp/manifests/grok_build.json +0 -0
  33. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/acp/reducer.py +0 -0
  34. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/acp/rpc.py +0 -0
  35. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/adapter.py +0 -0
  36. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/attachments.py +0 -0
  37. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/capabilities.py +0 -0
  38. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/claude/__init__.py +0 -0
  39. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/claude/history_adapter.py +0 -0
  40. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/claude/normalized.py +0 -0
  41. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/claude/normalizers.py +0 -0
  42. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/claude/path_utils.py +0 -0
  43. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/claude/preferences.py +0 -0
  44. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/claude/timeline_identity.py +0 -0
  45. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/claude/timeline_reducer.py +0 -0
  46. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/claude/trust.py +0 -0
  47. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/cli.py +0 -0
  48. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/codex/__init__.py +0 -0
  49. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/codex/collaboration.py +0 -0
  50. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/codex/history.py +0 -0
  51. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/codex/reducer.py +0 -0
  52. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/codex/rpc.py +0 -0
  53. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/json_rpc.py +0 -0
  54. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/launch.py +0 -0
  55. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/local/__init__.py +0 -0
  56. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/local_ops.py +0 -0
  57. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/logging.py +0 -0
  58. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/perf.py +0 -0
  59. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/protocol.py +0 -0
  60. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/registry.py +0 -0
  61. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/connector/time.py +0 -0
  62. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/run.sh +0 -0
  63. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/__init__.py +0 -0
  64. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/acp/__init__.py +0 -0
  65. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/acp/fake_agent/__init__.py +0 -0
  66. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/acp/fake_agent/__main__.py +0 -0
  67. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/acp/test_acp_adapter_fake.py +0 -0
  68. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/acp/test_acp_reducer_golden.py +0 -0
  69. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/acp/test_config_options.py +0 -0
  70. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/contract/__init__.py +0 -0
  71. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/contract/helpers.py +0 -0
  72. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_claude_history_adapter.py +0 -0
  73. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_claude_preferences.py +0 -0
  74. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_claude_sdk_adapter.py +0 -0
  75. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_claude_timeline_parity.py +0 -0
  76. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_claude_trust.py +0 -0
  77. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_codex_adapter.py +0 -0
  78. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_connector_capabilities.py +0 -0
  79. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_connector_cli.py +0 -0
  80. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_connector_control.py +0 -0
  81. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_connector_json_rpc.py +0 -0
  82. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_connector_logging.py +0 -0
  83. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_launch_command.py +0 -0
  84. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_local_runtime.py +0 -0
  85. {agentlink_cli-0.1.6 → agentlink_cli-0.1.7}/tests/test_perf.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentlink-cli
3
- Version: 0.1.6
3
+ Version: 0.1.7
4
4
  Summary: Local runtime connector for AgentLink
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: claude-agent-sdk
@@ -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 ClaudeTimelineReducer, is_task_event_tool_name
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.reducer import CODEX_APPROVAL_METHODS, ReductionResult, TimelineReducer
16
- from connector.codex.collaboration import collaboration_mode_payload, normalize_collaboration_modes
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 BackendRpcClient, ConnectorAuthenticationError, ConnectorConfig
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=float(params.get("heartbeatSeconds", 20)),
258
- reconnect_seconds=float(params.get("reconnectSeconds", 3)),
259
- sync_existing_on_connect=bool(params.get("syncExistingOnConnect", True)),
260
- sync_interval_seconds=float(params.get("syncIntervalSeconds", 30)),
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, Awaitable, Callable
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
- path = Path(raw_path).expanduser()
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
- return path.resolve(strict=False)
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 mimetypes
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
- data = path.read_bytes()
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": len(data),
51
- "sha256": hashlib.sha256(data).hexdigest(),
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
- path.write_bytes(data)
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
- full = path.read_bytes()
100
- clipped = full[:max_bytes]
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": len(full),
108
- "sha256": hashlib.sha256(full).hexdigest(),
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
- start = time.monotonic()
37
- process = await self._create_process(cwd, command)
38
- timed_out = False
39
- try:
40
- stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout_ms / 1000)
41
- except TimeoutError:
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(cwd, command, process.returncode, timed_out, start, stdout, stderr)
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
- process = await self._create_process(cwd, command)
110
- record["process"] = process
111
- try:
112
- stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout_ms / 1000)
113
- except TimeoutError:
114
- timed_out = True
115
- await self._terminate_process(process)
116
- stdout, stderr = await process.communicate()
117
- result = shell_result(cwd, command, process.returncode, timed_out, start, stdout, stderr)
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 Notify, nearest_existing_dir, required_string, resolve_path, workspace_root
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.write_text(
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
- runtime_file.chmod(0o600)
85
- except OSError:
86
- pass
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