@neoline/hostbridge 2.0.4 → 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 (40) hide show
  1. package/README.md +11 -9
  2. package/docs/ARCHITECTURE.md +62 -0
  3. package/package.json +2 -1
  4. package/pyproject.toml +1 -1
  5. package/src/server_control_mcp/__init__.py +4 -3
  6. package/src/server_control_mcp/async_lifecycle.py +41 -0
  7. package/src/server_control_mcp/cli.py +1 -1
  8. package/src/server_control_mcp/client.py +302 -287
  9. package/src/server_control_mcp/config.py +2 -1
  10. package/src/server_control_mcp/daemon.py +233 -526
  11. package/src/server_control_mcp/doctor.py +34 -2
  12. package/src/server_control_mcp/hosts.py +136 -2
  13. package/src/server_control_mcp/mock_ssh.py +52 -14
  14. package/src/server_control_mcp/mock_ssh_exec.py +147 -58
  15. package/src/server_control_mcp/mock_ssh_session.py +3 -2
  16. package/src/server_control_mcp/mock_ssh_sftp.py +126 -28
  17. package/src/server_control_mcp/mock_ssh_tunnel.py +141 -444
  18. package/src/server_control_mcp/mux_connection.py +326 -0
  19. package/src/server_control_mcp/mux_daemon.py +186 -0
  20. package/src/server_control_mcp/mux_protocol.py +279 -0
  21. package/src/server_control_mcp/mux_records.py +71 -0
  22. package/src/server_control_mcp/mux_rpc.py +1779 -0
  23. package/src/server_control_mcp/mux_service.py +506 -0
  24. package/src/server_control_mcp/mux_stream.py +283 -0
  25. package/src/server_control_mcp/mux_sync_client.py +224 -0
  26. package/src/server_control_mcp/remote_agent_bundle.py +56 -0
  27. package/src/server_control_mcp/remote_mux_agent.py +769 -0
  28. package/src/server_control_mcp/runtime_services.py +862 -0
  29. package/src/server_control_mcp/server.py +33 -18
  30. package/src/server_control_mcp/services.py +2 -666
  31. package/src/server_control_mcp/transports/__init__.py +4 -8
  32. package/src/server_control_mcp/transports/base.py +60 -38
  33. package/src/server_control_mcp/transports/ssh.py +206 -259
  34. package/src/server_control_mcp/tunnel_manager.py +1245 -0
  35. package/src/server_control_mcp/tunnel_native_ssh.py +454 -0
  36. package/src/server_control_mcp/tunnel_providers.py +20 -0
  37. package/src/server_control_mcp/tunnel_pty_agent.py +909 -0
  38. package/src/server_control_mcp/protocol.py +0 -363
  39. package/src/server_control_mcp/remote_tunnel_agent.py +0 -143
  40. package/src/server_control_mcp/transports/pty.py +0 -515
