@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
@@ -1,363 +0,0 @@
1
- """Versioned wire models for the HostBridge daemon protocol."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import struct
7
- import threading
8
- import uuid
9
- from dataclasses import dataclass, field
10
- from enum import IntFlag
11
- from typing import Any, BinaryIO
12
-
13
- PROTOCOL_VERSION = 1
14
- MAX_FRAME_BYTES = 1024 * 1024
15
- MAX_STREAM_ID_BYTES = 4096
16
- _FRAME_MAGIC = b"HBV1"
17
- _FRAME_HEADER = struct.Struct("!4sBBHIII")
18
-
19
-
20
- class ProtocolError(ValueError):
21
- """Raised when a wire message does not conform to protocol v1."""
22
-
23
-
24
- class FrameFlags(IntFlag):
25
- DATA = 1
26
- EOF = 2
27
- CANCEL = 4
28
-
29
-
30
- @dataclass(frozen=True, slots=True)
31
- class BinaryFrame:
32
- stream_id: str
33
- sequence: int
34
- flags: FrameFlags
35
- payload: bytes = b""
36
-
37
-
38
- def encode_frame(frame: BinaryFrame) -> bytes:
39
- stream_id = _required_text(frame.stream_id, "stream_id").encode("utf-8")
40
- if len(stream_id) > MAX_STREAM_ID_BYTES:
41
- raise ProtocolError(f"stream_id exceeds {MAX_STREAM_ID_BYTES} bytes")
42
- if not isinstance(frame.sequence, int) or isinstance(frame.sequence, bool) or frame.sequence < 0:
43
- raise ProtocolError("sequence must be a non-negative integer")
44
- if frame.sequence > 0xFFFFFFFF:
45
- raise ProtocolError("sequence exceeds frame limit")
46
- if not isinstance(frame.flags, FrameFlags) or not frame.flags:
47
- raise ProtocolError("flags must contain a supported frame flag")
48
- if int(frame.flags) & ~int(FrameFlags.DATA | FrameFlags.EOF | FrameFlags.CANCEL):
49
- raise ProtocolError("flags contain an unsupported frame flag")
50
- if not isinstance(frame.payload, bytes):
51
- raise ProtocolError("frame payload must be bytes")
52
- if len(frame.payload) > MAX_FRAME_BYTES:
53
- raise ProtocolError(f"frame payload exceeds {MAX_FRAME_BYTES} bytes")
54
- header = _FRAME_HEADER.pack(
55
- _FRAME_MAGIC,
56
- PROTOCOL_VERSION,
57
- int(frame.flags),
58
- 0,
59
- frame.sequence,
60
- len(stream_id),
61
- len(frame.payload),
62
- )
63
- return header + stream_id + frame.payload
64
-
65
-
66
- def decode_frame(data: bytes) -> tuple[BinaryFrame, bytes]:
67
- if not isinstance(data, bytes):
68
- raise ProtocolError("frame data must be bytes")
69
- sequence, flags, stream_id_length, payload_length = _decode_header(data)
70
- frame_length = _FRAME_HEADER.size + stream_id_length + payload_length
71
- if len(data) < frame_length:
72
- raise ProtocolError("incomplete frame payload")
73
- stream_id_start = _FRAME_HEADER.size
74
- payload_start = stream_id_start + stream_id_length
75
- try:
76
- stream_id = data[stream_id_start:payload_start].decode("utf-8")
77
- except UnicodeDecodeError as exc:
78
- raise ProtocolError("stream_id must be valid UTF-8") from exc
79
- payload = data[payload_start:frame_length]
80
- return BinaryFrame(_required_text(stream_id, "stream_id"), sequence, flags, payload), data[frame_length:]
81
-
82
-
83
- def _decode_header(data: bytes) -> tuple[int, FrameFlags, int, int]:
84
- if len(data) < _FRAME_HEADER.size:
85
- raise ProtocolError("incomplete frame header")
86
- magic, protocol, raw_flags, reserved, sequence, stream_id_length, payload_length = _FRAME_HEADER.unpack_from(data)
87
- if magic != _FRAME_MAGIC:
88
- raise ProtocolError("invalid frame magic")
89
- if protocol != PROTOCOL_VERSION:
90
- raise ProtocolError(f"unsupported protocol: {protocol!r}")
91
- if reserved != 0:
92
- raise ProtocolError("reserved frame bits must be zero")
93
- if stream_id_length > MAX_STREAM_ID_BYTES:
94
- raise ProtocolError(f"stream_id exceeds {MAX_STREAM_ID_BYTES} bytes")
95
- if payload_length > MAX_FRAME_BYTES:
96
- raise ProtocolError(f"frame payload exceeds {MAX_FRAME_BYTES} bytes")
97
- try:
98
- flags = FrameFlags(raw_flags)
99
- except ValueError as exc:
100
- raise ProtocolError("flags contain an unsupported frame flag") from exc
101
- if not flags or int(flags) & ~int(FrameFlags.DATA | FrameFlags.EOF | FrameFlags.CANCEL):
102
- raise ProtocolError("flags contain an unsupported frame flag")
103
- return sequence, flags, stream_id_length, payload_length
104
-
105
-
106
- def _read_exact(stream: BinaryIO, size: int) -> bytes:
107
- chunks = bytearray()
108
- while len(chunks) < size:
109
- chunk = stream.read(size - len(chunks))
110
- if not chunk:
111
- raise ProtocolError("incomplete frame")
112
- chunks.extend(chunk)
113
- return bytes(chunks)
114
-
115
-
116
- def read_frame(stream: BinaryIO) -> BinaryFrame:
117
- header = _read_exact(stream, _FRAME_HEADER.size)
118
- _, _, stream_id_length, payload_length = _decode_header(header)
119
- body = _read_exact(stream, stream_id_length + payload_length)
120
- frame, remainder = decode_frame(header + body)
121
- if remainder:
122
- raise ProtocolError("frame reader consumed trailing bytes")
123
- return frame
124
-
125
-
126
- class StreamWindow:
127
- """Thread-safe credit window used to apply stream backpressure."""
128
-
129
- def __init__(self, initial_credit: int):
130
- if not isinstance(initial_credit, int) or isinstance(initial_credit, bool) or initial_credit < 0:
131
- raise ValueError("initial_credit must be a non-negative integer")
132
- self._credit = initial_credit
133
- self._closed = False
134
- self._condition = threading.Condition()
135
-
136
- def acquire_nowait(self) -> bool:
137
- with self._condition:
138
- if self._closed or self._credit == 0:
139
- return False
140
- self._credit -= 1
141
- return True
142
-
143
- def acquire(self, timeout: float | None = None) -> bool:
144
- with self._condition:
145
- ready = self._condition.wait_for(lambda: self._closed or self._credit > 0, timeout=timeout)
146
- if not ready or self._closed:
147
- return False
148
- self._credit -= 1
149
- return True
150
-
151
- def ack(self, count: int = 1) -> None:
152
- if not isinstance(count, int) or isinstance(count, bool) or count <= 0:
153
- raise ValueError("count must be a positive integer")
154
- with self._condition:
155
- if self._closed:
156
- return
157
- self._credit += count
158
- self._condition.notify_all()
159
-
160
- def close(self) -> None:
161
- with self._condition:
162
- self._closed = True
163
- self._condition.notify_all()
164
-
165
-
166
- class FrameSequenceValidator:
167
- """Reject missing or repeated frames independently for each stream."""
168
-
169
- def __init__(self) -> None:
170
- self._next_by_stream: dict[str, int] = {}
171
-
172
- def accept(self, frame: BinaryFrame) -> None:
173
- expected = self._next_by_stream.get(frame.stream_id, 0)
174
- if frame.sequence != expected:
175
- raise ProtocolError(f"stream {frame.stream_id!r} expected sequence {expected}, received {frame.sequence}")
176
- self._next_by_stream[frame.stream_id] = expected + 1
177
-
178
-
179
- def _parse_object(payload: str) -> dict[str, Any]:
180
- try:
181
- value = json.loads(payload)
182
- except (json.JSONDecodeError, TypeError) as exc:
183
- raise ProtocolError("invalid JSON object") from exc
184
- if not isinstance(value, dict):
185
- raise ProtocolError("message must be a JSON object")
186
- return value
187
-
188
-
189
- def _required_text(value: object, field: str) -> str:
190
- if not isinstance(value, str) or not value.strip():
191
- raise ProtocolError(f"{field} must be a non-empty string")
192
- return value
193
-
194
-
195
- def _protocol(value: dict[str, Any]) -> int:
196
- protocol = value.get("protocol")
197
- if protocol != PROTOCOL_VERSION:
198
- raise ProtocolError(f"unsupported protocol: {protocol!r}")
199
- return PROTOCOL_VERSION
200
-
201
-
202
- def _object(value: object, field_name: str) -> dict[str, object]:
203
- if not isinstance(value, dict):
204
- raise ProtocolError(f"{field_name} must be a JSON object")
205
- return value
206
-
207
-
208
- @dataclass(frozen=True, slots=True)
209
- class ErrorInfo:
210
- code: str
211
- message: str
212
- retryable: bool = False
213
- details: dict[str, object] = field(default_factory=dict)
214
-
215
- @classmethod
216
- def from_dict(cls, value: object) -> ErrorInfo:
217
- data = _object(value, "error")
218
- retryable = data.get("retryable", False)
219
- if not isinstance(retryable, bool):
220
- raise ProtocolError("error.retryable must be a boolean")
221
- return cls(
222
- code=_required_text(data.get("code"), "error.code"),
223
- message=_required_text(data.get("message"), "error.message"),
224
- retryable=retryable,
225
- details=_object(data.get("details", {}), "error.details"),
226
- )
227
-
228
- def to_dict(self) -> dict[str, object]:
229
- return {
230
- "code": self.code,
231
- "message": self.message,
232
- "retryable": self.retryable,
233
- "details": self.details,
234
- }
235
-
236
-
237
- @dataclass(frozen=True, slots=True)
238
- class Request:
239
- protocol: int
240
- request_id: str
241
- method: str
242
- params: dict[str, object]
243
-
244
- @classmethod
245
- def new(
246
- cls,
247
- method: str,
248
- params: dict[str, object],
249
- *,
250
- request_id: str | None = None,
251
- ) -> Request:
252
- return cls(
253
- protocol=PROTOCOL_VERSION,
254
- request_id=_required_text(request_id or uuid.uuid4().hex, "request_id"),
255
- method=_required_text(method, "method"),
256
- params=dict(params),
257
- )
258
-
259
- @classmethod
260
- def from_json(cls, payload: str) -> Request:
261
- value = _parse_object(payload)
262
- return cls(
263
- protocol=_protocol(value),
264
- request_id=_required_text(value.get("request_id"), "request_id"),
265
- method=_required_text(value.get("method"), "method"),
266
- params=_object(value.get("params"), "params"),
267
- )
268
-
269
- def to_json(self) -> str:
270
- return json.dumps(
271
- {
272
- "protocol": self.protocol,
273
- "request_id": self.request_id,
274
- "method": self.method,
275
- "params": self.params,
276
- },
277
- ensure_ascii=False,
278
- separators=(",", ":"),
279
- )
280
-
281
-
282
- @dataclass(frozen=True, slots=True)
283
- class Response:
284
- protocol: int
285
- request_id: str
286
- ok: bool
287
- result: dict[str, object] = field(default_factory=dict)
288
- error: ErrorInfo | None = None
289
-
290
- @classmethod
291
- def success(cls, request_id: str, result: dict[str, object]) -> Response:
292
- return cls(PROTOCOL_VERSION, _required_text(request_id, "request_id"), True, dict(result))
293
-
294
- @classmethod
295
- def failure(cls, request_id: str, error: ErrorInfo) -> Response:
296
- return cls(PROTOCOL_VERSION, _required_text(request_id, "request_id"), False, {}, error)
297
-
298
- @classmethod
299
- def from_json(cls, payload: str) -> Response:
300
- value = _parse_object(payload)
301
- ok = value.get("ok")
302
- if not isinstance(ok, bool):
303
- raise ProtocolError("ok must be a boolean")
304
- request_id = _required_text(value.get("request_id"), "request_id")
305
- protocol = _protocol(value)
306
- if ok:
307
- if value.get("error") is not None:
308
- raise ProtocolError("successful response must not contain error")
309
- return cls(protocol, request_id, True, _object(value.get("result", {}), "result"))
310
- if value.get("result") not in (None, {}):
311
- raise ProtocolError("failed response must not contain result")
312
- return cls(protocol, request_id, False, {}, ErrorInfo.from_dict(value.get("error")))
313
-
314
- def to_json(self) -> str:
315
- payload: dict[str, object] = {
316
- "protocol": self.protocol,
317
- "request_id": self.request_id,
318
- "ok": self.ok,
319
- }
320
- if self.ok:
321
- payload["result"] = self.result
322
- elif self.error is not None:
323
- payload["error"] = self.error.to_dict()
324
- return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
325
-
326
-
327
- @dataclass(frozen=True, slots=True)
328
- class Event:
329
- protocol: int
330
- request_id: str
331
- stream_id: str
332
- sequence: int
333
- event: str
334
- data: dict[str, object] = field(default_factory=dict)
335
-
336
- @classmethod
337
- def from_json(cls, payload: str) -> Event:
338
- value = _parse_object(payload)
339
- sequence = value.get("sequence")
340
- if not isinstance(sequence, int) or isinstance(sequence, bool) or sequence < 0:
341
- raise ProtocolError("sequence must be a non-negative integer")
342
- return cls(
343
- protocol=_protocol(value),
344
- request_id=_required_text(value.get("request_id"), "request_id"),
345
- stream_id=_required_text(value.get("stream_id"), "stream_id"),
346
- sequence=sequence,
347
- event=_required_text(value.get("event"), "event"),
348
- data=_object(value.get("data", {}), "data"),
349
- )
350
-
351
- def to_json(self) -> str:
352
- return json.dumps(
353
- {
354
- "protocol": self.protocol,
355
- "request_id": self.request_id,
356
- "stream_id": self.stream_id,
357
- "sequence": self.sequence,
358
- "event": self.event,
359
- "data": self.data,
360
- },
361
- ensure_ascii=False,
362
- separators=(",", ":"),
363
- )
@@ -1,143 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import shlex
4
-
5
- REMOTE_TUNNEL_AGENT = r"""
6
- import base64
7
- import os
8
- import socket
9
- import sys
10
- import termios
11
- import threading
12
-
13
- host, port, prefix = sys.argv[1], int(sys.argv[2]), sys.argv[3]
14
- prefix_bytes = prefix.encode("ascii")
15
- write_lock = threading.Lock()
16
- downstream_sequence = 0
17
- upstream_sequence = 0
18
- terminal_attributes = None
19
- connection = None
20
- receiver_error = []
21
-
22
-
23
- def emit(kind, payload=b"", status=None):
24
- global downstream_sequence
25
- status_text = "-" if status is None else str(status)
26
- encoded = base64.b64encode(payload).decode("ascii")
27
- with write_lock:
28
- line = "%s:V1:D:%d:%s:%s:%s" % (
29
- prefix,
30
- downstream_sequence,
31
- kind,
32
- status_text,
33
- encoded,
34
- )
35
- downstream_sequence += 1
36
- print(line, flush=True)
37
-
38
-
39
- def disable_echo():
40
- global terminal_attributes
41
- if not os.isatty(sys.stdin.fileno()):
42
- return
43
- terminal_attributes = termios.tcgetattr(sys.stdin.fileno())
44
- updated = list(terminal_attributes)
45
- updated[3] &= ~termios.ECHO
46
- termios.tcsetattr(sys.stdin.fileno(), termios.TCSANOW, updated)
47
-
48
-
49
- def restore_terminal():
50
- if terminal_attributes is not None:
51
- termios.tcsetattr(sys.stdin.fileno(), termios.TCSANOW, terminal_attributes)
52
-
53
-
54
- def decode_upstream(raw_line):
55
- global upstream_sequence
56
- line = raw_line.rstrip(b"\r\n")
57
- marker = prefix_bytes + b":V"
58
- offset = line.find(marker)
59
- if offset < 0:
60
- return None
61
- fields = line[offset:].split(b":", 6)
62
- if len(fields) != 7:
63
- raise ValueError("malformed upstream frame")
64
- frame_prefix, version, direction, sequence, kind, status, payload = fields
65
- if frame_prefix != prefix_bytes or version != b"V1" or direction != b"U":
66
- raise ValueError("invalid upstream frame header")
67
- if int(sequence) != upstream_sequence:
68
- raise ValueError("unexpected upstream frame sequence")
69
- upstream_sequence += 1
70
- if status != b"-":
71
- raise ValueError("upstream frame must not contain status")
72
- kind_text = kind.decode("ascii")
73
- if kind_text not in {"DATA", "EOF"}:
74
- raise ValueError("invalid upstream frame kind")
75
- decoded = base64.b64decode(payload, validate=True)
76
- if kind_text == "EOF" and decoded:
77
- raise ValueError("upstream EOF must not contain payload")
78
- return kind_text, decoded
79
-
80
-
81
- def receive():
82
- try:
83
- while True:
84
- data = connection.recv(16 * 1024)
85
- if not data:
86
- emit("EOF")
87
- return
88
- emit("DATA", data)
89
- except Exception as exc:
90
- receiver_error.append(exc)
91
- emit("ERROR", str(exc).encode("utf-8", errors="replace"))
92
-
93
-
94
- status = 0
95
- try:
96
- disable_echo()
97
- connection = socket.create_connection((host, port), timeout=15)
98
- connection.settimeout(None)
99
- emit("READY")
100
- receiver = threading.Thread(target=receive, daemon=True)
101
- receiver.start()
102
- saw_upstream_eof = False
103
- for raw_line in sys.stdin.buffer:
104
- frame = decode_upstream(raw_line)
105
- if frame is None:
106
- continue
107
- kind, payload = frame
108
- if kind == "DATA":
109
- connection.sendall(payload)
110
- else:
111
- connection.shutdown(socket.SHUT_WR)
112
- saw_upstream_eof = True
113
- break
114
- if not saw_upstream_eof:
115
- raise EOFError("upstream tunnel closed without EOF frame")
116
- receiver.join()
117
- if receiver_error:
118
- status = 1
119
- except Exception as exc:
120
- status = 1
121
- emit("ERROR", str(exc).encode("utf-8", errors="replace"))
122
- finally:
123
- if connection is not None:
124
- try:
125
- connection.close()
126
- except Exception:
127
- status = 1
128
- try:
129
- restore_terminal()
130
- except Exception as exc:
131
- status = 1
132
- emit("ERROR", str(exc).encode("utf-8", errors="replace"))
133
- emit("CLOSED", status=status)
134
-
135
- raise SystemExit(status)
136
- """
137
-
138
-
139
- def build_remote_tunnel_command(dest_host: str, dest_port: int, prefix: str) -> bytes:
140
- command = (
141
- f"python3 -u -c {shlex.quote(REMOTE_TUNNEL_AGENT)} {shlex.quote(dest_host)} {dest_port} {shlex.quote(prefix)}\n"
142
- )
143
- return command.encode("utf-8")