@neoline/hostbridge 1.4.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,108 @@
1
+ """Deterministic HostBridge installation diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import tomllib
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Protocol
10
+
11
+ from . import __version__
12
+ from .config import load_v1_config
13
+ from .runtime import runtime_paths
14
+
15
+
16
+ class DoctorClient(Protocol):
17
+ def hello(self) -> dict[str, object]:
18
+ raise NotImplementedError
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class DiagnosticResult:
23
+ name: str
24
+ status: str
25
+ message: str
26
+ details: dict[str, object]
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class DoctorReport:
31
+ results: tuple[DiagnosticResult, ...]
32
+
33
+ @property
34
+ def ok(self) -> bool:
35
+ return all(result.status != "fail" for result in self.results)
36
+
37
+
38
+ def collect_diagnostics(
39
+ config_path: Path | str,
40
+ *,
41
+ client: DoctorClient,
42
+ executable_paths: list[str],
43
+ ) -> DoctorReport:
44
+ results = [
45
+ _version_diagnostic(),
46
+ _config_diagnostic(config_path),
47
+ _runtime_diagnostic(),
48
+ _daemon_diagnostic(client),
49
+ _executable_diagnostic(executable_paths),
50
+ ]
51
+ return DoctorReport(tuple(results))
52
+
53
+
54
+ def _version_diagnostic() -> DiagnosticResult:
55
+ root = Path(__file__).parents[2]
56
+ npm = json.loads((root / "package.json").read_text(encoding="utf-8"))
57
+ pyproject = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))
58
+ versions = {
59
+ "runtime": __version__,
60
+ "npm": npm.get("version"),
61
+ "python": pyproject.get("project", {}).get("version"),
62
+ }
63
+ status = "pass" if len(set(versions.values())) == 1 and __version__ == "2.0.0" else "fail"
64
+ return DiagnosticResult("version", status, "version sources agree" if status == "pass" else "version drift detected", versions)
65
+
66
+
67
+ def _config_diagnostic(config_path: Path | str) -> DiagnosticResult:
68
+ try:
69
+ config = load_v1_config(config_path)
70
+ except (OSError, ValueError) as exc:
71
+ return DiagnosticResult("config", "fail", str(exc), {"path": str(config_path)})
72
+ return DiagnosticResult("config", "pass", "schema v1 configuration is valid", {"path": str(config.path), "hosts": len(config.hosts)})
73
+
74
+
75
+ def _runtime_diagnostic() -> DiagnosticResult:
76
+ paths = runtime_paths()
77
+ try:
78
+ paths.ensure_directory()
79
+ except OSError as exc:
80
+ return DiagnosticResult("runtime", "fail", str(exc), {"directory": str(paths.directory)})
81
+ mode = paths.directory.stat().st_mode & 0o777
82
+ status = "pass" if mode == 0o700 else "fail"
83
+ return DiagnosticResult(
84
+ "runtime",
85
+ status,
86
+ "runtime directory is private" if status == "pass" else "runtime directory must use mode 0700",
87
+ {"directory": str(paths.directory), "mode": format(mode, "04o")},
88
+ )
89
+
90
+
91
+ def _daemon_diagnostic(client: DoctorClient) -> DiagnosticResult:
92
+ try:
93
+ hello = client.hello()
94
+ except Exception as exc:
95
+ return DiagnosticResult("daemon", "fail", f"daemon unavailable: {exc}", {})
96
+ valid = hello.get("service") == "hostbridge" and hello.get("protocol") == 1
97
+ return DiagnosticResult(
98
+ "daemon",
99
+ "pass" if valid else "fail",
100
+ "daemon protocol v1 is healthy" if valid else "daemon returned an incompatible handshake",
101
+ dict(hello),
102
+ )
103
+
104
+
105
+ def _executable_diagnostic(paths: list[str]) -> DiagnosticResult:
106
+ if not paths:
107
+ return DiagnosticResult("executable", "fail", "hostbridge executable was not found", {"paths": []})
108
+ return DiagnosticResult("executable", "pass", "hostbridge executable found", {"paths": paths})
@@ -34,11 +34,12 @@ class HostConfig:
34
34
  secrets: dict[str, str] = field(default_factory=dict)
35
35
  login_steps: list[LoginStep] = field(default_factory=list)
36
36
  ready_expect: str | None = None
37
+ transport: str = "auto"
37
38
 
38
39
  @classmethod
39
- def script(cls, host_id: str, label: str, script: Path | str) -> HostConfig:
40
+ def script(cls, host_id: str, label: str, script: Path | str, *, transport: str = "auto") -> HostConfig:
40
41
  path = Path(script).expanduser()
41
- return cls(host_id, label, "/bin/sh", [str(path)], "script", path)
42
+ return cls(host_id, label, "/bin/sh", [str(path)], "script", path, transport=transport)
42
43
 
43
44
  @classmethod
44
45
  def command_line(
@@ -52,6 +53,7 @@ class HostConfig:
52
53
  secrets: dict[str, str] | None = None,
53
54
  login_steps: list[LoginStep] | None = None,
54
55
  ready_expect: str | None = None,
56
+ transport: str = "auto",
55
57
  ) -> HostConfig:
56
58
  return cls(
57
59
  host_id,
@@ -63,6 +65,7 @@ class HostConfig:
63
65
  secrets or {},
64
66
  login_steps or [],
65
67
  ready_expect,
68
+ transport,
66
69
  )
67
70
 
68
71
  def describe(self) -> dict[str, object]:
@@ -72,6 +75,7 @@ class HostConfig:
72
75
  "connection_type": self.connection_type,
73
76
  "command": self.command,
74
77
  "args": list(self.args),
78
+ "transport": self.transport,
75
79
  }
76
80
  if self.script_path is not None:
77
81
  info.update(
@@ -130,16 +134,40 @@ def _parse_host(item: object, path: Path, index: int) -> HostConfig:
130
134
  secrets = _parse_secrets(item.get("secrets", {}), path, index)
131
135
  login_steps = _parse_login_steps(item.get("login_steps", []), path, index)
132
136
  ready_expect = _parse_ready_expect(item.get("ready_expect"), path, index)
137
+ transport = str(item.get("transport", "auto")).strip().lower()
138
+ if transport not in {"auto", "pty", "ssh"}:
139
+ raise ValueError(f"{path} hosts[{index}].transport must be one of: auto, pty, ssh")
133
140
 
134
141
  if "script" in item:
135
142
  script = str(item.get("script", "")).strip()
136
143
  if not script:
137
144
  raise ValueError(f"{path} hosts[{index}].script must not be empty")
138
- host = HostConfig.script(host_id, label, script)
139
- return HostConfig(host.id, host.label, host.command, host.args, host.connection_type, host.script_path, secrets, login_steps, ready_expect)
145
+ host = HostConfig.script(host_id, label, script, transport=transport)
146
+ return HostConfig(
147
+ host.id,
148
+ host.label,
149
+ host.command,
150
+ host.args,
151
+ host.connection_type,
152
+ host.script_path,
153
+ secrets,
154
+ login_steps,
155
+ ready_expect,
156
+ transport,
157
+ )
140
158
 
141
159
  if "ssh" in item:
142
- return HostConfig.command_line(host_id, label, "ssh", _parse_ssh_args(item["ssh"], path, index), connection_type="ssh", secrets=secrets, login_steps=login_steps, ready_expect=ready_expect)
160
+ return HostConfig.command_line(
161
+ host_id,
162
+ label,
163
+ "ssh",
164
+ _parse_ssh_args(item["ssh"], path, index),
165
+ connection_type="ssh",
166
+ secrets=secrets,
167
+ login_steps=login_steps,
168
+ ready_expect=ready_expect,
169
+ transport=transport,
170
+ )
143
171
 
144
172
  command = str(item.get("command", "")).strip()
145
173
  args = item.get("args", [])
@@ -147,7 +175,16 @@ def _parse_host(item: object, path: Path, index: int) -> HostConfig:
147
175
  raise ValueError(f"{path} hosts[{index}].command must not be empty")
148
176
  if not isinstance(args, list) or not all(isinstance(arg, str) for arg in args):
149
177
  raise ValueError(f"{path} hosts[{index}].args must be a list of strings")
150
- return HostConfig.command_line(host_id, label, command, args, secrets=secrets, login_steps=login_steps, ready_expect=ready_expect)
178
+ return HostConfig.command_line(
179
+ host_id,
180
+ label,
181
+ command,
182
+ args,
183
+ secrets=secrets,
184
+ login_steps=login_steps,
185
+ ready_expect=ready_expect,
186
+ transport=transport,
187
+ )
151
188
 
152
189
 
153
190
  def _parse_ready_expect(raw_ready_expect: object, path: Path, index: int) -> str | None:
@@ -196,7 +233,15 @@ def _parse_ssh_args(raw_ssh: object, path: Path, index: int) -> list[str]:
196
233
  args: list[str] = []
197
234
  port = raw_ssh.get("port")
198
235
  if port not in (None, ""):
199
- args.extend(["-p", str(int(port))])
236
+ if isinstance(port, bool) or not isinstance(port, int | str):
237
+ raise ValueError(f"{path} hosts[{index}].ssh.port must be an integer")
238
+ try:
239
+ parsed_port = int(port)
240
+ except ValueError as exc:
241
+ raise ValueError(f"{path} hosts[{index}].ssh.port must be an integer") from exc
242
+ if not 1 <= parsed_port <= 65535:
243
+ raise ValueError(f"{path} hosts[{index}].ssh.port must be between 1 and 65535")
244
+ args.extend(["-p", str(parsed_port)])
200
245
  identity_file = str(raw_ssh.get("identity_file", "")).strip()
201
246
  if identity_file:
202
247
  args.extend(["-i", str(Path(identity_file).expanduser())])
@@ -3,19 +3,40 @@ 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
8
10
  import time
9
- from contextlib import suppress
10
11
  from dataclasses import dataclass, field
12
+ from functools import partial
11
13
  from pathlib import Path
14
+ from typing import Any, cast
12
15
 
13
16
  import asyncssh
14
17
 
15
- from .daemon import build_manager
16
- from .manager import DEFAULT_COMMAND_TIMEOUT, DEFAULT_MAX_TRANSFER_BYTES, RemoteCommandError, SessionManager, _new_token
18
+ from .client import HostBridgeClient, HostBridgeError
17
19
 
18
20
  DEFAULT_PORT_RANGE = range(2222, 2300)
21
+ DEFAULT_COMMAND_TIMEOUT = 60
22
+ DEFAULT_MAX_TRANSFER_BYTES = 1024 * 1024 * 1024
23
+ EXEC_STDIN_PROBE_TIMEOUT = 0.05
24
+ STDIN_READING_COMMANDS = [
25
+ re.compile(r"\bcat\s*(?:>|>>)"),
26
+ re.compile(r"\btee(?:\s|$)"),
27
+ re.compile(r"(?:^|\s)(?:/bin/)?(?:sh|bash|dash|zsh)\b[^\n;]*\s-s(?:\s|$)"),
28
+ re.compile(r"(?:^|\s)(?:python[\d.]*|node)(?:\s+\S+)*\s+-($|\s)"),
29
+ re.compile(r"(?:^|\s)tar\s+(?:-[A-Za-z]*x[A-Za-z]*|[A-Za-z]*x[A-Za-z]*)(?:\s|$)"),
30
+ re.compile(r"(?:^|\s)tar\s+.*(?:^|\s)f\s*-(?:\s|$)"),
31
+ re.compile(r"(?:^|\s)dd\s+.*\bof="),
32
+ re.compile(r"(?:^|\s)scp(?:\s+\S+)*\s+-[A-Za-z]*(?:t|f)[A-Za-z]*(?:\s|$)"),
33
+ ]
34
+
35
+
36
+ def _to_bytes(chunk: str | bytes) -> bytes:
37
+ if isinstance(chunk, bytes):
38
+ return chunk
39
+ return chunk.encode("utf-8")
19
40
 
20
41
 
21
42
  class HostBridgeSSHServer(asyncssh.SSHServer):
@@ -36,8 +57,8 @@ class HostBridgeSSHServer(asyncssh.SSHServer):
36
57
 
37
58
 
38
59
  class HostBridgeSSHProcess:
39
- def __init__(self, manager: SessionManager, session_factory: "HostBridgeSessionFactory", timeout: int, output_limit: int) -> None:
40
- self._manager = manager
60
+ def __init__(self, client: HostBridgeClient, session_factory: HostBridgeSessionFactory, timeout: int, output_limit: int) -> None:
61
+ self._client = client
41
62
  self._session_factory = session_factory
42
63
  self._timeout = timeout
43
64
  self._output_limit = output_limit
@@ -57,26 +78,57 @@ class HostBridgeSSHProcess:
57
78
 
58
79
  loop = asyncio.get_running_loop()
59
80
  try:
81
+ stdin_data = await self._read_exec_stdin(process)
60
82
  result = await loop.run_in_executor(
61
83
  None,
62
- lambda: self._manager.run_command(
84
+ lambda: self._client.exec(
63
85
  session_id,
64
86
  command,
87
+ stdin=stdin_data,
65
88
  timeout=self._timeout,
66
- token_factory=_new_token,
67
89
  output_limit=self._output_limit,
90
+ owner="mock-ssh",
68
91
  ),
69
92
  )
70
- if result.output:
71
- process.stdout.write(result.output)
72
- if not result.output.endswith("\n"):
93
+ if result.stdout:
94
+ output = result.stdout.decode("utf-8", errors="replace")
95
+ process.stdout.write(output)
96
+ if not output.endswith("\n"):
73
97
  process.stdout.write("\n")
98
+ if result.stderr:
99
+ process.stderr.write(result.stderr.decode("utf-8", errors="replace"))
74
100
  process.exit(124 if result.timed_out else int(result.exit_code or 0))
75
101
  except Exception as exc:
76
102
  print(f"hostbridge mock-ssh exec failed: {exc}", file=sys.stderr, flush=True)
77
103
  process.stderr.write(f"hostbridge mock-ssh error: {exc}\n")
78
104
  process.exit(1)
79
105
 
106
+ async def _read_exec_stdin(self, process: asyncssh.SSHServerProcess[str]) -> bytes | None:
107
+ if process.stdin.at_eof():
108
+ return b""
109
+ try:
110
+ first_chunk = await asyncio.wait_for(process.stdin.read(4096), timeout=EXEC_STDIN_PROBE_TIMEOUT)
111
+ except TimeoutError:
112
+ return None
113
+ if not first_chunk:
114
+ return b""
115
+
116
+ first_chunk_bytes = _to_bytes(first_chunk)
117
+ total = len(first_chunk_bytes)
118
+ if total > self._output_limit:
119
+ raise HostBridgeError("resource_limit", f"mock-ssh exec stdin exceeds limit ({self._output_limit} bytes)")
120
+ chunks = [first_chunk_bytes]
121
+ while not process.stdin.at_eof():
122
+ chunk = await asyncio.wait_for(process.stdin.read(4096), timeout=self._timeout)
123
+ if not chunk:
124
+ break
125
+ chunk_bytes = _to_bytes(chunk)
126
+ total += len(chunk_bytes)
127
+ if total > self._output_limit:
128
+ raise HostBridgeError("resource_limit", f"mock-ssh exec stdin exceeds limit ({self._output_limit} bytes)")
129
+ chunks.append(chunk_bytes)
130
+ return b"".join(chunks)
131
+
80
132
  async def _run_shell(self, process: asyncssh.SSHServerProcess[str], session_id: str) -> None:
81
133
  loop = asyncio.get_running_loop()
82
134
  stop = asyncio.Event()
@@ -90,7 +142,12 @@ class HostBridgeSSHProcess:
90
142
  break
91
143
  await loop.run_in_executor(
92
144
  None,
93
- lambda chunk=data: self._manager.send(session_id, chunk, owner_agent_id="mock-ssh"),
145
+ partial(
146
+ self._client.shell_write,
147
+ session_id,
148
+ _to_bytes(data),
149
+ owner="mock-ssh",
150
+ ),
94
151
  )
95
152
  finally:
96
153
  input_done.set()
@@ -101,23 +158,22 @@ class HostBridgeSSHProcess:
101
158
  while not stop.is_set():
102
159
  result = await loop.run_in_executor(
103
160
  None,
104
- lambda: self._manager.read(
161
+ lambda: self._client.shell_read(
105
162
  session_id,
106
163
  timeout=0.2,
107
- max_chars=4096,
108
- owner_agent_id="mock-ssh",
164
+ max_bytes=4096,
165
+ owner="mock-ssh",
109
166
  ),
110
167
  )
111
- output = str(result.get("output") or "")
112
- if output:
168
+ if result.data:
113
169
  idle_after_input = 0
114
- process.stdout.write(output)
170
+ process.stdout.write(result.data.decode("utf-8", errors="replace"))
115
171
  elif input_done.is_set():
116
172
  idle_after_input += 1
117
173
  if idle_after_input >= 5:
118
174
  stop.set()
119
175
  break
120
- if not result.get("alive", True):
176
+ if not result.alive:
121
177
  stop.set()
122
178
  break
123
179
  except Exception as exc:
@@ -143,36 +199,39 @@ class HostBridgeSFTPHandle:
143
199
  mode: int = 0o644
144
200
 
145
201
 
202
+ @dataclass(frozen=True, slots=True)
203
+ class _TextCommandResult:
204
+ output: str
205
+ exit_code: int | None
206
+ timed_out: bool
207
+
208
+
146
209
  class HostBridgeSessionFactory:
147
- def __init__(self, manager: SessionManager, host_id: str, connect_timeout: int) -> None:
148
- self._manager = manager
210
+ def __init__(self, client: HostBridgeClient, host_id: str, connect_timeout: int = 60) -> None:
211
+ self._client = client
149
212
  self._host_id = host_id
150
213
  self._connect_timeout = connect_timeout
151
214
 
152
215
  def open(self) -> str:
153
- session = self._manager.open_session(
154
- self._host_id,
155
- connect_timeout=self._connect_timeout,
156
- owner_agent_id="mock-ssh",
157
- )
158
- return session.id
216
+ session = self._client.open_session(self._host_id, owner="mock-ssh")
217
+ return session.session_id
159
218
 
160
219
  def close(self, session_id: str) -> None:
161
220
  with _suppress_error():
162
- self._manager.close_session(session_id, owner_agent_id="mock-ssh", force=True)
221
+ self._client.close_session(session_id, owner="mock-ssh", force=True)
163
222
 
164
223
 
165
224
  class HostBridgeSFTPServer(asyncssh.SFTPServer):
166
225
  def __init__(
167
226
  self,
168
227
  chan: asyncssh.SSHServerChannel,
169
- manager: SessionManager,
228
+ client: HostBridgeClient,
170
229
  session_factory: HostBridgeSessionFactory,
171
230
  timeout: int,
172
231
  max_bytes: int,
173
232
  ) -> None:
174
233
  super().__init__(chan)
175
- self._manager = manager
234
+ self._client = client
176
235
  self._session_factory = session_factory
177
236
  self._session_id = session_factory.open()
178
237
  self._timeout = timeout
@@ -278,17 +337,25 @@ class HostBridgeSFTPServer(asyncssh.SFTPServer):
278
337
  self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)} {atime} {mtime}")
279
338
 
280
339
  def _download(self, remote_path: str) -> bytes:
340
+ temporary_path: Path | None = None
281
341
  try:
282
- result = self._manager.file_read_bytes(
342
+ with tempfile.NamedTemporaryFile(prefix="hostbridge-sftp-", delete=False) as temporary:
343
+ temporary_path = Path(temporary.name)
344
+ result = self._client.download_file(
283
345
  self._session_id,
284
346
  remote_path,
285
- max_bytes=self._max_bytes,
286
- token_factory=_new_token,
287
- owner_agent_id="mock-ssh",
347
+ temporary_path,
348
+ timeout=self._timeout,
349
+ owner="mock-ssh",
288
350
  )
289
- except RemoteCommandError as exc:
351
+ if result.bytes_transferred > self._max_bytes:
352
+ raise HostBridgeError("resource_limit", f"file exceeds mock-ssh limit ({self._max_bytes} bytes)")
353
+ return temporary_path.read_bytes()
354
+ except HostBridgeError as exc:
290
355
  raise self._sftp_error(exc) from exc
291
- return bytes(result["data_bytes"])
356
+ finally:
357
+ if temporary_path is not None:
358
+ temporary_path.unlink(missing_ok=True)
292
359
 
293
360
  def _upload(self, handle: HostBridgeSFTPHandle) -> None:
294
361
  size = len(handle.data)
@@ -299,19 +366,27 @@ class HostBridgeSFTPServer(asyncssh.SFTPServer):
299
366
  data.extend(b"\x00" * (size - len(data)))
300
367
  for offset, chunk in sorted(handle.writes.items()):
301
368
  data[offset : offset + len(chunk)] = chunk
369
+ if len(data) > self._max_bytes:
370
+ raise asyncssh.SFTPFailure(f"file exceeds mock-ssh limit ({self._max_bytes} bytes)")
371
+ temporary_path: Path | None = None
302
372
  try:
303
- self._manager.file_write_bytes(
373
+ with tempfile.NamedTemporaryFile(prefix="hostbridge-sftp-", delete=False) as temporary:
374
+ temporary.write(data)
375
+ temporary_path = Path(temporary.name)
376
+ self._client.upload_file(
304
377
  self._session_id,
378
+ temporary_path,
305
379
  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",
380
+ mode=handle.mode & 0o7777,
381
+ timeout=self._timeout,
382
+ owner="mock-ssh",
311
383
  )
312
- except RemoteCommandError as exc:
384
+ except HostBridgeError as exc:
313
385
  print(f"hostbridge mock-ssh sftp upload failed: {exc}", file=sys.stderr, flush=True)
314
386
  raise self._sftp_error(exc) from exc
387
+ finally:
388
+ if temporary_path is not None:
389
+ temporary_path.unlink(missing_ok=True)
315
390
 
316
391
  def _stat(self, remote_path: str, *, follow: bool) -> asyncssh.SFTPAttrs:
317
392
  script = r"""
