@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.
- package/README.md +46 -2
- package/examples/claude_desktop_config.json +4 -1
- package/examples/codex.config.toml +2 -2
- package/package.json +1 -1
- package/pyproject.toml +2 -2
- package/src/server_control_mcp/__init__.py +1 -1
- package/src/server_control_mcp/__main__.py +6 -3
- package/src/server_control_mcp/cli.py +52 -11
- package/src/server_control_mcp/daemon.py +420 -0
- package/src/server_control_mcp/manager.py +374 -11
- package/src/server_control_mcp/server.py +149 -115
|
@@ -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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
}
|