@neoline/hostbridge 1.4.3 → 2.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.
- package/README.md +24 -137
- package/package.json +1 -1
- package/pyproject.toml +2 -1
- package/src/server_control_mcp/__init__.py +4 -5
- package/src/server_control_mcp/cli.py +229 -1231
- package/src/server_control_mcp/client.py +468 -0
- package/src/server_control_mcp/config.py +139 -0
- package/src/server_control_mcp/daemon.py +387 -306
- package/src/server_control_mcp/doctor.py +108 -0
- package/src/server_control_mcp/hosts.py +52 -7
- package/src/server_control_mcp/mock_ssh.py +177 -87
- package/src/server_control_mcp/policy.py +2 -0
- package/src/server_control_mcp/protocol.py +362 -0
- package/src/server_control_mcp/runtime.py +40 -0
- package/src/server_control_mcp/server.py +132 -188
- package/src/server_control_mcp/services.py +508 -0
- package/src/server_control_mcp/transports/__init__.py +17 -0
- package/src/server_control_mcp/transports/base.py +78 -0
- package/src/server_control_mcp/transports/pty.py +405 -0
- package/src/server_control_mcp/transports/ssh.py +195 -0
- package/src/server_control_mcp/manager.py +0 -922
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
"""Daemon-owned HostBridge session and execution services."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
from .hosts import HostConfig, HostRegistry
|
|
15
|
+
from .policy import AuditSink, PolicyConfig, audit_event
|
|
16
|
+
from .transports.base import ExecResult, ShellReadResult, TransferResult, Transport, TransportBusy, TransportError
|
|
17
|
+
|
|
18
|
+
TransportFactory = Callable[[HostConfig], Transport]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ServiceError(RuntimeError):
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
code: str,
|
|
25
|
+
message: str,
|
|
26
|
+
*,
|
|
27
|
+
retryable: bool = False,
|
|
28
|
+
details: dict[str, object] | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
self.code = code
|
|
32
|
+
self.retryable = retryable
|
|
33
|
+
self.details = details or {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class ServiceSession:
|
|
38
|
+
session_id: str
|
|
39
|
+
host_id: str
|
|
40
|
+
owner: str | None
|
|
41
|
+
transport: Transport = field(repr=False)
|
|
42
|
+
created_at: float = field(default_factory=time.time)
|
|
43
|
+
last_used_at: float = field(default_factory=time.time)
|
|
44
|
+
active_operations: int = 0
|
|
45
|
+
|
|
46
|
+
def describe(self) -> dict[str, object]:
|
|
47
|
+
return {
|
|
48
|
+
"session_id": self.session_id,
|
|
49
|
+
"host_id": self.host_id,
|
|
50
|
+
"owner": self.owner,
|
|
51
|
+
"created_at": self.created_at,
|
|
52
|
+
"last_used_at": self.last_used_at,
|
|
53
|
+
"active_operations": self.active_operations,
|
|
54
|
+
"capabilities": {
|
|
55
|
+
"separate_stderr": self.transport.capabilities.separate_stderr,
|
|
56
|
+
"native_binary_transfer": self.transport.capabilities.native_binary_transfer,
|
|
57
|
+
"parallel_channels": self.transport.capabilities.parallel_channels,
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class ServiceExecResult:
|
|
64
|
+
session_id: str
|
|
65
|
+
exit_code: int | None
|
|
66
|
+
stdout: bytes
|
|
67
|
+
stderr: bytes
|
|
68
|
+
timed_out: bool
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class ServiceTask:
|
|
73
|
+
task_id: str
|
|
74
|
+
session_id: str
|
|
75
|
+
host_id: str
|
|
76
|
+
command: str
|
|
77
|
+
job_dir: str
|
|
78
|
+
remote_pid: int | None
|
|
79
|
+
owner: str | None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class HostBridgeServices:
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
hosts: HostRegistry,
|
|
86
|
+
*,
|
|
87
|
+
transport_factory: TransportFactory,
|
|
88
|
+
policy: PolicyConfig | None = None,
|
|
89
|
+
audit_sink: AuditSink | None = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
self.hosts = hosts
|
|
92
|
+
self._transport_factory = transport_factory
|
|
93
|
+
self._policy = policy
|
|
94
|
+
self._audit = audit_sink
|
|
95
|
+
self._sessions: dict[str, ServiceSession] = {}
|
|
96
|
+
self._tasks: dict[str, ServiceTask] = {}
|
|
97
|
+
self._sessions_lock = threading.RLock()
|
|
98
|
+
self._reaper_stop = threading.Event()
|
|
99
|
+
self._reaper_thread: threading.Thread | None = None
|
|
100
|
+
|
|
101
|
+
def open_session(self, host_id: str, *, owner: str | None = None) -> ServiceSession:
|
|
102
|
+
try:
|
|
103
|
+
host = self.hosts.get(host_id)
|
|
104
|
+
except KeyError as exc:
|
|
105
|
+
raise ServiceError("not_found", str(exc)) from exc
|
|
106
|
+
try:
|
|
107
|
+
transport = self._transport_factory(host)
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
raise ServiceError("connection_lost", f"failed to connect to host {host_id}", retryable=True) from exc
|
|
110
|
+
session = ServiceSession(uuid.uuid4().hex, host.id, owner, transport)
|
|
111
|
+
with self._sessions_lock:
|
|
112
|
+
self._sessions[session.session_id] = session
|
|
113
|
+
self._emit("session_opened", session_id=session.session_id, host_id=host.id, owner_agent_id=owner, outcome="ok")
|
|
114
|
+
return session
|
|
115
|
+
|
|
116
|
+
def list_sessions(self) -> list[dict[str, object]]:
|
|
117
|
+
with self._sessions_lock:
|
|
118
|
+
return [session.describe() for session in self._sessions.values()]
|
|
119
|
+
|
|
120
|
+
def exec(
|
|
121
|
+
self,
|
|
122
|
+
session_id: str,
|
|
123
|
+
command: str,
|
|
124
|
+
*,
|
|
125
|
+
stdin: Iterable[bytes] | None = None,
|
|
126
|
+
timeout: float = 60,
|
|
127
|
+
output_limit: int = 1_000_000,
|
|
128
|
+
owner: str | None = None,
|
|
129
|
+
) -> ServiceExecResult:
|
|
130
|
+
if not command.strip():
|
|
131
|
+
raise ServiceError("invalid_request", "command must not be empty")
|
|
132
|
+
if self._policy is not None:
|
|
133
|
+
decision = self._policy.evaluate(command)
|
|
134
|
+
if not decision.allowed:
|
|
135
|
+
raise ServiceError("denied", decision.reason, details={"matched_pattern": decision.matched_pattern})
|
|
136
|
+
session = self._get_session(session_id)
|
|
137
|
+
self._check_owner(session, owner)
|
|
138
|
+
with self._sessions_lock:
|
|
139
|
+
session.active_operations += 1
|
|
140
|
+
try:
|
|
141
|
+
result: ExecResult = session.transport.exec(
|
|
142
|
+
command,
|
|
143
|
+
stdin=stdin,
|
|
144
|
+
timeout=timeout,
|
|
145
|
+
output_limit=output_limit,
|
|
146
|
+
)
|
|
147
|
+
except TransportBusy as exc:
|
|
148
|
+
raise ServiceError("busy", str(exc), retryable=True) from exc
|
|
149
|
+
except TransportError as exc:
|
|
150
|
+
raise ServiceError("remote_error", str(exc), retryable=True) from exc
|
|
151
|
+
finally:
|
|
152
|
+
with self._sessions_lock:
|
|
153
|
+
session.active_operations -= 1
|
|
154
|
+
session.last_used_at = time.time()
|
|
155
|
+
self._emit(
|
|
156
|
+
"command_run",
|
|
157
|
+
session_id=session.session_id,
|
|
158
|
+
host_id=session.host_id,
|
|
159
|
+
owner_agent_id=owner,
|
|
160
|
+
command=command,
|
|
161
|
+
outcome="timeout" if result.timed_out else f"exit={result.exit_code}",
|
|
162
|
+
)
|
|
163
|
+
return ServiceExecResult(
|
|
164
|
+
session.session_id,
|
|
165
|
+
result.exit_code,
|
|
166
|
+
result.stdout,
|
|
167
|
+
result.stderr,
|
|
168
|
+
result.timed_out,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def close_session(self, session_id: str, *, owner: str | None = None, force: bool = False) -> None:
|
|
172
|
+
session = self._get_session(session_id)
|
|
173
|
+
if not force:
|
|
174
|
+
self._check_owner(session, owner)
|
|
175
|
+
with self._sessions_lock:
|
|
176
|
+
removed = self._sessions.pop(session_id, None)
|
|
177
|
+
if removed is not None:
|
|
178
|
+
with self._sessions_lock:
|
|
179
|
+
self._tasks = {task_id: task for task_id, task in self._tasks.items() if task.session_id != session_id}
|
|
180
|
+
removed.transport.close()
|
|
181
|
+
self._emit(
|
|
182
|
+
"session_closed",
|
|
183
|
+
session_id=session_id,
|
|
184
|
+
host_id=removed.host_id,
|
|
185
|
+
owner_agent_id=owner,
|
|
186
|
+
outcome="forced" if force else "user",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def close_all(self) -> dict[str, object]:
|
|
190
|
+
with self._sessions_lock:
|
|
191
|
+
sessions = list(self._sessions.values())
|
|
192
|
+
self._sessions.clear()
|
|
193
|
+
self._tasks.clear()
|
|
194
|
+
for session in sessions:
|
|
195
|
+
session.transport.close()
|
|
196
|
+
self._emit("session_closed", session_id=session.session_id, host_id=session.host_id, outcome="shutdown")
|
|
197
|
+
return {"closed": [session.session_id for session in sessions], "count": len(sessions)}
|
|
198
|
+
|
|
199
|
+
def reap_idle(self, timeout: float) -> int:
|
|
200
|
+
if timeout <= 0:
|
|
201
|
+
return 0
|
|
202
|
+
now = time.time()
|
|
203
|
+
with self._sessions_lock:
|
|
204
|
+
expired = [
|
|
205
|
+
session
|
|
206
|
+
for session in self._sessions.values()
|
|
207
|
+
if session.active_operations == 0 and now - session.last_used_at > timeout
|
|
208
|
+
]
|
|
209
|
+
for session in expired:
|
|
210
|
+
self._sessions.pop(session.session_id, None)
|
|
211
|
+
self._tasks = {
|
|
212
|
+
task_id: task
|
|
213
|
+
for task_id, task in self._tasks.items()
|
|
214
|
+
if task.session_id != session.session_id
|
|
215
|
+
}
|
|
216
|
+
for session in expired:
|
|
217
|
+
session.transport.close()
|
|
218
|
+
self._emit("session_idle_closed", session_id=session.session_id, host_id=session.host_id, outcome="expired")
|
|
219
|
+
return len(expired)
|
|
220
|
+
|
|
221
|
+
def start_idle_reaper(self, *, timeout: float, interval: float = 30) -> None:
|
|
222
|
+
if timeout <= 0 or interval <= 0:
|
|
223
|
+
return
|
|
224
|
+
if self._reaper_thread is not None and self._reaper_thread.is_alive():
|
|
225
|
+
return
|
|
226
|
+
self._reaper_stop.clear()
|
|
227
|
+
|
|
228
|
+
def reap_loop() -> None:
|
|
229
|
+
while not self._reaper_stop.wait(interval):
|
|
230
|
+
self.reap_idle(timeout)
|
|
231
|
+
|
|
232
|
+
self._reaper_thread = threading.Thread(target=reap_loop, daemon=True, name="hostbridge-v1-reaper")
|
|
233
|
+
self._reaper_thread.start()
|
|
234
|
+
|
|
235
|
+
def stop_idle_reaper(self) -> None:
|
|
236
|
+
self._reaper_stop.set()
|
|
237
|
+
thread = self._reaper_thread
|
|
238
|
+
if thread is not None and thread.is_alive():
|
|
239
|
+
thread.join(timeout=2)
|
|
240
|
+
self._reaper_thread = None
|
|
241
|
+
|
|
242
|
+
def start_task(self, session_id: str, command: str, *, owner: str | None = None) -> ServiceTask:
|
|
243
|
+
if not command.strip():
|
|
244
|
+
raise ServiceError("invalid_request", "command must not be empty")
|
|
245
|
+
session = self._get_session(session_id)
|
|
246
|
+
self._check_owner(session, owner)
|
|
247
|
+
task_id = uuid.uuid4().hex
|
|
248
|
+
job_dir = f"/tmp/hostbridge/jobs/{task_id}"
|
|
249
|
+
quoted_command = shlex.quote(command)
|
|
250
|
+
script = (
|
|
251
|
+
f"job_dir={shlex.quote(job_dir)}; mkdir -p \"$job_dir\"; "
|
|
252
|
+
f"({quoted_command} > \"$job_dir/stdout.log\" 2>&1; "
|
|
253
|
+
"status=$?; printf '%s\n' \"$status\" > \"$job_dir/exit_code\") & "
|
|
254
|
+
"pid=$!; printf '%s\n' \"$pid\" > \"$job_dir/pid\"; printf '%s' \"$pid\""
|
|
255
|
+
)
|
|
256
|
+
result = self.exec(session_id, script, timeout=10, output_limit=4096, owner=owner)
|
|
257
|
+
pid_match = re.search(rb"(\d+)", result.stdout)
|
|
258
|
+
task = ServiceTask(
|
|
259
|
+
task_id,
|
|
260
|
+
session_id,
|
|
261
|
+
session.host_id,
|
|
262
|
+
command,
|
|
263
|
+
job_dir,
|
|
264
|
+
int(pid_match.group(1)) if pid_match else None,
|
|
265
|
+
owner,
|
|
266
|
+
)
|
|
267
|
+
with self._sessions_lock:
|
|
268
|
+
self._tasks[task_id] = task
|
|
269
|
+
self._emit(
|
|
270
|
+
"task_started",
|
|
271
|
+
session_id=session_id,
|
|
272
|
+
task_id=task_id,
|
|
273
|
+
host_id=session.host_id,
|
|
274
|
+
owner_agent_id=owner,
|
|
275
|
+
command=command,
|
|
276
|
+
outcome="ok",
|
|
277
|
+
)
|
|
278
|
+
return task
|
|
279
|
+
|
|
280
|
+
def task_status(
|
|
281
|
+
self,
|
|
282
|
+
session_id: str,
|
|
283
|
+
task_id: str,
|
|
284
|
+
*,
|
|
285
|
+
owner: str | None = None,
|
|
286
|
+
tail_lines: int = 80,
|
|
287
|
+
) -> dict[str, object]:
|
|
288
|
+
task = self._get_task(session_id, task_id, owner)
|
|
289
|
+
safe_tail = max(1, min(int(tail_lines), 500))
|
|
290
|
+
script = (
|
|
291
|
+
f"job_dir={shlex.quote(task.job_dir)}; pid=$(cat \"$job_dir/pid\" 2>/dev/null || true); "
|
|
292
|
+
"if [ -f \"$job_dir/exit_code\" ]; then state=finished; exit_code=$(cat \"$job_dir/exit_code\"); "
|
|
293
|
+
"elif [ -n \"$pid\" ] && kill -0 \"$pid\" 2>/dev/null; then state=running; exit_code=''; "
|
|
294
|
+
"else state=unknown; exit_code=''; fi; "
|
|
295
|
+
"printf 'STATE=%s\nPID=%s\nEXIT=%s\n---LOG---\n' \"$state\" \"$pid\" \"$exit_code\"; "
|
|
296
|
+
f"tail -n {safe_tail} \"$job_dir/stdout.log\" 2>/dev/null || true"
|
|
297
|
+
)
|
|
298
|
+
result = self.exec(session_id, script, timeout=15, output_limit=1_000_000, owner=owner)
|
|
299
|
+
metadata, _, log = result.stdout.decode("utf-8", errors="replace").partition("---LOG---\n")
|
|
300
|
+
fields: dict[str, str] = {}
|
|
301
|
+
for line in metadata.splitlines():
|
|
302
|
+
key, separator, value = line.partition("=")
|
|
303
|
+
if separator:
|
|
304
|
+
fields[key] = value
|
|
305
|
+
exit_text = fields.get("EXIT", "")
|
|
306
|
+
return {
|
|
307
|
+
"task_id": task.task_id,
|
|
308
|
+
"session_id": session_id,
|
|
309
|
+
"host_id": task.host_id,
|
|
310
|
+
"command": task.command,
|
|
311
|
+
"job_dir": task.job_dir,
|
|
312
|
+
"state": fields.get("STATE", "unknown"),
|
|
313
|
+
"pid": fields.get("PID") or task.remote_pid,
|
|
314
|
+
"exit_code": int(exit_text) if exit_text.isdigit() else None,
|
|
315
|
+
"log_tail": log,
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
def cancel_task(self, session_id: str, task_id: str, *, owner: str | None = None) -> dict[str, object]:
|
|
319
|
+
task = self._get_task(session_id, task_id, owner)
|
|
320
|
+
if task.remote_pid is None:
|
|
321
|
+
raise ServiceError("remote_error", f"task {task_id} has no recorded remote pid")
|
|
322
|
+
script = (
|
|
323
|
+
f"job_dir={shlex.quote(task.job_dir)}; "
|
|
324
|
+
"if [ -f \"$job_dir/exit_code\" ]; then printf already; "
|
|
325
|
+
f"elif kill -TERM {task.remote_pid} 2>/dev/null; then printf killed; else printf noop; fi"
|
|
326
|
+
)
|
|
327
|
+
result = self.exec(session_id, script, timeout=10, output_limit=4096, owner=owner)
|
|
328
|
+
outcome = result.stdout.decode("utf-8", errors="replace").strip().splitlines()[-1] if result.stdout.strip() else "unknown"
|
|
329
|
+
self._emit("task_cancelled", session_id=session_id, task_id=task_id, outcome=outcome)
|
|
330
|
+
return {"task_id": task_id, "stopped": outcome in {"killed", "already"}, "outcome": outcome}
|
|
331
|
+
|
|
332
|
+
def upload(
|
|
333
|
+
self,
|
|
334
|
+
session_id: str,
|
|
335
|
+
chunks: Iterable[bytes],
|
|
336
|
+
remote_path: str,
|
|
337
|
+
*,
|
|
338
|
+
mode: int,
|
|
339
|
+
expected_size: int | None,
|
|
340
|
+
expected_sha256: str | None,
|
|
341
|
+
owner: str | None = None,
|
|
342
|
+
) -> TransferResult:
|
|
343
|
+
session = self._get_session(session_id)
|
|
344
|
+
self._check_owner(session, owner)
|
|
345
|
+
with self._sessions_lock:
|
|
346
|
+
session.active_operations += 1
|
|
347
|
+
try:
|
|
348
|
+
result = session.transport.upload(
|
|
349
|
+
chunks,
|
|
350
|
+
remote_path,
|
|
351
|
+
mode=mode,
|
|
352
|
+
expected_size=expected_size,
|
|
353
|
+
)
|
|
354
|
+
except TransportError as exc:
|
|
355
|
+
raise ServiceError("remote_error", str(exc), retryable=True) from exc
|
|
356
|
+
finally:
|
|
357
|
+
with self._sessions_lock:
|
|
358
|
+
session.active_operations -= 1
|
|
359
|
+
session.last_used_at = time.time()
|
|
360
|
+
if expected_size is not None and result.bytes_transferred != expected_size:
|
|
361
|
+
raise ServiceError(
|
|
362
|
+
"integrity_error",
|
|
363
|
+
f"transfer size mismatch: expected {expected_size}, received {result.bytes_transferred}",
|
|
364
|
+
)
|
|
365
|
+
if expected_sha256 is not None and result.sha256.lower() != expected_sha256.lower():
|
|
366
|
+
raise ServiceError("integrity_error", "transfer SHA-256 mismatch")
|
|
367
|
+
self._emit(
|
|
368
|
+
"file_uploaded",
|
|
369
|
+
session_id=session_id,
|
|
370
|
+
host_id=session.host_id,
|
|
371
|
+
owner_agent_id=owner,
|
|
372
|
+
command=remote_path,
|
|
373
|
+
outcome=f"bytes={result.bytes_transferred}",
|
|
374
|
+
)
|
|
375
|
+
return result
|
|
376
|
+
|
|
377
|
+
def download(
|
|
378
|
+
self,
|
|
379
|
+
session_id: str,
|
|
380
|
+
remote_path: str,
|
|
381
|
+
*,
|
|
382
|
+
chunk_size: int,
|
|
383
|
+
owner: str | None = None,
|
|
384
|
+
) -> Iterator[bytes]:
|
|
385
|
+
session = self._get_session(session_id)
|
|
386
|
+
self._check_owner(session, owner)
|
|
387
|
+
|
|
388
|
+
def stream() -> Iterator[bytes]:
|
|
389
|
+
with self._sessions_lock:
|
|
390
|
+
session.active_operations += 1
|
|
391
|
+
transferred = 0
|
|
392
|
+
try:
|
|
393
|
+
for chunk in session.transport.download(remote_path, chunk_size=chunk_size):
|
|
394
|
+
if not isinstance(chunk, bytes):
|
|
395
|
+
raise ServiceError("internal", "transport returned a non-bytes download chunk")
|
|
396
|
+
transferred += len(chunk)
|
|
397
|
+
yield chunk
|
|
398
|
+
except TransportError as exc:
|
|
399
|
+
raise ServiceError("remote_error", str(exc), retryable=True) from exc
|
|
400
|
+
finally:
|
|
401
|
+
with self._sessions_lock:
|
|
402
|
+
session.active_operations -= 1
|
|
403
|
+
session.last_used_at = time.time()
|
|
404
|
+
self._emit(
|
|
405
|
+
"file_downloaded",
|
|
406
|
+
session_id=session_id,
|
|
407
|
+
host_id=session.host_id,
|
|
408
|
+
owner_agent_id=owner,
|
|
409
|
+
command=remote_path,
|
|
410
|
+
outcome=f"bytes={transferred}",
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
return stream()
|
|
414
|
+
|
|
415
|
+
def write_text(
|
|
416
|
+
self,
|
|
417
|
+
session_id: str,
|
|
418
|
+
remote_path: str,
|
|
419
|
+
content: str,
|
|
420
|
+
*,
|
|
421
|
+
mode: int = 0o644,
|
|
422
|
+
max_bytes: int = 4 * 1024 * 1024,
|
|
423
|
+
owner: str | None = None,
|
|
424
|
+
) -> TransferResult:
|
|
425
|
+
data = content.encode("utf-8")
|
|
426
|
+
if len(data) > max_bytes:
|
|
427
|
+
raise ServiceError("resource_limit", f"text content exceeds {max_bytes} bytes")
|
|
428
|
+
return self.upload(
|
|
429
|
+
session_id,
|
|
430
|
+
iter([data]),
|
|
431
|
+
remote_path,
|
|
432
|
+
mode=mode,
|
|
433
|
+
expected_size=len(data),
|
|
434
|
+
expected_sha256=hashlib.sha256(data).hexdigest(),
|
|
435
|
+
owner=owner,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
def read_text(
|
|
439
|
+
self,
|
|
440
|
+
session_id: str,
|
|
441
|
+
remote_path: str,
|
|
442
|
+
*,
|
|
443
|
+
max_bytes: int = 4 * 1024 * 1024,
|
|
444
|
+
owner: str | None = None,
|
|
445
|
+
) -> str:
|
|
446
|
+
data = bytearray()
|
|
447
|
+
for chunk in self.download(session_id, remote_path, chunk_size=256 * 1024, owner=owner):
|
|
448
|
+
data.extend(chunk)
|
|
449
|
+
if len(data) > max_bytes:
|
|
450
|
+
raise ServiceError("resource_limit", f"text file exceeds {max_bytes} bytes")
|
|
451
|
+
try:
|
|
452
|
+
return bytes(data).decode("utf-8")
|
|
453
|
+
except UnicodeDecodeError as exc:
|
|
454
|
+
raise ServiceError("invalid_request", f"remote file is not valid UTF-8 at byte {exc.start}") from exc
|
|
455
|
+
|
|
456
|
+
def shell_write(self, session_id: str, data: bytes, *, owner: str | None = None) -> int:
|
|
457
|
+
session = self._get_session(session_id)
|
|
458
|
+
self._check_owner(session, owner)
|
|
459
|
+
try:
|
|
460
|
+
session.transport.shell_write(data)
|
|
461
|
+
except TransportError as exc:
|
|
462
|
+
raise ServiceError("remote_error", str(exc), retryable=True) from exc
|
|
463
|
+
session.last_used_at = time.time()
|
|
464
|
+
return len(data)
|
|
465
|
+
|
|
466
|
+
def shell_read(
|
|
467
|
+
self,
|
|
468
|
+
session_id: str,
|
|
469
|
+
*,
|
|
470
|
+
timeout: float,
|
|
471
|
+
max_bytes: int,
|
|
472
|
+
owner: str | None = None,
|
|
473
|
+
) -> ShellReadResult:
|
|
474
|
+
session = self._get_session(session_id)
|
|
475
|
+
self._check_owner(session, owner)
|
|
476
|
+
try:
|
|
477
|
+
result = session.transport.shell_read(timeout=timeout, max_bytes=max_bytes)
|
|
478
|
+
except TransportError as exc:
|
|
479
|
+
raise ServiceError("remote_error", str(exc), retryable=True) from exc
|
|
480
|
+
session.last_used_at = time.time()
|
|
481
|
+
return result
|
|
482
|
+
|
|
483
|
+
def _get_session(self, session_id: str) -> ServiceSession:
|
|
484
|
+
with self._sessions_lock:
|
|
485
|
+
session = self._sessions.get(session_id)
|
|
486
|
+
if session is None:
|
|
487
|
+
raise ServiceError("not_found", f"unknown session {session_id}")
|
|
488
|
+
return session
|
|
489
|
+
|
|
490
|
+
def _get_task(self, session_id: str, task_id: str, owner: str | None) -> ServiceTask:
|
|
491
|
+
session = self._get_session(session_id)
|
|
492
|
+
self._check_owner(session, owner)
|
|
493
|
+
with self._sessions_lock:
|
|
494
|
+
task = self._tasks.get(task_id)
|
|
495
|
+
if task is None or task.session_id != session_id:
|
|
496
|
+
raise ServiceError("not_found", f"unknown task {task_id}")
|
|
497
|
+
if task.owner is not None and task.owner != owner:
|
|
498
|
+
raise ServiceError("denied", f"task {task_id} is owned by another agent")
|
|
499
|
+
return task
|
|
500
|
+
|
|
501
|
+
@staticmethod
|
|
502
|
+
def _check_owner(session: ServiceSession, owner: str | None) -> None:
|
|
503
|
+
if session.owner is not None and session.owner != owner:
|
|
504
|
+
raise ServiceError("denied", f"session {session.session_id} is owned by another agent")
|
|
505
|
+
|
|
506
|
+
def _emit(self, event: str, **fields: object) -> None:
|
|
507
|
+
if self._audit is not None:
|
|
508
|
+
audit_event(self._audit, event=event, **{key: value for key, value in fields.items() if value is not None}) # type: ignore[arg-type]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Remote transport implementations used by HostBridge daemon services."""
|
|
2
|
+
|
|
3
|
+
from .base import ExecResult, ShellReadResult, TransferResult, Transport, TransportBusy, TransportCapabilities
|
|
4
|
+
from .pty import PtyTransport
|
|
5
|
+
from .ssh import SshConnectionConfig, SshTransport
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ExecResult",
|
|
9
|
+
"PtyTransport",
|
|
10
|
+
"SshConnectionConfig",
|
|
11
|
+
"SshTransport",
|
|
12
|
+
"ShellReadResult",
|
|
13
|
+
"TransferResult",
|
|
14
|
+
"Transport",
|
|
15
|
+
"TransportBusy",
|
|
16
|
+
"TransportCapabilities",
|
|
17
|
+
]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Shared transport contracts and result models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Iterator
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TransportError(RuntimeError):
|
|
11
|
+
"""Base error raised by a remote transport."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TransportBusy(TransportError):
|
|
15
|
+
"""Raised when an ordered transport already has an active operation."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class TransportCapabilities:
|
|
20
|
+
separate_stderr: bool
|
|
21
|
+
native_binary_transfer: bool
|
|
22
|
+
parallel_channels: int
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class ExecResult:
|
|
27
|
+
exit_code: int | None
|
|
28
|
+
stdout: bytes
|
|
29
|
+
stderr: bytes = b""
|
|
30
|
+
timed_out: bool = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class TransferResult:
|
|
35
|
+
bytes_transferred: int
|
|
36
|
+
sha256: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class ShellReadResult:
|
|
41
|
+
data: bytes
|
|
42
|
+
alive: bool
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Transport(Protocol):
|
|
46
|
+
capabilities: TransportCapabilities
|
|
47
|
+
|
|
48
|
+
def exec(
|
|
49
|
+
self,
|
|
50
|
+
command: str,
|
|
51
|
+
*,
|
|
52
|
+
stdin: Iterable[bytes] | None,
|
|
53
|
+
timeout: float,
|
|
54
|
+
output_limit: int,
|
|
55
|
+
) -> ExecResult:
|
|
56
|
+
raise NotImplementedError
|
|
57
|
+
|
|
58
|
+
def upload(
|
|
59
|
+
self,
|
|
60
|
+
chunks: Iterable[bytes],
|
|
61
|
+
remote_path: str,
|
|
62
|
+
*,
|
|
63
|
+
mode: int,
|
|
64
|
+
expected_size: int | None,
|
|
65
|
+
) -> TransferResult:
|
|
66
|
+
raise NotImplementedError
|
|
67
|
+
|
|
68
|
+
def download(self, remote_path: str, *, chunk_size: int) -> Iterator[bytes]:
|
|
69
|
+
raise NotImplementedError
|
|
70
|
+
|
|
71
|
+
def close(self) -> None:
|
|
72
|
+
raise NotImplementedError
|
|
73
|
+
|
|
74
|
+
def shell_write(self, data: bytes) -> None:
|
|
75
|
+
raise NotImplementedError
|
|
76
|
+
|
|
77
|
+
def shell_read(self, *, timeout: float, max_bytes: int) -> ShellReadResult:
|
|
78
|
+
raise NotImplementedError
|