@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.
- package/README.md +24 -137
- package/package.json +1 -1
- package/pyproject.toml +2 -1
- package/src/server_control_mcp/__init__.py +4 -5
- package/src/server_control_mcp/cli.py +229 -1231
- package/src/server_control_mcp/client.py +468 -0
- package/src/server_control_mcp/config.py +139 -0
- package/src/server_control_mcp/daemon.py +387 -306
- package/src/server_control_mcp/doctor.py +108 -0
- package/src/server_control_mcp/hosts.py +52 -7
- package/src/server_control_mcp/mock_ssh.py +177 -87
- package/src/server_control_mcp/policy.py +2 -0
- package/src/server_control_mcp/protocol.py +362 -0
- package/src/server_control_mcp/runtime.py +40 -0
- package/src/server_control_mcp/server.py +132 -188
- package/src/server_control_mcp/services.py +508 -0
- package/src/server_control_mcp/transports/__init__.py +17 -0
- package/src/server_control_mcp/transports/base.py +78 -0
- package/src/server_control_mcp/transports/pty.py +405 -0
- package/src/server_control_mcp/transports/ssh.py +195 -0
- package/src/server_control_mcp/manager.py +0 -925
|
@@ -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
|
-
}
|