@neoline/hostbridge 2.0.4 → 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 +11 -9
- package/docs/ARCHITECTURE.md +62 -0
- package/package.json +2 -1
- package/pyproject.toml +1 -1
- package/src/server_control_mcp/__init__.py +4 -3
- package/src/server_control_mcp/async_lifecycle.py +41 -0
- package/src/server_control_mcp/cli.py +1 -1
- package/src/server_control_mcp/client.py +302 -287
- package/src/server_control_mcp/config.py +2 -1
- package/src/server_control_mcp/daemon.py +233 -526
- package/src/server_control_mcp/doctor.py +34 -2
- package/src/server_control_mcp/hosts.py +136 -2
- package/src/server_control_mcp/mock_ssh.py +52 -14
- package/src/server_control_mcp/mock_ssh_exec.py +147 -58
- package/src/server_control_mcp/mock_ssh_session.py +3 -2
- package/src/server_control_mcp/mock_ssh_sftp.py +126 -28
- package/src/server_control_mcp/mock_ssh_tunnel.py +141 -444
- 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/remote_agent_bundle.py +56 -0
- package/src/server_control_mcp/remote_mux_agent.py +769 -0
- package/src/server_control_mcp/runtime_services.py +862 -0
- package/src/server_control_mcp/server.py +33 -18
- package/src/server_control_mcp/services.py +2 -666
- 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 +206 -259
- 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 -363
- package/src/server_control_mcp/remote_tunnel_agent.py +0 -143
- package/src/server_control_mcp/transports/pty.py +0 -515
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
"""Async session services backed by daemon-owned host runtime leases."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import hashlib
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from collections.abc import AsyncGenerator
|
|
12
|
+
from contextlib import asynccontextmanager, suppress
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Protocol
|
|
15
|
+
|
|
16
|
+
from .hosts import HostRegistry
|
|
17
|
+
from .policy import AuditSink, PolicyConfig, audit_event
|
|
18
|
+
from .remote_task_agent import (
|
|
19
|
+
build_remote_task_command,
|
|
20
|
+
build_remote_tasks_cleanup_command,
|
|
21
|
+
remote_job_dir,
|
|
22
|
+
remote_job_dir_assignment,
|
|
23
|
+
)
|
|
24
|
+
from .services import MAX_EXEC_OUTPUT_BYTES, ServiceError, ServiceExecResult, ServiceTask
|
|
25
|
+
from .transports.base import (
|
|
26
|
+
ByteStream,
|
|
27
|
+
ExecResult,
|
|
28
|
+
ShellAttachment,
|
|
29
|
+
ShellReadResult,
|
|
30
|
+
ShellTerminalResult,
|
|
31
|
+
TransferResult,
|
|
32
|
+
iterate_byte_stream,
|
|
33
|
+
)
|
|
34
|
+
from .tunnel_manager import HostRuntimeLease, TunnelError
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RuntimeLeaseManager(Protocol):
|
|
40
|
+
async def acquire(self, host_id: str) -> HostRuntimeLease: ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(slots=True)
|
|
44
|
+
class RuntimeServiceSession:
|
|
45
|
+
session_id: str
|
|
46
|
+
host_id: str
|
|
47
|
+
owner: str | None
|
|
48
|
+
lease: HostRuntimeLease = field(repr=False)
|
|
49
|
+
created_at: float = field(default_factory=time.time)
|
|
50
|
+
last_used_at: float = field(default_factory=time.time)
|
|
51
|
+
active_operations: int = 0
|
|
52
|
+
closing: bool = False
|
|
53
|
+
operation_tasks: set[asyncio.Task[object]] = field(default_factory=set, repr=False)
|
|
54
|
+
|
|
55
|
+
def describe(self) -> dict[str, object]:
|
|
56
|
+
capabilities = self.lease.capabilities
|
|
57
|
+
return {
|
|
58
|
+
"session_id": self.session_id,
|
|
59
|
+
"host_id": self.host_id,
|
|
60
|
+
"owner": self.owner,
|
|
61
|
+
"created_at": self.created_at,
|
|
62
|
+
"last_used_at": self.last_used_at,
|
|
63
|
+
"active_operations": self.active_operations,
|
|
64
|
+
"capabilities": {
|
|
65
|
+
"separate_stderr": capabilities.separate_stderr,
|
|
66
|
+
"native_binary_transfer": capabilities.native_binary_transfer,
|
|
67
|
+
"parallel_channels": capabilities.parallel_channels,
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class AsyncHostBridgeServices:
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
hosts: HostRegistry,
|
|
76
|
+
runtime_manager: RuntimeLeaseManager,
|
|
77
|
+
*,
|
|
78
|
+
default_connect_timeout: float = 30,
|
|
79
|
+
policy: PolicyConfig | None = None,
|
|
80
|
+
max_tasks_per_session: int = 32,
|
|
81
|
+
cleanup_timeout_seconds: float = 5,
|
|
82
|
+
idle_timeout_seconds: float | None = None,
|
|
83
|
+
reaper_interval_seconds: float = 60,
|
|
84
|
+
audit_sink: AuditSink | None = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
if default_connect_timeout <= 0:
|
|
87
|
+
raise ValueError("default_connect_timeout must be positive")
|
|
88
|
+
if max_tasks_per_session <= 0:
|
|
89
|
+
raise ValueError("max_tasks_per_session must be positive")
|
|
90
|
+
if cleanup_timeout_seconds <= 0:
|
|
91
|
+
raise ValueError("cleanup_timeout_seconds must be positive")
|
|
92
|
+
if idle_timeout_seconds is not None and idle_timeout_seconds < 0:
|
|
93
|
+
raise ValueError("idle_timeout_seconds must be non-negative")
|
|
94
|
+
if reaper_interval_seconds <= 0:
|
|
95
|
+
raise ValueError("reaper_interval_seconds must be positive")
|
|
96
|
+
self.hosts = hosts
|
|
97
|
+
self._runtime_manager = runtime_manager
|
|
98
|
+
self._default_connect_timeout = float(default_connect_timeout)
|
|
99
|
+
self._policy = policy
|
|
100
|
+
self._max_tasks_per_session = max_tasks_per_session
|
|
101
|
+
self._cleanup_timeout_seconds = float(cleanup_timeout_seconds)
|
|
102
|
+
self._idle_timeout_seconds = float(
|
|
103
|
+
policy.idle_timeout_seconds
|
|
104
|
+
if idle_timeout_seconds is None and policy is not None
|
|
105
|
+
else idle_timeout_seconds or 0
|
|
106
|
+
)
|
|
107
|
+
self._reaper_interval_seconds = float(reaper_interval_seconds)
|
|
108
|
+
self._audit = audit_sink
|
|
109
|
+
self._sessions: dict[str, RuntimeServiceSession] = {}
|
|
110
|
+
self._pending_sessions: dict[str, int] = {}
|
|
111
|
+
self._tasks: dict[str, ServiceTask] = {}
|
|
112
|
+
self._launching_tasks: dict[str, ServiceTask] = {}
|
|
113
|
+
self._lock = asyncio.Lock()
|
|
114
|
+
self._closing = False
|
|
115
|
+
self._reaper_task: asyncio.Task[None] | None = None
|
|
116
|
+
|
|
117
|
+
async def open_session(
|
|
118
|
+
self,
|
|
119
|
+
host_id: str,
|
|
120
|
+
*,
|
|
121
|
+
owner: str | None = None,
|
|
122
|
+
connect_timeout: float | None = None,
|
|
123
|
+
) -> RuntimeServiceSession:
|
|
124
|
+
try:
|
|
125
|
+
host = self.hosts.get(host_id)
|
|
126
|
+
except KeyError as exc:
|
|
127
|
+
raise ServiceError("not_found", str(exc)) from exc
|
|
128
|
+
timeout = self._default_connect_timeout if connect_timeout is None else connect_timeout
|
|
129
|
+
if timeout <= 0:
|
|
130
|
+
raise ServiceError("invalid_request", "connect_timeout must be positive")
|
|
131
|
+
async with self._lock:
|
|
132
|
+
if self._closing:
|
|
133
|
+
raise ServiceError("connection_lost", "HostBridge services are shutting down", retryable=True)
|
|
134
|
+
current = sum(session.host_id == host.id for session in self._sessions.values())
|
|
135
|
+
pending = self._pending_sessions.get(host.id, 0)
|
|
136
|
+
if current + pending >= host.limits.max_sessions:
|
|
137
|
+
raise ServiceError("resource_limit", f"host {host_id} reached its session limit")
|
|
138
|
+
self._pending_sessions[host.id] = pending + 1
|
|
139
|
+
lease: HostRuntimeLease | None = None
|
|
140
|
+
registered = False
|
|
141
|
+
try:
|
|
142
|
+
async with asyncio.timeout(timeout):
|
|
143
|
+
lease = await self._runtime_manager.acquire(host.id)
|
|
144
|
+
except TimeoutError as exc:
|
|
145
|
+
raise ServiceError("timeout", f"failed to connect to host {host_id}", retryable=True) from exc
|
|
146
|
+
except TunnelError as exc:
|
|
147
|
+
raise ServiceError(exc.code, str(exc), retryable=exc.retryable) from exc
|
|
148
|
+
except BaseException as exc:
|
|
149
|
+
if isinstance(exc, asyncio.CancelledError):
|
|
150
|
+
raise
|
|
151
|
+
raise ServiceError("connection_lost", f"failed to connect to host {host_id}", retryable=True) from exc
|
|
152
|
+
else:
|
|
153
|
+
session = RuntimeServiceSession(uuid.uuid4().hex, host.id, owner, lease)
|
|
154
|
+
try:
|
|
155
|
+
async with self._lock:
|
|
156
|
+
if self._closing:
|
|
157
|
+
raise ServiceError(
|
|
158
|
+
"connection_lost",
|
|
159
|
+
"HostBridge services are shutting down",
|
|
160
|
+
retryable=True,
|
|
161
|
+
)
|
|
162
|
+
self._sessions[session.session_id] = session
|
|
163
|
+
self._ensure_idle_reaper_locked()
|
|
164
|
+
registered = True
|
|
165
|
+
self._emit(
|
|
166
|
+
"session_opened",
|
|
167
|
+
session_id=session.session_id,
|
|
168
|
+
host_id=host.id,
|
|
169
|
+
owner_agent_id=owner,
|
|
170
|
+
outcome="ok",
|
|
171
|
+
)
|
|
172
|
+
return session
|
|
173
|
+
except BaseException:
|
|
174
|
+
if not registered:
|
|
175
|
+
await self._close_lease_quietly(lease)
|
|
176
|
+
raise
|
|
177
|
+
finally:
|
|
178
|
+
async with self._lock:
|
|
179
|
+
self._release_pending_locked(host.id)
|
|
180
|
+
|
|
181
|
+
async def list_sessions(self) -> list[dict[str, object]]:
|
|
182
|
+
async with self._lock:
|
|
183
|
+
return [session.describe() for session in self._sessions.values()]
|
|
184
|
+
|
|
185
|
+
async def close_session(
|
|
186
|
+
self,
|
|
187
|
+
session_id: str,
|
|
188
|
+
*,
|
|
189
|
+
owner: str | None = None,
|
|
190
|
+
force: bool = False,
|
|
191
|
+
) -> None:
|
|
192
|
+
async with self._lock:
|
|
193
|
+
session = self._sessions.get(session_id)
|
|
194
|
+
if session is None:
|
|
195
|
+
raise ServiceError("not_found", f"unknown session {session_id}")
|
|
196
|
+
if not force:
|
|
197
|
+
self._check_owner(session, owner)
|
|
198
|
+
if session.active_operations:
|
|
199
|
+
raise ServiceError("busy", f"session {session_id} has active operations", retryable=True)
|
|
200
|
+
if session.closing:
|
|
201
|
+
raise ServiceError("busy", f"session {session_id} is already closing", retryable=True)
|
|
202
|
+
session.closing = True
|
|
203
|
+
operations = tuple(task for task in session.operation_tasks if task is not asyncio.current_task())
|
|
204
|
+
tasks = tuple(
|
|
205
|
+
task
|
|
206
|
+
for task in (*self._tasks.values(), *self._launching_tasks.values())
|
|
207
|
+
if task.session_id == session_id
|
|
208
|
+
)
|
|
209
|
+
finalizer = asyncio.create_task(
|
|
210
|
+
self._finalize_session(session, force=force, operations=operations, tasks=tasks),
|
|
211
|
+
name=f"hostbridge-session-finalize-{session_id}",
|
|
212
|
+
)
|
|
213
|
+
cancellation_requested = False
|
|
214
|
+
while True:
|
|
215
|
+
try:
|
|
216
|
+
errors = await asyncio.shield(finalizer)
|
|
217
|
+
break
|
|
218
|
+
except asyncio.CancelledError:
|
|
219
|
+
cancellation_requested = True
|
|
220
|
+
if finalizer.done():
|
|
221
|
+
errors = finalizer.result()
|
|
222
|
+
break
|
|
223
|
+
if cancellation_requested:
|
|
224
|
+
cancelled = asyncio.CancelledError(f"session {session_id} close was cancelled after cleanup started")
|
|
225
|
+
for error in errors:
|
|
226
|
+
cancelled.add_note(f"session cleanup also failed: {error!r}")
|
|
227
|
+
raise cancelled
|
|
228
|
+
if errors:
|
|
229
|
+
raise ServiceError(
|
|
230
|
+
"connection_lost",
|
|
231
|
+
f"failed to fully clean up session {session_id}: {errors[0]}",
|
|
232
|
+
retryable=True,
|
|
233
|
+
) from errors[0]
|
|
234
|
+
|
|
235
|
+
async def close_mock_ssh_session(self, session_id: str) -> None:
|
|
236
|
+
async with self._lock:
|
|
237
|
+
session = self._sessions.get(session_id)
|
|
238
|
+
if session is None:
|
|
239
|
+
raise ServiceError("not_found", f"unknown session {session_id}")
|
|
240
|
+
if session.owner != "mock-ssh":
|
|
241
|
+
raise ServiceError("denied", f"session {session_id} is not owned by mock-ssh")
|
|
242
|
+
await self.close_session(session_id, force=True)
|
|
243
|
+
|
|
244
|
+
async def _finalize_session(
|
|
245
|
+
self,
|
|
246
|
+
session: RuntimeServiceSession,
|
|
247
|
+
*,
|
|
248
|
+
force: bool,
|
|
249
|
+
operations: tuple[asyncio.Task[object], ...],
|
|
250
|
+
tasks: tuple[ServiceTask, ...],
|
|
251
|
+
) -> list[BaseException]:
|
|
252
|
+
session_id = session.session_id
|
|
253
|
+
errors: list[BaseException] = []
|
|
254
|
+
if force and operations:
|
|
255
|
+
for operation in operations:
|
|
256
|
+
operation.cancel()
|
|
257
|
+
_, pending = await asyncio.wait(operations, timeout=self._cleanup_timeout_seconds)
|
|
258
|
+
if pending:
|
|
259
|
+
errors.append(TimeoutError(f"session {session_id} operations did not stop before cleanup deadline"))
|
|
260
|
+
if tasks:
|
|
261
|
+
try:
|
|
262
|
+
await self._cleanup_remote_tasks(session, tasks)
|
|
263
|
+
except BaseException as exc:
|
|
264
|
+
errors.append(exc)
|
|
265
|
+
try:
|
|
266
|
+
await session.lease.close()
|
|
267
|
+
except BaseException as exc:
|
|
268
|
+
errors.append(exc)
|
|
269
|
+
finally:
|
|
270
|
+
async with self._lock:
|
|
271
|
+
self._sessions.pop(session_id, None)
|
|
272
|
+
self._tasks = {task_id: task for task_id, task in self._tasks.items() if task.session_id != session_id}
|
|
273
|
+
self._launching_tasks = {
|
|
274
|
+
task_id: task for task_id, task in self._launching_tasks.items() if task.session_id != session_id
|
|
275
|
+
}
|
|
276
|
+
self._emit(
|
|
277
|
+
"session_closed",
|
|
278
|
+
session_id=session_id,
|
|
279
|
+
host_id=session.host_id,
|
|
280
|
+
owner_agent_id=session.owner,
|
|
281
|
+
outcome="cleanup_failed" if errors else "forced" if force else "user",
|
|
282
|
+
)
|
|
283
|
+
return errors
|
|
284
|
+
|
|
285
|
+
async def close_all(self, *, owner: str | None = None, force: bool = False) -> dict[str, object]:
|
|
286
|
+
finalizer = asyncio.create_task(
|
|
287
|
+
self._close_all_owned(owner=owner, force=force),
|
|
288
|
+
name="hostbridge-services-close-all",
|
|
289
|
+
)
|
|
290
|
+
cancellation: asyncio.CancelledError | None = None
|
|
291
|
+
while not finalizer.done():
|
|
292
|
+
try:
|
|
293
|
+
await asyncio.shield(finalizer)
|
|
294
|
+
except asyncio.CancelledError as exc:
|
|
295
|
+
cancellation = exc
|
|
296
|
+
result = finalizer.result()
|
|
297
|
+
if cancellation is not None:
|
|
298
|
+
raise cancellation
|
|
299
|
+
return result
|
|
300
|
+
|
|
301
|
+
async def _close_all_owned(self, *, owner: str | None, force: bool) -> dict[str, object]:
|
|
302
|
+
async with self._lock:
|
|
303
|
+
if force:
|
|
304
|
+
self._closing = True
|
|
305
|
+
reaper = self._reaper_task
|
|
306
|
+
self._reaper_task = None
|
|
307
|
+
else:
|
|
308
|
+
reaper = None
|
|
309
|
+
candidates = [session for session in self._sessions.values() if force or session.owner == owner]
|
|
310
|
+
if reaper is not None and reaper is not asyncio.current_task():
|
|
311
|
+
reaper.cancel()
|
|
312
|
+
await asyncio.gather(reaper, return_exceptions=True)
|
|
313
|
+
closed: list[str] = []
|
|
314
|
+
failed: list[str] = []
|
|
315
|
+
for session in candidates:
|
|
316
|
+
try:
|
|
317
|
+
await self.close_session(session.session_id, owner=owner, force=force)
|
|
318
|
+
except ServiceError:
|
|
319
|
+
failed.append(session.session_id)
|
|
320
|
+
else:
|
|
321
|
+
closed.append(session.session_id)
|
|
322
|
+
return {"closed": closed, "count": len(closed), "busy": [], "failed": failed}
|
|
323
|
+
|
|
324
|
+
async def exec(
|
|
325
|
+
self,
|
|
326
|
+
session_id: str,
|
|
327
|
+
command: str,
|
|
328
|
+
*,
|
|
329
|
+
stdin: ByteStream | None = None,
|
|
330
|
+
timeout: float = 60,
|
|
331
|
+
output_limit: int = 1_000_000,
|
|
332
|
+
owner: str | None = None,
|
|
333
|
+
) -> ServiceExecResult:
|
|
334
|
+
if not command.strip():
|
|
335
|
+
raise ServiceError("invalid_request", "command must not be empty")
|
|
336
|
+
self._check_command_policy(command)
|
|
337
|
+
async with self._operation(session_id, owner) as session:
|
|
338
|
+
service_result = await self._exec_on_session(
|
|
339
|
+
session,
|
|
340
|
+
command,
|
|
341
|
+
stdin=stdin,
|
|
342
|
+
timeout=timeout,
|
|
343
|
+
output_limit=output_limit,
|
|
344
|
+
)
|
|
345
|
+
self._emit(
|
|
346
|
+
"command_run",
|
|
347
|
+
session_id=session_id,
|
|
348
|
+
host_id=session.host_id,
|
|
349
|
+
owner_agent_id=owner,
|
|
350
|
+
command=command,
|
|
351
|
+
outcome="timed_out"
|
|
352
|
+
if service_result.timed_out
|
|
353
|
+
else f"exit_{service_result.exit_code}"
|
|
354
|
+
if service_result.exit_code is not None
|
|
355
|
+
else "unknown",
|
|
356
|
+
)
|
|
357
|
+
if service_result.timed_out:
|
|
358
|
+
await self.close_session(session_id, force=True)
|
|
359
|
+
return service_result
|
|
360
|
+
|
|
361
|
+
async def upload(
|
|
362
|
+
self,
|
|
363
|
+
session_id: str,
|
|
364
|
+
chunks: ByteStream,
|
|
365
|
+
remote_path: str,
|
|
366
|
+
*,
|
|
367
|
+
mode: int,
|
|
368
|
+
expected_size: int | None,
|
|
369
|
+
expected_sha256: str | None,
|
|
370
|
+
owner: str | None = None,
|
|
371
|
+
) -> TransferResult:
|
|
372
|
+
async with self._operation(session_id, owner) as session:
|
|
373
|
+
limit = self.hosts.get(session.host_id).limits.max_transfer_bytes
|
|
374
|
+
if expected_size is not None and expected_size > limit:
|
|
375
|
+
raise ServiceError("resource_limit", f"upload exceeds {limit} bytes")
|
|
376
|
+
result = await session.lease.upload(
|
|
377
|
+
self._bounded_chunks(chunks, limit, "upload"),
|
|
378
|
+
remote_path,
|
|
379
|
+
mode=mode,
|
|
380
|
+
expected_size=expected_size,
|
|
381
|
+
expected_sha256=expected_sha256,
|
|
382
|
+
)
|
|
383
|
+
if expected_size is not None and result.bytes_transferred != expected_size:
|
|
384
|
+
raise ServiceError("integrity_error", "transfer size mismatch")
|
|
385
|
+
if expected_sha256 is not None and result.sha256.lower() != expected_sha256.lower():
|
|
386
|
+
raise ServiceError("integrity_error", "transfer SHA-256 mismatch")
|
|
387
|
+
self._emit(
|
|
388
|
+
"file_uploaded",
|
|
389
|
+
session_id=session_id,
|
|
390
|
+
host_id=session.host_id,
|
|
391
|
+
owner_agent_id=owner,
|
|
392
|
+
command=remote_path,
|
|
393
|
+
outcome=f"bytes={result.bytes_transferred}",
|
|
394
|
+
)
|
|
395
|
+
return result
|
|
396
|
+
|
|
397
|
+
async def download(
|
|
398
|
+
self,
|
|
399
|
+
session_id: str,
|
|
400
|
+
remote_path: str,
|
|
401
|
+
*,
|
|
402
|
+
chunk_size: int,
|
|
403
|
+
owner: str | None = None,
|
|
404
|
+
) -> AsyncGenerator[bytes, None]:
|
|
405
|
+
async with self._operation(session_id, owner) as session:
|
|
406
|
+
limit = self.hosts.get(session.host_id).limits.max_transfer_bytes
|
|
407
|
+
transferred = 0
|
|
408
|
+
try:
|
|
409
|
+
async for chunk in session.lease.download(remote_path, chunk_size=chunk_size):
|
|
410
|
+
if not isinstance(chunk, bytes):
|
|
411
|
+
raise ServiceError("internal", "runtime returned a non-bytes download chunk")
|
|
412
|
+
transferred += len(chunk)
|
|
413
|
+
if transferred > limit:
|
|
414
|
+
raise ServiceError("resource_limit", f"download exceeds {limit} bytes")
|
|
415
|
+
yield chunk
|
|
416
|
+
finally:
|
|
417
|
+
self._emit(
|
|
418
|
+
"file_downloaded",
|
|
419
|
+
session_id=session_id,
|
|
420
|
+
host_id=session.host_id,
|
|
421
|
+
owner_agent_id=owner,
|
|
422
|
+
command=remote_path,
|
|
423
|
+
outcome=f"bytes={transferred}",
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
async def write_text(
|
|
427
|
+
self,
|
|
428
|
+
session_id: str,
|
|
429
|
+
remote_path: str,
|
|
430
|
+
content: str,
|
|
431
|
+
*,
|
|
432
|
+
mode: int = 0o644,
|
|
433
|
+
max_bytes: int = 4 * 1024 * 1024,
|
|
434
|
+
owner: str | None = None,
|
|
435
|
+
) -> TransferResult:
|
|
436
|
+
data = content.encode("utf-8")
|
|
437
|
+
session = await self._session_for_request(session_id, owner)
|
|
438
|
+
limit = min(max_bytes, self.hosts.get(session.host_id).limits.max_text_bytes)
|
|
439
|
+
if len(data) > limit:
|
|
440
|
+
raise ServiceError("resource_limit", f"text content exceeds {limit} bytes")
|
|
441
|
+
return await self.upload(
|
|
442
|
+
session_id,
|
|
443
|
+
[data],
|
|
444
|
+
remote_path,
|
|
445
|
+
mode=mode,
|
|
446
|
+
expected_size=len(data),
|
|
447
|
+
expected_sha256=hashlib.sha256(data).hexdigest(),
|
|
448
|
+
owner=owner,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
async def read_text(
|
|
452
|
+
self,
|
|
453
|
+
session_id: str,
|
|
454
|
+
remote_path: str,
|
|
455
|
+
*,
|
|
456
|
+
max_bytes: int = 4 * 1024 * 1024,
|
|
457
|
+
owner: str | None = None,
|
|
458
|
+
) -> str:
|
|
459
|
+
session = await self._session_for_request(session_id, owner)
|
|
460
|
+
limit = min(max_bytes, self.hosts.get(session.host_id).limits.max_text_bytes)
|
|
461
|
+
data = bytearray()
|
|
462
|
+
async for chunk in self.download(session_id, remote_path, chunk_size=256 * 1024, owner=owner):
|
|
463
|
+
data.extend(chunk)
|
|
464
|
+
if len(data) > limit:
|
|
465
|
+
raise ServiceError("resource_limit", f"text file exceeds {limit} bytes")
|
|
466
|
+
try:
|
|
467
|
+
return bytes(data).decode("utf-8")
|
|
468
|
+
except UnicodeDecodeError as exc:
|
|
469
|
+
raise ServiceError("invalid_request", f"remote file is not valid UTF-8 at byte {exc.start}") from exc
|
|
470
|
+
|
|
471
|
+
async def start_task(self, session_id: str, command: str, *, owner: str | None = None) -> ServiceTask:
|
|
472
|
+
if not command.strip():
|
|
473
|
+
raise ServiceError("invalid_request", "command must not be empty")
|
|
474
|
+
self._check_command_policy(command)
|
|
475
|
+
async with self._operation(session_id, owner) as session:
|
|
476
|
+
task_id = uuid.uuid4().hex
|
|
477
|
+
launching = ServiceTask(
|
|
478
|
+
task_id,
|
|
479
|
+
session_id,
|
|
480
|
+
session.host_id,
|
|
481
|
+
command,
|
|
482
|
+
remote_job_dir(task_id),
|
|
483
|
+
None,
|
|
484
|
+
owner,
|
|
485
|
+
)
|
|
486
|
+
async with self._lock:
|
|
487
|
+
retained = sum(
|
|
488
|
+
task.session_id == session_id for task in (*self._tasks.values(), *self._launching_tasks.values())
|
|
489
|
+
)
|
|
490
|
+
if retained >= self._max_tasks_per_session:
|
|
491
|
+
raise ServiceError("resource_limit", f"session {session_id} reached its task retention limit")
|
|
492
|
+
self._launching_tasks[task_id] = launching
|
|
493
|
+
try:
|
|
494
|
+
result = await self._exec_on_session(
|
|
495
|
+
session,
|
|
496
|
+
build_remote_task_command(command, task_id),
|
|
497
|
+
stdin=None,
|
|
498
|
+
timeout=10,
|
|
499
|
+
output_limit=4096,
|
|
500
|
+
)
|
|
501
|
+
pid_match = re.fullmatch(rb"\s*([1-9]\d*)\s*", result.stdout)
|
|
502
|
+
if result.timed_out or result.exit_code != 0 or pid_match is None:
|
|
503
|
+
raise ServiceError("remote_error", f"failed to launch task {task_id}", retryable=True)
|
|
504
|
+
task = ServiceTask(
|
|
505
|
+
task_id,
|
|
506
|
+
session_id,
|
|
507
|
+
session.host_id,
|
|
508
|
+
command,
|
|
509
|
+
launching.job_dir,
|
|
510
|
+
int(pid_match.group(1)),
|
|
511
|
+
owner,
|
|
512
|
+
)
|
|
513
|
+
async with self._lock:
|
|
514
|
+
self._launching_tasks.pop(task_id, None)
|
|
515
|
+
self._tasks[task_id] = task
|
|
516
|
+
self._emit(
|
|
517
|
+
"task_started",
|
|
518
|
+
session_id=session_id,
|
|
519
|
+
task_id=task_id,
|
|
520
|
+
host_id=session.host_id,
|
|
521
|
+
owner_agent_id=owner,
|
|
522
|
+
command=command,
|
|
523
|
+
outcome="ok",
|
|
524
|
+
)
|
|
525
|
+
return task
|
|
526
|
+
except BaseException:
|
|
527
|
+
async with self._lock:
|
|
528
|
+
cleanup_here = task_id in self._launching_tasks and not session.closing
|
|
529
|
+
if cleanup_here:
|
|
530
|
+
try:
|
|
531
|
+
await self._cleanup_remote_tasks(session, (launching,))
|
|
532
|
+
finally:
|
|
533
|
+
async with self._lock:
|
|
534
|
+
self._launching_tasks.pop(task_id, None)
|
|
535
|
+
else:
|
|
536
|
+
async with self._lock:
|
|
537
|
+
self._launching_tasks.pop(task_id, None)
|
|
538
|
+
raise
|
|
539
|
+
|
|
540
|
+
async def task_status(
|
|
541
|
+
self,
|
|
542
|
+
session_id: str,
|
|
543
|
+
task_id: str,
|
|
544
|
+
*,
|
|
545
|
+
owner: str | None = None,
|
|
546
|
+
tail_lines: int = 80,
|
|
547
|
+
) -> dict[str, object]:
|
|
548
|
+
task = await self._task_for_request(session_id, task_id, owner)
|
|
549
|
+
safe_tail = max(1, min(int(tail_lines), 500))
|
|
550
|
+
script = (
|
|
551
|
+
f'{remote_job_dir_assignment(task.task_id)}; pid=$(cat "$job_dir/pid" 2>/dev/null || true); '
|
|
552
|
+
'if [ -f "$job_dir/exit_code" ]; then state=finished; exit_code=$(cat "$job_dir/exit_code"); '
|
|
553
|
+
'elif [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then state=running; exit_code=\'\'; '
|
|
554
|
+
"else state=unknown; exit_code=''; fi; "
|
|
555
|
+
'printf \'STATE=%s\nPID=%s\nEXIT=%s\n---LOG---\n\' "$state" "$pid" "$exit_code"; '
|
|
556
|
+
f'tail -n {safe_tail} "$job_dir/stdout.log" 2>/dev/null || true'
|
|
557
|
+
)
|
|
558
|
+
result = await self.exec(session_id, script, timeout=15, output_limit=1_000_000, owner=owner)
|
|
559
|
+
metadata, _, log = result.stdout.decode("utf-8", errors="replace").partition("---LOG---\n")
|
|
560
|
+
fields = dict(line.partition("=")[::2] for line in metadata.splitlines() if "=" in line)
|
|
561
|
+
exit_text = fields.get("EXIT", "")
|
|
562
|
+
return {
|
|
563
|
+
"task_id": task.task_id,
|
|
564
|
+
"session_id": session_id,
|
|
565
|
+
"host_id": task.host_id,
|
|
566
|
+
"command": task.command,
|
|
567
|
+
"job_dir": task.job_dir,
|
|
568
|
+
"state": fields.get("STATE", "unknown"),
|
|
569
|
+
"pid": fields.get("PID") or task.remote_pid,
|
|
570
|
+
"exit_code": int(exit_text) if exit_text.isdigit() else None,
|
|
571
|
+
"log_tail": log,
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async def cancel_task(self, session_id: str, task_id: str, *, owner: str | None = None) -> dict[str, object]:
|
|
575
|
+
task = await self._task_for_request(session_id, task_id, owner)
|
|
576
|
+
if task.remote_pid is None:
|
|
577
|
+
raise ServiceError("remote_error", f"task {task_id} has no recorded remote pid")
|
|
578
|
+
script = (
|
|
579
|
+
f"{remote_job_dir_assignment(task.task_id)}; "
|
|
580
|
+
'if [ -f "$job_dir/exit_code" ]; then printf already; '
|
|
581
|
+
f"elif kill -TERM -- -{task.remote_pid} 2>/dev/null; then "
|
|
582
|
+
'attempt=0; while kill -0 -- -$(cat "$job_dir/pid") 2>/dev/null && [ "$attempt" -lt 20 ]; do '
|
|
583
|
+
"attempt=$((attempt + 1)); sleep 0.05; done; "
|
|
584
|
+
'if kill -0 -- -$(cat "$job_dir/pid") 2>/dev/null; then '
|
|
585
|
+
'kill -KILL -- -$(cat "$job_dir/pid") 2>/dev/null || true; fi; '
|
|
586
|
+
'printf \'143\n\' > "$job_dir/.exit_code.tmp"; mv "$job_dir/.exit_code.tmp" "$job_dir/exit_code"; '
|
|
587
|
+
"printf killed; else printf noop; fi"
|
|
588
|
+
)
|
|
589
|
+
result = await self.exec(session_id, script, timeout=10, output_limit=4096, owner=owner)
|
|
590
|
+
outcome = (
|
|
591
|
+
result.stdout.decode("utf-8", errors="replace").strip().splitlines()[-1]
|
|
592
|
+
if result.stdout.strip()
|
|
593
|
+
else "unknown"
|
|
594
|
+
)
|
|
595
|
+
self._emit(
|
|
596
|
+
"task_cancelled",
|
|
597
|
+
session_id=session_id,
|
|
598
|
+
task_id=task_id,
|
|
599
|
+
host_id=task.host_id,
|
|
600
|
+
owner_agent_id=owner,
|
|
601
|
+
outcome=outcome,
|
|
602
|
+
)
|
|
603
|
+
return {"task_id": task_id, "stopped": outcome in {"killed", "already"}, "outcome": outcome}
|
|
604
|
+
|
|
605
|
+
async def reap_idle(self) -> int:
|
|
606
|
+
if self._idle_timeout_seconds <= 0:
|
|
607
|
+
return 0
|
|
608
|
+
now = time.time()
|
|
609
|
+
async with self._lock:
|
|
610
|
+
expired = tuple(
|
|
611
|
+
session
|
|
612
|
+
for session in self._sessions.values()
|
|
613
|
+
if not session.closing
|
|
614
|
+
and session.active_operations == 0
|
|
615
|
+
and now - session.last_used_at > self._idle_timeout_seconds
|
|
616
|
+
)
|
|
617
|
+
closed = 0
|
|
618
|
+
for session in expired:
|
|
619
|
+
try:
|
|
620
|
+
await self.close_session(session.session_id, force=True)
|
|
621
|
+
except ServiceError:
|
|
622
|
+
logger.exception("failed to reap idle HostBridge session %s", session.session_id)
|
|
623
|
+
else:
|
|
624
|
+
closed += 1
|
|
625
|
+
self._emit(
|
|
626
|
+
"session_idle_closed",
|
|
627
|
+
session_id=session.session_id,
|
|
628
|
+
host_id=session.host_id,
|
|
629
|
+
owner_agent_id=session.owner,
|
|
630
|
+
outcome="expired",
|
|
631
|
+
)
|
|
632
|
+
return closed
|
|
633
|
+
|
|
634
|
+
async def shell_write(self, session_id: str, data: bytes, *, owner: str | None = None) -> int:
|
|
635
|
+
if not isinstance(data, bytes):
|
|
636
|
+
raise ServiceError("invalid_request", "shell data must be bytes")
|
|
637
|
+
async with self._operation(session_id, owner) as session:
|
|
638
|
+
limit = self.hosts.get(session.host_id).limits.max_shell_bytes
|
|
639
|
+
if len(data) > limit:
|
|
640
|
+
raise ServiceError("resource_limit", f"shell write exceeds {limit} bytes")
|
|
641
|
+
try:
|
|
642
|
+
await session.lease.shell_write(data)
|
|
643
|
+
except TunnelError as exc:
|
|
644
|
+
raise ServiceError(exc.code, str(exc), retryable=exc.retryable) from exc
|
|
645
|
+
return len(data)
|
|
646
|
+
|
|
647
|
+
async def shell_read(
|
|
648
|
+
self,
|
|
649
|
+
session_id: str,
|
|
650
|
+
*,
|
|
651
|
+
timeout: float | None,
|
|
652
|
+
max_bytes: int,
|
|
653
|
+
owner: str | None = None,
|
|
654
|
+
) -> ShellReadResult:
|
|
655
|
+
if timeout is not None and timeout < 0:
|
|
656
|
+
raise ServiceError("invalid_request", "timeout must be non-negative")
|
|
657
|
+
if max_bytes <= 0:
|
|
658
|
+
raise ServiceError("invalid_request", "max_bytes must be positive")
|
|
659
|
+
async with self._operation(session_id, owner) as session:
|
|
660
|
+
limit = min(max_bytes, self.hosts.get(session.host_id).limits.max_shell_bytes)
|
|
661
|
+
try:
|
|
662
|
+
return await session.lease.shell_read(timeout=timeout, max_bytes=limit)
|
|
663
|
+
except TunnelError as exc:
|
|
664
|
+
raise ServiceError(exc.code, str(exc), retryable=exc.retryable) from exc
|
|
665
|
+
|
|
666
|
+
async def attach_shell(
|
|
667
|
+
self,
|
|
668
|
+
session_id: str,
|
|
669
|
+
stdin: ByteStream,
|
|
670
|
+
*,
|
|
671
|
+
max_bytes: int,
|
|
672
|
+
owner: str | None = None,
|
|
673
|
+
) -> ShellAttachment:
|
|
674
|
+
if max_bytes <= 0:
|
|
675
|
+
raise ServiceError("invalid_request", "max_bytes must be positive")
|
|
676
|
+
terminal: asyncio.Future[ShellTerminalResult] = asyncio.get_running_loop().create_future()
|
|
677
|
+
|
|
678
|
+
async def output() -> AsyncGenerator[bytes, None]:
|
|
679
|
+
try:
|
|
680
|
+
async with self._operation(session_id, owner) as session:
|
|
681
|
+
limit = min(max_bytes, self.hosts.get(session.host_id).limits.max_shell_bytes)
|
|
682
|
+
attachment = await session.lease.attach_shell(stdin, max_bytes=limit)
|
|
683
|
+
async for chunk in attachment:
|
|
684
|
+
yield chunk
|
|
685
|
+
terminal.set_result(await attachment.finish())
|
|
686
|
+
except TunnelError as exc:
|
|
687
|
+
error = ServiceError(exc.code, str(exc), retryable=exc.retryable)
|
|
688
|
+
if not terminal.done():
|
|
689
|
+
terminal.set_exception(error)
|
|
690
|
+
terminal.exception()
|
|
691
|
+
raise error from exc
|
|
692
|
+
except BaseException as exc:
|
|
693
|
+
if not terminal.done():
|
|
694
|
+
terminal.set_exception(exc)
|
|
695
|
+
terminal.exception()
|
|
696
|
+
raise
|
|
697
|
+
|
|
698
|
+
return ShellAttachment(output(), terminal)
|
|
699
|
+
|
|
700
|
+
@asynccontextmanager
|
|
701
|
+
async def _operation(self, session_id: str, owner: str | None) -> AsyncGenerator[RuntimeServiceSession, None]:
|
|
702
|
+
operation = asyncio.current_task()
|
|
703
|
+
if operation is None:
|
|
704
|
+
raise RuntimeError("HostBridge operation is not running in an asyncio task")
|
|
705
|
+
async with self._lock:
|
|
706
|
+
session = self._sessions.get(session_id)
|
|
707
|
+
if session is None:
|
|
708
|
+
raise ServiceError("not_found", f"unknown session {session_id}")
|
|
709
|
+
self._check_owner(session, owner)
|
|
710
|
+
if session.closing:
|
|
711
|
+
raise ServiceError("busy", f"session {session_id} is closing", retryable=True)
|
|
712
|
+
capacity = max(1, session.lease.capabilities.parallel_channels)
|
|
713
|
+
if session.active_operations >= capacity:
|
|
714
|
+
raise ServiceError("busy", f"session {session_id} reached its channel capacity", retryable=True)
|
|
715
|
+
session.active_operations += 1
|
|
716
|
+
session.operation_tasks.add(operation)
|
|
717
|
+
try:
|
|
718
|
+
yield session
|
|
719
|
+
finally:
|
|
720
|
+
async with self._lock:
|
|
721
|
+
session.active_operations -= 1
|
|
722
|
+
session.operation_tasks.discard(operation)
|
|
723
|
+
session.last_used_at = time.time()
|
|
724
|
+
|
|
725
|
+
async def _exec_on_session(
|
|
726
|
+
self,
|
|
727
|
+
session: RuntimeServiceSession,
|
|
728
|
+
command: str,
|
|
729
|
+
*,
|
|
730
|
+
stdin: ByteStream | None,
|
|
731
|
+
timeout: float,
|
|
732
|
+
output_limit: int,
|
|
733
|
+
) -> ServiceExecResult:
|
|
734
|
+
limits = self.hosts.get(session.host_id).limits
|
|
735
|
+
timeout = min(timeout, limits.command_timeout_seconds)
|
|
736
|
+
output_limit = min(output_limit, limits.max_output_bytes, MAX_EXEC_OUTPUT_BYTES)
|
|
737
|
+
bounded_stdin = None if stdin is None else self._bounded_chunks(stdin, limits.max_stdin_bytes, "command stdin")
|
|
738
|
+
try:
|
|
739
|
+
result: ExecResult = await session.lease.exec(
|
|
740
|
+
command,
|
|
741
|
+
stdin=bounded_stdin,
|
|
742
|
+
timeout=timeout,
|
|
743
|
+
output_limit=output_limit,
|
|
744
|
+
)
|
|
745
|
+
except TunnelError as exc:
|
|
746
|
+
raise ServiceError(exc.code, str(exc), retryable=exc.retryable) from exc
|
|
747
|
+
stdout, stderr = self._limit_combined_output(result.stdout, result.stderr, output_limit)
|
|
748
|
+
return ServiceExecResult(session.session_id, result.exit_code, stdout, stderr, result.timed_out)
|
|
749
|
+
|
|
750
|
+
async def _cleanup_remote_tasks(
|
|
751
|
+
self,
|
|
752
|
+
session: RuntimeServiceSession,
|
|
753
|
+
tasks: tuple[ServiceTask, ...],
|
|
754
|
+
) -> None:
|
|
755
|
+
command = build_remote_tasks_cleanup_command((task.task_id, task.remote_pid) for task in tasks)
|
|
756
|
+
limits = self.hosts.get(session.host_id).limits
|
|
757
|
+
try:
|
|
758
|
+
result = await session.lease.exec(
|
|
759
|
+
command,
|
|
760
|
+
stdin=None,
|
|
761
|
+
timeout=min(10, limits.command_timeout_seconds),
|
|
762
|
+
output_limit=4096,
|
|
763
|
+
)
|
|
764
|
+
except TunnelError as exc:
|
|
765
|
+
raise ServiceError(exc.code, str(exc), retryable=exc.retryable) from exc
|
|
766
|
+
if result.timed_out or result.exit_code != 0:
|
|
767
|
+
raise ServiceError("remote_error", "failed to clean up remote tasks", retryable=True)
|
|
768
|
+
|
|
769
|
+
def _check_command_policy(self, command: str) -> None:
|
|
770
|
+
if self._policy is None:
|
|
771
|
+
return
|
|
772
|
+
decision = self._policy.evaluate(command)
|
|
773
|
+
if not decision.allowed:
|
|
774
|
+
raise ServiceError("denied", decision.reason, details={"matched_pattern": decision.matched_pattern})
|
|
775
|
+
|
|
776
|
+
def _ensure_idle_reaper_locked(self) -> None:
|
|
777
|
+
if self._idle_timeout_seconds <= 0 or self._reaper_task is not None:
|
|
778
|
+
return
|
|
779
|
+
self._reaper_task = asyncio.create_task(
|
|
780
|
+
self._idle_reaper_loop(),
|
|
781
|
+
name="hostbridge-session-idle-reaper",
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
async def _idle_reaper_loop(self) -> None:
|
|
785
|
+
while True:
|
|
786
|
+
await asyncio.sleep(self._reaper_interval_seconds)
|
|
787
|
+
await self.reap_idle()
|
|
788
|
+
|
|
789
|
+
def _emit(self, event: str, **fields: object) -> None:
|
|
790
|
+
if self._audit is None:
|
|
791
|
+
return
|
|
792
|
+
try:
|
|
793
|
+
audit_event(
|
|
794
|
+
self._audit,
|
|
795
|
+
event=event,
|
|
796
|
+
**{key: value for key, value in fields.items() if value is not None}, # type: ignore[arg-type]
|
|
797
|
+
)
|
|
798
|
+
except Exception:
|
|
799
|
+
logger.exception("audit event could not be written: %s", event)
|
|
800
|
+
|
|
801
|
+
@staticmethod
|
|
802
|
+
async def _close_lease_quietly(lease: HostRuntimeLease) -> None:
|
|
803
|
+
with suppress(BaseException):
|
|
804
|
+
await lease.close()
|
|
805
|
+
|
|
806
|
+
async def _bounded_chunks(
|
|
807
|
+
self,
|
|
808
|
+
chunks: ByteStream,
|
|
809
|
+
limit: int,
|
|
810
|
+
label: str,
|
|
811
|
+
) -> AsyncGenerator[bytes, None]:
|
|
812
|
+
transferred = 0
|
|
813
|
+
async for chunk in iterate_byte_stream(chunks, label=label):
|
|
814
|
+
transferred += len(chunk)
|
|
815
|
+
if transferred > limit:
|
|
816
|
+
raise ServiceError("resource_limit", f"{label} exceeds {limit} bytes")
|
|
817
|
+
yield chunk
|
|
818
|
+
|
|
819
|
+
async def _session_for_request(self, session_id: str, owner: str | None) -> RuntimeServiceSession:
|
|
820
|
+
async with self._lock:
|
|
821
|
+
session = self._sessions.get(session_id)
|
|
822
|
+
if session is None:
|
|
823
|
+
raise ServiceError("not_found", f"unknown session {session_id}")
|
|
824
|
+
self._check_owner(session, owner)
|
|
825
|
+
if session.closing:
|
|
826
|
+
raise ServiceError("busy", f"session {session_id} is closing", retryable=True)
|
|
827
|
+
return session
|
|
828
|
+
|
|
829
|
+
async def _task_for_request(self, session_id: str, task_id: str, owner: str | None) -> ServiceTask:
|
|
830
|
+
await self._session_for_request(session_id, owner)
|
|
831
|
+
async with self._lock:
|
|
832
|
+
task = self._tasks.get(task_id)
|
|
833
|
+
if task is None or task.session_id != session_id:
|
|
834
|
+
raise ServiceError("not_found", f"unknown task {task_id}")
|
|
835
|
+
if task.owner is not None and task.owner != owner:
|
|
836
|
+
raise ServiceError("denied", f"task {task_id} is owned by another agent")
|
|
837
|
+
return task
|
|
838
|
+
|
|
839
|
+
def _release_pending_locked(self, host_id: str) -> None:
|
|
840
|
+
pending = self._pending_sessions.get(host_id, 0)
|
|
841
|
+
if pending <= 1:
|
|
842
|
+
self._pending_sessions.pop(host_id, None)
|
|
843
|
+
else:
|
|
844
|
+
self._pending_sessions[host_id] = pending - 1
|
|
845
|
+
|
|
846
|
+
@staticmethod
|
|
847
|
+
def _check_owner(session: RuntimeServiceSession, owner: str | None) -> None:
|
|
848
|
+
if session.owner is not None and session.owner != owner:
|
|
849
|
+
raise ServiceError("denied", f"session {session.session_id} is owned by another agent")
|
|
850
|
+
|
|
851
|
+
@staticmethod
|
|
852
|
+
def _limit_combined_output(stdout: bytes, stderr: bytes, limit: int) -> tuple[bytes, bytes]:
|
|
853
|
+
if len(stdout) + len(stderr) <= limit:
|
|
854
|
+
return stdout, stderr
|
|
855
|
+
stdout_keep = min(len(stdout), (limit + 1) // 2)
|
|
856
|
+
stderr_keep = min(len(stderr), limit - stdout_keep)
|
|
857
|
+
remaining = limit - stdout_keep - stderr_keep
|
|
858
|
+
if remaining:
|
|
859
|
+
extra_stdout = min(len(stdout) - stdout_keep, remaining)
|
|
860
|
+
stdout_keep += extra_stdout
|
|
861
|
+
stderr_keep += min(len(stderr) - stderr_keep, remaining - extra_stdout)
|
|
862
|
+
return stdout[-stdout_keep:] if stdout_keep else b"", stderr[-stderr_keep:] if stderr_keep else b""
|