@@ -0,0 +1,279 @@
1
+ """Binary framing primitives for the HostBridge multiplexed protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import struct
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+ from enum import IntEnum
10
+
11
+ MUX_MAGIC = b"HBM2"
12
+ MUX_PROTOCOL_VERSION = 2
13
+ MAX_FRAME_PAYLOAD_BYTES = 256 * 1024
14
+ MAX_CONTROL_PAYLOAD_BYTES = 64 * 1024
15
+ MAX_SEQUENCE = 0xFFFFFFFF
16
+ MAX_STREAM_ID = 0xFFFFFFFFFFFFFFFF
17
+
18
+ _FRAME_HEADER = struct.Struct("!4sBBHQII")
19
+ FRAME_HEADER_SIZE = _FRAME_HEADER.size
20
+ _WINDOW_UPDATE = struct.Struct("!Q")
21
+
22
+
23
+ class MuxProtocolError(ValueError):
24
+ """Raised when a multiplexed frame violates the wire contract."""
25
+
26
+
27
+ class FrameType(IntEnum):
28
+ HELLO = 1
29
+ HELLO_OK = 2
30
+ GOAWAY = 3
31
+ PING = 4
32
+ PONG = 5
33
+ OPEN = 6
34
+ OPEN_OK = 7
35
+ DATA = 8
36
+ WINDOW_UPDATE = 9
37
+ EOF = 10
38
+ CLOSE = 11
39
+ CLOSE_ACK = 12
40
+ RESET = 13
41
+
42
+
43
+ class StreamParity(IntEnum):
44
+ EVEN = 0
45
+ ODD = 1
46
+
47
+
48
+ _CONNECTION_FRAME_TYPES = {
49
+ FrameType.HELLO,
50
+ FrameType.HELLO_OK,
51
+ FrameType.GOAWAY,
52
+ FrameType.PING,
53
+ FrameType.PONG,
54
+ }
55
+ _STREAM_FRAME_TYPES = {
56
+ FrameType.OPEN,
57
+ FrameType.OPEN_OK,
58
+ FrameType.DATA,
59
+ FrameType.EOF,
60
+ FrameType.CLOSE,
61
+ FrameType.CLOSE_ACK,
62
+ FrameType.RESET,
63
+ }
64
+ _JSON_CONTROL_FRAME_TYPES = {
65
+ FrameType.HELLO,
66
+ FrameType.HELLO_OK,
67
+ FrameType.GOAWAY,
68
+ FrameType.OPEN,
69
+ FrameType.OPEN_OK,
70
+ FrameType.CLOSE,
71
+ FrameType.RESET,
72
+ }
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class MuxFrame:
77
+ frame_type: FrameType
78
+ stream_id: int
79
+ sequence: int
80
+ flags: int = 0
81
+ payload: bytes = b""
82
+
83
+
84
+ def encode_frame(frame: MuxFrame) -> bytes:
85
+ _validate_frame(frame)
86
+ return (
87
+ _FRAME_HEADER.pack(
88
+ MUX_MAGIC,
89
+ MUX_PROTOCOL_VERSION,
90
+ int(frame.frame_type),
91
+ frame.flags,
92
+ frame.stream_id,
93
+ frame.sequence,
94
+ len(frame.payload),
95
+ )
96
+ + frame.payload
97
+ )
98
+
99
+
100
+ class MuxCodec:
101
+ """Incrementally decode complete frames from an arbitrary byte stream."""
102
+
103
+ def __init__(self) -> None:
104
+ self._buffer = bytearray()
105
+
106
+ @property
107
+ def buffered_bytes(self) -> int:
108
+ return len(self._buffer)
109
+
110
+ def feed(self, data: bytes | bytearray) -> list[MuxFrame]:
111
+ if not isinstance(data, bytes | bytearray):
112
+ raise TypeError("mux input must be bytes-like")
113
+ self._buffer.extend(data)
114
+ frames: list[MuxFrame] = []
115
+ offset = 0
116
+ while len(self._buffer) - offset >= FRAME_HEADER_SIZE:
117
+ frame_type, flags, stream_id, sequence, payload_length = _decode_header(self._buffer, offset)
118
+ frame_length = FRAME_HEADER_SIZE + payload_length
119
+ if len(self._buffer) - offset < frame_length:
120
+ break
121
+ payload_start = offset + FRAME_HEADER_SIZE
122
+ payload = bytes(self._buffer[payload_start : offset + frame_length])
123
+ frame = MuxFrame(frame_type, stream_id, sequence, flags=flags, payload=payload)
124
+ _validate_frame(frame)
125
+ frames.append(frame)
126
+ offset += frame_length
127
+ if offset:
128
+ del self._buffer[:offset]
129
+ return frames
130
+
131
+
132
+ def encode_control_payload(value: Mapping[str, object]) -> bytes:
133
+ if not isinstance(value, Mapping):
134
+ raise MuxProtocolError("control payload must be a JSON object")
135
+ try:
136
+ payload = json.dumps(dict(value), separators=(",", ":"), sort_keys=True).encode("utf-8")
137
+ except (TypeError, ValueError) as exc:
138
+ raise MuxProtocolError("control payload must be a JSON object") from exc
139
+ if len(payload) > MAX_CONTROL_PAYLOAD_BYTES:
140
+ raise MuxProtocolError(f"control payload exceeds {MAX_CONTROL_PAYLOAD_BYTES} bytes")
141
+ return payload
142
+
143
+
144
+ def decode_control_payload(payload: bytes) -> dict[str, object]:
145
+ if not isinstance(payload, bytes):
146
+ raise TypeError("control payload must be bytes")
147
+ if len(payload) > MAX_CONTROL_PAYLOAD_BYTES:
148
+ raise MuxProtocolError(f"control payload exceeds {MAX_CONTROL_PAYLOAD_BYTES} bytes")
149
+ try:
150
+ value = json.loads(payload.decode("utf-8"))
151
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
152
+ raise MuxProtocolError("control payload must contain valid JSON") from exc
153
+ if not isinstance(value, dict):
154
+ raise MuxProtocolError("control payload must be a JSON object")
155
+ return value
156
+
157
+
158
+ def encode_window_update(credit: int) -> bytes:
159
+ _validate_credit(credit)
160
+ return _WINDOW_UPDATE.pack(credit)
161
+
162
+
163
+ def decode_window_update(payload: bytes) -> int:
164
+ if not isinstance(payload, bytes):
165
+ raise TypeError("window update payload must be bytes")
166
+ if len(payload) != _WINDOW_UPDATE.size:
167
+ raise MuxProtocolError("window update payload must contain an 8-byte credit")
168
+ (credit,) = _WINDOW_UPDATE.unpack(payload)
169
+ _validate_credit(credit)
170
+ return credit
171
+
172
+
173
+ class StreamIdAllocator:
174
+ def __init__(self, parity: StreamParity, *, first: int | None = None) -> None:
175
+ if not isinstance(parity, StreamParity):
176
+ raise TypeError("stream parity must be a StreamParity")
177
+ candidate = (1 if parity is StreamParity.ODD else 2) if first is None else first
178
+ _validate_stream_id(candidate, allow_zero=False)
179
+ if candidate % 2 != int(parity):
180
+ raise ValueError("first stream ID does not match allocator parity")
181
+ self._next = candidate
182
+
183
+ def allocate(self) -> int:
184
+ if self._next > MAX_STREAM_ID:
185
+ raise MuxProtocolError("stream ID space is exhausted")
186
+ stream_id = self._next
187
+ self._next += 2
188
+ return stream_id
189
+
190
+
191
+ class SequenceTracker:
192
+ """Track independent inbound and outbound sequence spaces."""
193
+
194
+ def __init__(self, *, next_inbound: int = 0, next_outbound: int = 0) -> None:
195
+ _validate_sequence(next_inbound)
196
+ _validate_sequence(next_outbound)
197
+ self._next_inbound = next_inbound
198
+ self._next_outbound = next_outbound
199
+
200
+ def accept_inbound(self, sequence: int) -> None:
201
+ if self._next_inbound > MAX_SEQUENCE:
202
+ raise MuxProtocolError("inbound sequence space is exhausted")
203
+ _validate_sequence(sequence)
204
+ if sequence != self._next_inbound:
205
+ raise MuxProtocolError(f"expected sequence {self._next_inbound}, received {sequence}")
206
+ self._next_inbound += 1
207
+
208
+ def next_outbound(self) -> int:
209
+ if self._next_outbound > MAX_SEQUENCE:
210
+ raise MuxProtocolError("outbound sequence space is exhausted")
211
+ sequence = self._next_outbound
212
+ self._next_outbound += 1
213
+ return sequence
214
+
215
+
216
+ def _decode_header(data: bytes | bytearray, offset: int = 0) -> tuple[FrameType, int, int, int, int]:
217
+ magic, version, raw_type, flags, stream_id, sequence, payload_length = _FRAME_HEADER.unpack_from(data, offset)
218
+ if magic != MUX_MAGIC:
219
+ raise MuxProtocolError("invalid mux frame magic")
220
+ if version != MUX_PROTOCOL_VERSION:
221
+ raise MuxProtocolError(f"unsupported mux protocol version: {version}")
222
+ try:
223
+ frame_type = FrameType(raw_type)
224
+ except ValueError as exc:
225
+ raise MuxProtocolError(f"unsupported mux frame type: {raw_type}") from exc
226
+ if flags != 0:
227
+ raise MuxProtocolError("unsupported mux frame flags")
228
+ if payload_length > MAX_FRAME_PAYLOAD_BYTES:
229
+ raise MuxProtocolError(f"frame payload length exceeds {MAX_FRAME_PAYLOAD_BYTES} bytes")
230
+ return frame_type, flags, stream_id, sequence, payload_length
231
+
232
+
233
+ def _validate_frame(frame: MuxFrame) -> None:
234
+ if not isinstance(frame, MuxFrame):
235
+ raise TypeError("frame must be a MuxFrame")
236
+ if not isinstance(frame.frame_type, FrameType):
237
+ raise MuxProtocolError("invalid mux frame type")
238
+ if not isinstance(frame.flags, int) or isinstance(frame.flags, bool) or frame.flags != 0:
239
+ raise MuxProtocolError("unsupported mux frame flags")
240
+ _validate_stream_id(frame.stream_id, allow_zero=True)
241
+ _validate_sequence(frame.sequence)
242
+ if not isinstance(frame.payload, bytes):
243
+ raise MuxProtocolError("frame payload must be bytes")
244
+ if len(frame.payload) > MAX_FRAME_PAYLOAD_BYTES:
245
+ raise MuxProtocolError(f"frame payload exceeds {MAX_FRAME_PAYLOAD_BYTES} bytes")
246
+ if frame.frame_type in _CONNECTION_FRAME_TYPES and frame.stream_id != 0:
247
+ raise MuxProtocolError(f"{frame.frame_type.name} requires connection stream ID 0")
248
+ if frame.frame_type in _STREAM_FRAME_TYPES and frame.stream_id == 0:
249
+ raise MuxProtocolError(f"{frame.frame_type.name} requires a nonzero stream ID")
250
+ if frame.frame_type is FrameType.DATA and not frame.payload:
251
+ raise MuxProtocolError("DATA requires a non-empty payload")
252
+ if frame.frame_type is FrameType.EOF and frame.payload:
253
+ raise MuxProtocolError(f"{frame.frame_type.name} must not contain a payload")
254
+ if frame.frame_type is FrameType.CLOSE_ACK and frame.payload:
255
+ decode_control_payload(frame.payload)
256
+ if frame.frame_type is FrameType.WINDOW_UPDATE:
257
+ decode_window_update(frame.payload)
258
+ if frame.frame_type in _JSON_CONTROL_FRAME_TYPES:
259
+ decode_control_payload(frame.payload)
260
+ if frame.frame_type in {FrameType.PING, FrameType.PONG} and len(frame.payload) > 8:
261
+ raise MuxProtocolError(f"{frame.frame_type.name} payload exceeds 8 bytes")
262
+
263
+
264
+ def _validate_stream_id(stream_id: int, *, allow_zero: bool) -> None:
265
+ if not isinstance(stream_id, int) or isinstance(stream_id, bool):
266
+ raise MuxProtocolError("stream ID must be an integer")
267
+ minimum = 0 if allow_zero else 1
268
+ if not minimum <= stream_id <= MAX_STREAM_ID:
269
+ raise MuxProtocolError("stream ID is outside the unsigned 64-bit range")
270
+
271
+
272
+ def _validate_sequence(sequence: int) -> None:
273
+ if not isinstance(sequence, int) or isinstance(sequence, bool) or not 0 <= sequence <= MAX_SEQUENCE:
274
+ raise MuxProtocolError("sequence is outside the unsigned 32-bit range")
275
+
276
+
277
+ def _validate_credit(credit: int) -> None:
278
+ if not isinstance(credit, int) or isinstance(credit, bool) or not 1 <= credit <= MAX_STREAM_ID:
279
+ raise MuxProtocolError("credit must be an unsigned nonzero 64-bit integer")
@@ -0,0 +1,71 @@
1
+ """Binary application records carried inside multiplexed DATA frames."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import struct
6
+ from dataclasses import dataclass
7
+ from enum import IntEnum
8
+
9
+ MAX_RECORD_PAYLOAD_BYTES = 256 * 1024
10
+ _HEADER = struct.Struct("!BI")
11
+
12
+
13
+ class MuxRecordError(ValueError):
14
+ pass
15
+
16
+
17
+ class MuxRecordChannel(IntEnum):
18
+ STDOUT = 1
19
+ STDERR = 2
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class MuxRecord:
24
+ channel: MuxRecordChannel
25
+ payload: bytes
26
+
27
+ def __post_init__(self) -> None:
28
+ if not isinstance(self.channel, MuxRecordChannel):
29
+ raise TypeError("record channel must be a MuxRecordChannel")
30
+ if not isinstance(self.payload, bytes):
31
+ raise TypeError("record payload must be bytes")
32
+
33
+
34
+ def encode_record(record: MuxRecord) -> bytes:
35
+ if not isinstance(record, MuxRecord):
36
+ raise TypeError("record must be a MuxRecord")
37
+ if len(record.payload) > MAX_RECORD_PAYLOAD_BYTES:
38
+ raise MuxRecordError(f"record payload exceeds maximum of {MAX_RECORD_PAYLOAD_BYTES} bytes")
39
+ return _HEADER.pack(int(record.channel), len(record.payload)) + record.payload
40
+
41
+
42
+ class MuxRecordDecoder:
43
+ def __init__(self) -> None:
44
+ self._buffer = bytearray()
45
+
46
+ @property
47
+ def buffered_bytes(self) -> int:
48
+ return len(self._buffer)
49
+
50
+ def feed(self, data: bytes) -> list[MuxRecord]:
51
+ if not isinstance(data, bytes):
52
+ raise TypeError("record data must be bytes")
53
+ if data:
54
+ self._buffer.extend(data)
55
+
56
+ records: list[MuxRecord] = []
57
+ while len(self._buffer) >= _HEADER.size:
58
+ channel_value, payload_length = _HEADER.unpack_from(self._buffer)
59
+ try:
60
+ channel = MuxRecordChannel(channel_value)
61
+ except ValueError as exc:
62
+ raise MuxRecordError(f"unknown record channel {channel_value}") from exc
63
+ if payload_length > MAX_RECORD_PAYLOAD_BYTES:
64
+ raise MuxRecordError(f"record payload exceeds maximum of {MAX_RECORD_PAYLOAD_BYTES} bytes")
65
+ record_length = _HEADER.size + payload_length
66
+ if len(self._buffer) < record_length:
67
+ break
68
+ payload = bytes(self._buffer[_HEADER.size : record_length])
69
+ del self._buffer[:record_length]
70
+ records.append(MuxRecord(channel, payload))
71
+ return records