@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
|
@@ -1,21 +1,10 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Shared service errors and result models."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import
|
|
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
|
|
5
|
+
from dataclasses import dataclass
|
|
13
6
|
|
|
14
|
-
|
|
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]
|
|
7
|
+
MAX_EXEC_OUTPUT_BYTES = 3 * 1024 * 1024 - 64 * 1024
|
|
19
8
|
|
|
20
9
|
|
|
21
10
|
class ServiceError(RuntimeError):
|
|
@@ -33,32 +22,6 @@ class ServiceError(RuntimeError):
|
|
|
33
22
|
self.details = details or {}
|
|
34
23
|
|
|
35
24
|
|
|
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
25
|
@dataclass(frozen=True, slots=True)
|
|
63
26
|
class ServiceExecResult:
|
|
64
27
|
session_id: str
|
|
@@ -77,432 +40,3 @@ class ServiceTask:
|
|
|
77
40
|
job_dir: str
|
|
78
41
|
remote_pid: int | None
|
|
79
42
|
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]
|
|
@@ -1,17 +1,13 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Async transport primitives used by HostBridge host runtimes."""
|
|
2
2
|
|
|
3
|
-
from .base import ExecResult, ShellReadResult, TransferResult,
|
|
4
|
-
from .
|
|
5
|
-
from .ssh import SshConnectionConfig, SshTransport
|
|
3
|
+
from .base import ExecResult, ShellReadResult, TransferResult, TransportCapabilities
|
|
4
|
+
from .ssh import AsyncSshConnection, SshConnectionConfig
|
|
6
5
|
|
|
7
6
|
__all__ = [
|
|
8
7
|
"ExecResult",
|
|
9
|
-
"
|
|
8
|
+
"AsyncSshConnection",
|
|
10
9
|
"SshConnectionConfig",
|
|
11
|
-
"SshTransport",
|
|
12
10
|
"ShellReadResult",
|
|
13
11
|
"TransferResult",
|
|
14
|
-
"Transport",
|
|
15
|
-
"TransportBusy",
|
|
16
12
|
"TransportCapabilities",
|
|
17
13
|
]
|
|
@@ -2,17 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import AsyncGenerator, AsyncIterable, AsyncIterator, Awaitable, Iterable, Iterator, Mapping
|
|
6
7
|
from dataclasses import dataclass
|
|
7
|
-
from typing import Protocol
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
class TransportError(RuntimeError):
|
|
11
11
|
"""Base error raised by a remote transport."""
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
ByteStream = AsyncIterable[bytes] | Iterable[bytes]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def iterate_byte_stream(chunks: ByteStream, *, label: str) -> AsyncGenerator[bytes, None]:
|
|
18
|
+
if isinstance(chunks, AsyncIterable):
|
|
19
|
+
async for chunk in chunks:
|
|
20
|
+
if not isinstance(chunk, bytes):
|
|
21
|
+
raise TypeError(f"{label} chunks must be bytes")
|
|
22
|
+
yield chunk
|
|
23
|
+
return
|
|
24
|
+
iterator = iter(chunks)
|
|
25
|
+
while True:
|
|
26
|
+
present, chunk = await asyncio.to_thread(_next_chunk, iterator)
|
|
27
|
+
if not present:
|
|
28
|
+
return
|
|
29
|
+
if not isinstance(chunk, bytes):
|
|
30
|
+
raise TypeError(f"{label} chunks must be bytes")
|
|
31
|
+
yield chunk
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _next_chunk(iterator: Iterator[bytes]) -> tuple[bool, bytes]:
|
|
35
|
+
try:
|
|
36
|
+
return True, next(iterator)
|
|
37
|
+
except StopIteration:
|
|
38
|
+
return False, b""
|
|
16
39
|
|
|
17
40
|
|
|
18
41
|
@dataclass(frozen=True, slots=True)
|
|
@@ -42,37 +65,36 @@ class ShellReadResult:
|
|
|
42
65
|
alive: bool
|
|
43
66
|
|
|
44
67
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
raise NotImplementedError
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class ShellTerminalResult:
|
|
70
|
+
exit_code: int | None
|
|
71
|
+
signal: str | None = None
|
|
72
|
+
|
|
73
|
+
def __post_init__(self) -> None:
|
|
74
|
+
if self.exit_code is not None and (
|
|
75
|
+
not isinstance(self.exit_code, int) or isinstance(self.exit_code, bool) or not 0 <= self.exit_code <= 255
|
|
76
|
+
):
|
|
77
|
+
raise ValueError("shell exit_code must be between 0 and 255 or null")
|
|
78
|
+
if self.signal is not None and (not isinstance(self.signal, str) or not self.signal.strip()):
|
|
79
|
+
raise ValueError("shell signal must be a non-empty string or null")
|
|
80
|
+
if self.exit_code is not None and self.signal is not None:
|
|
81
|
+
raise ValueError("shell terminal result cannot contain both exit_code and signal")
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_mapping(cls, value: Mapping[str, object]) -> ShellTerminalResult:
|
|
85
|
+
return cls(value.get("exit_code"), value.get("signal")) # type: ignore[arg-type]
|
|
86
|
+
|
|
87
|
+
def to_mapping(self) -> dict[str, object]:
|
|
88
|
+
return {"exit_code": self.exit_code, "signal": self.signal}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ShellAttachment:
|
|
92
|
+
def __init__(self, output: AsyncIterator[bytes], terminal: Awaitable[ShellTerminalResult]) -> None:
|
|
93
|
+
self._output = output
|
|
94
|
+
self._terminal = terminal
|
|
95
|
+
|
|
96
|
+
def __aiter__(self) -> AsyncIterator[bytes]:
|
|
97
|
+
return self._output
|
|
98
|
+
|
|
99
|
+
async def finish(self) -> ShellTerminalResult:
|
|
100
|
+
return await self._terminal
|