@neoline/hostbridge 2.0.2 → 2.0.4

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/README.md CHANGED
@@ -6,13 +6,12 @@ This repo is portable: it ships with no personal server paths or built-in hosts.
6
6
 
7
7
  ## Tools
8
8
 
9
- - `hosts_list` — reload and show configured connection targets and local script availability when applicable.
10
- - `hosts_reload` — explicitly reload configured connection targets without restarting the MCP server.
9
+ - `hosts_list` — show the connection targets loaded by the daemon.
11
10
  - `session_open(host_id, connect_timeout=30)` — open a persistent interactive shell for one configured host.
12
- - `sessions_list()` — list live sessions in this MCP process.
11
+ - `sessions_list()` — list live sessions in the daemon.
13
12
  - `sessions_close_all()` — close every live session in one call; useful for shutdown and cleanup.
14
13
  - `command_run(session_id, command, timeout=60)` — run a foreground command and wait for completion.
15
- - `task_start(session_id, command)` — start a remote background job under `$HOME/.hostbridge/jobs/<task_id>`.
14
+ - `task_start(session_id, command)` — start a remote background job under `$HOME/.hostbridge/jobs/<task_id>` with private permissions.
16
15
  - `task_status(session_id, task_id, tail_lines=80)` — poll job state, exit code, and log tail.
17
16
  - `task_stop(session_id, task_id)` — terminate a running background job and reap its process.
18
17
  - `session_write(session_id, text)` — send a raw line for interactive edge cases.
@@ -23,7 +22,7 @@ This repo is portable: it ships with no personal server paths or built-in hosts.
23
22
  - `file_download(session_id, remote_path, local_path)` — download a remote file with chunked shell/base64 transfer, up to 100MiB.
24
23
  - `session_close(session_id)` — close a persistent session.
25
24
 
26
- Every tool returns a structured payload with a stable `error_code` on failure (`HOST_NOT_FOUND`, `SESSION_NOT_FOUND`, `POLICY_DENIED`, `TASK_NOT_FOUND`, `TIMEOUT`, `INTERNAL`).
25
+ Every tool returns a structured payload with a stable lowercase `error_code`, such as `not_found`, `denied`, `remote_error`, `timeout`, or `internal`.
27
26
 
28
27
  ## Install and configure v1
29
28
 
@@ -93,6 +92,8 @@ The bridge supports:
93
92
 
94
93
  Each SSH channel opens its own HostBridge backend session and closes it when the channel ends, so an interactive shell `exit` does not kill later exec or file-transfer requests. The SFTP/SCP implementation is a compatibility layer backed by HostBridge's shell/base64 transfer primitives, so it keeps working across JumpServer/menu/wrapper login flows where direct remote SFTP is unavailable. It inherits the same transfer size limit (100MiB by default); for larger files, start a remote-native pull from the target host.
95
94
 
95
+ Mock SSH exec channels are byte-oriented. Binary stdin is staged with one total deadline, client EOF is propagated to the remote command, and disconnect, timeout, cancellation, or a missing backend exit status closes the HostBridge session. PTY command output is tail-bounded before base64 encoding, so arbitrary bytes are preserved without accumulating unbounded local output.
96
+
96
97
  ## Daemon lifecycle
97
98
 
98
99
  HostBridge always keeps remote sessions in the local daemon. The MCP server process is only a lightweight stdio adapter for agent clients and talks to the daemon over a Unix domain socket. If the daemon is not already running, the MCP adapter starts it lazily on the first tool call.
@@ -109,6 +110,8 @@ The daemon uses a Unix domain socket with a small JSON-line RPC protocol by defa
109
110
  ~/.hostbridge/daemon.sock
