@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.
Files changed (48) hide show
  1. package/README.md +24 -15
  2. package/bin/hostbridge.js +80 -9
  3. package/docs/ARCHITECTURE.md +62 -0
  4. package/examples/claude_desktop_config.json +1 -1
  5. package/examples/codex.config.toml +1 -1
  6. package/examples/hosts.example.json +1 -0
  7. package/package.json +2 -1
  8. package/pyproject.toml +5 -4
  9. package/src/server_control_mcp/__init__.py +60 -24
  10. package/src/server_control_mcp/async_lifecycle.py +41 -0
  11. package/src/server_control_mcp/cli.py +2 -2
  12. package/src/server_control_mcp/client.py +345 -238
  13. package/src/server_control_mcp/config.py +18 -6
  14. package/src/server_control_mcp/daemon.py +309 -412
  15. package/src/server_control_mcp/doctor.py +41 -5
  16. package/src/server_control_mcp/hosts.py +252 -24
  17. package/src/server_control_mcp/mock_ssh.py +97 -960
  18. package/src/server_control_mcp/mock_ssh_exec.py +299 -0
  19. package/src/server_control_mcp/mock_ssh_session.py +69 -0
  20. package/src/server_control_mcp/mock_ssh_sftp.py +604 -0
  21. package/src/server_control_mcp/mock_ssh_tunnel.py +289 -0
  22. package/src/server_control_mcp/mux_connection.py +326 -0
  23. package/src/server_control_mcp/mux_daemon.py +186 -0
  24. package/src/server_control_mcp/mux_protocol.py +279 -0
  25. package/src/server_control_mcp/mux_records.py +71 -0
  26. package/src/server_control_mcp/mux_rpc.py +1779 -0
  27. package/src/server_control_mcp/mux_service.py +506 -0
  28. package/src/server_control_mcp/mux_stream.py +283 -0
  29. package/src/server_control_mcp/mux_sync_client.py +224 -0
  30. package/src/server_control_mcp/policy.py +86 -14
  31. package/src/server_control_mcp/remote_agent_bundle.py +56 -0
  32. package/src/server_control_mcp/remote_mux_agent.py +769 -0
  33. package/src/server_control_mcp/remote_task_agent.py +102 -0
  34. package/src/server_control_mcp/runtime.py +3 -2
  35. package/src/server_control_mcp/runtime_services.py +862 -0
  36. package/src/server_control_mcp/secrets.py +6 -6
  37. package/src/server_control_mcp/secure_files.py +121 -0
  38. package/src/server_control_mcp/server.py +53 -20
  39. package/src/server_control_mcp/services.py +3 -469
  40. package/src/server_control_mcp/transports/__init__.py +4 -8
  41. package/src/server_control_mcp/transports/base.py +60 -38
  42. package/src/server_control_mcp/transports/ssh.py +315 -84
  43. package/src/server_control_mcp/tunnel_manager.py +1245 -0
  44. package/src/server_control_mcp/tunnel_native_ssh.py +454 -0
  45. package/src/server_control_mcp/tunnel_providers.py +20 -0
  46. package/src/server_control_mcp/tunnel_pty_agent.py +909 -0
  47. package/src/server_control_mcp/protocol.py +0 -362
  48. 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.mkdir(mode=0o700, parents=True, exist_ok=True)
22
- self.directory.chmod(0o700)
23
+ ensure_private_directory(self.directory)
23
24
 
24
25
 
25
26
  def _default_runtime_dir() -> Path: