@neoline/hostbridge 2.0.2 → 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.
@@ -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(self, host_id: str, *, owner: str | None = None) -> ServiceSession:
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 Exception as exc:
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._sessions[session.session_id] = session
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
- 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()
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
- removed = self._sessions.pop(session_id, None)
177
- if removed is not None:
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
- 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
- )
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
- sessions = list(self._sessions.values())
192
- self._sessions.clear()
193
- self._tasks.clear()
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
- 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)}
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
- 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
- }
302
+ closed = 0
216
303
  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)
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
- 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
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
- 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)
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
- 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"
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
- 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()
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._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
- )
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
- if len(data) > max_bytes:
427
- raise ServiceError("resource_limit", f"text content exceeds {max_bytes} bytes")
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
- if len(data) > max_bytes:
450
- raise ServiceError("resource_limit", f"text file exceeds {max_bytes} bytes")
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
- 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()
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
- 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
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
- audit_event(self._audit, event=event, **{key: value for key, value in fields.items() if value is not None}) # type: ignore[arg-type]
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)