@neoline/hostbridge 0.2.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import base64
4
+ import hashlib
3
5
  import re
4
6
  import shlex
5
7
  import threading
@@ -8,7 +10,8 @@ import uuid
8
10
  from collections.abc import Callable
9
11
  from contextlib import suppress
10
12
  from dataclasses import dataclass, field
11
- from typing import Protocol
13
+ from pathlib import Path
14
+ from typing import Protocol, cast
12
15
 
13
16
  import pexpect
14
17
 
@@ -21,6 +24,10 @@ DEFAULT_REAPER_INTERVAL_SECONDS = 60
21
24
  MAX_OUTPUT_CHARS = 120_000
22
25
  REMOTE_JOB_ROOT = "$HOME/.hostbridge/jobs"
23
26
  REMOTE_SHELL = "bash"
27
+ MAX_SHELL_TRANSFER_BYTES = 100 * 1024 * 1024
28
+ DEFAULT_MAX_TRANSFER_BYTES = MAX_SHELL_TRANSFER_BYTES
29
+ UPLOAD_BASE64_CHUNK_CHARS = 256 * 1024
30
+ DOWNLOAD_CHUNK_BYTES = 5 * 1024 * 1024
24
31
 
25
32
  class PolicyDeniedError(RuntimeError):
26
33
  """Raised when a command is blocked by the configured policy."""
@@ -62,6 +69,7 @@ class RemoteSession:
62
69
  created_at: float = field(default_factory=time.time)
63
70
  last_used_at: float = field(default_factory=time.time)
64
71
  busy: bool = False
72
+ owner_agent_id: str | None = None
65
73
  last_output: str = ""
66
74
 
67
75
 
@@ -83,6 +91,7 @@ class RemoteTask:
83
91
  command: str
84
92
  job_dir: str
85
93
  remote_pid: int | None
94
+ owner_agent_id: str | None = None
86
95
  created_at: float = field(default_factory=time.time)
87
96
 
88
97
 
@@ -146,6 +155,7 @@ class SessionManager:
146
155
  remote_shell: str = REMOTE_SHELL,
147
156
  policy: PolicyConfig | None = None,
148
157
  audit_sink: AuditSink | None = None,
158
+ max_sessions_per_host: int = 0,
149
159
  ):
150
160
  self.hosts = hosts
151
161
  self.child_factory = child_factory
@@ -154,6 +164,7 @@ class SessionManager:
154
164
  self._tasks: dict[str, RemoteTask] = {}
155
165
  self._policy = policy
156
166
  self._audit = audit_sink
167
+ self._max_sessions_per_host = max(0, int(max_sessions_per_host))
157
168
  self._reaper_stop = threading.Event()
158
169
  self._reaper_thread: threading.Thread | None = None
159
170
 
@@ -223,8 +234,10 @@ class SessionManager:
223
234
  *,
224
235
  connect_timeout: int = DEFAULT_CONNECT_TIMEOUT,
225
236
  token_factory: TokenFactory = _new_token,
237
+ owner_agent_id: str | None = None,
226
238
  ) -> RemoteSession:
227
239
  host = self.hosts.get(host_id)
240
+ self._check_host_session_limit(host.id)
228
241
  child = self.child_factory(host.command, list(host.args), connect_timeout)
229
242
  token = token_factory()
230
243
  marker = f"__SCM_READY_{token}__"
@@ -247,6 +260,7 @@ class SessionManager:
247
260
  command=host.command,
248
261
  args=list(host.args),
249
262
  child=child,
263
+ owner_agent_id=owner_agent_id,
250
264
  last_output=_tail(str(child.before)),
251
265
  )
252
266
  self._sessions[session.id] = session
@@ -286,14 +300,23 @@ class SessionManager:
286
300
  closed.append(session_id)
287
301
  return {"closed": closed, "count": len(closed)}
288
302
 
