@neoline/hostbridge 0.2.2 → 1.0.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,7 @@ 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
+ DEFAULT_MAX_TRANSFER_BYTES = 1024 * 1024
24
28
 
25
29
  class PolicyDeniedError(RuntimeError):
26
30
  """Raised when a command is blocked by the configured policy."""
@@ -62,6 +66,7 @@ class RemoteSession:
62
66
  created_at: float = field(default_factory=time.time)
63
67
  last_used_at: float = field(default_factory=time.time)
64
68
  busy: bool = False
69
+ owner_agent_id: str | None = None
65
70
  last_output: str = ""
66
71
 
67
72
 
@@ -83,6 +88,7 @@ class RemoteTask:
83
88
  command: str
84
89
  job_dir: str
85
90
  remote_pid: int | None
91
+ owner_agent_id: str | None = None
86
92
  created_at: float = field(default_factory=time.time)
87
93
 
88
94
 
@@ -146,6 +152,7 @@ class SessionManager:
146
152
  remote_shell: str = REMOTE_SHELL,
147
153
  policy: PolicyConfig | None = None,
148
154
  audit_sink: AuditSink | None = None,
155
+ max_sessions_per_host: int = 0,
149
156
  ):
150
157
  self.hosts = hosts
151
158
  self.child_factory = child_factory
@@ -154,6 +161,7 @@ class SessionManager:
154
161
  self._tasks: dict[str, RemoteTask] = {}
155
162
  self._policy = policy
156
163
  self._audit = audit_sink
164
+ self._max_sessions_per_host = max(0, int(max_sessions_per_host))
157
165
  self._reaper_stop = threading.Event()
158
166
  self._reaper_thread: threading.Thread | None = None
159
167
 
@@ -223,8 +231,10 @@ class SessionManager:
223
231
  *,
224
232
  connect_timeout: int = DEFAULT_CONNECT_TIMEOUT,
225
233
  token_factory: TokenFactory = _new_token,
234
+ owner_agent_id: str | None = None,
226
235
  ) -> RemoteSession:
227
236
  host = self.hosts.get(host_id)
237
+ self._check_host_session_limit(host.id)
228
238
  child = self.child_factory(host.command, list(host.args), connect_timeout)
229
239
  token = token_factory()
230
240
  marker = f"__SCM_READY_{token}__"
@@ -247,6 +257,7 @@ class SessionManager:
247
257
  command=host.command,
248
258
  args=list(host.args),
249
259
  child=child,
260
+ owner_agent_id=owner_agent_id,
250
261
  last_output=_tail(str(child.before)),
251
262
  )
252
263
  self._sessions[session.id] = session
@@ -286,14 +297,23 @@ class SessionManager:
286
297
  closed.append(session_id)
287
298
  return {"closed": closed, "count": len(closed)}
288
299
 
289
- def close_session(self, session_id: str) -> dict[str, object]:
300
+ def close_session(
301
+ self,
302
+ session_id: str,
303
+ *,
304
+ owner_agent_id: str | None = None,
305
+ force: bool = False,
306
+ ) -> dict[str, object]:
290
307
  session = self._get_session(session_id)
308
+ self._check_owner(session, owner_agent_id, force=force)
309
+ if session.busy and not force:
310
+ raise RemoteCommandError(f"session {session_id} is busy; retry later or close with force")
291
311
  session.child.close(force=True)
292
312
  self._sessions.pop(session_id, None)
293
- self._emit("session_closed", session_id=session_id, host_id=session.host_id, outcome="user")
313
+ self._emit("session_closed", session_id=session_id, host_id=session.host_id, outcome="forced" if force else "user")
294
314
  return {"session_id": session_id, "closed": True}
295
315
 
296
- def stop_task(self, session_id: str, task_id: str) -> dict[str, object]:
316
+ def stop_task(self, session_id: str, task_id: str, *, owner_agent_id: str | None = None, force: bool = False) -> dict[str, object]:
297
317
  """Best-effort terminate a background task by killing its remote PID."""
298
318
 
299
319
  task = self._tasks.get(task_id)
@@ -301,10 +321,11 @@ class SessionManager:
301
321
  raise RemoteCommandError(f"unknown task {task_id}")
302
322
  if task.session_id != session_id:
303
323
  raise RemoteCommandError(f"task {task_id} does not belong to session {session_id}")
324
+ self._check_task_owner(task, owner_agent_id, force=force)
304
325
  if task.remote_pid is None:
