@neoline/hostbridge 2.0.3 → 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 (48) hide show
  1. package/README.md +24 -15
  2. package/bin/hostbridge.js +80 -9
  3. package/docs/ARCHITECTURE.md +62 -0
  4. package/examples/claude_desktop_config.json +1 -1
  5. package/examples/codex.config.toml +1 -1
  6. package/examples/hosts.example.json +1 -0
  7. package/package.json +2 -1
  8. package/pyproject.toml +5 -4
  9. package/src/server_control_mcp/__init__.py +60 -24
  10. package/src/server_control_mcp/async_lifecycle.py +41 -0
  11. package/src/server_control_mcp/cli.py +2 -2
  12. package/src/server_control_mcp/client.py +345 -238
  13. package/src/server_control_mcp/config.py +18 -6
  14. package/src/server_control_mcp/daemon.py +309 -412
  15. package/src/server_control_mcp/doctor.py +41 -5
  16. package/src/server_control_mcp/hosts.py +252 -24
  17. package/src/server_control_mcp/mock_ssh.py +97 -960
  18. package/src/server_control_mcp/mock_ssh_exec.py +299 -0
  19. package/src/server_control_mcp/mock_ssh_session.py +69 -0
  20. package/src/server_control_mcp/mock_ssh_sftp.py +604 -0
  21. package/src/server_control_mcp/mock_ssh_tunnel.py +289 -0
  22. package/src/server_control_mcp/mux_connection.py +326 -0
  23. package/src/server_control_mcp/mux_daemon.py +186 -0
  24. package/src/server_control_mcp/mux_protocol.py +279 -0
  25. package/src/server_control_mcp/mux_records.py +71 -0
  26. package/src/server_control_mcp/mux_rpc.py +1779 -0
  27. package/src/server_control_mcp/mux_service.py +506 -0
  28. package/src/server_control_mcp/mux_stream.py +283 -0
  29. package/src/server_control_mcp/mux_sync_client.py +224 -0
  30. package/src/server_control_mcp/policy.py +86 -14
  31. package/src/server_control_mcp/remote_agent_bundle.py +56 -0
  32. package/src/server_control_mcp/remote_mux_agent.py +769 -0
  33. package/src/server_control_mcp/remote_task_agent.py +102 -0
  34. package/src/server_control_mcp/runtime.py +3 -2
  35. package/src/server_control_mcp/runtime_services.py +862 -0
  36. package/src/server_control_mcp/secrets.py +6 -6
  37. package/src/server_control_mcp/secure_files.py +121 -0
  38. package/src/server_control_mcp/server.py +53 -20
  39. package/src/server_control_mcp/services.py +3 -469
  40. package/src/server_control_mcp/transports/__init__.py +4 -8
  41. package/src/server_control_mcp/transports/base.py +60 -38
  42. package/src/server_control_mcp/transports/ssh.py +315 -84
  43. package/src/server_control_mcp/tunnel_manager.py +1245 -0
  44. package/src/server_control_mcp/tunnel_native_ssh.py +454 -0
  45. package/src/server_control_mcp/tunnel_providers.py +20 -0
  46. package/src/server_control_mcp/tunnel_pty_agent.py +909 -0
  47. package/src/server_control_mcp/protocol.py +0 -362
  48. package/src/server_control_mcp/transports/pty.py +0 -434
