@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.
- package/README.md +24 -15
- package/bin/hostbridge.js +80 -9
- package/docs/ARCHITECTURE.md +62 -0
- package/examples/claude_desktop_config.json +1 -1
- package/examples/codex.config.toml +1 -1
- package/examples/hosts.example.json +1 -0
- package/package.json +2 -1
- package/pyproject.toml +5 -4
- package/src/server_control_mcp/__init__.py +60 -24
- package/src/server_control_mcp/async_lifecycle.py +41 -0
- package/src/server_control_mcp/cli.py +2 -2
- package/src/server_control_mcp/client.py +345 -238
- package/src/server_control_mcp/config.py +18 -6
- package/src/server_control_mcp/daemon.py +309 -412
- package/src/server_control_mcp/doctor.py +41 -5
- package/src/server_control_mcp/hosts.py +252 -24
- package/src/server_control_mcp/mock_ssh.py +97 -960
- package/src/server_control_mcp/mock_ssh_exec.py +299 -0
- package/src/server_control_mcp/mock_ssh_session.py +69 -0
- package/src/server_control_mcp/mock_ssh_sftp.py +604 -0
- package/src/server_control_mcp/mock_ssh_tunnel.py +289 -0
- package/src/server_control_mcp/mux_connection.py +326 -0
- package/src/server_control_mcp/mux_daemon.py +186 -0
- package/src/server_control_mcp/mux_protocol.py +279 -0
- package/src/server_control_mcp/mux_records.py +71 -0
- package/src/server_control_mcp/mux_rpc.py +1779 -0
- package/src/server_control_mcp/mux_service.py +506 -0
- package/src/server_control_mcp/mux_stream.py +283 -0
- package/src/server_control_mcp/mux_sync_client.py +224 -0
- package/src/server_control_mcp/policy.py +86 -14
- package/src/server_control_mcp/remote_agent_bundle.py +56 -0
- package/src/server_control_mcp/remote_mux_agent.py +769 -0
- package/src/server_control_mcp/remote_task_agent.py +102 -0
- package/src/server_control_mcp/runtime.py +3 -2
- package/src/server_control_mcp/runtime_services.py +862 -0
- package/src/server_control_mcp/secrets.py +6 -6
- package/src/server_control_mcp/secure_files.py +121 -0
- package/src/server_control_mcp/server.py +53 -20
- package/src/server_control_mcp/services.py +3 -469
- package/src/server_control_mcp/transports/__init__.py +4 -8
- package/src/server_control_mcp/transports/base.py +60 -38
- package/src/server_control_mcp/transports/ssh.py +315 -84
- package/src/server_control_mcp/tunnel_manager.py +1245 -0
- package/src/server_control_mcp/tunnel_native_ssh.py +454 -0
- package/src/server_control_mcp/tunnel_providers.py +20 -0
- package/src/server_control_mcp/tunnel_pty_agent.py +909 -0
- package/src/server_control_mcp/protocol.py +0 -362
- 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
|