server-management-lib 0.2.0__py3-none-any.whl

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,124 @@
1
+ """
2
+ Server Management Library
3
+
4
+ Shared components for server management MCPs:
5
+
6
+ - Configuration loading (YAML + environment + XDG paths)
7
+ - Security validation (commands, queries, paths)
8
+ - SSH connection management (host key verification by default)
9
+ - InfluxDB and Prometheus HTTP clients
10
+ """
11
+
12
+ from .audit import AuditLogger, create_audit_logger
13
+ from .base import (
14
+ ExecutorBase,
15
+ ExecutorError,
16
+ RemoteDirEntry,
17
+ RemoteFileError,
18
+ RemotePathError,
19
+ )
20
+ from .config import (
21
+ DEFAULT_CONFIG,
22
+ ConfigError,
23
+ ConfigNotFoundError,
24
+ config_schema,
25
+ load_config,
26
+ redact_config_values,
27
+ resolve_config_path,
28
+ )
29
+ from .executor import Executor, create_executor
30
+ from .http_clients import InfluxDBClient, PrometheusClient, parse_duration
31
+ from .local_executor import LocalExecutor
32
+ from .operations import Operation
33
+ from .output import DEFAULT_MAX_OUTPUT_BYTES, sanitize_output, strip_ansi
34
+ from .rate_limit import (
35
+ RateLimitConfig,
36
+ RateLimiter,
37
+ create_rate_limiter,
38
+ )
39
+ from .redaction import REDACTED_VALUE, redact_env_content, redact_secrets
40
+ from .responses import (
41
+ ToolOutput,
42
+ ToolResponse,
43
+ build_response,
44
+ make_structured_tool,
45
+ )
46
+ from .security import (
47
+ SNAPRAID_STATUS_SUDO_ARGV,
48
+ FileAccess,
49
+ SecurityValidator,
50
+ ServiceFilePath,
51
+ )
52
+ from .ssh_manager import SSHConfigurationError, SSHManager
53
+ from .transport import (
54
+ BearerTokenMiddleware,
55
+ is_loopback_host,
56
+ run_streamable_http,
57
+ validate_http_binding,
58
+ )
59
+ from .tunnel import (
60
+ ActiveTunnel,
61
+ SSHTunnel,
62
+ SSHTunnelError,
63
+ TunnelConfig,
64
+ TunnelEndpoint,
65
+ close_metric_tunnels,
66
+ open_metric_tunnels,
67
+ )
68
+
69
+ __all__ = [
70
+ "DEFAULT_CONFIG",
71
+ "DEFAULT_MAX_OUTPUT_BYTES",
72
+ "REDACTED_VALUE",
73
+ "SNAPRAID_STATUS_SUDO_ARGV",
74
+ "ActiveTunnel",
75
+ "AuditLogger",
76
+ "BearerTokenMiddleware",
77
+ "ConfigError",
78
+ "ConfigNotFoundError",
79
+ "Executor",
80
+ "ExecutorBase",
81
+ "ExecutorError",
82
+ "FileAccess",
83
+ "InfluxDBClient",
84
+ "LocalExecutor",
85
+ "Operation",
86
+ "PrometheusClient",
87
+ "RateLimitConfig",
88
+ "RateLimiter",
89
+ "RemoteDirEntry",
90
+ "RemoteFileError",
91
+ "RemotePathError",
92
+ "SSHConfigurationError",
93
+ "SSHManager",
94
+ "SSHTunnel",
95
+ "SSHTunnelError",
96
+ "SecurityValidator",
97
+ "ServiceFilePath",
98
+ "ToolOutput",
99
+ "ToolResponse",
100
+ "TunnelConfig",
101
+ "TunnelEndpoint",
102
+ "__version__",
103
+ "build_response",
104
+ "close_metric_tunnels",
105
+ "config_schema",
106
+ "create_audit_logger",
107
+ "create_executor",
108
+ "create_rate_limiter",
109
+ "is_loopback_host",
110
+ "load_config",
111
+ "make_structured_tool",
112
+ "open_metric_tunnels",
113
+ "parse_duration",
114
+ "redact_config_values",
115
+ "redact_env_content",
116
+ "redact_secrets",
117
+ "resolve_config_path",
118
+ "run_streamable_http",
119
+ "sanitize_output",
120
+ "strip_ansi",
121
+ "validate_http_binding",
122
+ ]
123
+
124
+ __version__ = "0.2.0"
@@ -0,0 +1,80 @@
1
+ """
2
+ Append-only audit log for privileged operations.
3
+
4
+ Every command, file read and blocked operation can be recorded as one JSON
5
+ object per line. Logging is best effort: an unwritable log must never break the
6
+ operation, but it is reported once.
7
+ """
8
+
9
+ import json
10
+ import logging
11
+ import threading
12
+ from datetime import UTC, datetime
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ REDACTED = "***REDACTED***"
19
+ SENSITIVE_KEY_PARTS = ("password", "token", "secret", "api_key", "apikey", "key")
20
+
21
+
22
+ def _redact(value: Any, key: str = "") -> Any:
23
+ if any(part in key.lower() for part in SENSITIVE_KEY_PARTS):
24
+ return REDACTED
25
+ if isinstance(value, dict):
26
+ return {k: _redact(v, str(k)) for k, v in value.items()}
27
+ if isinstance(value, list | tuple):
28
+ return [_redact(item) for item in value]
29
+ return value
30
+
31
+
32
+ class AuditLogger:
33
+ """Write JSONL audit records to ``path`` when enabled."""
34
+
35
+ def __init__(self, path: str | Path | None = None, *, enabled: bool = False):
36
+ self.enabled = bool(enabled and path)
37
+ self.path = Path(path).expanduser() if path else None
38
+ self._lock = threading.Lock()
39
+ self._warned = False
40
+
41
+ def log(self, event: str, **fields: Any) -> None:
42
+ if not self.enabled or self.path is None:
43
+ return
44
+
45
+ record = {
46
+ "ts": datetime.now(UTC).isoformat(timespec="milliseconds"),
47
+ "event": event,
48
+ **_redact(fields),
49
+ }
50
+ try:
51
+ parent = self.path.parent
52
+ parent_existed = parent.exists()
53
+ parent.mkdir(parents=True, exist_ok=True, mode=0o700)
54
+ if not parent_existed:
55
+ # mkdir honours the umask; make the new audit directory private.
56
+ parent.chmod(0o700)
57
+
58
+ line = json.dumps(record, default=str)
59
+ with self._lock, self.path.open("a", encoding="utf-8") as handle:
60
+ # The file may have been created by an older release with the
61
+ # default mode; tighten it on every write.
62
+ self.path.chmod(0o600)
63
+ handle.write(line + "\n")
64
+ except OSError as e: # pragma: no cover - environment dependent
65
+ if not self._warned:
66
+ logger.warning("Cannot write audit log %s: %s", self.path, e)
67
+ self._warned = True
68
+
69
+
70
+ def create_audit_logger(config: dict) -> AuditLogger:
71
+ """Build an :class:`AuditLogger` from the ``audit`` config section.
72
+
73
+ The HTTP clients receive the same logger as the executors so query
74
+ metadata is recorded in one audit log.
75
+ """
76
+ audit_config = config.get("audit", {}) or {}
77
+ return AuditLogger(
78
+ audit_config.get("path"),
79
+ enabled=audit_config.get("enabled", False),
80
+ )
@@ -0,0 +1,55 @@
1
+ """
2
+ Shared executor primitives.
3
+
4
+ An executor runs validated commands and reads remote/local files. SSH and local
5
+ implementations share output limits, audit logging and path-confinement rules.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+
10
+ from .audit import create_audit_logger
11
+ from .output import DEFAULT_MAX_OUTPUT_BYTES
12
+ from .security import SecurityValidator
13
+
14
+
15
+ class ExecutorError(RuntimeError):
16
+ """Base class for executor errors."""
17
+
18
+
19
+ class RemotePathError(ExecutorError):
20
+ """Raised when a path escapes its allowed base directory."""
21
+
22
+
23
+ class RemoteFileError(ExecutorError):
24
+ """Raised when a file cannot be read safely."""
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class RemoteDirEntry:
29
+ """A single directory entry returned by an executor."""
30
+
31
+ name: str
32
+ longname: str
33
+ size: int
34
+
35
+
36
+ def is_within(path: str, base: str) -> bool:
37
+ """Return True when ``path`` is ``base`` or below it (component-aware)."""
38
+ base = base.rstrip("/")
39
+ return path == base or path.startswith(base + "/")
40
+
41
+
42
+ class ExecutorBase:
43
+ """Configuration, output limits and audit logging shared by executors."""
44
+
45
+ def __init__(self, config: dict, security: SecurityValidator):
46
+ self.config = config
47
+ self.security = security
48
+ self.services_base_path = security.services_base_path
49
+
50
+ output_config = config.get("output", {}) or {}
51
+ self.max_output_bytes = int(
52
+ output_config.get("max_bytes", DEFAULT_MAX_OUTPUT_BYTES)
53
+ )
54
+
55
+ self.audit = create_audit_logger(config)
@@ -0,0 +1,385 @@
1
+ """
2
+ Configuration loader for Server Management Library.
3
+
4
+ Loads YAML configuration with sensible defaults for SSH, security,
5
+ InfluxDB, Prometheus, and host data source settings.
6
+
7
+ Resolution order (first match wins):
8
+
9
+ 1. explicit ``config_path`` argument (``--config``)
10
+ 2. the path in ``env_var`` (for example ``REMOTE_SERVER_MCP_CONFIG``)
11
+ 3. ``$XDG_CONFIG_HOME/<app_name>/config.yaml`` (``~/.config`` by default)
12
+ 4. ``./config.yaml`` (repo checkout / local development)
13
+
14
+ Values from the environment override the YAML file, so secrets can stay out of
15
+ the config file. A ``.env`` file next to the config file is loaded first (when
16
+ ``python-dotenv`` is installed), which matches the ``.env.example`` files.
17
+ """
18
+
19
+ import copy
20
+ import logging
21
+ import os
22
+ from collections.abc import Mapping
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ import yaml
27
+
28
+ from .security import DEFAULT_HOST_UNITS
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ DEFAULT_CONFIG: dict[str, Any] = {
33
+ # "ssh" (default) or "local" (run on the host where the MCP server runs)
34
+ "executor": "ssh",
35
+ # Container runtime used by the remote-server MCP operation catalog.
36
+ "container_runtime": "docker",
37
+ # MCP transport. stdio is the default; streamable-http is opt-in.
38
+ "transport": {
39
+ "type": "stdio",
40
+ "host": "127.0.0.1",
41
+ "port": 8000,
42
+ "path": "/mcp",
43
+ # Required for a non-loopback bind. Prefer MCP_HTTP_TOKEN over YAML.
44
+ "token": None,
45
+ },
46
+ # Sliding-window limit for destructive/state-changing tool calls.
47
+ "rate_limit": {
48
+ "enabled": True,
49
+ "max_calls": 10,
50
+ "window_seconds": 60,
51
+ },
52
+ "ssh": {
53
+ "host": "localhost",
54
+ "port": 22,
55
+ # None means: use the current user / ~/.ssh/config / ssh-agent.
56
+ "username": None,
57
+ "password": None,
58
+ "key_path": None,
59
+ # Path to known_hosts, or "none" to disable verification (insecure).
60
+ "known_hosts": "~/.ssh/known_hosts",
61
+ "connect_timeout": 10,
62
+ },
63
+ "security": {
64
+ # Service directory base path (for remote-server-mcp)
65
+ "services_path": "/srv",
66
+ # NEVER allow generic command execution
67
+ "allow_generic_commands": False,
68
+ # Container names that may be managed without a matching service
69
+ # directory. Containers that belong to a compose project below
70
+ # services_path do not need to be listed here.
71
+ "allowed_containers": [],
72
+ # Host systemd units that get_host_service_status/logs may inspect.
73
+ "host_units": list(DEFAULT_HOST_UNITS),
74
+ },
75
+ "host": {
76
+ "enabled": False,
77
+ },
78
+ "health": {
79
+ # Absolute mount points reported by remote-server-mcp's
80
+ # get_server_health. NAS pools are separate mounts, so list each one.
81
+ "disk_paths": ["/"],
82
+ },
83
+ "influxdb": {
84
+ "enabled": False,
85
+ "host": "localhost",
86
+ "port": 8181,
87
+ "use_https": False,
88
+ "database": None,
89
+ "token": None,
90
+ "query_limit": 1000,
91
+ # Optional local port forward to a loopback-only InfluxDB. When
92
+ # enabled, host/port are rewritten to the tunnel endpoint at startup.
93
+ "ssh_tunnel": {
94
+ "enabled": False,
95
+ "remote_host": "127.0.0.1",
96
+ "remote_port": None,
97
+ "local_host": "127.0.0.1",
98
+ "local_port": 0,
99
+ },
100
+ },
101
+ "prometheus": {
102
+ "enabled": False,
103
+ "host": "localhost",
104
+ "port": 9090,
105
+ "use_https": False,
106
+ "token": None,
107
+ "query_timeout": "30s",
108
+ "ssh_tunnel": {
109
+ "enabled": False,
110
+ "remote_host": "127.0.0.1",
111
+ "remote_port": None,
112
+ "local_host": "127.0.0.1",
113
+ "local_port": 0,
114
+ },
115
+ },
116
+ "output": {
117
+ # Maximum bytes returned to the model per command/file/query.
118
+ "max_bytes": 64000,
119
+ },
120
+ "audit": {
121
+ "enabled": False,
122
+ "path": "~/.local/state/server-management/audit.jsonl",
123
+ },
124
+ }
125
+
126
+ # Environment variable -> (section, key, converter). ``section`` is ``None``
127
+ # for top-level scalar settings such as ``executor`` and ``container_runtime``.
128
+ ENV_OVERRIDES: dict[str, tuple[str | None, str, type]] = {
129
+ "EXECUTOR": (None, "executor", str),
130
+ "CONTAINER_RUNTIME": (None, "container_runtime", str),
131
+ "SSH_HOST": ("ssh", "host", str),
132
+ "SSH_PORT": ("ssh", "port", int),
133
+ "SSH_USERNAME": ("ssh", "username", str),
134
+ "SSH_PASSWORD": ("ssh", "password", str),
135
+ "SSH_KEY_PATH": ("ssh", "key_path", str),
136
+ "SSH_KNOWN_HOSTS": ("ssh", "known_hosts", str),
137
+ "INFLUXDB_HOST": ("influxdb", "host", str),
138
+ "INFLUXDB_PORT": ("influxdb", "port", int),
139
+ "INFLUXDB_USE_HTTPS": ("influxdb", "use_https", bool),
140
+ "INFLUXDB_DATABASE": ("influxdb", "database", str),
141
+ "INFLUXDB_TOKEN": ("influxdb", "token", str),
142
+ "PROMETHEUS_HOST": ("prometheus", "host", str),
143
+ "PROMETHEUS_PORT": ("prometheus", "port", int),
144
+ "PROMETHEUS_USE_HTTPS": ("prometheus", "use_https", bool),
145
+ "PROMETHEUS_TOKEN": ("prometheus", "token", str),
146
+ "MCP_TRANSPORT": ("transport", "type", str),
147
+ "MCP_HTTP_HOST": ("transport", "host", str),
148
+ "MCP_HTTP_PORT": ("transport", "port", int),
149
+ "MCP_HTTP_PATH": ("transport", "path", str),
150
+ "MCP_HTTP_TOKEN": ("transport", "token", str),
151
+ "OUTPUT_MAX_BYTES": ("output", "max_bytes", int),
152
+ "AUDIT_ENABLED": ("audit", "enabled", bool),
153
+ "AUDIT_LOG_PATH": ("audit", "path", str),
154
+ }
155
+
156
+
157
+ class ConfigError(Exception):
158
+ """Base class for configuration problems."""
159
+
160
+
161
+ class ConfigNotFoundError(ConfigError, FileNotFoundError):
162
+ """No configuration file could be located."""
163
+
164
+
165
+ def _as_bool(value: str) -> bool:
166
+ return value.strip().lower() in {"1", "true", "yes", "on"}
167
+
168
+
169
+ def _convert(value: str, converter: type) -> Any:
170
+ if converter is bool:
171
+ return _as_bool(value)
172
+ return converter(value)
173
+
174
+
175
+ def _deep_merge(base: Mapping[str, Any], override: Mapping[str, Any]) -> dict:
176
+ """Merge ``override`` on top of ``base`` without mutating either."""
177
+ result: dict[str, Any] = copy.deepcopy(dict(base))
178
+ for key, value in override.items():
179
+ current = result.get(key)
180
+ if value is None and isinstance(current, dict):
181
+ # An empty YAML section (e.g. ``influxdb:``) keeps the defaults.
182
+ continue
183
+ if isinstance(value, Mapping) and isinstance(current, Mapping):
184
+ result[key] = _deep_merge(current, value)
185
+ else:
186
+ result[key] = copy.deepcopy(value)
187
+ return result
188
+
189
+
190
+ def resolve_config_path(
191
+ explicit: Path | str | None = None,
192
+ *,
193
+ env_var: str | None = None,
194
+ app_name: str | None = None,
195
+ ) -> Path | None:
196
+ """Return the configuration file path, or ``None`` if none was found.
197
+
198
+ An explicit path is returned even if the file does not exist; the caller
199
+ decides whether that is an error.
200
+ """
201
+ if explicit is not None:
202
+ return Path(explicit).expanduser()
203
+
204
+ if env_var:
205
+ value = os.environ.get(env_var)
206
+ if value:
207
+ return Path(value).expanduser()
208
+
209
+ if app_name:
210
+ xdg_home = os.environ.get("XDG_CONFIG_HOME")
211
+ base = Path(xdg_home).expanduser() if xdg_home else Path.home() / ".config"
212
+ candidate = base / app_name / "config.yaml"
213
+ if candidate.exists():
214
+ return candidate
215
+
216
+ cwd_candidate = Path.cwd() / "config.yaml"
217
+ if cwd_candidate.exists():
218
+ return cwd_candidate
219
+
220
+ return None
221
+
222
+
223
+ def _apply_env_overrides(
224
+ config: dict[str, Any],
225
+ env: Mapping[str, str],
226
+ ) -> None:
227
+ for env_name, (section, key, converter) in ENV_OVERRIDES.items():
228
+ value = env.get(env_name)
229
+ if value is None or value == "":
230
+ continue
231
+ try:
232
+ converted = _convert(value, converter)
233
+ if section is None:
234
+ config[key] = converted
235
+ continue
236
+ section_config = config.get(section)
237
+ if not isinstance(section_config, dict):
238
+ section_config = {}
239
+ config[section] = section_config
240
+ section_config[key] = converted
241
+ except (TypeError, ValueError):
242
+ logger.warning("Ignoring invalid value for %s", env_name)
243
+
244
+
245
+ def _load_dotenv(config_file: Path) -> None:
246
+ """Load ``.env`` next to the config file (best effort)."""
247
+ try:
248
+ from dotenv import load_dotenv
249
+ except ImportError: # pragma: no cover - optional dependency
250
+ return
251
+
252
+ env_file = config_file.parent / ".env"
253
+ if env_file.exists():
254
+ load_dotenv(env_file)
255
+
256
+
257
+ def load_config(
258
+ config_path: Path | str | None = None,
259
+ *,
260
+ env_var: str | None = None,
261
+ app_name: str | None = None,
262
+ required: bool = False,
263
+ env: Mapping[str, str] | None = None,
264
+ use_dotenv: bool = True,
265
+ log_missing: bool = True,
266
+ ) -> dict:
267
+ """Load configuration from YAML, merged over :data:`DEFAULT_CONFIG`.
268
+
269
+ Args:
270
+ config_path: Explicit path; when ``None`` the usual locations are probed.
271
+ env_var: Environment variable holding a config path.
272
+ app_name: Application name used for the XDG location.
273
+ required: Raise :class:`ConfigNotFoundError` instead of using defaults.
274
+ env: Environment mapping for overrides (defaults to ``os.environ``).
275
+ use_dotenv: Load a ``.env`` file next to the resolved config file.
276
+ log_missing: Log a warning when no config file is found. Disable this
277
+ for the best-effort import-time load in servers.
278
+
279
+ Raises:
280
+ ConfigNotFoundError: No config found (or explicit path missing) and
281
+ ``required`` is true.
282
+ ConfigError: The config file exists but cannot be parsed, and
283
+ ``required`` is true.
284
+ """
285
+ resolved = resolve_config_path(config_path, env_var=env_var, app_name=app_name)
286
+
287
+ searched = _describe_search_paths(config_path, env_var, app_name)
288
+ environment = os.environ if env is None else env
289
+
290
+ def defaults_with_overrides() -> dict:
291
+ config = _deep_merge(DEFAULT_CONFIG, {})
292
+ _apply_env_overrides(config, environment)
293
+ return config
294
+
295
+ if resolved is None or not resolved.exists():
296
+ if required:
297
+ raise ConfigNotFoundError(
298
+ "No configuration file found. Searched: "
299
+ f"{searched}. Create one from config.example.yaml or pass "
300
+ "--config / set the config environment variable."
301
+ )
302
+ if log_missing:
303
+ logger.warning(
304
+ "No configuration file found (searched: %s); using defaults",
305
+ searched,
306
+ )
307
+ return defaults_with_overrides()
308
+
309
+ if use_dotenv and env is None:
310
+ _load_dotenv(resolved)
311
+
312
+ try:
313
+ with open(resolved) as f:
314
+ loaded = yaml.safe_load(f)
315
+ except Exception as e:
316
+ if required:
317
+ raise ConfigError(f"Error loading config from {resolved}: {e}") from e
318
+ logger.error("Error loading config from %s: %s", resolved, e)
319
+ logger.warning("Using default configuration")
320
+ return defaults_with_overrides()
321
+
322
+ if loaded is None:
323
+ loaded = {}
324
+ if not isinstance(loaded, Mapping):
325
+ message = f"Configuration in {resolved} must be a YAML mapping"
326
+ if required:
327
+ raise ConfigError(message)
328
+ logger.error(message)
329
+ return defaults_with_overrides()
330
+
331
+ config = _deep_merge(DEFAULT_CONFIG, loaded)
332
+ _apply_env_overrides(config, environment)
333
+ logger.info("Loaded configuration from %s", resolved)
334
+ return config
335
+
336
+
337
+ # Values that must never be published by the config-schema MCP resource.
338
+ SECRET_CONFIG_KEYS = frozenset({"token", "password"})
339
+
340
+
341
+ def redact_config_values(value: Any) -> Any:
342
+ """Return a copy of a config mapping with credential-like values masked."""
343
+ if isinstance(value, Mapping):
344
+ return {
345
+ key: (
346
+ "***"
347
+ if str(key).lower() in SECRET_CONFIG_KEYS
348
+ else redact_config_values(item)
349
+ )
350
+ for key, item in value.items()
351
+ }
352
+ if isinstance(value, list):
353
+ return [redact_config_values(item) for item in value]
354
+ return value
355
+
356
+
357
+ def config_schema() -> dict[str, Any]:
358
+ """Describe the configuration defaults and environment overrides.
359
+
360
+ Used by the MCP config resource. Credential-like values are masked, and
361
+ environment variable names are listed without their values.
362
+ """
363
+ return {
364
+ "defaults": redact_config_values(DEFAULT_CONFIG),
365
+ "environment_overrides": {
366
+ name: {"section": key if section is None else section, "key": key}
367
+ for name, (section, key, _converter) in sorted(ENV_OVERRIDES.items())
368
+ },
369
+ }
370
+
371
+
372
+ def _describe_search_paths(
373
+ config_path: Path | str | None,
374
+ env_var: str | None,
375
+ app_name: str | None,
376
+ ) -> str:
377
+ if config_path is not None:
378
+ return str(Path(config_path).expanduser())
379
+ parts = []
380
+ if env_var:
381
+ parts.append(f"${env_var}")
382
+ if app_name:
383
+ parts.append(f"$XDG_CONFIG_HOME/{app_name}/config.yaml (~/.config fallback)")
384
+ parts.append("./config.yaml")
385
+ return ", ".join(parts)
@@ -0,0 +1,53 @@
1
+ """
2
+ Executor factory.
3
+
4
+ Selects the SSH or local implementation from ``config["executor"]``. Both
5
+ implement the same command/file interface, so MCP tools do not need to know
6
+ where they run.
7
+ """
8
+
9
+ from typing import Protocol
10
+
11
+ from .base import RemoteDirEntry
12
+ from .local_executor import LocalExecutor
13
+ from .security import SecurityValidator
14
+ from .ssh_manager import SSHManager
15
+
16
+
17
+ class Executor(Protocol):
18
+ """Interface shared by :class:`SSHManager` and :class:`LocalExecutor`."""
19
+
20
+ services_base_path: str
21
+ max_output_bytes: int
22
+
23
+ async def connect(self) -> None: ...
24
+
25
+ async def disconnect(self) -> None: ...
26
+
27
+ async def execute_safe_command(self, command: str, timeout: int = 30) -> str: ...
28
+
29
+ async def check_service_exists(self, service: str) -> bool: ...
30
+
31
+ async def read_remote_file(
32
+ self,
33
+ path: str,
34
+ *,
35
+ allowed_base: str | None = None,
36
+ max_bytes: int | None = None,
37
+ ) -> str: ...
38
+
39
+ async def list_remote_dir(
40
+ self, path: str, *, allowed_base: str | None = None
41
+ ) -> list[RemoteDirEntry]: ...
42
+
43
+
44
+ def create_executor(
45
+ config: dict, security: SecurityValidator
46
+ ) -> SSHManager | LocalExecutor:
47
+ """Create the executor selected by ``config["executor"]`` (default: ssh)."""
48
+ mode = str(config.get("executor", "ssh")).strip().lower()
49
+ if mode == "local":
50
+ return LocalExecutor(config, security)
51
+ if mode == "ssh":
52
+ return SSHManager(config, security)
53
+ raise ValueError(f"Unknown executor {mode!r}; expected 'ssh' or 'local'")