@neoline/hostbridge 2.0.3 → 2.0.5
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 +24 -15
- package/bin/hostbridge.js +80 -9
- package/docs/ARCHITECTURE.md +62 -0
- 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 +2 -1
- package/pyproject.toml +5 -4
- package/src/server_control_mcp/__init__.py +60 -24
- package/src/server_control_mcp/async_lifecycle.py +41 -0
- package/src/server_control_mcp/cli.py +2 -2
- package/src/server_control_mcp/client.py +345 -238
- package/src/server_control_mcp/config.py +18 -6
- package/src/server_control_mcp/daemon.py +309 -412
- package/src/server_control_mcp/doctor.py +41 -5
- package/src/server_control_mcp/hosts.py +252 -24
- package/src/server_control_mcp/mock_ssh.py +97 -960
- package/src/server_control_mcp/mock_ssh_exec.py +299 -0
- package/src/server_control_mcp/mock_ssh_session.py +69 -0
- package/src/server_control_mcp/mock_ssh_sftp.py +604 -0
- package/src/server_control_mcp/mock_ssh_tunnel.py +289 -0
- package/src/server_control_mcp/mux_connection.py +326 -0
- package/src/server_control_mcp/mux_daemon.py +186 -0
- package/src/server_control_mcp/mux_protocol.py +279 -0
- package/src/server_control_mcp/mux_records.py +71 -0
- package/src/server_control_mcp/mux_rpc.py +1779 -0
- package/src/server_control_mcp/mux_service.py +506 -0
- package/src/server_control_mcp/mux_stream.py +283 -0
- package/src/server_control_mcp/mux_sync_client.py +224 -0
- package/src/server_control_mcp/policy.py +86 -14
- package/src/server_control_mcp/remote_agent_bundle.py +56 -0
- package/src/server_control_mcp/remote_mux_agent.py +769 -0
- package/src/server_control_mcp/remote_task_agent.py +102 -0
- package/src/server_control_mcp/runtime.py +3 -2
- package/src/server_control_mcp/runtime_services.py +862 -0
- 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 +53 -20
- package/src/server_control_mcp/services.py +3 -469
- package/src/server_control_mcp/transports/__init__.py +4 -8
- package/src/server_control_mcp/transports/base.py +60 -38
- package/src/server_control_mcp/transports/ssh.py +315 -84
- package/src/server_control_mcp/tunnel_manager.py +1245 -0
- package/src/server_control_mcp/tunnel_native_ssh.py +454 -0
- package/src/server_control_mcp/tunnel_providers.py +20 -0
- package/src/server_control_mcp/tunnel_pty_agent.py +909 -0
- package/src/server_control_mcp/protocol.py +0 -362
- package/src/server_control_mcp/transports/pty.py +0 -434
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shlex
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
|
|
6
|
+
REMOTE_TASK_LOG_MAX_BYTES = 1024 * 1024
|
|
7
|
+
REMOTE_TASK_READ_CHUNK_BYTES = 64 * 1024
|
|
8
|
+
|
|
9
|
+
REMOTE_TASK_AGENT = r"""
|
|
10
|
+
import os
|
|
11
|
+
import pathlib
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
command, job_dir_text, log_limit_text = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
16
|
+
job_dir = pathlib.Path(job_dir_text)
|
|
17
|
+
log_limit = int(log_limit_text)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def write_atomic(name, value):
|
|
21
|
+
temporary = job_dir / ("." + name + ".tmp")
|
|
22
|
+
temporary.write_text(str(value) + "\n", encoding="ascii")
|
|
23
|
+
os.replace(temporary, job_dir / name)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
os.setsid()
|
|
27
|
+
write_atomic("pid", os.getpid())
|
|
28
|
+
with (job_dir / "stdout.log").open("w+b", buffering=0) as output:
|
|
29
|
+
process = subprocess.Popen(
|
|
30
|
+
["/bin/sh", "-lc", command],
|
|
31
|
+
stdin=subprocess.DEVNULL,
|
|
32
|
+
stdout=subprocess.PIPE,
|
|
33
|
+
stderr=subprocess.STDOUT,
|
|
34
|
+
)
|
|
35
|
+
current_size = 0
|
|
36
|
+
while True:
|
|
37
|
+
chunk = process.stdout.read(65536)
|
|
38
|
+
if not chunk:
|
|
39
|
+
break
|
|
40
|
+
if len(chunk) >= log_limit:
|
|
41
|
+
retained = chunk[-log_limit:]
|
|
42
|
+
elif current_size + len(chunk) > log_limit:
|
|
43
|
+
keep = log_limit - len(chunk)
|
|
44
|
+
output.seek(current_size - keep)
|
|
45
|
+
retained = output.read(keep) + chunk
|
|
46
|
+
else:
|
|
47
|
+
output.seek(current_size)
|
|
48
|
+
output.write(chunk)
|
|
49
|
+
current_size += len(chunk)
|
|
50
|
+
continue
|
|
51
|
+
output.seek(0)
|
|
52
|
+
output.write(retained)
|
|
53
|
+
output.truncate()
|
|
54
|
+
current_size = len(retained)
|
|
55
|
+
returncode = process.wait()
|
|
56
|
+
status = returncode if returncode >= 0 else 128 - returncode
|
|
57
|
+
write_atomic("exit_code", status)
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def remote_job_dir(task_id: str) -> str:
|
|
62
|
+
return f"$HOME/.hostbridge/jobs/{task_id}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def remote_job_dir_assignment(task_id: str) -> str:
|
|
66
|
+
relative_path = shlex.quote(f".hostbridge/jobs/{task_id}")
|
|
67
|
+
return f'job_dir="$HOME"/{relative_path}'
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build_remote_task_command(command: str, task_id: str) -> str:
|
|
71
|
+
supervisor = (
|
|
72
|
+
f'python3 -u -c {shlex.quote(REMOTE_TASK_AGENT)} {shlex.quote(command)} "$job_dir" {REMOTE_TASK_LOG_MAX_BYTES}'
|
|
73
|
+
)
|
|
74
|
+
return (
|
|
75
|
+
'umask 077; [ -n "${HOME:-}" ] || exit 1; '
|
|
76
|
+
f'{remote_job_dir_assignment(task_id)}; mkdir -p -- "$job_dir" || exit $?; '
|
|
77
|
+
'chmod 700 "$HOME/.hostbridge" "$HOME/.hostbridge/jobs" "$job_dir" || exit $?; '
|
|
78
|
+
f"nohup {supervisor} </dev/null >/dev/null 2>&1 & launcher=$!; "
|
|
79
|
+
'attempt=0; while [ "$attempt" -lt 100 ]; do '
|
|
80
|
+
'if [ -s "$job_dir/pid" ]; then cat "$job_dir/pid"; exit 0; fi; '
|
|
81
|
+
'if ! kill -0 "$launcher" 2>/dev/null; then break; fi; '
|
|
82
|
+
"attempt=$((attempt + 1)); sleep 0.05; done; exit 1"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def build_remote_tasks_cleanup_command(tasks: Iterable[tuple[str, int | None]]) -> str:
|
|
87
|
+
cleanup_steps = ["umask 077", '[ -n "${HOME:-}" ] || exit 1']
|
|
88
|
+
for task_id, recorded_pid in tasks:
|
|
89
|
+
fallback_pid = str(recorded_pid) if recorded_pid is not None else ""
|
|
90
|
+
cleanup_steps.extend(
|
|
91
|
+
(
|
|
92
|
+
remote_job_dir_assignment(task_id),
|
|
93
|
+
f'pid=$(cat "$job_dir/pid" 2>/dev/null || printf %s {shlex.quote(fallback_pid)})',
|
|
94
|
+
'if [ ! -f "$job_dir/exit_code" ] && [ -n "$pid" ] && kill -0 -- -"$pid" 2>/dev/null; then '
|
|
95
|
+
'kill -TERM -- -"$pid" 2>/dev/null || true; attempt=0; '
|
|
96
|
+
'while kill -0 -- -"$pid" 2>/dev/null && [ "$attempt" -lt 20 ]; do '
|
|
97
|
+
"attempt=$((attempt + 1)); sleep 0.05; done; "
|
|
98
|
+
'kill -KILL -- -"$pid" 2>/dev/null || true; fi',
|
|
99
|
+
'rm -rf -- "$job_dir" || exit $?',
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
return "; ".join(cleanup_steps)
|
|
@@ -9,6 +9,8 @@ import tempfile
|
|
|
9
9
|
from dataclasses import dataclass
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
|
+
from .secure_files import ensure_private_directory
|
|
13
|
+
|
|
12
14
|
|
|
13
15
|
@dataclass(frozen=True, slots=True)
|
|
14
16
|
class RuntimePaths:
|
|
@@ -18,8 +20,7 @@ class RuntimePaths:
|
|
|
18
20
|
log: Path
|
|
19
21
|
|
|
20
22
|
def ensure_directory(self) -> None:
|
|
21
|
-
self.directory
|
|
22
|
-
self.directory.chmod(0o700)
|
|
23
|
+
ensure_private_directory(self.directory)
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
def _default_runtime_dir() -> Path:
|