@neoline/hostbridge 2.0.0 → 2.0.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neoline/hostbridge",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "HostBridge MCP server for persistent allowlisted SSH and shell sessions.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hostbridge"
7
- version = "2.0.0"
7
+ version = "2.0.2"
8
8
  description = "HostBridge MCP server for persistent allowlisted SSH and shell sessions."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -8,13 +8,14 @@ import sys
8
8
  from typing import NoReturn
9
9
 
10
10
  __all__ = ["__version__", "ensure_runtime"]
11
- __version__ = "2.0.0"
11
+ __version__ = "2.0.2"
12
12
 
13
13
  # (import_name, pip_name) pairs for runtime dependency probing.
14
14
  _REQUIRED_DEPENDENCIES = (
15
15
  ("mcp", "mcp"),
16
16
  ("pexpect", "pexpect"),
17
17
  ("cryptography", "cryptography"),
18
+ ("asyncssh", "asyncssh"),
18
19
  )
19
20
 
20
21
 
@@ -146,8 +146,18 @@ class HostBridgeClient:
146
146
  def hello(self) -> dict[str, object]:
147
147
  return self.request("system.hello", {})
148
148
 
149
- def open_session(self, host_id: str, *, owner: str | None = None) -> SessionInfo:
150
- result = self.request("session.open", {"host_id": host_id, "owner": owner})
149
+ def open_session(
150
+ self,
151
+ host_id: str,
152
+ *,
153
+ owner: str | None = None,
154
+ timeout: float | None = None,
155
+ ) -> SessionInfo:
156
+ result = self.request(
157
+ "session.open",
158
+ {"host_id": host_id, "owner": owner},
159
+ timeout=timeout,
160
+ )
151
161
  return SessionInfo.from_dict(result)
152
162
 
153
163
  def exec(
@@ -9,6 +9,8 @@ import tempfile
9
9
  from dataclasses import dataclass
10
10
  from pathlib import Path
11
11
 
12
+ from .secrets import SCHEME, encrypt_secret
13
+
12
14
  SCHEMA_VERSION = 1
13
15
  _HOST_ID = re.compile(r"^[A-Za-z0-9_.-]+$")
14
16
  _CONNECTION_STYLES = {"script", "ssh", "command"}
@@ -83,8 +85,19 @@ def _validate_secrets(value: object, source: Path | str, index: int) -> None:
83
85
  for name, secret in value.items():
84
86
  if not isinstance(name, str):
85
87
  raise ValueError(f"{source}.hosts[{index}].secrets contains an invalid name")
86
- encrypted = isinstance(secret, str) and secret.startswith("gAAAA")
87
- environment_ref = isinstance(secret, dict) and set(secret) == {"env"} and isinstance(secret.get("env"), str)
88
+ encrypted = (
89
+ isinstance(secret, dict)
90
+ and set(secret) == {"scheme", "ciphertext"}
91
+ and secret.get("scheme") == SCHEME
92
+ and isinstance(secret.get("ciphertext"), str)
93
+ and bool(secret.get("ciphertext"))
94
+ )
95
+ environment_ref = (
96
+ isinstance(secret, dict)
97
+ and set(secret) == {"env"}
98
+ and isinstance(secret.get("env"), str)
99
+ and bool(secret.get("env"))
100
+ )
88
101
  if not encrypted and not environment_ref:
89
102
  raise ValueError(f"{source}.hosts[{index}] contains plaintext secret {name!r}")
90
103
 
@@ -116,6 +129,12 @@ def migrate_config(source: Path | str, destination: Path | str) -> ConfigDocumen
116
129
  raise ValueError(f"{source_path} contains a non-object host")
117
130
  host = dict(raw_host)
118
131
  host.setdefault("transport", "auto")
132
+ secrets = host.get("secrets")
133
+ if isinstance(secrets, dict):
134
+ host["secrets"] = {
135
+ name: encrypt_secret(secret) if isinstance(secret, str) else secret
136
+ for name, secret in secrets.items()
137
+ }
119
138
  migrated_hosts.append(host)
120
139
  payload = validate_v1_config(
121
140
  {"schema_version": SCHEMA_VERSION, "hosts": migrated_hosts},
@@ -60,7 +60,7 @@ def _version_diagnostic() -> DiagnosticResult:
60
60
  "npm": npm.get("version"),
61
61
  "python": pyproject.get("project", {}).get("version"),
62
62
  }
63
- status = "pass" if len(set(versions.values())) == 1 and __version__ == "2.0.0" else "fail"
63
+ status = "pass" if len(set(versions.values())) == 1 and __version__ == "2.0.2" else "fail"
64
64
  return DiagnosticResult("version", status, "version sources agree" if status == "pass" else "version drift detected", versions)
65
65
 
66
66
 
@@ -7,7 +7,9 @@ import re
7
7
  import shlex
8
8
  import sys
9
9
  import tempfile
10
+ import threading
10
11
  import time
12
+ from contextlib import suppress
11
13
  from dataclasses import dataclass, field
12
14
  from functools import partial
13
15
  from pathlib import Path
@@ -31,6 +33,7 @@ STDIN_READING_COMMANDS = [
31
33
  re.compile(r"(?:^|\s)dd\s+.*\bof="),
32
34
  re.compile(r"(?:^|\s)scp(?:\s+\S+)*\s+-[A-Za-z]*(?:t|f)[A-Za-z]*(?:\s|$)"),
33
35
  ]
36
+ MAX_IDLE_BACKEND_SESSIONS = 4
34
37
 
35
38
 
36
39
  def _to_bytes(chunk: str | bytes) -> bytes:
@@ -39,6 +42,10 @@ def _to_bytes(chunk: str | bytes) -> bytes:
39
42
  return chunk.encode("utf-8")
40
43
 
41
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))
47
+
48
+
42
49
  class HostBridgeSSHServer(asyncssh.SSHServer):
