@neoline/hostbridge 2.0.4 → 2.0.5

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 (40) hide show
  1. package/README.md +11 -9
  2. package/docs/ARCHITECTURE.md +62 -0
  3. package/package.json +2 -1
  4. package/pyproject.toml +1 -1
  5. package/src/server_control_mcp/__init__.py +4 -3
  6. package/src/server_control_mcp/async_lifecycle.py +41 -0
  7. package/src/server_control_mcp/cli.py +1 -1
  8. package/src/server_control_mcp/client.py +302 -287
  9. package/src/server_control_mcp/config.py +2 -1
  10. package/src/server_control_mcp/daemon.py +233 -526
  11. package/src/server_control_mcp/doctor.py +34 -2
  12. package/src/server_control_mcp/hosts.py +136 -2
  13. package/src/server_control_mcp/mock_ssh.py +52 -14
  14. package/src/server_control_mcp/mock_ssh_exec.py +147 -58
  15. package/src/server_control_mcp/mock_ssh_session.py +3 -2
  16. package/src/server_control_mcp/mock_ssh_sftp.py +126 -28
  17. package/src/server_control_mcp/mock_ssh_tunnel.py +141 -444
  18. package/src/server_control_mcp/mux_connection.py +326 -0
  19. package/src/server_control_mcp/mux_daemon.py +186 -0
  20. package/src/server_control_mcp/mux_protocol.py +279 -0
  21. package/src/server_control_mcp/mux_records.py +71 -0
  22. package/src/server_control_mcp/mux_rpc.py +1779 -0
  23. package/src/server_control_mcp/mux_service.py +506 -0
  24. package/src/server_control_mcp/mux_stream.py +283 -0
  25. package/src/server_control_mcp/mux_sync_client.py +224 -0
  26. package/src/server_control_mcp/remote_agent_bundle.py +56 -0
  27. package/src/server_control_mcp/remote_mux_agent.py +769 -0
  28. package/src/server_control_mcp/runtime_services.py +862 -0
  29. package/src/server_control_mcp/server.py +33 -18
  30. package/src/server_control_mcp/services.py +2 -666
  31. package/src/server_control_mcp/transports/__init__.py +4 -8
  32. package/src/server_control_mcp/transports/base.py +60 -38
  33. package/src/server_control_mcp/transports/ssh.py +206 -259
  34. package/src/server_control_mcp/tunnel_manager.py +1245 -0
  35. package/src/server_control_mcp/tunnel_native_ssh.py +454 -0
  36. package/src/server_control_mcp/tunnel_providers.py +20 -0
  37. package/src/server_control_mcp/tunnel_pty_agent.py +909 -0
  38. package/src/server_control_mcp/protocol.py +0 -363
  39. package/src/server_control_mcp/remote_tunnel_agent.py +0 -143
  40. package/src/server_control_mcp/transports/pty.py +0 -515