289
- def close_session(self, session_id: str) -> dict[str, object]:
303
+ def close_session(
304
+ self,
305
+ session_id: str,
306
+ *,
307
+ owner_agent_id: str | None = None,
308
+ force: bool = False,
309
+ ) -> dict[str, object]:
290
310
  session = self._get_session(session_id)
311
+ self._check_owner(session, owner_agent_id, force=force)
312
+ if session.busy and not force:
313
+ raise RemoteCommandError(f"session {session_id} is busy; retry later or close with force")
291
314
  session.child.close(force=True)
292
315
  self._sessions.pop(session_id, None)
293
- self._emit("session_closed", session_id=session_id, host_id=session.host_id, outcome="user")
316
+ self._emit("session_closed", session_id=session_id, host_id=session.host_id, outcome="forced" if force else "user")
294
317
  return {"session_id": session_id, "closed": True}
295
318
 
296
- def stop_task(self, session_id: str, task_id: str) -> dict[str, object]:
319
+ def stop_task(self, session_id: str, task_id: str, *, owner_agent_id: str | None = None, force: bool = False) -> dict[str, object]:
297
320
  """Best-effort terminate a background task by killing its remote PID."""
298
321
 
299
322
  task = self._tasks.get(task_id)
@@ -301,10 +324,11 @@ class SessionManager:
301
324
  raise RemoteCommandError(f"unknown task {task_id}")
302
325
  if task.session_id != session_id:
303
326
  raise RemoteCommandError(f"task {task_id} does not belong to session {session_id}")
327
+ self._check_task_owner(task, owner_agent_id, force=force)
304
328
  if task.remote_pid is None:
305
329
  raise RemoteCommandError(f"task {task_id} has no recorded remote pid")
306
330
  kill_script = f'if [ -f "{task.job_dir}/exit_code" ]; then echo already; else kill -TERM {task.remote_pid} 2>/dev/null && echo killed || echo noop; fi'
307
- result = self.run_command(session_id, kill_script, timeout=10)
331
+ result = self.run_command(session_id, kill_script, timeout=10, owner_agent_id=owner_agent_id)
308
332
  outcome = result.output.strip().splitlines()[-1] if result.output.strip() else "unknown"
309
333
  self._emit(
310
334
  "task_stopped",
@@ -322,6 +346,8 @@ class SessionManager:
322
346
  *,
323
347
  timeout: int = DEFAULT_COMMAND_TIMEOUT,
324
348
  token_factory: TokenFactory = _new_token,
349
+ owner_agent_id: str | None = None,
350
+ output_limit: int = MAX_OUTPUT_CHARS,
325
351
  ) -> CommandResult:
326
352
  if not command.strip():
327
353
  raise RemoteCommandError("command must not be empty")
@@ -336,6 +362,7 @@ class SessionManager:
336
362
  )
337
363
  raise PolicyDeniedError(decision.reason)
338
364
  session = self._get_session(session_id)
365
+ self._check_owner(session, owner_agent_id)
339
366
  if session.busy:
340
367
  raise RemoteCommandError(f"session {session_id} is busy")
341
368
  if not session.child.isalive():
@@ -351,7 +378,7 @@ class SessionManager:
351
378
  session.child.expect(exit_pattern, timeout=timeout)
352
379
  output = _extract_between_markers(str(session.child.before), start_marker)
353
380
  exit_code = _parse_exit_marker(session.child.after, token)
354
- result = CommandResult(session.id, session.host_id, command, exit_code, _tail(output))
381
+ result = CommandResult(session.id, session.host_id, command, exit_code, _tail(output, max(1, output_limit)))
355
382
  self._emit(
356
383
  "command_run",
357
384
  session_id=session.id,
@@ -381,6 +408,7 @@ class SessionManager:
381
408
  command: str,
382
409
  *,
383
410
  token_factory: TokenFactory = _new_token,
411
+ owner_agent_id: str | None = None,
384
412
  ) -> RemoteTask:
385
413
  if not command.strip():
386
414
  raise RemoteCommandError("command must not be empty")
@@ -406,7 +434,7 @@ class SessionManager:
406
434
  'date -u +%Y-%m-%dT%H:%M:%SZ > "$job_dir/finished_at") & '
407
435
  'pid=$!; printf "%s\\n" "$pid" > "$job_dir/pid"; printf "%s" "$pid"'
408
436
  )
409
- result = self.run_command(session_id, script, timeout=10, token_factory=lambda: f"task_{task_id}")
437
+ result = self.run_command(session_id, script, timeout=10, token_factory=lambda: f"task_{task_id}", owner_agent_id=owner_agent_id)
410
438
  pid_match = re.search(r"(\d+)", result.output)
411
439
  task = RemoteTask(
412
440
  id=task_id,
@@ -415,17 +443,26 @@ class SessionManager:
415
443
  command=command,
416
444
  job_dir=job_dir,
417
445
  remote_pid=int(pid_match.group(1)) if pid_match else None,
446
+ owner_agent_id=owner_agent_id,
418
447
  )
419
448
  self._tasks[task.id] = task
420
449
  self._emit("task_started", session_id=session_id, task_id=task.id, host_id=task.host_id, command=command, outcome="ok")
421
450
  return task
422
451
 
423
- def task_status(self, session_id: str, task_id: str, *, tail_lines: int = 80) -> dict[str, object]:
452
+ def task_status(
453
+ self,
454
+ session_id: str,
455
+ task_id: str,
456
+ *,
457
+ tail_lines: int = 80,
458
+ owner_agent_id: str | None = None,
459
+ ) -> dict[str, object]:
424
460
  task = self._tasks.get(task_id)
425
461
  if task is None:
426
462
  raise RemoteCommandError(f"unknown task {task_id}")
427
463
  if task.session_id != session_id:
428
464
  raise RemoteCommandError(f"task {task_id} does not belong to session {session_id}")
465
+ self._check_task_owner(task, owner_agent_id)
429
466
  safe_tail_lines = max(1, min(int(tail_lines), 500))
430
467
  script = (
431
468
  f'job_dir="{task.job_dir}"; '
@@ -436,7 +473,7 @@ class SessionManager:
436
473
  'printf "STATE=%s\\nPID=%s\\nEXIT=%s\\n---LOG---\\n" "$state" "$pid" "$exit_code"; '
437
474
  f'tail -n {safe_tail_lines} "$job_dir/stdout.log" 2>/dev/null || true'
438
475
  )
439
- result = self.run_command(session_id, script, timeout=15)
476
+ result = self.run_command(session_id, script, timeout=15, owner_agent_id=owner_agent_id)
440
477
  metadata, _, log = result.output.partition("---LOG---\n")
441
478
  fields: dict[str, str] = {}
442
479
  for line in metadata.splitlines():
@@ -456,14 +493,320 @@ class SessionManager:
456
493
  "log_tail": log,
457
494
  }
458
495
 