@@ -0,0 +1,299 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import queue
5
+ import sys
6
+ import threading
7
+ from contextlib import suppress
8
+ from typing import cast
9
+
10
+ import asyncssh
11
+
12
+ from .async_lifecycle import finish_task_despite_cancellation
13
+ from .client import ExecResult, HostBridgeClient, HostBridgeError
14
+ from .mock_ssh_session import HostBridgeSessionFactory
15
+
16
+
17
+ def _to_bytes(chunk: str | bytes) -> bytes:
18
+ if isinstance(chunk, bytes):
19
+ return chunk
20
+ return chunk.encode("utf-8")
21
+
22
+
23
+ class HostBridgeSSHProcess:
24
+ def __init__(
25
+ self, client: HostBridgeClient, session_factory: HostBridgeSessionFactory, timeout: int, output_limit: int
26
+ ) -> None:
27
+ self._client = client
28
+ self._session_factory = session_factory
29
+ self._timeout = timeout
30
+ self._output_limit = output_limit
31
+
32
+ async def __call__(self, process: asyncssh.SSHServerProcess[bytes]) -> None:
33
+ open_task = asyncio.create_task(asyncio.to_thread(self._session_factory.open))
34
+ try:
35
+ session_id = await asyncio.shield(open_task)
36
+ except asyncio.CancelledError:
37
+ session_id = None
38
+ with suppress(BaseException):
39
+ session_id = await finish_task_despite_cancellation(open_task)
40
+ if session_id is not None:
41
+ try:
42
+ await self._close_session(session_id, reusable=False)
43
+ except Exception as exc:
44
+ self._report_shell_error(process, exc)
45
+ self._set_exit_status(process, 255)
46
+ return
47
+ if session_id is None:
48
+ self._set_exit_status(process, 255)
49
+ return
50
+ reusable = False
51
+ try:
52
+ reusable = await self._run(process, session_id)
53
+ except asyncio.CancelledError:
54
+ self._set_exit_status(process, 255)
55
+ return
56
+ finally:
57
+ try:
58
+ await self._close_session(session_id, reusable=reusable)
59
+ except Exception as exc:
60
+ self._report_shell_error(process, exc)
61
+ self._set_exit_status(process, 255)
62
+
63
+ async def _close_session(self, session_id: str, *, reusable: bool) -> None:
64
+ close_task = asyncio.create_task(asyncio.to_thread(self._session_factory.close, session_id, reusable=reusable))
65
+ await finish_task_despite_cancellation(close_task)
66
+
67
+ async def _run(self, process: asyncssh.SSHServerProcess[bytes], session_id: str) -> bool:
68
+ command = process.command
69
+ if not command:
70
+ await self._run_shell(process, session_id)
71
+ return False
72
+
73
+ try:
74
+ result = await self._exec_stream(process, session_id, command)
75
+ if result.stdout:
76
+ process.stdout.write(result.stdout)
77
+ if result.stderr:
78
+ process.stderr.write(result.stderr)
79
+ if result.timed_out:
80
+ self._set_exit_status(process, 124)
81
+ return False
82
+ if result.exit_code is None:
83
+ self._set_exit_status(process, 255)
84
+ return False
85
+ self._set_exit_status(process, int(result.exit_code))
86
+ return True
87
+ except Exception as exc:
88
+ print(f"hostbridge mock-ssh exec failed: {exc}", file=sys.stderr, flush=True)
89
+ with suppress(Exception):
90
+ process.stderr.write(f"hostbridge mock-ssh error: {exc}\n".encode("utf-8", errors="replace"))
91
+ status = 124 if isinstance(exc, HostBridgeError) and exc.code == "timeout" else 1
92
+ self._set_exit_status(process, status)
93
+ return False
94
+
95
+ async def _exec_stream(
96
+ self,
97
+ process: asyncssh.SSHServerProcess[bytes],
98
+ session_id: str,
99
+ command: str,
100
+ ):
101
+ loop = asyncio.get_running_loop()
102
+ items: queue.Queue[bytes | BaseException | object] = queue.Queue(maxsize=8)
103
+ end = object()
104
+ cancelled = threading.Event()
105
+
106
+ def chunks():
107
+ while True:
108
+ item = items.get()
109
+ if item is end:
110
+ return
111
+ if isinstance(item, BaseException):
112
+ raise item
113
+ yield cast(bytes, item)
114
+
115
+ consumer = loop.run_in_executor(
116
+ None,
117
+ lambda: self._client.exec_stream(
118
+ session_id,
119
+ command,
120
+ chunks(),
121
+ timeout=self._timeout,
122
+ output_limit=self._output_limit,
123
+ owner="mock-ssh",
124
+ cancel_event=cancelled,
125
+ ),
126
+ )
127
+ staging_deadline = loop.time() + self._timeout
128
+
129
+ async def put(item: bytes | BaseException | object) -> bool:
130
+ while True:
131
+ if consumer.done():
132
+ await consumer
133
+ return False
134
+ try:
135
+ items.put_nowait(item)
136
+ return True
137
+ except queue.Full:
138
+ await asyncio.sleep(0.005)
139
+
140
+ try:
141
+ while not process.stdin.at_eof():
142
+ remaining = staging_deadline - loop.time()
143
+ if remaining <= 0:
144
+ if not await put(HostBridgeError("timeout", "timed out waiting for mock-ssh exec stdin")):
145
+ return await consumer
146
+ break
147
+ read_task = asyncio.create_task(process.stdin.read(64 * 1024))
148
+ try:
149
+ completed, _ = await asyncio.wait(
150
+ (read_task, consumer),
151
+ timeout=remaining,
152
+ return_when=asyncio.FIRST_COMPLETED,
153
+ )
154
+ except BaseException:
155
+ read_task.cancel()
156
+ await asyncio.gather(read_task, return_exceptions=True)
157
+ raise
158
+ if consumer in completed:
159
+ read_task.cancel()
160
+ await asyncio.gather(read_task, return_exceptions=True)
161
+ return await consumer
162
+ if read_task not in completed:
163
+ read_task.cancel()
164
+ await asyncio.gather(read_task, return_exceptions=True)
165
+ if not await put(HostBridgeError("timeout", "timed out waiting for mock-ssh exec stdin")):
166
+ return await consumer
167
+ break
168
+ try:
169
+ data = read_task.result()
170
+ except asyncio.CancelledError:
171
+ raise
172
+ except BaseException as exc:
173
+ if not await put(exc):
174
+ return await consumer
175
+ break
176
+ if not data:
177
+ break
178
+ if not await put(_to_bytes(data)):
179
+ return await consumer
180
+ if not await put(end):
181
+ return await consumer
182
+ return await asyncio.shield(consumer)
183
+ except asyncio.CancelledError:
184
+ cancelled.set()
185
+ close_succeeded = False
186
+
187
+ def observe_consumer(future: asyncio.Future[ExecResult]) -> None:
188
+ try:
189
+ future.result()
190
+ except asyncio.CancelledError:
191
+ return
192
+ except Exception as exc:
193
+ self._report_shell_error(process, exc)
194
+
195
+ try:
196
+ await self._close_session(session_id, reusable=False)
197
+ close_succeeded = True
198
+ except Exception as exc:
199
+ self._report_shell_error(process, exc)
200
+ with suppress(queue.Full):
201
+ items.put_nowait(end)
202
+ if close_succeeded:
203
+ with suppress(BaseException):
204
+ await finish_task_despite_cancellation(consumer)
205
+ else:
206
+ consumer.add_done_callback(observe_consumer)
207
+ raise
208
+
209
+ @staticmethod
210
+ def _set_exit_status(process: asyncssh.SSHServerProcess[bytes], status: int) -> None:
211
+ with suppress(Exception):
212
+ process.exit(status)
213
+
214
+ async def _run_shell(self, process: asyncssh.SSHServerProcess[bytes], session_id: str) -> None:
215
+ stop = asyncio.Event()
216
+ failed = False
217
+ terminal_failed = False
218
+ exit_code: int | None = None
219
+ exit_signal: str | None = None
220
+ open_task = asyncio.create_task(asyncio.to_thread(self._client.open_shell, session_id, owner="mock-ssh"))
221
+ try:
222
+ shell = await asyncio.shield(open_task)
223
+ except asyncio.CancelledError:
224
+ with suppress(BaseException):
225
+ shell = await finish_task_despite_cancellation(open_task)
226
+ close_task = asyncio.create_task(asyncio.to_thread(shell.close))
227
+ await finish_task_despite_cancellation(close_task)
228
+ raise
229
+
230
+ async def pump_input() -> None:
231
+ nonlocal failed
232
+ try:
233
+ while not process.stdin.at_eof():
234
+ data = await process.stdin.read(4096)
235
+ if not data:
236
+ break
237
+ await asyncio.to_thread(shell.send, _to_bytes(data))
238
+ except Exception as exc:
239
+ failed = True
240
+ self._report_shell_error(process, exc)
241
+ finally:
242
+ try:
243
+ await asyncio.to_thread(shell.send_eof)
244
+ except Exception as exc:
245
+ failed = True
246
+ self._report_shell_error(process, exc)
247
+ stop.set()
248
+
249
+ async def pump_output() -> None:
250
+ nonlocal exit_code, exit_signal, failed, terminal_failed
251
+ try:
252
+ while data := await asyncio.to_thread(shell.receive):
253
+ process.stdout.write(data)
254
+ exit_code = shell.exit_code
255
+ exit_signal = shell.exit_signal
256
+ except Exception as exc:
257
+ failed = True
258
+ terminal_failed = True
259
+ self._report_shell_error(process, exc)
260
+ finally:
261
+ stop.set()
262
+
263
+ tasks = [asyncio.create_task(pump_input()), asyncio.create_task(pump_output())]
264
+ try:
265
+ await stop.wait()
266
+ finally:
267
+ try:
268
+ close_task = asyncio.create_task(asyncio.to_thread(shell.close))
269
+ await finish_task_despite_cancellation(close_task)
270
+ except Exception as exc:
271
+ failed = True
272
+ self._report_shell_error(process, exc)
273
+ finally:
274
+ for task in tasks:
275
+ task.cancel()
276
+ await asyncio.gather(*tasks, return_exceptions=True)
277
+ if terminal_failed:
278
+ self._set_exit_status(process, 255)
279
+ elif failed:
280
+ self._set_exit_status(process, 1)
281
+ elif isinstance(exit_code, int) and not isinstance(exit_code, bool) and 0 <= exit_code <= 255:
282
+ self._set_exit_status(process, exit_code)
283
+ elif isinstance(exit_signal, str) and exit_signal.strip():
284
+ self._set_exit_signal(process, exit_signal)
285
+ else:
286
+ self._set_exit_status(process, 255)
287
+
288
+ @staticmethod
289
+ def _report_shell_error(process: asyncssh.SSHServerProcess[bytes], exc: Exception) -> None:
290
+ print(f"hostbridge mock-ssh shell failed: {exc}", file=sys.stderr, flush=True)
291
+ with suppress(Exception):
292
+ process.stderr.write(f"hostbridge mock-ssh shell error: {exc}\n".encode("utf-8", errors="replace"))
293
+
294
+ @classmethod
295
+ def _set_exit_signal(cls, process: asyncssh.SSHServerProcess[bytes], signal_name: str) -> None:
296
+ try:
297
+ process.exit_with_signal(signal_name)
298
+ except Exception:
299
+ cls._set_exit_status(process, 255)
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+
5
+ from .client import HostBridgeClient, HostBridgeError
6
+
7
+
8
+ class HostBridgeSessionFactory:
9
+ def __init__(self, client: HostBridgeClient, host_id: str, connect_timeout: int = 60) -> None:
10
+ self._client = client
11
+ self._host_id = host_id
12
+ self._connect_timeout = connect_timeout
13
+ self._lock = threading.Lock()
14
+ self._leased: set[str] = set()
15
+ self._shutdown = False
16
+
17
+ def open(self) -> str:
18
+ with self._lock:
19
+ if self._shutdown:
20
+ raise RuntimeError("mock-ssh session factory is shut down")
21
+ session = self._client.open_session(
22
+ self._host_id,
23
+ owner="mock-ssh",
24
+ connect_timeout=self._connect_timeout,
25
+ )
26
+ session_id = session.session_id
27
+ with self._lock:
28
+ if not self._shutdown:
29
+ self._leased.add(session_id)
30
+ return session_id
31
+ self._close_backend(session_id)
32
+ raise RuntimeError("mock-ssh session factory is shut down")
33
+
34
+ def close(self, session_id: str, *, reusable: bool = True) -> None:
35
+ with self._lock:
36
+ if session_id not in self._leased:
37
+ return
38
+ self._close_backend(session_id)
39
+ with self._lock:
40
+ self._leased.discard(session_id)
41
+
42
+ def shutdown(self) -> None:
43
+ with self._lock:
44
+ self._shutdown = True
45
+ session_ids = list(self._leased)
46
+ last_error: Exception | None = None
47
+ for session_id in session_ids:
48
+ try:
49
+ self.close(session_id, reusable=False)
50
+ except Exception as exc:
51
+ last_error = exc
52
+ if last_error is not None:
53
+ raise last_error
54
+
55
+ def _close_backend(self, session_id: str) -> None:
56
+ last_error: Exception | None = None
57
+ for _ in range(2):
58
+ try:
59
+ self._client.release_mock_ssh_session(session_id)
60
+ return
61
+ except HostBridgeError as exc:
62
+ if exc.code == "not_found":
63
+ return
64
+ last_error = exc
65
+ except Exception as exc:
66
+ last_error = exc
67
+ if last_error is None:
68
+ raise RuntimeError("backend close failed without an error")
69
+ raise last_error