@neoline/hostbridge 0.2.2 → 1.1.0

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.
@@ -1,28 +1,23 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
3
4
  import sys
4
- from dataclasses import asdict
5
5
  from typing import Any
6
+ from urllib.parse import quote
6
7
 
7
8
  from mcp.server.fastmcp import FastMCP
8
9
 
9
- from . import cli
10
- from .hosts import HostRegistry, load_hosts
10
+ from . import cli, daemon
11
11
  from .manager import (
12
12
  DEFAULT_COMMAND_TIMEOUT,
13
13
  DEFAULT_CONNECT_TIMEOUT,
14
+ DEFAULT_MAX_TRANSFER_BYTES,
15
+ MAX_SHELL_TRANSFER_BYTES,
14
16
  PolicyDeniedError,
15
17
  RemoteCommandError,
16
- SessionManager,
17
18
  )
18
- from .policy import build_audit_sink, load_policy
19
19
 
20
20
  mcp = FastMCP("hostbridge")
21
- _registry = HostRegistry(load_hosts())
22
- _policy = load_policy()
23
- _audit = build_audit_sink(_policy)
24
- _manager = SessionManager(_registry, policy=_policy, audit_sink=_audit)
25
- _manager.start_idle_reaper()
26
21
 
27
22
 
28
23
  _ERROR_CODES = {
@@ -40,200 +35,239 @@ def _error_code(exc: Exception) -> str:
40
35
  return "internal"
41
36
 
42
37
 
43
- def _reload_hosts() -> HostRegistry:
44
- """Reload host allowlist without dropping existing remote sessions."""
38
+ def _error(exc: Exception) -> dict[str, object]:
39
+ return {"ok": False, "error": str(exc), "error_code": _error_code(exc)}
45
40
 
46
- global _registry
47
- _registry = HostRegistry(load_hosts())
48
- _manager.hosts = _registry
49
- return _registry
50
41
 
42
+ def _owner_agent_id() -> str | None:
43
+ """Return the optional caller identity stamped onto daemon-owned sessions."""
51
44
 
52
- def _error(exc: Exception) -> dict[str, object]:
53
- return {"ok": False, "error": str(exc), "error_code": _error_code(exc)}
45
+ value = os.environ.get("HOSTBRIDGE_AGENT_ID", "").strip()
46
+ return value or None
47
+
48
+
49
+ def _daemon_request(method: str, path: str, payload: dict[str, object] | None = None, *, timeout: float = 5.0) -> dict[str, object]:
50
+ try:
51
+ return daemon.request_json(method, path, payload, timeout=timeout)
52
+ except OSError as exc:
53
+ # MCP stdout must stay protocol-clean, so auto-start the daemon quietly.
54
+ if daemon.start_background(quiet=True) != 0:
55
+ raise RuntimeError(f"hostbridge daemon failed to start; see {daemon.LOG_FILE}") from exc
56
+ return daemon.request_json(method, path, payload, timeout=timeout)
54
57
 
55
58
 
56
- def _ok(**payload: object) -> dict[str, object]:
57
- return {"ok": True, **payload}
59
+ def _daemon_payload(**payload: object) -> dict[str, object]:
60
+ owner = _owner_agent_id()
61
+ if owner is not None:
62
+ payload["owner_agent_id"] = owner
63
+ return payload
64
+
65
+
66
+ def _qid(value: str) -> str:
67
+ return quote(value, safe="")
68
+
69
+
70
+ def _safe_transfer_limit(max_bytes: int) -> int:
71
+ return max(1, min(int(max_bytes), MAX_SHELL_TRANSFER_BYTES))
58
72
 
59
73
 
60
74
  @mcp.tool()
61
75
  def hosts_list() -> dict[str, object]:
62
- """List approved connection targets and local metadata when available.
63
-
64
- Returns an object with ``hosts``: an array of host descriptors. Safe to call
65
- repeatedly; it triggers a config reload from hosts.json.
66
- """
76
+ """List approved connection targets and local metadata when available."""
67
77
  try:
68
- registry = _reload_hosts()
69
- return _ok(hosts=registry.describe())
78
+ return _daemon_request("POST", "/hosts/reload")
70
79
  except Exception as exc:
71
80
  return _error(exc)
72
81
 
73
82
 
74
83
  @mcp.tool()
75
84
  def hosts_reload() -> dict[str, object]:
76
- """Reload approved connection targets from hosts.json without restarting.
77
-
78
- Returns the same shape as ``hosts_list``. Use this when you know the config
79
- file changed and want a fresh descriptor immediately.
80
- """
85
+ """Reload approved connection targets from hosts.json without restarting."""
81
86
  try:
82
- registry = _reload_hosts()
83
- return _ok(hosts=registry.describe())
87
+ return _daemon_request("POST", "/hosts/reload")
84
88
  except Exception as exc:
85
89
  return _error(exc)
86
90
 
87
91
 
88
92
  @mcp.tool()
89
93
  def session_open(host_id: str, connect_timeout: int = DEFAULT_CONNECT_TIMEOUT) -> dict[str, object]:
90
- """Open a persistent interactive shell for one approved host.
91
-
92
- Args:
93
- host_id: identifier of a host in hosts.json.
94
- connect_timeout: seconds to wait for the shell to become ready (5-180).
95
-
96
- Returns the opened session descriptor. Keep the ``session_id`` for later calls.
97
- """
94
+ """Open a persistent interactive shell for one approved host."""
98
95
  try:
99
- _reload_hosts()
100
96
  timeout = max(5, min(int(connect_timeout), 180))
101
- session = _manager.open_session(host_id, connect_timeout=timeout)
102
- sessions = [item for item in _manager.list_sessions() if item["session_id"] == session.id]
103
- return _ok(session=sessions[0])
97
+ return _daemon_request("POST", "/sessions", _daemon_payload(host_id=host_id, connect_timeout=timeout), timeout=timeout + 5)
104
98
  except Exception as exc:
105
99
  return _error(exc)
106
100
 
107
101
 
108
102
  @mcp.tool()
109
103
  def sessions_list() -> dict[str, object]:
110
- """List currently open sessions held by this MCP process."""
111
- return _ok(sessions=_manager.list_sessions())
104
+ """List currently open daemon-owned sessions."""
105
+ try:
106
+ return _daemon_request("GET", "/sessions")
107
+ except Exception as exc:
108
+ return _error(exc)
112
109
 
113
110
 
114
111
  @mcp.tool()
115
- def session_close(session_id: str) -> dict[str, object]:
116
- """Close one persistent session and free its PTY.
117
-
118
- Args:
119
- session_id: value returned by ``session_open``.
120
- """
112
+ def session_close(session_id: str, force: bool = False) -> dict[str, object]:
113
+ """Close one persistent session and free its PTY."""
121
114
  try:
122
- return _ok(**_manager.close_session(session_id))
115
+ return _daemon_request("DELETE", f"/sessions/{_qid(session_id)}", _daemon_payload(force=bool(force)))
123
116
  except Exception as exc:
124
117
  return _error(exc)
125
118
 
126
119
 
127
120
  @mcp.tool()
128
121
  def sessions_close_all() -> dict[str, object]:
129
- """Close every live session. Use during graceful shutdown or reset.
130
-
131
- Returns ``{"closed": [...session_ids], "count": N}``.
132
- """
122
+ """Close every live session. Use during graceful shutdown or reset."""
133
123
  try:
134
- return _ok(**_manager.close_all_sessions())
124
+ return _daemon_request("POST", "/sessions/close_all")
135
125
  except Exception as exc:
136
126
  return _error(exc)
137
127
 
138
128
 
139
129
  @mcp.tool()
140
130
  def command_run(session_id: str, command: str, timeout: int = DEFAULT_COMMAND_TIMEOUT) -> dict[str, object]:
141
- """Run a foreground command on an open session and wait for completion or timeout.
142
-
143
- Args:
144
- session_id: an open session from ``session_open``.
145
- command: the shell command string to execute.
146
- timeout: max seconds to wait (1 - 86400). On timeout the result has ``timed_out=true``.
147
-
148
- The command is checked against the configured denylist before execution;
149
- a blocked command returns ``error_code="denied"``.
150
- """
131
+ """Run a foreground command on an open session and wait for completion or timeout."""
151
132
  try:
152
133
  safe_timeout = max(1, min(int(timeout), 24 * 60 * 60))
153
- result = _manager.run_command(session_id, command, timeout=safe_timeout)
154
- return _ok(result=asdict(result))
134
+ return _daemon_request(
135
+ "POST",
136
+ f"/sessions/{_qid(session_id)}/commands",
137
+ _daemon_payload(command=command, timeout=safe_timeout),
138
+ timeout=safe_timeout + 5,
139
+ )
155
140
  except Exception as exc:
156
141
  return _error(exc)
157
142
 
158
143
 
159
144
  @mcp.tool()
160
145
  def task_start(session_id: str, command: str) -> dict[str, object]:
161
- """Start a long-running background task on the remote host.
162
-
163
- Args:
164
- session_id: an open session.
165
- command: the shell command to run detached on the remote host.
166
-
167
- Returns a task descriptor with ``task_id`` for polling via ``task_status``.
168
- The command is checked against the configured denylist before execution.
169
- """
146
+ """Start a long-running background task on the remote host."""
170
147
  try:
171
- task = _manager.start_task(session_id, command)
172
- return _ok(task=asdict(task))
148
+ return _daemon_request("POST", f"/sessions/{_qid(session_id)}/tasks", _daemon_payload(command=command))
173
149
  except Exception as exc:
174
150
  return _error(exc)
175
151
 
176
152
 
177
153
  @mcp.tool()
178
154
  def task_status(session_id: str, task_id: str, tail_lines: int = 80) -> dict[str, object]:
179
- """Poll a background task: state, exit code, and recent log tail.
180
-
181
- Args:
182
- session_id: the session that owns the task.
183
- task_id: value returned by ``task_start``.
184
- tail_lines: number of trailing log lines to return (1 - 500).
185
- """
155
+ """Poll a background task: state, exit code, and recent log tail."""
186
156
  try:
187
- return _ok(status=_manager.task_status(session_id, task_id, tail_lines=tail_lines))
157
+ return _daemon_request(
158
+ "GET",
159
+ f"/sessions/{_qid(session_id)}/tasks/{_qid(task_id)}?tail_lines={int(tail_lines)}",
160
+ _daemon_payload(),
161
+ )
188
162
  except Exception as exc:
189
163
  return _error(exc)
190
164
 
191
165
 
192
166
  @mcp.tool()
193
167
  def task_stop(session_id: str, task_id: str) -> dict[str, object]:
194
- """Best-effort terminate a background task by sending SIGTERM to its remote PID.
195
-
196
- Args:
197
- session_id: the session that owns the task.
198
- task_id: value returned by ``task_start``.
199
-
200
- Returns ``{"task_id": ..., "stopped": bool, "outcome": "killed|already|noop"}``.
201
- """
168
+ """Best-effort terminate a background task by sending SIGTERM to its remote PID."""
202
169
  try:
203
- return _ok(**_manager.stop_task(session_id, task_id))
170
+ return _daemon_request("DELETE", f"/sessions/{_qid(session_id)}/tasks/{_qid(task_id)}", _daemon_payload())
204
171
  except Exception as exc:
205
172
  return _error(exc)
206
173
 
207
174
 
208
175
  @mcp.tool()
209
176
  def session_write(session_id: str, text: str) -> dict[str, object]:
210
- """Send one raw line to a session for rare interactive operations; prefer command_run otherwise.
211
-
212
- Args:
213
- session_id: an open session.
214
- text: the literal line to send. Must not be empty.
215
- """
177
+ """Send one raw line to a session for rare interactive operations; prefer command_run otherwise."""
216
178
  try:
217
179
  if not text.strip():
218
180
  raise RemoteCommandError("text must not be empty")
219
- return _ok(**_manager.write(session_id, text))
181
+ return _daemon_request("POST", f"/sessions/{_qid(session_id)}/write", _daemon_payload(text=text))
220
182
  except Exception as exc:
221
183
  return _error(exc)
222
184
 
223
185
 
224
186
  @mcp.tool()
225
187
  def session_read(session_id: str, timeout: float = 1.0, max_chars: int = 20000) -> dict[str, object]:
226
- """Read currently available raw output from a session after session_write or interactive commands.
227
-
228
- Args:
229
- session_id: an open session.
230
- timeout: max seconds to block waiting for output (0.0 - 10.0).
231
- max_chars: cap on returned characters (1 - 120000).
232
- """
188
+ """Read currently available raw output from a session after session_write or interactive commands."""
233
189
  try:
234
190
  safe_timeout = max(0.0, min(float(timeout), 10.0))
235
191
  safe_max = max(1, min(int(max_chars), 120000))
236
- return _ok(**_manager.read(session_id, timeout=safe_timeout, max_chars=safe_max))
192
+ return _daemon_request(
193
+ "POST",
194
+ f"/sessions/{_qid(session_id)}/read",
195
+ _daemon_payload(timeout=safe_timeout, max_chars=safe_max),
196
+ timeout=safe_timeout + 5,
197
+ )
198
+ except Exception as exc:
199
+ return _error(exc)
200
+
201
+
202
+ @mcp.tool()
203
+ def file_write_text(
204
+ session_id: str,
205
+ remote_path: str,
206
+ content: str,
207
+ mode: str = "0644",
208
+ max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
209
+ ) -> dict[str, object]:
210
+ """Write a UTF-8 text file through the active shell session; supports up to 100MiB via chunked base64."""
211
+ try:
212
+ safe_max = _safe_transfer_limit(max_bytes)
213
+ return _daemon_request(
214
+ "POST",
215
+ f"/sessions/{_qid(session_id)}/files",
216
+ _daemon_payload(action="write_text", remote_path=remote_path, content=content, mode=mode, max_bytes=safe_max),
217
+ )
218
+ except Exception as exc:
219
+ return _error(exc)
220
+
221
+
222
+ @mcp.tool()
223
+ def file_read_text(session_id: str, remote_path: str, max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES) -> dict[str, object]:
224
+ """Read a UTF-8 text file through the active shell session; supports up to 100MiB via chunked base64."""
225
+ try:
226
+ safe_max = _safe_transfer_limit(max_bytes)
227
+ return _daemon_request(
228
+ "POST",
229
+ f"/sessions/{_qid(session_id)}/files",
230
+ _daemon_payload(action="read_text", remote_path=remote_path, max_bytes=safe_max),
231
+ )
232
+ except Exception as exc:
233
+ return _error(exc)
234
+
235
+
236
+ @mcp.tool()
237
+ def file_upload(
238
+ session_id: str,
239
+ local_path: str,
240
+ remote_path: str,
241
+ mode: str = "0644",
242
+ max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
243
+ ) -> dict[str, object]:
244
+ """Upload a local file through the active shell session; supports up to 100MiB via chunked base64."""
245
+ try:
246
+ safe_max = _safe_transfer_limit(max_bytes)
247
+ return _daemon_request(
248
+ "POST",
249
+ f"/sessions/{_qid(session_id)}/files",
250
+ _daemon_payload(action="upload", local_path=local_path, remote_path=remote_path, mode=mode, max_bytes=safe_max),
251
+ )
252
+ except Exception as exc:
253
+ return _error(exc)
254
+
255
+
256
+ @mcp.tool()
257
+ def file_download(
258
+ session_id: str,
259
+ remote_path: str,
260
+ local_path: str,
261
+ max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
262
+ ) -> dict[str, object]:
263
+ """Download a remote file through the active shell session; supports up to 100MiB via chunked base64."""
264
+ try:
265
+ safe_max = _safe_transfer_limit(max_bytes)
266
+ return _daemon_request(
267
+ "POST",
268
+ f"/sessions/{_qid(session_id)}/files",
269
+ _daemon_payload(action="download", remote_path=remote_path, local_path=local_path, max_bytes=safe_max),
270
+ )
237
271
  except Exception as exc:
238
272
  return _error(exc)
239
273