@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,862 @@
1
+ """Async session services backed by daemon-owned host runtime leases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ import logging
8
+ import re
9
+ import time
10
+ import uuid
11
+ from collections.abc import AsyncGenerator
12
+ from contextlib import asynccontextmanager, suppress
13
+ from dataclasses import dataclass, field
14
+ from typing import Protocol
15
+
16
+ from .hosts import HostRegistry
17
+ from .policy import AuditSink, PolicyConfig, audit_event
18
+ from .remote_task_agent import (
19
+ build_remote_task_command,
20
+ build_remote_tasks_cleanup_command,
21
+ remote_job_dir,
22
+ remote_job_dir_assignment,
23
+ )
24
+ from .services import MAX_EXEC_OUTPUT_BYTES, ServiceError, ServiceExecResult, ServiceTask
25
+ from .transports.base import (
26
+ ByteStream,
27
+ ExecResult,
28
+ ShellAttachment,
29
+ ShellReadResult,
30
+ ShellTerminalResult,
31
+ TransferResult,
32
+ iterate_byte_stream,
33
+ )
34
+ from .tunnel_manager import HostRuntimeLease, TunnelError
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ class RuntimeLeaseManager(Protocol):
40
+ async def acquire(self, host_id: str) -> HostRuntimeLease: ...
41
+
42
+
43
+ @dataclass(slots=True)
44
+ class RuntimeServiceSession:
45
+ session_id: str
46
+ host_id: str
47
+ owner: str | None
48
+ lease: HostRuntimeLease = field(repr=False)
49
+ created_at: float = field(default_factory=time.time)
50
+ last_used_at: float = field(default_factory=time.time)
51
+ active_operations: int = 0
52
+ closing: bool = False
53
+ operation_tasks: set[asyncio.Task[object]] = field(default_factory=set, repr=False)
54
+
55
+ def describe(self) -> dict[str, object]:
56
+ capabilities = self.lease.capabilities
57
+ return {
58
+ "session_id": self.session_id,
59
+ "host_id": self.host_id,
60
+ "owner": self.owner,
61
+ "created_at": self.created_at,
62
+ "last_used_at": self.last_used_at,
63
+ "active_operations": self.active_operations,
64
+ "capabilities": {
65
+ "separate_stderr": capabilities.separate_stderr,
66
+ "native_binary_transfer": capabilities.native_binary_transfer,
67
+ "parallel_channels": capabilities.parallel_channels,
68
+ },
69
+ }
70
+
71
+
72
+ class AsyncHostBridgeServices:
73
+ def __init__(
74
+ self,
75
+ hosts: HostRegistry,
76
+ runtime_manager: RuntimeLeaseManager,
77
+ *,
78
+ default_connect_timeout: float = 30,
79
+ policy: PolicyConfig | None = None,
80
+ max_tasks_per_session: int = 32,
81
+ cleanup_timeout_seconds: float = 5,
82
+ idle_timeout_seconds: float | None = None,
83
+ reaper_interval_seconds: float = 60,
84
+ audit_sink: AuditSink | None = None,
85
+ ) -> None:
86
+ if default_connect_timeout <= 0:
87
+ raise ValueError("default_connect_timeout must be positive")
88
+ if max_tasks_per_session <= 0:
89
+ raise ValueError("max_tasks_per_session must be positive")
90
+ if cleanup_timeout_seconds <= 0:
91
+ raise ValueError("cleanup_timeout_seconds must be positive")
92
+ if idle_timeout_seconds is not None and idle_timeout_seconds < 0:
93
+ raise ValueError("idle_timeout_seconds must be non-negative")
94
+ if reaper_interval_seconds <= 0:
95
+ raise ValueError("reaper_interval_seconds must be positive")
96
+ self.hosts = hosts
97
+ self._runtime_manager = runtime_manager
98
+ self._default_connect_timeout = float(default_connect_timeout)
99
+ self._policy = policy
100
+ self._max_tasks_per_session = max_tasks_per_session
101
+ self._cleanup_timeout_seconds = float(cleanup_timeout_seconds)
102
+ self._idle_timeout_seconds = float(
103
+ policy.idle_timeout_seconds
104
+ if idle_timeout_seconds is None and policy is not None
105
+ else idle_timeout_seconds or 0
106
+ )
107
+ self._reaper_interval_seconds = float(reaper_interval_seconds)
108
+ self._audit = audit_sink
109
+ self._sessions: dict[str, RuntimeServiceSession] = {}
110
+ self._pending_sessions: dict[str, int] = {}
111
+ self._tasks: dict[str, ServiceTask] = {}
112
+ self._launching_tasks: dict[str, ServiceTask] = {}
113
+ self._lock = asyncio.Lock()
114
+ self._closing = False
115
+ self._reaper_task: asyncio.Task[None] | None = None
116
+
117
+ async def open_session(
118
+ self,
119
+ host_id: str,
120
+ *,
121
+ owner: str | None = None,
122
+ connect_timeout: float | None = None,
123
+ ) -> RuntimeServiceSession:
124
+ try:
125
+ host = self.hosts.get(host_id)
126
+ except KeyError as exc:
127
+ raise ServiceError("not_found", str(exc)) from exc
128
+ timeout = self._default_connect_timeout if connect_timeout is None else connect_timeout
129
+ if timeout <= 0:
130
+ raise ServiceError("invalid_request", "connect_timeout must be positive")
131
+ async with self._lock:
132
+ if self._closing:
133
+ raise ServiceError("connection_lost", "HostBridge services are shutting down", retryable=True)
134
+ current = sum(session.host_id == host.id for session in self._sessions.values())
135
+ pending = self._pending_sessions.get(host.id, 0)
136
+ if current + pending >= host.limits.max_sessions:
137
+ raise ServiceError("resource_limit", f"host {host_id} reached its session limit")
138
+ self._pending_sessions[host.id] = pending + 1
139
+ lease: HostRuntimeLease | None = None
140
+ registered = False
141
+ try:
142
+ async with asyncio.timeout(timeout):
143
+ lease = await self._runtime_manager.acquire(host.id)
144
+ except TimeoutError as exc:
145
+ raise ServiceError("timeout", f"failed to connect to host {host_id}", retryable=True) from exc
146
+ except TunnelError as exc:
147
+ raise ServiceError(exc.code, str(exc), retryable=exc.retryable) from exc
148
+ except BaseException as exc:
149
+ if isinstance(exc, asyncio.CancelledError):
150
+ raise
151
+ raise ServiceError("connection_lost", f"failed to connect to host {host_id}", retryable=True) from exc
152
+ else:
153
+ session = RuntimeServiceSession(uuid.uuid4().hex, host.id, owner, lease)
154
+ try:
155
+ async with self._lock:
156
+ if self._closing:
157
+ raise ServiceError(
158
+ "connection_lost",
159
+ "HostBridge services are shutting down",
160
+ retryable=True,
161
+ )
162
+ self._sessions[session.session_id] = session
163
+ self._ensure_idle_reaper_locked()
164
+ registered = True
165
+ self._emit(
166
+ "session_opened",
167
+ session_id=session.session_id,
168
+ host_id=host.id,
169
+ owner_agent_id=owner,
170
+ outcome="ok",
171
+ )
172
+ return session
173
+ except BaseException:
174
+ if not registered:
175
+ await self._close_lease_quietly(lease)
176
+ raise
177
+ finally:
178
+ async with self._lock:
179
+ self._release_pending_locked(host.id)
180
+
181
+ async def list_sessions(self) -> list[dict[str, object]]:
182
+ async with self._lock:
183
+ return [session.describe() for session in self._sessions.values()]
184
+
185
+ async def close_session(
186
+ self,
187
+ session_id: str,
188
+ *,
189
+ owner: str | None = None,
190
+ force: bool = False,
191
+ ) -> None:
192
+ async with self._lock:
193
+ session = self._sessions.get(session_id)
194
+ if session is None:
195
+ raise ServiceError("not_found", f"unknown session {session_id}")
196
+ if not force:
197
+ self._check_owner(session, owner)
198
+ if session.active_operations:
199
+ raise ServiceError("busy", f"session {session_id} has active operations", retryable=True)
200
+ if session.closing:
201
+ raise ServiceError("busy", f"session {session_id} is already closing", retryable=True)
202
+ session.closing = True
203
+ operations = tuple(task for task in session.operation_tasks if task is not asyncio.current_task())
204
+ tasks = tuple(
205
+ task
206
+ for task in (*self._tasks.values(), *self._launching_tasks.values())
207
+ if task.session_id == session_id
208
+ )
209
+ finalizer = asyncio.create_task(
210
+ self._finalize_session(session, force=force, operations=operations, tasks=tasks),
211
+ name=f"hostbridge-session-finalize-{session_id}",
212
+ )
213
+ cancellation_requested = False
214
+ while True:
215
+ try:
216
+ errors = await asyncio.shield(finalizer)
217
+ break
218
+ except asyncio.CancelledError:
219
+ cancellation_requested = True
220
+ if finalizer.done():
221
+ errors = finalizer.result()
222
+ break
223
+ if cancellation_requested:
224
+ cancelled = asyncio.CancelledError(f"session {session_id} close was cancelled after cleanup started")
225
+ for error in errors:
226
+ cancelled.add_note(f"session cleanup also failed: {error!r}")
227
+ raise cancelled
228
+ if errors:
229
+ raise ServiceError(
230
+ "connection_lost",
231
+ f"failed to fully clean up session {session_id}: {errors[0]}",
232
+ retryable=True,
233
+ ) from errors[0]
234
+
235
+ async def close_mock_ssh_session(self, session_id: str) -> None:
236
+ async with self._lock:
237
+ session = self._sessions.get(session_id)
238
+ if session is None:
239
+ raise ServiceError("not_found", f"unknown session {session_id}")
240
+ if session.owner != "mock-ssh":
241
+ raise ServiceError("denied", f"session {session_id} is not owned by mock-ssh")
242
+ await self.close_session(session_id, force=True)
243
+
244
+ async def _finalize_session(
245
+ self,
246
+ session: RuntimeServiceSession,
247
+ *,
248
+ force: bool,
249
+ operations: tuple[asyncio.Task[object], ...],
250
+ tasks: tuple[ServiceTask, ...],
251
+ ) -> list[BaseException]:
252
+ session_id = session.session_id
253
+ errors: list[BaseException] = []
254
+ if force and operations:
255
+ for operation in operations:
256
+ operation.cancel()
257
+ _, pending = await asyncio.wait(operations, timeout=self._cleanup_timeout_seconds)
258
+ if pending:
259
+ errors.append(TimeoutError(f"session {session_id} operations did not stop before cleanup deadline"))
260
+ if tasks:
261
+ try:
262
+ await self._cleanup_remote_tasks(session, tasks)
263
+ except BaseException as exc:
264
+ errors.append(exc)
265
+ try:
266
+ await session.lease.close()
267
+ except BaseException as exc:
268
+ errors.append(exc)
269
+ finally:
270
+ async with self._lock:
271
+ self._sessions.pop(session_id, None)
272
+ self._tasks = {task_id: task for task_id, task in self._tasks.items() if task.session_id != session_id}
273
+ self._launching_tasks = {
274
+ task_id: task for task_id, task in self._launching_tasks.items() if task.session_id != session_id
275
+ }
276
+ self._emit(
277
+ "session_closed",
278
+ session_id=session_id,
279
+ host_id=session.host_id,
280
+ owner_agent_id=session.owner,
281
+ outcome="cleanup_failed" if errors else "forced" if force else "user",
282
+ )
283
+ return errors
284
+
285
+ async def close_all(self, *, owner: str | None = None, force: bool = False) -> dict[str, object]:
286
+ finalizer = asyncio.create_task(
287
+ self._close_all_owned(owner=owner, force=force),
288
+ name="hostbridge-services-close-all",
289
+ )
290
+ cancellation: asyncio.CancelledError | None = None
291
+ while not finalizer.done():
292
+ try:
293
+ await asyncio.shield(finalizer)
294
+ except asyncio.CancelledError as exc:
295
+ cancellation = exc
296
+ result = finalizer.result()
297
+ if cancellation is not None:
298
+ raise cancellation
299
+ return result
300
+
301
+ async def _close_all_owned(self, *, owner: str | None, force: bool) -> dict[str, object]:
302
+ async with self._lock:
303
+ if force:
304
+ self._closing = True
305
+ reaper = self._reaper_task
306
+ self._reaper_task = None
307
+ else:
308
+ reaper = None
309
+ candidates = [session for session in self._sessions.values() if force or session.owner == owner]
310
+ if reaper is not None and reaper is not asyncio.current_task():
311
+ reaper.cancel()
312
+ await asyncio.gather(reaper, return_exceptions=True)
313
+ closed: list[str] = []
314
+ failed: list[str] = []
315
+ for session in candidates:
316
+ try:
317
+ await self.close_session(session.session_id, owner=owner, force=force)
318
+ except ServiceError:
319
+ failed.append(session.session_id)
320
+ else:
321
+ closed.append(session.session_id)
322
+ return {"closed": closed, "count": len(closed), "busy": [], "failed": failed}
323
+
324
+ async def exec(
325
+ self,
326
+ session_id: str,
327
+ command: str,
328
+ *,
329
+ stdin: ByteStream | None = None,
330
+ timeout: float = 60,
331
+ output_limit: int = 1_000_000,
332
+ owner: str | None = None,
333
+ ) -> ServiceExecResult:
334
+ if not command.strip():
335
+ raise ServiceError("invalid_request", "command must not be empty")
336
+ self._check_command_policy(command)
337
+ async with self._operation(session_id, owner) as session:
338
+ service_result = await self._exec_on_session(
339
+ session,
340
+ command,
341
+ stdin=stdin,
342
+ timeout=timeout,
343
+ output_limit=output_limit,
344
+ )
345
+ self._emit(
346
+ "command_run",
347
+ session_id=session_id,
348
+ host_id=session.host_id,
349
+ owner_agent_id=owner,
350
+ command=command,
351
+ outcome="timed_out"
352
+ if service_result.timed_out
353
+ else f"exit_{service_result.exit_code}"
354
+ if service_result.exit_code is not None
355
+ else "unknown",
356
+ )
357
+ if service_result.timed_out:
358
+ await self.close_session(session_id, force=True)
359
+ return service_result
360
+
361
+ async def upload(
362
+ self,
363
+ session_id: str,
364
+ chunks: ByteStream,
365
+ remote_path: str,
366
+ *,
367
+ mode: int,
368
+ expected_size: int | None,
369
+ expected_sha256: str | None,
370
+ owner: str | None = None,
371
+ ) -> TransferResult:
372
+ async with self._operation(session_id, owner) as session:
373
+ limit = self.hosts.get(session.host_id).limits.max_transfer_bytes
374
+ if expected_size is not None and expected_size > limit:
375
+ raise ServiceError("resource_limit", f"upload exceeds {limit} bytes")
376
+ result = await session.lease.upload(
377
+ self._bounded_chunks(chunks, limit, "upload"),
378
+ remote_path,
379
+ mode=mode,
380
+ expected_size=expected_size,
381
+ expected_sha256=expected_sha256,
382
+ )
383
+ if expected_size is not None and result.bytes_transferred != expected_size:
384
+ raise ServiceError("integrity_error", "transfer size mismatch")
385
+ if expected_sha256 is not None and result.sha256.lower() != expected_sha256.lower():
386
+ raise ServiceError("integrity_error", "transfer SHA-256 mismatch")
387
+ self._emit(
388
+ "file_uploaded",
389
+ session_id=session_id,
390
+ host_id=session.host_id,
391
+ owner_agent_id=owner,
392
+ command=remote_path,
393
+ outcome=f"bytes={result.bytes_transferred}",
394
+ )
395
+ return result
396
+
397
+ async def download(
398
+ self,
399
+ session_id: str,
400
+ remote_path: str,
401
+ *,
402
+ chunk_size: int,
403
+ owner: str | None = None,
404
+ ) -> AsyncGenerator[bytes, None]:
405
+ async with self._operation(session_id, owner) as session:
406
+ limit = self.hosts.get(session.host_id).limits.max_transfer_bytes
407
+ transferred = 0
408
+ try:
409
+ async for chunk in session.lease.download(remote_path, chunk_size=chunk_size):
410
+ if not isinstance(chunk, bytes):
411
+ raise ServiceError("internal", "runtime returned a non-bytes download chunk")
412
+ transferred += len(chunk)
413
+ if transferred > limit:
414
+ raise ServiceError("resource_limit", f"download exceeds {limit} bytes")
415
+ yield chunk
416
+ finally:
417
+ self._emit(
418
+ "file_downloaded",
419
+ session_id=session_id,
420
+ host_id=session.host_id,
421
+ owner_agent_id=owner,
422
+ command=remote_path,
423
+ outcome=f"bytes={transferred}",
424
+ )
425
+
426
+ async def write_text(
427
+ self,
428
+ session_id: str,
429
+ remote_path: str,
430
+ content: str,
431
+ *,
432
+ mode: int = 0o644,
433
+ max_bytes: int = 4 * 1024 * 1024,
434
+ owner: str | None = None,
435
+ ) -> TransferResult:
436
+ data = content.encode("utf-8")
437
+ session = await self._session_for_request(session_id, owner)
438
+ limit = min(max_bytes, self.hosts.get(session.host_id).limits.max_text_bytes)
439
+ if len(data) > limit:
440
+ raise ServiceError("resource_limit", f"text content exceeds {limit} bytes")
441
+ return await self.upload(
442
+ session_id,
443
+ [data],
444
+ remote_path,
445
+ mode=mode,
446
+ expected_size=len(data),
447
+ expected_sha256=hashlib.sha256(data).hexdigest(),
448
+ owner=owner,
449
+ )
450
+
451
+ async def read_text(
452
+ self,
453
+ session_id: str,
454
+ remote_path: str,
455
+ *,
456
+ max_bytes: int = 4 * 1024 * 1024,
457
+ owner: str | None = None,
458
+ ) -> str:
459
+ session = await self._session_for_request(session_id, owner)
460
+ limit = min(max_bytes, self.hosts.get(session.host_id).limits.max_text_bytes)
461
+ data = bytearray()
462
+ async for chunk in self.download(session_id, remote_path, chunk_size=256 * 1024, owner=owner):
463
+ data.extend(chunk)
464
+ if len(data) > limit:
465
+ raise ServiceError("resource_limit", f"text file exceeds {limit} bytes")
466
+ try:
467
+ return bytes(data).decode("utf-8")
468
+ except UnicodeDecodeError as exc:
469
+ raise ServiceError("invalid_request", f"remote file is not valid UTF-8 at byte {exc.start}") from exc
470
+
471
+ async def start_task(self, session_id: str, command: str, *, owner: str | None = None) -> ServiceTask:
472
+ if not command.strip():
473
+ raise ServiceError("invalid_request", "command must not be empty")
474
+ self._check_command_policy(command)
475
+ async with self._operation(session_id, owner) as session:
476
+ task_id = uuid.uuid4().hex
477
+ launching = ServiceTask(
478
+ task_id,
479
+ session_id,
480
+ session.host_id,
481
+ command,
482
+ remote_job_dir(task_id),
483
+ None,
484
+ owner,
485
+ )
486
+ async with self._lock:
487
+ retained = sum(
488
+ task.session_id == session_id for task in (*self._tasks.values(), *self._launching_tasks.values())
489
+ )
490
+ if retained >= self._max_tasks_per_session:
491
+ raise ServiceError("resource_limit", f"session {session_id} reached its task retention limit")
492
+ self._launching_tasks[task_id] = launching
493
+ try:
494
+ result = await self._exec_on_session(
495
+ session,
496
+ build_remote_task_command(command, task_id),
497
+ stdin=None,
498
+ timeout=10,
499
+ output_limit=4096,
500
+ )
501
+ pid_match = re.fullmatch(rb"\s*([1-9]\d*)\s*", result.stdout)
502
+ if result.timed_out or result.exit_code != 0 or pid_match is None:
503
+ raise ServiceError("remote_error", f"failed to launch task {task_id}", retryable=True)
504
+ task = ServiceTask(
505
+ task_id,
506
+ session_id,
507
+ session.host_id,
508
+ command,
509
+ launching.job_dir,
510
+ int(pid_match.group(1)),
511
+ owner,
512
+ )
513
+ async with self._lock:
514
+ self._launching_tasks.pop(task_id, None)
515
+ self._tasks[task_id] = task
516
+ self._emit(
517
+ "task_started",
518
+ session_id=session_id,
519
+ task_id=task_id,
520
+ host_id=session.host_id,
521
+ owner_agent_id=owner,
522
+ command=command,
523
+ outcome="ok",
524
+ )
525
+ return task
526
+ except BaseException:
527
+ async with self._lock:
528
+ cleanup_here = task_id in self._launching_tasks and not session.closing
529
+ if cleanup_here:
530
+ try:
531
+ await self._cleanup_remote_tasks(session, (launching,))
532
+ finally:
533
+ async with self._lock:
534
+ self._launching_tasks.pop(task_id, None)
535
+ else:
536
+ async with self._lock:
537
+ self._launching_tasks.pop(task_id, None)
538
+ raise
539
+
540
+ async def task_status(
541
+ self,
542
+ session_id: str,
543
+ task_id: str,
544
+ *,
545
+ owner: str | None = None,
546
+ tail_lines: int = 80,
547
+ ) -> dict[str, object]:
548
+ task = await self._task_for_request(session_id, task_id, owner)
549
+ safe_tail = max(1, min(int(tail_lines), 500))
550
+ script = (
551
+ f'{remote_job_dir_assignment(task.task_id)}; pid=$(cat "$job_dir/pid" 2>/dev/null || true); '
552
+ 'if [ -f "$job_dir/exit_code" ]; then state=finished; exit_code=$(cat "$job_dir/exit_code"); '
553
+ 'elif [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then state=running; exit_code=\'\'; '
554
+ "else state=unknown; exit_code=''; fi; "
555
+ 'printf \'STATE=%s\nPID=%s\nEXIT=%s\n---LOG---\n\' "$state" "$pid" "$exit_code"; '
556
+ f'tail -n {safe_tail} "$job_dir/stdout.log" 2>/dev/null || true'
557
+ )
558
+ result = await self.exec(session_id, script, timeout=15, output_limit=1_000_000, owner=owner)
559
+ metadata, _, log = result.stdout.decode("utf-8", errors="replace").partition("---LOG---\n")
560
+ fields = dict(line.partition("=")[::2] for line in metadata.splitlines() if "=" in line)
561
+ exit_text = fields.get("EXIT", "")
562
+ return {
563
+ "task_id": task.task_id,
564
+ "session_id": session_id,
565
+ "host_id": task.host_id,
566
+ "command": task.command,
567
+ "job_dir": task.job_dir,
568
+ "state": fields.get("STATE", "unknown"),
569
+ "pid": fields.get("PID") or task.remote_pid,
570
+ "exit_code": int(exit_text) if exit_text.isdigit() else None,
571
+ "log_tail": log,
572
+ }
573
+
574
+ async def cancel_task(self, session_id: str, task_id: str, *, owner: str | None = None) -> dict[str, object]:
575
+ task = await self._task_for_request(session_id, task_id, owner)
576
+ if task.remote_pid is None:
577
+ raise ServiceError("remote_error", f"task {task_id} has no recorded remote pid")
578
+ script = (
579
+ f"{remote_job_dir_assignment(task.task_id)}; "
580
+ 'if [ -f "$job_dir/exit_code" ]; then printf already; '
581
+ f"elif kill -TERM -- -{task.remote_pid} 2>/dev/null; then "
582
+ 'attempt=0; while kill -0 -- -$(cat "$job_dir/pid") 2>/dev/null && [ "$attempt" -lt 20 ]; do '
583
+ "attempt=$((attempt + 1)); sleep 0.05; done; "
584
+ 'if kill -0 -- -$(cat "$job_dir/pid") 2>/dev/null; then '
585
+ 'kill -KILL -- -$(cat "$job_dir/pid") 2>/dev/null || true; fi; '
586
+ 'printf \'143\n\' > "$job_dir/.exit_code.tmp"; mv "$job_dir/.exit_code.tmp" "$job_dir/exit_code"; '
587
+ "printf killed; else printf noop; fi"
588
+ )
589
+ result = await self.exec(session_id, script, timeout=10, output_limit=4096, owner=owner)
590
+ outcome = (
591
+ result.stdout.decode("utf-8", errors="replace").strip().splitlines()[-1]
592
+ if result.stdout.strip()
593
+ else "unknown"
594
+ )
595
+ self._emit(
596
+ "task_cancelled",
597
+ session_id=session_id,
598
+ task_id=task_id,
599
+ host_id=task.host_id,
600
+ owner_agent_id=owner,
601
+ outcome=outcome,
602
+ )
603
+ return {"task_id": task_id, "stopped": outcome in {"killed", "already"}, "outcome": outcome}
604
+
605
+ async def reap_idle(self) -> int:
606
+ if self._idle_timeout_seconds <= 0:
607
+ return 0
608
+ now = time.time()
609
+ async with self._lock:
610
+ expired = tuple(
611
+ session
612
+ for session in self._sessions.values()
613
+ if not session.closing
614
+ and session.active_operations == 0
615
+ and now - session.last_used_at > self._idle_timeout_seconds
616
+ )
617
+ closed = 0
618
+ for session in expired:
619
+ try:
620
+ await self.close_session(session.session_id, force=True)
621
+ except ServiceError:
622
+ logger.exception("failed to reap idle HostBridge session %s", session.session_id)
623
+ else:
624
+ closed += 1
625
+ self._emit(
626
+ "session_idle_closed",
627
+ session_id=session.session_id,
628
+ host_id=session.host_id,
629
+ owner_agent_id=session.owner,
630
+ outcome="expired",
631
+ )
632
+ return closed
633
+
634
+ async def shell_write(self, session_id: str, data: bytes, *, owner: str | None = None) -> int:
635
+ if not isinstance(data, bytes):
636
+ raise ServiceError("invalid_request", "shell data must be bytes")
637
+ async with self._operation(session_id, owner) as session:
638
+ limit = self.hosts.get(session.host_id).limits.max_shell_bytes
639
+ if len(data) > limit:
640
+ raise ServiceError("resource_limit", f"shell write exceeds {limit} bytes")
641
+ try:
642
+ await session.lease.shell_write(data)
643
+ except TunnelError as exc:
644
+ raise ServiceError(exc.code, str(exc), retryable=exc.retryable) from exc
645
+ return len(data)
646
+
647
+ async def shell_read(
648
+ self,
649
+ session_id: str,
650
+ *,
651
+ timeout: float | None,
652
+ max_bytes: int,
653
+ owner: str | None = None,
654
+ ) -> ShellReadResult:
655
+ if timeout is not None and timeout < 0:
656
+ raise ServiceError("invalid_request", "timeout must be non-negative")
657
+ if max_bytes <= 0:
658
+ raise ServiceError("invalid_request", "max_bytes must be positive")
659
+ async with self._operation(session_id, owner) as session:
660
+ limit = min(max_bytes, self.hosts.get(session.host_id).limits.max_shell_bytes)
661
+ try:
662
+ return await session.lease.shell_read(timeout=timeout, max_bytes=limit)
663
+ except TunnelError as exc:
664
+ raise ServiceError(exc.code, str(exc), retryable=exc.retryable) from exc
665
+
666
+ async def attach_shell(
667
+ self,
668
+ session_id: str,
669
+ stdin: ByteStream,
670
+ *,
671
+ max_bytes: int,
672
+ owner: str | None = None,
673
+ ) -> ShellAttachment:
674
+ if max_bytes <= 0:
675
+ raise ServiceError("invalid_request", "max_bytes must be positive")
676
+ terminal: asyncio.Future[ShellTerminalResult] = asyncio.get_running_loop().create_future()
677
+
678
+ async def output() -> AsyncGenerator[bytes, None]:
679
+ try:
680
+ async with self._operation(session_id, owner) as session:
681
+ limit = min(max_bytes, self.hosts.get(session.host_id).limits.max_shell_bytes)
682
+ attachment = await session.lease.attach_shell(stdin, max_bytes=limit)
683
+ async for chunk in attachment:
684
+ yield chunk
685
+ terminal.set_result(await attachment.finish())
686
+ except TunnelError as exc:
687
+ error = ServiceError(exc.code, str(exc), retryable=exc.retryable)
688
+ if not terminal.done():
689
+ terminal.set_exception(error)
690
+ terminal.exception()
691
+ raise error from exc
692
+ except BaseException as exc:
693
+ if not terminal.done():
694
+ terminal.set_exception(exc)
695
+ terminal.exception()
696
+ raise
697
+
698
+ return ShellAttachment(output(), terminal)
699
+
700
+ @asynccontextmanager
701
+ async def _operation(self, session_id: str, owner: str | None) -> AsyncGenerator[RuntimeServiceSession, None]:
702
+ operation = asyncio.current_task()
703
+ if operation is None:
704
+ raise RuntimeError("HostBridge operation is not running in an asyncio task")
705
+ async with self._lock:
706
+ session = self._sessions.get(session_id)
707
+ if session is None:
708
+ raise ServiceError("not_found", f"unknown session {session_id}")
709
+ self._check_owner(session, owner)
710
+ if session.closing:
711
+ raise ServiceError("busy", f"session {session_id} is closing", retryable=True)
712
+ capacity = max(1, session.lease.capabilities.parallel_channels)
713
+ if session.active_operations >= capacity:
714
+ raise ServiceError("busy", f"session {session_id} reached its channel capacity", retryable=True)
715
+ session.active_operations += 1
716
+ session.operation_tasks.add(operation)
717
+ try:
718
+ yield session
719
+ finally:
720
+ async with self._lock:
721
+ session.active_operations -= 1
722
+ session.operation_tasks.discard(operation)
723
+ session.last_used_at = time.time()
724
+
725
+ async def _exec_on_session(
726
+ self,
727
+ session: RuntimeServiceSession,
728
+ command: str,
729
+ *,
730
+ stdin: ByteStream | None,
731
+ timeout: float,
732
+ output_limit: int,
733
+ ) -> ServiceExecResult:
734
+ limits = self.hosts.get(session.host_id).limits
735
+ timeout = min(timeout, limits.command_timeout_seconds)
736
+ output_limit = min(output_limit, limits.max_output_bytes, MAX_EXEC_OUTPUT_BYTES)
737
+ bounded_stdin = None if stdin is None else self._bounded_chunks(stdin, limits.max_stdin_bytes, "command stdin")
738
+ try:
739
+ result: ExecResult = await session.lease.exec(
740
+ command,
741
+ stdin=bounded_stdin,
742
+ timeout=timeout,
743
+ output_limit=output_limit,
744
+ )
745
+ except TunnelError as exc:
746
+ raise ServiceError(exc.code, str(exc), retryable=exc.retryable) from exc
747
+ stdout, stderr = self._limit_combined_output(result.stdout, result.stderr, output_limit)
748
+ return ServiceExecResult(session.session_id, result.exit_code, stdout, stderr, result.timed_out)
749
+
750
+ async def _cleanup_remote_tasks(
751
+ self,
752
+ session: RuntimeServiceSession,
753
+ tasks: tuple[ServiceTask, ...],
754
+ ) -> None:
755
+ command = build_remote_tasks_cleanup_command((task.task_id, task.remote_pid) for task in tasks)
756
+ limits = self.hosts.get(session.host_id).limits
757
+ try:
758
+ result = await session.lease.exec(
759
+ command,
760
+ stdin=None,
761
+ timeout=min(10, limits.command_timeout_seconds),
762
+ output_limit=4096,
763
+ )
764
+ except TunnelError as exc:
765
+ raise ServiceError(exc.code, str(exc), retryable=exc.retryable) from exc
766
+ if result.timed_out or result.exit_code != 0:
767
+ raise ServiceError("remote_error", "failed to clean up remote tasks", retryable=True)
768
+
769
+ def _check_command_policy(self, command: str) -> None:
770
+ if self._policy is None:
771
+ return
772
+ decision = self._policy.evaluate(command)
773
+ if not decision.allowed:
774
+ raise ServiceError("denied", decision.reason, details={"matched_pattern": decision.matched_pattern})
775
+
776
+ def _ensure_idle_reaper_locked(self) -> None:
777
+ if self._idle_timeout_seconds <= 0 or self._reaper_task is not None:
778
+ return
779
+ self._reaper_task = asyncio.create_task(
780
+ self._idle_reaper_loop(),
781
+ name="hostbridge-session-idle-reaper",
782
+ )
783
+
784
+ async def _idle_reaper_loop(self) -> None:
785
+ while True:
786
+ await asyncio.sleep(self._reaper_interval_seconds)
787
+ await self.reap_idle()
788
+
789
+ def _emit(self, event: str, **fields: object) -> None:
790
+ if self._audit is None:
791
+ return
792
+ try:
793
+ audit_event(
794
+ self._audit,
795
+ event=event,
796
+ **{key: value for key, value in fields.items() if value is not None}, # type: ignore[arg-type]
797
+ )
798
+ except Exception:
799
+ logger.exception("audit event could not be written: %s", event)
800
+
801
+ @staticmethod
802
+ async def _close_lease_quietly(lease: HostRuntimeLease) -> None:
803
+ with suppress(BaseException):
804
+ await lease.close()
805
+
806
+ async def _bounded_chunks(
807
+ self,
808
+ chunks: ByteStream,
809
+ limit: int,
810
+ label: str,
811
+ ) -> AsyncGenerator[bytes, None]:
812
+ transferred = 0
813
+ async for chunk in iterate_byte_stream(chunks, label=label):
814
+ transferred += len(chunk)
815
+ if transferred > limit:
816
+ raise ServiceError("resource_limit", f"{label} exceeds {limit} bytes")
817
+ yield chunk
818
+
819
+ async def _session_for_request(self, session_id: str, owner: str | None) -> RuntimeServiceSession:
820
+ async with self._lock:
821
+ session = self._sessions.get(session_id)
822
+ if session is None:
823
+ raise ServiceError("not_found", f"unknown session {session_id}")
824
+ self._check_owner(session, owner)
825
+ if session.closing:
826
+ raise ServiceError("busy", f"session {session_id} is closing", retryable=True)
827
+ return session
828
+
829
+ async def _task_for_request(self, session_id: str, task_id: str, owner: str | None) -> ServiceTask:
830
+ await self._session_for_request(session_id, owner)
831
+ async with self._lock:
832
+ task = self._tasks.get(task_id)
833
+ if task is None or task.session_id != session_id:
834
+ raise ServiceError("not_found", f"unknown task {task_id}")
835
+ if task.owner is not None and task.owner != owner:
836
+ raise ServiceError("denied", f"task {task_id} is owned by another agent")
837
+ return task
838
+
839
+ def _release_pending_locked(self, host_id: str) -> None:
840
+ pending = self._pending_sessions.get(host_id, 0)
841
+ if pending <= 1:
842
+ self._pending_sessions.pop(host_id, None)
843
+ else:
844
+ self._pending_sessions[host_id] = pending - 1
845
+
846
+ @staticmethod
847
+ def _check_owner(session: RuntimeServiceSession, owner: str | None) -> None:
848
+ if session.owner is not None and session.owner != owner:
849
+ raise ServiceError("denied", f"session {session.session_id} is owned by another agent")
850
+
851
+ @staticmethod
852
+ def _limit_combined_output(stdout: bytes, stderr: bytes, limit: int) -> tuple[bytes, bytes]:
853
+ if len(stdout) + len(stderr) <= limit:
854
+ return stdout, stderr
855
+ stdout_keep = min(len(stdout), (limit + 1) // 2)
856
+ stderr_keep = min(len(stderr), limit - stdout_keep)
857
+ remaining = limit - stdout_keep - stderr_keep
858
+ if remaining:
859
+ extra_stdout = min(len(stdout) - stdout_keep, remaining)
860
+ stdout_keep += extra_stdout
861
+ stderr_keep += min(len(stderr) - stderr_keep, remaining - extra_stdout)
862
+ return stdout[-stdout_keep:] if stdout_keep else b"", stderr[-stderr_keep:] if stderr_keep else b""