@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,115 +1,32 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
- import base64
5
- import binascii
6
- import json
7
- import posixpath
8
- import re
9
- import shlex
10
4
  import sys
11
- import tempfile
12
- import threading
13
- import time
14
- import uuid
15
- from collections import deque
16
5
  from collections.abc import Awaitable
17
- from contextlib import suppress
18
- from dataclasses import dataclass, field
19
- from functools import partial
20
6
  from pathlib import Path
21
7
  from typing import Any, cast
22
8
 
23
9
  import asyncssh
24
10
 
25
11
  from .client import HostBridgeClient, HostBridgeError
12
+ from .mock_ssh_exec import HostBridgeSSHProcess
13
+ from .mock_ssh_session import HostBridgeSessionFactory
14
+ from .mock_ssh_sftp import HostBridgeSFTPServer
15
+ from .mock_ssh_tunnel import TunnelConnector
16
+ from .mux_rpc import MuxRpcClient
17
+ from .secure_files import create_private_file, ensure_private_directory, replace_private_file, validate_private_file
26
18
 
27
19
  DEFAULT_PORT_RANGE = range(2222, 2300)
28
20
  DEFAULT_COMMAND_TIMEOUT = 60
29
21
  DEFAULT_MAX_TRANSFER_BYTES = 1024 * 1024 * 1024
30
- EXEC_STDIN_PROBE_TIMEOUT = 0.05
31
- STDIN_READING_COMMANDS = [
32
- re.compile(r"\bcat\s*(?:>|>>)"),
33
- re.compile(r"\btee(?:\s|$)"),
34
- re.compile(r"(?:^|\s)(?:/bin/)?(?:sh|bash|dash|zsh)\b[^\n;]*\s-s(?:\s|$)"),
35
- re.compile(r"(?:^|\s)(?:python[\d.]*|node)(?:\s+\S+)*\s+-($|\s)"),
36
- re.compile(r"(?:^|\s)tar\s+(?:-[A-Za-z]*x[A-Za-z]*|[A-Za-z]*x[A-Za-z]*)(?:\s|$)"),
37
- re.compile(r"(?:^|\s)tar\s+.*(?:^|\s)f\s*-(?:\s|$)"),
38
- re.compile(r"(?:^|\s)dd\s+.*\bof="),
39
- re.compile(r"(?:^|\s)scp(?:\s+\S+)*\s+-[A-Za-z]*(?:t|f)[A-Za-z]*(?:\s|$)"),
40
- ]
41
- MAX_IDLE_BACKEND_SESSIONS = 4
42
- TUNNEL_FRAME_BYTES = 2048
43
- TUNNEL_READ_BYTES = 32 * 1024
44
- TUNNEL_MAX_BUFFER_BYTES = 128 * 1024
45
-
46
- _REMOTE_TUNNEL_SCRIPT = r"""
47
- import base64
48
- import os
49
- import socket
50
- import sys
51
- import threading
52
-
53
- host, port, prefix = sys.argv[1], int(sys.argv[2]), sys.argv[3]
54
- write_lock = threading.Lock()
55
-
56
- def emit(kind, payload=b""):
57
- suffix = ":" + base64.b64encode(payload).decode("ascii") if payload else ""
58
- with write_lock:
59
- print(prefix + ":" + kind + suffix, flush=True)
60
-
61
- try:
62
- connection = socket.create_connection((host, port), timeout=15)
63
- connection.settimeout(None)
64
- except Exception as exc:
65
- emit("ERROR", str(exc).encode("utf-8", errors="replace"))
66
- raise SystemExit(1)
67
-
68
- emit("READY")
69
-
70
- def receive():
71
- try:
72
- while True:
73
- data = connection.recv(2048)
74
- if not data:
75
- emit("EOF")
76
- os._exit(0)
77
- emit("DATA", data)
78
- except Exception as exc:
79
- emit("ERROR", str(exc).encode("utf-8", errors="replace"))
80
- os._exit(1)
81
-
82
- receiver = threading.Thread(target=receive, daemon=True)
83
- receiver.start()
84
- try:
85
- for raw_line in sys.stdin.buffer:
86
- line = raw_line.strip()
87
- if line == (prefix + ":EOF").encode("ascii"):
88
- connection.shutdown(socket.SHUT_WR)
89
- receiver.join()
90
- break
91
- data_prefix = (prefix + ":DATA:").encode("ascii")
92
- if line.startswith(data_prefix):
93
- connection.sendall(base64.b64decode(line[len(data_prefix):], validate=True))
94
- finally:
95
- connection.close()
96
- """
97
-
98
-
99
- def _to_bytes(chunk: str | bytes) -> bytes:
100
- if isinstance(chunk, bytes):
101
- return chunk
102
- return chunk.encode("utf-8")
103
-
104
-
105
- def _command_reads_stdin(command: str | None) -> bool:
106
- return bool(command and any(pattern.search(command) for pattern in STDIN_READING_COMMANDS))
22
+ DEFAULT_SFTP_MAX_OPEN_HANDLES = 16
23
+ DEFAULT_SFTP_MAX_TEMP_BYTES = 1024 * 1024 * 1024
107
24
 
108
25
 
109
26
  class HostBridgeSSHServer(asyncssh.SSHServer):
110
- def __init__(self, authorized_public_key: str, tcp_forwarder: HostBridgeTCPForwarder | None = None) -> None:
27
+ def __init__(self, authorized_public_key: str, tunnel_connector: TunnelConnector | None = None) -> None:
111
28
  self._authorized_public_key = authorized_public_key.strip()
112
- self._tcp_forwarder = tcp_forwarder
29
+ self._tunnel_connector = tunnel_connector
113
30
 
114
31
  def begin_auth(self, username: str) -> bool: # noqa: ARG002
115
32
  return True
@@ -130,851 +47,29 @@ class HostBridgeSSHServer(asyncssh.SSHServer):
130
47
  orig_host: str, # noqa: ARG002
131
48
  orig_port: int, # noqa: ARG002
132
49
  ) -> Awaitable[asyncssh.SSHTCPSession[bytes]] | bool:
133
- if self._tcp_forwarder is None:
50
+ if self._tunnel_connector is None:
134
51
  return False
135
52
  try:
136
- self._tcp_forwarder.validate_destination(dest_host, dest_port)
53
+ self._tunnel_connector.validate_destination(dest_host, dest_port)
137
54
  except ValueError as exc:
138
55
  raise asyncssh.ChannelOpenError(asyncssh.OPEN_CONNECT_FAILED, str(exc)) from exc
139
- return self._tcp_forwarder.connect(dest_host, dest_port)
140
-
141
-
142
- class HostBridgeSSHProcess:
143
- def __init__(self, client: HostBridgeClient, session_factory: HostBridgeSessionFactory, timeout: int, output_limit: int) -> None:
144
- self._client = client
145
- self._session_factory = session_factory
146
- self._timeout = timeout
147
- self._output_limit = output_limit
148
-
149
- async def __call__(self, process: asyncssh.SSHServerProcess[bytes]) -> None:
150
- session_id = self._session_factory.open()
151
- reusable = False
152
- try:
153
- reusable = await self._run(process, session_id)
154
- except asyncio.CancelledError:
155
- self._set_exit_status(process, 255)
156
- return
157
- finally:
158
- self._session_factory.close(session_id, reusable=reusable)
159
-
160
- async def _run(self, process: asyncssh.SSHServerProcess[bytes], session_id: str) -> bool:
161
- command = process.command
162
- if not command:
163
- await self._run_shell(process, session_id)
164
- return False
165
-
166
- loop = asyncio.get_running_loop()
167
- try:
168
- stdin_data = await self._read_exec_stdin(process, command)
169
- result = await loop.run_in_executor(
170
- None,
171
- lambda: self._client.exec(
172
- session_id,
173
- command,
174
- stdin=stdin_data,
175
- timeout=self._timeout,
176
- output_limit=self._output_limit,
177
- owner="mock-ssh",
178
- ),
179
- )
180
- if result.stdout:
181
- process.stdout.write(result.stdout)
182
- if result.stderr:
183
- process.stderr.write(result.stderr)
184
- self._set_exit_status(process, 124 if result.timed_out else int(result.exit_code or 0))
185
- return not result.timed_out
186
- except Exception as exc:
187
- print(f"hostbridge mock-ssh exec failed: {exc}", file=sys.stderr, flush=True)
188
- with suppress(Exception):
189
- process.stderr.write(f"hostbridge mock-ssh error: {exc}\n".encode("utf-8", errors="replace"))
190
- status = 124 if isinstance(exc, HostBridgeError) and exc.code == "timeout" else 1
191
- self._set_exit_status(process, status)
192
- return False
193
-
194
- @staticmethod
195
- def _set_exit_status(process: asyncssh.SSHServerProcess[bytes], status: int) -> None:
196
- with suppress(Exception):
197
- process.exit(status)
198
-
199
- async def _read_exec_stdin(self, process: asyncssh.SSHServerProcess[bytes], command: str) -> bytes | None:
200
- waits_for_eof = _command_reads_stdin(command)
201
- if process.stdin.at_eof():
202
- return b"" if waits_for_eof else None
203
- try:
204
- first_chunk = await asyncio.wait_for(
205
- process.stdin.read(4096),
206
- timeout=self._timeout if waits_for_eof else EXEC_STDIN_PROBE_TIMEOUT,
207
- )
208
- except TimeoutError:
209
- if waits_for_eof:
210
- raise HostBridgeError("timeout", "timed out waiting for mock-ssh exec stdin") from None
211
- return None
212
- if not first_chunk:
213
- return b"" if waits_for_eof else None
214
-
215
- first_chunk_bytes = _to_bytes(first_chunk)
216
- total = len(first_chunk_bytes)
217
- if total > self._output_limit:
218
- raise HostBridgeError("resource_limit", f"mock-ssh exec stdin exceeds limit ({self._output_limit} bytes)")
219
- chunks = [first_chunk_bytes]
220
- while not process.stdin.at_eof():
221
- chunk = await asyncio.wait_for(process.stdin.read(4096), timeout=self._timeout)
222
- if not chunk:
223
- break
224
- chunk_bytes = _to_bytes(chunk)
225
- total += len(chunk_bytes)
226
- if total > self._output_limit:
227
- raise HostBridgeError("resource_limit", f"mock-ssh exec stdin exceeds limit ({self._output_limit} bytes)")
228
- chunks.append(chunk_bytes)
229
- return b"".join(chunks)
230
-
231
- async def _run_shell(self, process: asyncssh.SSHServerProcess[bytes], session_id: str) -> None:
232
- loop = asyncio.get_running_loop()
233
- stop = asyncio.Event()
234
- input_done = asyncio.Event()
235
-
236
- async def pump_input() -> None:
237
- try:
238
- while not process.stdin.at_eof():
239
- data = await process.stdin.read(4096)
240
- if not data:
241
- break
242
- await loop.run_in_executor(
243
- None,
244
- partial(
245
- self._client.shell_write,
246
- session_id,
247
- _to_bytes(data),
248
- owner="mock-ssh",
249
- ),
250
- )
251
- finally:
252
- input_done.set()
253
-
254
- async def pump_output() -> None:
255
- try:
256
- idle_after_input = 0
257
- while not stop.is_set():
258
- result = await loop.run_in_executor(
259
- None,
260
- lambda: self._client.shell_read(
261
- session_id,
262
- timeout=0.2,
263
- max_bytes=4096,
264
- owner="mock-ssh",
265
- ),
266
- )
267
- if result.data:
268
- idle_after_input = 0
269
- process.stdout.write(result.data)
270
- elif input_done.is_set():
271
- idle_after_input += 1
272
- if idle_after_input >= 5:
273
- stop.set()
274
- break
275
- if not result.alive:
276
- stop.set()
277
- break
278
- except Exception as exc:
279
- print(f"hostbridge mock-ssh shell failed: {exc}", file=sys.stderr, flush=True)
280
- with suppress(Exception):
281
- process.stderr.write(f"hostbridge mock-ssh shell error: {exc}\n".encode("utf-8", errors="replace"))
282
- stop.set()
283
-
284
- tasks = [asyncio.create_task(pump_input()), asyncio.create_task(pump_output())]
285
- await stop.wait()
286
- for task in tasks:
287
- task.cancel()
288
- await asyncio.gather(*tasks, return_exceptions=True)
289
- self._set_exit_status(process, 0)
290
-
291
-
292
- @dataclass
293
- class HostBridgeSFTPHandle:
294
- path: str
295
- readable: bool = False
296
- writable: bool = False
297
- data: bytes = b""
298
- writes: dict[int, bytes] = field(default_factory=dict)
299
- mode: int = 0o644
300
-
301
-
302
- @dataclass(frozen=True, slots=True)
303
- class _TextCommandResult:
304
- output: str
305
- exit_code: int | None
306
- timed_out: bool
307
-
308
-
309
- class HostBridgeSessionFactory:
310
- def __init__(self, client: HostBridgeClient, host_id: str, connect_timeout: int = 60) -> None:
311
- self._client = client
312
- self._host_id = host_id
313
- self._connect_timeout = connect_timeout
314
- self._lock = threading.Lock()
315
- self._idle: list[str] = []
316
- self._leased: set[str] = set()
317
- self._shutdown = False
318
-
319
- def open(self) -> str:
320
- with self._lock:
321
- if self._shutdown:
322
- raise RuntimeError("mock-ssh session factory is shut down")
323
- if self._idle:
324
- session_id = self._idle.pop()
325
- self._leased.add(session_id)
326
- return session_id
327
- session = self._client.open_session(
328
- self._host_id,
329
- owner="mock-ssh",
330
- timeout=self._connect_timeout + 5,
331
- )
332
- session_id = session.session_id
333
- with self._lock:
334
- if not self._shutdown:
335
- self._leased.add(session_id)
336
- return session_id
337
- self._close_backend(session_id)
338
- raise RuntimeError("mock-ssh session factory is shut down")
339
-
340
- def close(self, session_id: str, *, reusable: bool = True) -> None:
341
- should_close = False
342
- with self._lock:
343
- if session_id not in self._leased:
344
- return
345
- self._leased.remove(session_id)
346
- if reusable and not self._shutdown and len(self._idle) < MAX_IDLE_BACKEND_SESSIONS:
347
- self._idle.append(session_id)
348
- else:
349
- should_close = True
350
- if not should_close:
351
- return
352
- self._close_backend(session_id)
353
-
354
- def shutdown(self) -> None:
355
- with self._lock:
356
- self._shutdown = True
357
- session_ids = [*self._idle, *self._leased]
358
- self._idle.clear()
359
- self._leased.clear()
360
- for session_id in session_ids:
361
- self._close_backend(session_id)
362
-
363
- def _close_backend(self, session_id: str) -> None:
364
- last_error: Exception | None = None
365
- for attempt in range(2):
366
- try:
367
- self._client.close_session(session_id, owner="mock-ssh", force=True)
368
- return
369
- except HostBridgeError as exc:
370
- if exc.code == "not_found":
371
- return
372
- last_error = exc
373
- except Exception as exc:
374
- last_error = exc
375
- if attempt == 0:
376
- time.sleep(0.05)
377
- print(
378
- f"hostbridge mock-ssh failed to close backend session {session_id}: {last_error}",
379
- file=sys.stderr,
380
- flush=True,
381
- )
382
-
383
-
384
- class _TunnelFrameReader:
385
- def __init__(self, client: HostBridgeClient, session_id: str, prefix: str) -> None:
386
- self._client = client
387
- self._session_id = session_id
388
- self._prefix = prefix.encode("ascii") + b":"
389
- self._buffer = bytearray()
390
- self._pending: deque[tuple[str, bytes]] = deque()
391
-
392
- async def next(self) -> tuple[str, bytes]:
393
- while not self._pending:
394
- result = await asyncio.to_thread(
395
- self._client.shell_read,
396
- self._session_id,
397
- timeout=0.2,
398
- max_bytes=4096,
399
- owner="mock-ssh",
400
- )
401
- if result.data:
402
- self._buffer.extend(result.data)
403
- self._parse_lines()
404
- if not result.alive and not self._pending:
405
- raise HostBridgeError("connection_lost", "remote TCP tunnel closed unexpectedly", retryable=True)
406
- return self._pending.popleft()
407
-
408
- def _parse_lines(self) -> None:
409
- while b"\n" in self._buffer:
410
- raw_line, _, remainder = self._buffer.partition(b"\n")
411
- self._buffer = bytearray(remainder)
412
- line = raw_line.rstrip(b"\r")
413
- marker = line.find(self._prefix)
414
- if marker < 0:
415
- continue
416
- frame = line[marker + len(self._prefix) :]
417
- kind, separator, encoded = frame.partition(b":")
418
- try:
419
- kind_text = kind.decode("ascii")
420
- except UnicodeDecodeError:
421
- continue
422
- if kind_text not in {"READY", "DATA", "EOF", "ERROR", "CLOSED"}:
423
- continue
424
- payload = b""
425
- if separator:
426
- try:
427
- payload = base64.b64decode(encoded, validate=True)
428
- except (binascii.Error, ValueError) as exc:
429
- raise HostBridgeError("protocol_mismatch", "remote TCP tunnel returned invalid base64") from exc
430
- self._pending.append((kind_text, payload))
431
- if len(self._buffer) > TUNNEL_MAX_BUFFER_BYTES:
432
- marker = self._buffer.rfind(self._prefix)
433
- self._buffer = self._buffer[marker:] if marker >= 0 else bytearray()
434
-
435
-
436
- class HostBridgeTCPForwarder:
437
- def __init__(
438
- self,
439
- client: HostBridgeClient,
440
- session_factory: HostBridgeSessionFactory,
441
- *,
442
- connect_timeout: int,
443
- ) -> None:
444
- self._client = client
445
- self._session_factory = session_factory
446
- self._connect_timeout = connect_timeout
447
-
448
- @staticmethod
449
- def validate_destination(dest_host: str, dest_port: int) -> None:
450
- if not isinstance(dest_host, str) or not dest_host.strip() or "\x00" in dest_host or len(dest_host) > 253:
451
- raise ValueError("TCP forwarding destination host is invalid")
452
- if not isinstance(dest_port, int) or isinstance(dest_port, bool) or not 1 <= dest_port <= 65535:
453
- raise ValueError("TCP forwarding destination port must be between 1 and 65535")
454
-
455
- async def connect(self, dest_host: str, dest_port: int) -> asyncssh.SSHTCPSession[bytes]:
456
- self.validate_destination(dest_host, dest_port)
457
- session_id = await asyncio.to_thread(self._session_factory.open)
458
- prefix = f"__HB_TUNNEL_{uuid.uuid4().hex}__"
459
- frames = _TunnelFrameReader(self._client, session_id, prefix)
460
- try:
461
- await self._start_remote(session_id, dest_host, dest_port, prefix)
462
- kind, payload = await asyncio.wait_for(frames.next(), timeout=self._connect_timeout)
463
- if kind == "ERROR":
464
- raise HostBridgeError("connection_lost", payload.decode("utf-8", errors="replace"), retryable=True)
465
- if kind != "READY":
466
- raise HostBridgeError("protocol_mismatch", f"remote TCP tunnel returned {kind} before READY")
467
- except asyncio.CancelledError:
468
- await asyncio.to_thread(self._session_factory.close, session_id, reusable=False)
469
- raise
470
- except Exception as exc:
471
- await asyncio.to_thread(self._session_factory.close, session_id, reusable=False)
472
- raise asyncssh.ChannelOpenError(asyncssh.OPEN_CONNECT_FAILED, str(exc)) from exc
473
- return HostBridgeTCPSession(
474
- self,
475
- session_id=session_id,
476
- prefix=prefix,
477
- frames=frames,
478
- dest_host=dest_host,
479
- dest_port=dest_port,
480
- )
481
-
482
- async def _start_remote(self, session_id: str, dest_host: str, dest_port: int, prefix: str) -> None:
483
- command = (
484
- f"__hb_prefix={shlex.quote(prefix)}; stty -echo; "
485
- f"python3 -u -c {shlex.quote(_REMOTE_TUNNEL_SCRIPT)} "
486
- f"{shlex.quote(dest_host)} {dest_port} \"$__hb_prefix\"; "
487
- "__hb_status=$?; stty echo; printf '\n%s:CLOSED\n' \"$__hb_prefix\"\n"
488
- )
489
- await asyncio.to_thread(
490
- self._client.shell_write,
491
- session_id,
492
- command.encode("utf-8"),
493
- owner="mock-ssh",
494
- )
495
-
496
- async def _send_data(self, session_id: str, prefix: str, payload: bytes) -> None:
497
- lines = [
498
- f"{prefix}:DATA:{base64.b64encode(payload[offset : offset + TUNNEL_FRAME_BYTES]).decode('ascii')}\n"
499
- for offset in range(0, len(payload), TUNNEL_FRAME_BYTES)
500
- ]
501
- await self._send_lines(session_id, lines)
502
-
503
- async def _send_lines(self, session_id: str, lines: list[str]) -> None:
504
- await asyncio.to_thread(
505
- self._client.shell_write,
506
- session_id,
507
- "".join(lines).encode("ascii"),
508
- owner="mock-ssh",
509
- )
510
-
511
-
512
-
513
- _TUNNEL_INPUT_EOF = object()
514
-
515
-
516
- class HostBridgeTCPSession(asyncssh.SSHTCPSession[bytes]):
517
- def __init__(
518
- self,
519
- forwarder: HostBridgeTCPForwarder,
520
- *,
521
- session_id: str,
522
- prefix: str,
523
- frames: _TunnelFrameReader,
524
- dest_host: str,
525
- dest_port: int,
526
- ) -> None:
527
- self._forwarder = forwarder
528
- self._session_id = session_id
529
- self._prefix = prefix
530
- self._frames = frames
531
- self._dest_host = dest_host
532
- self._dest_port = dest_port
533
- self._channel: asyncssh.SSHTCPChannel[bytes] | None = None
534
- self._input: asyncio.Queue[bytes | object] = asyncio.Queue()
535
- self._write_ready = asyncio.Event()
536
- self._write_ready.set()
537
- self._runner: asyncio.Task[None] | None = None
538
- self._orphan_cleanup: asyncio.Task[None] | None = None
539
- self._released = False
540
- self._finished = asyncio.Event()
541
-
542
- def connection_made(self, chan: asyncssh.SSHTCPChannel[bytes]) -> None:
543
- self._channel = chan
544
-
545
- def session_started(self) -> None:
546
- self._runner = asyncio.create_task(self._run())
547
- self._runner.add_done_callback(self._runner_done)
548
-
549
- def data_received(self, data: bytes, datatype: asyncssh.DataType) -> None: # noqa: ARG002
550
- if data:
551
- self._input.put_nowait(bytes(data))
552
-
553
- def eof_received(self) -> bool:
554
- self._input.put_nowait(_TUNNEL_INPUT_EOF)
555
- return True
556
-
557
- def connection_lost(self, exc: Exception | None) -> None: # noqa: ARG002
558
- self.abort()
559
-
560
- def pause_writing(self) -> None:
561
- self._write_ready.clear()
562
-
563
- def resume_writing(self) -> None:
564
- self._write_ready.set()
565
-
566
- def abort(self) -> None:
567
- if self._runner is not None and self._runner is not asyncio.current_task() and not self._runner.done():
568
- self._runner.cancel()
569
- elif self._runner is None:
570
- self._schedule_orphan_cleanup()
571
-
572
- async def wait_finished(self) -> None:
573
- await self._finished.wait()
574
-
575
- def _runner_done(self, task: asyncio.Task[None]) -> None: # noqa: ARG002
576
- if not self._finished.is_set():
577
- self._schedule_orphan_cleanup()
578
-
579
- def _schedule_orphan_cleanup(self) -> None:
580
- if self._orphan_cleanup is None:
581
- self._orphan_cleanup = asyncio.create_task(self._finish_orphaned_session())
582
-
583
- async def _finish_orphaned_session(self) -> None:
584
- await self._release(reusable=False)
585
- if self._channel is not None:
586
- with suppress(Exception):
587
- self._channel.close()
588
- self._finished.set()
589
-
590
- async def _run(self) -> None:
591
- input_task = asyncio.create_task(self._pump_input())
592
- output_task = asyncio.create_task(self._pump_output())
593
- reusable = False
594
- try:
595
- done, _ = await asyncio.wait({input_task, output_task}, return_when=asyncio.FIRST_COMPLETED)
596
- if output_task in done:
597
- input_task.cancel()
598
- await asyncio.gather(input_task, return_exceptions=True)
599
- await output_task
600
- else:
601
- await input_task
602
- await output_task
603
- reusable = True
604
- except asyncio.CancelledError:
605
- raise
606
- except Exception as exc:
607
- print(
608
- f"hostbridge mock-ssh TCP forwarding to {self._dest_host}:{self._dest_port} closed: {exc}",
609
- file=sys.stderr,
610
- flush=True,
611
- )
612
- finally:
613
- for task in (input_task, output_task):
614
- if not task.done():
615
- task.cancel()
616
- await asyncio.gather(input_task, output_task, return_exceptions=True)
617
- await self._release(reusable=reusable)
618
- if self._channel is not None:
619
- with suppress(Exception):
620
- self._channel.close()
621
- self._finished.set()
622
-
623
- async def _release(self, *, reusable: bool) -> None:
624
- if self._released:
625
- return
626
- self._released = True
627
- await asyncio.to_thread(
628
- self._forwarder._session_factory.close,
629
- self._session_id,
630
- reusable=reusable,
631
- )
632
-
633
- async def _pump_input(self) -> None:
634
- while True:
635
- item = await self._input.get()
636
- if item is _TUNNEL_INPUT_EOF:
637
- await self._forwarder._send_lines(self._session_id, [f"{self._prefix}:EOF\n"])
638
- return
639
- await self._forwarder._send_data(self._session_id, self._prefix, cast(bytes, item))
640
-
641
- async def _pump_output(self) -> None:
642
- if self._channel is None:
643
- raise RuntimeError("TCP tunnel channel was not initialized")
644
- saw_eof = False
645
- while True:
646
- kind, payload = await self._frames.next()
647
- if kind == "DATA":
648
- await self._write_ready.wait()
649
- self._channel.write(payload)
650
- elif kind == "EOF":
651
- self._channel.write_eof()
652
- saw_eof = True
653
- elif kind == "CLOSED":
654
- if not saw_eof:
655
- raise HostBridgeError("protocol_mismatch", "remote TCP tunnel closed without EOF")
656
- return
657
- elif kind == "ERROR":
658
- raise HostBridgeError(
659
- "connection_lost",
660
- payload.decode("utf-8", errors="replace") or "remote TCP tunnel failed",
661
- retryable=True,
662
- )
663
-
664
-
665
- class HostBridgeSFTPServer(asyncssh.SFTPServer):
666
- def __init__(
667
- self,
668
- chan: asyncssh.SSHServerChannel,
669
- client: HostBridgeClient,
670
- session_factory: HostBridgeSessionFactory,
671
- timeout: int,
672
- max_bytes: int,
673
- ) -> None:
674
- super().__init__(chan)
675
- self._client = client
676
- self._session_factory = session_factory
677
- self._session_id = session_factory.open()
678
- self._timeout = timeout
679
- self._max_bytes = max_bytes
680
-
681
- def exit(self) -> None:
682
- self._session_factory.close(self._session_id)
683
-
684
- def open(self, path: bytes, pflags: int, attrs: asyncssh.SFTPAttrs) -> HostBridgeSFTPHandle:
685
- remote_path = self._path(path)
686
- readable = bool(pflags & asyncssh.FXF_READ)
687
- writable = bool(pflags & asyncssh.FXF_WRITE)
688
- mode = attrs.permissions if attrs.permissions is not None else 0o644
689
- data = b""
690
- if readable or (writable and not (pflags & asyncssh.FXF_TRUNC) and not (pflags & asyncssh.FXF_CREAT)):
691
- try:
692
- data = self._download(remote_path)
693
- except asyncssh.SFTPNoSuchFile:
694
- if not writable:
695
- raise
696
- data = b""
697
- return HostBridgeSFTPHandle(remote_path, readable=readable, writable=writable, data=data, mode=mode)
698
-
699
- def read(self, file_obj: object, offset: int, size: int) -> bytes:
700
- handle = self._handle(file_obj)
701
- if not handle.readable:
702
- raise asyncssh.SFTPPermissionDenied("file is not open for reading")
703
- return handle.data[offset : offset + size]
704
-
705
- def write(self, file_obj: object, offset: int, data: bytes) -> int:
706
- handle = self._handle(file_obj)
707
- if not handle.writable:
708
- raise asyncssh.SFTPPermissionDenied("file is not open for writing")
709
- handle.writes[int(offset)] = bytes(data)
710
- return len(data)
711
-
712
- def close(self, file_obj: object) -> None:
713
- handle = self._handle(file_obj)
714
- if handle.writable:
715
- self._upload(handle)
716
-
717
- def stat(self, path: bytes) -> asyncssh.SFTPAttrs:
718
- return self._stat(self._path(path), follow=True)
719
-
720
- def lstat(self, path: bytes) -> asyncssh.SFTPAttrs:
721
- return self._stat(self._path(path), follow=False)
722
-
723
- def fstat(self, file_obj: object) -> asyncssh.SFTPAttrs:
724
- handle = self._handle(file_obj)
725
- if handle.writable and handle.writes:
726
- size = len(handle.data)
727
- for offset, chunk in handle.writes.items():
728
- size = max(size, offset + len(chunk))
729
- return asyncssh.SFTPAttrs(size=size, permissions=handle.mode, type=asyncssh.FILEXFER_TYPE_REGULAR)
730
- return self._stat(handle.path, follow=True)
731
-
732
- def fsetstat(self, file_obj: object, attrs: asyncssh.SFTPAttrs) -> None:
733
- handle = self._handle(file_obj)
734
- if attrs.permissions is not None:
735
- handle.mode = attrs.permissions
736
- if attrs.atime is not None or attrs.mtime is not None:
737
- # Applied after upload in close(); the handle may not exist remotely yet.
738
- return
739
-
740
- async def scandir(self, path: bytes):
741
- for item in self._listdir(self._path(path)):
742
- yield item
743
-
744
- def mkdir(self, path: bytes, attrs: asyncssh.SFTPAttrs) -> None:
745
- mode = attrs.permissions if attrs.permissions is not None else 0o755
746
- self._checked_run(f"mkdir -p -m {shlex.quote(format(mode & 0o7777, 'o'))} -- {shlex.quote(self._path(path))}")
747
-
748
- def rmdir(self, path: bytes) -> None:
749
- self._checked_run(f"rmdir -- {shlex.quote(self._path(path))}")
750
-
751
- def remove(self, path: bytes) -> None:
752
- self._checked_run(f"rm -f -- {shlex.quote(self._path(path))}")
753
-
754
- def rename(self, oldpath: bytes, newpath: bytes) -> None:
755
- self._checked_run(f"mv -- {shlex.quote(self._path(oldpath))} {shlex.quote(self._path(newpath))}")
756
-
757
- def realpath(self, path: bytes) -> bytes:
758
- script = (
759
- "import os,sys; "
760
- "path=sys.argv[1]; "
761
- "parent=os.path.dirname(path) or '/'; "
762
- "base=os.path.basename(path); "
763
- "print(os.path.join(os.path.realpath(parent), base) if not os.path.exists(path) else os.path.realpath(path))"
764
- )
765
- command = "python3 -c " + shlex.quote(script) + " " + shlex.quote(self._path(path))
766
- result = self._checked_run(command)
767
- return (result.output.strip() or self._path(path)).encode("utf-8")
768
-
769
- def setstat(self, path: bytes, attrs: asyncssh.SFTPAttrs) -> None:
770
- remote_path = self._path(path)
771
- if attrs.permissions is not None:
772
- self._checked_run(f"chmod {shlex.quote(format(attrs.permissions & 0o7777, 'o'))} -- {shlex.quote(remote_path)}")
773
- if attrs.atime is not None or attrs.mtime is not None:
774
- script = "import os,sys; os.utime(sys.argv[1], (int(sys.argv[2]), int(sys.argv[3])))"
775
- current = self._stat(remote_path, follow=True)
776
- atime = int(attrs.atime if attrs.atime is not None else current.atime or time.time())
777
- mtime = int(attrs.mtime if attrs.mtime is not None else current.mtime or time.time())
778
- self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)} {atime} {mtime}")
779
-
780
- def _download(self, remote_path: str) -> bytes:
781
- temporary_path: Path | None = None
782
- try:
783
- with tempfile.NamedTemporaryFile(prefix="hostbridge-sftp-", delete=False) as temporary:
784
- temporary_path = Path(temporary.name)
785
- result = self._client.download_file(
786
- self._session_id,
787
- remote_path,
788
- temporary_path,
789
- timeout=self._timeout,
790
- owner="mock-ssh",
791
- )
792
- if result.bytes_transferred > self._max_bytes:
793
- raise HostBridgeError("resource_limit", f"file exceeds mock-ssh limit ({self._max_bytes} bytes)")
794
- return temporary_path.read_bytes()
795
- except HostBridgeError as exc:
796
- raise self._sftp_error(exc) from exc
797
- finally:
798
- if temporary_path is not None:
799
- temporary_path.unlink(missing_ok=True)
800
-
801
- def _upload(self, handle: HostBridgeSFTPHandle) -> None:
802
- size = len(handle.data)
803
- for offset, chunk in handle.writes.items():
804
- size = max(size, offset + len(chunk))
805
- data = bytearray(handle.data)
806
- if len(data) < size:
807
- data.extend(b"\x00" * (size - len(data)))
808
- for offset, chunk in sorted(handle.writes.items()):
809
- data[offset : offset + len(chunk)] = chunk
810
- if len(data) > self._max_bytes:
811
- raise asyncssh.SFTPFailure(f"file exceeds mock-ssh limit ({self._max_bytes} bytes)")
812
- temporary_path: Path | None = None
813
- try:
814
- with tempfile.NamedTemporaryFile(prefix="hostbridge-sftp-", delete=False) as temporary:
815
- temporary.write(data)
816
- temporary_path = Path(temporary.name)
817
- self._client.upload_file(
818
- self._session_id,
819
- temporary_path,
820
- handle.path,
821
- mode=handle.mode & 0o7777,
822
- timeout=self._timeout,
823
- owner="mock-ssh",
824
- )
825
- except HostBridgeError as exc:
826
- print(f"hostbridge mock-ssh sftp upload failed: {exc}", file=sys.stderr, flush=True)
827
- raise self._sftp_error(exc) from exc
828
- finally:
829
- if temporary_path is not None:
830
- temporary_path.unlink(missing_ok=True)
831
-
832
- def _stat(self, remote_path: str, *, follow: bool) -> asyncssh.SFTPAttrs:
833
- script = r"""
834
- import json, os, stat, sys
835
- path = sys.argv[1]
836
- st = os.stat(path) if sys.argv[2] == "1" else os.lstat(path)
837
- print(json.dumps({
838
- "size": st.st_size,
839
- "permissions": st.st_mode,
840
- "uid": st.st_uid,
841
- "gid": st.st_gid,
842
- "atime": int(st.st_atime),
843
- "mtime": int(st.st_mtime),
844
- "type": "dir" if stat.S_ISDIR(st.st_mode) else "link" if stat.S_ISLNK(st.st_mode) else "file",
845
- }))
846
- """
847
- result = self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)} {'1' if follow else '0'}")
848
- return self._attrs(json.loads(result.output))
849
-
850
- def _listdir(self, remote_path: str) -> list[asyncssh.SFTPName]:
851
- script = r"""
852
- import json, os, stat, sys, time
853
- path = sys.argv[1]
854
- items = []
855
- for name in os.listdir(path):
856
- full = os.path.join(path, name)
857
- st = os.lstat(full)
858
- item_type = "dir" if stat.S_ISDIR(st.st_mode) else "link" if stat.S_ISLNK(st.st_mode) else "file"
859
- items.append({
860
- "filename": name,
861
- "longname": name,
862
- "attrs": {
863
- "size": st.st_size,
864
- "permissions": st.st_mode,
865
- "uid": st.st_uid,
866
- "gid": st.st_gid,
867
- "atime": int(st.st_atime),
868
- "mtime": int(st.st_mtime),
869
- "type": item_type,
870
- },
871
- })
872
- print(json.dumps(items))
873
- """
874
- result = self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)}")
875
- return [
876
- asyncssh.SFTPName(item["filename"], item["longname"], self._attrs(item["attrs"]))
877
- for item in json.loads(result.output or "[]")
878
- ]
879
-
880
- def _attrs(self, data: dict[str, object]) -> asyncssh.SFTPAttrs:
881
- permissions = self._int_value(data.get("permissions"), 0)
882
- raw_type = data.get("type")
883
- file_type = {
884
- "file": asyncssh.FILEXFER_TYPE_REGULAR,
885
- "dir": asyncssh.FILEXFER_TYPE_DIRECTORY,
886
- "link": asyncssh.FILEXFER_TYPE_SYMLINK,
887
- }.get(str(raw_type), asyncssh.FILEXFER_TYPE_UNKNOWN)
888
- return asyncssh.SFTPAttrs(
889
- type=file_type,
890
- size=self._int_value(data.get("size"), 0),
891
- uid=self._int_value(data.get("uid"), 0),
892
- gid=self._int_value(data.get("gid"), 0),
893
- permissions=permissions,
894
- atime=self._int_value(data.get("atime"), int(time.time())),
895
- mtime=self._int_value(data.get("mtime"), int(time.time())),
896
- )
897
-
898
- @staticmethod
899
- def _int_value(value: object, default: int) -> int:
900
- if value is None:
901
- return default
902
- if isinstance(value, bool) or not isinstance(value, int | float | str):
903
- raise asyncssh.SFTPFailure("remote metadata contains a non-numeric field")
904
- try:
905
- return int(value)
906
- except ValueError as exc:
907
- raise asyncssh.SFTPFailure("remote metadata contains an invalid numeric field") from exc
908
-
909
- def _checked_run(self, command: str):
910
- try:
911
- result = self._client.exec(
912
- self._session_id,
913
- command,
914
- timeout=self._timeout,
915
- owner="mock-ssh",
916
- )
917
- except HostBridgeError as exc:
918
- raise self._sftp_error(exc) from exc
919
- output = result.stdout.decode("utf-8", errors="replace")
920
- stderr = result.stderr.decode("utf-8", errors="replace")
921
- if result.timed_out:
922
- print(f"hostbridge mock-ssh sftp command timed out: {command}", file=sys.stderr, flush=True)
923
- raise asyncssh.SFTPFailure("remote command timed out")
924
- if result.exit_code not in (0, None):
925
- error_output = "\n".join(part for part in (output, stderr) if part).strip()
926
- error = self._sftp_error(
927
- HostBridgeError("remote_error", error_output or f"remote command exited {result.exit_code}")
928
- )
929
- if isinstance(error, asyncssh.SFTPNoSuchFile):
930
- raise error
931
- print(
932
- f"hostbridge mock-ssh sftp command failed exit={result.exit_code}: {command}\n{error_output}",
933
- file=sys.stderr,
934
- flush=True,
935
- )
936
- raise error
937
- return _TextCommandResult(output, result.exit_code, result.timed_out)
938
-
939
- def _path(self, path: bytes) -> str:
940
- text = path.decode("utf-8", "surrogateescape")
941
- if not text:
942
- return "."
943
- return posixpath.normpath(text)
944
-
945
- def _handle(self, file_obj: object) -> HostBridgeSFTPHandle:
946
- if not isinstance(file_obj, HostBridgeSFTPHandle):
947
- raise asyncssh.SFTPInvalidHandle("invalid HostBridge SFTP handle")
948
- return file_obj
949
-
950
- def _sftp_error(self, exc: HostBridgeError) -> Exception:
951
- text = str(exc)
952
- lowered = text.lower()
953
- if "no such file" in lowered or "not found" in lowered or "filenotfounderror" in lowered:
954
- return asyncssh.SFTPNoSuchFile(text)
955
- if "permission denied" in lowered:
956
- return asyncssh.SFTPPermissionDenied(text)
957
- return asyncssh.SFTPFailure(text)
56
+ return self._tunnel_connector.connect(dest_host, dest_port)
958
57
 
