@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,454 @@
1
+ """Daemon-loop-owned native AsyncSSH TCP forwarding provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import AsyncGenerator, Callable
7
+ from contextlib import suppress
8
+
9
+ import asyncssh
10
+
11
+ from .async_lifecycle import finish_task_before_cancellation
12
+ from .hosts import ForwardingPolicy, HostConfig
13
+ from .transports.base import ByteStream, ExecResult, ShellAttachment, ShellReadResult, TransferResult, TransportError
14
+ from .transports.ssh import AsyncSshConnection
15
+ from .tunnel_manager import TunnelBackendError
16
+
17
+ DEFAULT_RECEIVE_WINDOW = 2 * 1024 * 1024
18
+
19
+
20
+ class _ConnectionObserver(asyncssh.SSHClient):
21
+ def __init__(self, lost: Callable[[BaseException | None], None]) -> None:
22
+ self._lost = lost
23
+
24
+ def connection_lost(self, exc: Exception | None) -> None:
25
+ self._lost(exc)
26
+
27
+
28
+ class NativeSshTunnelEndpoint(asyncssh.SSHTCPSession[bytes]):
29
+ def __init__(
30
+ self,
31
+ provider_stream_id: int,
32
+ *,
33
+ receive_window: int,
34
+ closed_handler: Callable[[int], None],
35
+ write_stall_handler: Callable[[], None],
36
+ ) -> None:
37
+ if receive_window <= 0:
38
+ raise ValueError("receive_window must be positive")
39
+ self.provider_stream_id = provider_stream_id
40
+ self._receive_window = receive_window
41
+ self._high_water = max(1, receive_window * 3 // 4)
42
+ self._low_water = receive_window // 2
43
+ self._closed_handler = closed_handler
44
+ self._write_stall_handler = write_stall_handler
45
+ self._channel: asyncssh.SSHTCPChannel[bytes] | None = None
46
+ self._buffer = bytearray()
47
+ self._read_ready = asyncio.Event()
48
+ self._write_ready = asyncio.Event()
49
+ self._write_ready.set()
50
+ self._remote_eof = False
51
+ self._local_eof = False
52
+ self._reading_paused = False
53
+ self._error: TunnelBackendError | None = None
54
+ self._closed = False
55
+
56
+ @property
57
+ def closed(self) -> bool:
58
+ return self._closed
59
+
60
+ @property
61
+ def queued_bytes(self) -> int:
62
+ return len(self._buffer)
63
+
64
+ def connection_made(self, chan: asyncssh.SSHTCPChannel[bytes]) -> None:
65
+ self._channel = chan
66
+
67
+ def session_started(self) -> None:
68
+ return None
69
+
70
+ def data_received(self, data: bytes, datatype: asyncssh.DataType) -> None: # noqa: ARG002
71
+ if self._closed or not data:
72
+ return
73
+ payload = bytes(data)
74
+ if len(self._buffer) + len(payload) > self._receive_window:
75
+ self._buffer.clear()
76
+ error = TunnelBackendError("native SSH stream exceeded its receive window")
77
+ self._error = error
78
+ if self._channel is not None:
79
+ self._channel.abort()
80
+ self._finish()
81
+ return
82
+ self._buffer.extend(payload)
83
+ if len(self._buffer) >= self._high_water and not self._reading_paused and self._channel is not None:
84
+ self._channel.pause_reading()
85
+ self._reading_paused = True
86
+ self._read_ready.set()
87
+
88
+ def eof_received(self) -> bool:
89
+ self._remote_eof = True
90
+ self._read_ready.set()
91
+ return True
92
+
93
+ def connection_lost(self, exc: Exception | None) -> None:
94
+ if exc is not None and self._error is None:
95
+ self._error = TunnelBackendError(str(exc) or "native SSH stream connection lost")
96
+ self._remote_eof = True
97
+ self._finish()
98
+
99
+ def pause_writing(self) -> None:
100
+ if self._write_ready.is_set():
101
+ self._write_stall_handler()
102
+ self._write_ready.clear()
103
+
104
+ def resume_writing(self) -> None:
105
+ self._write_ready.set()
106
+
107
+ async def send(self, data: bytes) -> None:
108
+ if not isinstance(data, bytes):
109
+ raise TypeError("tunnel stream data must be bytes")
110
+ if not data:
111
+ return
112
+ if self._local_eof:
113
+ raise TunnelBackendError("native SSH stream input is closed")
114
+ self._raise_if_failed()
115
+ await self._write_ready.wait()
116
+ self._raise_if_failed()
117
+ channel = self._require_channel()
118
+ channel.write(data)
119
+ await asyncio.sleep(0)
120
+
121
+ async def send_eof(self) -> None:
122
+ if self._local_eof or self._closed:
123
+ return
124
+ self._raise_if_failed()
125
+ self._local_eof = True
126
+ self._require_channel().write_eof()
127
+
128
+ async def receive(self, max_bytes: int = 64 * 1024) -> bytes | None:
129
+ if not isinstance(max_bytes, int) or isinstance(max_bytes, bool) or max_bytes <= 0:
130
+ raise ValueError("max_bytes must be a positive integer")
131
+ while not (self._buffer or self._remote_eof or self._error is not None or self._closed):
132
+ self._read_ready.clear()
133
+ await self._read_ready.wait()
134
+ if self._error is not None:
135
+ raise self._error
136
+ if self._buffer:
137
+ size = min(max_bytes, len(self._buffer))
138
+ data = bytes(self._buffer[:size])
139
+ del self._buffer[:size]
140
+ self._resume_reading_if_ready()
141
+ return data
142
+ return None
143
+
144
+ async def close(self) -> None:
145
+ if self._closed:
146
+ return
147
+ if self._channel is not None:
148
+ self._channel.close()
149
+ self._remote_eof = True
150
+ self._finish()
151
+
152
+ async def reset(self, reason: str) -> None:
153
+ if self._closed:
154
+ return
155
+ self._error = TunnelBackendError(reason or "native SSH stream reset")
156
+ if self._channel is not None:
157
+ self._channel.abort()
158
+ self._remote_eof = True
159
+ self._finish()
160
+
161
+ def backend_lost(self, error: BaseException | None) -> None:
162
+ if self._closed:
163
+ return
164
+ if error is not None:
165
+ self._error = TunnelBackendError(str(error) or "native SSH connection lost")
166
+ if self._channel is not None:
167
+ self._channel.abort()
168
+ self._remote_eof = True
169
+ self._finish()
170
+
171
+ def _require_channel(self) -> asyncssh.SSHTCPChannel[bytes]:
172
+ if self._channel is None:
173
+ raise TunnelBackendError("native SSH stream is not open")
174
+ return self._channel
175
+
176
+ def _raise_if_failed(self) -> None:
177
+ if self._error is not None:
178
+ raise self._error
179
+ if self._closed:
180
+ raise TunnelBackendError("native SSH stream is closed")
181
+
182
+ def _resume_reading_if_ready(self) -> None:
183
+ if self._reading_paused and len(self._buffer) <= self._low_water and self._channel is not None:
184
+ self._channel.resume_reading()
185
+ self._reading_paused = False
186
+
187
+ def _finish(self) -> None:
188
+ if self._closed:
189
+ return
190
+ self._closed = True
191
+ self._write_ready.set()
192
+ self._read_ready.set()
193
+ self._closed_handler(self.provider_stream_id)
194
+
195
+
196
+ class NativeSshHostRuntime:
197
+ provider_name = "native_ssh"
198
+ capabilities = AsyncSshConnection.capabilities
199
+
200
+ @property
201
+ def agent_pid(self) -> int | None:
202
+ return None
203
+
204
+ def __init__(
205
+ self,
206
+ host: HostConfig,
207
+ *,
208
+ generation: int,
209
+ connect_timeout: float = 30,
210
+ receive_window: int = DEFAULT_RECEIVE_WINDOW,
211
+ ) -> None:
212
+ if generation <= 0:
213
+ raise ValueError("generation must be positive")
214
+ if connect_timeout <= 0:
215
+ raise ValueError("connect_timeout must be positive")
216
+ if receive_window <= 0:
217
+ raise ValueError("receive_window must be positive")
218
+ self.host = host
219
+ self.generation = generation
220
+ self.connect_timeout = float(connect_timeout)
221
+ self.receive_window = receive_window
222
+ self._connection: asyncssh.SSHClientConnection | None = None
223
+ self._operations: AsyncSshConnection | None = None
224
+ self._closed_future: asyncio.Future[BaseException | None] | None = None
225
+ self._endpoints: dict[int, NativeSshTunnelEndpoint] = {}
226
+ self._next_stream_id = 1
227
+ self._closing = False
228
+ self._close_lock = asyncio.Lock()
229
+ self._window_stalls = 0
230
+
231
+ @property
232
+ def queued_bytes(self) -> int:
233
+ return sum(endpoint.queued_bytes for endpoint in self._endpoints.values())
234
+
235
+ @property
236
+ def window_stalls(self) -> int:
237
+ return self._window_stalls
238
+
239
+ @property
240
+ def protocol_failures(self) -> int:
241
+ return 0
242
+
243
+ def _record_window_stall(self) -> None:
244
+ self._window_stalls += 1
245
+
246
+ async def start(self) -> None:
247
+ if self._connection is not None:
248
+ return
249
+ ssh = self.host.ssh
250
+ if ssh is None:
251
+ raise TunnelBackendError("native SSH tunnel requires an SSH host configuration")
252
+ self._closed_future = asyncio.get_running_loop().create_future()
253
+ observer = _ConnectionObserver(self._connection_lost)
254
+ options: dict[str, object] = {
255
+ "port": ssh.port,
256
+ "username": ssh.username,
257
+ "tunnel": ssh.proxy_jump,
258
+ "password": self.host.secrets.get("password"),
259
+ "connect_timeout": self.connect_timeout,
260
+ "encoding": None,
261
+ "client_factory": lambda: observer,
262
+ }
263
+ if ssh.identity_file is not None:
264
+ options["client_keys"] = [str(ssh.identity_file)]
265
+ try:
266
+ self._connection = await asyncssh.connect(ssh.host, **options)
267
+ self._operations = AsyncSshConnection(self._connection)
268
+ except asyncio.CancelledError:
269
+ raise
270
+ except Exception as exc:
271
+ raise TunnelBackendError(f"native SSH connection failed: {exc}") from exc
272
+
273
+ async def exec(
274
+ self,
275
+ command: str,
276
+ *,
277
+ stdin: ByteStream | None,
278
+ timeout: float,
279
+ output_limit: int,
280
+ ) -> ExecResult:
281
+ return await self._require_operations().exec(
282
+ command,
283
+ stdin=stdin,
284
+ timeout=timeout,
285
+ output_limit=output_limit,
286
+ )
287
+
288
+ async def upload(
289
+ self,
290
+ chunks: ByteStream,
291
+ remote_path: str,
292
+ *,
293
+ mode: int,
294
+ expected_size: int | None,
295
+ expected_sha256: str | None,
296
+ ) -> TransferResult:
297
+ try:
298
+ return await self._require_operations().upload(
299
+ chunks,
300
+ remote_path,
301
+ mode=mode,
302
+ expected_size=expected_size,
303
+ expected_sha256=expected_sha256,
304
+ )
305
+ except TransportError as exc:
306
+ raise TunnelBackendError(str(exc)) from exc
307
+
308
+ async def download(self, remote_path: str, *, chunk_size: int) -> AsyncGenerator[bytes, None]:
309
+ async for chunk in self._require_operations().download(remote_path, chunk_size=chunk_size):
310
+ yield chunk
311
+
312
+ async def shell_write(self, shell_id: str, data: bytes) -> None:
313
+ await self._require_operations().shell_write(shell_id, data)
314
+
315
+ async def shell_read(self, shell_id: str, *, timeout: float | None, max_bytes: int) -> ShellReadResult:
316
+ return await self._require_operations().shell_read(shell_id, timeout=timeout, max_bytes=max_bytes)
317
+
318
+ async def attach_shell(self, shell_id: str, stdin: ByteStream, *, max_bytes: int) -> ShellAttachment:
319
+ return await self._require_operations().attach_shell(shell_id, stdin, max_bytes=max_bytes)
320
+
321
+ async def shell_close(self, shell_id: str) -> None:
322
+ await self._require_operations().shell_close(shell_id)
323
+
324
+ async def open_stream(
325
+ self,
326
+ host: str,
327
+ port: int,
328
+ policy: ForwardingPolicy,
329
+ ) -> NativeSshTunnelEndpoint:
330
+ if self._closing or self._connection is None or self._backend_closed():
331
+ raise TunnelBackendError("native SSH runtime is not connected")
332
+ if policy != self.host.forwarding:
333
+ raise TunnelBackendError("native SSH stream policy does not match the host runtime")
334
+ stream_id = self._next_stream_id
335
+ self._next_stream_id += 1
336
+ endpoint = NativeSshTunnelEndpoint(
337
+ stream_id,
338
+ receive_window=self.receive_window,
339
+ closed_handler=self._endpoint_closed,
340
+ write_stall_handler=self._record_window_stall,
341
+ )
342
+ try:
343
+ _, session = await self._connection.create_connection(
344
+ lambda: endpoint,
345
+ host,
346
+ port,
347
+ orig_host="",
348
+ orig_port=0,
349
+ encoding=None,
350
+ )
351
+ except asyncio.CancelledError:
352
+ await endpoint.reset("native SSH stream open cancelled")
353
+ raise
354
+ except Exception as exc:
355
+ await endpoint.reset(str(exc) or "native SSH stream open failed")
356
+ raise TunnelBackendError(f"native SSH destination open failed: {exc}") from exc
357
+ if session is not endpoint:
358
+ await endpoint.reset("native SSH returned an unexpected stream session")
359
+ raise TunnelBackendError("native SSH returned an unexpected stream session")
360
+ if self._closing or self._backend_closed():
361
+ message = self._backend_failure_message()
362
+ await endpoint.reset(message)
363
+ raise TunnelBackendError(message)
364
+ self._endpoints[stream_id] = endpoint
365
+ return endpoint
366
+
367
+ async def wait_closed(self) -> BaseException | None:
368
+ if self._closed_future is None:
369
+ raise TunnelBackendError("native SSH runtime has not started")
370
+ return await asyncio.shield(self._closed_future)
371
+
372
+ async def close(self) -> None:
373
+ async with self._close_lock:
374
+ self._closing = True
375
+ cleanup = asyncio.create_task(self._finish_close())
376
+ await finish_task_before_cancellation(cleanup)
377
+
378
+ def abort(self) -> None:
379
+ self._closing = True
380
+ error = TunnelBackendError("native SSH runtime cleanup timed out")
381
+ for endpoint in list(self._endpoints.values()):
382
+ with suppress(Exception):
383
+ endpoint.backend_lost(error)
384
+ self._endpoints.clear()
385
+ connection = self._connection
386
+ self._connection = None
387
+ self._operations = None
388
+ abort_error: Exception | None = None
389
+ if connection is not None:
390
+ try:
391
+ connection.abort()
392
+ except Exception as exc:
393
+ try:
394
+ connection.close()
395
+ except Exception as fallback_error:
396
+ fallback_error.add_note(f"connection abort failed: {exc}")
397
+ abort_error = fallback_error
398
+ if self._closed_future is not None and not self._closed_future.done():
399
+ with suppress(Exception):
400
+ self._closed_future.set_result(error)
401
+ if abort_error is not None:
402
+ raise abort_error
403
+
404
+ async def _finish_close(self) -> None:
405
+ endpoints = list(self._endpoints.values())
406
+ await asyncio.gather(*(endpoint.close() for endpoint in endpoints), return_exceptions=True)
407
+ for endpoint in endpoints:
408
+ self._endpoints.pop(endpoint.provider_stream_id, None)
409
+ connection = self._connection
410
+ self._operations = None
411
+ if connection is not None:
412
+ connection.close()
413
+ with suppress(Exception):
414
+ await connection.wait_closed()
415
+ if self._connection is connection:
416
+ self._connection = None
417
+ if self._closed_future is not None and not self._closed_future.done():
418
+ self._closed_future.set_result(None)
419
+
420
+ def _connection_lost(self, error: BaseException | None) -> None:
421
+ if error is None and not self._closing:
422
+ error = TunnelBackendError("native SSH connection closed")
423
+ for endpoint in list(self._endpoints.values()):
424
+ endpoint.backend_lost(error)
425
+ self._endpoints.clear()
426
+ if self._closed_future is not None and not self._closed_future.done():
427
+ self._closed_future.set_result(error)
428
+
429
+ def _endpoint_closed(self, stream_id: int) -> None:
430
+ self._endpoints.pop(stream_id, None)
431
+
432
+ def _require_operations(self) -> AsyncSshConnection:
433
+ if self._closing or self._backend_closed() or self._operations is None:
434
+ raise TunnelBackendError("native SSH runtime is not connected")
435
+ return self._operations
436
+
437
+ def _backend_closed(self) -> bool:
438
+ return self._closed_future is not None and self._closed_future.done()
439
+
440
+ def _backend_failure_message(self) -> str:
441
+ if self._closed_future is None or not self._closed_future.done():
442
+ return "native SSH connection closed"
443
+ error = self._closed_future.result()
444
+ return str(error) if error is not None else "native SSH connection closed"
445
+
446
+
447
+ def native_ssh_provider_factory(
448
+ host: HostConfig,
449
+ provider_name: str,
450
+ generation: int,
451
+ ) -> NativeSshHostRuntime:
452
+ if provider_name != "native_ssh":
453
+ raise TunnelBackendError(f"native SSH factory cannot create provider {provider_name!r}")
454
+ return NativeSshHostRuntime(host, generation=generation)
@@ -0,0 +1,20 @@
1
+ """Strict tunnel provider registry owned by the HostBridge daemon."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .hosts import HostConfig
6
+ from .tunnel_manager import TunnelBackendError, TunnelProviderRuntime
7
+ from .tunnel_native_ssh import native_ssh_provider_factory
8
+ from .tunnel_pty_agent import pty_agent_provider_factory
9
+
10
+
11
+ def tunnel_provider_factory(
12
+ host: HostConfig,
13
+ provider_name: str,
14
+ generation: int,
15
+ ) -> TunnelProviderRuntime:
16
+ if provider_name == "native_ssh":
17
+ return native_ssh_provider_factory(host, provider_name, generation)
18
+ if provider_name == "pty_agent":
19
+ return pty_agent_provider_factory(host, provider_name, generation)
20
+ raise TunnelBackendError(f"unsupported tunnel provider {provider_name!r}")