Route remote workspace filesystem operations through the extracted files router without starting agent-file synchronization. Resolve SSH download targets before applying sensitive-path policy and stream authorized files without buffering or preview-size limits. Tests: python -m pytest tests/hermes_cli/test_web_server_fs.py tests/hermes_cli/test_ssh_workspace_fs.py tests/tools/test_ssh_environment.py -q. Original implementation authored by fangliquanflq.
294 lines
12 KiB
Python
294 lines
12 KiB
Python
"""Filesystem adapter for Desktop workspaces executed over SSH."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import binascii
|
|
import posixpath
|
|
import shlex
|
|
import subprocess
|
|
import threading
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from typing import Any, Iterator
|
|
|
|
from hermes_cli._subprocess_compat import windows_hide_flags
|
|
from tools.environments.ssh import SSHEnvironment
|
|
|
|
|
|
class SshWorkspaceFsError(RuntimeError):
|
|
"""A filesystem error reported by the SSH execution target."""
|
|
|
|
def __init__(self, code: str, message: str | None = None):
|
|
super().__init__(message or code)
|
|
self.code = code
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _SshFsConfig:
|
|
host: str
|
|
user: str
|
|
port: int
|
|
key_path: str
|
|
cwd: str
|
|
timeout: int
|
|
|
|
|
|
_CACHE_LOCK = threading.RLock()
|
|
_BACKENDS: dict[str, tuple[_SshFsConfig, "SshWorkspaceFs"]] = {}
|
|
|
|
|
|
class SshWorkspaceFs:
|
|
"""Binary-safe workspace operations using the configured SSH transport."""
|
|
|
|
def __init__(self, env: SSHEnvironment):
|
|
self._env = env
|
|
self._lock = threading.RLock()
|
|
|
|
@property
|
|
def cwd(self) -> str:
|
|
return self._normalize(self._env.cwd)
|
|
|
|
def _normalize(self, path: str) -> str:
|
|
raw = str(path or "").strip()
|
|
if not raw:
|
|
raise SshWorkspaceFsError("EINVAL", "Path is required")
|
|
if "\0" in raw or raw.lower().startswith("file:"):
|
|
raise SshWorkspaceFsError("EINVAL", "Invalid path")
|
|
if raw == "~":
|
|
raw = self._env._remote_home
|
|
elif raw.startswith("~/"):
|
|
raw = posixpath.join(self._env._remote_home, raw[2:])
|
|
elif not raw.startswith("/"):
|
|
raw = posixpath.join(self._env.cwd, raw)
|
|
return posixpath.normpath(raw)
|
|
|
|
def _execute(self, command: str, *, stdin_data: str | None = None, timeout: int | None = None) -> dict:
|
|
with self._lock:
|
|
return self._env.execute(
|
|
command,
|
|
cwd=self._env.cwd,
|
|
stdin_data=stdin_data,
|
|
timeout=timeout or self._env.timeout,
|
|
)
|
|
|
|
@staticmethod
|
|
def _error_code(output: str, fallback: str = "EIO") -> str:
|
|
marker = "__HERMES_FS_ERROR__:"
|
|
for line in (output or "").splitlines():
|
|
if line.startswith(marker):
|
|
return line[len(marker) :].strip() or fallback
|
|
return fallback
|
|
|
|
def list_dir(self, path: str, hidden_names: set[str] | frozenset[str]) -> dict[str, Any]:
|
|
target = self._normalize(path)
|
|
quoted = shlex.quote(target)
|
|
command = f"""
|
|
p={quoted}
|
|
if [ ! -e "$p" ] && [ ! -L "$p" ]; then printf '__HERMES_FS_ERROR__:ENOENT\\n'; exit 44; fi
|
|
if [ ! -d "$p" ]; then printf '__HERMES_FS_ERROR__:ENOTDIR\\n'; exit 45; fi
|
|
if [ ! -r "$p" ]; then printf '__HERMES_FS_ERROR__:EACCES\\n'; exit 46; fi
|
|
for child in "$p"/* "$p"/.[!.]* "$p"/..?*; do
|
|
[ -e "$child" ] || [ -L "$child" ] || continue
|
|
name=${{child##*/}}
|
|
name64=$(printf '%s' "$name" | base64 | tr -d '\\r\\n')
|
|
path64=$(printf '%s' "$child" | base64 | tr -d '\\r\\n')
|
|
if [ -d "$child" ]; then kind=d; else kind=f; fi
|
|
printf '%s\\t%s\\t%s\\n' "$name64" "$path64" "$kind"
|
|
done
|
|
"""
|
|
result = self._execute(command)
|
|
if result.get("returncode") != 0:
|
|
return {"entries": [], "error": self._error_code(result.get("output", ""), "read-error")}
|
|
|
|
entries = []
|
|
try:
|
|
for line in result.get("output", "").splitlines():
|
|
if not line.strip():
|
|
continue
|
|
name64, path64, kind = line.split("\t", 2)
|
|
name = base64.b64decode(name64, validate=True).decode("utf-8", errors="replace")
|
|
child_path = base64.b64decode(path64, validate=True).decode("utf-8", errors="replace")
|
|
if name in hidden_names:
|
|
continue
|
|
entries.append({"name": name, "path": child_path, "isDirectory": kind == "d"})
|
|
except (ValueError, UnicodeError, binascii.Error) as exc:
|
|
raise SshWorkspaceFsError("EIO", "SSH filesystem returned an invalid directory listing") from exc
|
|
|
|
entries.sort(key=lambda item: (not item["isDirectory"], item["name"].lower(), item["name"]))
|
|
return {"entries": entries}
|
|
|
|
def read_bytes(
|
|
self,
|
|
path: str,
|
|
*,
|
|
max_bytes: int,
|
|
read_limit: int | None = None,
|
|
) -> tuple[bytes, int, str]:
|
|
target = self._normalize(path)
|
|
quoted = shlex.quote(target)
|
|
read_command = (
|
|
f"set -o pipefail; head -c {int(read_limit)} < \"$p\" | base64"
|
|
if read_limit is not None
|
|
else 'base64 < "$p"'
|
|
)
|
|
command = f"""
|
|
p={quoted}
|
|
if [ ! -e "$p" ] && [ ! -L "$p" ]; then printf '__HERMES_FS_ERROR__:ENOENT\\n'; exit 44; fi
|
|
if [ ! -f "$p" ]; then printf '__HERMES_FS_ERROR__:ENOTREG\\n'; exit 45; fi
|
|
size=$(stat -c %s "$p" 2>/dev/null || stat -f %z "$p" 2>/dev/null) || {{ printf '__HERMES_FS_ERROR__:EACCES\\n'; exit 46; }}
|
|
if [ "$size" -gt {int(max_bytes)} ]; then printf '__HERMES_FS_ERROR__:EFBIG\\n'; exit 47; fi
|
|
printf '__HERMES_FS_SIZE__:%s\\n' "$size"
|
|
{{ {read_command}; }} || {{ printf '__HERMES_FS_ERROR__:EACCES\\n'; exit 46; }}
|
|
"""
|
|
result = self._execute(command)
|
|
output = result.get("output", "")
|
|
if result.get("returncode") != 0:
|
|
raise SshWorkspaceFsError(self._error_code(output))
|
|
lines = output.splitlines()
|
|
if not lines or not lines[0].startswith("__HERMES_FS_SIZE__:"):
|
|
raise SshWorkspaceFsError("EIO", "SSH filesystem did not return a file size")
|
|
try:
|
|
size = int(lines[0].split(":", 1)[1])
|
|
except ValueError as exc:
|
|
raise SshWorkspaceFsError("EIO", "SSH filesystem returned an invalid file size") from exc
|
|
encoded = "".join(lines[1:])
|
|
try:
|
|
data = base64.b64decode(encoded, validate=True)
|
|
except (ValueError, binascii.Error) as exc:
|
|
raise SshWorkspaceFsError("EIO", "SSH filesystem returned invalid file data") from exc
|
|
return data, size, target
|
|
|
|
def inspect_file(self, path: str) -> tuple[str, int]:
|
|
"""Return a regular file's resolved remote path and size without reading it."""
|
|
target = self._normalize(path)
|
|
command = f"""
|
|
p={shlex.quote(target)}
|
|
target=$(realpath "$p" 2>/dev/null || readlink -f "$p" 2>/dev/null) || {{ printf '__HERMES_FS_ERROR__:ENOENT\\n'; exit 44; }}
|
|
if [ ! -f "$target" ]; then printf '__HERMES_FS_ERROR__:ENOTREG\\n'; exit 45; fi
|
|
size=$(stat -c %s "$target" 2>/dev/null || stat -f %z "$target" 2>/dev/null) || {{ printf '__HERMES_FS_ERROR__:EACCES\\n'; exit 46; }}
|
|
printf '__HERMES_FS_TARGET__:%s\\n' "$(printf '%s' "$target" | base64 | tr -d '\\r\\n')"
|
|
printf '__HERMES_FS_SIZE__:%s\\n' "$size"
|
|
"""
|
|
result = self._execute(command)
|
|
output = result.get("output", "")
|
|
if result.get("returncode") != 0:
|
|
raise SshWorkspaceFsError(self._error_code(output))
|
|
target_line = next((line for line in output.splitlines() if line.startswith("__HERMES_FS_TARGET__:")), "")
|
|
size_line = next((line for line in output.splitlines() if line.startswith("__HERMES_FS_SIZE__:")), "")
|
|
if not target_line or not size_line:
|
|
raise SshWorkspaceFsError("EIO", "SSH filesystem did not return file metadata")
|
|
try:
|
|
resolved = base64.b64decode(target_line.split(":", 1)[1], validate=True).decode("utf-8", errors="strict")
|
|
size = int(size_line.split(":", 1)[1])
|
|
except (ValueError, UnicodeError, binascii.Error) as exc:
|
|
raise SshWorkspaceFsError("EIO", "SSH filesystem returned invalid file metadata") from exc
|
|
return resolved, size
|
|
|
|
def stream_file(self, path: str, chunk_size: int = 64 * 1024) -> Iterator[bytes]:
|
|
"""Stream an already-authorized remote file without buffering it in memory."""
|
|
target = self._normalize(path)
|
|
command = self._env._build_ssh_command()
|
|
command.extend(["bash", "-c", shlex.quote(f"exec cat < {shlex.quote(target)}")])
|
|
process = subprocess.Popen(
|
|
command,
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.DEVNULL,
|
|
creationflags=windows_hide_flags(),
|
|
)
|
|
try:
|
|
assert process.stdout is not None
|
|
while chunk := process.stdout.read(chunk_size):
|
|
yield chunk
|
|
finally:
|
|
if process.poll() is None:
|
|
process.terminate()
|
|
process.wait()
|
|
|
|
def write_text(self, path: str, content: str, *, max_bytes: int) -> tuple[str, int]:
|
|
target = self._normalize(path)
|
|
encoded_size = len(content.encode("utf-8"))
|
|
if encoded_size > max_bytes:
|
|
raise SshWorkspaceFsError("EFBIG")
|
|
parent = posixpath.dirname(target) or "/"
|
|
temp_path = posixpath.join(parent, f".{posixpath.basename(target)}.hermes-tmp-{uuid.uuid4().hex[:12]}")
|
|
q_target = shlex.quote(target)
|
|
q_parent = shlex.quote(parent)
|
|
q_temp = shlex.quote(temp_path)
|
|
command = f"""
|
|
target={q_target}
|
|
parent={q_parent}
|
|
tmp={q_temp}
|
|
if [ ! -d "$parent" ]; then printf '__HERMES_FS_ERROR__:ENOENT\\n'; exit 44; fi
|
|
if [ -e "$target" ] && [ ! -f "$target" ]; then printf '__HERMES_FS_ERROR__:ENOTREG\\n'; exit 45; fi
|
|
trap 'rm -f "$tmp"' EXIT HUP INT TERM
|
|
cat > "$tmp" && mv -f "$tmp" "$target"
|
|
"""
|
|
result = self._execute(command, stdin_data=content)
|
|
if result.get("returncode") != 0 or result.get("stdin_error"):
|
|
raise SshWorkspaceFsError(self._error_code(result.get("output", "")))
|
|
return target, encoded_size
|
|
|
|
def git_root(self, path: str) -> str | None:
|
|
target = self._normalize(path)
|
|
quoted = shlex.quote(target)
|
|
command = f"""
|
|
p={quoted}
|
|
[ -d "$p" ] || p=${{p%/*}}
|
|
while [ -n "$p" ]; do
|
|
if [ -e "$p/.git" ]; then printf '%s\\n' "$p"; exit 0; fi
|
|
[ "$p" = / ] && break
|
|
p=${{p%/*}}; [ -n "$p" ] || p=/
|
|
done
|
|
exit 1
|
|
"""
|
|
result = self._execute(command)
|
|
if result.get("returncode") != 0:
|
|
return None
|
|
return (result.get("output") or "").strip() or None
|
|
|
|
def git_branch(self, path: str) -> str:
|
|
target = self._normalize(path)
|
|
result = self._execute(f"git -C {shlex.quote(target)} branch --show-current 2>/dev/null")
|
|
return (result.get("output") or "").strip() if result.get("returncode") == 0 else ""
|
|
|
|
|
|
def get_ssh_workspace_fs(profile_key: str, terminal_config: dict[str, Any]) -> SshWorkspaceFs | None:
|
|
"""Return a cached adapter when this profile selects the SSH backend."""
|
|
if str(terminal_config.get("backend") or "local").strip().lower() != "ssh":
|
|
return None
|
|
|
|
config = _SshFsConfig(
|
|
host=str(terminal_config.get("ssh_host") or "").strip(),
|
|
user=str(terminal_config.get("ssh_user") or "").strip(),
|
|
port=int(terminal_config.get("ssh_port") or 22),
|
|
key_path=str(terminal_config.get("ssh_key") or "").strip(),
|
|
cwd=str(terminal_config.get("cwd") or "~").strip() or "~",
|
|
timeout=int(terminal_config.get("timeout") or 180),
|
|
)
|
|
if not config.host or not config.user:
|
|
raise SshWorkspaceFsError("ECONN", "SSH host and user are required")
|
|
|
|
with _CACHE_LOCK:
|
|
cached = _BACKENDS.get(profile_key)
|
|
if cached and cached[0] == config:
|
|
return cached[1]
|
|
if cached:
|
|
cached[1]._env.cleanup()
|
|
try:
|
|
env = SSHEnvironment(
|
|
host=config.host,
|
|
user=config.user,
|
|
cwd=config.cwd,
|
|
timeout=config.timeout,
|
|
port=config.port,
|
|
key_path=config.key_path,
|
|
sync_files=False,
|
|
)
|
|
except Exception as exc:
|
|
raise SshWorkspaceFsError("ECONN", "Could not connect to the SSH workspace") from exc
|
|
backend = SshWorkspaceFs(env)
|
|
_BACKENDS[profile_key] = (config, backend)
|
|
return backend
|