959
58
 
960
59
  def ensure_keypair(key_dir: Path) -> tuple[Path, Path, str]:
961
- key_dir.mkdir(parents=True, exist_ok=True)
962
- with _suppress_chmod_error(key_dir):
963
- key_dir.chmod(0o700)
60
+ ensure_private_directory(key_dir)
964
61
  host_key_path = key_dir / "ssh_host_ed25519_key"
965
62
  client_key_path = key_dir / "client_ed25519_key"
966
- if not host_key_path.exists():
967
- host_key = asyncssh.generate_private_key("ssh-ed25519")
968
- host_key_path.write_bytes(host_key.export_private_key())
969
- if not client_key_path.exists():
970
- client_key = asyncssh.generate_private_key("ssh-ed25519")
971
- client_key_path.write_bytes(client_key.export_private_key())
972
- (key_dir / "client_ed25519_key.pub").write_bytes(client_key.export_public_key())
973
- with _suppress_chmod_error(host_key_path):
974
- host_key_path.chmod(0o600)
975
- with _suppress_chmod_error(client_key_path):
976
- client_key_path.chmod(0o600)
977
- public_key = (key_dir / "client_ed25519_key.pub").read_text(encoding="utf-8").strip()
63
+ for path in (host_key_path, client_key_path):
64
+ if path.exists():
65
+ validate_private_file(path)
66
+ continue
67
+ private_key = asyncssh.generate_private_key("ssh-ed25519")
68
+ create_private_file(path, private_key.export_private_key())
69
+ client_key = asyncssh.import_private_key(client_key_path.read_bytes())
70
+ public_key_path = key_dir / "client_ed25519_key.pub"
71
+ replace_private_file(public_key_path, client_key.export_public_key())
72
+ public_key = public_key_path.read_text(encoding="utf-8").strip()
978
73
  return host_key_path, client_key_path, public_key