110
111
  ```
111
112
 
113
+ Every client validates both protocol version and the exact HostBridge runtime version. When an older or incompatible daemon owns the configured socket, the launcher shuts it down and starts the current runtime before use. The daemon accepts at most 64 concurrent control connections; an idle control connection is closed after 30 seconds so abandoned clients cannot consume unbounded worker threads.
114
+
112
115
  When launched through the npm wrapper on macOS, HostBridge sets `HOSTBRIDGE_DAEMON_SOCKET` to `/private/tmp/hostbridge-$USER/daemon.sock` so sandboxed MCP launchers do not accidentally isolate the daemon under a rewritten `HOME`. Python-only launches keep the `~/.hostbridge/daemon.sock` default unless you set `HOSTBRIDGE_DAEMON_SOCKET` or `HOSTBRIDGE_DAEMON_DIR`.
113
116
 
114
117
  This avoids fixed port conflicts, removes the local HTTP surface, and keeps the control API local to the current machine. The daemon directory should be private to the local user. To point daemon management commands at a custom socket path:
@@ -127,7 +130,7 @@ For MCP clients, prefer setting the environment variable instead of relying on s
127
130
  }
128
131
  ```
129
132
 
130
- Sessions can outlive a single agent client process. HostBridge records an optional `HOSTBRIDGE_AGENT_ID` owner on sessions and refuses cross-owner operations unless a force-close path is used. Set `HOSTBRIDGE_MAX_SESSIONS_PER_HOST` to cap concurrent live sessions per configured host (default `0`, unlimited).
133
+ Sessions can outlive a single agent client process. HostBridge records an optional `HOSTBRIDGE_AGENT_ID` owner on sessions and refuses cross-owner operations unless an internal cleanup path force-closes them. Configure `limits.max_sessions` per host to cap concurrent live sessions (default `16`).
131
134
 
132
135
  ## Configuration lifecycle
133
136
 
@@ -259,7 +262,7 @@ Claude Desktop style config:
259
262
  "mcpServers": {
260
263
  "hostbridge": {
261
264
  "command": "npx",
262
- "args": ["-y", "hostbridge"],
265
+ "args": ["-y", "@neoline/hostbridge"],
263
266
  "env": {"HOSTBRIDGE_AGENT_ID": "claude-code"}
264
267
  }
265
268
  }
@@ -288,6 +291,8 @@ Environment variables:
288
291
  | `HOSTBRIDGE_DENYLIST=pat1:pat2` | Append extra deny regex patterns (colon-separated). |
289
292
  | `HOSTBRIDGE_IDLE_TIMEOUT=900` | Idle timeout in seconds before an inactive session is reaped (default 1800). |
290
293
  | `HOSTBRIDGE_AUDIT_LOG=/path/to/audit.jsonl` | Override audit log path (default `~/.hostbridge/audit.jsonl`). |
294
+ | `HOSTBRIDGE_AUDIT_MAX_BYTES=10485760` | Rotate the current audit log before it exceeds this size (default 10MiB). |
295
+ | `HOSTBRIDGE_AUDIT_BACKUP_COUNT=3` | Number of rotated audit logs to retain (default 3; `0` retains none). |
291
296
 
292
297
  Default denylist blocks obviously destructive commands (`rm -rf /`, `mkfs`, `dd of=/dev/sd*`, fork bombs, `shutdown`, `reboot`). A denied command returns `error_code=POLICY_DENIED` with the matched pattern, and is recorded in the audit log alongside session/task lifecycle events.
293
298
 
@@ -298,7 +303,9 @@ Policy config file (optional):
298
303
  "idle_timeout_seconds": 1800,
299
304
  "use_default_denylist": true,
300
305
  "command_denylist": ["\\bmytool\\b"],
301
- "audit_log_path": "~/.hostbridge/audit.jsonl"
306
+ "audit_log_path": "~/.hostbridge/audit.jsonl",
307
+ "audit_max_bytes": 10485760,
308
+ "audit_backup_count": 3
302
309
  }