@@ -362,7 +437,7 @@ print(json.dumps(items))
362
437
  ]
363
438
 
364
439
  def _attrs(self, data: dict[str, object]) -> asyncssh.SFTPAttrs:
365
- permissions = int(data.get("permissions") or 0)
440
+ permissions = self._int_value(data.get("permissions"), 0)
366
441
  raw_type = data.get("type")
367
442
  file_type = {
368
443
  "file": asyncssh.FILEXFER_TYPE_REGULAR,
@@ -371,39 +446,52 @@ print(json.dumps(items))
371
446
  }.get(str(raw_type), asyncssh.FILEXFER_TYPE_UNKNOWN)
372
447
  return asyncssh.SFTPAttrs(
373
448
  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),
449
+ size=self._int_value(data.get("size"), 0),
450
+ uid=self._int_value(data.get("uid"), 0),
451
+ gid=self._int_value(data.get("gid"), 0),
377
452
  permissions=permissions,
378
- atime=int(data.get("atime") or time.time()),
379
- mtime=int(data.get("mtime") or time.time()),
453
+ atime=self._int_value(data.get("atime"), int(time.time())),
454
+ mtime=self._int_value(data.get("mtime"), int(time.time())),
380
455
  )
381
456
 
457
+ @staticmethod
458
+ def _int_value(value: object, default: int) -> int:
459
+ if value is None:
460
+ return default
461
+ if isinstance(value, bool) or not isinstance(value, int | float | str):
462
+ raise asyncssh.SFTPFailure("remote metadata contains a non-numeric field")
463
+ try:
464
+ return int(value)
465
+ except ValueError as exc:
466
+ raise asyncssh.SFTPFailure("remote metadata contains an invalid numeric field") from exc
467
+
382
468
  def _checked_run(self, command: str):