43
50
  def __init__(self, authorized_public_key: str) -> None:
44
51
  self._authorized_public_key = authorized_public_key.strip()
@@ -63,22 +70,26 @@ class HostBridgeSSHProcess:
63
70
  self._timeout = timeout
64
71
  self._output_limit = output_limit
65
72
 
66
- async def __call__(self, process: asyncssh.SSHServerProcess[str]) -> None:
73
+ async def __call__(self, process: asyncssh.SSHServerProcess[bytes]) -> None:
67
74
  session_id = self._session_factory.open()
75
+ reusable = False
68
76
  try:
69
- 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
70
81
  finally:
71
- self._session_factory.close(session_id)
82
+ self._session_factory.close(session_id, reusable=reusable)
72
83
 
73
- 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:
74
85
  command = process.command
75
86
  if not command:
76
87
  await self._run_shell(process, session_id)
77
- return
88
+ return False
78
89
 
79
90
  loop = asyncio.get_running_loop()
80
91
  try:
81
- stdin_data = await self._read_exec_stdin(process)
92
+ stdin_data = await self._read_exec_stdin(process, command)
82
93
  result = await loop.run_in_executor(
83
94
  None,
84
95
  lambda: self._client.exec(
@@ -91,27 +102,39 @@ class HostBridgeSSHProcess:
91
102
  ),
92
103
  )
93
104
  if result.stdout:
94
- output = result.stdout.decode("utf-8", errors="replace")
95
- process.stdout.write(output)
96
- if not output.endswith("\n"):
97
- process.stdout.write("\n")
105
+ process.stdout.write(result.stdout)
98
106
  if result.stderr:
99
- process.stderr.write(result.stderr.decode("utf-8", errors="replace"))
100
- process.exit(124 if result.timed_out else int(result.exit_code or 0))
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
101
110
  except Exception as exc:
102
111
  print(f"hostbridge mock-ssh exec failed: {exc}", file=sys.stderr, flush=True)
103
- process.stderr.write(f"hostbridge mock-ssh error: {exc}\n")
104
- process.exit(1)
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
105
117
 
106
- async def _read_exec_stdin(self, process: asyncssh.SSHServerProcess[str]) -> bytes | None:
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
+ waits_for_eof = _command_reads_stdin(command)
107
125
  if process.stdin.at_eof():
108
- return b""
126
+ return b"" if waits_for_eof else None
109
127
  try:
110
- first_chunk = await asyncio.wait_for(process.stdin.read(4096), timeout=EXEC_STDIN_PROBE_TIMEOUT)
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
+ )
111
132
  except TimeoutError:
133
+ if waits_for_eof:
134
+ raise HostBridgeError("timeout", "timed out waiting for mock-ssh exec stdin") from None
112
135
  return None
113
136
  if not first_chunk:
114
- return b""
137
+ return b"" if waits_for_eof else None
115
138
 
116
139
  first_chunk_bytes = _to_bytes(first_chunk)
117
140
  total = len(first_chunk_bytes)
@@ -129,7 +152,7 @@ class HostBridgeSSHProcess:
129
152
  chunks.append(chunk_bytes)
130
153
  return b"".join(chunks)
131
154
 
132
- async def _run_shell(self, process: asyncssh.SSHServerProcess[str], session_id: str) -> None:
155
+ async def _run_shell(self, process: asyncssh.SSHServerProcess[bytes], session_id: str) -> None:
133
156
  loop = asyncio.get_running_loop()
134
157
  stop = asyncio.Event()
135
158
  input_done = asyncio.Event()
@@ -167,7 +190,7 @@ class HostBridgeSSHProcess:
167
190
  )
168
191
  if result.data:
169
192
  idle_after_input = 0
170
- process.stdout.write(result.data.decode("utf-8", errors="replace"))
193
+ process.stdout.write(result.data)
171
194
  elif input_done.is_set():
172
195
  idle_after_input += 1
173
196
  if idle_after_input >= 5:
@@ -178,7 +201,8 @@ class HostBridgeSSHProcess:
178
201
  break
179
202
  except Exception as exc:
180
203
  print(f"hostbridge mock-ssh shell failed: {exc}", file=sys.stderr, flush=True)
181
- 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"))
182
206
  stop.set()
183
207
 
184
208
  tasks = [asyncio.create_task(pump_input()), asyncio.create_task(pump_output())]
@@ -186,7 +210,7 @@ class HostBridgeSSHProcess:
186
210
  for task in tasks:
187
211
  task.cancel()
188
212
  await asyncio.gather(*tasks, return_exceptions=True)
189
- process.exit(0)
213
+ self._set_exit_status(process, 0)
190
214
 
191
215
 
192
216
  @dataclass
@@ -211,14 +235,74 @@ class HostBridgeSessionFactory:
211
235
  self._client = client
212
236
  self._host_id = host_id
213
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
214
242
 
215
243
  def open(self) -> str:
216
- session = self._client.open_session(self._host_id, owner="mock-ssh")
217
- return session.session_id
218
-
219
- def close(self, session_id: str) -> None:
220
- with _suppress_error():
221
- self._client.close_session(session_id, owner="mock-ssh", force=True)
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(
252
+ self._host_id,
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,
305
+ )
222
306
 
223
307
 
224
308
  class HostBridgeSFTPServer(asyncssh.SFTPServer):
@@ -476,17 +560,19 @@ print(json.dumps(items))
476
560
  except HostBridgeError as exc:
477
561
  raise self._sftp_error(exc) from exc
478
562
  output = result.stdout.decode("utf-8", errors="replace")
563
+ stderr = result.stderr.decode("utf-8", errors="replace")
479
564
  if result.timed_out:
480
565
  print(f"hostbridge mock-ssh sftp command timed out: {command}", file=sys.stderr, flush=True)
481
566
  raise asyncssh.SFTPFailure("remote command timed out")
482
567
  if result.exit_code not in (0, None):
568
+ error_output = "\n".join(part for part in (output, stderr) if part).strip()
483
569
  error = self._sftp_error(
484
- HostBridgeError("remote_error", output or f"remote command exited {result.exit_code}")
570
+ HostBridgeError("remote_error", error_output or f"remote command exited {result.exit_code}")
485
571
  )
486
572
  if isinstance(error, asyncssh.SFTPNoSuchFile):
487
573
  raise error
488
574
  print(
489
- f"hostbridge mock-ssh sftp command failed exit={result.exit_code}: {command}\n{output}",
575
+ f"hostbridge mock-ssh sftp command failed exit={result.exit_code}: {command}\n{error_output}",
490
576
  file=sys.stderr,
491
577
  flush=True,
492
578
  )
@@ -568,6 +654,7 @@ async def create_server_on_available_port(
568
654
  process_factory=process_factory,
569
655
  sftp_factory=sftp_factory,
570
656
  allow_scp=True,
657
+ encoding=None,
571
658
  )
572
659
  return server, int(candidate)
573
660
  except OSError as exc:
@@ -718,8 +805,11 @@ async def serve_mock_ssh(
718
805
  print(f"ssh config: {installed_config_path}", flush=True)
719
806
  print(f"identity file: {client_key_path}", flush=True)
720
807
  print("supports SSH exec, shell, SFTP, and SCP; press Ctrl-C to stop", flush=True)
721
- await server.wait_closed()
722
- return 0
808
+ try:
809
+ await server.wait_closed()
810
+ return 0
811
+ finally:
812
+ session_factory.shutdown()
723
813
 
724
814
 
725
815
  def run_mock_ssh(**kwargs: object) -> int:
@@ -43,6 +43,14 @@ def encrypt_secret(value: str) -> dict[str, str]:
43
43
  def decrypt_secret(payload: Any) -> str:
44
44
  if not isinstance(payload, dict):
45
45
  raise ValueError("encrypted secret must be an object")
46
+ if set(payload) == {"env"}:
47
+ variable = payload.get("env")
48
+ if not isinstance(variable, str) or not variable:
49
+ raise ValueError("secret environment variable name must not be empty")
50
+ value = os.environ.get(variable)
51
+ if value is None:
52
+ raise ValueError(f"secret environment variable is not set: {variable}")
53
+ return value
46
54
  if payload.get("scheme") != SCHEME:
47
55
  raise ValueError(f"unsupported secret scheme: {payload.get('scheme')}")
48
56
  ciphertext = payload.get("ciphertext")
@@ -20,6 +20,7 @@ from ..hosts import HostConfig
20
20
  from .base import ExecResult, ShellReadResult, TransferResult, TransportBusy, TransportCapabilities, TransportError
21
21
 
22
22
  _REMOTE_BASE64_DECODE = "{ base64 --decode 2>/dev/null || base64 -d 2>/dev/null || base64 -D; }"
23
+ PTY_BASE64_LINE_LENGTH = 3072
23
24
 
24
25
 
25
26
  class ChildProcess(Protocol):
@@ -60,6 +61,10 @@ def _default_child_factory(command: str, args: list[str], timeout: int | float)
60
61
 
61
62
 
62
63
  def _iter_base64(chunks: Iterable[bytes]) -> Iterator[str]:
64
+ def split_line(encoded: str) -> Iterator[str]:
65
+ for offset in range(0, len(encoded), PTY_BASE64_LINE_LENGTH):
66
+ yield encoded[offset : offset + PTY_BASE64_LINE_LENGTH]
67
+
63
68
  remainder = b""
64
69
  for chunk in chunks:
65
70
  if not isinstance(chunk, bytes):
@@ -67,10 +72,10 @@ def _iter_base64(chunks: Iterable[bytes]) -> Iterator[str]:
67
72
  data = remainder + chunk
68
73
  complete = len(data) - (len(data) % 3)
69
74
  if complete:
70
- yield base64.b64encode(data[:complete]).decode("ascii")
75
+ yield from split_line(base64.b64encode(data[:complete]).decode("ascii"))
71
76
  remainder = data[complete:]
72
77
  if remainder:
73
- yield base64.b64encode(remainder).decode("ascii")
78
+ yield from split_line(base64.b64encode(remainder).decode("ascii"))
74
79
 
75
80
 
76
81
  def _marker_text(value: object) -> str:
@@ -158,13 +163,12 @@ class PtyTransport:
158
163
  raise TransportError("PTY transport is not alive")
159
164
  if stdin is not None:
160
165
  stdin_path = self._stage_stdin_locked(stdin, timeout=timeout)
161
- command = f"{command} < {shlex.quote(stdin_path)}"
162
- return self._exec_locked(command, timeout=timeout, output_limit=output_limit)
166
+ return self._exec_locked(command, timeout=timeout, output_limit=output_limit, stdin_path=stdin_path)
163
167
  finally:
164
168
  if stdin_path is not None and self.child.isalive():
165
169
  with suppress(Exception):
166
170
  self._exec_locked(
167
- f"rm -f -- {shlex.quote(stdin_path)}",
171
+ f"trap - EXIT HUP INT TERM; rm -f -- {shlex.quote(stdin_path)} {shlex.quote(f'{stdin_path}.b64')}",
168
172
  timeout=min(timeout, 10),
169
173
  output_limit=1024,
170
174
  )
@@ -173,32 +177,50 @@ class PtyTransport:
173
177
  def _stage_stdin_locked(self, chunks: Iterable[bytes], *, timeout: float) -> str:
174
178
  token = self._token_factory()
175
179
  path = f"/tmp/hostbridge.stdin.{token}"
180
+ base64_path = f"{path}.b64"
176
181
  heredoc = f"__{self._marker_prefix}_STDIN_{token}__"
177
182
  done_pattern = re.compile(rf"__{self._marker_prefix}_STDIN_DONE_{re.escape(token)}__:(\d+)")
178
- self.child.send(f"cat > {shlex.quote(path)} <<'{heredoc}'\n")
179
- for encoded in _iter_base64(chunks):
180
- self.child.send(encoded)
181
- self.child.send(
182
- f"\n{heredoc}\n"
183
- f"tmp={shlex.quote(path)}.b64; mv {shlex.quote(path)} \"$tmp\"; "
184
- f"{{ {_REMOTE_BASE64_DECODE} < \"$tmp\"; }} > {shlex.quote(path)}; status=$?; rm -f \"$tmp\"; "
185
- f"printf '\n__{self._marker_prefix}_STDIN_DONE_{token}__:%s\n' \"$status\"\n"
186
- )
187
- self.child.expect(done_pattern, timeout=timeout)
183
+ cleanup = f"rm -f -- {shlex.quote(path)} {shlex.quote(base64_path)}"
184
+ try:
185
+ self.child.send(f"trap {shlex.quote(cleanup)} EXIT HUP INT TERM; cat > {shlex.quote(path)} <<'{heredoc}'\n")
186
+ for encoded in _iter_base64(chunks):
187
+ self.child.send(encoded)
188
+ self.child.send(
189
+ f"\n{heredoc}\n"
190
+ f'tmp={shlex.quote(base64_path)}; mv {shlex.quote(path)} "$tmp"; '
191
+ f'{{ {_REMOTE_BASE64_DECODE} < "$tmp"; }} > {shlex.quote(path)}; status=$?; rm -f "$tmp"; '
192
+ f"printf '\n__{self._marker_prefix}_STDIN_DONE_{token}__:%s\n' \"$status\"\n"
193
+ )
194
+ self.child.expect(done_pattern, timeout=timeout)
195
+ except BaseException:
196
+ if self.child.isalive():
197
+ with suppress(Exception):
198
+ self.child.send("\x03")
199
+ with suppress(Exception):
200
+ self.child.sendline(f"trap - EXIT HUP INT TERM; {cleanup}")
201
+ raise
188
202
  match = re.search(r":(\d+)", _marker_text(self.child.after))
189
203
  if match is None or int(match.group(1)) != 0:
190
204
  raise TransportError("failed to stage PTY stdin")
191
205
  return path
192
206
 
193
- def _exec_locked(self, command: str, *, timeout: float, output_limit: int) -> ExecResult:
207
+ def _exec_locked(
208
+ self,
209
+ command: str,
210
+ *,
211
+ timeout: float,
212
+ output_limit: int,
213
+ stdin_path: str | None = None,
214
+ ) -> ExecResult:
194
215
  token = self._token_factory()
195
216
  start_marker = f"__{self._marker_prefix}_START_{token}__"
196
217
  exit_pattern = re.compile(rf"__{self._marker_prefix}_EXIT_{re.escape(token)}__:(\d+)")
197
218
  encoded_command = base64.b64encode(command.encode("utf-8")).decode("ascii")
219
+ stdin_redirect = f" < {shlex.quote(stdin_path)}" if stdin_path is not None else ""
198
220
  remote_line = (
199
221
  f"__scm_cmd=$(printf %s {shlex.quote(encoded_command)} | {_REMOTE_BASE64_DECODE}); "
200
222
  f"printf '\n{start_marker}\n'; "
201
- f"{self.remote_shell} -lc \"$__scm_cmd\"; "
223
+ f"{self.remote_shell} -lc \"$__scm_cmd\"{stdin_redirect}; "
202
224
  "__scm_status=$?; "
203
225
  f"printf '\n__{self._marker_prefix}_EXIT_{token}__:%s\n' \"$__scm_status\""
204
226
  )
@@ -321,13 +343,20 @@ class PtyTransport:
321
343
  raise TransportError("PTY transport is not alive")
322
344
  remote_q = shlex.quote(remote_path)
323
345
  metadata = (
324
- f"target={remote_q}; bytes=$(wc -c < \"$target\" | tr -d ' '); "
346
+ f"target={remote_q}; "
347
+ 'if [ ! -e "$target" ] && [ ! -L "$target" ]; then printf "__HB_FILE_NOT_FOUND__\\n"; exit 44; fi; '
348
+ 'if [ ! -r "$target" ]; then printf "__HB_PERMISSION_DENIED__\\n"; exit 45; fi; '
349
+ "bytes=$(wc -c < \"$target\" | tr -d ' '); "
325
350
  "if command -v sha256sum >/dev/null 2>&1; then sha=$(sha256sum \"$target\" | awk '{print $1}'); "
326
351
  "else sha=$(shasum -a 256 \"$target\" | awk '{print $1}'); fi; "
327
352
  'printf "BYTES=%s\\nSHA256=%s\\n" "$bytes" "$sha"'
328
353
  )
329
354
  result = self._exec_locked(metadata, timeout=60, output_limit=4096)
330
355
  if result.timed_out or result.exit_code != 0:
356
+ if b"__HB_FILE_NOT_FOUND__" in result.stdout:
357
+ raise TransportError(f"remote file not found: {remote_path}")
358
+ if b"__HB_PERMISSION_DENIED__" in result.stdout:
359
+ raise TransportError(f"permission denied reading remote file: {remote_path}")
331
360
  raise TransportError(f"failed to inspect remote file {remote_path}")
332
361
  fields = self._metadata_fields(result.stdout)
333
362
  byte_count = int(fields.get("BYTES", "0"))