@neoline/hostbridge 2.0.3 → 2.0.4
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 +15 -8
- package/bin/hostbridge.js +80 -9
- 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 +1 -1
- package/pyproject.toml +5 -4
- package/src/server_control_mcp/__init__.py +58 -23
- package/src/server_control_mcp/cli.py +1 -1
- package/src/server_control_mcp/client.py +99 -7
- package/src/server_control_mcp/config.py +16 -5
- package/src/server_control_mcp/daemon.py +239 -49
- package/src/server_control_mcp/doctor.py +8 -4
- package/src/server_control_mcp/hosts.py +118 -24
- package/src/server_control_mcp/mock_ssh.py +53 -954
- package/src/server_control_mcp/mock_ssh_exec.py +210 -0
- package/src/server_control_mcp/mock_ssh_session.py +68 -0
- package/src/server_control_mcp/mock_ssh_sftp.py +506 -0
- package/src/server_control_mcp/mock_ssh_tunnel.py +592 -0
- package/src/server_control_mcp/policy.py +86 -14
- package/src/server_control_mcp/protocol.py +18 -17
- package/src/server_control_mcp/remote_task_agent.py +102 -0
- package/src/server_control_mcp/remote_tunnel_agent.py +143 -0
- package/src/server_control_mcp/runtime.py +3 -2
- 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 +30 -12
- package/src/server_control_mcp/services.py +363 -165
- package/src/server_control_mcp/transports/pty.py +114 -33
- package/src/server_control_mcp/transports/ssh.py +308 -24
|
@@ -3,19 +3,28 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import hashlib
|
|
6
|
+
import logging
|
|
6
7
|
import re
|
|
7
|
-
import shlex
|
|
8
8
|
import threading
|
|
9
9
|
import time
|
|
10
10
|
import uuid
|
|
11
11
|
from collections.abc import Callable, Iterable, Iterator
|
|
12
|
+
from contextlib import contextmanager, suppress
|
|
12
13
|
from dataclasses import dataclass, field
|
|
13
14
|
|
|
14
15
|
from .hosts import HostConfig, HostRegistry
|
|
15
16
|
from .policy import AuditSink, PolicyConfig, audit_event
|
|
17
|
+
from .remote_task_agent import (
|
|
18
|
+
build_remote_task_command,
|
|
19
|
+
build_remote_tasks_cleanup_command,
|
|
20
|
+
remote_job_dir,
|
|
21
|
+
remote_job_dir_assignment,
|
|
22
|
+
)
|
|
16
23
|
from .transports.base import ExecResult, ShellReadResult, TransferResult, Transport, TransportBusy, TransportError
|
|
17
24
|
|
|
18
|
-
TransportFactory = Callable[[HostConfig], Transport]
|
|
25
|
+
TransportFactory = Callable[[HostConfig, float], Transport]
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
MAX_EXEC_OUTPUT_BYTES = 3 * 1024 * 1024 - 64 * 1024
|
|
19
28
|
|
|
20
29
|
|
|
21
30
|
class ServiceError(RuntimeError):
|
|
@@ -42,6 +51,7 @@ class ServiceSession:
|
|
|
42
51
|
created_at: float = field(default_factory=time.time)
|
|
43
52
|
last_used_at: float = field(default_factory=time.time)
|
|
44
53
|
active_operations: int = 0
|
|
54
|
+
closing: bool = False
|
|
45
55
|
|
|
46
56
|
def describe(self) -> dict[str, object]:
|
|
47
57
|
return {
|
|
@@ -85,31 +95,69 @@ class HostBridgeServices:
|
|
|
85
95
|
hosts: HostRegistry,
|
|
86
96
|
*,
|
|
87
97
|
transport_factory: TransportFactory,
|
|
98
|
+
default_connect_timeout: float = 30,
|
|
88
99
|
policy: PolicyConfig | None = None,
|
|
89
100
|
audit_sink: AuditSink | None = None,
|
|
101
|
+
max_tasks_per_session: int = 32,
|
|
90
102
|
) -> None:
|
|
103
|
+
if max_tasks_per_session <= 0:
|
|
104
|
+
raise ValueError("max_tasks_per_session must be positive")
|
|
91
105
|
self.hosts = hosts
|
|
92
106
|
self._transport_factory = transport_factory
|
|
107
|
+
self._default_connect_timeout = default_connect_timeout
|
|
93
108
|
self._policy = policy
|
|
94
109
|
self._audit = audit_sink
|
|
110
|
+
self._max_tasks_per_session = max_tasks_per_session
|
|
95
111
|
self._sessions: dict[str, ServiceSession] = {}
|
|
112
|
+
self._pending_sessions: dict[str, int] = {}
|
|
96
113
|
self._tasks: dict[str, ServiceTask] = {}
|
|
114
|
+
self._launching_tasks: dict[str, ServiceTask] = {}
|
|
97
115
|
self._sessions_lock = threading.RLock()
|
|
116
|
+
self._operation_local = threading.local()
|
|
117
|
+
self._closing = False
|
|
98
118
|
self._reaper_stop = threading.Event()
|
|
99
119
|
self._reaper_thread: threading.Thread | None = None
|
|
100
120
|
|
|
101
|
-
def open_session(
|
|
121
|
+
def open_session(
|
|
122
|
+
self,
|
|
123
|
+
host_id: str,
|
|
124
|
+
*,
|
|
125
|
+
owner: str | None = None,
|
|
126
|
+
connect_timeout: float | None = None,
|
|
127
|
+
) -> ServiceSession:
|
|
102
128
|
try:
|
|
103
129
|
host = self.hosts.get(host_id)
|
|
104
130
|
except KeyError as exc:
|
|
105
131
|
raise ServiceError("not_found", str(exc)) from exc
|
|
132
|
+
with self._sessions_lock:
|
|
133
|
+
if self._closing:
|
|
134
|
+
raise ServiceError("connection_lost", "HostBridge services are shutting down", retryable=True)
|
|
135
|
+
current = sum(session.host_id == host.id for session in self._sessions.values())
|
|
136
|
+
pending = self._pending_sessions.get(host.id, 0)
|
|
137
|
+
if current + pending >= host.limits.max_sessions:
|
|
138
|
+
raise ServiceError("resource_limit", f"host {host_id} reached its session limit")
|
|
139
|
+
self._pending_sessions[host.id] = pending + 1
|
|
140
|
+
effective_timeout = self._default_connect_timeout if connect_timeout is None else connect_timeout
|
|
141
|
+
if effective_timeout <= 0:
|
|
142
|
+
self._release_pending_session(host.id)
|
|
143
|
+
raise ServiceError("invalid_request", "connect_timeout must be positive")
|
|
106
144
|
try:
|
|
107
|
-
transport = self._transport_factory(host)
|
|
108
|
-
except
|
|
145
|
+
transport = self._transport_factory(host, effective_timeout)
|
|
146
|
+
except BaseException as exc:
|
|
147
|
+
self._release_pending_session(host.id)
|
|
148
|
+
if not isinstance(exc, Exception):
|
|
149
|
+
raise
|
|
109
150
|
raise ServiceError("connection_lost", f"failed to connect to host {host_id}", retryable=True) from exc
|
|
110
151
|
session = ServiceSession(uuid.uuid4().hex, host.id, owner, transport)
|
|
111
152
|
with self._sessions_lock:
|
|
112
|
-
self.
|
|
153
|
+
self._release_pending_session_locked(host.id)
|
|
154
|
+
accepted = not self._closing
|
|
155
|
+
if accepted:
|
|
156
|
+
self._sessions[session.session_id] = session
|
|
157
|
+
if not accepted:
|
|
158
|
+
with suppress(Exception):
|
|
159
|
+
transport.close()
|
|
160
|
+
raise ServiceError("connection_lost", "HostBridge services are shutting down", retryable=True)
|
|
113
161
|
self._emit("session_opened", session_id=session.session_id, host_id=host.id, owner_agent_id=owner, outcome="ok")
|
|
114
162
|
return session
|
|
115
163
|
|
|
@@ -133,25 +181,24 @@ class HostBridgeServices:
|
|
|
133
181
|
decision = self._policy.evaluate(command)
|
|
134
182
|
if not decision.allowed:
|
|
135
183
|
raise ServiceError("denied", decision.reason, details={"matched_pattern": decision.matched_pattern})
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
session.last_used_at = time.time()
|
|
184
|
+
with self._operation(session_id, owner) as session:
|
|
185
|
+
limits = self.hosts.get(session.host_id).limits
|
|
186
|
+
timeout = min(timeout, limits.command_timeout_seconds)
|
|
187
|
+
output_limit = min(output_limit, limits.max_output_bytes, MAX_EXEC_OUTPUT_BYTES)
|
|
188
|
+
stdin = self._bounded_chunks(stdin, limits.max_stdin_bytes, "command stdin")
|
|
189
|
+
try:
|
|
190
|
+
result: ExecResult = session.transport.exec(
|
|
191
|
+
command,
|
|
192
|
+
stdin=stdin,
|
|
193
|
+
timeout=timeout,
|
|
194
|
+
output_limit=output_limit,
|
|
195
|
+
)
|
|
196
|
+
except TransportBusy as exc:
|
|
197
|
+
raise ServiceError("busy", str(exc), retryable=True) from exc
|
|
198
|
+
except TransportError as exc:
|
|
199
|
+
raise ServiceError("remote_error", str(exc), retryable=True) from exc
|
|
200
|
+
if result.timed_out:
|
|
201
|
+
self.close_session(session_id, force=True)
|
|
155
202
|
self._emit(
|
|
156
203
|
"command_run",
|
|
157
204
|
session_id=session.session_id,
|
|
@@ -163,38 +210,84 @@ class HostBridgeServices:
|
|
|
163
210
|
return ServiceExecResult(
|
|
164
211
|
session.session_id,
|
|
165
212
|
result.exit_code,
|
|
166
|
-
result.stdout,
|
|
167
|
-
result.stderr,
|
|
213
|
+
*self._limit_combined_output(result.stdout, result.stderr, output_limit),
|
|
168
214
|
result.timed_out,
|
|
169
215
|
)
|
|
170
216
|
|
|
171
217
|
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
218
|
with self._sessions_lock:
|
|
176
|
-
|
|
177
|
-
|
|
219
|
+
session = self._sessions.get(session_id)
|
|
220
|
+
if session is None:
|
|
221
|
+
raise ServiceError("not_found", f"unknown session {session_id}")
|
|
222
|
+
if not force:
|
|
223
|
+
self._check_owner(session, owner)
|
|
224
|
+
if session.active_operations:
|
|
225
|
+
raise ServiceError("busy", f"session {session_id} has active operations", retryable=True)
|
|
226
|
+
if session.closing:
|
|
227
|
+
raise ServiceError("busy", f"session {session_id} is already closing", retryable=True)
|
|
228
|
+
session.closing = True
|
|
229
|
+
tasks = [
|
|
230
|
+
task
|
|
231
|
+
for task in (*self._tasks.values(), *self._launching_tasks.values())
|
|
232
|
+
if task.session_id == session_id
|
|
233
|
+
]
|
|
234
|
+
try:
|
|
235
|
+
self._cleanup_tasks(session, tasks)
|
|
236
|
+
except Exception as exc:
|
|
237
|
+
if not force:
|
|
238
|
+
with self._sessions_lock:
|
|
239
|
+
session.closing = False
|
|
240
|
+
raise ServiceError("connection_lost", f"failed to close session {session_id}", retryable=True) from exc
|
|
241
|
+
logger.exception("failed to clean up tasks while force-closing HostBridge session %s", session_id)
|
|
242
|
+
try:
|
|
243
|
+
session.transport.close()
|
|
244
|
+
except Exception as exc:
|
|
178
245
|
with self._sessions_lock:
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
246
|
+
session.closing = False
|
|
247
|
+
raise ServiceError("connection_lost", f"failed to close session {session_id}", retryable=True) from exc
|
|
248
|
+
with self._sessions_lock:
|
|
249
|
+
self._sessions.pop(session_id, None)
|
|
250
|
+
self._tasks = {task_id: task for task_id, task in self._tasks.items() if task.session_id != session_id}
|
|
251
|
+
self._launching_tasks = {
|
|
252
|
+
task_id: task for task_id, task in self._launching_tasks.items() if task.session_id != session_id
|
|
253
|
+
}
|
|
254
|
+
self._emit(
|
|
255
|
+
"session_closed",
|
|
256
|
+
session_id=session_id,
|
|
257
|
+
host_id=session.host_id,
|
|
258
|
+
owner_agent_id=owner,
|
|
259
|
+
outcome="forced" if force else "user",
|
|
260
|
+
)
|
|
188
261
|
|
|
189
|
-
def close_all(self) -> dict[str, object]:
|
|
262
|
+
def close_all(self, *, owner: str | None = None, force: bool = False) -> dict[str, object]:
|
|
190
263
|
with self._sessions_lock:
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
self.
|
|
264
|
+
if force:
|
|
265
|
+
self._closing = True
|
|
266
|
+
candidates = [session for session in self._sessions.values() if force or session.owner == owner]
|
|
267
|
+
busy = [session.session_id for session in candidates if session.active_operations and not force]
|
|
268
|
+
sessions = [session for session in candidates if force or not session.active_operations]
|
|
269
|
+
closed: list[str] = []
|
|
270
|
+
failed: list[str] = []
|
|
194
271
|
for session in sessions:
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
272
|
+
try:
|
|
273
|
+
self.close_session(session.session_id, owner=owner, force=force)
|
|
274
|
+
except ServiceError:
|
|
275
|
+
failed.append(session.session_id)
|
|
276
|
+
logger.exception("failed to close HostBridge session %s", session.session_id)
|
|
277
|
+
else:
|
|
278
|
+
closed.append(session.session_id)
|
|
279
|
+
return {"closed": closed, "count": len(closed), "busy": busy, "failed": failed}
|
|
280
|
+
|
|
281
|
+
def _release_pending_session(self, host_id: str) -> None:
|
|
282
|
+
with self._sessions_lock:
|
|
283
|
+
self._release_pending_session_locked(host_id)
|
|
284
|
+
|
|
285
|
+
def _release_pending_session_locked(self, host_id: str) -> None:
|
|
286
|
+
pending = self._pending_sessions.get(host_id, 0)
|
|
287
|
+
if pending <= 1:
|
|
288
|
+
self._pending_sessions.pop(host_id, None)
|
|
289
|
+
else:
|
|
290
|
+
self._pending_sessions[host_id] = pending - 1
|
|
198
291
|
|
|
199
292
|
def reap_idle(self, timeout: float) -> int:
|
|
200
293
|
if timeout <= 0:
|
|
@@ -206,17 +299,18 @@ class HostBridgeServices:
|
|
|
206
299
|
for session in self._sessions.values()
|
|
207
300
|
if session.active_operations == 0 and now - session.last_used_at > timeout
|
|
208
301
|
]
|
|
209
|
-
|
|
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
|
-
}
|
|
302
|
+
closed = 0
|
|
216
303
|
for session in expired:
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
304
|
+
try:
|
|
305
|
+
self.close_session(session.session_id, force=True)
|
|
306
|
+
except ServiceError:
|
|
307
|
+
logger.exception("failed to reap idle HostBridge session %s", session.session_id)
|
|
308
|
+
else:
|
|
309
|
+
closed += 1
|
|
310
|
+
self._emit(
|
|
311
|
+
"session_idle_closed", session_id=session.session_id, host_id=session.host_id, outcome="expired"
|
|
312
|
+
)
|
|
313
|
+
return closed
|
|
220
314
|
|
|
221
315
|
def start_idle_reaper(self, *, timeout: float, interval: float = 30) -> None:
|
|
222
316
|
if timeout <= 0 or interval <= 0:
|
|
@@ -242,30 +336,47 @@ class HostBridgeServices:
|
|
|
242
336
|
def start_task(self, session_id: str, command: str, *, owner: str | None = None) -> ServiceTask:
|
|
243
337
|
if not command.strip():
|
|
244
338
|
raise ServiceError("invalid_request", "command must not be empty")
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
339
|
+
with self._operation(session_id, owner) as session:
|
|
340
|
+
task_id = uuid.uuid4().hex
|
|
341
|
+
job_dir = remote_job_dir(task_id)
|
|
342
|
+
script = build_remote_task_command(command, task_id)
|
|
343
|
+
launching_task = ServiceTask(task_id, session_id, session.host_id, command, job_dir, None, owner)
|
|
344
|
+
with self._sessions_lock:
|
|
345
|
+
retained = sum(
|
|
346
|
+
task.session_id == session_id for task in (*self._tasks.values(), *self._launching_tasks.values())
|
|
347
|
+
)
|
|
348
|
+
if retained >= self._max_tasks_per_session:
|
|
349
|
+
raise ServiceError("resource_limit", f"session {session_id} reached its task retention limit")
|
|
350
|
+
self._launching_tasks[task_id] = launching_task
|
|
351
|
+
try:
|
|
352
|
+
result = self.exec(session_id, script, timeout=10, output_limit=4096, owner=owner)
|
|
353
|
+
pid_match = re.fullmatch(rb"\s*([1-9]\d*)\s*", result.stdout)
|
|
354
|
+
if result.timed_out or result.exit_code != 0 or pid_match is None:
|
|
355
|
+
raise ServiceError("remote_error", f"failed to launch task {task_id}", retryable=True)
|
|
356
|
+
task = ServiceTask(
|
|
357
|
+
task_id,
|
|
358
|
+
session_id,
|
|
359
|
+
session.host_id,
|
|
360
|
+
command,
|
|
361
|
+
job_dir,
|
|
362
|
+
int(pid_match.group(1)),
|
|
363
|
+
owner,
|
|
364
|
+
)
|
|
365
|
+
with self._sessions_lock:
|
|
366
|
+
self._launching_tasks.pop(task_id, None)
|
|
367
|
+
self._tasks[task_id] = task
|
|
368
|
+
except BaseException:
|
|
369
|
+
with self._sessions_lock:
|
|
370
|
+
needs_cleanup = task_id in self._launching_tasks and session_id in self._sessions
|
|
371
|
+
if needs_cleanup:
|
|
372
|
+
try:
|
|
373
|
+
self._cleanup_tasks(session, [launching_task])
|
|
374
|
+
except Exception:
|
|
375
|
+
logger.exception("failed to clean up task %s after launch failure", task_id)
|
|
376
|
+
else:
|
|
377
|
+
with self._sessions_lock:
|
|
378
|
+
self._launching_tasks.pop(task_id, None)
|
|
379
|
+
raise
|
|
269
380
|
self._emit(
|
|
270
381
|
"task_started",
|
|
271
382
|
session_id=session_id,
|
|
@@ -285,17 +396,18 @@ class HostBridgeServices:
|
|
|
285
396
|
owner: str | None = None,
|
|
286
397
|
tail_lines: int = 80,
|
|
287
398
|
) -> dict[str, object]:
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
399
|
+
with self._operation(session_id, owner):
|
|
400
|
+
task = self._get_task(session_id, task_id, owner)
|
|
401
|
+
safe_tail = max(1, min(int(tail_lines), 500))
|
|
402
|
+
script = (
|
|
403
|
+
f'{remote_job_dir_assignment(task.task_id)}; pid=$(cat "$job_dir/pid" 2>/dev/null || true); '
|
|
404
|
+
'if [ -f "$job_dir/exit_code" ]; then state=finished; exit_code=$(cat "$job_dir/exit_code"); '
|
|
405
|
+
'elif [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then state=running; exit_code=\'\'; '
|
|
406
|
+
"else state=unknown; exit_code=''; fi; "
|
|
407
|
+
'printf \'STATE=%s\nPID=%s\nEXIT=%s\n---LOG---\n\' "$state" "$pid" "$exit_code"; '
|
|
408
|
+
f'tail -n {safe_tail} "$job_dir/stdout.log" 2>/dev/null || true'
|
|
409
|
+
)
|
|
410
|
+
result = self.exec(session_id, script, timeout=15, output_limit=1_000_000, owner=owner)
|
|
299
411
|
metadata, _, log = result.stdout.decode("utf-8", errors="replace").partition("---LOG---\n")
|
|
300
412
|
fields: dict[str, str] = {}
|
|
301
413
|
for line in metadata.splitlines():
|
|
@@ -316,16 +428,27 @@ class HostBridgeServices:
|
|
|
316
428
|
}
|
|
317
429
|
|
|
318
430
|
def cancel_task(self, session_id: str, task_id: str, *, owner: str | None = None) -> dict[str, object]:
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
431
|
+
with self._operation(session_id, owner):
|
|
432
|
+
task = self._get_task(session_id, task_id, owner)
|
|
433
|
+
if task.remote_pid is None:
|
|
434
|
+
raise ServiceError("remote_error", f"task {task_id} has no recorded remote pid")
|
|
435
|
+
script = (
|
|
436
|
+
f"{remote_job_dir_assignment(task.task_id)}; "
|
|
437
|
+
'if [ -f "$job_dir/exit_code" ]; then printf already; '
|
|
438
|
+
f"elif kill -TERM -- -{task.remote_pid} 2>/dev/null; then "
|
|
439
|
+
'attempt=0; while kill -0 -- -$(cat "$job_dir/pid") 2>/dev/null && [ "$attempt" -lt 20 ]; do '
|
|
440
|
+
"attempt=$((attempt + 1)); sleep 0.05; done; "
|
|
441
|
+
'if kill -0 -- -$(cat "$job_dir/pid") 2>/dev/null; then '
|
|
442
|
+
'kill -KILL -- -$(cat "$job_dir/pid") 2>/dev/null || true; fi; '
|
|
443
|
+
'printf \'143\n\' > "$job_dir/.exit_code.tmp"; mv "$job_dir/.exit_code.tmp" "$job_dir/exit_code"; '
|
|
444
|
+
"printf killed; else printf noop; fi"
|
|
445
|
+
)
|
|
446
|
+
result = self.exec(session_id, script, timeout=10, output_limit=4096, owner=owner)
|
|
447
|
+
outcome = (
|
|
448
|
+
result.stdout.decode("utf-8", errors="replace").strip().splitlines()[-1]
|
|
449
|
+
if result.stdout.strip()
|
|
450
|
+
else "unknown"
|
|
326
451
|
)
|
|
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
452
|
self._emit("task_cancelled", session_id=session_id, task_id=task_id, outcome=outcome)
|
|
330
453
|
return {"task_id": task_id, "stopped": outcome in {"killed", "already"}, "outcome": outcome}
|
|
331
454
|
|
|
@@ -340,23 +463,21 @@ class HostBridgeServices:
|
|
|
340
463
|
expected_sha256: str | None,
|
|
341
464
|
owner: str | None = None,
|
|
342
465
|
) -> TransferResult:
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
session.active_operations -= 1
|
|
359
|
-
session.last_used_at = time.time()
|
|
466
|
+
with self._operation(session_id, owner) as session:
|
|
467
|
+
limit = self.hosts.get(session.host_id).limits.max_transfer_bytes
|
|
468
|
+
if expected_size is not None and expected_size > limit:
|
|
469
|
+
raise ServiceError("resource_limit", f"upload exceeds {limit} bytes")
|
|
470
|
+
bounded_chunks = self._bounded_chunks(chunks, limit, "upload")
|
|
471
|
+
assert bounded_chunks is not None
|
|
472
|
+
try:
|
|
473
|
+
result = session.transport.upload(
|
|
474
|
+
bounded_chunks,
|
|
475
|
+
remote_path,
|
|
476
|
+
mode=mode,
|
|
477
|
+
expected_size=expected_size,
|
|
478
|
+
)
|
|
479
|
+
except TransportError as exc:
|
|
480
|
+
raise ServiceError("remote_error", str(exc), retryable=True) from exc
|
|
360
481
|
if expected_size is not None and result.bytes_transferred != expected_size:
|
|
361
482
|
raise ServiceError(
|
|
362
483
|
"integrity_error",
|
|
@@ -382,33 +503,29 @@ class HostBridgeServices:
|
|
|
382
503
|
chunk_size: int,
|
|
383
504
|
owner: str | None = None,
|
|
384
505
|
) -> Iterator[bytes]:
|
|
385
|
-
session = self._get_session(session_id)
|
|
386
|
-
self._check_owner(session, owner)
|
|
387
|
-
|
|
388
506
|
def stream() -> Iterator[bytes]:
|
|
389
|
-
with self.
|
|
390
|
-
session.
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
)
|
|
507
|
+
with self._operation(session_id, owner) as session:
|
|
508
|
+
limit = self.hosts.get(session.host_id).limits.max_transfer_bytes
|
|
509
|
+
transferred = 0
|
|
510
|
+
try:
|
|
511
|
+
for chunk in session.transport.download(remote_path, chunk_size=chunk_size):
|
|
512
|
+
if not isinstance(chunk, bytes):
|
|
513
|
+
raise ServiceError("internal", "transport returned a non-bytes download chunk")
|
|
514
|
+
transferred += len(chunk)
|
|
515
|
+
if transferred > limit:
|
|
516
|
+
raise ServiceError("resource_limit", f"download exceeds {limit} bytes")
|
|
517
|
+
yield chunk
|
|
518
|
+
except TransportError as exc:
|
|
519
|
+
raise ServiceError("remote_error", str(exc), retryable=True) from exc
|
|
520
|
+
finally:
|
|
521
|
+
self._emit(
|
|
522
|
+
"file_downloaded",
|
|
523
|
+
session_id=session_id,
|
|
524
|
+
host_id=session.host_id,
|
|
525
|
+
owner_agent_id=owner,
|
|
526
|
+
command=remote_path,
|
|
527
|
+
outcome=f"bytes={transferred}",
|
|
528
|
+
)
|
|
412
529
|
|
|
413
530
|
return stream()
|
|
414
531
|
|
|
@@ -423,8 +540,11 @@ class HostBridgeServices:
|
|
|
423
540
|
owner: str | None = None,
|
|
424
541
|
) -> TransferResult:
|
|
425
542
|
data = content.encode("utf-8")
|
|
426
|
-
|
|
427
|
-
|
|
543
|
+
session = self._get_session(session_id)
|
|
544
|
+
self._check_owner(session, owner)
|
|
545
|
+
limit = min(max_bytes, self.hosts.get(session.host_id).limits.max_text_bytes)
|
|
546
|
+
if len(data) > limit:
|
|
547
|
+
raise ServiceError("resource_limit", f"text content exceeds {limit} bytes")
|
|
428
548
|
return self.upload(
|
|
429
549
|
session_id,
|
|
430
550
|
iter([data]),
|
|
@@ -446,21 +566,24 @@ class HostBridgeServices:
|
|
|
446
566
|
data = bytearray()
|
|
447
567
|
for chunk in self.download(session_id, remote_path, chunk_size=256 * 1024, owner=owner):
|
|
448
568
|
data.extend(chunk)
|
|
449
|
-
|
|
450
|
-
|
|
569
|
+
session = self._get_session(session_id)
|
|
570
|
+
limit = min(max_bytes, self.hosts.get(session.host_id).limits.max_text_bytes)
|
|
571
|
+
if len(data) > limit:
|
|
572
|
+
raise ServiceError("resource_limit", f"text file exceeds {limit} bytes")
|
|
451
573
|
try:
|
|
452
574
|
return bytes(data).decode("utf-8")
|
|
453
575
|
except UnicodeDecodeError as exc:
|
|
454
576
|
raise ServiceError("invalid_request", f"remote file is not valid UTF-8 at byte {exc.start}") from exc
|
|
455
577
|
|
|
456
578
|
def shell_write(self, session_id: str, data: bytes, *, owner: str | None = None) -> int:
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
579
|
+
with self._operation(session_id, owner) as session:
|
|
580
|
+
limit = self.hosts.get(session.host_id).limits.max_shell_bytes
|
|
581
|
+
if len(data) > limit:
|
|
582
|
+
raise ServiceError("resource_limit", f"shell write exceeds {limit} bytes")
|
|
583
|
+
try:
|
|
584
|
+
session.transport.shell_write(data)
|
|
585
|
+
except TransportError as exc:
|
|
586
|
+
raise ServiceError("remote_error", str(exc), retryable=True) from exc
|
|
464
587
|
return len(data)
|
|
465
588
|
|
|
466
589
|
def shell_read(
|
|
@@ -471,14 +594,12 @@ class HostBridgeServices:
|
|
|
471
594
|
max_bytes: int,
|
|
472
595
|
owner: str | None = None,
|
|
473
596
|
) -> ShellReadResult:
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
session.last_used_at = time.time()
|
|
481
|
-
return result
|
|
597
|
+
with self._operation(session_id, owner) as session:
|
|
598
|
+
max_bytes = min(max_bytes, self.hosts.get(session.host_id).limits.max_shell_bytes)
|
|
599
|
+
try:
|
|
600
|
+
return session.transport.shell_read(timeout=timeout, max_bytes=max_bytes)
|
|
601
|
+
except TransportError as exc:
|
|
602
|
+
raise ServiceError("remote_error", str(exc), retryable=True) from exc
|
|
482
603
|
|
|
483
604
|
def _get_session(self, session_id: str) -> ServiceSession:
|
|
484
605
|
with self._sessions_lock:
|
|
@@ -487,6 +608,76 @@ class HostBridgeServices:
|
|
|
487
608
|
raise ServiceError("not_found", f"unknown session {session_id}")
|
|
488
609
|
return session
|
|
489
610
|
|
|
611
|
+
@staticmethod
|
|
612
|
+
def _cleanup_tasks(session: ServiceSession, tasks: list[ServiceTask]) -> None:
|
|
613
|
+
if not tasks:
|
|
614
|
+
return
|
|
615
|
+
command = build_remote_tasks_cleanup_command((task.task_id, task.remote_pid) for task in tasks)
|
|
616
|
+
result = session.transport.exec(command, stdin=None, timeout=15, output_limit=4096)
|
|
617
|
+
if result.timed_out or result.exit_code != 0:
|
|
618
|
+
raise TransportError(f"failed to clean up tasks for session {session.session_id}")
|
|
619
|
+
|
|
620
|
+
@contextmanager
|
|
621
|
+
def _operation(self, session_id: str, owner: str | None) -> Iterator[ServiceSession]:
|
|
622
|
+
leased_sessions = getattr(self._operation_local, "session_ids", None)
|
|
623
|
+
if leased_sessions is None:
|
|
624
|
+
leased_sessions = set()
|
|
625
|
+
self._operation_local.session_ids = leased_sessions
|
|
626
|
+
if session_id in leased_sessions:
|
|
627
|
+
leased_session = self._get_session(session_id)
|
|
628
|
+
self._check_owner(leased_session, owner)
|
|
629
|
+
yield leased_session
|
|
630
|
+
return
|
|
631
|
+
with self._sessions_lock:
|
|
632
|
+
session = self._sessions.get(session_id)
|
|
633
|
+
if session is None:
|
|
634
|
+
raise ServiceError("not_found", f"unknown session {session_id}")
|
|
635
|
+
self._check_owner(session, owner)
|
|
636
|
+
if session.closing:
|
|
637
|
+
raise ServiceError("busy", f"session {session_id} is closing", retryable=True)
|
|
638
|
+
capacity = max(1, session.transport.capabilities.parallel_channels)
|
|
639
|
+
if session.active_operations >= capacity:
|
|
640
|
+
raise ServiceError("busy", f"session {session_id} reached its channel capacity", retryable=True)
|
|
641
|
+
session.active_operations += 1
|
|
642
|
+
leased_sessions.add(session_id)
|
|
643
|
+
try:
|
|
644
|
+
yield session
|
|
645
|
+
finally:
|
|
646
|
+
with self._sessions_lock:
|
|
647
|
+
session.active_operations -= 1
|
|
648
|
+
session.last_used_at = time.time()
|
|
649
|
+
leased_sessions.remove(session_id)
|
|
650
|
+
|
|
651
|
+
@staticmethod
|
|
652
|
+
def _limit_combined_output(stdout: bytes, stderr: bytes, limit: int) -> tuple[bytes, bytes]:
|
|
653
|
+
if len(stdout) + len(stderr) <= limit:
|
|
654
|
+
return stdout, stderr
|
|
655
|
+
stdout_keep = min(len(stdout), (limit + 1) // 2)
|
|
656
|
+
stderr_keep = min(len(stderr), limit - stdout_keep)
|
|
657
|
+
remaining = limit - stdout_keep - stderr_keep
|
|
658
|
+
if remaining:
|
|
659
|
+
extra_stdout = min(len(stdout) - stdout_keep, remaining)
|
|
660
|
+
stdout_keep += extra_stdout
|
|
661
|
+
stderr_keep += min(len(stderr) - stderr_keep, remaining - extra_stdout)
|
|
662
|
+
return stdout[-stdout_keep:] if stdout_keep else b"", stderr[-stderr_keep:] if stderr_keep else b""
|
|
663
|
+
|
|
664
|
+
@staticmethod
|
|
665
|
+
def _bounded_chunks(chunks: Iterable[bytes] | None, limit: int, label: str) -> Iterator[bytes] | None:
|
|
666
|
+
if chunks is None:
|
|
667
|
+
return None
|
|
668
|
+
|
|
669
|
+
def bounded() -> Iterator[bytes]:
|
|
670
|
+
transferred = 0
|
|
671
|
+
for chunk in chunks:
|
|
672
|
+
if not isinstance(chunk, bytes):
|
|
673
|
+
raise TypeError(f"{label} chunks must be bytes")
|
|
674
|
+
transferred += len(chunk)
|
|
675
|
+
if transferred > limit:
|
|
676
|
+
raise ServiceError("resource_limit", f"{label} exceeds {limit} bytes")
|
|
677
|
+
yield chunk
|
|
678
|
+
|
|
679
|
+
return bounded()
|
|
680
|
+
|
|
490
681
|
def _get_task(self, session_id: str, task_id: str, owner: str | None) -> ServiceTask:
|
|
491
682
|
session = self._get_session(session_id)
|
|
492
683
|
self._check_owner(session, owner)
|
|
@@ -505,4 +696,11 @@ class HostBridgeServices:
|
|
|
505
696
|
|
|
506
697
|
def _emit(self, event: str, **fields: object) -> None:
|
|
507
698
|
if self._audit is not None:
|
|
508
|
-
|
|
699
|
+
try:
|
|
700
|
+
audit_event(
|
|
701
|
+
self._audit,
|
|
702
|
+
event=event,
|
|
703
|
+
**{key: value for key, value in fields.items() if value is not None}, # type: ignore[arg-type]
|
|
704
|
+
)
|
|
705
|
+
except Exception:
|
|
706
|
+
logger.exception("audit event could not be written: %s", event)
|