303
310
  ```
304
311
 
package/bin/hostbridge.js CHANGED
@@ -1,22 +1,65 @@
1
1
  #!/usr/bin/env node
2
- import { spawn } from 'node:child_process';
3
- import { existsSync } from 'node:fs';
2
+ import { spawn, spawnSync } from 'node:child_process';
3
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
4
4
  import { tmpdir, userInfo } from 'node:os';
5
5
  import { dirname, join, delimiter } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
 
8
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
9
  const packageRoot = dirname(__dirname);
10
- const pythonPath = process.env.HOSTBRIDGE_PYTHON || process.env.SERVER_CONTROL_MCP_PYTHON || 'python3';
11
10
  const bundledSrc = join(packageRoot, 'src');
12
11
  const moduleDir = join(bundledSrc, 'server_control_mcp');
13
12
  const docsUrl = 'https://www.npmjs.com/package/@neoline/hostbridge';
13
+ const packageVersion = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).version;
14
14
 
15
15
  function hostbridgeRuntimeDir(username) {
16
16
  const baseTmp = process.platform === 'darwin' ? '/private/tmp' : tmpdir();
17
17
  return join(baseTmp, `hostbridge-${username}`);
18
18
  }
19
19
 
20
+ function ensurePrivateDirectory(path) {
21
+ mkdirSync(path, { recursive: true, mode: 0o700 });
22
+ const metadata = lstatSync(path);
23
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
24
+ throw new Error(`refusing unsafe npm runtime path: ${path}`);
25
+ }
26
+ if (typeof process.getuid === 'function' && metadata.uid !== process.getuid()) {
27
+ throw new Error(`npm runtime path is not owned by the current user: ${path}`);
28
+ }
29
+ chmodSync(path, 0o700);
30
+ }
31
+
32
+ function acquireBootstrapLock(path) {
33
+ const deadline = Date.now() + 120_000;
34
+ const sleeper = new Int32Array(new SharedArrayBuffer(4));
35
+ while (Date.now() < deadline) {
36
+ try {
37
+ mkdirSync(path, { mode: 0o700 });
38
+ return;
39
+ } catch (error) {
40
+ if (error.code !== 'EEXIST') throw error;
41
+ const metadata = lstatSync(path);
42
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
43
+ throw new Error(`refusing unsafe npm bootstrap lock: ${path}`);
44
+ }
45
+ if (Date.now() - metadata.mtimeMs > 300_000) {
46
+ rmSync(path, { recursive: true });
47
+ continue;
48
+ }
49
+ Atomics.wait(sleeper, 0, 0, 50);
50
+ }
51
+ }
52
+ throw new Error(`timed out waiting for npm runtime bootstrap: ${path}`);
53
+ }
54
+
55
+ function runBootstrap(python, args, env) {
56
+ const result = spawnSync(python, args, { stdio: 'inherit', env });
57
+ if (result.error) throw result.error;
58
+ if (result.signal || result.status !== 0) {
59
+ throw new Error(`Python runtime bootstrap failed (${result.signal || `exit ${result.status}`})`);
60
+ }
61
+ }
62
+
20
63
  if (!existsSync(moduleDir)) {
21
64
  console.error(`hostbridge: bundled Python sources not found at ${moduleDir}`);
22
65
  console.error(` This npm package wraps a Python MCP core. See: ${docsUrl}`);
@@ -27,7 +70,40 @@ const env = { ...process.env };
27
70
  env.PYTHONPATH = env.PYTHONPATH ? `${bundledSrc}${delimiter}${env.PYTHONPATH}` : bundledSrc;
28
71
  env.HOSTBRIDGE_DEFAULT_MCP_COMMAND = env.HOSTBRIDGE_DEFAULT_MCP_COMMAND || 'npx';
29
72
  env.HOSTBRIDGE_DEFAULT_MCP_ARGS = env.HOSTBRIDGE_DEFAULT_MCP_ARGS || JSON.stringify(['-y', '@neoline/hostbridge']);
30
- env.HOSTBRIDGE_NPM_LAUNCHER = env.HOSTBRIDGE_NPM_LAUNCHER || '1';
73
+ const configuredPython = env.HOSTBRIDGE_PYTHON || env.SERVER_CONTROL_MCP_PYTHON;
74
+ let pythonPath = configuredPython;
75
+ if (!pythonPath) {
76
+ const username = env.USER || env.LOGNAME || userInfo().username || 'user';
77
+ const npmRuntimeRoot = env.HOSTBRIDGE_NPM_RUNTIME_DIR || join(hostbridgeRuntimeDir(username), 'npm-runtime');
78
+ const environmentDir = join(npmRuntimeRoot, packageVersion);
79
+ const scriptsDir = process.platform === 'win32' ? 'Scripts' : 'bin';
80
+ const environmentPython = join(environmentDir, scriptsDir, process.platform === 'win32' ? 'python.exe' : 'python');
81
+ const lock = join(npmRuntimeRoot, `${packageVersion}.bootstrap.lock`);
82
+ try {
83
+ ensurePrivateDirectory(npmRuntimeRoot);
84
+ acquireBootstrapLock(lock);
85
+ try {
86
+ if (!existsSync(environmentPython)) {
87
+ runBootstrap('python3', ['-m', 'venv', environmentDir], env);
88
+ }
89
+ runBootstrap(
90
+ environmentPython,
91
+ ['-c', 'import server_control_mcp; server_control_mcp.ensure_runtime()'],
92
+ { ...env, HOSTBRIDGE_NPM_LAUNCHER: '1' },
93
+ );
94
+ } finally {
95
+ rmSync(lock, { recursive: true, force: true });
96
+ }
97
+ } catch (error) {
98
+ console.error(`hostbridge: failed to prepare isolated Python runtime: ${error.message}`);
99
+ console.error(` See: ${docsUrl}`);
100
+ process.exit(1);
101
+ }
102
+ pythonPath = environmentPython;
103
+ env.HOSTBRIDGE_NPM_LAUNCHER = '0';
104
+ } else {
105
+ env.HOSTBRIDGE_NPM_LAUNCHER = env.HOSTBRIDGE_NPM_LAUNCHER || '1';
106
+ }
31
107
  if (!env.HOSTBRIDGE_DAEMON_SOCKET && !env.HOSTBRIDGE_DAEMON_DIR) {
32
108
  const username = env.USER || env.LOGNAME || userInfo().username || 'user';
33
109
  env.HOSTBRIDGE_DAEMON_SOCKET = join(hostbridgeRuntimeDir(username), 'daemon.sock');
@@ -55,10 +131,5 @@ child.on('exit', (code, signal) => {
55
131
  process.kill(process.pid, signal);
56
132
  return;
57
133
  }
58
- if (code === 2) {
59
- // exit code 2 is reserved by the Python runtime guard for missing dependencies.
60
- // The actionable install command was already printed to stderr by Python.
61
- console.error(`\nhostbridge: runtime dependency check failed. See: ${docsUrl}`);
62
- }
63
134
  process.exit(code ?? 0);
64
135
  });
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "hostbridge": {
4
4
  "command": "npx",
5
- "args": ["-y", "hostbridge"],
5
+ "args": ["-y", "@neoline/hostbridge"],
6
6
  "env": {
7
7
  "HOSTBRIDGE_AGENT_ID": "claude-code"
8
8
  }
@@ -1,6 +1,6 @@
1
1
  [mcp_servers.hostbridge]
2
2
  command = "npx"
3
- args = ["-y", "hostbridge"]
3
+ args = ["-y", "@neoline/hostbridge"]
4
4
  [mcp_servers.hostbridge.env]
5
5
  HOSTBRIDGE_AGENT_ID = "codex"
6
6
  # Optional: use a project-specific host file instead of ~/.hostbridge/hosts.json
@@ -1,4 +1,5 @@
1
1
  {
2
+ "schema_version": 1,
2
3
  "hosts": [
3
4
  {
4
5
  "id": "gpu",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neoline/hostbridge",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
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.2"
7
+ version = "2.0.4"
8
8
  description = "HostBridge MCP server for persistent allowlisted SSH and shell sessions."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -36,10 +36,11 @@ classifiers = [
36
36
  "Topic :: Software Development :: Libraries",
37
37
  ]
38
38
  dependencies = [
39
- "mcp>=1.0.0",
39
+ "packaging>=24.0",
40
+ "mcp>=1.28.1",
40
41
  "pexpect>=4.9.0",
41
- "cryptography>=42.0.0",
42
- "asyncssh>=2.14.0",
42
+ "cryptography>=48.0.1",
43
+ "asyncssh>=2.14.2",
43
44
  ]
44
45
 
45
46
  [project.optional-dependencies]
@@ -5,30 +5,51 @@ from __future__ import annotations
5
5
  import os
6
6
  import subprocess
7
7
  import sys
8
+ from importlib import metadata
8
9
  from typing import NoReturn
9
10
 
10
11
  __all__ = ["__version__", "ensure_runtime"]
11
- __version__ = "2.0.2"
12
+ __version__ = "2.0.4"
12
13
 
13
- # (import_name, pip_name) pairs for runtime dependency probing.
14
+ # (import_name, requirement) pairs for runtime dependency probing.
14
15
  _REQUIRED_DEPENDENCIES = (
15
- ("mcp", "mcp"),
16
- ("pexpect", "pexpect"),
17
- ("cryptography", "cryptography"),
18
- ("asyncssh", "asyncssh"),
16
+ ("packaging", "packaging>=24.0"),
17
+ ("mcp", "mcp>=1.28.1"),
18
+ ("pexpect", "pexpect>=4.9.0"),
19
+ ("cryptography", "cryptography>=48.0.1"),
20
+ ("asyncssh", "asyncssh>=2.14.2"),
19
21
  )
20
22
 
21
23
 
22
- def _missing_dependencies() -> list[str]:
23
- """Return pip names of runtime dependencies that cannot be imported."""
24
+ def _unsatisfied_dependencies() -> list[str]:
25
+ """Return requirements which are missing or below their supported version."""
24
26
 
25
- missing: list[str] = []
26
- for import_name, pip_name in _REQUIRED_DEPENDENCIES:
27
+ try:
28
+ from packaging.requirements import Requirement
29
+ from packaging.version import InvalidVersion
30
+ except ImportError:
31
+ return ["packaging>=24.0"]
32
+
33
+ unsatisfied: list[str] = []
34
+ for import_name, requirement_text in _REQUIRED_DEPENDENCIES:
27
35
  try:
28
36
  __import__(import_name)
29
37
  except ImportError:
30
- missing.append(pip_name)
31
- return missing
38
+ unsatisfied.append(requirement_text)
39
+ continue
40
+ requirement = Requirement(requirement_text)
41
+ try:
42
+ installed_version = metadata.version(requirement.name)
43
+ except metadata.PackageNotFoundError:
44
+ unsatisfied.append(requirement_text)
45
+ continue
46
+ try:
47
+ supported = requirement.specifier.contains(installed_version, prereleases=True)
48
+ except InvalidVersion:
49
+ supported = False
50
+ if not supported:
51
+ unsatisfied.append(requirement_text)
52
+ return unsatisfied
32
53
 
33
54
 
34
55
  def ensure_runtime() -> None:
@@ -38,15 +59,25 @@ def ensure_runtime() -> None:
38
59
  single, copy-pasteable fix instead of a deep ImportError traceback.
39
60
  """
40
61
 
41
- missing = _missing_dependencies()
42
- if not missing:
62
+ unsatisfied = _unsatisfied_dependencies()
63
+ if not unsatisfied:
43
64
  return
44
- if _auto_install_enabled() and _install_missing_dependencies(missing) and not _missing_dependencies():
45
- return
46
-
47
- install_command = "pip install " + " ".join(missing)
65
+ if _auto_install_enabled():
66
+ attempted_states: set[tuple[str, ...]] = set()
67
+ for _ in range(len(_REQUIRED_DEPENDENCIES) + 1):
68
+ state = tuple(unsatisfied)
69
+ if state in attempted_states:
70
+ break
71
+ attempted_states.add(state)
72
+ if not _install_dependencies(unsatisfied):
73
+ break
74
+ unsatisfied = _unsatisfied_dependencies()
75
+ if not unsatisfied:
76
+ return
77
+
78
+ install_command = "pip install " + " ".join(unsatisfied)
48
79
  message = (
49
- f"hostbridge: missing Python dependencies: {', '.join(missing)}\n"
80
+ f"hostbridge: missing or unsupported Python dependencies: {', '.join(unsatisfied)}\n"
50
81
  f" Install them with: {install_command}\n"
51
82
  " Or reinstall the package: pip install --force-reinstall hostbridge"
52
83
  )
@@ -60,14 +91,18 @@ def _abort_with_error(message: str) -> NoReturn:
60
91
 
61
92
 
62
93
  def _auto_install_enabled() -> bool:
63
- return (os.environ.get("HOSTBRIDGE_AUTO_INSTALL_DEPS") or os.environ.get("HOSTBRIDGE_NPM_LAUNCHER") or "").lower() in {
94
+ return (
95
+ os.environ.get("HOSTBRIDGE_AUTO_INSTALL_DEPS") or os.environ.get("HOSTBRIDGE_NPM_LAUNCHER") or ""
96
+ ).lower() in {
64
97
  "1",
65
98
  "true",
66
99
  "yes",
67
100
  }
68
101
 
69
102
 
70
- def _install_missing_dependencies(missing: list[str]) -> bool:
71
- print(f"hostbridge: installing missing Python dependencies: {', '.join(missing)}", file=sys.stderr)
72
- result = subprocess.run([sys.executable, "-m", "pip", "install", *missing], stdout=sys.stderr, stderr=sys.stderr, check=False)
103
+ def _install_dependencies(requirements: list[str]) -> bool:
104
+ print(f"hostbridge: installing Python dependencies: {', '.join(requirements)}", file=sys.stderr)
105
+ result = subprocess.run(
106
+ [sys.executable, "-m", "pip", "install", *requirements], stdout=sys.stderr, stderr=sys.stderr, check=False
107
+ )
73
108
  return result.returncode == 0
@@ -153,7 +153,7 @@ def _run_hosts(args: argparse.Namespace) -> int:
153
153
  try:
154
154
  result = client.exec(session.session_id, "printf hostbridge-ok", timeout=args.timeout, owner="cli-check")
155
155
  finally:
156
- client.close_session(session.session_id, owner="cli-check", force=True)
156
+ client.close_session(session.session_id, owner="cli-check")
157
157
  if result.exit_code != 0 or result.stdout != b"hostbridge-ok":
158
158
  raise HostBridgeError("remote_error", "host check command failed")
159
159
  print(f"{args.host_id}: ok")
@@ -9,12 +9,15 @@ import json
9
9
  import socket
10
10
  import threading
11
11
  import uuid
12
- from collections.abc import Mapping
12
+ from collections.abc import Iterable, Mapping
13
+ from contextlib import suppress
13
14
  from dataclasses import dataclass
14
15
  from pathlib import Path
15
16
 
17
+ from . import __version__
16
18
  from .protocol import (
17
19
  MAX_FRAME_BYTES,
20
+ PROTOCOL_VERSION,
18
21
  BinaryFrame,
19
22
  ErrorInfo,
20
23
  FrameFlags,
@@ -144,19 +147,31 @@ class HostBridgeClient:
144
147
  return response.result
145
148
 
146
149
  def hello(self) -> dict[str, object]:
147
- return self.request("system.hello", {})
150
+ result = self.request("system.hello", {})
151
+ if (
152
+ result.get("service") != "hostbridge"
153
+ or result.get("protocol") != PROTOCOL_VERSION
154
+ or result.get("version") != __version__
155
+ ):
156
+ raise HostBridgeError(
157
+ "protocol_mismatch",
158
+ f"HostBridge daemon is not compatible with runtime {__version__}",
159
+ )
160
+ return result
148
161
 
149
162
  def open_session(
150
163
  self,
151
164
  host_id: str,
152
165
  *,
153
166
  owner: str | None = None,
154
- timeout: float | None = None,
167
+ connect_timeout: float = 30,
155
168
  ) -> SessionInfo:
169
+ if connect_timeout <= 0:
170
+ raise ValueError("connect_timeout must be positive")
156
171
  result = self.request(
157
172
  "session.open",
158
- {"host_id": host_id, "owner": owner},
159
- timeout=timeout,
173
+ {"host_id": host_id, "owner": owner, "connect_timeout": connect_timeout},
174
+ timeout=connect_timeout + 5,
160
175
  )
161
176
  return SessionInfo.from_dict(result)
162
177
 
@@ -184,8 +199,72 @@ class HostBridgeClient:
184
199
  result = self.request("exec.start", params, timeout=timeout)
185
200
  return ExecResult.from_dict(result)
186
201
 
187
- def close_session(self, session_id: str, *, owner: str | None = None, force: bool = False) -> None:
188
- params: dict[str, object] = {"session_id": session_id, "force": force}
202
+ def exec_stream(
203
+ self,
204
+ session_id: str,
205
+ command: str,
206
+ stdin: Iterable[bytes],
207
+ *,
208
+ timeout: float | None = None,
209
+ output_limit: int | None = None,
210
+ owner: str | None = None,
211
+ cancel_event: threading.Event | None = None,
212
+ ) -> ExecResult:
213
+ stream_id = uuid.uuid4().hex
214
+ params: dict[str, object] = {
215
+ "session_id": session_id,
216
+ "command": command,
217
+ "stream_id": stream_id,
218
+ }
219
+ if timeout is not None:
220
+ params["timeout"] = timeout
221
+ if output_limit is not None:
222
+ params["output_limit"] = output_limit
223
+ if owner is not None:
224
+ params["owner"] = owner
225
+ request = Request.new("exec.stream", params)
226
+ request_timeout = self.default_timeout if timeout is None else timeout
227
+ sequence = 0
228
+ try:
229
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
230
+ connection.settimeout(request_timeout)
231
+ connection.connect(str(self.socket_path))
232
+ connection.sendall(request.to_json().encode("utf-8") + b"\n")
233
+ try:
234
+ for chunk in stdin:
235
+ if cancel_event is not None and cancel_event.is_set():
236
+ raise HostBridgeError("cancelled", "exec stdin cancelled")
237
+ if not isinstance(chunk, bytes):
238
+ raise TypeError("exec stdin chunks must be bytes")
239
+ for offset in range(0, len(chunk), MAX_FRAME_BYTES):
240
+ payload = chunk[offset : offset + MAX_FRAME_BYTES]
241
+ connection.sendall(encode_frame(BinaryFrame(stream_id, sequence, FrameFlags.DATA, payload)))
242
+ sequence += 1
243
+ if cancel_event is not None and cancel_event.is_set():
244
+ raise HostBridgeError("cancelled", "exec stdin cancelled")
245
+ connection.sendall(encode_frame(BinaryFrame(stream_id, sequence, FrameFlags.EOF)))
246
+ except BaseException as exc:
247
+ metadata = json.dumps(
248
+ {"code": "cancelled", "message": str(exc) or "exec stdin failed"},
249
+ separators=(",", ":"),
250
+ ).encode("utf-8")
251
+ with suppress(OSError):
252
+ connection.sendall(encode_frame(BinaryFrame(stream_id, sequence, FrameFlags.CANCEL, metadata)))
253
+ raise
254
+ with connection.makefile("rb") as reader:
255
+ response = self._read_response(reader, request)
256
+ except HostBridgeError:
257
+ raise
258
+ except TimeoutError as exc:
259
+ raise HostBridgeError("timeout", "HostBridge exec stream timed out", retryable=True) from exc
260
+ except OSError as exc:
261
+ raise HostBridgeError(
262
+ "connection_lost", "HostBridge exec stream connection failed", retryable=True
263
+ ) from exc
264
+ return ExecResult.from_dict(response.result)
265
+
266
+ def close_session(self, session_id: str, *, owner: str | None = None) -> None:
267
+ params: dict[str, object] = {"session_id": session_id}
189
268
  if owner is not None:
190
269
  params["owner"] = owner
191
270
  self.request("session.close", params)
@@ -274,10 +353,14 @@ class HostBridgeClient:
274
353
  timeout: float | None = None,
275
354
  cancel_event: threading.Event | None = None,
276
355
  owner: str | None = None,
356
+ max_bytes: int | None = None,
277
357
  ) -> TransferResult:
278
358
  self._validate_chunk_size(chunk_size)
279
359
  source = Path(local_path)
280
360
  expected_size = source.stat().st_size
361
+ self._validate_optional_byte_limit(max_bytes)
362
+ if max_bytes is not None and expected_size > max_bytes:
363
+ raise HostBridgeError("resource_limit", f"upload exceeds {max_bytes} bytes")
281
364
  stream_id = uuid.uuid4().hex
282
365
  params: dict[str, object] = {
283
366
  "session_id": session_id,
@@ -335,8 +418,10 @@ class HostBridgeClient:
335
418
  chunk_size: int = 256 * 1024,
336
419
  timeout: float | None = None,
337
420
  owner: str | None = None,
421
+ max_bytes: int | None = None,
338
422
  ) -> TransferResult:
339
423
  self._validate_chunk_size(chunk_size)
424
+ self._validate_optional_byte_limit(max_bytes)
340
425
  destination = Path(local_path)
341
426
  destination.parent.mkdir(parents=True, exist_ok=True)
342
427
  temporary = destination.with_name(f".{destination.name}.hostbridge-{uuid.uuid4().hex}.tmp")
@@ -375,6 +460,8 @@ class HostBridgeClient:
375
460
  local_file.write(frame.payload)
376
461
  digest.update(frame.payload)
377
462
  transferred += len(frame.payload)
463
+ if max_bytes is not None and transferred > max_bytes:
464
+ raise HostBridgeError("resource_limit", f"download exceeds {max_bytes} bytes")
378
465
  if frame.flags & FrameFlags.EOF:
379
466
  metadata = self._frame_metadata(frame.payload)
380
467
  break
@@ -399,6 +486,11 @@ class HostBridgeClient:
399
486
  if not isinstance(chunk_size, int) or isinstance(chunk_size, bool) or not 4096 <= chunk_size <= MAX_FRAME_BYTES:
400
487
  raise ValueError(f"chunk_size must be between 4096 and {MAX_FRAME_BYTES} bytes")
401
488
 
489
+ @staticmethod
490
+ def _validate_optional_byte_limit(max_bytes: int | None) -> None:
491
+ if max_bytes is not None and (not isinstance(max_bytes, int) or isinstance(max_bytes, bool) or max_bytes <= 0):
492
+ raise ValueError("max_bytes must be a positive integer or None")
493
+
402
494
  @staticmethod
403
495
  def _transfer_result(value: Mapping[str, object]) -> TransferResult:
404
496
  byte_count = value.get("bytes_transferred")
@@ -10,11 +10,21 @@ from dataclasses import dataclass
10
10
  from pathlib import Path
11
11
 
12
12
  from .secrets import SCHEME, encrypt_secret
13
+ from .secure_files import read_private_text
13
14
 
14
15
  SCHEMA_VERSION = 1
15
16
  _HOST_ID = re.compile(r"^[A-Za-z0-9_.-]+$")
16
17
  _CONNECTION_STYLES = {"script", "ssh", "command"}
17
18
  _TRANSPORTS = {"auto", "pty", "ssh"}
19
+ _LIMITS = {
20
+ "max_sessions",
21
+ "command_timeout_seconds",
22
+ "max_output_bytes",
23
+ "max_stdin_bytes",
24
+ "max_transfer_bytes",
25
+ "max_text_bytes",
26
+ "max_shell_bytes",
27
+ }
18
28
 
19
29
 
20
30
  @dataclass(frozen=True, slots=True)
@@ -32,7 +42,7 @@ class ConfigDocument:
32
42
  def load_v1_config(path: Path | str) -> ConfigDocument:
33
43
  config_path = Path(path).expanduser()
34
44
  try:
35
- payload = json.loads(config_path.read_text(encoding="utf-8"))
45
+ payload = json.loads(read_private_text(config_path))
36
46
  except json.JSONDecodeError as exc:
37
47
  raise ValueError(f"{config_path} is not valid JSON") from exc
38
48
  validated = validate_v1_config(payload, source=config_path)
@@ -108,8 +118,10 @@ def _validate_limits(value: object, source: Path | str, index: int) -> None:
108
118
  if not isinstance(value, dict):
109
119
  raise ValueError(f"{source}.hosts[{index}].limits must be an object")
110
120
  for name, limit in value.items():
111
- if not isinstance(name, str) or not isinstance(limit, int) or isinstance(limit, bool) or limit <= 0:
112
- raise ValueError(f"{source}.hosts[{index}].limits values must be positive integers")
121
+ if name not in _LIMITS:
122
+ raise ValueError(f"{source}.hosts[{index}].limits contains unknown limit {name!r}")
123
+ if not isinstance(limit, int) or isinstance(limit, bool) or limit <= 0:
124
+ raise ValueError(f"{source}.hosts[{index}].limits.{name} must be a positive integer")
113
125
 
114
126
 
115
127
  def migrate_config(source: Path | str, destination: Path | str) -> ConfigDocument:
@@ -132,8 +144,7 @@ def migrate_config(source: Path | str, destination: Path | str) -> ConfigDocumen
132
144
  secrets = host.get("secrets")
133
145
  if isinstance(secrets, dict):
134
146
  host["secrets"] = {
135
- name: encrypt_secret(secret) if isinstance(secret, str) else secret
136
- for name, secret in secrets.items()
147
+ name: encrypt_secret(secret) if isinstance(secret, str) else secret for name, secret in secrets.items()
137
148
  }
138
149
  migrated_hosts.append(host)
139
150
  payload = validate_v1_config(