@neoline/hostbridge 1.4.4 → 2.0.1

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.
@@ -0,0 +1,508 @@
1
+ """Daemon-owned HostBridge session and execution services."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ import shlex
8
+ import threading
9
+ import time
10
+ import uuid
11
+ from collections.abc import Callable, Iterable, Iterator
12
+ from dataclasses import dataclass, field
13
+
14
+ from .hosts import HostConfig, HostRegistry
15
+ from .policy import AuditSink, PolicyConfig, audit_event
16
+ from .transports.base import ExecResult, ShellReadResult, TransferResult, Transport, TransportBusy, TransportError
17
+
18
+ TransportFactory = Callable[[HostConfig], Transport]
19
+
20
+
21
+ class ServiceError(RuntimeError):
22
+ def __init__(
23
+ self,
24
+ code: str,
25
+ message: str,
26
+ *,
27
+ retryable: bool = False,
28
+ details: dict[str, object] | None = None,
29
+ ) -> None:
30
+ super().__init__(message)
31
+ self.code = code
32
+ self.retryable = retryable
33
+ self.details = details or {}
34
+
35
+
36
+ @dataclass(slots=True)
37
+ class ServiceSession:
38
+ session_id: str
39
+ host_id: str
40
+ owner: str | None
41
+ transport: Transport = field(repr=False)
42
+ created_at: float = field(default_factory=time.time)
43
+ last_used_at: float = field(default_factory=time.time)
44
+ active_operations: int = 0
45
+
46
+ def describe(self) -> dict[str, object]:
47
+ return {
48
+ "session_id": self.session_id,
49
+ "host_id": self.host_id,
50
+ "owner": self.owner,
51
+ "created_at": self.created_at,
52
+ "last_used_at": self.last_used_at,
53
+ "active_operations": self.active_operations,
54
+ "capabilities": {
55
+ "separate_stderr": self.transport.capabilities.separate_stderr,
56
+ "native_binary_transfer": self.transport.capabilities.native_binary_transfer,
57
+ "parallel_channels": self.transport.capabilities.parallel_channels,
58
+ },
59
+ }
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class ServiceExecResult:
64
+ session_id: str
65
+ exit_code: int | None
66
+ stdout: bytes
67
+ stderr: bytes
68
+ timed_out: bool
69
+
70
+
71
+ @dataclass(frozen=True, slots=True)
72
+ class ServiceTask:
73
+ task_id: str
74
+ session_id: str
75
+ host_id: str
76
+ command: str
77
+ job_dir: str
78
+ remote_pid: int | None
79
+ owner: str | None
80
+
81
+
82
+ class HostBridgeServices:
83
+ def __init__(
84
+ self,
85
+ hosts: HostRegistry,
86
+ *,
87
+ transport_factory: TransportFactory,
88
+ policy: PolicyConfig | None = None,
89
+ audit_sink: AuditSink | None = None,
90
+ ) -> None:
91
+ self.hosts = hosts
92
+ self._transport_factory = transport_factory
93
+ self._policy = policy
94
+ self._audit = audit_sink
95
+ self._sessions: dict[str, ServiceSession] = {}
96
+ self._tasks: dict[str, ServiceTask] = {}
97
+ self._sessions_lock = threading.RLock()
98
+ self._reaper_stop = threading.Event()
99
+ self._reaper_thread: threading.Thread | None = None
100
+
101
+ def open_session(self, host_id: str, *, owner: str | None = None) -> ServiceSession:
102
+ try:
103
+ host = self.hosts.get(host_id)
104
+ except KeyError as exc:
105
+ raise ServiceError("not_found", str(exc)) from exc
106
+ try:
107
+ transport = self._transport_factory(host)
108
+ except Exception as exc:
109
+ raise ServiceError("connection_lost", f"failed to connect to host {host_id}", retryable=True) from exc
110
+ session = ServiceSession(uuid.uuid4().hex, host.id, owner, transport)
111
+ with self._sessions_lock:
112
+ self._sessions[session.session_id] = session
113
+ self._emit("session_opened", session_id=session.session_id, host_id=host.id, owner_agent_id=owner, outcome="ok")
114
+ return session
115
+
116
+ def list_sessions(self) -> list[dict[str, object]]:
117
+ with self._sessions_lock:
118
+ return [session.describe() for session in self._sessions.values()]
119
+
120
+ def exec(
121
+ self,
122
+ session_id: str,
123
+ command: str,
124
+ *,
125
+ stdin: Iterable[bytes] | None = None,
126
+ timeout: float = 60,
127
+ output_limit: int = 1_000_000,
128
+ owner: str | None = None,
129
+ ) -> ServiceExecResult:
130
+ if not command.strip():
131
+ raise ServiceError("invalid_request", "command must not be empty")
132
+ if self._policy is not None:
133
+ decision = self._policy.evaluate(command)
134
+ if not decision.allowed:
135
+ raise ServiceError("denied", decision.reason, details={"matched_pattern": decision.matched_pattern})
136
+ session = self._get_session(session_id)
137
+ self._check_owner(session, owner)
138
+ with self._sessions_lock:
139
+ session.active_operations += 1
140
+ try:
141
+ result: ExecResult = session.transport.exec(
142
+ command,
143
+ stdin=stdin,
144
+ timeout=timeout,
145
+ output_limit=output_limit,
146
+ )
147
+ except TransportBusy as exc:
148
+ raise ServiceError("busy", str(exc), retryable=True) from exc
149
+ except TransportError as exc:
150
+ raise ServiceError("remote_error", str(exc), retryable=True) from exc
151
+ finally:
152
+ with self._sessions_lock:
153
+ session.active_operations -= 1
154
+ session.last_used_at = time.time()
155
+ self._emit(
156
+ "command_run",
157
+ session_id=session.session_id,
158
+ host_id=session.host_id,
159
+ owner_agent_id=owner,
160
+ command=command,
161
+ outcome="timeout" if result.timed_out else f"exit={result.exit_code}",
162
+ )
163
+ return ServiceExecResult(
164
+ session.session_id,
165
+ result.exit_code,
166
+ result.stdout,
167
+ result.stderr,
168
+ result.timed_out,
169
+ )
170
+
171
+ def close_session(self, session_id: str, *, owner: str | None = None, force: bool = False) -> None:
172
+ session = self._get_session(session_id)
173
+ if not force:
174
+ self._check_owner(session, owner)
175
+ with self._sessions_lock:
176
+ removed = self._sessions.pop(session_id, None)
177
+ if removed is not None:
178
+ with self._sessions_lock:
179
+ self._tasks = {task_id: task for task_id, task in self._tasks.items() if task.session_id != session_id}
180
+ removed.transport.close()
181
+ self._emit(
182
+ "session_closed",
183
+ session_id=session_id,
184
+ host_id=removed.host_id,
185
+ owner_agent_id=owner,
186
+ outcome="forced" if force else "user",
187
+ )
188
+
189
+ def close_all(self) -> dict[str, object]:
190
+ with self._sessions_lock:
191
+ sessions = list(self._sessions.values())
192
+ self._sessions.clear()
193
+ self._tasks.clear()
194
+ for session in sessions:
195
+ session.transport.close()
196
+ self._emit("session_closed", session_id=session.session_id, host_id=session.host_id, outcome="shutdown")
197
+ return {"closed": [session.session_id for session in sessions], "count": len(sessions)}
198
+
199
+ def reap_idle(self, timeout: float) -> int:
200
+ if timeout <= 0:
201
+ return 0
202
+ now = time.time()
203
+ with self._sessions_lock:
204
+ expired = [
205
+ session
206
+ for session in self._sessions.values()
207
+ if session.active_operations == 0 and now - session.last_used_at > timeout
208
+ ]
209
+ for session in expired:
210
+ self._sessions.pop(session.session_id, None)
211
+ self._tasks = {
212
+ task_id: task
213
+ for task_id, task in self._tasks.items()
214
+ if task.session_id != session.session_id
215
+ }
216
+ for session in expired:
217
+ session.transport.close()
218
+ self._emit("session_idle_closed", session_id=session.session_id, host_id=session.host_id, outcome="expired")
219
+ return len(expired)
220
+
221
+ def start_idle_reaper(self, *, timeout: float, interval: float = 30) -> None:
222
+ if timeout <= 0 or interval <= 0:
223
+ return
224
+ if self._reaper_thread is not None and self._reaper_thread.is_alive():
225
+ return
226
+ self._reaper_stop.clear()
227
+
228
+ def reap_loop() -> None:
229
+ while not self._reaper_stop.wait(interval):
230
+ self.reap_idle(timeout)
231
+
232
+ self._reaper_thread = threading.Thread(target=reap_loop, daemon=True, name="hostbridge-v1-reaper")
233
+ self._reaper_thread.start()
234
+
235
+ def stop_idle_reaper(self) -> None:
236
+ self._reaper_stop.set()
237
+ thread = self._reaper_thread
238
+ if thread is not None and thread.is_alive():
239
+ thread.join(timeout=2)
240
+ self._reaper_thread = None
241
+
242
+ def start_task(self, session_id: str, command: str, *, owner: str | None = None) -> ServiceTask:
243
+ if not command.strip():
244
+ raise ServiceError("invalid_request", "command must not be empty")
245
+ session = self._get_session(session_id)
246
+ self._check_owner(session, owner)
247
+ task_id = uuid.uuid4().hex
248
+ job_dir = f"/tmp/hostbridge/jobs/{task_id}"
249
+ quoted_command = shlex.quote(command)
250
+ script = (
251
+ f"job_dir={shlex.quote(job_dir)}; mkdir -p \"$job_dir\"; "
252
+ f"({quoted_command} > \"$job_dir/stdout.log\" 2>&1; "
253
+ "status=$?; printf '%s\n' \"$status\" > \"$job_dir/exit_code\") & "
254
+ "pid=$!; printf '%s\n' \"$pid\" > \"$job_dir/pid\"; printf '%s' \"$pid\""
255
+ )
256
+ result = self.exec(session_id, script, timeout=10, output_limit=4096, owner=owner)
257
+ pid_match = re.search(rb"(\d+)", result.stdout)
258
+ task = ServiceTask(
259
+ task_id,
260
+ session_id,
261
+ session.host_id,
262
+ command,
263
+ job_dir,
264
+ int(pid_match.group(1)) if pid_match else None,
265
+ owner,
266
+ )
267
+ with self._sessions_lock:
268
+ self._tasks[task_id] = task
269
+ self._emit(
270
+ "task_started",
271
+ session_id=session_id,
272
+ task_id=task_id,
273
+ host_id=session.host_id,
274
+ owner_agent_id=owner,
275
+ command=command,
276
+ outcome="ok",
277
+ )
278
+ return task
279
+
280
+ def task_status(
281
+ self,
282
+ session_id: str,
283
+ task_id: str,
284
+ *,
285
+ owner: str | None = None,
286
+ tail_lines: int = 80,
287
+ ) -> dict[str, object]:
288
+ task = self._get_task(session_id, task_id, owner)
289
+ safe_tail = max(1, min(int(tail_lines), 500))
290
+ script = (
291
+ f"job_dir={shlex.quote(task.job_dir)}; pid=$(cat \"$job_dir/pid\" 2>/dev/null || true); "
292
+ "if [ -f \"$job_dir/exit_code\" ]; then state=finished; exit_code=$(cat \"$job_dir/exit_code\"); "
293
+ "elif [ -n \"$pid\" ] && kill -0 \"$pid\" 2>/dev/null; then state=running; exit_code=''; "
294
+ "else state=unknown; exit_code=''; fi; "
295
+ "printf 'STATE=%s\nPID=%s\nEXIT=%s\n---LOG---\n' \"$state\" \"$pid\" \"$exit_code\"; "
296
+ f"tail -n {safe_tail} \"$job_dir/stdout.log\" 2>/dev/null || true"
297
+ )
298
+ result = self.exec(session_id, script, timeout=15, output_limit=1_000_000, owner=owner)
299
+ metadata, _, log = result.stdout.decode("utf-8", errors="replace").partition("---LOG---\n")
300
+ fields: dict[str, str] = {}
301
+ for line in metadata.splitlines():
302
+ key, separator, value = line.partition("=")
303
+ if separator:
304
+ fields[key] = value
305
+ exit_text = fields.get("EXIT", "")
306
+ return {
307
+ "task_id": task.task_id,
308
+ "session_id": session_id,
309
+ "host_id": task.host_id,
310
+ "command": task.command,
311
+ "job_dir": task.job_dir,
312
+ "state": fields.get("STATE", "unknown"),
313
+ "pid": fields.get("PID") or task.remote_pid,
314
+ "exit_code": int(exit_text) if exit_text.isdigit() else None,
315
+ "log_tail": log,
316
+ }
317
+
318
+ def cancel_task(self, session_id: str, task_id: str, *, owner: str | None = None) -> dict[str, object]:
319
+ task = self._get_task(session_id, task_id, owner)
320
+ if task.remote_pid is None:
321
+ raise ServiceError("remote_error", f"task {task_id} has no recorded remote pid")
322
+ script = (
323
+ f"job_dir={shlex.quote(task.job_dir)}; "
324
+ "if [ -f \"$job_dir/exit_code\" ]; then printf already; "
325
+ f"elif kill -TERM {task.remote_pid} 2>/dev/null; then printf killed; else printf noop; fi"
326
+ )
327
+ result = self.exec(session_id, script, timeout=10, output_limit=4096, owner=owner)
328
+ outcome = result.stdout.decode("utf-8", errors="replace").strip().splitlines()[-1] if result.stdout.strip() else "unknown"
329
+ self._emit("task_cancelled", session_id=session_id, task_id=task_id, outcome=outcome)
330
+ return {"task_id": task_id, "stopped": outcome in {"killed", "already"}, "outcome": outcome}
331
+
332
+ def upload(
333
+ self,
334
+ session_id: str,
335
+ chunks: Iterable[bytes],
336
+ remote_path: str,
337
+ *,
338
+ mode: int,
339
+ expected_size: int | None,
340
+ expected_sha256: str | None,
341
+ owner: str | None = None,
342
+ ) -> TransferResult:
343
+ session = self._get_session(session_id)
344
+ self._check_owner(session, owner)
345
+ with self._sessions_lock:
346
+ session.active_operations += 1
347
+ try:
348
+ result = session.transport.upload(
349
+ chunks,
350
+ remote_path,
351
+ mode=mode,
352
+ expected_size=expected_size,
353
+ )
354
+ except TransportError as exc:
355
+ raise ServiceError("remote_error", str(exc), retryable=True) from exc
356
+ finally:
357
+ with self._sessions_lock:
358
+ session.active_operations -= 1
359
+ session.last_used_at = time.time()
360
+ if expected_size is not None and result.bytes_transferred != expected_size:
361
+ raise ServiceError(
362
+ "integrity_error",
363
+ f"transfer size mismatch: expected {expected_size}, received {result.bytes_transferred}",
364
+ )
365
+ if expected_sha256 is not None and result.sha256.lower() != expected_sha256.lower():
366
+ raise ServiceError("integrity_error", "transfer SHA-256 mismatch")
367
+ self._emit(
368
+ "file_uploaded",
369
+ session_id=session_id,
370
+ host_id=session.host_id,
371
+ owner_agent_id=owner,
372
+ command=remote_path,
373
+ outcome=f"bytes={result.bytes_transferred}",
374
+ )
375
+ return result
376
+
377
+ def download(
378
+ self,
379
+ session_id: str,
380
+ remote_path: str,
381
+ *,
382
+ chunk_size: int,
383
+ owner: str | None = None,
384
+ ) -> Iterator[bytes]:
385
+ session = self._get_session(session_id)
386
+ self._check_owner(session, owner)
387
+
388
+ def stream() -> Iterator[bytes]:
389
+ with self._sessions_lock:
390
+ session.active_operations += 1
391
+ transferred = 0
392
+ try:
393
+ for chunk in session.transport.download(remote_path, chunk_size=chunk_size):
394
+ if not isinstance(chunk, bytes):
395
+ raise ServiceError("internal", "transport returned a non-bytes download chunk")
396
+ transferred += len(chunk)
397
+ yield chunk
398
+ except TransportError as exc:
399
+ raise ServiceError("remote_error", str(exc), retryable=True) from exc
400
+ finally:
401
+ with self._sessions_lock:
402
+ session.active_operations -= 1
403
+ session.last_used_at = time.time()
404
+ self._emit(
405
+ "file_downloaded",
406
+ session_id=session_id,
407
+ host_id=session.host_id,
408
+ owner_agent_id=owner,
409
+ command=remote_path,
410
+ outcome=f"bytes={transferred}",
411
+ )
412
+
413
+ return stream()
414
+
415
+ def write_text(
416
+ self,
417
+ session_id: str,
418
+ remote_path: str,
419
+ content: str,
420
+ *,
421
+ mode: int = 0o644,
422
+ max_bytes: int = 4 * 1024 * 1024,
423
+ owner: str | None = None,
424
+ ) -> TransferResult:
425
+ data = content.encode("utf-8")
426
+ if len(data) > max_bytes:
427
+ raise ServiceError("resource_limit", f"text content exceeds {max_bytes} bytes")
428
+ return self.upload(
429
+ session_id,
430
+ iter([data]),
431
+ remote_path,
432
+ mode=mode,
433
+ expected_size=len(data),
434
+ expected_sha256=hashlib.sha256(data).hexdigest(),
435
+ owner=owner,
436
+ )
437
+
438
+ def read_text(
439
+ self,
440
+ session_id: str,
441
+ remote_path: str,
442
+ *,
443
+ max_bytes: int = 4 * 1024 * 1024,
444
+ owner: str | None = None,
445
+ ) -> str:
446
+ data = bytearray()
447
+ for chunk in self.download(session_id, remote_path, chunk_size=256 * 1024, owner=owner):
448
+ data.extend(chunk)
449
+ if len(data) > max_bytes:
450
+ raise ServiceError("resource_limit", f"text file exceeds {max_bytes} bytes")
451
+ try:
452
+ return bytes(data).decode("utf-8")
453
+ except UnicodeDecodeError as exc:
454
+ raise ServiceError("invalid_request", f"remote file is not valid UTF-8 at byte {exc.start}") from exc
455
+
456
+ def shell_write(self, session_id: str, data: bytes, *, owner: str | None = None) -> int:
457
+ session = self._get_session(session_id)
458
+ self._check_owner(session, owner)
459
+ try:
460
+ session.transport.shell_write(data)
461
+ except TransportError as exc:
462
+ raise ServiceError("remote_error", str(exc), retryable=True) from exc
463
+ session.last_used_at = time.time()
464
+ return len(data)
465
+
466
+ def shell_read(
467
+ self,
468
+ session_id: str,
469
+ *,
470
+ timeout: float,
471
+ max_bytes: int,
472
+ owner: str | None = None,
473
+ ) -> ShellReadResult:
474
+ session = self._get_session(session_id)
475
+ self._check_owner(session, owner)
476
+ try:
477
+ result = session.transport.shell_read(timeout=timeout, max_bytes=max_bytes)
478
+ except TransportError as exc:
479
+ raise ServiceError("remote_error", str(exc), retryable=True) from exc
480
+ session.last_used_at = time.time()
481
+ return result
482
+
483
+ def _get_session(self, session_id: str) -> ServiceSession:
484
+ with self._sessions_lock:
485
+ session = self._sessions.get(session_id)
486
+ if session is None:
487
+ raise ServiceError("not_found", f"unknown session {session_id}")
488
+ return session
489
+
490
+ def _get_task(self, session_id: str, task_id: str, owner: str | None) -> ServiceTask:
491
+ session = self._get_session(session_id)
492
+ self._check_owner(session, owner)
493
+ with self._sessions_lock:
494
+ task = self._tasks.get(task_id)
495
+ if task is None or task.session_id != session_id:
496
+ raise ServiceError("not_found", f"unknown task {task_id}")
497
+ if task.owner is not None and task.owner != owner:
498
+ raise ServiceError("denied", f"task {task_id} is owned by another agent")
499
+ return task
500
+
501
+ @staticmethod
502
+ def _check_owner(session: ServiceSession, owner: str | None) -> None:
503
+ if session.owner is not None and session.owner != owner:
504
+ raise ServiceError("denied", f"session {session.session_id} is owned by another agent")
505
+
506
+ def _emit(self, event: str, **fields: object) -> None:
507
+ if self._audit is not None:
508
+ audit_event(self._audit, event=event, **{key: value for key, value in fields.items() if value is not None}) # type: ignore[arg-type]
@@ -0,0 +1,17 @@
1
+ """Remote transport implementations used by HostBridge daemon services."""
2
+
3
+ from .base import ExecResult, ShellReadResult, TransferResult, Transport, TransportBusy, TransportCapabilities
4
+ from .pty import PtyTransport
5
+ from .ssh import SshConnectionConfig, SshTransport
6
+
7
+ __all__ = [
8
+ "ExecResult",
9
+ "PtyTransport",
10
+ "SshConnectionConfig",
11
+ "SshTransport",
12
+ "ShellReadResult",
13
+ "TransferResult",
14
+ "Transport",
15
+ "TransportBusy",
16
+ "TransportCapabilities",
17
+ ]
@@ -0,0 +1,78 @@
1
+ """Shared transport contracts and result models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Iterator
6
+ from dataclasses import dataclass
7
+ from typing import Protocol
8
+
9
+
10
+ class TransportError(RuntimeError):
11
+ """Base error raised by a remote transport."""
12
+
13
+
14
+ class TransportBusy(TransportError):
15
+ """Raised when an ordered transport already has an active operation."""
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class TransportCapabilities:
20
+ separate_stderr: bool
21
+ native_binary_transfer: bool
22
+ parallel_channels: int
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class ExecResult:
27
+ exit_code: int | None
28
+ stdout: bytes
29
+ stderr: bytes = b""
30
+ timed_out: bool = False
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class TransferResult:
35
+ bytes_transferred: int
36
+ sha256: str
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class ShellReadResult:
41
+ data: bytes
42
+ alive: bool
43
+
44
+
45
+ class Transport(Protocol):
46
+ capabilities: TransportCapabilities
47
+
48
+ def exec(
49
+ self,
50
+ command: str,
51
+ *,
52
+ stdin: Iterable[bytes] | None,
53
+ timeout: float,
54
+ output_limit: int,
55
+ ) -> ExecResult:
56
+ raise NotImplementedError
57
+
58
+ def upload(
59
+ self,
60
+ chunks: Iterable[bytes],
61
+ remote_path: str,
62
+ *,
63
+ mode: int,
64
+ expected_size: int | None,
65
+ ) -> TransferResult:
66
+ raise NotImplementedError
67
+
68
+ def download(self, remote_path: str, *, chunk_size: int) -> Iterator[bytes]:
69
+ raise NotImplementedError
70
+
71
+ def close(self) -> None:
72
+ raise NotImplementedError
73
+
74
+ def shell_write(self, data: bytes) -> None:
75
+ raise NotImplementedError
76
+
77
+ def shell_read(self, *, timeout: float, max_bytes: int) -> ShellReadResult:
78
+ raise NotImplementedError