383
469
  try:
384
- result = self._manager.run_command(
470
+ result = self._client.exec(
385
471
  self._session_id,
386
472
  command,
387
473
  timeout=self._timeout,
388
- token_factory=_new_token,
389
- owner_agent_id="mock-ssh",
474
+ owner="mock-ssh",
390
475
  )
391
- except RemoteCommandError as exc:
476
+ except HostBridgeError as exc:
392
477
  raise self._sftp_error(exc) from exc
478
+ output = result.stdout.decode("utf-8", errors="replace")
393
479
  if result.timed_out:
394
480
  print(f"hostbridge mock-ssh sftp command timed out: {command}", file=sys.stderr, flush=True)
395
481
  raise asyncssh.SFTPFailure("remote command timed out")
396
482
  if result.exit_code not in (0, None):
397
- error = self._sftp_error(RemoteCommandError(result.output or f"remote command exited {result.exit_code}"))
483
+ error = self._sftp_error(
484
+ HostBridgeError("remote_error", output or f"remote command exited {result.exit_code}")
485
+ )
398
486
  if isinstance(error, asyncssh.SFTPNoSuchFile):
399
487
  raise error
400
488
  print(
401
- f"hostbridge mock-ssh sftp command failed exit={result.exit_code}: {command}\n{result.output}",
489
+ f"hostbridge mock-ssh sftp command failed exit={result.exit_code}: {command}\n{output}",
402
490
  file=sys.stderr,
403
491
  flush=True,
404
492
  )
405
493
  raise error
406
- return result
494
+ return _TextCommandResult(output, result.exit_code, result.timed_out)
407
495
 
408
496
  def _path(self, path: bytes) -> str:
409
497
  text = path.decode("utf-8", "surrogateescape")
@@ -416,7 +504,7 @@ print(json.dumps(items))
416
504
  raise asyncssh.SFTPInvalidHandle("invalid HostBridge SFTP handle")
417
505
  return file_obj
418
506
 
419
- def _sftp_error(self, exc: RemoteCommandError) -> Exception:
507
+ def _sftp_error(self, exc: HostBridgeError) -> Exception:
420
508
  text = str(exc)
421
509
  lowered = text.lower()
422
510
  if "no such file" in lowered or "not found" in lowered or "filenotfounderror" in lowered:
@@ -595,49 +683,51 @@ async def serve_mock_ssh(
595
683
  command_timeout: int = DEFAULT_COMMAND_TIMEOUT,
596
684
  output_limit: int = DEFAULT_MAX_TRANSFER_BYTES,
597
685
  ) -> int:
686
+ if config_path is not None:
687
+ raise HostBridgeError(
688
+ "invalid_request",
689
+ "mock-ssh no longer accepts a separate config path; start the v1 daemon with the intended config",
690
+ )
598
691
  key_dir = key_dir or default_key_dir(host_id)
599
692
  ssh_host_alias = ssh_host_alias or default_ssh_host_alias(host_id)
600
693
  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),
