@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,283 @@
1
+ """Bounded stream state shared by multiplexed RPC clients and servers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Callable
7
+ from contextlib import suppress
8
+ from typing import Protocol
9
+
10
+ from .mux_connection import ByteCreditWindow, StreamLifecycle, StreamState
11
+ from .mux_protocol import SequenceTracker
12
+
13
+
14
+ class MuxStreamOwner(Protocol):
15
+ async def _stream_accept(self, stream: MuxRpcStream) -> None: ...
16
+
17
+ async def _stream_send_data(self, stream: MuxRpcStream, data: bytes) -> None: ...
18
+
19
+ async def _stream_send_eof(self, stream: MuxRpcStream) -> None: ...
20
+
21
+ async def _stream_release_credit(self, stream: MuxRpcStream, amount: int) -> None: ...
22
+
23
+ async def _stream_reset(self, stream: MuxRpcStream, message: str) -> None: ...
24
+
25
+
26
+ class AsyncByteCreditWindow:
27
+ def __init__(self, credit: int, *, on_stall: Callable[[], None] | None = None) -> None:
28
+ if not isinstance(credit, int) or isinstance(credit, bool) or credit <= 0:
29
+ raise ValueError("credit must be a positive integer")
30
+ self._available = credit
31
+ self._maximum = credit
32
+ self._on_stall = on_stall
33
+ self._stall_count = 0
34
+ self._closed_error: BaseException | None = None
35
+ self._condition = asyncio.Condition()
36
+
37
+ @property
38
+ def available(self) -> int:
39
+ return self._available
40
+
41
+ @property
42
+ def maximum(self) -> int:
43
+ return self._maximum
44
+
45
+ @property
46
+ def stall_count(self) -> int:
47
+ return self._stall_count
48
+
49
+ async def acquire(self, amount: int) -> None:
50
+ _positive(amount, "amount")
51
+ if amount > self._maximum:
52
+ raise ValueError("amount exceeds maximum credit")
53
+ async with self._condition:
54
+ if self._closed_error is None and self._available < amount:
55
+ self._stall_count += 1
56
+ if self._on_stall is not None:
57
+ with suppress(Exception):
58
+ self._on_stall()
59
+ await self._condition.wait_for(lambda: self._closed_error is not None or self._available >= amount)
60
+ if self._closed_error is not None:
61
+ raise self._closed_error
62
+ self._available -= amount
63
+
64
+ async def grant(self, amount: int) -> None:
65
+ _positive(amount, "amount")
66
+ async with self._condition:
67
+ if self._closed_error is not None:
68
+ return
69
+ if self._available + amount > self._maximum:
70
+ raise ValueError("credit grant exceeds negotiated maximum")
71
+ self._available += amount
72
+ self._condition.notify_all()
73
+
74
+ async def close(self, error: BaseException) -> None:
75
+ async with self._condition:
76
+ if self._closed_error is None:
77
+ self._closed_error = error
78
+ self._condition.notify_all()
79
+
80
+
81
+ class _ReceiveBuffer:
82
+ def __init__(self) -> None:
83
+ self._buffer = bytearray()
84
+ self._eof = False
85
+ self._error: BaseException | None = None
86
+ self._condition = asyncio.Condition()
87
+
88
+ @property
89
+ def buffered_bytes(self) -> int:
90
+ return len(self._buffer)
91
+
92
+ async def feed_data(self, data: bytes) -> None:
93
+ async with self._condition:
94
+ if self._eof or self._error is not None:
95
+ return
96
+ self._buffer.extend(data)
97
+ self._condition.notify_all()
98
+
99
+ async def feed_eof(self) -> None:
100
+ async with self._condition:
101
+ self._eof = True
102
+ self._condition.notify_all()
103
+
104
+ async def fail(self, error: BaseException) -> None:
105
+ async with self._condition:
106
+ self._buffer.clear()
107
+ self._error = error
108
+ self._condition.notify_all()
109
+
110
+ async def read(self, max_bytes: int) -> bytes | None:
111
+ _positive(max_bytes, "max_bytes")
112
+ async with self._condition:
113
+ await self._condition.wait_for(lambda: bool(self._buffer) or self._eof or self._error is not None)
114
+ if self._error is not None:
115
+ raise self._error
116
+ if self._buffer:
117
+ size = min(max_bytes, len(self._buffer))
118
+ data = bytes(self._buffer[:size])
119
+ del self._buffer[:size]
120
+ return data
121
+ return None
122
+
123
+
124
+ class MuxRpcStream:
125
+ def __init__(
126
+ self,
127
+ owner: MuxStreamOwner,
128
+ stream_id: int,
129
+ sequences: SequenceTracker,
130
+ *,
131
+ send_credit: int,
132
+ receive_credit: int,
133
+ open_initiator: bool,
134
+ result_expected: bool = True,
135
+ accepted: bool = True,
136
+ on_send_stall: Callable[[], None] | None = None,
137
+ ) -> None:
138
+ self._owner = owner
139
+ self.stream_id = stream_id
140
+ self._sequences = sequences
141
+ self._send_window = AsyncByteCreditWindow(send_credit, on_stall=on_send_stall)
142
+ self._receive_window = ByteCreditWindow(
143
+ initial_credit=receive_credit,
144
+ maximum_credit=receive_credit,
145
+ )
146
+ self._receive_buffer = _ReceiveBuffer()
147
+ self._result: asyncio.Future[dict[str, object]] | None = (
148
+ asyncio.get_running_loop().create_future() if result_expected else None
149
+ )
150
+ self._lifecycle = StreamLifecycle(open_initiator=open_initiator)
151
+ if accepted:
152
+ self._lifecycle.mark_open()
153
+
154
+ @property
155
+ def buffered_bytes(self) -> int:
156
+ return self._receive_buffer.buffered_bytes
157
+
158
+ @property
159
+ def closed(self) -> bool:
160
+ return self._lifecycle.terminal
161
+
162
+ @property
163
+ def state(self) -> StreamState:
164
+ return self._lifecycle.state
165
+
166
+ @property
167
+ def local_eof(self) -> bool:
168
+ return self._lifecycle.local_eof
169
+
170
+ @property
171
+ def accepted(self) -> bool:
172
+ return self._lifecycle.opened
173
+
174
+ @property
175
+ def remote_eof(self) -> bool:
176
+ return self._lifecycle.remote_eof
177
+
178
+ @property
179
+ def close_sent(self) -> bool:
180
+ return self._lifecycle.close_sent
181
+
182
+ @property
183
+ def close_received(self) -> bool:
184
+ return self._lifecycle.close_received
185
+
186
+ @property
187
+ def receive_credit(self) -> int:
188
+ return self._receive_window.available
189
+
190
+ async def accept(self) -> None:
191
+ if self.accepted:
192
+ return
193
+ if self.closed:
194
+ raise RuntimeError("stream is closed")
195
+ self._lifecycle.mark_open()
196
+ await self._owner._stream_accept(self)
197
+
198
+ async def send(self, data: bytes) -> None:
199
+ if not isinstance(data, bytes):
200
+ raise TypeError("stream data must be bytes")
201
+ if not data:
202
+ return
203
+ if self.closed:
204
+ raise RuntimeError("stream is closed")
205
+ if not self.accepted:
206
+ raise RuntimeError("stream is not accepted")
207
+ self._lifecycle.validate_send_data()
208
+ await self._owner._stream_send_data(self, data)
209
+
210
+ async def send_eof(self) -> None:
211
+ if self.closed or self.local_eof:
212
+ return
213
+ if not self.accepted:
214
+ raise RuntimeError("stream is not accepted")
215
+ self._lifecycle.send_eof()
216
+ await self._owner._stream_send_eof(self)
217
+
218
+ async def receive(self, max_bytes: int = 64 * 1024) -> bytes | None:
219
+ if not self.accepted:
220
+ raise RuntimeError("stream is not accepted")
221
+ data = await self._receive_buffer.read(max_bytes)
222
+ if data is not None:
223
+ await self._owner._stream_release_credit(self, len(data))
224
+ return data
225
+
226
+ async def finish(self) -> dict[str, object]:
227
+ if self._result is None:
228
+ raise RuntimeError("server-owned streams do not receive a terminal result")
229
+ return await self._result
230
+
231
+ async def reset(self, message: str = "stream reset") -> None:
232
+ if self.closed:
233
+ return
234
+ await self._owner._stream_reset(self, message)
235
+
236
+ async def _accept_data(self, data: bytes) -> None:
237
+ self._lifecycle.validate_receive_data()
238
+ self._receive_window.consume(len(data))
239
+ await self._receive_buffer.feed_data(data)
240
+
241
+ async def _accept_eof(self) -> None:
242
+ self._lifecycle.receive_eof()
243
+ await self._receive_buffer.feed_eof()
244
+
245
+ def _send_close(self) -> None:
246
+ self._lifecycle.send_close()
247
+
248
+ def _receive_close(self) -> None:
249
+ self._lifecycle.receive_close()
250
+
251
+ async def _release_receive_credit(self, amount: int) -> None:
252
+ self._receive_window.grant(amount)
253
+
254
+ async def _grant_send_credit(self, amount: int) -> None:
255
+ await self._send_window.grant(amount)
256
+
257
+ async def _fail(self, error: BaseException) -> None:
258
+ if self.closed:
259
+ return
260
+ self._lifecycle.send_reset()
261
+ self._receive_window.close()
262
+ await self._send_window.close(error)
263
+ await self._receive_buffer.fail(error)
264
+ if self._result is not None and not self._result.done():
265
+ self._result.set_exception(error)
266
+ self._result.exception()
267
+
268
+ async def _finish(self, result: dict[str, object]) -> None:
269
+ if self.closed:
270
+ return
271
+ if self._lifecycle.open_initiator:
272
+ self._lifecycle.receive_close_ack()
273
+ else:
274
+ self._lifecycle.send_close_ack()
275
+ self._receive_window.close()
276
+ await self._send_window.close(RuntimeError("stream is closed"))
277
+ if self._result is not None and not self._result.done():
278
+ self._result.set_result(result)
279
+
280
+
281
+ def _positive(value: int, name: str) -> None:
282
+ if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
283
+ raise ValueError(f"{name} must be a positive integer")
@@ -0,0 +1,224 @@
1
+ """Thread-safe synchronous facade for the asyncio multiplexed RPC client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import threading
7
+ from collections.abc import Coroutine, Mapping
8
+ from concurrent.futures import TimeoutError as FutureTimeoutError
9
+ from pathlib import Path
10
+ from types import TracebackType
11
+ from typing import Any, TypeVar
12
+
13
+ from .mux_rpc import MuxConnectionClosed, MuxRpcClient
14
+ from .mux_stream import MuxRpcStream
15
+
16
+ T = TypeVar("T")
17
+
18
+
19
+ class MuxSyncStream:
20
+ def __init__(self, owner: MuxSyncClient, stream: MuxRpcStream) -> None:
21
+ self._owner = owner
22
+ self._stream = stream
23
+
24
+ @property
25
+ def stream_id(self) -> int:
26
+ return self._stream.stream_id
27
+
28
+ @property
29
+ def closed(self) -> bool:
30
+ return self._stream.closed
31
+
32
+ def send(self, data: bytes, *, timeout: float | None = None) -> None:
33
+ self._owner._submit(self._stream.send(data), timeout=timeout)
34
+
35
+ def send_eof(self, *, timeout: float | None = None) -> None:
36
+ self._owner._submit(self._stream.send_eof(), timeout=timeout)
37
+
38
+ def receive(self, max_bytes: int = 64 * 1024, *, timeout: float | None = None) -> bytes | None:
39
+ return self._owner._submit(self._stream.receive(max_bytes), timeout=timeout)
40
+
41
+ def receive_unbounded(self, max_bytes: int = 64 * 1024) -> bytes | None:
42
+ return self._owner._submit_unbounded(self._stream.receive(max_bytes))
43
+
44
+ def finish(self, *, timeout: float | None = None) -> dict[str, object]:
45
+ return self._owner._submit(self._stream.finish(), timeout=timeout)
46
+
47
+ def reset(self, message: str = "stream reset", *, timeout: float | None = None) -> None:
48
+ self._owner._submit(self._stream.reset(message), timeout=timeout)
49
+
50
+
51
+ class MuxSyncClient:
52
+ def __init__(
53
+ self,
54
+ socket_path: Path | str,
55
+ *,
56
+ default_timeout: float = 30.0,
57
+ max_pending: int = 256,
58
+ ) -> None:
59
+ if default_timeout <= 0:
60
+ raise ValueError("default_timeout must be positive")
61
+ if not isinstance(max_pending, int) or isinstance(max_pending, bool) or max_pending <= 0:
62
+ raise ValueError("max_pending must be a positive integer")
63
+ self.socket_path = Path(socket_path)
64
+ self.default_timeout = float(default_timeout)
65
+ self._max_pending = max_pending
66
+ self._loop = asyncio.new_event_loop()
67
+ self._client: MuxRpcClient | None = None
68
+ self._connect_lock: asyncio.Lock | None = None
69
+ self._state_lock = threading.Lock()
70
+ self._started = threading.Event()
71
+ self._closed = False
72
+ self._thread = threading.Thread(target=self._run_loop, name="hostbridge-mux-client", daemon=True)
73
+ self._thread.start()
74
+ self._started.wait()
75
+
76
+ @property
77
+ def closed(self) -> bool:
78
+ with self._state_lock:
79
+ return self._closed
80
+
81
+ @property
82
+ def worker_thread_alive(self) -> bool:
83
+ return self._thread.is_alive()
84
+
85
+ def request(
86
+ self,
87
+ method: str,
88
+ params: Mapping[str, object],
89
+ *,
90
+ timeout: float | None = None,
91
+ ) -> dict[str, object]:
92
+ return self._submit(self._request(method, params), timeout=timeout)
93
+
94
+ def open_stream(
95
+ self,
96
+ method: str,
97
+ params: Mapping[str, object],
98
+ *,
99
+ timeout: float | None = None,
100
+ receive_window: int | None = None,
101
+ ) -> MuxSyncStream:
102
+ stream = self._submit(
103
+ self._open_stream(method, params, receive_window=receive_window),
104
+ timeout=timeout,
105
+ )
106
+ return MuxSyncStream(self, stream)
107
+
108
+ def close(self) -> None:
109
+ with self._state_lock:
110
+ if self._closed:
111
+ return
112
+ self._closed = True
113
+ if self._thread.is_alive():
114
+ future = asyncio.run_coroutine_threadsafe(self._close_client(), self._loop)
115
+ try:
116
+ future.result(timeout=self.default_timeout)
117
+ finally:
118
+ self._loop.call_soon_threadsafe(self._loop.stop)
119
+ if threading.get_ident() != self._thread.ident:
120
+ self._thread.join(timeout=self.default_timeout)
121
+
122
+ def __enter__(self) -> MuxSyncClient:
123
+ return self
124
+
125
+ def __exit__(
126
+ self,
127
+ exc_type: type[BaseException] | None,
128
+ exc: BaseException | None,
129
+ traceback: TracebackType | None,
130
+ ) -> None:
131
+ self.close()
132
+
133
+ async def _request(self, method: str, params: Mapping[str, object]) -> dict[str, object]:
134
+ client = await self._ensure_client()
135
+ try:
136
+ return await client.request(method, params)
137
+ except MuxConnectionClosed:
138
+ await self._discard_client(client)
139
+ raise
140
+
141
+ async def _open_stream(
142
+ self,
143
+ method: str,
144
+ params: Mapping[str, object],
145
+ *,
146
+ receive_window: int | None,
147
+ ) -> MuxRpcStream:
148
+ client = await self._ensure_client()
149
+ try:
150
+ return await client.open_stream(method, params, receive_window=receive_window)
151
+ except MuxConnectionClosed:
152
+ await self._discard_client(client)
153
+ raise
154
+
155
+ async def _ensure_client(self) -> MuxRpcClient:
156
+ current = self._client
157
+ if current is not None and not current.closed:
158
+ return current
159
+ if self._connect_lock is None:
160
+ self._connect_lock = asyncio.Lock()
161
+ async with self._connect_lock:
162
+ current = self._client
163
+ if current is not None and not current.closed:
164
+ return current
165
+ replacement = await MuxRpcClient.connect_unix(self.socket_path, max_pending=self._max_pending)
166
+ self._client = replacement
167
+ if current is not None:
168
+ await current.close()
169
+ return replacement
170
+
171
+ async def _discard_client(self, client: MuxRpcClient) -> None:
172
+ if self._connect_lock is None:
173
+ self._connect_lock = asyncio.Lock()
174
+ async with self._connect_lock:
175
+ if self._client is client:
176
+ self._client = None
177
+ await client.close()
178
+
179
+ async def _close_client(self) -> None:
180
+ if self._client is not None:
181
+ await self._client.close()
182
+ self._client = None
183
+
184
+ def _submit(self, coroutine: Coroutine[Any, Any, T], *, timeout: float | None) -> T:
185
+ effective_timeout = self.default_timeout if timeout is None else timeout
186
+ if effective_timeout <= 0:
187
+ coroutine.close()
188
+ raise ValueError("timeout must be positive")
189
+ with self._state_lock:
190
+ if self._closed:
191
+ coroutine.close()
192
+ raise RuntimeError("multiplexed client is closed")
193
+ future = asyncio.run_coroutine_threadsafe(coroutine, self._loop)
194
+ try:
195
+ return future.result(timeout=effective_timeout)
196
+ except FutureTimeoutError as exc:
197
+ future.cancel()
198
+ raise TimeoutError("multiplexed client operation timed out") from exc
199
+
200
+ def _submit_unbounded(self, coroutine: Coroutine[Any, Any, T]) -> T:
201
+ with self._state_lock:
202
+ if self._closed:
203
+ coroutine.close()
204
+ raise RuntimeError("multiplexed client is closed")
205
+ future = asyncio.run_coroutine_threadsafe(coroutine, self._loop)
206
+ try:
207
+ return future.result()
208
+ except BaseException:
209
+ future.cancel()
210
+ raise
211
+
212
+ def _run_loop(self) -> None:
213
+ asyncio.set_event_loop(self._loop)
214
+ self._started.set()
215
+ try:
216
+ self._loop.run_forever()
217
+ finally:
218
+ pending = asyncio.all_tasks(self._loop)
219
+ for task in pending:
220
+ task.cancel()
221
+ if pending:
222
+ self._loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
223
+ self._loop.run_until_complete(self._loop.shutdown_asyncgens())
224
+ self._loop.close()
@@ -0,0 +1,56 @@
1
+ """Build the deterministic, self-contained remote tunnel agent zipapp."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import zipfile
7
+ from pathlib import Path
8
+
9
+ _PACKAGE_MODULES = (
10
+ "async_lifecycle.py",
11
+ "mux_connection.py",
12
+ "mux_protocol.py",
13
+ "mux_records.py",
14
+ "mux_rpc.py",
15
+ "mux_stream.py",
16
+ "remote_mux_agent.py",
17
+ )
18
+ _ZIP_TIMESTAMP = (1980, 1, 1, 0, 0, 0)
19
+ _PACKAGE_INIT = b'"""Self-contained HostBridge remote tunnel agent."""\n'
20
+ _ENTRYPOINT = b"""from __future__ import annotations
21
+
22
+ import asyncio
23
+ import os
24
+ import sys
25
+
26
+ from server_control_mcp.remote_mux_agent import run_stdio_agent
27
+
28
+ try:
29
+ exit_code = asyncio.run(run_stdio_agent())
30
+ except Exception as exc:
31
+ nonce = sys.argv[1].encode("ascii")
32
+ error = type(exc).__name__.encode("ascii", "replace")[:64]
33
+ os.write(1, b"\\0HBFAIL2:" + nonce + b":" + error + b"\\0")
34
+ raise
35
+ raise SystemExit(exit_code)
36
+ """
37
+
38
+
39
+ def build_remote_agent_bundle() -> bytes:
40
+ package_dir = Path(__file__).resolve().parent
41
+ files: list[tuple[str, bytes]] = [
42
+ ("__main__.py", _ENTRYPOINT),
43
+ ("server_control_mcp/__init__.py", _PACKAGE_INIT),
44
+ ]
45
+ for name in _PACKAGE_MODULES:
46
+ files.append((f"server_control_mcp/{name}", (package_dir / name).read_bytes()))
47
+
48
+ output = io.BytesIO()
49
+ with zipfile.ZipFile(output, mode="w", compression=zipfile.ZIP_STORED, strict_timestamps=True) as archive:
50
+ for path, content in files:
51
+ info = zipfile.ZipInfo(path, date_time=_ZIP_TIMESTAMP)
52
+ info.compress_type = zipfile.ZIP_STORED
53
+ info.create_system = 3
54
+ info.external_attr = 0o100644 << 16
55
+ archive.writestr(info, content)
56
+ return output.getvalue()