@neoline/hostbridge 1.4.4 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,19 +3,47 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import json
5
5
  import posixpath
6
+ import re
6
7
  import shlex
7
8
  import sys
9
+ import tempfile
10
+ import threading
8
11
  import time
9
12
  from contextlib import suppress
10
13
  from dataclasses import dataclass, field
14
+ from functools import partial
11
15
  from pathlib import Path
16
+ from typing import Any, cast
12
17
 
13
18
  import asyncssh
14
19
 
15
- from .daemon import build_manager
16
- from .manager import DEFAULT_COMMAND_TIMEOUT, DEFAULT_MAX_TRANSFER_BYTES, RemoteCommandError, SessionManager, _new_token
20
+ from .client import HostBridgeClient, HostBridgeError
17
21
 
18
22
  DEFAULT_PORT_RANGE = range(2222, 2300)
23
+ DEFAULT_COMMAND_TIMEOUT = 60
24
+ DEFAULT_MAX_TRANSFER_BYTES = 1024 * 1024 * 1024
25
+ EXEC_STDIN_PROBE_TIMEOUT = 0.05
26
+ STDIN_READING_COMMANDS = [
27
+ re.compile(r"\bcat\s*(?:>|>>)"),
28
+ re.compile(r"\btee(?:\s|$)"),
29
+ re.compile(r"(?:^|\s)(?:/bin/)?(?:sh|bash|dash|zsh)\b[^\n;]*\s-s(?:\s|$)"),
30
+ re.compile(r"(?:^|\s)(?:python[\d.]*|node)(?:\s+\S+)*\s+-($|\s)"),
31
+ re.compile(r"(?:^|\s)tar\s+(?:-[A-Za-z]*x[A-Za-z]*|[A-Za-z]*x[A-Za-z]*)(?:\s|$)"),
32
+ re.compile(r"(?:^|\s)tar\s+.*(?:^|\s)f\s*-(?:\s|$)"),
33
+ re.compile(r"(?:^|\s)dd\s+.*\bof="),
34
+ re.compile(r"(?:^|\s)scp(?:\s+\S+)*\s+-[A-Za-z]*(?:t|f)[A-Za-z]*(?:\s|$)"),
35
+ ]
36
+ MAX_IDLE_BACKEND_SESSIONS = 4
37
+
38
+
39
+ def _to_bytes(chunk: str | bytes) -> bytes:
40
+ if isinstance(chunk, bytes):
41
+ return chunk
42
+ return chunk.encode("utf-8")
43
+
44
+
45
+ def _command_reads_stdin(command: str | None) -> bool:
46
+ return bool(command and any(pattern.search(command) for pattern in STDIN_READING_COMMANDS))
19
47
 
20
48
 
21
49
  class HostBridgeSSHServer(asyncssh.SSHServer):
@@ -36,48 +64,95 @@ class HostBridgeSSHServer(asyncssh.SSHServer):
36
64
 
37
65
 
38
66
  class HostBridgeSSHProcess:
39
- def __init__(self, manager: SessionManager, session_factory: "HostBridgeSessionFactory", timeout: int, output_limit: int) -> None:
40
- self._manager = manager
67
+ def __init__(self, client: HostBridgeClient, session_factory: HostBridgeSessionFactory, timeout: int, output_limit: int) -> None:
68
+ self._client = client
41
69
  self._session_factory = session_factory
42
70
  self._timeout = timeout
43
71
  self._output_limit = output_limit
44
72
 
45
- async def __call__(self, process: asyncssh.SSHServerProcess[str]) -> None:
73
+ async def __call__(self, process: asyncssh.SSHServerProcess[bytes]) -> None:
46
74
  session_id = self._session_factory.open()
75
+ reusable = False
47
76
  try:
48
- await self._run(process, session_id)
77
+ reusable = await self._run(process, session_id)
78
+ except asyncio.CancelledError:
79
+ self._set_exit_status(process, 255)
80
+ return
49
81
  finally:
50
- self._session_factory.close(session_id)
82
+ self._session_factory.close(session_id, reusable=reusable)
51
83
 