305
326
  raise RemoteCommandError(f"task {task_id} has no recorded remote pid")
306
327
  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)
328
+ result = self.run_command(session_id, kill_script, timeout=10, owner_agent_id=owner_agent_id)
308
329
  outcome = result.output.strip().splitlines()[-1] if result.output.strip() else "unknown"
309
330
  self._emit(
310
331
  "task_stopped",
@@ -322,6 +343,8 @@ class SessionManager:
322
343
  *,
323
344
  timeout: int = DEFAULT_COMMAND_TIMEOUT,
324
345
  token_factory: TokenFactory = _new_token,
346
+ owner_agent_id: str | None = None,
347
+ output_limit: int = MAX_OUTPUT_CHARS,
325
348
  ) -> CommandResult:
326
349
  if not command.strip():
327
350
  raise RemoteCommandError("command must not be empty")
@@ -336,6 +359,7 @@ class SessionManager:
336
359
  )
337
360
  raise PolicyDeniedError(decision.reason)
338
361
  session = self._get_session(session_id)
362
+ self._check_owner(session, owner_agent_id)
339
363
  if session.busy:
340
364
  raise RemoteCommandError(f"session {session_id} is busy")
341
365
  if not session.child.isalive():
@@ -351,7 +375,7 @@ class SessionManager:
351
375
  session.child.expect(exit_pattern, timeout=timeout)
352
376
  output = _extract_between_markers(str(session.child.before), start_marker)
353
377
  exit_code = _parse_exit_marker(session.child.after, token)
354
- result = CommandResult(session.id, session.host_id, command, exit_code, _tail(output))
378
+ result = CommandResult(session.id, session.host_id, command, exit_code, _tail(output, max(1, output_limit)))
355
379
  self._emit(
356
380
  "command_run",
357
381
  session_id=session.id,
@@ -381,6 +405,7 @@ class SessionManager:
381
405
  command: str,
382
406
  *,
383
407
  token_factory: TokenFactory = _new_token,
408
+ owner_agent_id: str | None = None,
384
409
  ) -> RemoteTask:
385
410
  if not command.strip():
386
411
  raise RemoteCommandError("command must not be empty")
@@ -406,7 +431,7 @@ class SessionManager:
406
431
  'date -u +%Y-%m-%dT%H:%M:%SZ > "$job_dir/finished_at") & '
407
432
  'pid=$!; printf "%s\\n" "$pid" > "$job_dir/pid"; printf "%s" "$pid"'
408
433
  )
409
- result = self.run_command(session_id, script, timeout=10, token_factory=lambda: f"task_{task_id}")
434
+ result = self.run_command(session_id, script, timeout=10, token_factory=lambda: f"task_{task_id}", owner_agent_id=owner_agent_id)
410
435
  pid_match = re.search(r"(\d+)", result.output)
411
436
  task = RemoteTask(
412
437
  id=task_id,
@@ -415,17 +440,26 @@ class SessionManager:
415
440
  command=command,
416
441
  job_dir=job_dir,
417
442
  remote_pid=int(pid_match.group(1)) if pid_match else None,
443
+ owner_agent_id=owner_agent_id,
418
444
  )
419
445
  self._tasks[task.id] = task
420
446
  self._emit("task_started", session_id=session_id, task_id=task.id, host_id=task.host_id, command=command, outcome="ok")
421
447
  return task
422
448
 
423
- def task_status(self, session_id: str, task_id: str, *, tail_lines: int = 80) -> dict[str, object]:
449
+ def task_status(
450
+ self,
451
+ session_id: str,
452
+ task_id: str,
453
+ *,
454
+ tail_lines: int = 80,
455
+ owner_agent_id: str | None = None,
456
+ ) -> dict[str, object]:
424
457
  task = self._tasks.get(task_id)
425
458
  if task is None:
426
459
  raise RemoteCommandError(f"unknown task {task_id}")
427
460
  if task.session_id != session_id:
428
461
  raise RemoteCommandError(f"task {task_id} does not belong to session {session_id}")
462
+ self._check_task_owner(task, owner_agent_id)
429
463
  safe_tail_lines = max(1, min(int(tail_lines), 500))
430
464
  script = (
431
465
  f'job_dir="{task.job_dir}"; '
@@ -436,7 +470,7 @@ class SessionManager:
436
470
  'printf "STATE=%s\\nPID=%s\\nEXIT=%s\\n---LOG---\\n" "$state" "$pid" "$exit_code"; '
437
471
  f'tail -n {safe_tail_lines} "$job_dir/stdout.log" 2>/dev/null || true'
438
472
  )