979
74
 
980
75
 
@@ -1019,7 +114,9 @@ async def create_server_on_available_port(
1019
114
  continue
1020
115
  if port is not None:
1021
116
  raise RuntimeError(f"port {port} is not available on {listen_host}") from last_error
1022
- raise RuntimeError(f"no free port found in {DEFAULT_PORT_RANGE.start}-{DEFAULT_PORT_RANGE.stop - 1}") from last_error
117
+ raise RuntimeError(
118
+ f"no free port found in {DEFAULT_PORT_RANGE.start}-{DEFAULT_PORT_RANGE.stop - 1}"
119
+ ) from last_error
1023
120
 
1024
121
 
1025
122
  def install_ssh_config_block(
@@ -1085,13 +182,15 @@ def _remove_managed_block(existing: str, begin: str, end: str) -> str:
1085
182
  stop += len(end)
1086
183
  while stop < len(existing) and existing[stop] in "\r\n":
1087
184
  stop += 1
1088
- return existing[:start].rstrip() + ("\n" if existing[:start].strip() and existing[stop:].strip() else "") + existing[stop:].lstrip()
185
+ return (
186
+ existing[:start].rstrip()
187
+ + ("\n" if existing[:start].strip() and existing[stop:].strip() else "")
188
+ + existing[stop:].lstrip()
189
+ )
1089
190
 
1090
191
 
1091
192
  def _write_text_atomic(path: Path, text: str) -> None:
1092
- tmp_path = path.with_name(f".{path.name}.hostbridge.tmp")
1093
- tmp_path.write_text(text, encoding="utf-8")
1094
- tmp_path.replace(path)
193
+ replace_private_file(path, text.encode("utf-8"))
1095
194
 
1096
195
 
1097
196
  class _suppress_chmod_error:
@@ -1130,7 +229,7 @@ async def serve_mock_ssh(
1130
229
  if config_path is not None:
1131
230
  raise HostBridgeError(
1132
231
  "invalid_request",
1133
- "mock-ssh no longer accepts a separate config path; start the v1 daemon with the intended config",
232
+ "mock-ssh uses the daemon's config; restart the daemon with the intended config",
1134
233
  )
1135
234
  key_dir = key_dir or default_key_dir(host_id)
1136
235
  ssh_host_alias = ssh_host_alias or default_ssh_host_alias(host_id)
@@ -1138,36 +237,74 @@ async def serve_mock_ssh(
1138
237
  client = HostBridgeClient()
1139
238
  client.hello()
1140
239
  session_factory = HostBridgeSessionFactory(client, host_id, connect_timeout)
1141
- tcp_forwarder = HostBridgeTCPForwarder(client, session_factory, connect_timeout=connect_timeout)
1142
- server, selected_port = await create_server_on_available_port(
1143
- server_factory=lambda: HostBridgeSSHServer(public_key, tcp_forwarder),
1144
- listen_host=listen_host,
1145
- port=port,
1146
- server_host_keys=[str(host_key_path)],
1147
- process_factory=HostBridgeSSHProcess(client, session_factory, command_timeout, output_limit),
1148
- sftp_factory=lambda chan: HostBridgeSFTPServer(chan, client, session_factory, command_timeout, output_limit),
1149
- )
1150
- installed_config_path: Path | None = None
1151
- if install_ssh_config:
1152
- installed_config_path = install_ssh_config_block(
1153
- host_alias=ssh_host_alias,
240
+
241
+ async def connect_mux_client() -> MuxRpcClient:
242
+ return await MuxRpcClient.connect_unix(client.socket_path)
243
+
244
+ tunnel_connector = TunnelConnector(host_id, connect_mux_client, connect_timeout=connect_timeout)
245
+ server: asyncssh.SSHAcceptor | None = None
246
+ try:
247
+ await tunnel_connector.start()
248
+ server, selected_port = await create_server_on_available_port(
249
+ server_factory=lambda: HostBridgeSSHServer(public_key, tunnel_connector),
1154
250
  listen_host=listen_host,
1155
- port=selected_port,
1156
- client_key_path=client_key_path,
1157
- config_path=ssh_config_path,
251
+ port=port,
252
+ server_host_keys=[str(host_key_path)],
253
+ process_factory=HostBridgeSSHProcess(client, session_factory, command_timeout, output_limit),
254
+ sftp_factory=lambda chan: HostBridgeSFTPServer(
255
+ chan,
256
+ client,
257
+ session_factory,
258
+ command_timeout,
259
+ output_limit,
260
+ DEFAULT_SFTP_MAX_OPEN_HANDLES,
261
+ DEFAULT_SFTP_MAX_TEMP_BYTES,
262
+ ),
1158
263
  )
1159
- print(f"hostbridge mock-ssh forwarding host '{host_id}'", flush=True)
1160
- print(f"listening on ssh://{listen_host}:{selected_port}", flush=True)
1161
- print(f"ssh host alias: {ssh_host_alias}", flush=True)
1162
- if installed_config_path is not None:
1163
- print(f"ssh config: {installed_config_path}", flush=True)
1164
- print(f"identity file: {client_key_path}", flush=True)
1165
- print("supports SSH exec, shell, SFTP, SCP, and TCP forwarding; press Ctrl-C to stop", flush=True)
1166
- try:
264
+ installed_config_path: Path | None = None
265
+ if install_ssh_config:
266
+ installed_config_path = install_ssh_config_block(
267
+ host_alias=ssh_host_alias,
268
+ listen_host=listen_host,
269
+ port=selected_port,
270
+ client_key_path=client_key_path,
271
+ config_path=ssh_config_path,
272
+ )
273
+ print(f"hostbridge mock-ssh forwarding host '{host_id}'", flush=True)
274
+ print(f"listening on ssh://{listen_host}:{selected_port}", flush=True)
275
+ print(f"ssh host alias: {ssh_host_alias}", flush=True)
276
+ if installed_config_path is not None:
277
+ print(f"ssh config: {installed_config_path}", flush=True)
278
+ print(f"identity file: {client_key_path}", flush=True)
279
+ print("supports SSH exec, shell, SFTP, SCP, and TCP forwarding; press Ctrl-C to stop", flush=True)
1167
280
  await server.wait_closed()
1168
281
  return 0
1169
282
  finally:
1170
- session_factory.shutdown()
283
+ primary_error = sys.exception()
284
+ cleanup_errors: list[BaseException] = []
285
+ if server is not None:
286
+ try:
287
+ server.close()
288
+ await server.wait_closed()
289
+ except BaseException as exc:
290
+ cleanup_errors.append(exc)
291
+ try:
292
+ await tunnel_connector.close()
293
+ except BaseException as exc:
294
+ cleanup_errors.append(exc)
295
+ try:
296
+ await asyncio.to_thread(session_factory.shutdown)
297
+ except BaseException as exc:
298
+ cleanup_errors.append(exc)
299
+ try:
300
+ await asyncio.to_thread(client.close)
301
+ except BaseException as exc:
302
+ cleanup_errors.append(exc)
303
+ if cleanup_errors:
304
+ if primary_error is None:
305
+ raise cleanup_errors[0]
306
+ for error in cleanup_errors:
307
+ primary_error.add_note(f"mock-ssh cleanup failed: {error!r}")
1171
308
 
1172
309
 
1173
310
  def run_mock_ssh(**kwargs: object) -> int: