@neoline/hostbridge 2.0.3 → 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 (48) hide show
  1. package/README.md +24 -15
  2. package/bin/hostbridge.js +80 -9
  3. package/docs/ARCHITECTURE.md +62 -0
  4. package/examples/claude_desktop_config.json +1 -1
  5. package/examples/codex.config.toml +1 -1
  6. package/examples/hosts.example.json +1 -0
  7. package/package.json +2 -1
  8. package/pyproject.toml +5 -4
  9. package/src/server_control_mcp/__init__.py +60 -24
  10. package/src/server_control_mcp/async_lifecycle.py +41 -0
  11. package/src/server_control_mcp/cli.py +2 -2
  12. package/src/server_control_mcp/client.py +345 -238
  13. package/src/server_control_mcp/config.py +18 -6
  14. package/src/server_control_mcp/daemon.py +309 -412
  15. package/src/server_control_mcp/doctor.py +41 -5
  16. package/src/server_control_mcp/hosts.py +252 -24
  17. package/src/server_control_mcp/mock_ssh.py +97 -960
  18. package/src/server_control_mcp/mock_ssh_exec.py +299 -0
  19. package/src/server_control_mcp/mock_ssh_session.py +69 -0
  20. package/src/server_control_mcp/mock_ssh_sftp.py +604 -0
  21. package/src/server_control_mcp/mock_ssh_tunnel.py +289 -0
  22. package/src/server_control_mcp/mux_connection.py +326 -0
  23. package/src/server_control_mcp/mux_daemon.py +186 -0
  24. package/src/server_control_mcp/mux_protocol.py +279 -0
  25. package/src/server_control_mcp/mux_records.py +71 -0
  26. package/src/server_control_mcp/mux_rpc.py +1779 -0
  27. package/src/server_control_mcp/mux_service.py +506 -0
  28. package/src/server_control_mcp/mux_stream.py +283 -0
  29. package/src/server_control_mcp/mux_sync_client.py +224 -0
  30. package/src/server_control_mcp/policy.py +86 -14
  31. package/src/server_control_mcp/remote_agent_bundle.py +56 -0
  32. package/src/server_control_mcp/remote_mux_agent.py +769 -0
  33. package/src/server_control_mcp/remote_task_agent.py +102 -0
  34. package/src/server_control_mcp/runtime.py +3 -2
  35. package/src/server_control_mcp/runtime_services.py +862 -0
  36. package/src/server_control_mcp/secrets.py +6 -6
  37. package/src/server_control_mcp/secure_files.py +121 -0
  38. package/src/server_control_mcp/server.py +53 -20
  39. package/src/server_control_mcp/services.py +3 -469
  40. package/src/server_control_mcp/transports/__init__.py +4 -8
  41. package/src/server_control_mcp/transports/base.py +60 -38
  42. package/src/server_control_mcp/transports/ssh.py +315 -84
  43. package/src/server_control_mcp/tunnel_manager.py +1245 -0
  44. package/src/server_control_mcp/tunnel_native_ssh.py +454 -0
  45. package/src/server_control_mcp/tunnel_providers.py +20 -0
  46. package/src/server_control_mcp/tunnel_pty_agent.py +909 -0
  47. package/src/server_control_mcp/protocol.py +0 -362
  48. package/src/server_control_mcp/transports/pty.py +0 -434
@@ -1,21 +1,10 @@
1
- """Daemon-owned HostBridge session and execution services."""
1
+ """Shared service errors and result models."""
2
2
 
3
3
  from __future__ import annotations
4
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
5
+ from dataclasses import dataclass
13
6
 
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]
7
+ MAX_EXEC_OUTPUT_BYTES = 3 * 1024 * 1024 - 64 * 1024
19
8
 
20
9
 
21
10
  class ServiceError(RuntimeError):
@@ -33,32 +22,6 @@ class ServiceError(RuntimeError):
33
22
  self.details = details or {}
34
23
 
35
24
 
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
25
  @dataclass(frozen=True, slots=True)
63
26
  class ServiceExecResult:
64
27
  session_id: str
@@ -77,432 +40,3 @@ class ServiceTask:
77
40
  job_dir: str
78
41
  remote_pid: int | None
79
42
  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]
@@ -1,17 +1,13 @@
1
- """Remote transport implementations used by HostBridge daemon services."""
1
+ """Async transport primitives used by HostBridge host runtimes."""
2
2
 
