remote-cli 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- {remote_cli-0.1.0 → remote_cli-0.2.0}/PKG-INFO +19 -2
- {remote_cli-0.1.0 → remote_cli-0.2.0}/README.md +18 -1
- remote_cli-0.2.0/assets/remote_cli_ai_poster.png +0 -0
- {remote_cli-0.1.0 → remote_cli-0.2.0}/skills/remote-cli/SKILL.md +23 -1
- {remote_cli-0.1.0 → remote_cli-0.2.0}/src/remote_cli/_version.py +2 -2
- {remote_cli-0.1.0 → remote_cli-0.2.0}/src/remote_cli/cli.py +110 -3
- {remote_cli-0.1.0 → remote_cli-0.2.0}/src/remote_cli/client.py +36 -0
- {remote_cli-0.1.0 → remote_cli-0.2.0}/src/remote_cli/daemon.py +32 -2
- {remote_cli-0.1.0 → remote_cli-0.2.0}/src/remote_cli/session.py +84 -23
- remote_cli-0.2.0/src/remote_cli/transfer.py +298 -0
- {remote_cli-0.1.0 → remote_cli-0.2.0}/src/remote_cli/utils.py +12 -0
- {remote_cli-0.1.0 → remote_cli-0.2.0}/tests/test_cli.py +10 -0
- remote_cli-0.2.0/tests/test_transfer.py +246 -0
- {remote_cli-0.1.0 → remote_cli-0.2.0}/.github/workflows/publish.yml +0 -0
- {remote_cli-0.1.0 → remote_cli-0.2.0}/.gitignore +0 -0
- {remote_cli-0.1.0 → remote_cli-0.2.0}/.python-version +0 -0
- {remote_cli-0.1.0 → remote_cli-0.2.0}/LICENSE +0 -0
- {remote_cli-0.1.0 → remote_cli-0.2.0}/pyproject.toml +0 -0
- {remote_cli-0.1.0 → remote_cli-0.2.0}/src/remote_cli/__init__.py +0 -0
- {remote_cli-0.1.0 → remote_cli-0.2.0}/src/remote_cli/protocol.py +0 -0
- {remote_cli-0.1.0 → remote_cli-0.2.0}/src/remote_cli/screen.py +0 -0
- {remote_cli-0.1.0 → remote_cli-0.2.0}/src/remote_cli/terminal.py +0 -0
- {remote_cli-0.1.0 → remote_cli-0.2.0}/tests/test_daemon_and_client.py +0 -0
- {remote_cli-0.1.0 → remote_cli-0.2.0}/tests/test_e2e_cli.py +0 -0
- {remote_cli-0.1.0 → remote_cli-0.2.0}/tests/test_multiplexing.py +0 -0
- {remote_cli-0.1.0 → remote_cli-0.2.0}/tests/test_protocol.py +0 -0
- {remote_cli-0.1.0 → remote_cli-0.2.0}/tests/test_screen.py +0 -0
- {remote_cli-0.1.0 → remote_cli-0.2.0}/tests/test_session.py +0 -0
- {remote_cli-0.1.0 → remote_cli-0.2.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: remote-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A shared SSH CLI tool for AI Agent and human co-piloting
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Requires-Python: >=3.12
|
|
@@ -12,6 +12,10 @@ Description-Content-Type: text/markdown
|
|
|
12
12
|
|
|
13
13
|
# remote-cli
|
|
14
14
|
|
|
15
|
+
<p align="center">
|
|
16
|
+
<img src="assets/remote_cli_ai_poster.png" alt="remote-cli Poster" width="600">
|
|
17
|
+
</p>
|
|
18
|
+
|
|
15
19
|
> **Shared SSH & Terminal CLI tool for AI Agent and Human Co-piloting.**
|
|
16
20
|
|
|
17
21
|
`remote-cli` allows a human user to start an interactive SSH session (handling passwords, 2FA, bastion hosts, and SSH keys themselves) and share that session with an AI Agent via a unique `session-id`.
|
|
@@ -105,13 +109,17 @@ uv run remote-cli send s_7f8a9b2c "y"
|
|
|
105
109
|
|
|
106
110
|
# Agent sends Ctrl+C to interrupt a long-running process
|
|
107
111
|
uv run remote-cli send s_7f8a9b2c --ctrl-c
|
|
112
|
+
|
|
113
|
+
# Agent transfers files or directories (like docker cp)
|
|
114
|
+
uv run remote-cli cp ./app.tar.gz s_7f8a9b2c:/opt/
|
|
115
|
+
uv run remote-cli cp s_7f8a9b2c:/var/log/nginx/error.log ./logs/
|
|
108
116
|
```
|
|
109
117
|
|
|
110
118
|
---
|
|
111
119
|
|
|
112
120
|
### 3. Human Observation & Intervention
|
|
113
121
|
|
|
114
|
-
While the Agent is executing commands, the human user sees all command text and outputs scrolling in real time in their terminal window. If needed, the human can type commands directly into that same terminal window.
|
|
122
|
+
While the Agent is executing commands or transferring files, the human user sees all command text and outputs scrolling in real time in their terminal window. If needed, the human can type commands directly into that same terminal window.
|
|
115
123
|
|
|
116
124
|
---
|
|
117
125
|
|
|
@@ -125,11 +133,15 @@ While the Agent is executing commands, the human user sees all command text and
|
|
|
125
133
|
| `remote-cli session attach <ID>` (or `attach`) | Attach terminal in raw mode to existing session |
|
|
126
134
|
| `remote-cli session close <ID>` | Close and terminate a session |
|
|
127
135
|
| `remote-cli exec <ID> "<COMMAND>"` | Execute command in session, capture output & exit code |
|
|
136
|
+
| `remote-cli cp <SRC> <DEST>` | Transfer files/dirs between local & session (`./file s_xxx:/remote/`) |
|
|
137
|
+
| `remote-cli upload <ID> <LOCAL> <REMOTE>` | Upload local file or directory to remote session |
|
|
138
|
+
| `remote-cli download <ID> <REMOTE> <LOCAL>` | Download remote file or directory from session to local |
|
|
128
139
|
| `remote-cli send <ID> [TEXT]` | Send raw keystrokes or control keys (`--ctrl-c`, `--ctrl-d`) |
|
|
129
140
|
| `remote-cli snapshot <ID>` | Capture 2D terminal screen state (ANSI-rendered) |
|
|
130
141
|
| `remote-cli logs <ID> [-n LINES]` | View recent output scrollback logs |
|
|
131
142
|
| `remote-cli daemon start / stop / status` | Manage background daemon lifecycle |
|
|
132
143
|
|
|
144
|
+
|
|
133
145
|
---
|
|
134
146
|
|
|
135
147
|
## Architecture
|
|
@@ -149,14 +161,19 @@ While the Agent is executing commands, the human user sees all command text and
|
|
|
149
161
|
│ ┌────────────────────────────────────────────────────────┐ │
|
|
150
162
|
│ │ Session (e.g. s_7f8a9b2c) │ │
|
|
151
163
|
│ │ - Master/Slave PTY (`pty.openpty`) │ │
|
|
164
|
+
│ │ - SSH ControlMaster Socket (`~/.remote-cli/cm/*.sock`) │ │
|
|
152
165
|
│ │ - Pyte Virtual Terminal Screen (`pyte.HistoryScreen`) │ │
|
|
153
166
|
│ │ - Scrollback Ring Buffer │ │
|
|
154
167
|
│ │ - Exec Sentinel Detection Engine │ │
|
|
168
|
+
│ │ - Dual Transfer Engine: │ │
|
|
169
|
+
│ │ * Fast Path: Native OpenSSH ControlMaster SCP │ │
|
|
170
|
+
│ │ * Fallback: Universal In-Band Tar+Base64 Stream │ │
|
|
155
171
|
│ │ - Process: `ssh user@remote-server` (or local shell) │ │
|
|
156
172
|
│ └────────────────────────────────────────────────────────┘ │
|
|
157
173
|
└─────────────────────────────────────────────────────────────┘
|
|
158
174
|
```
|
|
159
175
|
|
|
176
|
+
|
|
160
177
|
---
|
|
161
178
|
|
|
162
179
|
## Running Tests
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# remote-cli
|
|
2
2
|
|
|
3
|
+
<p align="center">
|
|
4
|
+
<img src="assets/remote_cli_ai_poster.png" alt="remote-cli Poster" width="600">
|
|
5
|
+
</p>
|
|
6
|
+
|
|
3
7
|
> **Shared SSH & Terminal CLI tool for AI Agent and Human Co-piloting.**
|
|
4
8
|
|
|
5
9
|
`remote-cli` allows a human user to start an interactive SSH session (handling passwords, 2FA, bastion hosts, and SSH keys themselves) and share that session with an AI Agent via a unique `session-id`.
|
|
@@ -93,13 +97,17 @@ uv run remote-cli send s_7f8a9b2c "y"
|
|
|
93
97
|
|
|
94
98
|
# Agent sends Ctrl+C to interrupt a long-running process
|
|
95
99
|
uv run remote-cli send s_7f8a9b2c --ctrl-c
|
|
100
|
+
|
|
101
|
+
# Agent transfers files or directories (like docker cp)
|
|
102
|
+
uv run remote-cli cp ./app.tar.gz s_7f8a9b2c:/opt/
|
|
103
|
+
uv run remote-cli cp s_7f8a9b2c:/var/log/nginx/error.log ./logs/
|
|
96
104
|
```
|
|
97
105
|
|
|
98
106
|
---
|
|
99
107
|
|
|
100
108
|
### 3. Human Observation & Intervention
|
|
101
109
|
|
|
102
|
-
While the Agent is executing commands, the human user sees all command text and outputs scrolling in real time in their terminal window. If needed, the human can type commands directly into that same terminal window.
|
|
110
|
+
While the Agent is executing commands or transferring files, the human user sees all command text and outputs scrolling in real time in their terminal window. If needed, the human can type commands directly into that same terminal window.
|
|
103
111
|
|
|
104
112
|
---
|
|
105
113
|
|
|
@@ -113,11 +121,15 @@ While the Agent is executing commands, the human user sees all command text and
|
|
|
113
121
|
| `remote-cli session attach <ID>` (or `attach`) | Attach terminal in raw mode to existing session |
|
|
114
122
|
| `remote-cli session close <ID>` | Close and terminate a session |
|
|
115
123
|
| `remote-cli exec <ID> "<COMMAND>"` | Execute command in session, capture output & exit code |
|
|
124
|
+
| `remote-cli cp <SRC> <DEST>` | Transfer files/dirs between local & session (`./file s_xxx:/remote/`) |
|
|
125
|
+
| `remote-cli upload <ID> <LOCAL> <REMOTE>` | Upload local file or directory to remote session |
|
|
126
|
+
| `remote-cli download <ID> <REMOTE> <LOCAL>` | Download remote file or directory from session to local |
|
|
116
127
|
| `remote-cli send <ID> [TEXT]` | Send raw keystrokes or control keys (`--ctrl-c`, `--ctrl-d`) |
|
|
117
128
|
| `remote-cli snapshot <ID>` | Capture 2D terminal screen state (ANSI-rendered) |
|
|
118
129
|
| `remote-cli logs <ID> [-n LINES]` | View recent output scrollback logs |
|
|
119
130
|
| `remote-cli daemon start / stop / status` | Manage background daemon lifecycle |
|
|
120
131
|
|
|
132
|
+
|
|
121
133
|
---
|
|
122
134
|
|
|
123
135
|
## Architecture
|
|
@@ -137,14 +149,19 @@ While the Agent is executing commands, the human user sees all command text and
|
|
|
137
149
|
│ ┌────────────────────────────────────────────────────────┐ │
|
|
138
150
|
│ │ Session (e.g. s_7f8a9b2c) │ │
|
|
139
151
|
│ │ - Master/Slave PTY (`pty.openpty`) │ │
|
|
152
|
+
│ │ - SSH ControlMaster Socket (`~/.remote-cli/cm/*.sock`) │ │
|
|
140
153
|
│ │ - Pyte Virtual Terminal Screen (`pyte.HistoryScreen`) │ │
|
|
141
154
|
│ │ - Scrollback Ring Buffer │ │
|
|
142
155
|
│ │ - Exec Sentinel Detection Engine │ │
|
|
156
|
+
│ │ - Dual Transfer Engine: │ │
|
|
157
|
+
│ │ * Fast Path: Native OpenSSH ControlMaster SCP │ │
|
|
158
|
+
│ │ * Fallback: Universal In-Band Tar+Base64 Stream │ │
|
|
143
159
|
│ │ - Process: `ssh user@remote-server` (or local shell) │ │
|
|
144
160
|
│ └────────────────────────────────────────────────────────┘ │
|
|
145
161
|
└─────────────────────────────────────────────────────────────┘
|
|
146
162
|
```
|
|
147
163
|
|
|
164
|
+
|
|
148
165
|
---
|
|
149
166
|
|
|
150
167
|
## Running Tests
|
|
Binary file
|
|
@@ -126,7 +126,28 @@ remote-cli logs <session-id> --lines 50
|
|
|
126
126
|
|
|
127
127
|
---
|
|
128
128
|
|
|
129
|
-
### 3.5.
|
|
129
|
+
### 3.5. Transfer Files & Directories (`remote-cli cp` / `upload` / `download`)
|
|
130
|
+
|
|
131
|
+
Seamlessly transfers files and directories between the local machine and the remote server over the active session without requiring SFTP configuration or opening extra ports.
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
# Docker-style copy syntax:
|
|
135
|
+
# Upload local file/dir to remote server:
|
|
136
|
+
remote-cli cp ./dist/app.tar.gz <session-id>:/opt/app/
|
|
137
|
+
remote-cli cp ./config/ <session-id>:/opt/config/
|
|
138
|
+
|
|
139
|
+
# Download remote file/dir to local machine:
|
|
140
|
+
remote-cli cp <session-id>:/var/log/nginx/error.log ./logs/
|
|
141
|
+
remote-cli cp <session-id>:/var/log/nginx/ ./local_nginx_logs/
|
|
142
|
+
|
|
143
|
+
# Direct upload/download commands:
|
|
144
|
+
remote-cli upload <session-id> <local-path> <remote-path>
|
|
145
|
+
remote-cli download <session-id> <remote-path> <local-path>
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
### 3.6. List Active Sessions (`remote-cli session list` / `remote-cli ls`)
|
|
130
151
|
|
|
131
152
|
Discovers active and recent sessions.
|
|
132
153
|
|
|
@@ -134,6 +155,7 @@ Discovers active and recent sessions.
|
|
|
134
155
|
remote-cli ls
|
|
135
156
|
```
|
|
136
157
|
|
|
158
|
+
|
|
137
159
|
---
|
|
138
160
|
|
|
139
161
|
## 4. Agent Guidelines & Best Practices
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.2.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -134,10 +134,21 @@ def session_list():
|
|
|
134
134
|
|
|
135
135
|
|
|
136
136
|
@session_app.command("attach")
|
|
137
|
-
def session_attach(
|
|
138
|
-
|
|
137
|
+
def session_attach(
|
|
138
|
+
session_id: str | None = typer.Argument(
|
|
139
|
+
None, help="Target session ID (defaults to latest active session)"
|
|
140
|
+
),
|
|
141
|
+
):
|
|
142
|
+
"""Attach terminal to an existing session (like tmux attach)."""
|
|
139
143
|
ensure_daemon_running()
|
|
140
144
|
client = Client()
|
|
145
|
+
if not session_id:
|
|
146
|
+
active_sessions = [s for s in client.list_sessions() if s.status == "active"]
|
|
147
|
+
if not active_sessions:
|
|
148
|
+
err_console.print("[yellow]No active sessions to attach.[/yellow]")
|
|
149
|
+
raise typer.Exit(1)
|
|
150
|
+
session_id = active_sessions[-1].session_id
|
|
151
|
+
|
|
141
152
|
session = client.get_session(session_id)
|
|
142
153
|
if not session:
|
|
143
154
|
err_console.print(f"[red]Error:[/red] Session {session_id} not found.")
|
|
@@ -170,7 +181,11 @@ def ls_shortcut():
|
|
|
170
181
|
|
|
171
182
|
|
|
172
183
|
@app.command("attach")
|
|
173
|
-
def attach_shortcut(
|
|
184
|
+
def attach_shortcut(
|
|
185
|
+
session_id: str | None = typer.Argument(
|
|
186
|
+
None, help="Target session ID (defaults to latest active session)"
|
|
187
|
+
),
|
|
188
|
+
):
|
|
174
189
|
"""Shortcut for `session attach`."""
|
|
175
190
|
session_attach(session_id)
|
|
176
191
|
|
|
@@ -266,6 +281,98 @@ def logs_command(
|
|
|
266
281
|
raise typer.Exit(1) from None
|
|
267
282
|
|
|
268
283
|
|
|
284
|
+
@app.command("cp")
|
|
285
|
+
def cp_command(
|
|
286
|
+
src: str = typer.Argument(..., help="Source path (e.g. ./file or s_12345:/remote/path)"),
|
|
287
|
+
dest: str = typer.Argument(..., help="Destination path (e.g. s_12345:/remote/path or ./file)"),
|
|
288
|
+
):
|
|
289
|
+
"""Copy files or directories between local machine and remote session (like docker cp)."""
|
|
290
|
+
ensure_daemon_running()
|
|
291
|
+
client = Client()
|
|
292
|
+
|
|
293
|
+
is_download = ":" in src and not src.startswith("http://") and not src.startswith("https://")
|
|
294
|
+
is_upload = ":" in dest and not dest.startswith("http://") and not dest.startswith("https://")
|
|
295
|
+
|
|
296
|
+
if is_download and is_upload:
|
|
297
|
+
err_console.print(
|
|
298
|
+
"[red]Error:[/red] Direct session-to-session copy is not supported. Use a local staging path."
|
|
299
|
+
)
|
|
300
|
+
raise typer.Exit(1) from None
|
|
301
|
+
|
|
302
|
+
if not is_download and not is_upload:
|
|
303
|
+
err_console.print(
|
|
304
|
+
"[red]Error:[/red] One of SRC or DEST must include SESSION_ID (e.g. `remote-cli cp ./app.tar.gz s_12345:/opt/`)"
|
|
305
|
+
)
|
|
306
|
+
raise typer.Exit(1) from None
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
if is_download:
|
|
310
|
+
session_id, remote_path = src.split(":", 1)
|
|
311
|
+
with console.status(
|
|
312
|
+
f"[cyan]Downloading [bold]{remote_path}[/bold] from session {session_id}...[/cyan]"
|
|
313
|
+
):
|
|
314
|
+
client.download_file(session_id, remote_path, dest)
|
|
315
|
+
console.print(
|
|
316
|
+
f"[green]✓ Successfully downloaded [bold]{remote_path}[/bold] -> [bold]{dest}[/bold][/green]"
|
|
317
|
+
)
|
|
318
|
+
else:
|
|
319
|
+
session_id, remote_path = dest.split(":", 1)
|
|
320
|
+
with console.status(
|
|
321
|
+
f"[cyan]Uploading [bold]{src}[/bold] to session {session_id}:{remote_path}...[/cyan]"
|
|
322
|
+
):
|
|
323
|
+
client.upload_file(session_id, src, remote_path)
|
|
324
|
+
console.print(
|
|
325
|
+
f"[green]✓ Successfully uploaded [bold]{src}[/bold] -> [bold]{session_id}:{remote_path}[/bold][/green]"
|
|
326
|
+
)
|
|
327
|
+
except Exception as e:
|
|
328
|
+
err_console.print(f"[red]Copy error:[/red] {e}")
|
|
329
|
+
raise typer.Exit(1) from None
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@app.command("upload")
|
|
333
|
+
def upload_command(
|
|
334
|
+
session_id: str = typer.Argument(..., help="Target session ID"),
|
|
335
|
+
local_path: str = typer.Argument(..., help="Local file or directory path"),
|
|
336
|
+
remote_path: str = typer.Argument(..., help="Remote destination path on server"),
|
|
337
|
+
):
|
|
338
|
+
"""Upload a local file or directory to remote session."""
|
|
339
|
+
ensure_daemon_running()
|
|
340
|
+
client = Client()
|
|
341
|
+
try:
|
|
342
|
+
with console.status(
|
|
343
|
+
f"[cyan]Uploading [bold]{local_path}[/bold] to {session_id}:{remote_path}...[/cyan]"
|
|
344
|
+
):
|
|
345
|
+
client.upload_file(session_id, local_path, remote_path)
|
|
346
|
+
console.print(
|
|
347
|
+
f"[green]✓ Successfully uploaded [bold]{local_path}[/bold] -> [bold]{session_id}:{remote_path}[/bold][/green]"
|
|
348
|
+
)
|
|
349
|
+
except Exception as e:
|
|
350
|
+
err_console.print(f"[red]Upload error:[/red] {e}")
|
|
351
|
+
raise typer.Exit(1) from None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@app.command("download")
|
|
355
|
+
def download_command(
|
|
356
|
+
session_id: str = typer.Argument(..., help="Target session ID"),
|
|
357
|
+
remote_path: str = typer.Argument(..., help="Remote file or directory path on server"),
|
|
358
|
+
local_path: str = typer.Argument(..., help="Local destination path"),
|
|
359
|
+
):
|
|
360
|
+
"""Download a remote file or directory from session to local machine."""
|
|
361
|
+
ensure_daemon_running()
|
|
362
|
+
client = Client()
|
|
363
|
+
try:
|
|
364
|
+
with console.status(
|
|
365
|
+
f"[cyan]Downloading [bold]{remote_path}[/bold] from {session_id} to {local_path}...[/cyan]"
|
|
366
|
+
):
|
|
367
|
+
client.download_file(session_id, remote_path, local_path)
|
|
368
|
+
console.print(
|
|
369
|
+
f"[green]✓ Successfully downloaded [bold]{remote_path}[/bold] -> [bold]{local_path}[/bold][/green]"
|
|
370
|
+
)
|
|
371
|
+
except Exception as e:
|
|
372
|
+
err_console.print(f"[red]Download error:[/red] {e}")
|
|
373
|
+
raise typer.Exit(1) from None
|
|
374
|
+
|
|
375
|
+
|
|
269
376
|
@daemon_app.command("start")
|
|
270
377
|
def daemon_start():
|
|
271
378
|
"""Start the daemon process explicitly in foreground or background."""
|
|
@@ -162,3 +162,39 @@ class Client:
|
|
|
162
162
|
)
|
|
163
163
|
resp = self._send_request(req)
|
|
164
164
|
return resp.success
|
|
165
|
+
|
|
166
|
+
def upload_file(
|
|
167
|
+
self,
|
|
168
|
+
session_id: str,
|
|
169
|
+
local_path: str | Path,
|
|
170
|
+
remote_path: str,
|
|
171
|
+
progress_callback=None,
|
|
172
|
+
) -> None:
|
|
173
|
+
"""Uploads a local file or directory to remote_path in the session."""
|
|
174
|
+
from .transfer import TransferManager
|
|
175
|
+
|
|
176
|
+
tm = TransferManager(self)
|
|
177
|
+
tm.upload(
|
|
178
|
+
session_id=session_id,
|
|
179
|
+
local_path=local_path,
|
|
180
|
+
remote_path=remote_path,
|
|
181
|
+
progress_callback=progress_callback,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def download_file(
|
|
185
|
+
self,
|
|
186
|
+
session_id: str,
|
|
187
|
+
remote_path: str,
|
|
188
|
+
local_path: str | Path,
|
|
189
|
+
progress_callback=None,
|
|
190
|
+
) -> None:
|
|
191
|
+
"""Downloads a remote file or directory from the session to local_path."""
|
|
192
|
+
from .transfer import TransferManager
|
|
193
|
+
|
|
194
|
+
tm = TransferManager(self)
|
|
195
|
+
tm.download(
|
|
196
|
+
session_id=session_id,
|
|
197
|
+
remote_path=remote_path,
|
|
198
|
+
local_path=local_path,
|
|
199
|
+
progress_callback=progress_callback,
|
|
200
|
+
)
|
|
@@ -281,12 +281,42 @@ def ensure_daemon_running() -> None:
|
|
|
281
281
|
def stop_daemon() -> bool:
|
|
282
282
|
"""Stops the running daemon process."""
|
|
283
283
|
pid_path = get_pid_path()
|
|
284
|
+
sock_path = get_socket_path()
|
|
284
285
|
if not pid_path.exists():
|
|
286
|
+
if sock_path.exists():
|
|
287
|
+
try:
|
|
288
|
+
sock_path.unlink()
|
|
289
|
+
except Exception:
|
|
290
|
+
pass
|
|
285
291
|
return False
|
|
286
292
|
try:
|
|
287
293
|
pid = int(pid_path.read_text().strip())
|
|
288
|
-
|
|
289
|
-
|
|
294
|
+
try:
|
|
295
|
+
os.kill(pid, signal.SIGTERM)
|
|
296
|
+
for _ in range(20):
|
|
297
|
+
time.sleep(0.1)
|
|
298
|
+
try:
|
|
299
|
+
os.kill(pid, 0)
|
|
300
|
+
except OSError:
|
|
301
|
+
break
|
|
302
|
+
else:
|
|
303
|
+
try:
|
|
304
|
+
os.kill(pid, signal.SIGKILL)
|
|
305
|
+
except OSError:
|
|
306
|
+
pass
|
|
307
|
+
except OSError:
|
|
308
|
+
pass
|
|
309
|
+
|
|
310
|
+
if pid_path.exists():
|
|
311
|
+
try:
|
|
312
|
+
pid_path.unlink()
|
|
313
|
+
except Exception:
|
|
314
|
+
pass
|
|
315
|
+
if sock_path.exists():
|
|
316
|
+
try:
|
|
317
|
+
sock_path.unlink()
|
|
318
|
+
except Exception:
|
|
319
|
+
pass
|
|
290
320
|
return True
|
|
291
321
|
except Exception:
|
|
292
322
|
return False
|
|
@@ -149,33 +149,15 @@ class Session:
|
|
|
149
149
|
if self.master_fd is not None:
|
|
150
150
|
self._set_pty_size(self.master_fd, rows, cols)
|
|
151
151
|
|
|
152
|
-
def
|
|
153
|
-
"""
|
|
154
|
-
if self.master_fd is None:
|
|
155
|
-
return
|
|
156
|
-
|
|
157
|
-
try:
|
|
158
|
-
data = os.read(self.master_fd, 4096)
|
|
159
|
-
except (BlockingIOError, InterruptedError):
|
|
160
|
-
return
|
|
161
|
-
except OSError:
|
|
162
|
-
# PTY slave closed / child exited
|
|
163
|
-
data = b""
|
|
164
|
-
|
|
165
|
-
if not data:
|
|
166
|
-
self._on_process_exit()
|
|
167
|
-
return
|
|
168
|
-
|
|
169
|
-
# Feed screen and buffer
|
|
152
|
+
def _handle_output_data(self, data: bytes) -> None:
|
|
153
|
+
"""Processes output bytes received from PTY."""
|
|
170
154
|
self.screen.feed(data)
|
|
171
155
|
self.buffer.append(data)
|
|
172
156
|
|
|
173
|
-
# Feed active watcher
|
|
174
157
|
if self.active_watcher:
|
|
175
158
|
text = data.decode("utf-8", errors="replace")
|
|
176
159
|
self.active_watcher.feed(text)
|
|
177
160
|
|
|
178
|
-
# Broadcast to all attached clients
|
|
179
161
|
dead_writers = set()
|
|
180
162
|
for writer in self.attached_writers:
|
|
181
163
|
try:
|
|
@@ -186,6 +168,24 @@ class Session:
|
|
|
186
168
|
for writer in dead_writers:
|
|
187
169
|
self.attached_writers.discard(writer)
|
|
188
170
|
|
|
171
|
+
def _on_pty_readable(self) -> None:
|
|
172
|
+
"""Callback invoked when PTY master has output to read."""
|
|
173
|
+
if self.master_fd is None:
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
data = os.read(self.master_fd, 4096)
|
|
178
|
+
except (BlockingIOError, InterruptedError):
|
|
179
|
+
return
|
|
180
|
+
except OSError:
|
|
181
|
+
data = b""
|
|
182
|
+
|
|
183
|
+
if not data:
|
|
184
|
+
self._on_process_exit()
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
self._handle_output_data(data)
|
|
188
|
+
|
|
189
189
|
def _on_process_exit(self) -> None:
|
|
190
190
|
"""Handles PTY close and subprocess termination."""
|
|
191
191
|
if self.master_fd is not None:
|
|
@@ -204,6 +204,16 @@ class Session:
|
|
|
204
204
|
except Exception:
|
|
205
205
|
pass
|
|
206
206
|
|
|
207
|
+
# Clean up ControlMaster socket if exists
|
|
208
|
+
try:
|
|
209
|
+
from .utils import get_cm_socket_path
|
|
210
|
+
|
|
211
|
+
cm_sock = get_cm_socket_path(self.session_id)
|
|
212
|
+
if cm_sock.exists():
|
|
213
|
+
cm_sock.unlink()
|
|
214
|
+
except Exception:
|
|
215
|
+
pass
|
|
216
|
+
|
|
207
217
|
# Notify attached writers
|
|
208
218
|
for writer in list(self.attached_writers):
|
|
209
219
|
try:
|
|
@@ -219,11 +229,43 @@ class Session:
|
|
|
219
229
|
)
|
|
220
230
|
|
|
221
231
|
def write_input(self, data: bytes) -> None:
|
|
222
|
-
"""Writes raw input bytes to the PTY."""
|
|
232
|
+
"""Writes raw input bytes to the PTY with non-blocking flow control and echo draining."""
|
|
223
233
|
if self.master_fd is None or self.status != "active":
|
|
224
234
|
raise RuntimeError(f"Session {self.session_id} is not active")
|
|
225
235
|
try:
|
|
226
|
-
|
|
236
|
+
import select
|
|
237
|
+
import time
|
|
238
|
+
|
|
239
|
+
total_written = 0
|
|
240
|
+
total_len = len(data)
|
|
241
|
+
|
|
242
|
+
while total_written < total_len:
|
|
243
|
+
if self.master_fd is None or self.status != "active":
|
|
244
|
+
break
|
|
245
|
+
|
|
246
|
+
# 1. Drain pending output from PTY to prevent slave echo buffer deadlock
|
|
247
|
+
try:
|
|
248
|
+
rlist, _, _ = select.select([self.master_fd], [], [], 0)
|
|
249
|
+
if self.master_fd in rlist:
|
|
250
|
+
chunk_out = os.read(self.master_fd, 4096)
|
|
251
|
+
if chunk_out:
|
|
252
|
+
self._handle_output_data(chunk_out)
|
|
253
|
+
except Exception:
|
|
254
|
+
pass
|
|
255
|
+
|
|
256
|
+
# 2. Write chunk
|
|
257
|
+
chunk = data[total_written : total_written + 512]
|
|
258
|
+
try:
|
|
259
|
+
n = os.write(self.master_fd, chunk)
|
|
260
|
+
total_written += n
|
|
261
|
+
if total_len > 512:
|
|
262
|
+
time.sleep(0.0005)
|
|
263
|
+
except (BlockingIOError, InterruptedError):
|
|
264
|
+
# PTY buffer full: wait briefly and drain
|
|
265
|
+
try:
|
|
266
|
+
select.select([self.master_fd], [self.master_fd], [], 0.02)
|
|
267
|
+
except Exception:
|
|
268
|
+
pass
|
|
227
269
|
except Exception as e:
|
|
228
270
|
raise RuntimeError(f"Failed to write to session {self.session_id}: {e}") from e
|
|
229
271
|
|
|
@@ -312,10 +354,29 @@ class SessionManager:
|
|
|
312
354
|
) -> Session:
|
|
313
355
|
session_id = generate_session_id()
|
|
314
356
|
session_name = name or f"session-{session_id}"
|
|
357
|
+
|
|
358
|
+
# If command starts with ssh, automatically configure ControlMaster
|
|
359
|
+
final_command = list(command)
|
|
360
|
+
if final_command and final_command[0] == "ssh":
|
|
361
|
+
from .utils import get_cm_socket_path
|
|
362
|
+
|
|
363
|
+
cm_sock = get_cm_socket_path(session_id)
|
|
364
|
+
has_cm = any("ControlPath" in str(arg) for arg in final_command)
|
|
365
|
+
if not has_cm:
|
|
366
|
+
cm_options = [
|
|
367
|
+
"-o",
|
|
368
|
+
"ControlMaster=auto",
|
|
369
|
+
"-o",
|
|
370
|
+
f"ControlPath={cm_sock}",
|
|
371
|
+
"-o",
|
|
372
|
+
"ControlPersist=600s",
|
|
373
|
+
]
|
|
374
|
+
final_command = [final_command[0]] + cm_options + final_command[1:]
|
|
375
|
+
|
|
315
376
|
session = Session(
|
|
316
377
|
session_id=session_id,
|
|
317
378
|
name=session_name,
|
|
318
|
-
command=
|
|
379
|
+
command=final_command,
|
|
319
380
|
rows=rows,
|
|
320
381
|
cols=cols,
|
|
321
382
|
)
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""File transfer engine for uploading and downloading files over remote-cli sessions."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import io
|
|
6
|
+
import os
|
|
7
|
+
import shlex
|
|
8
|
+
import tarfile
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .client import Client
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def calculate_md5(data: bytes) -> str:
|
|
16
|
+
"""Calculates MD5 hex digest for given bytes."""
|
|
17
|
+
return hashlib.md5(data).hexdigest()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def create_tar_gz_bytes(local_path: Path) -> tuple[bytes, bool]:
|
|
21
|
+
"""
|
|
22
|
+
Creates a tar.gz archive in memory from a local file or directory.
|
|
23
|
+
Returns (tar_gz_bytes, is_directory).
|
|
24
|
+
"""
|
|
25
|
+
local_path = local_path.resolve()
|
|
26
|
+
if not local_path.exists():
|
|
27
|
+
raise FileNotFoundError(f"Local path does not exist: {local_path}")
|
|
28
|
+
|
|
29
|
+
is_dir = local_path.is_dir()
|
|
30
|
+
bio = io.BytesIO()
|
|
31
|
+
|
|
32
|
+
with tarfile.open(fileobj=bio, mode="w:gz") as tar:
|
|
33
|
+
if is_dir:
|
|
34
|
+
for root, _, files in os.walk(local_path):
|
|
35
|
+
for f in files:
|
|
36
|
+
full_p = Path(root) / f
|
|
37
|
+
rel_p = full_p.relative_to(local_path)
|
|
38
|
+
tar.add(str(full_p), arcname=str(rel_p))
|
|
39
|
+
else:
|
|
40
|
+
tar.add(str(local_path), arcname=local_path.name)
|
|
41
|
+
|
|
42
|
+
return bio.getvalue(), is_dir
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def extract_tar_gz_bytes(
|
|
46
|
+
archive_bytes: bytes, dest_path: Path, single_file_name: str | None = None
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Extracts a tar.gz archive to destination path.
|
|
49
|
+
|
|
50
|
+
If destination is a file path and archive contains a single file without directory structure, writes directly to dest_path.
|
|
51
|
+
"""
|
|
52
|
+
dest_path = dest_path.resolve()
|
|
53
|
+
bio = io.BytesIO(archive_bytes)
|
|
54
|
+
|
|
55
|
+
with tarfile.open(fileobj=bio, mode="r:*") as tar:
|
|
56
|
+
members = tar.getmembers()
|
|
57
|
+
has_dirs = any(m.isdir() for m in members)
|
|
58
|
+
|
|
59
|
+
# Filter regular files (excluding directory entries, PAX headers, and AppleDouble ._ files)
|
|
60
|
+
regular_members = [
|
|
61
|
+
m
|
|
62
|
+
for m in members
|
|
63
|
+
if not m.isdir()
|
|
64
|
+
and not m.name.startswith("PaxHeaders")
|
|
65
|
+
and not Path(m.name).name.startswith("._")
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
if len(regular_members) == 1 and not has_dirs and not dest_path.is_dir():
|
|
69
|
+
# True single file target
|
|
70
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
extracted_f = tar.extractfile(regular_members[0])
|
|
72
|
+
if extracted_f:
|
|
73
|
+
dest_path.write_bytes(extracted_f.read())
|
|
74
|
+
else:
|
|
75
|
+
dest_path.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
try:
|
|
77
|
+
tar.extractall(path=str(dest_path), filter="data")
|
|
78
|
+
except TypeError:
|
|
79
|
+
tar.extractall(path=str(dest_path))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def is_control_master_active(session_id: str) -> bool:
|
|
83
|
+
"""Checks if an SSH ControlMaster socket is active and responsive for the session."""
|
|
84
|
+
import subprocess
|
|
85
|
+
|
|
86
|
+
from .utils import get_cm_socket_path
|
|
87
|
+
|
|
88
|
+
sock = get_cm_socket_path(session_id)
|
|
89
|
+
if not sock.exists():
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
res = subprocess.run(
|
|
94
|
+
["ssh", "-O", "check", "-o", f"ControlPath={sock}", "dummy_host"],
|
|
95
|
+
capture_output=True,
|
|
96
|
+
text=True,
|
|
97
|
+
timeout=2.0,
|
|
98
|
+
)
|
|
99
|
+
return res.returncode == 0 or "Master running" in (res.stdout + res.stderr)
|
|
100
|
+
except Exception:
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TransferManager:
|
|
105
|
+
"""Manages file transfer over active remote-cli sessions using chunked streams or SSH ControlMaster."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, client: Client):
|
|
108
|
+
self.client = client
|
|
109
|
+
|
|
110
|
+
def upload(
|
|
111
|
+
self,
|
|
112
|
+
session_id: str,
|
|
113
|
+
local_path: str | Path,
|
|
114
|
+
remote_path: str,
|
|
115
|
+
progress_callback: Callable[[int, int], None] | None = None,
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Uploads a local file or directory to remote_path in the session."""
|
|
118
|
+
loc_p = Path(local_path).resolve()
|
|
119
|
+
if not loc_p.exists():
|
|
120
|
+
raise FileNotFoundError(f"Local file or directory not found: {loc_p}")
|
|
121
|
+
|
|
122
|
+
# Plan B Fast-Path: Use SSH ControlMaster via native SCP if available
|
|
123
|
+
if is_control_master_active(session_id):
|
|
124
|
+
try:
|
|
125
|
+
import subprocess
|
|
126
|
+
|
|
127
|
+
from .utils import get_cm_socket_path
|
|
128
|
+
|
|
129
|
+
sock = get_cm_socket_path(session_id)
|
|
130
|
+
scp_cmd = [
|
|
131
|
+
"scp",
|
|
132
|
+
"-o",
|
|
133
|
+
f"ControlPath={sock}",
|
|
134
|
+
"-o",
|
|
135
|
+
"BatchMode=yes",
|
|
136
|
+
"-o",
|
|
137
|
+
"StrictHostKeyChecking=no",
|
|
138
|
+
]
|
|
139
|
+
if loc_p.is_dir():
|
|
140
|
+
scp_cmd.append("-r")
|
|
141
|
+
scp_cmd.extend([str(loc_p), f"dummy_host:{remote_path}"])
|
|
142
|
+
|
|
143
|
+
res = subprocess.run(scp_cmd, capture_output=True, text=True, timeout=120.0)
|
|
144
|
+
if res.returncode == 0:
|
|
145
|
+
if progress_callback:
|
|
146
|
+
progress_callback(100, 100)
|
|
147
|
+
return
|
|
148
|
+
except Exception:
|
|
149
|
+
pass # Fallback to Tar + Base64 stream
|
|
150
|
+
|
|
151
|
+
# Fallback Engine: In-band Tar + Base64 single-line stream
|
|
152
|
+
tar_bytes, is_dir = create_tar_gz_bytes(loc_p)
|
|
153
|
+
b64_data = base64.b64encode(tar_bytes).decode("ascii")
|
|
154
|
+
total_len = len(b64_data)
|
|
155
|
+
|
|
156
|
+
# Dynamic timeout based on archive size (at least 30s)
|
|
157
|
+
timeout = max(30.0, float(len(tar_bytes)) / (50 * 1024) + 15.0)
|
|
158
|
+
|
|
159
|
+
remote_dest_quoted = shlex.quote(remote_path)
|
|
160
|
+
is_remote_dir = is_dir or remote_path.endswith("/")
|
|
161
|
+
|
|
162
|
+
if is_remote_dir:
|
|
163
|
+
unpack_cmd = (
|
|
164
|
+
f'mkdir -p {remote_dest_quoted} && tar -xzf "$__RCLI_TAR" -C {remote_dest_quoted}'
|
|
165
|
+
)
|
|
166
|
+
else:
|
|
167
|
+
unpack_cmd = (
|
|
168
|
+
f'mkdir -p "$(dirname {remote_dest_quoted})" && '
|
|
169
|
+
f"__RCLI_EXTRACT_DIR=$(mktemp -d /tmp/rcli_ext_XXXXXX) && "
|
|
170
|
+
f'tar -xzf "$__RCLI_TAR" -C "$__RCLI_EXTRACT_DIR" && '
|
|
171
|
+
f'mv "$__RCLI_EXTRACT_DIR/{loc_p.name}" {remote_dest_quoted} && '
|
|
172
|
+
f'rm -rf "$__RCLI_EXTRACT_DIR"'
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Step 1: Create remote staging temp file
|
|
176
|
+
init_cmd = '__RCLI_B64=$(mktemp /tmp/rcli_up_XXXXXX) && echo "$__RCLI_B64"'
|
|
177
|
+
init_res = self.client.exec_command(session_id, init_cmd, timeout=15.0)
|
|
178
|
+
if init_res.exit_code != 0 or not init_res.output.strip():
|
|
179
|
+
raise RuntimeError(f"Failed to initialize remote upload staging: {init_res.output}")
|
|
180
|
+
|
|
181
|
+
remote_b64_file = init_res.output.strip().splitlines()[-1]
|
|
182
|
+
|
|
183
|
+
# Step 2: Stream single-line chunks of 512 chars (safe across all PTY & SSH connections)
|
|
184
|
+
chunk_size = 512
|
|
185
|
+
sent = 0
|
|
186
|
+
for i in range(0, total_len, chunk_size):
|
|
187
|
+
chunk = b64_data[i : i + chunk_size]
|
|
188
|
+
append_cmd = f"printf '%s' '{chunk}' >> {shlex.quote(remote_b64_file)}"
|
|
189
|
+
chunk_res = self.client.exec_command(session_id, append_cmd, timeout=timeout)
|
|
190
|
+
if chunk_res.exit_code != 0:
|
|
191
|
+
self.client.exec_command(
|
|
192
|
+
session_id, f"rm -f {shlex.quote(remote_b64_file)}", timeout=5.0
|
|
193
|
+
)
|
|
194
|
+
raise RuntimeError(f"Failed uploading chunk: {chunk_res.output}")
|
|
195
|
+
sent += len(chunk)
|
|
196
|
+
if progress_callback:
|
|
197
|
+
progress_callback(sent, total_len)
|
|
198
|
+
|
|
199
|
+
# Step 3: Unpack remotely
|
|
200
|
+
unpack_script = (
|
|
201
|
+
f"__RCLI_TAR=$(mktemp /tmp/rcli_pkg_XXXXXX); "
|
|
202
|
+
f'base64 -d < {shlex.quote(remote_b64_file)} > "$__RCLI_TAR" && '
|
|
203
|
+
f"rm -f {shlex.quote(remote_b64_file)} && "
|
|
204
|
+
f"{unpack_cmd} && "
|
|
205
|
+
f'rm -f "$__RCLI_TAR"'
|
|
206
|
+
)
|
|
207
|
+
unpack_res = self.client.exec_command(session_id, unpack_script, timeout=timeout)
|
|
208
|
+
if unpack_res.exit_code != 0:
|
|
209
|
+
raise RuntimeError(f"Failed to unpack uploaded file remotely: {unpack_res.output}")
|
|
210
|
+
|
|
211
|
+
def download(
|
|
212
|
+
self,
|
|
213
|
+
session_id: str,
|
|
214
|
+
remote_path: str,
|
|
215
|
+
local_path: str | Path,
|
|
216
|
+
progress_callback: Callable[[int, int], None] | None = None,
|
|
217
|
+
) -> None:
|
|
218
|
+
"""Downloads a remote file or directory from the session to local_path."""
|
|
219
|
+
loc_p = Path(local_path).resolve()
|
|
220
|
+
|
|
221
|
+
# Plan B Fast-Path: Use SSH ControlMaster via native SCP if available
|
|
222
|
+
if is_control_master_active(session_id):
|
|
223
|
+
try:
|
|
224
|
+
import subprocess
|
|
225
|
+
|
|
226
|
+
from .utils import get_cm_socket_path
|
|
227
|
+
|
|
228
|
+
sock = get_cm_socket_path(session_id)
|
|
229
|
+
loc_p.parent.mkdir(parents=True, exist_ok=True)
|
|
230
|
+
scp_cmd = [
|
|
231
|
+
"scp",
|
|
232
|
+
"-r",
|
|
233
|
+
"-o",
|
|
234
|
+
f"ControlPath={sock}",
|
|
235
|
+
"-o",
|
|
236
|
+
"BatchMode=yes",
|
|
237
|
+
"-o",
|
|
238
|
+
"StrictHostKeyChecking=no",
|
|
239
|
+
f"dummy_host:{remote_path}",
|
|
240
|
+
str(loc_p),
|
|
241
|
+
]
|
|
242
|
+
res = subprocess.run(scp_cmd, capture_output=True, text=True, timeout=120.0)
|
|
243
|
+
if res.returncode == 0:
|
|
244
|
+
if progress_callback:
|
|
245
|
+
progress_callback(100, 100)
|
|
246
|
+
return
|
|
247
|
+
except Exception:
|
|
248
|
+
pass # Fallback to Tar + Base64 stream
|
|
249
|
+
|
|
250
|
+
# Fallback Engine: In-band Tar + Base64 stream
|
|
251
|
+
remote_quoted = shlex.quote(remote_path)
|
|
252
|
+
|
|
253
|
+
# Pack to tar.gz + base64 with clear sentinel markers
|
|
254
|
+
pack_script = (
|
|
255
|
+
f"if [ ! -e {remote_quoted} ]; then echo '__RCLI_NOT_FOUND__'; exit 44; fi; "
|
|
256
|
+
f"__RCLI_T=$(mktemp /tmp/rcli_dl_XXXXXX); "
|
|
257
|
+
f"if [ -d {remote_quoted} ]; then "
|
|
258
|
+
f'tar -czf "$__RCLI_T" -C {remote_quoted} .; '
|
|
259
|
+
f"else "
|
|
260
|
+
f"__RCLI_P=$(dirname {remote_quoted}); "
|
|
261
|
+
f"__RCLI_B=$(basename {remote_quoted}); "
|
|
262
|
+
f'tar -czf "$__RCLI_T" -C "$__RCLI_P" "$__RCLI_B"; '
|
|
263
|
+
f"fi && "
|
|
264
|
+
f"printf '__B64_START__\\n' && "
|
|
265
|
+
f'base64 < "$__RCLI_T" && '
|
|
266
|
+
f"printf '\\n__B64_END__\\n'; "
|
|
267
|
+
f'rm -f "$__RCLI_T"'
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
res = self.client.exec_command(session_id, pack_script, timeout=60.0)
|
|
271
|
+
if res.exit_code == 44 or "__RCLI_NOT_FOUND__" in res.output:
|
|
272
|
+
raise FileNotFoundError(f"Remote path not found: {remote_path}")
|
|
273
|
+
if res.exit_code != 0:
|
|
274
|
+
raise RuntimeError(f"Failed to package remote file: {res.output}")
|
|
275
|
+
|
|
276
|
+
# Extract base64 between markers
|
|
277
|
+
if "__B64_START__" not in res.output:
|
|
278
|
+
raise RuntimeError(f"Invalid remote download response: {res.output}")
|
|
279
|
+
|
|
280
|
+
_, _, after = res.output.partition("__B64_START__")
|
|
281
|
+
b64_content, _, _ = after.partition("__B64_END__")
|
|
282
|
+
import re
|
|
283
|
+
|
|
284
|
+
b64_clean = re.sub(r"[^A-Za-z0-9+/=]", "", b64_content)
|
|
285
|
+
if not b64_clean:
|
|
286
|
+
raise RuntimeError("Received empty payload from remote server")
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
tar_bytes = base64.b64decode(b64_clean)
|
|
290
|
+
except Exception as e:
|
|
291
|
+
raise RuntimeError(f"Failed to decode base64 download payload: {e}") from e
|
|
292
|
+
|
|
293
|
+
if progress_callback:
|
|
294
|
+
progress_callback(len(tar_bytes), len(tar_bytes))
|
|
295
|
+
|
|
296
|
+
# Extract locally
|
|
297
|
+
single_name = Path(remote_path).name
|
|
298
|
+
extract_tar_gz_bytes(tar_bytes, loc_p, single_file_name=single_name)
|
|
@@ -30,6 +30,18 @@ def get_log_path() -> Path:
|
|
|
30
30
|
return get_base_dir() / "daemon.log"
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
def get_cm_dir() -> Path:
|
|
34
|
+
"""Returns the ControlMaster sockets directory."""
|
|
35
|
+
cm_dir = get_base_dir() / "cm"
|
|
36
|
+
cm_dir.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
return cm_dir
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_cm_socket_path(session_id: str) -> Path:
|
|
41
|
+
"""Returns the ControlMaster socket path for a given session."""
|
|
42
|
+
return get_cm_dir() / f"{session_id}.sock"
|
|
43
|
+
|
|
44
|
+
|
|
33
45
|
def generate_session_id() -> str:
|
|
34
46
|
"""Generates a short, user-friendly session ID."""
|
|
35
47
|
return f"s_{uuid.uuid4().hex[:8]}"
|
|
@@ -12,6 +12,9 @@ def test_cli_help():
|
|
|
12
12
|
assert "ssh" in result.output
|
|
13
13
|
assert "exec" in result.output
|
|
14
14
|
assert "snapshot" in result.output
|
|
15
|
+
assert "cp" in result.output
|
|
16
|
+
assert "upload" in result.output
|
|
17
|
+
assert "download" in result.output
|
|
15
18
|
|
|
16
19
|
|
|
17
20
|
def test_cli_session_help():
|
|
@@ -20,3 +23,10 @@ def test_cli_session_help():
|
|
|
20
23
|
assert "create" in result.output
|
|
21
24
|
assert "list" in result.output
|
|
22
25
|
assert "attach" in result.output
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_cli_cp_help():
|
|
29
|
+
result = runner.invoke(app, ["cp", "--help"])
|
|
30
|
+
assert result.exit_code == 0
|
|
31
|
+
assert "src" in result.output.lower()
|
|
32
|
+
assert "dest" in result.output.lower()
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from remote_cli.client import Client
|
|
10
|
+
from remote_cli.daemon import DaemonServer
|
|
11
|
+
from remote_cli.transfer import (
|
|
12
|
+
create_tar_gz_bytes,
|
|
13
|
+
extract_tar_gz_bytes,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def transfer_server():
|
|
19
|
+
sock_path = Path(f"/tmp/rcli_tf_{uuid.uuid4().hex[:8]}.sock")
|
|
20
|
+
server = DaemonServer(socket_path=sock_path)
|
|
21
|
+
server_loop = asyncio.new_event_loop()
|
|
22
|
+
|
|
23
|
+
import threading
|
|
24
|
+
|
|
25
|
+
def run_server():
|
|
26
|
+
asyncio.set_event_loop(server_loop)
|
|
27
|
+
try:
|
|
28
|
+
server_loop.run_until_complete(server.start())
|
|
29
|
+
except Exception:
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
thread = threading.Thread(target=run_server, daemon=True)
|
|
33
|
+
thread.start()
|
|
34
|
+
|
|
35
|
+
for _ in range(50):
|
|
36
|
+
if sock_path.exists():
|
|
37
|
+
break
|
|
38
|
+
time.sleep(0.05)
|
|
39
|
+
|
|
40
|
+
client = Client(socket_path=sock_path)
|
|
41
|
+
assert client.ping() is True
|
|
42
|
+
|
|
43
|
+
yield client, server
|
|
44
|
+
|
|
45
|
+
for s in list(server.session_manager.sessions.values()):
|
|
46
|
+
s.close()
|
|
47
|
+
|
|
48
|
+
def stop_server():
|
|
49
|
+
if server.server:
|
|
50
|
+
server.server.close()
|
|
51
|
+
server_loop.stop()
|
|
52
|
+
|
|
53
|
+
server_loop.call_soon_threadsafe(stop_server)
|
|
54
|
+
thread.join(timeout=1.0)
|
|
55
|
+
if sock_path.exists():
|
|
56
|
+
try:
|
|
57
|
+
sock_path.unlink()
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_tar_gz_bytes_roundtrip(tmp_path: Path):
|
|
63
|
+
# Test directory archive & extract
|
|
64
|
+
src_dir = tmp_path / "src_dir"
|
|
65
|
+
src_dir.mkdir()
|
|
66
|
+
(src_dir / "file1.txt").write_text("Hello file 1")
|
|
67
|
+
sub = src_dir / "sub"
|
|
68
|
+
sub.mkdir()
|
|
69
|
+
(sub / "file2.bin").write_bytes(b"\x00\x01\x02\x03\x04\xff")
|
|
70
|
+
|
|
71
|
+
tar_bytes, is_dir = create_tar_gz_bytes(src_dir)
|
|
72
|
+
assert is_dir is True
|
|
73
|
+
assert len(tar_bytes) > 0
|
|
74
|
+
|
|
75
|
+
dest_dir = tmp_path / "dest_dir"
|
|
76
|
+
extract_tar_gz_bytes(tar_bytes, dest_dir)
|
|
77
|
+
assert (dest_dir / "file1.txt").read_text() == "Hello file 1"
|
|
78
|
+
assert (dest_dir / "sub" / "file2.bin").read_bytes() == b"\x00\x01\x02\x03\x04\xff"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_session_upload_and_download(transfer_server, tmp_path: Path):
|
|
82
|
+
client, _ = transfer_server
|
|
83
|
+
|
|
84
|
+
# Create local shell session
|
|
85
|
+
session_info = client.create_session(command=["/bin/sh"], name="transfer-test")
|
|
86
|
+
session_id = session_info.session_id
|
|
87
|
+
time.sleep(0.2)
|
|
88
|
+
|
|
89
|
+
# 1. Upload a single file
|
|
90
|
+
local_file = tmp_path / "upload_test.txt"
|
|
91
|
+
local_file.write_text("Unique Upload Content 12345\nSecond line")
|
|
92
|
+
|
|
93
|
+
remote_dest_file = f"/tmp/rcli_dest_{uuid.uuid4().hex[:6]}.txt"
|
|
94
|
+
client.upload_file(session_id, local_file, remote_dest_file)
|
|
95
|
+
|
|
96
|
+
# Verify content remotely
|
|
97
|
+
cat_res = client.exec_command(session_id, f"cat {remote_dest_file}", timeout=5.0)
|
|
98
|
+
assert cat_res.exit_code == 0
|
|
99
|
+
assert "Unique Upload Content 12345" in cat_res.output
|
|
100
|
+
|
|
101
|
+
# 2. Download the file back to a new local path
|
|
102
|
+
local_download_dest = tmp_path / "downloaded_test.txt"
|
|
103
|
+
client.download_file(session_id, remote_dest_file, local_download_dest)
|
|
104
|
+
assert local_download_dest.exists()
|
|
105
|
+
assert (
|
|
106
|
+
local_download_dest.read_text().strip()
|
|
107
|
+
== "Unique Upload Content 12345\nSecond line".strip()
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# 3. Upload a full directory
|
|
111
|
+
local_pkg = tmp_path / "my_pkg"
|
|
112
|
+
local_pkg.mkdir()
|
|
113
|
+
(local_pkg / "config.yaml").write_text("version: 1.0\napp: test")
|
|
114
|
+
(local_pkg / "data.bin").write_bytes(os.urandom(1024 * 10)) # 10KB binary
|
|
115
|
+
|
|
116
|
+
remote_pkg_dir = f"/tmp/rcli_pkg_{uuid.uuid4().hex[:6]}"
|
|
117
|
+
client.upload_file(session_id, local_pkg, remote_pkg_dir)
|
|
118
|
+
|
|
119
|
+
# Verify directory contents remotely
|
|
120
|
+
ls_res = client.exec_command(session_id, f"ls {remote_pkg_dir}", timeout=5.0)
|
|
121
|
+
assert ls_res.exit_code == 0
|
|
122
|
+
assert "config.yaml" in ls_res.output
|
|
123
|
+
assert "data.bin" in ls_res.output
|
|
124
|
+
|
|
125
|
+
# 4. Download directory back locally
|
|
126
|
+
local_pkg_download = tmp_path / "downloaded_pkg"
|
|
127
|
+
client.download_file(session_id, remote_pkg_dir, local_pkg_download)
|
|
128
|
+
assert (local_pkg_download / "config.yaml").read_text() == "version: 1.0\napp: test"
|
|
129
|
+
assert (local_pkg_download / "data.bin").read_bytes() == (local_pkg / "data.bin").read_bytes()
|
|
130
|
+
|
|
131
|
+
# Cleanup remote files
|
|
132
|
+
client.exec_command(session_id, f"rm -rf {remote_dest_file} {remote_pkg_dir}", timeout=5.0)
|
|
133
|
+
client.close_session(session_id)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_cli_transfer_commands(transfer_server, tmp_path: Path, monkeypatch):
|
|
137
|
+
client, server = transfer_server
|
|
138
|
+
monkeypatch.setattr("remote_cli.utils.get_socket_path", lambda: server.socket_path)
|
|
139
|
+
monkeypatch.setattr("remote_cli.cli.ensure_daemon_running", lambda: None)
|
|
140
|
+
monkeypatch.setattr("remote_cli.client.get_socket_path", lambda: server.socket_path)
|
|
141
|
+
from typer.testing import CliRunner
|
|
142
|
+
|
|
143
|
+
from remote_cli.cli import app
|
|
144
|
+
|
|
145
|
+
runner = CliRunner()
|
|
146
|
+
|
|
147
|
+
# Create session
|
|
148
|
+
session_info = client.create_session(command=["/bin/sh"], name="cli-transfer")
|
|
149
|
+
sid = session_info.session_id
|
|
150
|
+
time.sleep(0.2)
|
|
151
|
+
|
|
152
|
+
# Test upload command
|
|
153
|
+
local_f = tmp_path / "cli_up.txt"
|
|
154
|
+
local_f.write_text("CLI Upload Data")
|
|
155
|
+
remote_f = f"/tmp/rcli_cli_up_{uuid.uuid4().hex[:6]}.txt"
|
|
156
|
+
|
|
157
|
+
res_up = runner.invoke(app, ["upload", sid, str(local_f), remote_f])
|
|
158
|
+
assert res_up.exit_code == 0
|
|
159
|
+
assert "Successfully uploaded" in res_up.output
|
|
160
|
+
|
|
161
|
+
# Test download command
|
|
162
|
+
local_dl = tmp_path / "cli_dl.txt"
|
|
163
|
+
res_dl = runner.invoke(app, ["download", sid, remote_f, str(local_dl)])
|
|
164
|
+
assert res_dl.exit_code == 0
|
|
165
|
+
assert "Successfully downloaded" in res_dl.output
|
|
166
|
+
assert local_dl.read_text().strip() == "CLI Upload Data"
|
|
167
|
+
|
|
168
|
+
# Test cp command (upload syntax)
|
|
169
|
+
local_cp_src = tmp_path / "cli_cp_src.txt"
|
|
170
|
+
local_cp_src.write_text("CP Command Data")
|
|
171
|
+
remote_cp_dest = f"/tmp/rcli_cp_{uuid.uuid4().hex[:6]}.txt"
|
|
172
|
+
|
|
173
|
+
res_cp_up = runner.invoke(app, ["cp", str(local_cp_src), f"{sid}:{remote_cp_dest}"])
|
|
174
|
+
assert res_cp_up.exit_code == 0
|
|
175
|
+
assert "Successfully uploaded" in res_cp_up.output
|
|
176
|
+
|
|
177
|
+
# Test cp command (download syntax)
|
|
178
|
+
local_cp_dl = tmp_path / "cli_cp_dl.txt"
|
|
179
|
+
res_cp_dl = runner.invoke(app, ["cp", f"{sid}:{remote_cp_dest}", str(local_cp_dl)])
|
|
180
|
+
assert res_cp_dl.exit_code == 0
|
|
181
|
+
assert "Successfully downloaded" in res_cp_dl.output
|
|
182
|
+
assert local_cp_dl.read_text().strip() == "CP Command Data"
|
|
183
|
+
|
|
184
|
+
# Cleanup
|
|
185
|
+
client.exec_command(session_id=sid, command=f"rm -f {remote_f} {remote_cp_dest}")
|
|
186
|
+
client.close_session(sid)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_ssh_session_injects_control_master(transfer_server):
|
|
190
|
+
client, _ = transfer_server
|
|
191
|
+
# Verify that SessionManager auto-injects ControlMaster for ssh commands
|
|
192
|
+
session_info = client.create_session(
|
|
193
|
+
command=["ssh", "-p", "22", "user@example.com"], name="test-ssh-cm"
|
|
194
|
+
)
|
|
195
|
+
cmd_str = " ".join(session_info.command)
|
|
196
|
+
assert "ControlMaster=auto" in cmd_str
|
|
197
|
+
assert "ControlPath=" in cmd_str
|
|
198
|
+
assert f"{session_info.session_id}.sock" in cmd_str
|
|
199
|
+
client.close_session(session_info.session_id)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_control_master_fast_path(tmp_path: Path, monkeypatch):
|
|
203
|
+
import remote_cli.transfer as rt
|
|
204
|
+
|
|
205
|
+
# Create dummy socket
|
|
206
|
+
sock_path = tmp_path / "dummy_cm.sock"
|
|
207
|
+
sock_path.touch()
|
|
208
|
+
|
|
209
|
+
# Monkeypatch get_cm_socket_path and is_control_master_active
|
|
210
|
+
monkeypatch.setattr("remote_cli.utils.get_cm_socket_path", lambda sid: sock_path)
|
|
211
|
+
monkeypatch.setattr(rt, "is_control_master_active", lambda sid: True)
|
|
212
|
+
|
|
213
|
+
assert rt.is_control_master_active("s_test") is True
|
|
214
|
+
|
|
215
|
+
# Test TransferManager upload using mock subprocess
|
|
216
|
+
executed_cmds = []
|
|
217
|
+
|
|
218
|
+
def mock_run(cmd, *args, **kwargs):
|
|
219
|
+
executed_cmds.append(cmd)
|
|
220
|
+
import subprocess
|
|
221
|
+
|
|
222
|
+
return subprocess.CompletedProcess(cmd, returncode=0, stdout="", stderr="")
|
|
223
|
+
|
|
224
|
+
monkeypatch.setattr("subprocess.run", mock_run)
|
|
225
|
+
|
|
226
|
+
dummy_client = None
|
|
227
|
+
tm = rt.TransferManager(dummy_client)
|
|
228
|
+
|
|
229
|
+
local_f = tmp_path / "test.txt"
|
|
230
|
+
local_f.write_text("Fast path content")
|
|
231
|
+
tm.upload("s_test", local_f, "/remote/test.txt")
|
|
232
|
+
|
|
233
|
+
assert len(executed_cmds) == 1
|
|
234
|
+
assert executed_cmds[0][0] == "scp"
|
|
235
|
+
assert f"ControlPath={sock_path}" in " ".join(executed_cmds[0])
|
|
236
|
+
assert "dummy_host:/remote/test.txt" in " ".join(executed_cmds[0])
|
|
237
|
+
|
|
238
|
+
# Test download fast path
|
|
239
|
+
executed_cmds.clear()
|
|
240
|
+
local_dl = tmp_path / "dl.txt"
|
|
241
|
+
tm.download("s_test", "/remote/test.txt", local_dl)
|
|
242
|
+
|
|
243
|
+
assert len(executed_cmds) == 1
|
|
244
|
+
assert executed_cmds[0][0] == "scp"
|
|
245
|
+
assert f"ControlPath={sock_path}" in " ".join(executed_cmds[0])
|
|
246
|
+
assert "dummy_host:/remote/test.txt" in " ".join(executed_cmds[0])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|