@neoline/hostbridge 1.4.4 → 2.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,925 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import base64
4
- import hashlib
5
- import re
6
- import shlex
7
- import threading
8
- import time
9
- import uuid
10
- from collections.abc import Callable
11
- from contextlib import suppress
12
- from dataclasses import dataclass, field
13
- from pathlib import Path
14
- from typing import Protocol, cast
15
-
16
- import pexpect
17
-
18
- from .hosts import HostRegistry
19
- from .policy import AuditSink, PolicyConfig, PolicyDecision, audit_event
20
-
21
- DEFAULT_CONNECT_TIMEOUT = 30
22
- DEFAULT_COMMAND_TIMEOUT = 60
23
- DEFAULT_REAPER_INTERVAL_SECONDS = 60
24
- MAX_OUTPUT_CHARS = 120_000
25
- REMOTE_JOB_ROOT = "$HOME/.hostbridge/jobs"
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
31
- REMOTE_BASE64_DECODE_STDIN_CMD = "{ base64 --decode 2>/dev/null || base64 -d 2>/dev/null || base64 -D; }"
32
- REMOTE_BASE64_ENCODE_CMD = "{ base64 --wrap=0 2>/dev/null || base64 | tr -d '\\n'; }"
33
-
34
- class PolicyDeniedError(RuntimeError):
35
- """Raised when a command is blocked by the configured policy."""
36
-
37
-
38
- class ChildProcess(Protocol):
39
- before: str
40
- after: object
41
-
42
- def sendline(self, line: str) -> None: ...
43
-
44
- def send(self, text: str) -> None: ...
45
-
46
- def expect(self, patterns: object, timeout: int | float = -1) -> int: ...
47
-
48
- def isalive(self) -> bool: ...
49
-
50
- def close(self, force: bool = False) -> None: ...
51
-
52
- def read_nonblocking(self, size: int = 1, timeout: int | float = -1) -> str: ...
53
-
54
-
55
- ChildFactory = Callable[[str, list[str], int | float], ChildProcess]
56
- TokenFactory = Callable[[], str]
57
-
58
-
59
- class RemoteCommandError(RuntimeError):
60
- """Raised when a remote session cannot execute a requested operation."""
61
-
62
-
63
- @dataclass(slots=True)
64
- class RemoteSession:
65
- id: str
66
- host_id: str
67
- label: str
68
- command: str
69
- args: list[str]
70
- child: ChildProcess
71
- created_at: float = field(default_factory=time.time)
72
- last_used_at: float = field(default_factory=time.time)
73
- busy: bool = False
74
- owner_agent_id: str | None = None
75
- last_output: str = ""
76
-
77
-
78
- @dataclass(slots=True)
79
- class CommandResult:
80
- session_id: str
81
- host_id: str
82
- command: str
83
- exit_code: int | None
84
- output: str
85
- timed_out: bool = False
86
-
87
-
88
- @dataclass(slots=True)
89
- class RemoteTask:
90
- id: str
91
- session_id: str
92
- host_id: str
93
- command: str
94
- job_dir: str
95
- remote_pid: int | None
96
- owner_agent_id: str | None = None
97
- created_at: float = field(default_factory=time.time)
98
-
99
-
100
- def _new_token() -> str:
101
- return uuid.uuid4().hex[:12]
102
-
103
-
104
- def _default_child_factory(command: str, args: list[str], timeout: int | float) -> ChildProcess:
105
- return pexpect.spawn(
106
- command,
107
- args,
108
- encoding="utf-8",
109
- codec_errors="replace",
110
- timeout=timeout,
111
- echo=False,
112
- )
113
-
114
-
115
- def _tail(value: str, limit: int = MAX_OUTPUT_CHARS) -> str:
116
- if len(value) <= limit:
117
- return value
118
- return value[-limit:]
119
-
120
-
121
- def _extract_between_markers(raw: str, start_marker: str) -> str:
122
- raw = raw.replace("\x1b[?2004h", "").replace("\x1b[?2004l", "")
123
- if start_marker in raw:
124
- raw = raw.split(start_marker, 1)[1]
125
- lines = raw.replace("\r\n", "\n").replace("\r", "\n").split("\n")
126
- cleaned = [
127
- line
128
- for line in lines
129
- if not line.startswith("printf '") and "__SCM_START_" not in line and "__SCM_EXIT_" not in line
130
- ]
131
- return "\n".join(cleaned).strip("\n")
132
-
133
-
134
- def _parse_exit_marker(marker: object, token: str) -> int | None:
135
- text = marker.group(0) if hasattr(marker, "group") else str(marker)
136
- match = re.search(rf"__SCM_EXIT_{re.escape(token)}:(\d+)", text)
137
- return int(match.group(1)) if match else None
138
-
139
-
140
- def _contains_terminal_control(text: str) -> bool:
141
- return "\x1b" in text or "\x07" in text or "\\x1b" in text or "\\u001b" in text
142
-
143
-
144
- def _looks_shell_prompt(text: str) -> bool:
145
- return re.search(r"[#$](?:\\s\*)?$", text.strip()) is not None
146
-
147
-
148
- def _runtime_expect_pattern(pattern: str) -> str:
149
- if _contains_terminal_control(pattern) and _looks_shell_prompt(pattern):
150
- return r"(?m)[^\r\n]*[#$]\s*$"
151
- return pattern
152
-
153
-
154
- class SessionManager:
155
- """Manages long-lived interactive PTYs and remote background jobs."""
156
-
157
- def __init__(
158
- self,
159
- hosts: HostRegistry,
160
- *,
161
- child_factory: ChildFactory = _default_child_factory,
162
- remote_shell: str = REMOTE_SHELL,
163
- policy: PolicyConfig | None = None,
164
- audit_sink: AuditSink | None = None,
165
- max_sessions_per_host: int = 0,
166
- ):
167
- self.hosts = hosts
168
- self.child_factory = child_factory
169
- self.remote_shell = remote_shell
170
- self._sessions: dict[str, RemoteSession] = {}
171
- self._tasks: dict[str, RemoteTask] = {}
172
- self._policy = policy
173
- self._audit = audit_sink
174
- self._max_sessions_per_host = max(0, int(max_sessions_per_host))
175
- self._reaper_stop = threading.Event()
176
- self._reaper_thread: threading.Thread | None = None
177
-
178
- @property
179
- def policy(self) -> PolicyConfig | None:
180
- return self._policy
181
-
182
- def start_idle_reaper(self, *, interval: int = DEFAULT_REAPER_INTERVAL_SECONDS) -> None:
183
- """Spawn a background thread that closes idle sessions past policy timeout."""
184
-
185
- if self._policy is None or not self._policy.enabled or self._policy.idle_timeout_seconds <= 0:
186
- return
187
- if self._reaper_thread is not None and self._reaper_thread.is_alive():
188
- return
189
- self._reaper_stop.clear()
190
- timeout = self._policy.idle_timeout_seconds
191
- thread = threading.Thread(
192
- target=self._reap_loop,
193
- kwargs={"interval": interval, "timeout": timeout},
194
- daemon=True,
195
- name="scm-idle-reaper",
196
- )
197
- self._reaper_thread = thread
198
- thread.start()
199
-
200
- def stop_idle_reaper(self) -> None:
201
- self._reaper_stop.set()
202
- thread = self._reaper_thread
203
- if thread is not None and thread.is_alive():
204
- thread.join(timeout=2.0)
205
- self._reaper_thread = None
206
-
207
- def _reap_loop(self, *, interval: int, timeout: int) -> None:
208
- while not self._reaper_stop.wait(interval):
209
- self._reap_idle(timeout)
210
-
211
- def _reap_idle(self, timeout: int) -> int:
212
- now = time.time()
213
- expired = [
214
- sid for sid, session in list(self._sessions.items())
215
- if not session.busy and (now - session.last_used_at) > timeout
216
- ]
217
- for sid in expired:
218
- self._safe_close(sid)
219
- self._emit("session_idle_closed", session_id=sid, detail=f"idle > {timeout}s")
220
- return len(expired)
221
-
222
- def _safe_close(self, session_id: str) -> None:
223
- session = self._sessions.pop(session_id, None)
224
- if session is not None:
225
- with suppress(Exception):
226
- session.child.close(force=True)
227
-
228
- def _emit(self, event: str, **fields: object) -> None:
229
- if self._audit is None:
230
- return
231
- audit_event(self._audit, event=event, **{k: v for k, v in fields.items() if v is not None}) # type: ignore[arg-type]
232
-
233
- def _check_policy(self, command: str) -> PolicyDecision | None:
234
- if self._policy is None:
235
- return None
236
- return self._policy.evaluate(command)
237
-
238
- def open_session(
239
- self,
240
- host_id: str,
241
- *,
242
- connect_timeout: int = DEFAULT_CONNECT_TIMEOUT,
243
- token_factory: TokenFactory = _new_token,
244
- owner_agent_id: str | None = None,
245
- ) -> RemoteSession:
246
- host = self.hosts.get(host_id)
247
- self._check_host_session_limit(host.id)
248
- child = self.child_factory(host.command, list(host.args), connect_timeout)
249
- token = token_factory()
250
- marker = f"__SCM_READY_{token}__"
251
- try:
252
- self._run_login_steps(child, host, connect_timeout)
253
- if getattr(host, "login_steps", []):
254
- self._wait_for_ready_prompt(child, host, connect_timeout)
255
- child.send(f"printf '\\n{marker}\\n'\r")
256
- child.expect(re.escape(marker), timeout=connect_timeout)
257
- except Exception as exc: # pexpect.TIMEOUT/EOF and fake child errors are surfaced uniformly.
258
- try:
259
- child.close(force=True)
260
- finally:
261
- raise RemoteCommandError(f"failed to establish ready shell for {host_id}: {exc}") from exc
262
-
263
- session = RemoteSession(
264
- id=uuid.uuid4().hex,
265
- host_id=host.id,
266
- label=host.label,
267
- command=host.command,
268
- args=list(host.args),
269
- child=child,
270
- owner_agent_id=owner_agent_id,
271
- last_output=_tail(str(child.before)),
272
- )
273
- self._sessions[session.id] = session
274
- self._emit("session_opened", session_id=session.id, host_id=host.id, outcome="ok")
275
- return session
276
-
277
- def _run_login_steps(self, child: ChildProcess, host: object, timeout: int) -> None:
278
- for step in getattr(host, "login_steps", []):
279
- child.expect(_runtime_expect_pattern(step.expect), timeout=timeout)
280
- if step.send_secret is not None:
281
- secrets = getattr(host, "secrets", {})
282
- if step.send_secret not in secrets:
283
- raise RemoteCommandError(f"login step references missing secret '{step.send_secret}'")
284
- self._send_login_text(child, secrets[step.send_secret])
285
- else:
286
- self._send_login_text(child, step.send or "")
287
-
288
- def _send_login_text(self, child: ChildProcess, text: str) -> None:
289
- # JumpServer-style menus often require carriage return rather than LF.
290
- child.send(f"{text}\r")
291
-
292
- def _wait_for_ready_prompt(self, child: ChildProcess, host: object, timeout: int) -> None:
293
- ready_expect = getattr(host, "ready_expect", None)
294
- pattern = ready_expect if isinstance(ready_expect, str) and ready_expect.strip() else r"(?m)[^\r\n]*[#$]\s*$"
295
- child.expect(_runtime_expect_pattern(pattern), timeout=timeout)
296
-
297
- def list_sessions(self) -> list[dict[str, object]]:
298
- return [self._describe_session(session) for session in self._sessions.values()]
299
-
300
- def close_all_sessions(self) -> dict[str, object]:
301
- """Close every live session. Used for graceful shutdown."""
302
-
303
- closed: list[str] = []
304
- for session_id in list(self._sessions.keys()):
305
- self._safe_close(session_id)
306
- self._emit("session_closed", session_id=session_id, outcome="shutdown")
307
- closed.append(session_id)
308
- return {"closed": closed, "count": len(closed)}
309
-
310
- def close_session(
311
- self,
312
- session_id: str,
313
- *,
314
- owner_agent_id: str | None = None,
315
- force: bool = False,
316
- ) -> dict[str, object]:
317
- session = self._get_session(session_id)
318
- self._check_owner(session, owner_agent_id, force=force)
319
- if session.busy and not force:
320
- raise RemoteCommandError(f"session {session_id} is busy; retry later or close with force")
321
- session.child.close(force=True)
322
- self._sessions.pop(session_id, None)
323
- self._emit("session_closed", session_id=session_id, host_id=session.host_id, outcome="forced" if force else "user")
324
- return {"session_id": session_id, "closed": True}
325
-
326
- def stop_task(self, session_id: str, task_id: str, *, owner_agent_id: str | None = None, force: bool = False) -> dict[str, object]:
327
- """Best-effort terminate a background task by killing its remote PID."""
328
-
329
- task = self._tasks.get(task_id)
330
- if task is None:
331
- raise RemoteCommandError(f"unknown task {task_id}")
332
- if task.session_id != session_id:
333
- raise RemoteCommandError(f"task {task_id} does not belong to session {session_id}")
334
- self._check_task_owner(task, owner_agent_id, force=force)
335
- if task.remote_pid is None:
336
- raise RemoteCommandError(f"task {task_id} has no recorded remote pid")
337
- 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'
338
- result = self.run_command(session_id, kill_script, timeout=10, owner_agent_id=owner_agent_id)
339
- outcome = result.output.strip().splitlines()[-1] if result.output.strip() else "unknown"
340
- self._emit(
341
- "task_stopped",
342
- session_id=session_id,
343
- task_id=task_id,
344
- host_id=task.host_id,
345
- outcome=outcome,
346
- )
347
- return {"task_id": task_id, "stopped": outcome in ("killed", "already"), "outcome": outcome}
348
-
349
- def run_command(
350
- self,
351
- session_id: str,
352
- command: str,
353
- *,
354
- timeout: int = DEFAULT_COMMAND_TIMEOUT,
355
- token_factory: TokenFactory = _new_token,
356
- owner_agent_id: str | None = None,
357
- output_limit: int = MAX_OUTPUT_CHARS,
358
- ) -> CommandResult:
359
- if not command.strip():
360
- raise RemoteCommandError("command must not be empty")
361
- decision = self._check_policy(command)
362
- if decision is not None and not decision.allowed:
363
- self._emit(
364
- "command_denied",
365
- session_id=session_id,
366
- command=command,
367
- outcome=decision.reason,
368
- detail=decision.matched_pattern,
369
- )
370
- raise PolicyDeniedError(decision.reason)
371
- session = self._get_session(session_id)
372
- self._check_owner(session, owner_agent_id)
373
- if session.busy:
374
- raise RemoteCommandError(f"session {session_id} is busy")
375
- if not session.child.isalive():
376
- raise RemoteCommandError(f"session {session_id} is not alive")
377
-
378
- token = token_factory()
379
- start_marker = f"__SCM_START_{token}__"
380
- exit_pattern = re.compile(rf"__SCM_EXIT_{re.escape(token)}:(\d+)")
381
- remote_line = self._build_remote_line(command, token, start_marker)
382
- session.busy = True
383
- try:
384
- session.child.sendline(remote_line)
385
- session.child.expect(exit_pattern, timeout=timeout)
386
- output = _extract_between_markers(str(session.child.before), start_marker)
387
- exit_code = _parse_exit_marker(session.child.after, token)
388
- result = CommandResult(session.id, session.host_id, command, exit_code, _tail(output, max(1, output_limit)))
389
- self._emit(
390
- "command_run",
391
- session_id=session.id,
392
- host_id=session.host_id,
393
- command=command,
394
- outcome=f"exit={exit_code}",
395
- )
396
- except pexpect.TIMEOUT:
397
- output = _tail(str(session.child.before))
398
- result = CommandResult(session.id, session.host_id, command, None, output, timed_out=True)
399
- self._emit(
400
- "command_run",
401
- session_id=session.id,
402
- host_id=session.host_id,
403
- command=command,
404
- outcome="timeout",
405
- )
406
- finally:
407
- session.busy = False
408
- session.last_used_at = time.time()
409
- session.last_output = _tail(str(session.child.before))
410
- return result
411
-
412
- def start_task(
413
- self,
414
- session_id: str,
415
- command: str,
416
- *,
417
- token_factory: TokenFactory = _new_token,
418
- owner_agent_id: str | None = None,
419
- ) -> RemoteTask:
420
- if not command.strip():
421
- raise RemoteCommandError("command must not be empty")
422
- decision = self._check_policy(command)
423
- if decision is not None and not decision.allowed:
424
- self._emit(
425
- "task_denied",
426
- session_id=session_id,
427
- command=command,
428
- outcome=decision.reason,
429
- detail=decision.matched_pattern,
430
- )
431
- raise PolicyDeniedError(decision.reason)
432
- task_id = token_factory()
433
- job_dir = f"{REMOTE_JOB_ROOT}/{task_id}"
434
- quoted_command = shlex.quote(command)
435
- script = (
436
- f'job_dir="{job_dir}"; '
437
- 'mkdir -p "$job_dir"; '
438
- 'date -u +%Y-%m-%dT%H:%M:%SZ > "$job_dir/started_at"; '
439
- f'({self.remote_shell} -lc {quoted_command} > "$job_dir/stdout.log" 2>&1; '
440
- 'status=$?; printf "%s\\n" "$status" > "$job_dir/exit_code"; '
441
- 'date -u +%Y-%m-%dT%H:%M:%SZ > "$job_dir/finished_at") & '
442
- 'pid=$!; printf "%s\\n" "$pid" > "$job_dir/pid"; printf "%s" "$pid"'
443
- )
444
- result = self.run_command(session_id, script, timeout=10, token_factory=lambda: f"task_{task_id}", owner_agent_id=owner_agent_id)
445
- pid_match = re.search(r"(\d+)", result.output)
446
- task = RemoteTask(
447
- id=task_id,
448
- session_id=session_id,
449
- host_id=self._get_session(session_id).host_id,
450
- command=command,
451
- job_dir=job_dir,
452
- remote_pid=int(pid_match.group(1)) if pid_match else None,
453
- owner_agent_id=owner_agent_id,
454
- )
455
- self._tasks[task.id] = task
456
- self._emit("task_started", session_id=session_id, task_id=task.id, host_id=task.host_id, command=command, outcome="ok")
457
- return task
458
-
459
- def task_status(
460
- self,
461
- session_id: str,
462
- task_id: str,
463
- *,
464
- tail_lines: int = 80,
465
- owner_agent_id: str | None = None,
466
- ) -> dict[str, object]:
467
- task = self._tasks.get(task_id)
468
- if task is None:
469
- raise RemoteCommandError(f"unknown task {task_id}")
470
- if task.session_id != session_id:
471
- raise RemoteCommandError(f"task {task_id} does not belong to session {session_id}")
472
- self._check_task_owner(task, owner_agent_id)
473
- safe_tail_lines = max(1, min(int(tail_lines), 500))
474
- script = (
475
- f'job_dir="{task.job_dir}"; '
476
- 'pid=$(cat "$job_dir/pid" 2>/dev/null || true); '
477
- 'if [ -f "$job_dir/exit_code" ]; then state=finished; exit_code=$(cat "$job_dir/exit_code"); '
478
- 'elif [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then state=running; exit_code=""; '
479
- 'else state=unknown; exit_code=""; fi; '
480
- 'printf "STATE=%s\\nPID=%s\\nEXIT=%s\\n---LOG---\\n" "$state" "$pid" "$exit_code"; '
481
- f'tail -n {safe_tail_lines} "$job_dir/stdout.log" 2>/dev/null || true'
482
- )
483
- result = self.run_command(session_id, script, timeout=15, owner_agent_id=owner_agent_id)
484
- metadata, _, log = result.output.partition("---LOG---\n")
485
- fields: dict[str, str] = {}
486
- for line in metadata.splitlines():
487
- key, sep, value = line.partition("=")
488
- if sep:
489
- fields[key] = value
490
- exit_text = fields.get("EXIT") or ""
491
- return {
492
- "task_id": task.id,
493
- "session_id": session_id,
494
- "host_id": task.host_id,
495
- "command": task.command,
496
- "job_dir": task.job_dir,
497
- "state": fields.get("STATE", "unknown"),
498
- "pid": fields.get("PID") or task.remote_pid,
499
- "exit_code": int(exit_text) if exit_text.isdigit() else None,
500
- "log_tail": log,
501
- }
502
-
503
-
504
- def file_write_text(
505
- self,
506
- session_id: str,
507
- remote_path: str,
508
- content: str,
509
- *,
510
- mode: str = "0644",
511
- max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
512
- token_factory: TokenFactory = _new_token,
513
- owner_agent_id: str | None = None,
514
- ) -> dict[str, object]:
515
- data = content.encode("utf-8")
516
- return self._upload_bytes(
517
- session_id,
518
- data,
519
- remote_path,
520
- mode=mode,
521
- max_bytes=max_bytes,
522
- token_factory=token_factory,
523
- owner_agent_id=owner_agent_id,
524
- )
525
-
526
- def file_read_text(
527
- self,
528
- session_id: str,
529
- remote_path: str,
530
- *,
531
- max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
532
- token_factory: TokenFactory = _new_token,
533
- owner_agent_id: str | None = None,
534
- ) -> dict[str, object]:
535
- result = self._download_bytes(
536
- session_id,
537
- remote_path,
538
- max_bytes=max_bytes,
539
- token_factory=token_factory,
540
- owner_agent_id=owner_agent_id,
541
- )
542
- data = cast(bytes, result.pop("data_bytes"))
543
- result["content"] = data.decode("utf-8")
544
- return result
545
-
546
- def file_write_bytes(
547
- self,
548
- session_id: str,
549
- remote_path: str,
550
- data: bytes,
551
- *,
552
- mode: str = "0644",
553
- max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
554
- token_factory: TokenFactory = _new_token,
555
- owner_agent_id: str | None = None,
556
- ) -> dict[str, object]:
557
- return self._upload_bytes(
558
- session_id,
559
- data,
560
- remote_path,
561
- mode=mode,
562
- max_bytes=max_bytes,
563
- token_factory=token_factory,
564
- owner_agent_id=owner_agent_id,
565
- )
566
-
567
- def file_read_bytes(
568
- self,
569
- session_id: str,
570
- remote_path: str,
571
- *,
572
- max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
573
- token_factory: TokenFactory = _new_token,
574
- owner_agent_id: str | None = None,
575
- ) -> dict[str, object]:
576
- return self._download_bytes(
577
- session_id,
578
- remote_path,
579
- max_bytes=max_bytes,
580
- token_factory=token_factory,
581
- owner_agent_id=owner_agent_id,
582
- )
583
-
584
- def file_upload(
585
- self,
586
- session_id: str,
587
- local_path: Path | str,
588
- remote_path: str,
589
- *,
590
- mode: str = "0644",
591
- max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
592
- token_factory: TokenFactory = _new_token,
593
- owner_agent_id: str | None = None,
594
- ) -> dict[str, object]:
595
- path = Path(local_path).expanduser()
596
- if not path.is_absolute():
597
- raise RemoteCommandError("local_path must be absolute")
598
- if not path.is_file():
599
- raise RemoteCommandError(f"local file not found: {path}")
600
- size = path.stat().st_size
601
- self._check_transfer_size(size, max_bytes)
602
- return self._upload_bytes(
603
- session_id,
604
- path.read_bytes(),
605
- remote_path,
606
- mode=mode,
607
- max_bytes=max_bytes,
608
- token_factory=token_factory,
609
- owner_agent_id=owner_agent_id,
610
- local_path=str(path),
611
- )
612
-
613
- def file_download(
614
- self,
615
- session_id: str,
616
- remote_path: str,
617
- local_path: Path | str,
618
- *,
619
- max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
620
- token_factory: TokenFactory = _new_token,
621
- owner_agent_id: str | None = None,
622
- ) -> dict[str, object]:
623
- path = Path(local_path).expanduser()
624
- if not path.is_absolute():
625
- raise RemoteCommandError("local_path must be absolute")
626
- result = self._download_bytes(
627
- session_id,
628
- remote_path,
629
- max_bytes=max_bytes,
630
- token_factory=token_factory,
631
- owner_agent_id=owner_agent_id,
632
- )
633
- data = cast(bytes, result.pop("data_bytes"))
634
- path.parent.mkdir(parents=True, exist_ok=True)
635
- path.write_bytes(data)
636
- result["local_path"] = str(path)
637
- return result
638
-
639
- def _upload_bytes(
640
- self,
641
- session_id: str,
642
- data: bytes,
643
- remote_path: str,
644
- *,
645
- mode: str,
646
- max_bytes: int,
647
- token_factory: TokenFactory,
648
- owner_agent_id: str | None,
649
- local_path: str | None = None,
650
- ) -> dict[str, object]:
651
- self._check_transfer_size(len(data), max_bytes)
652
- self._validate_remote_path(remote_path)
653
- b64 = base64.b64encode(data).decode("ascii")
654
- sha256 = hashlib.sha256(data).hexdigest()
655
- token = token_factory()
656
- remote_q = shlex.quote(remote_path)
657
- mode_q = shlex.quote(mode)
658
- tmp_prefix = f"/tmp/hostbridge.upload.{token}"
659
- tmp_b64 = f"{tmp_prefix}.b64"
660
- tmp_out = f"{tmp_prefix}.out"
661
- cleanup_script = f"rm -f {shlex.quote(tmp_b64)} {shlex.quote(tmp_out)}"
662
- init_script = (
663
- "set -e; "
664
- f"target={remote_q}; "
665
- 'dir=$(dirname "$target"); mkdir -p "$dir"; '
666
- f"rm -f {shlex.quote(tmp_b64)} {shlex.quote(tmp_out)}; "
667
- f": > {shlex.quote(tmp_b64)}"
668
- )
669
- result = self.run_command(session_id, init_script, timeout=60, token_factory=lambda: token, owner_agent_id=owner_agent_id)
670
- if result.exit_code != 0:
671
- raise RemoteCommandError(result.output or f"failed to prepare upload for {remote_path}")
672
- try:
673
- self._stream_base64_to_remote_file(
674
- session_id,
675
- b64,
676
- tmp_b64,
677
- token=token,
678
- owner_agent_id=owner_agent_id,
679
- )
680
- finalize_script = (
681
- "set -e; "
682
- f"target={remote_q}; mode={mode_q}; tmp_b64={shlex.quote(tmp_b64)}; tmp_out={shlex.quote(tmp_out)}; "
683
- '{ base64 --decode < "$tmp_b64" 2>/dev/null || base64 -d < "$tmp_b64" 2>/dev/null || base64 -D < "$tmp_b64"; } > "$tmp_out"; '
684
- 'chmod "$mode" "$tmp_out"; mv "$tmp_out" "$target"; rm -f "$tmp_b64"; '
685
- f"printf 'BYTES=%s\\nSHA256=%s\\n' {len(data)} {shlex.quote(sha256)}"
686
- )
687
- result = self.run_command(
688
- session_id,
689
- finalize_script,
690
- timeout=60,
691
- token_factory=lambda: token,
692
- owner_agent_id=owner_agent_id,
693
- )
694
- if result.exit_code != 0:
695
- raise RemoteCommandError(result.output or f"failed to finalize upload for {remote_path}")
696
- except Exception:
697
- with suppress(Exception):
698
- self.run_command(session_id, cleanup_script, timeout=10, token_factory=lambda: token, owner_agent_id=owner_agent_id)
699
- raise
700
- payload: dict[str, object] = {
701
- "session_id": session_id,
702
- "remote_path": remote_path,
703
- "bytes": len(data),
704
- "sha256": sha256,
705
- "strategy": "shell_base64_chunked",
706
- }
707
- if local_path is not None:
708
- payload["local_path"] = local_path
709
- self._emit("file_uploaded", session_id=session_id, command=remote_path, outcome=f"bytes={len(data)}")
710
- return payload
711
-
712
- def _stream_base64_to_remote_file(
713
- self,
714
- session_id: str,
715
- encoded: str,
716
- remote_tmp_b64: str,
717
- *,
718
- token: str,
719
- owner_agent_id: str | None,
720
- ) -> None:
721
- session = self._get_session(session_id)
722
- self._check_owner(session, owner_agent_id)
723
- if session.busy:
724
- raise RemoteCommandError(f"session {session_id} is busy")
725
- if not session.child.isalive():
726
- raise RemoteCommandError(f"session {session_id} is not alive")
727
-
728
- heredoc = f"__HOSTBRIDGE_UPLOAD_{token}__"
729
- done_pattern = re.compile(rf"__HOSTBRIDGE_UPLOAD_DONE_{re.escape(token)}__:(\d+)")
730
- chunk_size = max(4, int(UPLOAD_BASE64_CHUNK_CHARS))
731
- session.busy = True
732
- try:
733
- session.child.send(f"cat >> {shlex.quote(remote_tmp_b64)} <<'{heredoc}'\n")
734
- for offset in range(0, len(encoded), chunk_size):
735
- session.child.send(encoded[offset : offset + chunk_size])
736
- session.child.send(f"\n{heredoc}\nprintf '\\n__HOSTBRIDGE_UPLOAD_DONE_{token}__:%s\\n' \"$?\"\n")
737
- session.child.expect(done_pattern, timeout=60)
738
- marker = session.child.after
739
- text = marker.group(0) if hasattr(marker, "group") else str(marker)
740
- match = re.search(r":(\d+)", text)
741
- if match is None or int(match.group(1)) != 0:
742
- raise RemoteCommandError(f"failed to stream upload chunk for {session_id}")
743
- finally:
744
- session.busy = False
745
- session.last_used_at = time.time()
746
- session.last_output = _tail(str(session.child.before))
747
-
748
- def _download_bytes(
749
- self,
750
- session_id: str,
751
- remote_path: str,
752
- *,
753
- max_bytes: int,
754
- token_factory: TokenFactory,
755
- owner_agent_id: str | None,
756
- ) -> dict[str, object]:
757
- self._validate_remote_path(remote_path)
758
- token = token_factory()
759
- remote_q = shlex.quote(remote_path)
760
- metadata_script = (
761
- "set -e; "
762
- f"target={remote_q}; "
763
- 'bytes=$(wc -c < "$target" | tr -d " "); '
764
- f"if [ \"$bytes\" -gt {int(max_bytes)} ]; then echo 'FILE_TOO_LARGE remote pull required' >&2; exit 73; fi; "
765
- "if command -v sha256sum >/dev/null 2>&1; then sha=$(sha256sum \"$target\" | awk '{print $1}'); "
766
- "else sha=$(shasum -a 256 \"$target\" | awk '{print $1}'); fi; "
767
- "printf 'BYTES=%s\\nSHA256=%s\\n' \"$bytes\" \"$sha\""
768
- )
769
- result = self.run_command(
770
- session_id,
771
- metadata_script,
772
- timeout=60,
773
- token_factory=lambda: token,
774
- owner_agent_id=owner_agent_id,
775
- )
776
- if result.exit_code == 73:
777
- self._raise_remote_pull_required(max_bytes)
778
- if result.exit_code != 0:
779
- raise RemoteCommandError(result.output or f"failed to download {remote_path}")
780
- fields: dict[str, str] = {}
781
- for line in result.output.splitlines():
782
- key, _, value = line.partition("=")
783
- if key:
784
- fields[key] = value
785
- byte_count = int(fields.get("BYTES") or 0)
786
- self._check_transfer_size(byte_count, max_bytes)
787
- chunks: list[bytes] = []
788
- chunk_size = max(1, int(DOWNLOAD_CHUNK_BYTES))
789
- for index in range((byte_count + chunk_size - 1) // chunk_size):
790
- chunk_script = (
791
- "set -e; "
792
- f"dd if={remote_q} bs={chunk_size} skip={index} count=1 2>/dev/null | {REMOTE_BASE64_ENCODE_CMD}"
793
- )
794
- output_limit = max(MAX_OUTPUT_CHARS, chunk_size * 2 + 4096)
795
- result = self.run_command(
796
- session_id,
797
- chunk_script,
798
- timeout=60,
799
- token_factory=lambda: token,
800
- owner_agent_id=owner_agent_id,
801
- output_limit=output_limit,
802
- )
803
- if result.exit_code != 0:
804
- raise RemoteCommandError(result.output or f"failed to download chunk {index} from {remote_path}")
805
- chunks.append(base64.b64decode("".join(result.output.split())))
806
- data = b"".join(chunks)
807
- sha256 = hashlib.sha256(data).hexdigest()
808
- remote_sha = fields.get("SHA256") or sha256
809
- if remote_sha != "ignored" and sha256 != remote_sha:
810
- raise RemoteCommandError("download checksum mismatch")
811
- self._emit("file_downloaded", session_id=session_id, command=remote_path, outcome=f"bytes={len(data)}")
812
- return {
813
- "session_id": session_id,
814
- "remote_path": remote_path,
815
- "bytes": len(data),
816
- "sha256": remote_sha,
817
- "strategy": "shell_base64_chunked",
818
- "data_bytes": data,
819
- }
820
-
821
- def _validate_remote_path(self, remote_path: str) -> None:
822
- if not remote_path or remote_path.strip() in {"", "/"}:
823
- raise RemoteCommandError("remote_path must not be empty or root")
824
-
825
- def _check_transfer_size(self, size: int, max_bytes: int) -> None:
826
- if size > int(max_bytes):
827
- self._raise_remote_pull_required(max_bytes)
828
-
829
- def _raise_remote_pull_required(self, max_bytes: int) -> None:
830
- raise RemoteCommandError(
831
- f"file exceeds HostBridge shell transfer limit ({int(max_bytes)} bytes); use remote pull (curl/wget/modelscope/git) for large files"
832
- )
833
-
834
- def write(self, session_id: str, text: str, *, owner_agent_id: str | None = None) -> dict[str, object]:
835
- return self.send(session_id, f"{text}\n", owner_agent_id=owner_agent_id)
836
-
837
- def send(self, session_id: str, text: str, *, owner_agent_id: str | None = None) -> dict[str, object]:
838
- session = self._get_session(session_id)
839
- self._check_owner(session, owner_agent_id)
840
- if session.busy:
841
- raise RemoteCommandError(f"session {session_id} is busy")
842
- if not session.child.isalive():
843
- raise RemoteCommandError(f"session {session_id} is not alive")
844
- session.child.send(text)
845
- session.last_used_at = time.time()
846
- return {"session_id": session_id, "written": True}
847
-
848
- def read(
849
- self,
850
- session_id: str,
851
- *,
852
- timeout: float = 1.0,
853
- max_chars: int = 20_000,
854
- owner_agent_id: str | None = None,
855
- ) -> dict[str, object]:
856
- session = self._get_session(session_id)
857
- self._check_owner(session, owner_agent_id)
858
- chunks: list[str] = []
859
- deadline = time.time() + max(0.0, timeout)
860
- while len("".join(chunks)) < max_chars:
861
- remaining = max(0.0, deadline - time.time())
862
- if remaining <= 0 and chunks:
863
- break
864
- try:
865
- chunk = session.child.read_nonblocking(size=4096, timeout=remaining)
866
- except (pexpect.TIMEOUT, pexpect.EOF):
867
- break
868
- if not chunk:
869
- break
870
- chunks.append(str(chunk))
871
- output = _tail("".join(chunks), max_chars)
872
- session.last_output = output or session.last_output
873
- session.last_used_at = time.time()
874
- return {"session_id": session_id, "output": output, "alive": session.child.isalive()}
875
-
876
- def _get_session(self, session_id: str) -> RemoteSession:
877
- try:
878
- return self._sessions[session_id]
879
- except KeyError as exc:
880
- raise RemoteCommandError(f"unknown session {session_id}") from exc
881
-
882
- def _check_owner(self, session: RemoteSession, owner_agent_id: str | None, *, force: bool = False) -> None:
883
- if force or owner_agent_id is None or session.owner_agent_id is None:
884
- return
885
- if owner_agent_id != session.owner_agent_id:
886
- raise RemoteCommandError(f"session {session.id} is owned by another agent")
887
-
888
- def _check_task_owner(self, task: RemoteTask, owner_agent_id: str | None, *, force: bool = False) -> None:
889
- if force or owner_agent_id is None or task.owner_agent_id is None:
890
- return
891
- if owner_agent_id != task.owner_agent_id:
892
- raise RemoteCommandError(f"task {task.id} is owned by another agent")
893
-
894
- def _check_host_session_limit(self, host_id: str) -> None:
895
- if self._max_sessions_per_host <= 0:
896
- return
897
- open_count = sum(1 for session in self._sessions.values() if session.host_id == host_id and session.child.isalive())
898
- if open_count >= self._max_sessions_per_host:
899
- raise RemoteCommandError(f"host {host_id} reached session limit {self._max_sessions_per_host}")
900
-
901
- def _build_remote_line(self, command: str, token: str, start_marker: str) -> str:
902
- encoded_command = base64.b64encode(command.encode("utf-8")).decode("ascii")
903
- return (
904
- f"__scm_cmd=$(printf %s {shlex.quote(encoded_command)} | "
905
- f"{REMOTE_BASE64_DECODE_STDIN_CMD}); "
906
- f"printf '\\n{start_marker}\\n'; "
907
- f"{self.remote_shell} -lc \"$__scm_cmd\"; "
908
- "__scm_status=$?; "
909
- f"printf '\\n__SCM_EXIT_{token}:%s\\n' \"$__scm_status\""
910
- )
911
-
912
- @staticmethod
913
- def _describe_session(session: RemoteSession) -> dict[str, object]:
914
- return {
915
- "session_id": session.id,
916
- "host_id": session.host_id,
917
- "label": session.label,
918
- "command": session.command,
919
- "args": session.args,
920
- "alive": session.child.isalive(),
921
- "busy": session.busy,
922
- "owner_agent_id": session.owner_agent_id,
923
- "created_at": session.created_at,
924
- "last_used_at": session.last_used_at,
925
- }