@neoline/hostbridge 0.2.2 → 1.0.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,22 @@
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,
14
15
  PolicyDeniedError,
15
16
  RemoteCommandError,
16
- SessionManager,
17
17
  )
18
- from .policy import build_audit_sink, load_policy
19
18
 
20
19
  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
20
 
27
21
 
28
22
  _ERROR_CODES = {
@@ -40,200 +34,235 @@ def _error_code(exc: Exception) -> str:
40
34
  return "internal"
41
35
 
42
36
 
43
- def _reload_hosts() -> HostRegistry:
44
- """Reload host allowlist without dropping existing remote sessions."""
37
+ def _error(exc: Exception) -> dict[str, object]:
38
+ return {"ok": False, "error": str(exc), "error_code": _error_code(exc)}
45
39
 
46
- global _registry
47
- _registry = HostRegistry(load_hosts())
48
- _manager.hosts = _registry
49
- return _registry
50
40
 
41
+ def _owner_agent_id() -> str | None:
42
+ """Return the optional caller identity stamped onto daemon-owned sessions."""
51
43
 
52
- def _error(exc: Exception) -> dict[str, object]:
53
- return {"ok": False, "error": str(exc), "error_code": _error_code(exc)}
44
+ value = os.environ.get("HOSTBRIDGE_AGENT_ID", "").strip()
45
+ return value or None
46
+
47
+
48
+ def _daemon_request(method: str, path: str, payload: dict[str, object] | None = None, *, timeout: float = 5.0) -> dict[str, object]:
49
+ try:
50
+ return daemon.request_json(method, path, payload, timeout=timeout)
51
+ except OSError as exc:
52
+ # MCP stdout must stay protocol-clean, so auto-start the daemon quietly.
53
+ if daemon.start_background(quiet=True) != 0:
54
+ raise RuntimeError(f"hostbridge daemon failed to start; see {daemon.LOG_FILE}") from exc
55
+ return daemon.request_json(method, path, payload, timeout=timeout)
54
56
 
55
57
 
56
- def _ok(**payload: object) -> dict[str, object]:
57
- return {"ok": True, **payload}
58
+ def _daemon_payload(**payload: object) -> dict[str, object]:
59
+ owner = _owner_agent_id()
60
+ if owner is not None:
61
+ payload["owner_agent_id"] = owner
62
+ return payload
63
+
64
+
65
+ def _qid(value: str) -> str:
66
+ return quote(value, safe="")
58
67
 
59
68
 
60
69
  @mcp.tool()
61
70
  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
- """
71
+ """List approved connection targets and local metadata when available."""
67
72
  try:
68
- registry = _reload_hosts()
69
- return _ok(hosts=registry.describe())
73
+ return _daemon_request("POST", "/hosts/reload")
70
74
  except Exception as exc:
71
75
  return _error(exc)
72
76
 
73
77
 
74
78
  @mcp.tool()
75
79
  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
- """
80
+ """Reload approved connection targets from hosts.json without restarting."""
81
81
  try:
82
- registry = _reload_hosts()
83
- return _ok(hosts=registry.describe())
82
+ return _daemon_request("POST", "/hosts/reload")
84
83
  except Exception as exc:
85
84
  return _error(exc)
86
85
 
87
86
 
88
87
  @mcp.tool()
89
88
  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
- """
89
+ """Open a persistent interactive shell for one approved host."""
98
90
  try:
99
- _reload_hosts()
100
91
  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])
92
+ return _daemon_request("POST", "/sessions", _daemon_payload(host_id=host_id, connect_timeout=timeout), timeout=timeout + 5)
104
93
  except Exception as exc:
105
94
  return _error(exc)
106
95
 
107
96
 
108
97
  @mcp.tool()
109
98
  def sessions_list() -> dict[str, object]:
110
- """List currently open sessions held by this MCP process."""
111
- return _ok(sessions=_manager.list_sessions())
99
+ """List currently open daemon-owned sessions."""
100
+ try:
101
+ return _daemon_request("GET", "/sessions")
102
+ except Exception as exc:
103
+ return _error(exc)
112
104
 
113
105
 
114
106
  @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
- """
107
+ def session_close(session_id: str, force: bool = False) -> dict[str, object]:
108
+ """Close one persistent session and free its PTY."""
121
109
  try:
122
- return _ok(**_manager.close_session(session_id))
110
+ return _daemon_request("DELETE", f"/sessions/{_qid(session_id)}", _daemon_payload(force=bool(force)))
123
111
  except Exception as exc:
124
112
  return _error(exc)
125
113
 
126
114
 
127
115
  @mcp.tool()
128
116
  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
- """
117
+ """Close every live session. Use during graceful shutdown or reset."""
133
118
  try:
134
- return _ok(**_manager.close_all_sessions())
119
+ return _daemon_request("POST", "/sessions/close_all")
135
120
  except Exception as exc:
136
121
  return _error(exc)
137
122
 
138
123
 
139
124
  @mcp.tool()
140
125
  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
- """
126
+ """Run a foreground command on an open session and wait for completion or timeout."""
151
127
  try:
152
128
  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))
129
+ return _daemon_request(
130
+ "POST",
131
+ f"/sessions/{_qid(session_id)}/commands",
132
+ _daemon_payload(command=command, timeout=safe_timeout),
133
+ timeout=safe_timeout + 5,
134
+ )
155
135
  except Exception as exc:
156
136
  return _error(exc)
157
137
 
158
138
 
159
139
  @mcp.tool()
160
140
  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
- """
141
+ """Start a long-running background task on the remote host."""
170
142
  try:
171
- task = _manager.start_task(session_id, command)
172
- return _ok(task=asdict(task))
143
+ return _daemon_request("POST", f"/sessions/{_qid(session_id)}/tasks", _daemon_payload(command=command))
173
144
  except Exception as exc:
174
145
  return _error(exc)
175
146
 
176
147
 
177
148
  @mcp.tool()
178
149
  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
- """
150
+ """Poll a background task: state, exit code, and recent log tail."""
186
151
  try:
187
- return _ok(status=_manager.task_status(session_id, task_id, tail_lines=tail_lines))
152
+ return _daemon_request(
153
+ "GET",
154
+ f"/sessions/{_qid(session_id)}/tasks/{_qid(task_id)}?tail_lines={int(tail_lines)}",
155
+ _daemon_payload(),
156
+ )
188
157
  except Exception as exc:
189
158
  return _error(exc)
190
159
 
191
160
 
192
161
  @mcp.tool()
193
162
  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
- """
163
+ """Best-effort terminate a background task by sending SIGTERM to its remote PID."""
202
164
  try:
203
- return _ok(**_manager.stop_task(session_id, task_id))
165
+ return _daemon_request("DELETE", f"/sessions/{_qid(session_id)}/tasks/{_qid(task_id)}", _daemon_payload())
204
166
  except Exception as exc:
205
167
  return _error(exc)
206
168
 
207
169
 
208
170
  @mcp.tool()
209
171
  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
- """
172
+ """Send one raw line to a session for rare interactive operations; prefer command_run otherwise."""
216
173
  try:
217
174
  if not text.strip():
218
175
  raise RemoteCommandError("text must not be empty")
219
- return _ok(**_manager.write(session_id, text))
176
+ return _daemon_request("POST", f"/sessions/{_qid(session_id)}/write", _daemon_payload(text=text))
220
177
  except Exception as exc:
221
178
  return _error(exc)
222
179
 
223
180
 
224
181
  @mcp.tool()
225
182
  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
- """
183
+ """Read currently available raw output from a session after session_write or interactive commands."""
233
184
  try:
234
185
  safe_timeout = max(0.0, min(float(timeout), 10.0))
235
186
  safe_max = max(1, min(int(max_chars), 120000))
236
- return _ok(**_manager.read(session_id, timeout=safe_timeout, max_chars=safe_max))
187
+ return _daemon_request(
188
+ "POST",
189
+ f"/sessions/{_qid(session_id)}/read",
190
+ _daemon_payload(timeout=safe_timeout, max_chars=safe_max),
191
+ timeout=safe_timeout + 5,
192
+ )
193
+ except Exception as exc:
194
+ return _error(exc)
195
+
196
+
197
+ @mcp.tool()
198
+ def file_write_text(
199
+ session_id: str,
200
+ remote_path: str,
201
+ content: str,
202
+ mode: str = "0644",
203
+ max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
204
+ ) -> dict[str, object]:
205
+ """Write a small UTF-8 text file through the active shell session."""
206
+ try:
207
+ safe_max = max(1, min(int(max_bytes), DEFAULT_MAX_TRANSFER_BYTES))
208
+ return _daemon_request(
209
+ "POST",
210
+ f"/sessions/{_qid(session_id)}/files",
211
+ _daemon_payload(action="write_text", remote_path=remote_path, content=content, mode=mode, max_bytes=safe_max),
212
+ )
213
+ except Exception as exc:
214
+ return _error(exc)
215
+
216
+
217
+ @mcp.tool()
218
+ def file_read_text(session_id: str, remote_path: str, max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES) -> dict[str, object]:
219
+ """Read a small UTF-8 text file through the active shell session."""
220
+ try:
221
+ safe_max = max(1, min(int(max_bytes), DEFAULT_MAX_TRANSFER_BYTES))
222
+ return _daemon_request(
223
+ "POST",
224
+ f"/sessions/{_qid(session_id)}/files",
225
+ _daemon_payload(action="read_text", remote_path=remote_path, max_bytes=safe_max),
226
+ )
227
+ except Exception as exc:
228
+ return _error(exc)
229
+
230
+
231
+ @mcp.tool()
232
+ def file_upload(
233
+ session_id: str,
234
+ local_path: str,
235
+ remote_path: str,
236
+ mode: str = "0644",
237
+ max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
238
+ ) -> dict[str, object]:
239
+ """Upload a small local file via shell/base64."""
240
+ try:
241
+ safe_max = max(1, min(int(max_bytes), DEFAULT_MAX_TRANSFER_BYTES))
242
+ return _daemon_request(
243
+ "POST",
244
+ f"/sessions/{_qid(session_id)}/files",
245
+ _daemon_payload(action="upload", local_path=local_path, remote_path=remote_path, mode=mode, max_bytes=safe_max),
246
+ )
247
+ except Exception as exc:
248
+ return _error(exc)
249
+
250
+
251
+ @mcp.tool()
252
+ def file_download(
253
+ session_id: str,
254
+ remote_path: str,
255
+ local_path: str,
256
+ max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
257
+ ) -> dict[str, object]:
258
+ """Download a small remote file via shell/base64."""
259
+ try:
260
+ safe_max = max(1, min(int(max_bytes), DEFAULT_MAX_TRANSFER_BYTES))
261
+ return _daemon_request(
262
+ "POST",
263
+ f"/sessions/{_qid(session_id)}/files",
264
+ _daemon_payload(action="download", remote_path=remote_path, local_path=local_path, max_bytes=safe_max),
265
+ )
237
266
  except Exception as exc:
238
267
  return _error(exc)
239
268