@@ -0,0 +1,326 @@
1
+ """Lifecycle and bounded resource primitives for multiplexed connections."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from enum import Enum, StrEnum
9
+
10
+ from .mux_protocol import FrameType, MuxProtocolError
11
+
12
+
13
+ class MuxStateError(RuntimeError):
14
+ """Raised when an operation is invalid for the current lifecycle state."""
15
+
16
+
17
+ class StreamState(StrEnum):
18
+ OPENING = "OPENING"
19
+ OPEN = "OPEN"
20
+ LOCAL_EOF = "LOCAL_EOF"
21
+ REMOTE_EOF = "REMOTE_EOF"
22
+ DRAINING = "DRAINING"
23
+ CLOSING = "CLOSING"
24
+ CLOSED = "CLOSED"
25
+ RESET = "RESET"
26
+
27
+
28
+ class TerminalKind(StrEnum):
29
+ CLOSED = "CLOSED"
30
+ RESET = "RESET"
31
+
32
+
33
+ class ConnectionState(StrEnum):
34
+ DISCONNECTED = "DISCONNECTED"
35
+ CONNECTING = "CONNECTING"
36
+ HANDSHAKING = "HANDSHAKING"
37
+ READY = "READY"
38
+ DRAINING = "DRAINING"
39
+ CLOSED = "CLOSED"
40
+ FAILED = "FAILED"
41
+
42
+
43
+ class FrameDisposition(Enum):
44
+ UNKNOWN_STREAM = "unknown_stream"
45
+ DISCARD_LATE_DATA = "discard_late_data"
46
+ IGNORE_TERMINAL = "ignore_terminal"
47
+ PROTOCOL_ERROR = "protocol_error"
48
+
49
+
50
+ class ByteCreditWindow:
51
+ def __init__(self, *, initial_credit: int, maximum_credit: int) -> None:
52
+ _positive_or_zero(initial_credit, "initial_credit")
53
+ _positive(maximum_credit, "maximum_credit")
54
+ if initial_credit > maximum_credit:
55
+ raise ValueError("initial credit exceeds maximum credit")
56
+ self._available = initial_credit
57
+ self._maximum = maximum_credit
58
+ self._closed = False
59
+
60
+ @property
61
+ def available(self) -> int:
62
+ return self._available
63
+
64
+ @property
65
+ def closed(self) -> bool:
66
+ return self._closed
67
+
68
+ def consume(self, amount: int) -> None:
69
+ _positive(amount, "amount")
70
+ if self._closed:
71
+ raise MuxStateError("credit window is closed")
72
+ if amount > self._available:
73
+ raise MuxStateError("insufficient byte credit")
74
+ self._available -= amount
75
+
76
+ def grant(self, amount: int) -> None:
77
+ _positive(amount, "amount")
78
+ if self._closed:
79
+ return
80
+ if self._available + amount > self._maximum:
81
+ raise MuxProtocolError("credit grant exceeds negotiated maximum")
82
+ self._available += amount
83
+
84
+ def close(self) -> None:
85
+ self._closed = True
86
+
87
+
88
+ class StreamLifecycle:
89
+ def __init__(self, *, open_initiator: bool) -> None:
90
+ if not isinstance(open_initiator, bool):
91
+ raise TypeError("open_initiator must be a boolean")
92
+ self.open_initiator = open_initiator
93
+ self.state = StreamState.OPENING
94
+ self.terminal_kind: TerminalKind | None = None
95
+ self._opened = False
96
+ self._local_eof = False
97
+ self._remote_eof = False
98
+ self._close_sent = False
99
+ self._close_received = False
100
+
101
+ @property
102
+ def opened(self) -> bool:
103
+ return self._opened
104
+
105
+ @property
106
+ def local_eof(self) -> bool:
107
+ return self._local_eof
108
+
109
+ @property
110
+ def remote_eof(self) -> bool:
111
+ return self._remote_eof
112
+
113
+ @property
114
+ def close_sent(self) -> bool:
115
+ return self._close_sent
116
+
117
+ @property
118
+ def close_received(self) -> bool:
119
+ return self._close_received
120
+
121
+ @property
122
+ def terminal(self) -> bool:
123
+ return self.state in {StreamState.CLOSED, StreamState.RESET}
124
+
125
+ def mark_open(self) -> None:
126
+ self._require(StreamState.OPENING)
127
+ self._opened = True
128
+ self.state = StreamState.OPEN
129
+
130
+ def send_eof(self) -> None:
131
+ if self._local_eof:
132
+ return
133
+ self._require_active("send EOF")
134
+ self._local_eof = True
135
+ self._update_eof_state()
136
+
137
+ def receive_eof(self) -> None:
138
+ if self._remote_eof:
139
+ return
140
+ self._require_active("receive EOF")
141
+ self._remote_eof = True
142
+ self._update_eof_state()
143
+
144
+ def validate_send_data(self) -> None:
145
+ if self.state not in {StreamState.OPEN, StreamState.REMOTE_EOF}:
146
+ raise MuxStateError(f"cannot send DATA while stream is {self.state.value}")
147
+
148
+ def validate_receive_data(self) -> None:
149
+ if self.state not in {StreamState.OPEN, StreamState.LOCAL_EOF}:
150
+ raise MuxStateError(f"cannot receive DATA while stream is {self.state.value}")
151
+
152
+ def send_close(self) -> None:
153
+ if self._close_sent:
154
+ return
155
+ if not self.open_initiator:
156
+ raise MuxStateError("only the OPEN initiator may send CLOSE")
157
+ self._require(StreamState.DRAINING)
158
+ self._close_sent = True
159
+ self.state = StreamState.CLOSING
160
+
161
+ def receive_close(self) -> None:
162
+ if self._close_received:
163
+ return
164
+ if self.open_initiator:
165
+ raise MuxStateError("OPEN initiator cannot receive CLOSE")
166
+ self._require(StreamState.DRAINING)
167
+ self._close_received = True
168
+ self.state = StreamState.CLOSING
169
+
170
+ def send_close_ack(self) -> None:
171
+ if self.state is StreamState.CLOSED:
172
+ return
173
+ if self.open_initiator or not self._close_received:
174
+ raise MuxStateError("CLOSE_ACK requires a received CLOSE on the acceptor")
175
+ self._finish_closed()
176
+
177
+ def receive_close_ack(self) -> None:
178
+ if self.state is StreamState.CLOSED:
179
+ return
180
+ if not self.open_initiator or not self._close_sent:
181
+ raise MuxStateError("CLOSE_ACK requires a sent CLOSE on the initiator")
182
+ self._finish_closed()
183
+
184
+ def send_reset(self) -> None:
185
+ self._finish_reset()
186
+
187
+ def receive_reset(self) -> None:
188
+ self._finish_reset()
189
+
190
+ def _update_eof_state(self) -> None:
191
+ if self._local_eof and self._remote_eof:
192
+ self.state = StreamState.DRAINING
193
+ elif self._local_eof:
194
+ self.state = StreamState.LOCAL_EOF
195
+ else:
196
+ self.state = StreamState.REMOTE_EOF
197
+
198
+ def _require_active(self, action: str) -> None:
199
+ if self.state not in {StreamState.OPEN, StreamState.LOCAL_EOF, StreamState.REMOTE_EOF}:
200
+ raise MuxStateError(f"cannot {action} while stream is {self.state.value}")
201
+
202
+ def _require(self, state: StreamState) -> None:
203
+ if self.state is not state:
204
+ raise MuxStateError(f"stream state must be {state.value}, found {self.state.value}")
205
+
206
+ def _finish_closed(self) -> None:
207
+ self.state = StreamState.CLOSED
208
+ self.terminal_kind = TerminalKind.CLOSED
209
+
210
+ def _finish_reset(self) -> None:
211
+ if self.state in {StreamState.CLOSED, StreamState.RESET}:
212
+ return
213
+ self.state = StreamState.RESET
214
+ self.terminal_kind = TerminalKind.RESET
215
+
216
+
217
+ class ConnectionLifecycle:
218
+ def __init__(self) -> None:
219
+ self.state = ConnectionState.DISCONNECTED
220
+
221
+ def start_connecting(self) -> None:
222
+ self._require_any(ConnectionState.DISCONNECTED, ConnectionState.CLOSED, ConnectionState.FAILED)
223
+ self.state = ConnectionState.CONNECTING
224
+
225
+ @property
226
+ def terminal(self) -> bool:
227
+ return self.state in {ConnectionState.CLOSED, ConnectionState.FAILED}
228
+
229
+ @property
230
+ def draining(self) -> bool:
231
+ return self.state is ConnectionState.DRAINING
232
+
233
+ def start_handshake(self) -> None:
234
+ self._require(ConnectionState.CONNECTING)
235
+ self.state = ConnectionState.HANDSHAKING
236
+
237
+ def mark_ready(self) -> None:
238
+ self._require(ConnectionState.HANDSHAKING)
239
+ self.state = ConnectionState.READY
240
+
241
+ def start_draining(self) -> None:
242
+ if self.state is ConnectionState.DRAINING:
243
+ return
244
+ self._require(ConnectionState.READY)
245
+ self.state = ConnectionState.DRAINING
246
+
247
+ def mark_closed(self) -> None:
248
+ if self.state is ConnectionState.CLOSED:
249
+ return
250
+ self._require_any(ConnectionState.CONNECTING, ConnectionState.HANDSHAKING, ConnectionState.DRAINING)
251
+ self.state = ConnectionState.CLOSED
252
+
253
+ def mark_failed(self) -> None:
254
+ if self.state is ConnectionState.FAILED:
255
+ return
256
+ if self.state is ConnectionState.CLOSED:
257
+ raise MuxStateError("cannot fail connection while CLOSED")
258
+ self.state = ConnectionState.FAILED
259
+
260
+ def _require(self, state: ConnectionState) -> None:
261
+ if self.state is not state:
262
+ raise MuxStateError(f"connection state must be {state.value}, found {self.state.value}")
263
+
264
+ def _require_any(self, *states: ConnectionState) -> None:
265
+ if self.state not in states:
266
+ allowed = ", ".join(state.value for state in states)
267
+ raise MuxStateError(f"connection state must be one of {allowed}, found {self.state.value}")
268
+
269
+
270
+ @dataclass(frozen=True, slots=True)
271
+ class _Tombstone:
272
+ terminal_kind: TerminalKind
273
+ expires_at: float
274
+
275
+
276
+ class StreamTombstones:
277
+ def __init__(
278
+ self,
279
+ *,
280
+ retention_seconds: float,
281
+ max_entries: int = 4096,
282
+ clock: Callable[[], float] = time.monotonic,
283
+ ) -> None:
284
+ if retention_seconds <= 0:
285
+ raise ValueError("retention_seconds must be positive")
286
+ _positive(max_entries, "max_entries")
287
+ self._retention_seconds = float(retention_seconds)
288
+ self._max_entries = max_entries
289
+ self._clock = clock
290
+ self._entries: dict[int, _Tombstone] = {}
291
+
292
+ def add(self, stream_id: int, terminal_kind: TerminalKind) -> None:
293
+ if not isinstance(stream_id, int) or isinstance(stream_id, bool) or stream_id <= 0:
294
+ raise ValueError("stream_id must be a positive integer")
295
+ if not isinstance(terminal_kind, TerminalKind):
296
+ raise TypeError("terminal_kind must be a TerminalKind")
297
+ self.expire()
298
+ if stream_id not in self._entries and len(self._entries) >= self._max_entries:
299
+ raise MuxStateError("tombstone capacity is exhausted")
300
+ self._entries[stream_id] = _Tombstone(terminal_kind, self._clock() + self._retention_seconds)
301
+
302
+ def classify(self, stream_id: int, frame_type: FrameType) -> FrameDisposition:
303
+ self.expire()
304
+ if stream_id not in self._entries:
305
+ return FrameDisposition.UNKNOWN_STREAM
306
+ if frame_type is FrameType.OPEN:
307
+ return FrameDisposition.PROTOCOL_ERROR
308
+ if frame_type is FrameType.DATA:
309
+ return FrameDisposition.DISCARD_LATE_DATA
310
+ return FrameDisposition.IGNORE_TERMINAL
311
+
312
+ def expire(self) -> None:
313
+ now = self._clock()
314
+ expired = [stream_id for stream_id, tombstone in self._entries.items() if tombstone.expires_at <= now]
315
+ for stream_id in expired:
316
+ del self._entries[stream_id]
317
+
318
+
319
+ def _positive(value: int, name: str) -> None:
320
+ if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
321
+ raise ValueError(f"{name} must be a positive integer")
322
+
323
+
324
+ def _positive_or_zero(value: int, name: str) -> None:
325
+ if not isinstance(value, int) or isinstance(value, bool) or value < 0:
326
+ raise ValueError(f"{name} must be a non-negative integer")
@@ -0,0 +1,186 @@
1
+ """Async Unix daemon primitives for persistent multiplexed HostBridge RPC."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ from collections.abc import Awaitable, Callable
8
+ from contextlib import suppress
9
+ from pathlib import Path
10
+ from typing import Any
11
+ from uuid import uuid4
12
+
13
+ from .mux_rpc import (
14
+ DEFAULT_CONNECTION_WINDOW_BYTES,
15
+ DEFAULT_STREAM_WINDOW_BYTES,
16
+ MuxRpcRequestClosedHandler,
17
+ MuxRpcServerConnection,
18
+ )
19
+ from .mux_stream import MuxRpcStream
20
+
21
+ AsyncRpcHandler = Callable[[str, dict[str, object]], Awaitable[dict[str, object]]]
22
+ MuxDaemonStreamHandler = Callable[
23
+ [str, dict[str, object], MuxRpcStream, str],
24
+ Awaitable[dict[str, object]],
25
+ ]
26
+
27
+
28
+ class MuxDaemonServer:
29
+ def __init__(
30
+ self,
31
+ socket_path: Path | str,
32
+ handler: AsyncRpcHandler,
33
+ *,
34
+ stream_handler: MuxDaemonStreamHandler | None = None,
35
+ request_closed_handler: MuxRpcRequestClosedHandler | None = None,
36
+ max_handlers_per_connection: int = 256,
37
+ drain_timeout: float = 5.0,
38
+ ) -> None:
39
+ _positive(max_handlers_per_connection, "max_handlers_per_connection")
40
+ if drain_timeout <= 0:
41
+ raise ValueError("drain_timeout must be positive")
42
+ self.socket_path = Path(socket_path)
43
+ self._handler = handler
44
+ self._stream_handler = stream_handler
45
+ self._request_closed_handler = request_closed_handler
46
+ self._max_handlers_per_connection = max_handlers_per_connection
47
+ self._drain_timeout = float(drain_timeout)
48
+ self._server: asyncio.AbstractServer | None = None
49
+ self._tasks: set[asyncio.Task[None]] = set()
50
+ self._writers: set[asyncio.StreamWriter] = set()
51
+ self._connections: set[MuxRpcServerConnection] = set()
52
+ self._closing = False
53
+ self._close_task: asyncio.Task[None] | None = None
54
+ self.accepted_connections = 0
55
+
56
+ @property
57
+ def active_connections(self) -> int:
58
+ return len(self._tasks)
59
+
60
+ @property
61
+ def active_handlers(self) -> int:
62
+ return sum(connection.active_requests for connection in self._connections)
63
+
64
+ async def start(self) -> None:
65
+ if self._server is not None:
66
+ raise RuntimeError("multiplexed daemon is already started")
67
+ if self._closing:
68
+ raise RuntimeError("multiplexed daemon is closed")
69
+ self.socket_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
70
+ self._server = await asyncio.start_unix_server(self._accept, path=self.socket_path)
71
+ os.chmod(self.socket_path, 0o600)
72
+
73
+ async def close(self) -> None:
74
+ task = self._close_task
75
+ if task is None:
76
+ self._closing = True
77
+ task = asyncio.create_task(
78
+ self._close_owned_resources(),
79
+ name="hostbridge-mux-daemon-close",
80
+ )
81
+ self._close_task = task
82
+ await asyncio.shield(task)
83
+
84
+ async def _close_owned_resources(self) -> None:
85
+ listener = self._server
86
+ if self._server is not None:
87
+ self._server.close()
88
+ self._server = None
89
+ connections = list(self._connections)
90
+ if connections:
91
+ drain_tasks = [asyncio.create_task(connection.start_draining()) for connection in connections]
92
+ await _wait_tasks_bounded(drain_tasks, self._drain_timeout)
93
+ pending = set(self._tasks)
94
+ if pending:
95
+ _, pending = await asyncio.wait(pending, timeout=self._drain_timeout)
96
+ for writer in list(self._writers):
97
+ writer.close()
98
+ if self._writers:
99
+ waiters = [asyncio.create_task(writer.wait_closed()) for writer in list(self._writers)]
100
+ await _wait_tasks_bounded(waiters, self._drain_timeout)
101
+ for task in pending:
102
+ task.cancel()
103
+ task.add_done_callback(_consume_task_result)
104
+ if listener is not None:
105
+ await listener.wait_closed()
106
+ self.socket_path.unlink(missing_ok=True)
107
+
108
+ def _accept(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
109
+ if self._closing:
110
+ writer.close()
111
+ return
112
+ self.accepted_connections += 1
113
+ client_connection_id = uuid4().hex
114
+ self._writers.add(writer)
115
+
116
+ async def run_stream_handler(
117
+ method: str,
118
+ params: dict[str, object],
119
+ stream: MuxRpcStream,
120
+ ) -> dict[str, object]:
121
+ return await self._run_stream_handler(
122
+ method,
123
+ params,
124
+ stream,
125
+ client_connection_id,
126
+ )
127
+
128
+ connection = MuxRpcServerConnection(
129
+ reader,
130
+ writer,
131
+ self._run_handler,
132
+ max_handlers=self._max_handlers_per_connection,
133
+ stream_handler=run_stream_handler if self._stream_handler is not None else None,
134
+ request_closed_handler=self._request_closed_handler,
135
+ connection_window=DEFAULT_CONNECTION_WINDOW_BYTES,
136
+ stream_window=DEFAULT_STREAM_WINDOW_BYTES,
137
+ cleanup_timeout=self._drain_timeout,
138
+ )
139
+ self._connections.add(connection)
140
+ task = asyncio.create_task(
141
+ connection.serve(),
142
+ name=f"hostbridge-mux-daemon-{self.accepted_connections}",
143
+ )
144
+ self._tasks.add(task)
145
+
146
+ def finished(done: asyncio.Task[None]) -> None:
147
+ _consume_task_result(done)
148
+ self._tasks.discard(done)
149
+ self._writers.discard(writer)
150
+ self._connections.discard(connection)
151
+
152
+ task.add_done_callback(finished)
153
+
154
+ async def _run_handler(self, method: str, params: dict[str, object]) -> dict[str, object]:
155
+ return await self._handler(method, params)
156
+
157
+ async def _run_stream_handler(
158
+ self,
159
+ method: str,
160
+ params: dict[str, object],
161
+ stream: MuxRpcStream,
162
+ client_connection_id: str,
163
+ ) -> dict[str, object]:
164
+ handler = self._stream_handler
165
+ if handler is None:
166
+ raise RuntimeError("multiplexed daemon stream handler is unavailable")
167
+ return await handler(method, params, stream, client_connection_id)
168
+
169
+
170
+ def _positive(value: int, name: str) -> None:
171
+ if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
172
+ raise ValueError(f"{name} must be a positive integer")
173
+
174
+
175
+ def _consume_task_result(task: asyncio.Task[object]) -> None:
176
+ with suppress(BaseException):
177
+ task.result()
178
+
179
+
180
+ async def _wait_tasks_bounded(tasks: list[asyncio.Task[Any]], timeout: float) -> None:
181
+ done, stalled = await asyncio.wait(tasks, timeout=timeout)
182
+ for task in done:
183
+ _consume_task_result(task)
184
+ for task in stalled:
185
+ task.cancel()
186
+ task.add_done_callback(_consume_task_result)