3
- from .base import ExecResult, ShellReadResult, TransferResult, Transport, TransportBusy, TransportCapabilities
4
- from .pty import PtyTransport
5
- from .ssh import SshConnectionConfig, SshTransport
3
+ from .base import ExecResult, ShellReadResult, TransferResult, TransportCapabilities
4
+ from .ssh import AsyncSshConnection, SshConnectionConfig
6
5
 
7
6
  __all__ = [
8
7
  "ExecResult",
9
- "PtyTransport",
8
+ "AsyncSshConnection",
10
9
  "SshConnectionConfig",
11
- "SshTransport",
12
10
  "ShellReadResult",
13
11
  "TransferResult",
14
- "Transport",
15
- "TransportBusy",
16
12
  "TransportCapabilities",
17
13
  ]
@@ -2,17 +2,40 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from collections.abc import Iterable, Iterator
5
+ import asyncio
6
+ from collections.abc import AsyncGenerator, AsyncIterable, AsyncIterator, Awaitable, Iterable, Iterator, Mapping
6
7
  from dataclasses import dataclass
7
- from typing import Protocol
8
8
 
9
9
 
10
10
  class TransportError(RuntimeError):
11
11
  """Base error raised by a remote transport."""
12
12
 
13
13
 
14
- class TransportBusy(TransportError):
15
- """Raised when an ordered transport already has an active operation."""
14
+ ByteStream = AsyncIterable[bytes] | Iterable[bytes]
15
+
16
+
17
+ async def iterate_byte_stream(chunks: ByteStream, *, label: str) -> AsyncGenerator[bytes, None]:
18
+ if isinstance(chunks, AsyncIterable):
19
+ async for chunk in chunks:
20
+ if not isinstance(chunk, bytes):
21
+ raise TypeError(f"{label} chunks must be bytes")
22
+ yield chunk
23
+ return
24
+ iterator = iter(chunks)
25
+ while True:
26
+ present, chunk = await asyncio.to_thread(_next_chunk, iterator)
27
+ if not present:
28
+ return
29
+ if not isinstance(chunk, bytes):
30
+ raise TypeError(f"{label} chunks must be bytes")
31
+ yield chunk
32
+
33
+
34
+ def _next_chunk(iterator: Iterator[bytes]) -> tuple[bool, bytes]:
35
+ try:
36
+ return True, next(iterator)
37
+ except StopIteration:
38
+ return False, b""
16
39
 
17
40
 
18
41
  @dataclass(frozen=True, slots=True)
@@ -42,37 +65,36 @@ class ShellReadResult:
42
65
  alive: bool
43
66
 
44
67
 
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
68
+ @dataclass(frozen=True, slots=True)
69
+ class ShellTerminalResult:
70
+ exit_code: int | None
71
+ signal: str | None = None
72
+
73
+ def __post_init__(self) -> None:
74
+ if self.exit_code is not None and (
75
+ not isinstance(self.exit_code, int) or isinstance(self.exit_code, bool) or not 0 <= self.exit_code <= 255
76
+ ):
77
+ raise ValueError("shell exit_code must be between 0 and 255 or null")
78
+ if self.signal is not None and (not isinstance(self.signal, str) or not self.signal.strip()):
79
+ raise ValueError("shell signal must be a non-empty string or null")
80
+ if self.exit_code is not None and self.signal is not None:
81
+ raise ValueError("shell terminal result cannot contain both exit_code and signal")
82
+
83
+ @classmethod
84
+ def from_mapping(cls, value: Mapping[str, object]) -> ShellTerminalResult:
85
+ return cls(value.get("exit_code"), value.get("signal")) # type: ignore[arg-type]
86
+
87
+ def to_mapping(self) -> dict[str, object]:
88
+ return {"exit_code": self.exit_code, "signal": self.signal}
89
+
90
+
91
+ class ShellAttachment:
92
+ def __init__(self, output: AsyncIterator[bytes], terminal: Awaitable[ShellTerminalResult]) -> None:
93
+ self._output = output
94
+ self._terminal = terminal
95
+
96
+ def __aiter__(self) -> AsyncIterator[bytes]:
97
+ return self._output
98
+
99
+ async def finish(self) -> ShellTerminalResult:
100
+ return await self._terminal