@neoline/hostbridge 2.0.3 → 2.0.4
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 +15 -8
- package/bin/hostbridge.js +80 -9
- 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 +1 -1
- package/pyproject.toml +5 -4
- package/src/server_control_mcp/__init__.py +58 -23
- package/src/server_control_mcp/cli.py +1 -1
- package/src/server_control_mcp/client.py +99 -7
- package/src/server_control_mcp/config.py +16 -5
- package/src/server_control_mcp/daemon.py +239 -49
- package/src/server_control_mcp/doctor.py +8 -4
- package/src/server_control_mcp/hosts.py +118 -24
- package/src/server_control_mcp/mock_ssh.py +53 -954
- package/src/server_control_mcp/mock_ssh_exec.py +210 -0
- package/src/server_control_mcp/mock_ssh_session.py +68 -0
- package/src/server_control_mcp/mock_ssh_sftp.py +506 -0
- package/src/server_control_mcp/mock_ssh_tunnel.py +592 -0
- package/src/server_control_mcp/policy.py +86 -14
- package/src/server_control_mcp/protocol.py +18 -17
- package/src/server_control_mcp/remote_task_agent.py +102 -0
- package/src/server_control_mcp/remote_tunnel_agent.py +143 -0
- package/src/server_control_mcp/runtime.py +3 -2
- 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 +30 -12
- package/src/server_control_mcp/services.py +363 -165
- package/src/server_control_mcp/transports/pty.py +114 -33
- package/src/server_control_mcp/transports/ssh.py +308 -24
|
@@ -0,0 +1,210 @@
|
|
|
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 functools import partial
|
|
9
|
+
from typing import cast
|
|
10
|
+
|
|
11
|
+
import asyncssh
|
|
12
|
+
|
|
13
|
+
from .client import 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 open_task
|
|
40
|
+
if session_id is not None:
|
|
41
|
+
await asyncio.to_thread(self._session_factory.close, session_id, reusable=False)
|
|
42
|
+
self._set_exit_status(process, 255)
|
|
43
|
+
return
|
|
44
|
+
assert session_id is not None
|
|
45
|
+
reusable = False
|
|
46
|
+
try:
|
|
47
|
+
reusable = await self._run(process, session_id)
|
|
48
|
+
except asyncio.CancelledError:
|
|
49
|
+
self._set_exit_status(process, 255)
|
|
50
|
+
return
|
|
51
|
+
finally:
|
|
52
|
+
await asyncio.to_thread(self._session_factory.close, session_id, reusable=reusable)
|
|
53
|
+
|
|
54
|
+
async def _run(self, process: asyncssh.SSHServerProcess[bytes], session_id: str) -> bool:
|
|
55
|
+
command = process.command
|
|
56
|
+
if not command:
|
|
57
|
+
await self._run_shell(process, session_id)
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
result = await self._exec_stream(process, session_id, command)
|
|
62
|
+
if result.stdout:
|
|
63
|
+
process.stdout.write(result.stdout)
|
|
64
|
+
if result.stderr:
|
|
65
|
+
process.stderr.write(result.stderr)
|
|
66
|
+
if result.timed_out:
|
|
67
|
+
self._set_exit_status(process, 124)
|
|
68
|
+
return False
|
|
69
|
+
if result.exit_code is None:
|
|
70
|
+
self._set_exit_status(process, 255)
|
|
71
|
+
return False
|
|
72
|
+
self._set_exit_status(process, int(result.exit_code))
|
|
73
|
+
return True
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
print(f"hostbridge mock-ssh exec failed: {exc}", file=sys.stderr, flush=True)
|
|
76
|
+
with suppress(Exception):
|
|
77
|
+
process.stderr.write(f"hostbridge mock-ssh error: {exc}\n".encode("utf-8", errors="replace"))
|
|
78
|
+
status = 124 if isinstance(exc, HostBridgeError) and exc.code == "timeout" else 1
|
|
79
|
+
self._set_exit_status(process, status)
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
async def _exec_stream(
|
|
83
|
+
self,
|
|
84
|
+
process: asyncssh.SSHServerProcess[bytes],
|
|
85
|
+
session_id: str,
|
|
86
|
+
command: str,
|
|
87
|
+
):
|
|
88
|
+
loop = asyncio.get_running_loop()
|
|
89
|
+
items: queue.Queue[bytes | BaseException | object] = queue.Queue(maxsize=8)
|
|
90
|
+
end = object()
|
|
91
|
+
cancelled = threading.Event()
|
|
92
|
+
|
|
93
|
+
def chunks():
|
|
94
|
+
while True:
|
|
95
|
+
item = items.get()
|
|
96
|
+
if item is end:
|
|
97
|
+
return
|
|
98
|
+
if isinstance(item, BaseException):
|
|
99
|
+
raise item
|
|
100
|
+
yield cast(bytes, item)
|
|
101
|
+
|
|
102
|
+
consumer = loop.run_in_executor(
|
|
103
|
+
None,
|
|
104
|
+
lambda: self._client.exec_stream(
|
|
105
|
+
session_id,
|
|
106
|
+
command,
|
|
107
|
+
chunks(),
|
|
108
|
+
timeout=self._timeout,
|
|
109
|
+
output_limit=self._output_limit,
|
|
110
|
+
owner="mock-ssh",
|
|
111
|
+
cancel_event=cancelled,
|
|
112
|
+
),
|
|
113
|
+
)
|
|
114
|
+
staging_deadline = loop.time() + self._timeout
|
|
115
|
+
|
|
116
|
+
async def put(item: bytes | BaseException | object) -> None:
|
|
117
|
+
while True:
|
|
118
|
+
try:
|
|
119
|
+
items.put_nowait(item)
|
|
120
|
+
return
|
|
121
|
+
except queue.Full:
|
|
122
|
+
if consumer.done():
|
|
123
|
+
await consumer
|
|
124
|
+
await asyncio.sleep(0.005)
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
while not process.stdin.at_eof():
|
|
128
|
+
remaining = staging_deadline - loop.time()
|
|
129
|
+
if remaining <= 0:
|
|
130
|
+
await put(HostBridgeError("timeout", "timed out waiting for mock-ssh exec stdin"))
|
|
131
|
+
break
|
|
132
|
+
try:
|
|
133
|
+
data = await asyncio.wait_for(process.stdin.read(64 * 1024), timeout=remaining)
|
|
134
|
+
except TimeoutError:
|
|
135
|
+
await put(HostBridgeError("timeout", "timed out waiting for mock-ssh exec stdin"))
|
|
136
|
+
break
|
|
137
|
+
except BaseException as exc:
|
|
138
|
+
await put(exc)
|
|
139
|
+
break
|
|
140
|
+
if not data:
|
|
141
|
+
break
|
|
142
|
+
await put(_to_bytes(data))
|
|
143
|
+
await put(end)
|
|
144
|
+
return await consumer
|
|
145
|
+
except asyncio.CancelledError:
|
|
146
|
+
cancelled.set()
|
|
147
|
+
await put(end)
|
|
148
|
+
with suppress(BaseException):
|
|
149
|
+
await asyncio.shield(consumer)
|
|
150
|
+
raise
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _set_exit_status(process: asyncssh.SSHServerProcess[bytes], status: int) -> None:
|
|
154
|
+
with suppress(Exception):
|
|
155
|
+
process.exit(status)
|
|
156
|
+
|
|
157
|
+
async def _run_shell(self, process: asyncssh.SSHServerProcess[bytes], session_id: str) -> None:
|
|
158
|
+
loop = asyncio.get_running_loop()
|
|
159
|
+
stop = asyncio.Event()
|
|
160
|
+
input_done = asyncio.Event()
|
|
161
|
+
|
|
162
|
+
async def pump_input() -> None:
|
|
163
|
+
try:
|
|
164
|
+
while not process.stdin.at_eof():
|
|
165
|
+
data = await process.stdin.read(4096)
|
|
166
|
+
if not data:
|
|
167
|
+
break
|
|
168
|
+
await loop.run_in_executor(
|
|
169
|
+
None,
|
|
170
|
+
partial(self._client.shell_write, session_id, _to_bytes(data), owner="mock-ssh"),
|
|
171
|
+
)
|
|
172
|
+
finally:
|
|
173
|
+
input_done.set()
|
|
174
|
+
|
|
175
|
+
async def pump_output() -> None:
|
|
176
|
+
try:
|
|
177
|
+
idle_after_input = 0
|
|
178
|
+
while not stop.is_set():
|
|
179
|
+
result = await loop.run_in_executor(
|
|
180
|
+
None,
|
|
181
|
+
lambda: self._client.shell_read(
|
|
182
|
+
session_id,
|
|
183
|
+
timeout=0.2,
|
|
184
|
+
max_bytes=4096,
|
|
185
|
+
owner="mock-ssh",
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
if result.data:
|
|
189
|
+
idle_after_input = 0
|
|
190
|
+
process.stdout.write(result.data)
|
|
191
|
+
elif input_done.is_set():
|
|
192
|
+
idle_after_input += 1
|
|
193
|
+
if idle_after_input >= 5:
|
|
194
|
+
stop.set()
|
|
195
|
+
break
|
|
196
|
+
if not result.alive:
|
|
197
|
+
stop.set()
|
|
198
|
+
break
|
|
199
|
+
except Exception as exc:
|
|
200
|
+
print(f"hostbridge mock-ssh shell failed: {exc}", file=sys.stderr, flush=True)
|
|
201
|
+
with suppress(Exception):
|
|
202
|
+
process.stderr.write(f"hostbridge mock-ssh shell error: {exc}\n".encode("utf-8", errors="replace"))
|
|
203
|
+
stop.set()
|
|
204
|
+
|
|
205
|
+
tasks = [asyncio.create_task(pump_input()), asyncio.create_task(pump_output())]
|
|
206
|
+
await stop.wait()
|
|
207
|
+
for task in tasks:
|
|
208
|
+
task.cancel()
|
|
209
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
210
|
+
self._set_exit_status(process, 0)
|
|
@@ -0,0 +1,68 @@
|
|
|
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.close_session(session_id, owner="mock-ssh")
|
|
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
|
+
assert last_error is not None
|
|
68
|
+
raise last_error
|