@neoline/hostbridge 1.4.3 → 2.0.0
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 -137
- package/package.json +1 -1
- package/pyproject.toml +2 -1
- package/src/server_control_mcp/__init__.py +4 -5
- package/src/server_control_mcp/cli.py +229 -1231
- package/src/server_control_mcp/client.py +468 -0
- package/src/server_control_mcp/config.py +139 -0
- package/src/server_control_mcp/daemon.py +387 -306
- package/src/server_control_mcp/doctor.py +108 -0
- package/src/server_control_mcp/hosts.py +52 -7
- package/src/server_control_mcp/mock_ssh.py +177 -87
- package/src/server_control_mcp/policy.py +2 -0
- package/src/server_control_mcp/protocol.py +362 -0
- package/src/server_control_mcp/runtime.py +40 -0
- package/src/server_control_mcp/server.py +132 -188
- package/src/server_control_mcp/services.py +508 -0
- package/src/server_control_mcp/transports/__init__.py +17 -0
- package/src/server_control_mcp/transports/base.py +78 -0
- package/src/server_control_mcp/transports/pty.py +405 -0
- package/src/server_control_mcp/transports/ssh.py +195 -0
- package/src/server_control_mcp/manager.py +0 -922
|
@@ -167,6 +167,7 @@ def audit_event(
|
|
|
167
167
|
event: str,
|
|
168
168
|
session_id: str | None = None,
|
|
169
169
|
host_id: str | None = None,
|
|
170
|
+
owner_agent_id: str | None = None,
|
|
170
171
|
command: str | None = None,
|
|
171
172
|
task_id: str | None = None,
|
|
172
173
|
outcome: str | None = None,
|
|
@@ -177,6 +178,7 @@ def audit_event(
|
|
|
177
178
|
for key, value in {
|
|
178
179
|
"session_id": session_id,
|
|
179
180
|
"host_id": host_id,
|
|
181
|
+
"owner_agent_id": owner_agent_id,
|
|
180
182
|
"command": command,
|
|
181
183
|
"task_id": task_id,
|
|
182
184
|
"outcome": outcome,
|
|
@@ -0,0 +1,362 @@
|
|
|
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
|
+
if len(data) < _FRAME_HEADER.size:
|
|
70
|
+
raise ProtocolError("incomplete frame header")
|
|
71
|
+
magic, protocol, raw_flags, reserved, sequence, stream_id_length, payload_length = _FRAME_HEADER.unpack_from(data)
|
|
72
|
+
if magic != _FRAME_MAGIC:
|
|
73
|
+
raise ProtocolError("invalid frame magic")
|
|
74
|
+
if protocol != PROTOCOL_VERSION:
|
|
75
|
+
raise ProtocolError(f"unsupported protocol: {protocol!r}")
|
|
76
|
+
if reserved != 0:
|
|
77
|
+
raise ProtocolError("reserved frame bits must be zero")
|
|
78
|
+
if stream_id_length > MAX_STREAM_ID_BYTES:
|
|
79
|
+
raise ProtocolError(f"stream_id exceeds {MAX_STREAM_ID_BYTES} bytes")
|
|
80
|
+
if payload_length > MAX_FRAME_BYTES:
|
|
81
|
+
raise ProtocolError(f"frame payload exceeds {MAX_FRAME_BYTES} bytes")
|
|
82
|
+
frame_length = _FRAME_HEADER.size + stream_id_length + payload_length
|
|
83
|
+
if len(data) < frame_length:
|
|
84
|
+
raise ProtocolError("incomplete frame payload")
|
|
85
|
+
stream_id_start = _FRAME_HEADER.size
|
|
86
|
+
payload_start = stream_id_start + stream_id_length
|
|
87
|
+
try:
|
|
88
|
+
stream_id = data[stream_id_start:payload_start].decode("utf-8")
|
|
89
|
+
except UnicodeDecodeError as exc:
|
|
90
|
+
raise ProtocolError("stream_id must be valid UTF-8") from exc
|
|
91
|
+
try:
|
|
92
|
+
flags = FrameFlags(raw_flags)
|
|
93
|
+
except ValueError as exc:
|
|
94
|
+
raise ProtocolError("flags contain an unsupported frame flag") from exc
|
|
95
|
+
if not flags or int(flags) & ~int(FrameFlags.DATA | FrameFlags.EOF | FrameFlags.CANCEL):
|
|
96
|
+
raise ProtocolError("flags contain an unsupported frame flag")
|
|
97
|
+
payload = data[payload_start:frame_length]
|
|
98
|
+
return BinaryFrame(_required_text(stream_id, "stream_id"), sequence, flags, payload), data[frame_length:]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _read_exact(stream: BinaryIO, size: int) -> bytes:
|
|
102
|
+
chunks = bytearray()
|
|
103
|
+
while len(chunks) < size:
|
|
104
|
+
chunk = stream.read(size - len(chunks))
|
|
105
|
+
if not chunk:
|
|
106
|
+
raise ProtocolError("incomplete frame")
|
|
107
|
+
chunks.extend(chunk)
|
|
108
|
+
return bytes(chunks)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def read_frame(stream: BinaryIO) -> BinaryFrame:
|
|
112
|
+
header = _read_exact(stream, _FRAME_HEADER.size)
|
|
113
|
+
_, _, _, _, _, stream_id_length, payload_length = _FRAME_HEADER.unpack(header)
|
|
114
|
+
if stream_id_length > MAX_STREAM_ID_BYTES or payload_length > MAX_FRAME_BYTES:
|
|
115
|
+
decode_frame(header)
|
|
116
|
+
body = _read_exact(stream, stream_id_length + payload_length)
|
|
117
|
+
frame, remainder = decode_frame(header + body)
|
|
118
|
+
if remainder:
|
|
119
|
+
raise ProtocolError("frame reader consumed trailing bytes")
|
|
120
|
+
return frame
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class StreamWindow:
|
|
124
|
+
"""Thread-safe credit window used to apply stream backpressure."""
|
|
125
|
+
|
|
126
|
+
def __init__(self, initial_credit: int):
|
|
127
|
+
if not isinstance(initial_credit, int) or isinstance(initial_credit, bool) or initial_credit < 0:
|
|
128
|
+
raise ValueError("initial_credit must be a non-negative integer")
|
|
129
|
+
self._credit = initial_credit
|
|
130
|
+
self._closed = False
|
|
131
|
+
self._condition = threading.Condition()
|
|
132
|
+
|
|
133
|
+
def acquire_nowait(self) -> bool:
|
|
134
|
+
with self._condition:
|
|
135
|
+
if self._closed or self._credit == 0:
|
|
136
|
+
return False
|
|
137
|
+
self._credit -= 1
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
def acquire(self, timeout: float | None = None) -> bool:
|
|
141
|
+
with self._condition:
|
|
142
|
+
ready = self._condition.wait_for(lambda: self._closed or self._credit > 0, timeout=timeout)
|
|
143
|
+
if not ready or self._closed:
|
|
144
|
+
return False
|
|
145
|
+
self._credit -= 1
|
|
146
|
+
return True
|
|
147
|
+
|
|
148
|
+
def ack(self, count: int = 1) -> None:
|
|
149
|
+
if not isinstance(count, int) or isinstance(count, bool) or count <= 0:
|
|
150
|
+
raise ValueError("count must be a positive integer")
|
|
151
|
+
with self._condition:
|
|
152
|
+
if self._closed:
|
|
153
|
+
return
|
|
154
|
+
self._credit += count
|
|
155
|
+
self._condition.notify_all()
|
|
156
|
+
|
|
157
|
+
def close(self) -> None:
|
|
158
|
+
with self._condition:
|
|
159
|
+
self._closed = True
|
|
160
|
+
self._condition.notify_all()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class FrameSequenceValidator:
|
|
164
|
+
"""Reject missing or repeated frames independently for each stream."""
|
|
165
|
+
|
|
166
|
+
def __init__(self) -> None:
|
|
167
|
+
self._next_by_stream: dict[str, int] = {}
|
|
168
|
+
|
|
169
|
+
def accept(self, frame: BinaryFrame) -> None:
|
|
170
|
+
expected = self._next_by_stream.get(frame.stream_id, 0)
|
|
171
|
+
if frame.sequence != expected:
|
|
172
|
+
raise ProtocolError(
|
|
173
|
+
f"stream {frame.stream_id!r} expected sequence {expected}, received {frame.sequence}"
|
|
174
|
+
)
|
|
175
|
+
self._next_by_stream[frame.stream_id] = expected + 1
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _parse_object(payload: str) -> dict[str, Any]:
|
|
179
|
+
try:
|
|
180
|
+
value = json.loads(payload)
|
|
181
|
+
except (json.JSONDecodeError, TypeError) as exc:
|
|
182
|
+
raise ProtocolError("invalid JSON object") from exc
|
|
183
|
+
if not isinstance(value, dict):
|
|
184
|
+
raise ProtocolError("message must be a JSON object")
|
|
185
|
+
return value
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _required_text(value: object, field: str) -> str:
|
|
189
|
+
if not isinstance(value, str) or not value.strip():
|
|
190
|
+
raise ProtocolError(f"{field} must be a non-empty string")
|
|
191
|
+
return value
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _protocol(value: dict[str, Any]) -> int:
|
|
195
|
+
protocol = value.get("protocol")
|
|
196
|
+
if protocol != PROTOCOL_VERSION:
|
|
197
|
+
raise ProtocolError(f"unsupported protocol: {protocol!r}")
|
|
198
|
+
return PROTOCOL_VERSION
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _object(value: object, field_name: str) -> dict[str, object]:
|
|
202
|
+
if not isinstance(value, dict):
|
|
203
|
+
raise ProtocolError(f"{field_name} must be a JSON object")
|
|
204
|
+
return value
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@dataclass(frozen=True, slots=True)
|
|
208
|
+
class ErrorInfo:
|
|
209
|
+
code: str
|
|
210
|
+
message: str
|
|
211
|
+
retryable: bool = False
|
|
212
|
+
details: dict[str, object] = field(default_factory=dict)
|
|
213
|
+
|
|
214
|
+
@classmethod
|
|
215
|
+
def from_dict(cls, value: object) -> ErrorInfo:
|
|
216
|
+
data = _object(value, "error")
|
|
217
|
+
retryable = data.get("retryable", False)
|
|
218
|
+
if not isinstance(retryable, bool):
|
|
219
|
+
raise ProtocolError("error.retryable must be a boolean")
|
|
220
|
+
return cls(
|
|
221
|
+
code=_required_text(data.get("code"), "error.code"),
|
|
222
|
+
message=_required_text(data.get("message"), "error.message"),
|
|
223
|
+
retryable=retryable,
|
|
224
|
+
details=_object(data.get("details", {}), "error.details"),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def to_dict(self) -> dict[str, object]:
|
|
228
|
+
return {
|
|
229
|
+
"code": self.code,
|
|
230
|
+
"message": self.message,
|
|
231
|
+
"retryable": self.retryable,
|
|
232
|
+
"details": self.details,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@dataclass(frozen=True, slots=True)
|
|
237
|
+
class Request:
|
|
238
|
+
protocol: int
|
|
239
|
+
request_id: str
|
|
240
|
+
method: str
|
|
241
|
+
params: dict[str, object]
|
|
242
|
+
|
|
243
|
+
@classmethod
|
|
244
|
+
def new(
|
|
245
|
+
cls,
|
|
246
|
+
method: str,
|
|
247
|
+
params: dict[str, object],
|
|
248
|
+
*,
|
|
249
|
+
request_id: str | None = None,
|
|
250
|
+
) -> Request:
|
|
251
|
+
return cls(
|
|
252
|
+
protocol=PROTOCOL_VERSION,
|
|
253
|
+
request_id=_required_text(request_id or uuid.uuid4().hex, "request_id"),
|
|
254
|
+
method=_required_text(method, "method"),
|
|
255
|
+
params=dict(params),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
@classmethod
|
|
259
|
+
def from_json(cls, payload: str) -> Request:
|
|
260
|
+
value = _parse_object(payload)
|
|
261
|
+
return cls(
|
|
262
|
+
protocol=_protocol(value),
|
|
263
|
+
request_id=_required_text(value.get("request_id"), "request_id"),
|
|
264
|
+
method=_required_text(value.get("method"), "method"),
|
|
265
|
+
params=_object(value.get("params"), "params"),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def to_json(self) -> str:
|
|
269
|
+
return json.dumps(
|
|
270
|
+
{
|
|
271
|
+
"protocol": self.protocol,
|
|
272
|
+
"request_id": self.request_id,
|
|
273
|
+
"method": self.method,
|
|
274
|
+
"params": self.params,
|
|
275
|
+
},
|
|
276
|
+
ensure_ascii=False,
|
|
277
|
+
separators=(",", ":"),
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@dataclass(frozen=True, slots=True)
|
|
282
|
+
class Response:
|
|
283
|
+
protocol: int
|
|
284
|
+
request_id: str
|
|
285
|
+
ok: bool
|
|
286
|
+
result: dict[str, object] = field(default_factory=dict)
|
|
287
|
+
error: ErrorInfo | None = None
|
|
288
|
+
|
|
289
|
+
@classmethod
|
|
290
|
+
def success(cls, request_id: str, result: dict[str, object]) -> Response:
|
|
291
|
+
return cls(PROTOCOL_VERSION, _required_text(request_id, "request_id"), True, dict(result))
|
|
292
|
+
|
|
293
|
+
@classmethod
|
|
294
|
+
def failure(cls, request_id: str, error: ErrorInfo) -> Response:
|
|
295
|
+
return cls(PROTOCOL_VERSION, _required_text(request_id, "request_id"), False, {}, error)
|
|
296
|
+
|
|
297
|
+
@classmethod
|
|
298
|
+
def from_json(cls, payload: str) -> Response:
|
|
299
|
+
value = _parse_object(payload)
|
|
300
|
+
ok = value.get("ok")
|
|
301
|
+
if not isinstance(ok, bool):
|
|
302
|
+
raise ProtocolError("ok must be a boolean")
|
|
303
|
+
request_id = _required_text(value.get("request_id"), "request_id")
|
|
304
|
+
protocol = _protocol(value)
|
|
305
|
+
if ok:
|
|
306
|
+
if value.get("error") is not None:
|
|
307
|
+
raise ProtocolError("successful response must not contain error")
|
|
308
|
+
return cls(protocol, request_id, True, _object(value.get("result", {}), "result"))
|
|
309
|
+
if value.get("result") not in (None, {}):
|
|
310
|
+
raise ProtocolError("failed response must not contain result")
|
|
311
|
+
return cls(protocol, request_id, False, {}, ErrorInfo.from_dict(value.get("error")))
|
|
312
|
+
|
|
313
|
+
def to_json(self) -> str:
|
|
314
|
+
payload: dict[str, object] = {
|
|
315
|
+
"protocol": self.protocol,
|
|
316
|
+
"request_id": self.request_id,
|
|
317
|
+
"ok": self.ok,
|
|
318
|
+
}
|
|
319
|
+
if self.ok:
|
|
320
|
+
payload["result"] = self.result
|
|
321
|
+
elif self.error is not None:
|
|
322
|
+
payload["error"] = self.error.to_dict()
|
|
323
|
+
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@dataclass(frozen=True, slots=True)
|
|
327
|
+
class Event:
|
|
328
|
+
protocol: int
|
|
329
|
+
request_id: str
|
|
330
|
+
stream_id: str
|
|
331
|
+
sequence: int
|
|
332
|
+
event: str
|
|
333
|
+
data: dict[str, object] = field(default_factory=dict)
|
|
334
|
+
|
|
335
|
+
@classmethod
|
|
336
|
+
def from_json(cls, payload: str) -> Event:
|
|
337
|
+
value = _parse_object(payload)
|
|
338
|
+
sequence = value.get("sequence")
|
|
339
|
+
if not isinstance(sequence, int) or isinstance(sequence, bool) or sequence < 0:
|
|
340
|
+
raise ProtocolError("sequence must be a non-negative integer")
|
|
341
|
+
return cls(
|
|
342
|
+
protocol=_protocol(value),
|
|
343
|
+
request_id=_required_text(value.get("request_id"), "request_id"),
|
|
344
|
+
stream_id=_required_text(value.get("stream_id"), "stream_id"),
|
|
345
|
+
sequence=sequence,
|
|
346
|
+
event=_required_text(value.get("event"), "event"),
|
|
347
|
+
data=_object(value.get("data", {}), "data"),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
def to_json(self) -> str:
|
|
351
|
+
return json.dumps(
|
|
352
|
+
{
|
|
353
|
+
"protocol": self.protocol,
|
|
354
|
+
"request_id": self.request_id,
|
|
355
|
+
"stream_id": self.stream_id,
|
|
356
|
+
"sequence": self.sequence,
|
|
357
|
+
"event": self.event,
|
|
358
|
+
"data": self.data,
|
|
359
|
+
},
|
|
360
|
+
ensure_ascii=False,
|
|
361
|
+
separators=(",", ":"),
|
|
362
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Runtime paths and process metadata for the HostBridge daemon."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import getpass
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class RuntimePaths:
|
|
15
|
+
directory: Path
|
|
16
|
+
socket: Path
|
|
17
|
+
pid: Path
|
|
18
|
+
log: Path
|
|
19
|
+
|
|
20
|
+
def ensure_directory(self) -> None:
|
|
21
|
+
self.directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
22
|
+
self.directory.chmod(0o700)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _default_runtime_dir() -> Path:
|
|
26
|
+
username = os.environ.get("USER") or os.environ.get("LOGNAME") or getpass.getuser() or "user"
|
|
27
|
+
base = Path("/private/tmp") if sys.platform == "darwin" else Path(tempfile.gettempdir())
|
|
28
|
+
return base / f"hostbridge-{username}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def runtime_paths(socket_path: Path | str | None = None) -> RuntimePaths:
|
|
32
|
+
configured_socket = socket_path or os.environ.get("HOSTBRIDGE_DAEMON_SOCKET")
|
|
33
|
+
if configured_socket:
|
|
34
|
+
socket = Path(configured_socket).expanduser()
|
|
35
|
+
directory = socket.parent
|
|
36
|
+
else:
|
|
37
|
+
configured_dir = os.environ.get("HOSTBRIDGE_DAEMON_DIR")
|
|
38
|
+
directory = Path(configured_dir).expanduser() if configured_dir else _default_runtime_dir()
|
|
39
|
+
socket = directory / "daemon.sock"
|
|
40
|
+
return RuntimePaths(directory, socket, directory / "daemon.pid", directory / "daemon.log")
|