@neoline/hostbridge 1.4.4 → 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.
@@ -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")