459
- def write(self, session_id: str, text: str) -> dict[str, object]:
496
+
497
+ def file_write_text(
498
+ self,
499
+ session_id: str,
500
+ remote_path: str,
501
+ content: str,
502
+ *,
503
+ mode: str = "0644",
504
+ max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
505
+ token_factory: TokenFactory = _new_token,
506
+ owner_agent_id: str | None = None,
507
+ ) -> dict[str, object]:
508
+ data = content.encode("utf-8")
509
+ return self._upload_bytes(
510
+ session_id,
511
+ data,
512
+ remote_path,
513
+ mode=mode,
514
+ max_bytes=max_bytes,
515
+ token_factory=token_factory,
516
+ owner_agent_id=owner_agent_id,
517
+ )
518
+
519
+ def file_read_text(
520
+ self,
521
+ session_id: str,
522
+ remote_path: str,
523
+ *,
524
+ max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
525
+ token_factory: TokenFactory = _new_token,
526
+ owner_agent_id: str | None = None,
527
+ ) -> dict[str, object]:
528
+ result = self._download_bytes(
529
+ session_id,
530
+ remote_path,
531
+ max_bytes=max_bytes,
532
+ token_factory=token_factory,
533
+ owner_agent_id=owner_agent_id,
534
+ )
535
+ data = cast(bytes, result.pop("data_bytes"))
536
+ result["content"] = data.decode("utf-8")
537
+ return result
538
+
539
+ def file_upload(
540
+ self,
541
+ session_id: str,
542
+ local_path: Path | str,
543
+ remote_path: str,
544
+ *,
545
+ mode: str = "0644",
546
+ max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
547
+ token_factory: TokenFactory = _new_token,
548
+ owner_agent_id: str | None = None,
549
+ ) -> dict[str, object]:
550
+ path = Path(local_path).expanduser()
551
+ if not path.is_absolute():
552
+ raise RemoteCommandError("local_path must be absolute")
553
+ if not path.is_file():
554
+ raise RemoteCommandError(f"local file not found: {path}")
555
+ size = path.stat().st_size
556
+ self._check_transfer_size(size, max_bytes)
557
+ return self._upload_bytes(
558
+ session_id,
559
+ path.read_bytes(),
560
+ remote_path,
561
+ mode=mode,
562
+ max_bytes=max_bytes,
563
+ token_factory=token_factory,
564
+ owner_agent_id=owner_agent_id,
565
+ local_path=str(path),
566
+ )
567
+
568
+ def file_download(
569
+ self,
570
+ session_id: str,
571
+ remote_path: str,
572
+ local_path: Path | str,
573
+ *,
574
+ max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
575
+ token_factory: TokenFactory = _new_token,
576
+ owner_agent_id: str | None = None,
577
+ ) -> dict[str, object]:
578
+ path = Path(local_path).expanduser()
579
+ if not path.is_absolute():
580
+ raise RemoteCommandError("local_path must be absolute")
581
+ result = self._download_bytes(
582
+ session_id,
583
+ remote_path,
584
+ max_bytes=max_bytes,
585
+ token_factory=token_factory,
586
+ owner_agent_id=owner_agent_id,
587
+ )
588
+ data = cast(bytes, result.pop("data_bytes"))
589
+ path.parent.mkdir(parents=True, exist_ok=True)
590
+ path.write_bytes(data)
591
+ result["local_path"] = str(path)
592
+ return result
593
+
594
+ def _upload_bytes(
595
+ self,
596
+ session_id: str,
597
+ data: bytes,
598
+ remote_path: str,
599
+ *,
600
+ mode: str,
601
+ max_bytes: int,
602
+ token_factory: TokenFactory,
603
+ owner_agent_id: str | None,
604
+ local_path: str | None = None,
605
+ ) -> dict[str, object]:
606
+ self._check_transfer_size(len(data), max_bytes)
607
+ self._validate_remote_path(remote_path)
608
+ b64 = base64.b64encode(data).decode("ascii")
609
+ sha256 = hashlib.sha256(data).hexdigest()
610
+ token = token_factory()
611
+ remote_q = shlex.quote(remote_path)
612
+ mode_q = shlex.quote(mode)
613
+ tmp_prefix = f"/tmp/hostbridge.upload.{token}"
614
+ tmp_b64 = f"{tmp_prefix}.b64"
615
+ tmp_out = f"{tmp_prefix}.out"
616
+ cleanup_script = f"rm -f {shlex.quote(tmp_b64)} {shlex.quote(tmp_out)}"
617
+ init_script = (
618
+ "set -e; "
619
+ f"target={remote_q}; "
620
+ 'dir=$(dirname "$target"); mkdir -p "$dir"; '
621
+ f"rm -f {shlex.quote(tmp_b64)} {shlex.quote(tmp_out)}; "
622
+ f": > {shlex.quote(tmp_b64)}"
623
+ )
624
+ result = self.run_command(session_id, init_script, timeout=60, token_factory=lambda: token, owner_agent_id=owner_agent_id)
625
+ if result.exit_code != 0:
626
+ raise RemoteCommandError(result.output or f"failed to prepare upload for {remote_path}")
627
+ try:
628
+ self._stream_base64_to_remote_file(
629
+ session_id,
630
+ b64,
631
+ tmp_b64,
632
+ token=token,
633
+ owner_agent_id=owner_agent_id,
634
+ )
635
+ finalize_script = (
636
+ "set -e; "
637
+ f"target={remote_q}; mode={mode_q}; tmp_b64={shlex.quote(tmp_b64)}; tmp_out={shlex.quote(tmp_out)}; "
638
+ 'base64 -d "$tmp_b64" > "$tmp_out"; '
639
+ 'chmod "$mode" "$tmp_out"; mv "$tmp_out" "$target"; rm -f "$tmp_b64"; '
640
+ f"printf 'BYTES=%s\\nSHA256=%s\\n' {len(data)} {shlex.quote(sha256)}"
641
+ )
642
+ result = self.run_command(
643
+ session_id,
644
+ finalize_script,
645
+ timeout=60,
646
+ token_factory=lambda: token,
647
+ owner_agent_id=owner_agent_id,
648
+ )
649
+ if result.exit_code != 0:
650
+ raise RemoteCommandError(result.output or f"failed to finalize upload for {remote_path}")
651
+ except Exception:
652
+ with suppress(Exception):
653
+ self.run_command(session_id, cleanup_script, timeout=10, token_factory=lambda: token, owner_agent_id=owner_agent_id)
654
+ raise
655
+ payload: dict[str, object] = {
656
+ "session_id": session_id,
657
+ "remote_path": remote_path,
658
+ "bytes": len(data),
659
+ "sha256": sha256,
660
+ "strategy": "shell_base64_chunked",
661
+ }
662
+ if local_path is not None:
663
+ payload["local_path"] = local_path
664
+ self._emit("file_uploaded", session_id=session_id, command=remote_path, outcome=f"bytes={len(data)}")
665
+ return payload
666
+
667
+ def _stream_base64_to_remote_file(
668
+ self,
669
+ session_id: str,
670
+ encoded: str,
671
+ remote_tmp_b64: str,
672
+ *,
673
+ token: str,
674
+ owner_agent_id: str | None,
675
+ ) -> None:
676
+ session = self._get_session(session_id)
677
+ self._check_owner(session, owner_agent_id)
678
+ if session.busy:
679
+ raise RemoteCommandError(f"session {session_id} is busy")
680
+ if not session.child.isalive():
681
+ raise RemoteCommandError(f"session {session_id} is not alive")
682
+
683
+ heredoc = f"__HOSTBRIDGE_UPLOAD_{token}__"
684
+ done_pattern = re.compile(rf"__HOSTBRIDGE_UPLOAD_DONE_{re.escape(token)}__:(\d+)")
685
+ chunk_size = max(4, int(UPLOAD_BASE64_CHUNK_CHARS))
686
+ session.busy = True
687
+ try:
688
+ session.child.send(f"cat >> {shlex.quote(remote_tmp_b64)} <<'{heredoc}'\n")
689
+ for offset in range(0, len(encoded), chunk_size):
690
+ session.child.send(encoded[offset : offset + chunk_size])
691
+ session.child.send(f"\n{heredoc}\nprintf '\\n__HOSTBRIDGE_UPLOAD_DONE_{token}__:%s\\n' \"$?\"\n")
692
+ session.child.expect(done_pattern, timeout=60)
693
+ marker = session.child.after
694
+ text = marker.group(0) if hasattr(marker, "group") else str(marker)
695
+ match = re.search(r":(\d+)", text)
696
+ if match is None or int(match.group(1)) != 0:
697
+ raise RemoteCommandError(f"failed to stream upload chunk for {session_id}")
698
+ finally:
699
+ session.busy = False
700
+ session.last_used_at = time.time()
701
+ session.last_output = _tail(str(session.child.before))
702
+
703
+ def _download_bytes(
704
+ self,
705
+ session_id: str,
706
+ remote_path: str,
707
+ *,
708
+ max_bytes: int,
709
+ token_factory: TokenFactory,
710
+ owner_agent_id: str | None,
711
+ ) -> dict[str, object]:
712
+ self._validate_remote_path(remote_path)
713
+ token = token_factory()
714
+ remote_q = shlex.quote(remote_path)
715
+ metadata_script = (
716
+ "set -e; "
717
+ f"target={remote_q}; "
718
+ 'bytes=$(wc -c < "$target" | tr -d " "); '
719
+ f"if [ \"$bytes\" -gt {int(max_bytes)} ]; then echo 'FILE_TOO_LARGE remote pull required' >&2; exit 73; fi; "
720
+ "if command -v sha256sum >/dev/null 2>&1; then sha=$(sha256sum \"$target\" | awk '{print $1}'); "
721
+ "else sha=$(shasum -a 256 \"$target\" | awk '{print $1}'); fi; "
722
+ "printf 'BYTES=%s\\nSHA256=%s\\n' \"$bytes\" \"$sha\""
723
+ )
724
+ result = self.run_command(
725
+ session_id,
726
+ metadata_script,
727
+ timeout=60,
728
+ token_factory=lambda: token,
729
+ owner_agent_id=owner_agent_id,
730
+ )
731
+ if result.exit_code == 73:
732
+ self._raise_remote_pull_required(max_bytes)
733
+ if result.exit_code != 0:
734
+ raise RemoteCommandError(result.output or f"failed to download {remote_path}")
735
+ fields: dict[str, str] = {}
736
+ for line in result.output.splitlines():
737
+ key, _, value = line.partition("=")
738
+ if key:
739
+ fields[key] = value
740
+ byte_count = int(fields.get("BYTES") or 0)
741
+ self._check_transfer_size(byte_count, max_bytes)
742
+ chunks: list[bytes] = []
743
+ chunk_size = max(1, int(DOWNLOAD_CHUNK_BYTES))
744
+ for index in range((byte_count + chunk_size - 1) // chunk_size):
745
+ chunk_script = (
746
+ "set -e; "
747
+ f"dd if={remote_q} bs={chunk_size} skip={index} count=1 2>/dev/null | base64"
748
+ )
749
+ output_limit = max(MAX_OUTPUT_CHARS, chunk_size * 2 + 4096)
750
+ result = self.run_command(
751
+ session_id,
752
+ chunk_script,
753
+ timeout=60,
754
+ token_factory=lambda: token,
755
+ owner_agent_id=owner_agent_id,
756
+ output_limit=output_limit,
757
+ )
758
+ if result.exit_code != 0:
759
+ raise RemoteCommandError(result.output or f"failed to download chunk {index} from {remote_path}")
760
+ chunks.append(base64.b64decode("".join(result.output.split())))
761
+ data = b"".join(chunks)
762
+ sha256 = hashlib.sha256(data).hexdigest()
763
+ remote_sha = fields.get("SHA256") or sha256
764
+ if remote_sha != "ignored" and sha256 != remote_sha:
765
+ raise RemoteCommandError("download checksum mismatch")
766
+ self._emit("file_downloaded", session_id=session_id, command=remote_path, outcome=f"bytes={len(data)}")
767
+ return {
768
+ "session_id": session_id,
769
+ "remote_path": remote_path,
770
+ "bytes": len(data),
771
+ "sha256": remote_sha,
772
+ "strategy": "shell_base64_chunked",
773
+ "data_bytes": data,
774
+ }
775
+
776
+ def _validate_remote_path(self, remote_path: str) -> None:
777
+ if not remote_path or remote_path.strip() in {"", "/"}:
778
+ raise RemoteCommandError("remote_path must not be empty or root")
779
+
780
+ def _check_transfer_size(self, size: int, max_bytes: int) -> None:
781
+ if size > int(max_bytes):
782
+ self._raise_remote_pull_required(max_bytes)
783
+
784
+ def _raise_remote_pull_required(self, max_bytes: int) -> None:
785
+ raise RemoteCommandError(
786
+ f"file exceeds HostBridge shell transfer limit ({int(max_bytes)} bytes); use remote pull (curl/wget/modelscope/git) for large files"
787
+ )
788
+
789
+ def write(self, session_id: str, text: str, *, owner_agent_id: str | None = None) -> dict[str, object]:
460
790
  session = self._get_session(session_id)
791
+ self._check_owner(session, owner_agent_id)
792
+ if session.busy:
793
+ raise RemoteCommandError(f"session {session_id} is busy")
794
+ if not session.child.isalive():
795
+ raise RemoteCommandError(f"session {session_id} is not alive")
461
796
  session.child.sendline(text)
462
797
  session.last_used_at = time.time()
463
798
  return {"session_id": session_id, "written": True}
464
799
 
465
- def read(self, session_id: str, *, timeout: float = 1.0, max_chars: int = 20_000) -> dict[str, object]:
800
+ def read(
801
+ self,
802
+ session_id: str,
803
+ *,
804
+ timeout: float = 1.0,
805
+ max_chars: int = 20_000,
806
+ owner_agent_id: str | None = None,
807
+ ) -> dict[str, object]:
466
808
  session = self._get_session(session_id)
809
+ self._check_owner(session, owner_agent_id)
467
810
  chunks: list[str] = []
468
811
  deadline = time.time() + max(0.0, timeout)
469
812
  while len("".join(chunks)) < max_chars:
@@ -488,6 +831,25 @@ class SessionManager:
488
831
  except KeyError as exc:
489
832
  raise RemoteCommandError(f"unknown session {session_id}") from exc
490
833
 
834
+ def _check_owner(self, session: RemoteSession, owner_agent_id: str | None, *, force: bool = False) -> None:
835
+ if force or owner_agent_id is None or session.owner_agent_id is None:
836
+ return
837
+ if owner_agent_id != session.owner_agent_id:
838
+ raise RemoteCommandError(f"session {session.id} is owned by another agent")
839
+
840
+ def _check_task_owner(self, task: RemoteTask, owner_agent_id: str | None, *, force: bool = False) -> None:
841
+ if force or owner_agent_id is None or task.owner_agent_id is None:
842
+ return
843
+ if owner_agent_id != task.owner_agent_id:
844
+ raise RemoteCommandError(f"task {task.id} is owned by another agent")
845
+
846
+ def _check_host_session_limit(self, host_id: str) -> None:
847
+ if self._max_sessions_per_host <= 0:
848
+ return
849
+ open_count = sum(1 for session in self._sessions.values() if session.host_id == host_id and session.child.isalive())
850
+ if open_count >= self._max_sessions_per_host:
851
+ raise RemoteCommandError(f"host {host_id} reached session limit {self._max_sessions_per_host}")
852
+
491
853
  def _build_remote_line(self, command: str, token: str, start_marker: str) -> str:
492
854
  quoted_command = shlex.quote(command)
493
855
  return (
@@ -507,6 +869,7 @@ class SessionManager:
507
869
  "args": session.args,
508
870
  "alive": session.child.isalive(),
509
871
  "busy": session.busy,
872
+ "owner_agent_id": session.owner_agent_id,
510
873
  "created_at": session.created_at,
511
874
  "last_used_at": session.last_used_at,
512
875
  }