@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.
- 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 +1 -1
- 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 +51 -11
- package/src/server_control_mcp/daemon.py +419 -0
- package/src/server_control_mcp/manager.py +294 -11
- package/src/server_control_mcp/server.py +144 -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,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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
}
|