@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,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()
|
|
@@ -18,12 +18,16 @@ from dataclasses import dataclass, field
|
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
from typing import Any, Protocol
|
|
20
20
|
|
|
21
|
+
from .secure_files import open_private_append, read_private_text
|
|
22
|
+
|
|
21
23
|
DEFAULT_POLICY_FILE = Path("~/.hostbridge/policy.json").expanduser()
|
|
22
24
|
LEGACY_POLICY_FILE = Path("~/.server_control_mcp/policy.json").expanduser()
|
|
23
25
|
DEFAULT_AUDIT_LOG = Path("~/.hostbridge/audit.jsonl").expanduser()
|
|
24
26
|
LEGACY_AUDIT_LOG = Path("~/.server_control_mcp/audit.jsonl").expanduser()
|
|
25
27
|
DEFAULT_IDLE_TIMEOUT_SECONDS = 1800
|
|
26
28
|
DEFAULT_REAPER_INTERVAL_SECONDS = 60
|
|
29
|
+
DEFAULT_AUDIT_MAX_BYTES = 10 * 1024 * 1024
|
|
30
|
+
DEFAULT_AUDIT_BACKUP_COUNT = 3
|
|
27
31
|
|
|
28
32
|
DEFAULT_DENYLIST_PATTERNS = (
|
|
29
33
|
r"\brm\s+-rf\s+/(?:\s|$)",
|
|
@@ -46,16 +50,37 @@ class _NullAudit:
|
|
|
46
50
|
|
|
47
51
|
|
|
48
52
|
class _JsonlAudit:
|
|
49
|
-
def __init__(self, path: Path) -> None:
|
|
53
|
+
def __init__(self, path: Path, *, max_bytes: int, backup_count: int) -> None:
|
|
50
54
|
self._path = path
|
|
55
|
+
self._max_bytes = max_bytes
|
|
56
|
+
self._backup_count = backup_count
|
|
51
57
|
self._lock = threading.Lock()
|
|
52
58
|
|
|
53
59
|
def write(self, event: dict[str, Any]) -> None:
|
|
54
60
|
line = json.dumps(event, ensure_ascii=False, sort_keys=True) + "\n"
|
|
61
|
+
encoded_size = len(line.encode("utf-8"))
|
|
55
62
|
with self._lock:
|
|
56
|
-
self._path.
|
|
57
|
-
|
|
63
|
+
current_size = self._path.stat().st_size if self._path.exists() else 0
|
|
64
|
+
if current_size and current_size + encoded_size > self._max_bytes:
|
|
65
|
+
self._rotate()
|
|
66
|
+
with open_private_append(self._path) as handle:
|
|
58
67
|
handle.write(line)
|
|
68
|
+
handle.flush()
|
|
69
|
+
|
|
70
|
+
def _rotate(self) -> None:
|
|
71
|
+
if self._backup_count == 0:
|
|
72
|
+
self._path.unlink(missing_ok=True)
|
|
73
|
+
return
|
|
74
|
+
oldest = self._backup_path(self._backup_count)
|
|
75
|
+
oldest.unlink(missing_ok=True)
|
|
76
|
+
for index in range(self._backup_count - 1, 0, -1):
|
|
77
|
+
source = self._backup_path(index)
|
|
78
|
+
if source.exists():
|
|
79
|
+
source.replace(self._backup_path(index + 1))
|
|
80
|
+
self._path.replace(self._backup_path(1))
|
|
81
|
+
|
|
82
|
+
def _backup_path(self, index: int) -> Path:
|
|
83
|
+
return self._path.with_name(f"{self._path.name}.{index}")
|
|
59
84
|
|
|
60
85
|
|
|
61
86
|
@dataclass(frozen=True, slots=True)
|
|
@@ -70,6 +95,8 @@ class PolicyConfig:
|
|
|
70
95
|
idle_timeout_seconds: int = DEFAULT_IDLE_TIMEOUT_SECONDS
|
|
71
96
|
command_denylist: tuple[re.Pattern[str], ...] = field(default_factory=tuple)
|
|
72
97
|
audit_log_path: Path | None = None
|
|
98
|
+
audit_max_bytes: int = DEFAULT_AUDIT_MAX_BYTES
|
|
99
|
+
audit_backup_count: int = DEFAULT_AUDIT_BACKUP_COUNT
|
|
73
100
|
enabled: bool = True
|
|
74
101
|
|
|
75
102
|
@classmethod
|
|
@@ -90,7 +117,9 @@ class PolicyConfig:
|
|
|
90
117
|
idle_timeout = int(
|
|
91
118
|
env.get(
|
|
92
119
|
"HOSTBRIDGE_IDLE_TIMEOUT",
|
|
93
|
-
env.get(
|
|
120
|
+
env.get(
|
|
121
|
+
"SERVER_CONTROL_MCP_IDLE_TIMEOUT", raw.get("idle_timeout_seconds", DEFAULT_IDLE_TIMEOUT_SECONDS)
|
|
122
|
+
),
|
|
94
123
|
)
|
|
95
124
|
)
|
|
96
125
|
idle_timeout = max(0, idle_timeout)
|
|
@@ -104,17 +133,43 @@ class PolicyConfig:
|
|
|
104
133
|
if env_extra:
|
|
105
134
|
patterns.extend(p for p in env_extra.split(os.pathsep) if p)
|
|
106
135
|
|
|
107
|
-
default_audit =
|
|
136
|
+
default_audit = (
|
|
137
|
+
DEFAULT_AUDIT_LOG if DEFAULT_AUDIT_LOG.exists() or not LEGACY_AUDIT_LOG.exists() else LEGACY_AUDIT_LOG
|
|
138
|
+
)
|
|
108
139
|
audit_path_str = env.get(
|
|
109
140
|
"HOSTBRIDGE_AUDIT_LOG",
|
|
110
141
|
env.get("SERVER_CONTROL_MCP_AUDIT_LOG", str(raw.get("audit_log_path") or default_audit)),
|
|
111
142
|
)
|
|
112
143
|
audit_path = Path(audit_path_str).expanduser() if audit_path_str else None
|
|
144
|
+
audit_max_bytes = int(
|
|
145
|
+
env.get(
|
|
146
|
+
"HOSTBRIDGE_AUDIT_MAX_BYTES",
|
|
147
|
+
env.get(
|
|
148
|
+
"SERVER_CONTROL_MCP_AUDIT_MAX_BYTES",
|
|
149
|
+
raw.get("audit_max_bytes", DEFAULT_AUDIT_MAX_BYTES),
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
audit_backup_count = int(
|
|
154
|
+
env.get(
|
|
155
|
+
"HOSTBRIDGE_AUDIT_BACKUP_COUNT",
|
|
156
|
+
env.get(
|
|
157
|
+
"SERVER_CONTROL_MCP_AUDIT_BACKUP_COUNT",
|
|
158
|
+
raw.get("audit_backup_count", DEFAULT_AUDIT_BACKUP_COUNT),
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
if audit_max_bytes <= 0:
|
|
163
|
+
raise ValueError("audit_max_bytes must be positive")
|
|
164
|
+
if audit_backup_count < 0:
|
|
165
|
+
raise ValueError("audit_backup_count must be non-negative")
|
|
113
166
|
|
|
114
167
|
return cls(
|
|
115
168
|
idle_timeout_seconds=idle_timeout,
|
|
116
169
|
command_denylist=tuple(re.compile(pattern) for pattern in patterns),
|
|
117
170
|
audit_log_path=audit_path,
|
|
171
|
+
audit_max_bytes=audit_max_bytes,
|
|
172
|
+
audit_backup_count=audit_backup_count,
|
|
118
173
|
enabled=True,
|
|
119
174
|
)
|
|
120
175
|
|
|
@@ -130,7 +185,9 @@ class PolicyConfig:
|
|
|
130
185
|
|
|
131
186
|
|
|
132
187
|
def load_policy(config_path: Path | str | None = None) -> PolicyConfig:
|
|
133
|
-
if (
|
|
188
|
+
if (
|
|
189
|
+
os.environ.get("HOSTBRIDGE_DISABLE_POLICY", "") or os.environ.get("SERVER_CONTROL_MCP_DISABLE_POLICY", "")
|
|
190
|
+
).lower() in (
|
|
134
191
|
"1",
|
|
135
192
|
"true",
|
|
136
193
|
"yes",
|
|
@@ -141,24 +198,39 @@ def load_policy(config_path: Path | str | None = None) -> PolicyConfig:
|
|
|
141
198
|
config_path
|
|
142
199
|
or os.environ.get("HOSTBRIDGE_POLICY_FILE")
|
|
143
200
|
or os.environ.get("SERVER_CONTROL_MCP_POLICY_FILE")
|
|
144
|
-
or (
|
|
201
|
+
or (
|
|
202
|
+
DEFAULT_POLICY_FILE
|
|
203
|
+
if DEFAULT_POLICY_FILE.exists() or not LEGACY_POLICY_FILE.exists()
|
|
204
|
+
else LEGACY_POLICY_FILE
|
|
205
|
+
)
|
|
145
206
|
).expanduser()
|
|
146
207
|
|
|
147
208
|
raw: dict[str, Any] = {}
|
|
148
|
-
|
|
209
|
+
try:
|
|
210
|
+
policy_text = read_private_text(path)
|
|
211
|
+
except FileNotFoundError:
|
|
212
|
+
policy_text = None
|
|
213
|
+
except OSError as exc:
|
|
214
|
+
raise ValueError(f"failed to read HostBridge policy file {path}") from exc
|
|
215
|
+
if policy_text is not None:
|
|
149
216
|
try:
|
|
150
|
-
data = json.loads(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
217
|
+
data = json.loads(policy_text)
|
|
218
|
+
except json.JSONDecodeError as exc:
|
|
219
|
+
raise ValueError(f"HostBridge policy file {path} is not valid JSON") from exc
|
|
220
|
+
if not isinstance(data, dict):
|
|
221
|
+
raise ValueError(f"HostBridge policy file {path} must contain a JSON object")
|
|
222
|
+
raw = data
|
|
155
223
|
return PolicyConfig.from_dict(raw)
|
|
156
224
|
|
|
157
225
|
|
|
158
226
|
def build_audit_sink(policy: PolicyConfig) -> AuditSink:
|
|
159
227
|
if not policy.enabled or policy.audit_log_path is None:
|
|
160
228
|
return _NullAudit()
|
|
161
|
-
return _JsonlAudit(
|
|
229
|
+
return _JsonlAudit(
|
|
230
|
+
policy.audit_log_path,
|
|
231
|
+
max_bytes=policy.audit_max_bytes,
|
|
232
|
+
backup_count=policy.audit_backup_count,
|
|
233
|
+
)
|
|
162
234
|
|
|
163
235
|
|
|
164
236
|
def audit_event(
|