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.
Files changed (29) hide show
  1. {remote_cli-0.1.0 → remote_cli-0.2.0}/PKG-INFO +19 -2
  2. {remote_cli-0.1.0 → remote_cli-0.2.0}/README.md +18 -1
  3. remote_cli-0.2.0/assets/remote_cli_ai_poster.png +0 -0
  4. {remote_cli-0.1.0 → remote_cli-0.2.0}/skills/remote-cli/SKILL.md +23 -1
  5. {remote_cli-0.1.0 → remote_cli-0.2.0}/src/remote_cli/_version.py +2 -2
  6. {remote_cli-0.1.0 → remote_cli-0.2.0}/src/remote_cli/cli.py +110 -3
  7. {remote_cli-0.1.0 → remote_cli-0.2.0}/src/remote_cli/client.py +36 -0
  8. {remote_cli-0.1.0 → remote_cli-0.2.0}/src/remote_cli/daemon.py +32 -2
  9. {remote_cli-0.1.0 → remote_cli-0.2.0}/src/remote_cli/session.py +84 -23
  10. remote_cli-0.2.0/src/remote_cli/transfer.py +298 -0
  11. {remote_cli-0.1.0 → remote_cli-0.2.0}/src/remote_cli/utils.py +12 -0
  12. {remote_cli-0.1.0 → remote_cli-0.2.0}/tests/test_cli.py +10 -0
  13. remote_cli-0.2.0/tests/test_transfer.py +246 -0
  14. {remote_cli-0.1.0 → remote_cli-0.2.0}/.github/workflows/publish.yml +0 -0
  15. {remote_cli-0.1.0 → remote_cli-0.2.0}/.gitignore +0 -0
  16. {remote_cli-0.1.0 → remote_cli-0.2.0}/.python-version +0 -0
  17. {remote_cli-0.1.0 → remote_cli-0.2.0}/LICENSE +0 -0
  18. {remote_cli-0.1.0 → remote_cli-0.2.0}/pyproject.toml +0 -0
  19. {remote_cli-0.1.0 → remote_cli-0.2.0}/src/remote_cli/__init__.py +0 -0
  20. {remote_cli-0.1.0 → remote_cli-0.2.0}/src/remote_cli/protocol.py +0 -0
  21. {remote_cli-0.1.0 → remote_cli-0.2.0}/src/remote_cli/screen.py +0 -0
  22. {remote_cli-0.1.0 → remote_cli-0.2.0}/src/remote_cli/terminal.py +0 -0
  23. {remote_cli-0.1.0 → remote_cli-0.2.0}/tests/test_daemon_and_client.py +0 -0
  24. {remote_cli-0.1.0 → remote_cli-0.2.0}/tests/test_e2e_cli.py +0 -0
  25. {remote_cli-0.1.0 → remote_cli-0.2.0}/tests/test_multiplexing.py +0 -0
  26. {remote_cli-0.1.0 → remote_cli-0.2.0}/tests/test_protocol.py +0 -0
  27. {remote_cli-0.1.0 → remote_cli-0.2.0}/tests/test_screen.py +0 -0
  28. {remote_cli-0.1.0 → remote_cli-0.2.0}/tests/test_session.py +0 -0
  29. {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.1.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
@@ -126,7 +126,28 @@ remote-cli logs <session-id> --lines 50
126
126
 
127
127
  ---
128
128
 
129
- ### 3.5. List Active Sessions (`remote-cli session list` / `remote-cli ls`)
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.1.0'
22
- __version_tuple__ = version_tuple = (0, 1, 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(session_id: str):
138
- """Attach terminal to an existing session."""
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(session_id: str):
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
- os.kill(pid, signal.SIGTERM)
289
- time.sleep(0.3)
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 _on_pty_readable(self) -> None:
153
- """Callback invoked when PTY master has output to read."""
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
- os.write(self.master_fd, data)
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=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