52
- async def _run(self, process: asyncssh.SSHServerProcess[str], session_id: str) -> None:
84
+ async def _run(self, process: asyncssh.SSHServerProcess[bytes], session_id: str) -> bool:
53
85
  command = process.command
54
86
  if not command:
55
87
  await self._run_shell(process, session_id)
56
- return
88
+ return False
57
89
 
58
90
  loop = asyncio.get_running_loop()
59
91
  try:
92
+ stdin_data = await self._read_exec_stdin(process, command)
60
93
  result = await loop.run_in_executor(
61
94
  None,
62
- lambda: self._manager.run_command(
95
+ lambda: self._client.exec(
63
96
  session_id,
64
97
  command,
98
+ stdin=stdin_data,
65
99
  timeout=self._timeout,
66
- token_factory=_new_token,
67
100
  output_limit=self._output_limit,
101
+ owner="mock-ssh",
68
102
  ),
69
103
  )
70
- if result.output:
71
- process.stdout.write(result.output)
72
- if not result.output.endswith("\n"):
73
- process.stdout.write("\n")
74
- process.exit(124 if result.timed_out else int(result.exit_code or 0))
104
+ if result.stdout:
105
+ process.stdout.write(result.stdout)
106
+ if result.stderr:
107
+ process.stderr.write(result.stderr)
108
+ self._set_exit_status(process, 124 if result.timed_out else int(result.exit_code or 0))
109
+ return not result.timed_out
75
110
  except Exception as exc:
76
111
  print(f"hostbridge mock-ssh exec failed: {exc}", file=sys.stderr, flush=True)
77
- process.stderr.write(f"hostbridge mock-ssh error: {exc}\n")
78
- process.exit(1)
79
-
80
- async def _run_shell(self, process: asyncssh.SSHServerProcess[str], session_id: str) -> None:
112
+ with suppress(Exception):
113
+ process.stderr.write(f"hostbridge mock-ssh error: {exc}\n".encode("utf-8", errors="replace"))
114
+ status = 124 if isinstance(exc, HostBridgeError) and exc.code == "timeout" else 1
115
+ self._set_exit_status(process, status)
116
+ return False
117
+
118
+ @staticmethod
119
+ def _set_exit_status(process: asyncssh.SSHServerProcess[bytes], status: int) -> None:
120
+ with suppress(Exception):
121
+ process.exit(status)
122
+
123
+ async def _read_exec_stdin(self, process: asyncssh.SSHServerProcess[bytes], command: str) -> bytes | None:
124
+ if process.stdin.at_eof():
125
+ return b""
126
+ waits_for_eof = _command_reads_stdin(command)
127
+ try:
128
+ first_chunk = await asyncio.wait_for(
129
+ process.stdin.read(4096),
130
+ timeout=self._timeout if waits_for_eof else EXEC_STDIN_PROBE_TIMEOUT,
131
+ )
132
+ except TimeoutError:
133
+ if waits_for_eof:
134
+ raise HostBridgeError("timeout", "timed out waiting for mock-ssh exec stdin") from None
135
+ return None
136
+ if not first_chunk:
137
+ return b""
138
+
139
+ first_chunk_bytes = _to_bytes(first_chunk)
140
+ total = len(first_chunk_bytes)
141
+ if total > self._output_limit:
142
+ raise HostBridgeError("resource_limit", f"mock-ssh exec stdin exceeds limit ({self._output_limit} bytes)")
143
+ chunks = [first_chunk_bytes]
144
+ while not process.stdin.at_eof():
145
+ chunk = await asyncio.wait_for(process.stdin.read(4096), timeout=self._timeout)
146
+ if not chunk:
147
+ break
148
+ chunk_bytes = _to_bytes(chunk)
149
+ total += len(chunk_bytes)
150
+ if total > self._output_limit:
151
+ raise HostBridgeError("resource_limit", f"mock-ssh exec stdin exceeds limit ({self._output_limit} bytes)")
152
+ chunks.append(chunk_bytes)
153
+ return b"".join(chunks)
154
+
155
+ async def _run_shell(self, process: asyncssh.SSHServerProcess[bytes], session_id: str) -> None:
81
156
  loop = asyncio.get_running_loop()
82
157
  stop = asyncio.Event()
83
158
  input_done = asyncio.Event()
@@ -90,7 +165,12 @@ class HostBridgeSSHProcess:
90
165
  break
91
166
  await loop.run_in_executor(
92
167
  None,
93
- lambda chunk=data: self._manager.send(session_id, chunk, owner_agent_id="mock-ssh"),
168
+ partial(
169
+ self._client.shell_write,
170
+ session_id,
171
+ _to_bytes(data),
172
+ owner="mock-ssh",
173
+ ),
94
174
  )
95
175
  finally:
96
176
  input_done.set()
@@ -101,28 +181,28 @@ class HostBridgeSSHProcess:
101
181
  while not stop.is_set():
102
182
  result = await loop.run_in_executor(
103
183
  None,
104
- lambda: self._manager.read(
184
+ lambda: self._client.shell_read(
105
185
  session_id,
106
186
  timeout=0.2,
107
- max_chars=4096,
108
- owner_agent_id="mock-ssh",
187
+ max_bytes=4096,
188
+ owner="mock-ssh",
109
189
  ),
110
190
  )
111
- output = str(result.get("output") or "")
112
- if output:
191
+ if result.data:
113
192
  idle_after_input = 0
114
- process.stdout.write(output)
193
+ process.stdout.write(result.data)
115
194
  elif input_done.is_set():
116
195
  idle_after_input += 1
117
196
  if idle_after_input >= 5:
118
197
  stop.set()
119
198
  break
120
- if not result.get("alive", True):
199
+ if not result.alive:
121
200
  stop.set()
122
201
  break
123
202
  except Exception as exc:
124
203
  print(f"hostbridge mock-ssh shell failed: {exc}", file=sys.stderr, flush=True)
125
- process.stderr.write(f"hostbridge mock-ssh shell error: {exc}\n")
204
+ with suppress(Exception):
205
+ process.stderr.write(f"hostbridge mock-ssh shell error: {exc}\n".encode("utf-8", errors="replace"))
126
206
  stop.set()
127
207
 
128
208
  tasks = [asyncio.create_task(pump_input()), asyncio.create_task(pump_output())]
@@ -130,7 +210,7 @@ class HostBridgeSSHProcess:
130
210
  for task in tasks:
131
211
  task.cancel()
132
212
  await asyncio.gather(*tasks, return_exceptions=True)
133
- process.exit(0)
213
+ self._set_exit_status(process, 0)
134
214
 
135
215
 
136
216
  @dataclass
@@ -143,36 +223,99 @@ class HostBridgeSFTPHandle:
143
223
  mode: int = 0o644
144
224
 
145
225
 
226
+ @dataclass(frozen=True, slots=True)
227
+ class _TextCommandResult:
228
+ output: str
229
+ exit_code: int | None
230
+ timed_out: bool
231
+
232
+
146
233
  class HostBridgeSessionFactory:
147
- def __init__(self, manager: SessionManager, host_id: str, connect_timeout: int) -> None:
148
- self._manager = manager
234
+ def __init__(self, client: HostBridgeClient, host_id: str, connect_timeout: int = 60) -> None:
235
+ self._client = client
149
236
  self._host_id = host_id
150
237
  self._connect_timeout = connect_timeout
238
+ self._lock = threading.Lock()
239
+ self._idle: list[str] = []
240
+ self._leased: set[str] = set()
241
+ self._shutdown = False
151
242
 
152
243
  def open(self) -> str:
153
- session = self._manager.open_session(
244
+ with self._lock:
245
+ if self._shutdown:
246
+ raise RuntimeError("mock-ssh session factory is shut down")
247
+ if self._idle:
248
+ session_id = self._idle.pop()
249
+ self._leased.add(session_id)
250
+ return session_id
251
+ session = self._client.open_session(
154
252
  self._host_id,
155
- connect_timeout=self._connect_timeout,
156
- owner_agent_id="mock-ssh",
253
+ owner="mock-ssh",
254
+ timeout=self._connect_timeout + 5,
255
+ )
256
+ session_id = session.session_id
257
+ with self._lock:
258
+ if not self._shutdown:
259
+ self._leased.add(session_id)
260
+ return session_id
261
+ self._close_backend(session_id)
262
+ raise RuntimeError("mock-ssh session factory is shut down")
263
+
264
+ def close(self, session_id: str, *, reusable: bool = True) -> None:
265
+ should_close = False
266
+ with self._lock:
267
+ if session_id not in self._leased:
268
+ return
269
+ self._leased.remove(session_id)
270
+ if reusable and not self._shutdown and len(self._idle) < MAX_IDLE_BACKEND_SESSIONS:
271
+ self._idle.append(session_id)
272
+ else:
273
+ should_close = True
274
+ if not should_close:
275
+ return
276
+ self._close_backend(session_id)
277
+
278
+ def shutdown(self) -> None:
279
+ with self._lock:
280
+ self._shutdown = True
281
+ session_ids = [*self._idle, *self._leased]
282
+ self._idle.clear()
283
+ self._leased.clear()
284
+ for session_id in session_ids:
285
+ self._close_backend(session_id)
286
+
287
+ def _close_backend(self, session_id: str) -> None:
288
+ last_error: Exception | None = None
289
+ for attempt in range(2):
290
+ try:
291
+ self._client.close_session(session_id, owner="mock-ssh", force=True)
292
+ return
293
+ except HostBridgeError as exc:
294
+ if exc.code == "not_found":
295
+ return
296
+ last_error = exc
297
+ except Exception as exc:
298
+ last_error = exc
299
+ if attempt == 0:
300
+ time.sleep(0.05)
301
+ print(
302
+ f"hostbridge mock-ssh failed to close backend session {session_id}: {last_error}",
303
+ file=sys.stderr,
304
+ flush=True,
157
305
  )
158
- return session.id
159
-
160
- def close(self, session_id: str) -> None:
161
- with _suppress_error():
162
- self._manager.close_session(session_id, owner_agent_id="mock-ssh", force=True)
163
306
 
164
307
 
165
308
  class HostBridgeSFTPServer(asyncssh.SFTPServer):
166
309
  def __init__(
167
310
  self,
168
311
  chan: asyncssh.SSHServerChannel,
169
- manager: SessionManager,
312
+ client: HostBridgeClient,
170
313
  session_factory: HostBridgeSessionFactory,
171
314
  timeout: int,
172
315
  max_bytes: int,
173
316
  ) -> None:
174
317
  super().__init__(chan)
175
- self._manager = manager
318
+ self._client = client
176
319
  self._session_factory = session_factory
177
320
  self._session_id = session_factory.open()
178
321
  self._timeout = timeout
@@ -278,17 +421,25 @@ class HostBridgeSFTPServer(asyncssh.SFTPServer):
278
421
  self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)} {atime} {mtime}")
279
422
 
280
423
  def _download(self, remote_path: str) -> bytes:
424
+ temporary_path: Path | None = None
281
425
  try:
282
- result = self._manager.file_read_bytes(
426
+ with tempfile.NamedTemporaryFile(prefix="hostbridge-sftp-", delete=False) as temporary:
427
+ temporary_path = Path(temporary.name)
428
+ result = self._client.download_file(
283
429
  self._session_id,
284
430
  remote_path,
285
- max_bytes=self._max_bytes,
286
- token_factory=_new_token,
287
- owner_agent_id="mock-ssh",
431
+ temporary_path,
432
+ timeout=self._timeout,
433
+ owner="mock-ssh",
288
434
  )
289
- except RemoteCommandError as exc:
435
+ if result.bytes_transferred > self._max_bytes:
436
+ raise HostBridgeError("resource_limit", f"file exceeds mock-ssh limit ({self._max_bytes} bytes)")
437
+ return temporary_path.read_bytes()
438
+ except HostBridgeError as exc:
290
439
  raise self._sftp_error(exc) from exc
291
- return bytes(result["data_bytes"])
440
+ finally:
441
+ if temporary_path is not None:
442
+ temporary_path.unlink(missing_ok=True)
292
443
 
293
444
  def _upload(self, handle: HostBridgeSFTPHandle) -> None:
294
445
  size = len(handle.data)
@@ -299,19 +450,27 @@ class HostBridgeSFTPServer(asyncssh.SFTPServer):
299
450
  data.extend(b"\x00" * (size - len(data)))
300
451
  for offset, chunk in sorted(handle.writes.items()):
301
452
  data[offset : offset + len(chunk)] = chunk
453
+ if len(data) > self._max_bytes:
454
+ raise asyncssh.SFTPFailure(f"file exceeds mock-ssh limit ({self._max_bytes} bytes)")
455
+ temporary_path: Path | None = None
302
456
  try:
303
- self._manager.file_write_bytes(
457
+ with tempfile.NamedTemporaryFile(prefix="hostbridge-sftp-", delete=False) as temporary:
458
+ temporary.write(data)
459
+ temporary_path = Path(temporary.name)
460
+ self._client.upload_file(
304
461
  self._session_id,
462
+ temporary_path,
305
463
  handle.path,
306
- bytes(data),
307
- mode=format(handle.mode & 0o7777, "04o"),
308
- max_bytes=self._max_bytes,
309
- token_factory=_new_token,
310
- owner_agent_id="mock-ssh",
464
+ mode=handle.mode & 0o7777,
465
+ timeout=self._timeout,
466
+ owner="mock-ssh",
311
467
  )
312
- except RemoteCommandError as exc:
468
+ except HostBridgeError as exc:
313
469
  print(f"hostbridge mock-ssh sftp upload failed: {exc}", file=sys.stderr, flush=True)
314
470
  raise self._sftp_error(exc) from exc
471
+ finally:
472
+ if temporary_path is not None:
473
+ temporary_path.unlink(missing_ok=True)
315
474
 
316
475
  def _stat(self, remote_path: str, *, follow: bool) -> asyncssh.SFTPAttrs:
317
476
  script = r"""
@@ -362,7 +521,7 @@ print(json.dumps(items))
362
521
  ]
363
522
 
364
523
  def _attrs(self, data: dict[str, object]) -> asyncssh.SFTPAttrs:
365
- permissions = int(data.get("permissions") or 0)
524
+ permissions = self._int_value(data.get("permissions"), 0)
366
525
  raw_type = data.get("type")
367
526
  file_type = {
368
527
  "file": asyncssh.FILEXFER_TYPE_REGULAR,
@@ -371,39 +530,54 @@ print(json.dumps(items))
371
530
  }.get(str(raw_type), asyncssh.FILEXFER_TYPE_UNKNOWN)
372
531
  return asyncssh.SFTPAttrs(
373
532
  type=file_type,
374
- size=int(data.get("size") or 0),
375
- uid=int(data.get("uid") or 0),
376
- gid=int(data.get("gid") or 0),
533
+ size=self._int_value(data.get("size"), 0),
534
+ uid=self._int_value(data.get("uid"), 0),
535
+ gid=self._int_value(data.get("gid"), 0),
377
536
  permissions=permissions,
378
- atime=int(data.get("atime") or time.time()),
379
- mtime=int(data.get("mtime") or time.time()),
537
+ atime=self._int_value(data.get("atime"), int(time.time())),
538
+ mtime=self._int_value(data.get("mtime"), int(time.time())),
380
539
  )
381
540
 
541
+ @staticmethod
542
+ def _int_value(value: object, default: int) -> int:
543
+ if value is None:
544
+ return default
545
+ if isinstance(value, bool) or not isinstance(value, int | float | str):
546
+ raise asyncssh.SFTPFailure("remote metadata contains a non-numeric field")
547
+ try:
548
+ return int(value)
549
+ except ValueError as exc:
550
+ raise asyncssh.SFTPFailure("remote metadata contains an invalid numeric field") from exc
551
+
382
552
  def _checked_run(self, command: str):
383
553
  try:
384
- result = self._manager.run_command(
554
+ result = self._client.exec(
385
555
  self._session_id,
386
556
  command,
387
557
  timeout=self._timeout,
388
- token_factory=_new_token,
389
- owner_agent_id="mock-ssh",
558
+ owner="mock-ssh",
390
559
  )
391
- except RemoteCommandError as exc:
560
+ except HostBridgeError as exc:
392
561
  raise self._sftp_error(exc) from exc
562
+ output = result.stdout.decode("utf-8", errors="replace")
563
+ stderr = result.stderr.decode("utf-8", errors="replace")
393
564
  if result.timed_out:
394
565
  print(f"hostbridge mock-ssh sftp command timed out: {command}", file=sys.stderr, flush=True)
395
566
  raise asyncssh.SFTPFailure("remote command timed out")
396
567
  if result.exit_code not in (0, None):
397
- error = self._sftp_error(RemoteCommandError(result.output or f"remote command exited {result.exit_code}"))
568
+ error_output = "\n".join(part for part in (output, stderr) if part).strip()
569
+ error = self._sftp_error(
570
+ HostBridgeError("remote_error", error_output or f"remote command exited {result.exit_code}")
571
+ )
398
572
  if isinstance(error, asyncssh.SFTPNoSuchFile):
399
573
  raise error
400
574
  print(
401
- f"hostbridge mock-ssh sftp command failed exit={result.exit_code}: {command}\n{result.output}",
575
+ f"hostbridge mock-ssh sftp command failed exit={result.exit_code}: {command}\n{error_output}",
402
576
  file=sys.stderr,
403
577
  flush=True,
404
578
  )
405
579
  raise error
406
- return result
580
+ return _TextCommandResult(output, result.exit_code, result.timed_out)
407
581
 
408
582
  def _path(self, path: bytes) -> str:
409
583
  text = path.decode("utf-8", "surrogateescape")
@@ -416,7 +590,7 @@ print(json.dumps(items))
416
590
  raise asyncssh.SFTPInvalidHandle("invalid HostBridge SFTP handle")
417
591
  return file_obj
418
592
 
419
- def _sftp_error(self, exc: RemoteCommandError) -> Exception:
593
+ def _sftp_error(self, exc: HostBridgeError) -> Exception:
420
594
  text = str(exc)
421
595
  lowered = text.lower()
422
596
  if "no such file" in lowered or "not found" in lowered or "filenotfounderror" in lowered:
@@ -480,6 +654,7 @@ async def create_server_on_available_port(
480
654
  process_factory=process_factory,
481
655
  sftp_factory=sftp_factory,
482
656
  allow_scp=True,
657
+ encoding=None,
483
658
  )
484
659
  return server, int(candidate)
485
660
  except OSError as exc:
@@ -595,49 +770,54 @@ async def serve_mock_ssh(
595
770
  command_timeout: int = DEFAULT_COMMAND_TIMEOUT,
596
771
  output_limit: int = DEFAULT_MAX_TRANSFER_BYTES,
597
772
  ) -> int:
773
+ if config_path is not None:
774
+ raise HostBridgeError(
775
+ "invalid_request",
776
+ "mock-ssh no longer accepts a separate config path; start the v1 daemon with the intended config",
777
+ )
598
778
  key_dir = key_dir or default_key_dir(host_id)
599
779
  ssh_host_alias = ssh_host_alias or default_ssh_host_alias(host_id)
600
780
  host_key_path, client_key_path, public_key = ensure_keypair(key_dir)
601
- manager = build_manager(config_path)
602
- session_factory = HostBridgeSessionFactory(manager, host_id, connect_timeout)
603
- try:
604
- server, selected_port = await create_server_on_available_port(
605
- server_factory=lambda: HostBridgeSSHServer(public_key),
781
+ client = HostBridgeClient()
782
+ client.hello()
783
+ session_factory = HostBridgeSessionFactory(client, host_id, connect_timeout)
784
+ server, selected_port = await create_server_on_available_port(
785
+ server_factory=lambda: HostBridgeSSHServer(public_key),
786
+ listen_host=listen_host,
787
+ port=port,
788
+ server_host_keys=[str(host_key_path)],
789
+ process_factory=HostBridgeSSHProcess(client, session_factory, command_timeout, output_limit),
790
+ sftp_factory=lambda chan: HostBridgeSFTPServer(chan, client, session_factory, command_timeout, output_limit),
791
+ )
792
+ installed_config_path: Path | None = None
793
+ if install_ssh_config:
794
+ installed_config_path = install_ssh_config_block(
795
+ host_alias=ssh_host_alias,
606
796
  listen_host=listen_host,
607
- port=port,
608
- server_host_keys=[str(host_key_path)],
609
- process_factory=HostBridgeSSHProcess(manager, session_factory, command_timeout, output_limit),
610
- sftp_factory=lambda chan: HostBridgeSFTPServer(chan, manager, session_factory, command_timeout, output_limit),
797
+ port=selected_port,
798
+ client_key_path=client_key_path,
799
+ config_path=ssh_config_path,
611
800
  )
612
- installed_config_path: Path | None = None
613
- if install_ssh_config:
614
- installed_config_path = install_ssh_config_block(
615
- host_alias=ssh_host_alias,
616
- listen_host=listen_host,
617
- port=selected_port,
618
- client_key_path=client_key_path,
619
- config_path=ssh_config_path,
620
- )
621
- print(f"hostbridge mock-ssh forwarding host '{host_id}'", flush=True)
622
- print(f"listening on ssh://{listen_host}:{selected_port}", flush=True)
623
- print(f"ssh host alias: {ssh_host_alias}", flush=True)
624
- if installed_config_path is not None:
625
- print(f"ssh config: {installed_config_path}", flush=True)
626
- print(f"identity file: {client_key_path}", flush=True)
627
- print("supports SSH exec, shell, SFTP, and SCP; press Ctrl-C to stop", flush=True)
801
+ print(f"hostbridge mock-ssh forwarding host '{host_id}'", flush=True)
802
+ print(f"listening on ssh://{listen_host}:{selected_port}", flush=True)
803
+ print(f"ssh host alias: {ssh_host_alias}", flush=True)
804
+ if installed_config_path is not None:
805
+ print(f"ssh config: {installed_config_path}", flush=True)
806
+ print(f"identity file: {client_key_path}", flush=True)
807
+ print("supports SSH exec, shell, SFTP, and SCP; press Ctrl-C to stop", flush=True)
808
+ try:
628
809
  await server.wait_closed()
810
+ return 0
629
811
  finally:
630
- manager.close_all_sessions()
631
- manager.stop_idle_reaper()
632
- return 0
812
+ session_factory.shutdown()
633
813
 
634
814
 
635
815
  def run_mock_ssh(**kwargs: object) -> int:
636
816
  try:
637
- return asyncio.run(serve_mock_ssh(**kwargs))
817
+ return asyncio.run(cast(Any, serve_mock_ssh)(**kwargs))
638
818
  except KeyboardInterrupt:
639
819
  return 130
640
- except RemoteCommandError as exc:
820
+ except HostBridgeError as exc:
641
821
  print(f"hostbridge mock-ssh failed: {exc}")
642
822
  return 1
643
823
 
@@ -167,6 +167,7 @@ def audit_event(
167
167
  event: str,
168
168
  session_id: str | None = None,
169
169
  host_id: str | None = None,
170
+ owner_agent_id: str | None = None,
170
171
  command: str | None = None,
171
172
  task_id: str | None = None,
172
173
  outcome: str | None = None,
@@ -177,6 +178,7 @@ def audit_event(
177
178
  for key, value in {
178
179
  "session_id": session_id,
179
180
  "host_id": host_id,
181
+ "owner_agent_id": owner_agent_id,
180
182
  "command": command,
181
183
  "task_id": task_id,
182
184
  "outcome": outcome,