694
+ client = HostBridgeClient()
695
+ client.hello()
696
+ session_factory = HostBridgeSessionFactory(client, host_id, connect_timeout)
697
+ server, selected_port = await create_server_on_available_port(
698
+ server_factory=lambda: HostBridgeSSHServer(public_key),
699
+ listen_host=listen_host,
700
+ port=port,
701
+ server_host_keys=[str(host_key_path)],
702
+ process_factory=HostBridgeSSHProcess(client, session_factory, command_timeout, output_limit),
703
+ sftp_factory=lambda chan: HostBridgeSFTPServer(chan, client, session_factory, command_timeout, output_limit),
704
+ )
705
+ installed_config_path: Path | None = None
706
+ if install_ssh_config:
707
+ installed_config_path = install_ssh_config_block(
708
+ host_alias=ssh_host_alias,
606
709
  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),
710
+ port=selected_port,
711
+ client_key_path=client_key_path,
712
+ config_path=ssh_config_path,
611
713
  )
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)
628
- await server.wait_closed()
629
- finally:
630
- manager.close_all_sessions()
631
- manager.stop_idle_reaper()
714
+ print(f"hostbridge mock-ssh forwarding host '{host_id}'", flush=True)
715
+ print(f"listening on ssh://{listen_host}:{selected_port}", flush=True)
716
+ print(f"ssh host alias: {ssh_host_alias}", flush=True)
717
+ if installed_config_path is not None:
718
+ print(f"ssh config: {installed_config_path}", flush=True)
719
+ print(f"identity file: {client_key_path}", flush=True)
720
+ print("supports SSH exec, shell, SFTP, and SCP; press Ctrl-C to stop", flush=True)
721
+ await server.wait_closed()
632
722
  return 0
633
723
 
634
724
 
635
725
  def run_mock_ssh(**kwargs: object) -> int:
636
726
  try:
637
- return asyncio.run(serve_mock_ssh(**kwargs))
727
+ return asyncio.run(cast(Any, serve_mock_ssh)(**kwargs))
638
728
  except KeyboardInterrupt:
639
729
  return 130
640
- except RemoteCommandError as exc:
730
+ except HostBridgeError as exc:
641
731
  print(f"hostbridge mock-ssh failed: {exc}")
642
732
  return 1
643
733