439
- result = self.run_command(session_id, script, timeout=15)
473
+ result = self.run_command(session_id, script, timeout=15, owner_agent_id=owner_agent_id)
440
474
  metadata, _, log = result.output.partition("---LOG---\n")
441
475
  fields: dict[str, str] = {}
442
476
  for line in metadata.splitlines():
@@ -456,14 +490,243 @@ class SessionManager:
456
490
  "log_tail": log,
457
491
  }
458
492
 
459
- def write(self, session_id: str, text: str) -> dict[str, object]:
493
+
494
+ def file_write_text(
495
+ self,
496
+ session_id: str,
497
+ remote_path: str,
498
+ content: str,
499
+ *,
500
+ mode: str = "0644",
501
+ max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
502
+ token_factory: TokenFactory = _new_token,
503
+ owner_agent_id: str | None = None,
504
+ ) -> dict[str, object]:
505
+ data = content.encode("utf-8")
506
+ return self._upload_bytes(
507
+ session_id,
508
+ data,
509
+ remote_path,
510
+ mode=mode,
511
+ max_bytes=max_bytes,
512
+ token_factory=token_factory,
513
+ owner_agent_id=owner_agent_id,
514
+ )
515
+
516
+ def file_read_text(
517
+ self,
518
+ session_id: str,
519
+ remote_path: str,
520
+ *,
521
+ max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
522
+ token_factory: TokenFactory = _new_token,
523
+ owner_agent_id: str | None = None,
524
+ ) -> dict[str, object]:
525
+ result = self._download_bytes(
526
+ session_id,
527
+ remote_path,
528
+ max_bytes=max_bytes,
529
+ token_factory=token_factory,
530
+ owner_agent_id=owner_agent_id,
531
+ )
532
+ data = cast(bytes, result.pop("data_bytes"))
533
+ result["content"] = data.decode("utf-8")
534
+ return result
535
+
536
+ def file_upload(
537
+ self,
538
+ session_id: str,
539
+ local_path: Path | str,
540
+ remote_path: str,
541
+ *,
542
+ mode: str = "0644",
543
+ max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
544
+ token_factory: TokenFactory = _new_token,
545
+ owner_agent_id: str | None = None,
546
+ ) -> dict[str, object]:
547
+ path = Path(local_path).expanduser()
548
+ if not path.is_absolute():
549
+ raise RemoteCommandError("local_path must be absolute")
550
+ if not path.is_file():
551
+ raise RemoteCommandError(f"local file not found: {path}")
552
+ size = path.stat().st_size
553
+ self._check_transfer_size(size, max_bytes)
554
+ return self._upload_bytes(
555
+ session_id,
556
+ path.read_bytes(),
557
+ remote_path,
558
+ mode=mode,
559
+ max_bytes=max_bytes,
560
+ token_factory=token_factory,
561
+ owner_agent_id=owner_agent_id,
562
+ local_path=str(path),
563
+ )
564
+
565
+ def file_download(
566
+ self,
567
+ session_id: str,
568
+ remote_path: str,
569
+ local_path: Path | str,
570
+ *,
571
+ max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
572
+ token_factory: TokenFactory = _new_token,
573
+ owner_agent_id: str | None = None,
574
+ ) -> dict[str, object]:
575
+ path = Path(local_path).expanduser()
576
+ if not path.is_absolute():
577
+ raise RemoteCommandError("local_path must be absolute")
578
+ result = self._download_bytes(
579
+ session_id,
580
+ remote_path,
581
+ max_bytes=max_bytes,
582
+ token_factory=token_factory,
583
+ owner_agent_id=owner_agent_id,
584
+ )
585
+ data = cast(bytes, result.pop("data_bytes"))
586
+ path.parent.mkdir(parents=True, exist_ok=True)
587
+ path.write_bytes(data)
588
+ result["local_path"] = str(path)
589
+ return result
590
+
591
+ def _upload_bytes(
592
+ self,
593
+ session_id: str,
594
+ data: bytes,
595
+ remote_path: str,
596
+ *,
597
+ mode: str,
598
+ max_bytes: int,
599
+ token_factory: TokenFactory,
600
+ owner_agent_id: str | None,
601
+ local_path: str | None = None,
602
+ ) -> dict[str, object]:
603
+ self._check_transfer_size(len(data), max_bytes)
604
+ self._validate_remote_path(remote_path)
605
+ b64 = base64.b64encode(data).decode("ascii")
606
+ sha256 = hashlib.sha256(data).hexdigest()
607
+ token = token_factory()
608
+ remote_q = shlex.quote(remote_path)
609
+ mode_q = shlex.quote(mode)
610
+ heredoc = f"__HOSTBRIDGE_B64_{token}__"
611
+ script = (
612
+ "set -e; "
613
+ f"target={remote_q}; mode={mode_q}; "
614
+ 'dir=$(dirname "$target"); mkdir -p "$dir"; '
615
+ 'tmp_b64=$(mktemp "${TMPDIR:-/tmp}/hostbridge.XXXXXX.b64"); '
616
+ 'tmp_out=$(mktemp "${TMPDIR:-/tmp}/hostbridge.XXXXXX.out"); '
617
+ f"cat > \"$tmp_b64\" <<'{heredoc}'\n{b64}\n{heredoc}\n"
618
+ 'base64 -d "$tmp_b64" > "$tmp_out"; '
619
+ 'chmod "$mode" "$tmp_out"; mv "$tmp_out" "$target"; rm -f "$tmp_b64"; '
620
+ f"printf 'BYTES=%s\\nSHA256=%s\\n' {len(data)} {shlex.quote(sha256)}"
621
+ )
622
+ result = self.run_command(session_id, script, timeout=60, token_factory=lambda: token, owner_agent_id=owner_agent_id)
623
+ if result.exit_code != 0:
624
+ raise RemoteCommandError(result.output or f"failed to upload {remote_path}")
625
+ payload: dict[str, object] = {
626
+ "session_id": session_id,
627
+ "remote_path": remote_path,
628
+ "bytes": len(data),
629
+ "sha256": sha256,
630
+ "strategy": "shell_base64",
631
+ }
632
+ if local_path is not None:
633
+ payload["local_path"] = local_path
634
+ self._emit("file_uploaded", session_id=session_id, command=remote_path, outcome=f"bytes={len(data)}")
635
+ return payload
636
+
637
+ def _download_bytes(
638
+ self,
639
+ session_id: str,
640
+ remote_path: str,
641
+ *,
642
+ max_bytes: int,
643
+ token_factory: TokenFactory,
644
+ owner_agent_id: str | None,
645
+ ) -> dict[str, object]:
646
+ self._validate_remote_path(remote_path)
647
+ token = token_factory()
648
+ remote_q = shlex.quote(remote_path)
649
+ script = (
650
+ "set -e; "
651
+ f"target={remote_q}; "
652
+ 'bytes=$(wc -c < "$target" | tr -d " "); '
653
+ f"if [ \"$bytes\" -gt {int(max_bytes)} ]; then echo 'FILE_TOO_LARGE remote pull required' >&2; exit 73; fi; "
654
+ "if command -v sha256sum >/dev/null 2>&1; then sha=$(sha256sum \"$target\" | awk '{print $1}'); "
655
+ "else sha=$(shasum -a 256 \"$target\" | awk '{print $1}'); fi; "
656
+ "printf 'BYTES=%s\\nSHA256=%s\\n---BASE64---\\n' \"$bytes\" \"$sha\"; base64 \"$target\""
657
+ )
658
+ output_limit = max(MAX_OUTPUT_CHARS, int(max_bytes) * 2 + 4096)
659
+ result = self.run_command(
660
+ session_id,
661
+ script,
662
+ timeout=60,
663
+ token_factory=lambda: token,
664
+ owner_agent_id=owner_agent_id,
665
+ output_limit=output_limit,
666
+ )
667
+ if result.exit_code == 73:
668
+ self._raise_remote_pull_required(max_bytes)
669
+ if result.exit_code != 0:
670
+ raise RemoteCommandError(result.output or f"failed to download {remote_path}")
671
+ metadata, sep, encoded = result.output.partition("---BASE64---\n")
672
+ if not sep:
673
+ raise RemoteCommandError("download response missing base64 marker")
674
+ fields: dict[str, str] = {}
675
+ for line in metadata.splitlines():
676
+ key, _, value = line.partition("=")
677
+ if key:
678
+ fields[key] = value
679
+ data = base64.b64decode("".join(encoded.split()))
680
+ byte_count = int(fields.get("BYTES") or len(data))
681
+ self._check_transfer_size(byte_count, max_bytes)
682
+ sha256 = hashlib.sha256(data).hexdigest()
683
+ remote_sha = fields.get("SHA256") or sha256
684
+ if remote_sha != "ignored" and sha256 != remote_sha:
685
+ raise RemoteCommandError("download checksum mismatch")
686
+ self._emit("file_downloaded", session_id=session_id, command=remote_path, outcome=f"bytes={len(data)}")
687
+ return {
688
+ "session_id": session_id,
689
+ "remote_path": remote_path,
690
+ "bytes": len(data),
691
+ "sha256": remote_sha,
692
+ "strategy": "shell_base64",
693
+ "data_bytes": data,
694
+ }
695
+
696
+ def _validate_remote_path(self, remote_path: str) -> None:
697
+ if not remote_path or remote_path.strip() in {"", "/"}:
698
+ raise RemoteCommandError("remote_path must not be empty or root")
699
+
700
+ def _check_transfer_size(self, size: int, max_bytes: int) -> None:
701
+ if size > int(max_bytes):
702
+ self._raise_remote_pull_required(max_bytes)
703
+
704
+ def _raise_remote_pull_required(self, max_bytes: int) -> None:
705
+ raise RemoteCommandError(
706
+ f"file exceeds HostBridge shell transfer limit ({int(max_bytes)} bytes); use remote pull (curl/wget/modelscope/git) for large files"
707
+ )
708
+
709
+ def write(self, session_id: str, text: str, *, owner_agent_id: str | None = None) -> dict[str, object]:
460
710
  session = self._get_session(session_id)
