@neoline/hostbridge 2.0.3 → 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 +15 -8
- package/bin/hostbridge.js +80 -9
- package/examples/claude_desktop_config.json +1 -1
- package/examples/codex.config.toml +1 -1
- package/examples/hosts.example.json +1 -0
- package/package.json +1 -1
- package/pyproject.toml +5 -4
- package/src/server_control_mcp/__init__.py +58 -23
- package/src/server_control_mcp/cli.py +1 -1
- package/src/server_control_mcp/client.py +99 -7
- package/src/server_control_mcp/config.py +16 -5
- package/src/server_control_mcp/daemon.py +239 -49
- package/src/server_control_mcp/doctor.py +8 -4
- package/src/server_control_mcp/hosts.py +118 -24
- package/src/server_control_mcp/mock_ssh.py +53 -954
- package/src/server_control_mcp/mock_ssh_exec.py +210 -0
- package/src/server_control_mcp/mock_ssh_session.py +68 -0
- package/src/server_control_mcp/mock_ssh_sftp.py +506 -0
- package/src/server_control_mcp/mock_ssh_tunnel.py +592 -0
- package/src/server_control_mcp/policy.py +86 -14
- package/src/server_control_mcp/protocol.py +18 -17
- package/src/server_control_mcp/remote_task_agent.py +102 -0
- package/src/server_control_mcp/remote_tunnel_agent.py +143 -0
- package/src/server_control_mcp/runtime.py +3 -2
- package/src/server_control_mcp/secrets.py +6 -6
- package/src/server_control_mcp/secure_files.py +121 -0
- package/src/server_control_mcp/server.py +30 -12
- package/src/server_control_mcp/services.py +363 -165
- package/src/server_control_mcp/transports/pty.py +114 -33
- package/src/server_control_mcp/transports/ssh.py +308 -24
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` —
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
});
|
package/package.json
CHANGED
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.
|
|
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
|
-
"
|
|
39
|
+
"packaging>=24.0",
|
|
40
|
+
"mcp>=1.28.1",
|
|
40
41
|
"pexpect>=4.9.0",
|
|
41
|
-
"cryptography>=
|
|
42
|
-
"asyncssh>=2.14.
|
|
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.
|
|
12
|
+
__version__ = "2.0.4"
|
|
12
13
|
|
|
13
|
-
# (import_name,
|
|
14
|
+
# (import_name, requirement) pairs for runtime dependency probing.
|
|
14
15
|
_REQUIRED_DEPENDENCIES = (
|
|
15
|
-
("
|
|
16
|
-
("
|
|
17
|
-
("
|
|
18
|
-
("
|
|
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
|
|
23
|
-
"""Return
|
|
24
|
+
def _unsatisfied_dependencies() -> list[str]:
|
|
25
|
+
"""Return requirements which are missing or below their supported version."""
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
42
|
-
if not
|
|
62
|
+
unsatisfied = _unsatisfied_dependencies()
|
|
63
|
+
if not unsatisfied:
|
|
43
64
|
return
|
|
44
|
-
if _auto_install_enabled()
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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(
|
|
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 (
|
|
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
|
|
71
|
-
print(f"hostbridge: installing
|
|
72
|
-
result = subprocess.run(
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
|
188
|
-
|
|
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
|
|
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
|
|
112
|
-
raise ValueError(f"{source}.hosts[{index}].limits
|
|
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(
|