@neoline/hostbridge 1.0.0 → 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 +6 -6
- package/package.json +1 -1
- package/pyproject.toml +2 -2
- package/src/server_control_mcp/__init__.py +1 -1
- package/src/server_control_mcp/cli.py +3 -2
- package/src/server_control_mcp/daemon.py +5 -4
- package/src/server_control_mcp/manager.py +105 -25
- package/src/server_control_mcp/server.py +13 -8
package/README.md
CHANGED
|
@@ -17,10 +17,10 @@ This repo is portable: it ships with no personal server paths or built-in hosts.
|
|
|
17
17
|
- `task_stop(session_id, task_id)` — terminate a running background job and reap its process.
|
|
18
18
|
- `session_write(session_id, text)` — send a raw line for interactive edge cases.
|
|
19
19
|
- `session_read(session_id, timeout=1.0, max_chars=20000)` — read available raw output after interactive writes.
|
|
20
|
-
- `file_write_text(session_id, remote_path, content)` — write a
|
|
21
|
-
- `file_read_text(session_id, remote_path)` — read a
|
|
22
|
-
- `file_upload(session_id, local_path, remote_path)` — upload a
|
|
23
|
-
- `file_download(session_id, remote_path, local_path)` — download a
|
|
20
|
+
- `file_write_text(session_id, remote_path, content)` — write a UTF-8 text file through the active shell, up to 100MiB.
|
|
21
|
+
- `file_read_text(session_id, remote_path)` — read a UTF-8 text file through the active shell, up to 100MiB.
|
|
22
|
+
- `file_upload(session_id, local_path, remote_path)` — upload a local file with chunked shell/base64 transfer, up to 100MiB.
|
|
23
|
+
- `file_download(session_id, remote_path, local_path)` — download a remote file with chunked shell/base64 transfer, up to 100MiB.
|
|
24
24
|
- `session_close(session_id)` — close a persistent session.
|
|
25
25
|
|
|
26
26
|
Every tool returns a structured payload with a stable `error_code` on failure (`HOST_NOT_FOUND`, `SESSION_NOT_FOUND`, `POLICY_DENIED`, `TASK_NOT_FOUND`, `TIMEOUT`, `INTERNAL`).
|
|
@@ -78,14 +78,14 @@ npm run test:node
|
|
|
78
78
|
|
|
79
79
|
## File transfer model
|
|
80
80
|
|
|
81
|
-
HostBridge file transfer is shell-based because recorded login paths may be JumpServer menus, OTP flows, wrapper scripts, or `docker exec` sessions rather than standard SSH/SFTP connections. The first-class transfer tools
|
|
81
|
+
HostBridge file transfer is shell-based because recorded login paths may be JumpServer menus, OTP flows, wrapper scripts, or `docker exec` sessions rather than standard SSH/SFTP connections. The first-class transfer tools stream chunked base64 through the already-open shell session, verify hashes, and work for text or binary artifacts up to 100MiB:
|
|
82
82
|
|
|
83
83
|
```bash
|
|
84
84
|
file_upload(session_id, "/absolute/local/script.py", "/remote/work/script.py")
|
|
85
85
|
file_download(session_id, "/remote/work/result.log", "/absolute/local/result.log")
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
Give file tools local and remote paths; do not paste base64 into tool arguments. Files larger than 100MiB are deliberately rejected with guidance to use remote pull instead, for example `curl`, `wget`, `git clone`, `modelscope download`, or another remote-native downloader. HostBridge does not plan a standard SSH/SFTP fast path; the reliable fallback for non-standard login flows is shell/base64, and large model/data movement should happen from the remote host.
|
|
89
89
|
|
|
90
90
|
## Daemon lifecycle
|
|
91
91
|
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hostbridge"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.1.0"
|
|
8
8
|
description = "HostBridge MCP server for persistent allowlisted SSH and shell sessions."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -86,4 +86,4 @@ python_version = "3.11"
|
|
|
86
86
|
ignore_missing_imports = true
|
|
87
87
|
warn_unused_ignores = true
|
|
88
88
|
warn_redundant_casts = true
|
|
89
|
-
files = ["src/server_control_mcp"]
|
|
89
|
+
files = ["src/server_control_mcp"]
|
|
@@ -38,8 +38,9 @@ Use HostBridge when the user asks to connect to, inspect, or operate an allowlis
|
|
|
38
38
|
- Do not run `hostbridge check` before every connection; only use `hostbridge check <host_id>` when `session_open` times out or fails.
|
|
39
39
|
- Do not search shell history, SSH config, or old scripts unless the setup flow lacks required information or the user explicitly asks.
|
|
40
40
|
- Keep long-running work in `task_start`/`task_status`; use `session_close` or `sessions_close_all` when done.
|
|
41
|
-
- Use `file_write_text`/`file_read_text` for
|
|
42
|
-
-
|
|
41
|
+
- Use `file_write_text`/`file_read_text` for UTF-8 files and `file_upload`/`file_download` for binary artifacts up to 100MiB; these tools stream through the already-open shell and work across JumpServer/menu/sudo flows where SFTP may not exist.
|
|
42
|
+
- For files larger than 100MiB, do not use HostBridge file tools; start a remote pull instead (`curl`, `wget`, `git clone`, `modelscope download`, etc.) and poll it with `task_status`.
|
|
43
|
+
- Do not ask the user to provide base64 content. Give file tools local/remote paths; HostBridge handles chunking, encoding, hashing, and cleanup internally.
|
|
43
44
|
{HOSTBRIDGE_GUIDANCE_END}
|
|
44
45
|
"""
|
|
45
46
|
SUPPORTED_INSTALL_TARGETS = (
|
|
@@ -19,6 +19,7 @@ from .hosts import HostRegistry, load_hosts
|
|
|
19
19
|
from .manager import (
|
|
20
20
|
DEFAULT_COMMAND_TIMEOUT,
|
|
21
21
|
DEFAULT_CONNECT_TIMEOUT,
|
|
22
|
+
DEFAULT_MAX_TRANSFER_BYTES,
|
|
22
23
|
PolicyDeniedError,
|
|
23
24
|
RemoteCommandError,
|
|
24
25
|
SessionManager,
|
|
@@ -173,7 +174,7 @@ class HostBridgeDaemonHandler(StreamRequestHandler):
|
|
|
173
174
|
str(body.get("remote_path", "")),
|
|
174
175
|
str(body.get("content", "")),
|
|
175
176
|
mode=str(body.get("mode", "0644")),
|
|
176
|
-
max_bytes=int(body.get("max_bytes",
|
|
177
|
+
max_bytes=int(body.get("max_bytes", DEFAULT_MAX_TRANSFER_BYTES)),
|
|
177
178
|
owner_agent_id=owner,
|
|
178
179
|
token_factory=token_factory,
|
|
179
180
|
)
|
|
@@ -183,7 +184,7 @@ class HostBridgeDaemonHandler(StreamRequestHandler):
|
|
|
183
184
|
transfer=self.manager.file_read_text(
|
|
184
185
|
parts[1],
|
|
185
186
|
str(body.get("remote_path", "")),
|
|
186
|
-
max_bytes=int(body.get("max_bytes",
|
|
187
|
+
max_bytes=int(body.get("max_bytes", DEFAULT_MAX_TRANSFER_BYTES)),
|
|
187
188
|
owner_agent_id=owner,
|
|
188
189
|
token_factory=token_factory,
|
|
189
190
|
)
|
|
@@ -195,7 +196,7 @@ class HostBridgeDaemonHandler(StreamRequestHandler):
|
|
|
195
196
|
str(body.get("local_path", "")),
|
|
196
197
|
str(body.get("remote_path", "")),
|
|
197
198
|
mode=str(body.get("mode", "0644")),
|
|
198
|
-
max_bytes=int(body.get("max_bytes",
|
|
199
|
+
max_bytes=int(body.get("max_bytes", DEFAULT_MAX_TRANSFER_BYTES)),
|
|
199
200
|
owner_agent_id=owner,
|
|
200
201
|
token_factory=token_factory,
|
|
201
202
|
)
|
|
@@ -206,7 +207,7 @@ class HostBridgeDaemonHandler(StreamRequestHandler):
|
|
|
206
207
|
parts[1],
|
|
207
208
|
str(body.get("remote_path", "")),
|
|
208
209
|
str(body.get("local_path", "")),
|
|
209
|
-
max_bytes=int(body.get("max_bytes",
|
|
210
|
+
max_bytes=int(body.get("max_bytes", DEFAULT_MAX_TRANSFER_BYTES)),
|
|
210
211
|
owner_agent_id=owner,
|
|
211
212
|
token_factory=token_factory,
|
|
212
213
|
)
|
|
@@ -24,7 +24,10 @@ DEFAULT_REAPER_INTERVAL_SECONDS = 60
|
|
|
24
24
|
MAX_OUTPUT_CHARS = 120_000
|
|
25
25
|
REMOTE_JOB_ROOT = "$HOME/.hostbridge/jobs"
|
|
26
26
|
REMOTE_SHELL = "bash"
|
|
27
|
-
|
|
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
|
|
28
31
|
|
|
29
32
|
class PolicyDeniedError(RuntimeError):
|
|
30
33
|
"""Raised when a command is blocked by the configured policy."""
|
|
@@ -607,33 +610,96 @@ class SessionManager:
|
|
|
607
610
|
token = token_factory()
|
|
608
611
|
remote_q = shlex.quote(remote_path)
|
|
609
612
|
mode_q = shlex.quote(mode)
|
|
610
|
-
|
|
611
|
-
|
|
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 = (
|
|
612
618
|
"set -e; "
|
|
613
|
-
f"target={remote_q};
|
|
619
|
+
f"target={remote_q}; "
|
|
614
620
|
'dir=$(dirname "$target"); mkdir -p "$dir"; '
|
|
615
|
-
|
|
616
|
-
|
|
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
|
+
f"rm -f {shlex.quote(tmp_b64)} {shlex.quote(tmp_out)}; "
|
|
622
|
+
f": > {shlex.quote(tmp_b64)}"
|
|
621
623
|
)
|
|
622
|
-
result = self.run_command(session_id,
|
|
624
|
+
result = self.run_command(session_id, init_script, timeout=60, token_factory=lambda: token, owner_agent_id=owner_agent_id)
|
|
623
625
|
if result.exit_code != 0:
|
|
624
|
-
raise RemoteCommandError(result.output or f"failed to upload {remote_path}")
|
|
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
|
|
625
655
|
payload: dict[str, object] = {
|
|
626
656
|
"session_id": session_id,
|
|
627
657
|
"remote_path": remote_path,
|
|
628
658
|
"bytes": len(data),
|
|
629
659
|
"sha256": sha256,
|
|
630
|
-
"strategy": "
|
|
660
|
+
"strategy": "shell_base64_chunked",
|
|
631
661
|
}
|
|
632
662
|
if local_path is not None:
|
|
633
663
|
payload["local_path"] = local_path
|
|
634
664
|
self._emit("file_uploaded", session_id=session_id, command=remote_path, outcome=f"bytes={len(data)}")
|
|
635
665
|
return payload
|
|
636
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
|
+
|
|
637
703
|
def _download_bytes(
|
|
638
704
|
self,
|
|
639
705
|
session_id: str,
|
|
@@ -646,39 +712,53 @@ class SessionManager:
|
|
|
646
712
|
self._validate_remote_path(remote_path)
|
|
647
713
|
token = token_factory()
|
|
648
714
|
remote_q = shlex.quote(remote_path)
|
|
649
|
-
|
|
715
|
+
metadata_script = (
|
|
650
716
|
"set -e; "
|
|
651
717
|
f"target={remote_q}; "
|
|
652
718
|
'bytes=$(wc -c < "$target" | tr -d " "); '
|
|
653
719
|
f"if [ \"$bytes\" -gt {int(max_bytes)} ]; then echo 'FILE_TOO_LARGE remote pull required' >&2; exit 73; fi; "
|
|
654
720
|
"if command -v sha256sum >/dev/null 2>&1; then sha=$(sha256sum \"$target\" | awk '{print $1}'); "
|
|
655
721
|
"else sha=$(shasum -a 256 \"$target\" | awk '{print $1}'); fi; "
|
|
656
|
-
"printf 'BYTES=%s\\nSHA256=%s\\n
|
|
722
|
+
"printf 'BYTES=%s\\nSHA256=%s\\n' \"$bytes\" \"$sha\""
|
|
657
723
|
)
|
|
658
|
-
output_limit = max(MAX_OUTPUT_CHARS, int(max_bytes) * 2 + 4096)
|
|
659
724
|
result = self.run_command(
|
|
660
725
|
session_id,
|
|
661
|
-
|
|
726
|
+
metadata_script,
|
|
662
727
|
timeout=60,
|
|
663
728
|
token_factory=lambda: token,
|
|
664
729
|
owner_agent_id=owner_agent_id,
|
|
665
|
-
output_limit=output_limit,
|
|
666
730
|
)
|
|
667
731
|
if result.exit_code == 73:
|
|
668
732
|
self._raise_remote_pull_required(max_bytes)
|
|
669
733
|
if result.exit_code != 0:
|
|
670
734
|
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
735
|
fields: dict[str, str] = {}
|
|
675
|
-
for line in
|
|
736
|
+
for line in result.output.splitlines():
|
|
676
737
|
key, _, value = line.partition("=")
|
|
677
738
|
if key:
|
|
678
739
|
fields[key] = value
|
|
679
|
-
|
|
680
|
-
byte_count = int(fields.get("BYTES") or len(data))
|
|
740
|
+
byte_count = int(fields.get("BYTES") or 0)
|
|
681
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)
|
|
682
762
|
sha256 = hashlib.sha256(data).hexdigest()
|
|
683
763
|
remote_sha = fields.get("SHA256") or sha256
|
|
684
764
|
if remote_sha != "ignored" and sha256 != remote_sha:
|
|
@@ -689,7 +769,7 @@ class SessionManager:
|
|
|
689
769
|
"remote_path": remote_path,
|
|
690
770
|
"bytes": len(data),
|
|
691
771
|
"sha256": remote_sha,
|
|
692
|
-
"strategy": "
|
|
772
|
+
"strategy": "shell_base64_chunked",
|
|
693
773
|
"data_bytes": data,
|
|
694
774
|
}
|
|
695
775
|
|
|
@@ -12,6 +12,7 @@ from .manager import (
|
|
|
12
12
|
DEFAULT_COMMAND_TIMEOUT,
|
|
13
13
|
DEFAULT_CONNECT_TIMEOUT,
|
|
14
14
|
DEFAULT_MAX_TRANSFER_BYTES,
|
|
15
|
+
MAX_SHELL_TRANSFER_BYTES,
|
|
15
16
|
PolicyDeniedError,
|
|
16
17
|
RemoteCommandError,
|
|
17
18
|
)
|
|
@@ -66,6 +67,10 @@ def _qid(value: str) -> str:
|
|
|
66
67
|
return quote(value, safe="")
|
|
67
68
|
|
|
68
69
|
|
|
70
|
+
def _safe_transfer_limit(max_bytes: int) -> int:
|
|
71
|
+
return max(1, min(int(max_bytes), MAX_SHELL_TRANSFER_BYTES))
|
|
72
|
+
|
|
73
|
+
|
|
69
74
|
@mcp.tool()
|
|
70
75
|
def hosts_list() -> dict[str, object]:
|
|
71
76
|
"""List approved connection targets and local metadata when available."""
|
|
@@ -202,9 +207,9 @@ def file_write_text(
|
|
|
202
207
|
mode: str = "0644",
|
|
203
208
|
max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
|
|
204
209
|
) -> dict[str, object]:
|
|
205
|
-
"""Write a
|
|
210
|
+
"""Write a UTF-8 text file through the active shell session; supports up to 100MiB via chunked base64."""
|
|
206
211
|
try:
|
|
207
|
-
safe_max =
|
|
212
|
+
safe_max = _safe_transfer_limit(max_bytes)
|
|
208
213
|
return _daemon_request(
|
|
209
214
|
"POST",
|
|
210
215
|
f"/sessions/{_qid(session_id)}/files",
|
|
@@ -216,9 +221,9 @@ def file_write_text(
|
|
|
216
221
|
|
|
217
222
|
@mcp.tool()
|
|
218
223
|
def file_read_text(session_id: str, remote_path: str, max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES) -> dict[str, object]:
|
|
219
|
-
"""Read a
|
|
224
|
+
"""Read a UTF-8 text file through the active shell session; supports up to 100MiB via chunked base64."""
|
|
220
225
|
try:
|
|
221
|
-
safe_max =
|
|
226
|
+
safe_max = _safe_transfer_limit(max_bytes)
|
|
222
227
|
return _daemon_request(
|
|
223
228
|
"POST",
|
|
224
229
|
f"/sessions/{_qid(session_id)}/files",
|
|
@@ -236,9 +241,9 @@ def file_upload(
|
|
|
236
241
|
mode: str = "0644",
|
|
237
242
|
max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
|
|
238
243
|
) -> dict[str, object]:
|
|
239
|
-
"""Upload a
|
|
244
|
+
"""Upload a local file through the active shell session; supports up to 100MiB via chunked base64."""
|
|
240
245
|
try:
|
|
241
|
-
safe_max =
|
|
246
|
+
safe_max = _safe_transfer_limit(max_bytes)
|
|
242
247
|
return _daemon_request(
|
|
243
248
|
"POST",
|
|
244
249
|
f"/sessions/{_qid(session_id)}/files",
|
|
@@ -255,9 +260,9 @@ def file_download(
|
|
|
255
260
|
local_path: str,
|
|
256
261
|
max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
|
|
257
262
|
) -> dict[str, object]:
|
|
258
|
-
"""Download a
|
|
263
|
+
"""Download a remote file through the active shell session; supports up to 100MiB via chunked base64."""
|
|
259
264
|
try:
|
|
260
|
-
safe_max =
|
|
265
|
+
safe_max = _safe_transfer_limit(max_bytes)
|
|
261
266
|
return _daemon_request(
|
|
262
267
|
"POST",
|
|
263
268
|
f"/sessions/{_qid(session_id)}/files",
|