711
+ self._check_owner(session, owner_agent_id)
712
+ if session.busy:
713
+ raise RemoteCommandError(f"session {session_id} is busy")
714
+ if not session.child.isalive():
715
+ raise RemoteCommandError(f"session {session_id} is not alive")
461
716
  session.child.sendline(text)
462
717
  session.last_used_at = time.time()
463
718
  return {"session_id": session_id, "written": True}
464
719
 
465
- def read(self, session_id: str, *, timeout: float = 1.0, max_chars: int = 20_000) -> dict[str, object]:
720
+ def read(
721
+ self,
722
+ session_id: str,
723
+ *,
724
+ timeout: float = 1.0,
725
+ max_chars: int = 20_000,
726
+ owner_agent_id: str | None = None,
727
+ ) -> dict[str, object]:
466
728
  session = self._get_session(session_id)
729
+ self._check_owner(session, owner_agent_id)
467
730
  chunks: list[str] = []
468
731
  deadline = time.time() + max(0.0, timeout)
469
732
  while len("".join(chunks)) < max_chars:
@@ -488,6 +751,25 @@ class SessionManager:
488
751
  except KeyError as exc:
489
752
  raise RemoteCommandError(f"unknown session {session_id}") from exc
490
753
 
754
+ def _check_owner(self, session: RemoteSession, owner_agent_id: str | None, *, force: bool = False) -> None:
755
+ if force or owner_agent_id is None or session.owner_agent_id is None:
756
+ return
757
+ if owner_agent_id != session.owner_agent_id:
758
+ raise RemoteCommandError(f"session {session.id} is owned by another agent")
759
+
760
+ def _check_task_owner(self, task: RemoteTask, owner_agent_id: str | None, *, force: bool = False) -> None:
761
+ if force or owner_agent_id is None or task.owner_agent_id is None:
762
+ return
763
+ if owner_agent_id != task.owner_agent_id:
764
+ raise RemoteCommandError(f"task {task.id} is owned by another agent")
765
+
766
+ def _check_host_session_limit(self, host_id: str) -> None:
767
+ if self._max_sessions_per_host <= 0:
768
+ return
769
+ open_count = sum(1 for session in self._sessions.values() if session.host_id == host_id and session.child.isalive())
770
+ if open_count >= self._max_sessions_per_host:
771
+ raise RemoteCommandError(f"host {host_id} reached session limit {self._max_sessions_per_host}")
772
+
491
773
  def _build_remote_line(self, command: str, token: str, start_marker: str) -> str:
492
774
  quoted_command = shlex.quote(command)
493
775
  return (
@@ -507,6 +789,7 @@ class SessionManager:
507
789
  "args": session.args,
508
790
  "alive": session.child.isalive(),
509
791
  "busy": session.busy,
792
+ "owner_agent_id": session.owner_agent_id,
510
793
  "created_at": session.created_at,
511
794
  "last_used_at": session.last_used_at,
512
795
  }