├── .gitignore ├── LICENSE ├── README.md ├── rcode ├── __init__.py ├── __main__.py ├── ipc │ ├── __init__.py │ ├── ipc.py │ └── ipc_runner.py ├── rcode.py └── rssh.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | .kindle_session 132 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Christian Volkmann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Rcode 2 | 3 | This repo is fork from [code-connect](https://github.com/chvolkmann/code-connect) 4 | ~~Thanks for this cool repo. 5 | 6 | https://user-images.githubusercontent.com/1651790/172983742-b27a3fe0-2704-4fc8-b075-a6544783443a.mp4 7 | 8 | 9 | ## What changed 10 | 11 | 1. PyPI 12 | 2. support local open remote dir command `rcode ${ssh_name} ${ssh_dir}` 13 | 3. support cursor to open remote dir command `rcursor ${ssh_name} ${ssh_dir}` 14 | 4. you can also open dir from remote to local `cursor` just `cursor ${dir_name}` 15 | 16 | ## INFO 17 | 18 | 1. pip3 install rcode (or clone it pip3 install .) 19 | 2. ~~install socat like: (sudo yum install socat)~~ 20 | 3. just `rcode file` like your VSCode `code .` 21 | 4. or use cursor just `cursor .` 22 | 5. local open remote use rcode if you use `.ssh/config` --> `rcode remote_ssh ~/test` 23 | 6. local open latest remote `.ssh/config` --> `rcode -l or rcode --latest` 24 | 7. add shortcut_name `rcode s ~/abc -sn abc` then you can use `rcode -os abc` to open this dir quickly 25 | 8. support cursor to open remote dir command `rcursor ${ssh_name} ${ssh_dir}` 26 | 9. Connect to your SSH server with `rssh`, and you can run `rcode/rcursor` on the server to launch VS Code/Cursor, even if they are not running. 27 | 28 | > Note: 29 | > - If using traditional SSH connection, be sure to [connect to the remote host](https://code.visualstudio.com/docs/remote/ssh#_connect-to-a-remote-host) first before typing any `rcode` in the terminal 30 | > - We may want to add `~/.local/bin` in to your `$PATH` in your `~/.zshrc` or `~/.bashrc` to enable `rcode` being resolved properly 31 | > ```diff 32 | > - export PATH=$PATH:/usr/local/go/bin 33 | > + export PATH=$PATH:/usr/local/go/bin:~/.local/bin 34 | > ``` 35 | 36 | ## Remote Development with RSSH 37 | 38 | RSSH enables seamless remote development by allowing you to launch VS Code/Cursor on your local machine while working with files on a remote server. It works by: 39 | 40 | 1. Creating a secure SSH tunnel between your local machine and remote server 41 | 2. Setting up IPC (Inter-Process Communication) sockets for command transmission 42 | 3. Managing remote sessions with unique identifiers and keys 43 | 44 | https://github.com/user-attachments/assets/41a44915-4714-4fce-8705-a1550921b2f3 45 | 46 | ### Usage 47 | 48 | 1. Connect to remote server using `rssh`: 49 | 50 | RSSH is designed to be fully compatible with SSH parameters, with the exception of the -R and -T options, which are not allowed when using RSSH. 51 | 52 | ```bash 53 | rssh your-remote-server 54 | ``` 55 | 56 | All standard SSH parameters can be used with rssh, except -R and -T. 57 | 58 | 2. On the remote server, you can now use: 59 | ```bash 60 | rcode . # Launch VS Code 61 | rcursor . # Launch Cursor 62 | ``` 63 | 64 | ### Using `ssh-wrapper` 65 | 66 | If you'd like to use rssh as a drop-in replacement for ssh, you can utilize the provided ssh-wrapper. By adding an alias in your shell configuration file (e.g., ~/.bashrc or ~/.zshrc), you can override the ssh command: 67 | 68 | ```shell 69 | alias ssh="ssh-wrapper" 70 | ``` 71 | 72 | With this alias in place, when you use ssh, it will invoke ssh-wrapper. To activate rssh, include the --rssh parameter: 73 | 74 | ```shell 75 | ssh --rssh your-remote-server 76 | ``` 77 | 78 | If you do not include the --rssh parameter, it will behave as the default ssh command. 79 | 80 | ### How It Works 81 | 82 | 1. When you connect with `rssh`: 83 | - Generates a unique session ID and key 84 | - Creates an SSH tunnel for IPC communication 85 | - Sets up environment variables on the remote server 86 | 87 | 2. When running `rcode`/`rcursor` on the remote: 88 | - Communicates with the local IDE through the IPC socket 89 | - Automatically launches the appropriate IDE on your local machine 90 | - Opens the remote directory in your IDE 91 | 92 | ### Advanced Options 93 | 94 | - Custom IPC host: `rssh --host your-remote-server` 95 | - Custom IPC port: `rssh --port your-remote-server` 96 | 97 | -------------------------------------------------------------------------------- /rcode/__init__.py: -------------------------------------------------------------------------------- 1 | from .rcode import * 2 | -------------------------------------------------------------------------------- /rcode/__main__.py: -------------------------------------------------------------------------------- 1 | from .rcode import main 2 | 3 | 4 | if __name__ == "__main__": 5 | main() 6 | -------------------------------------------------------------------------------- /rcode/ipc/__init__.py: -------------------------------------------------------------------------------- 1 | from .ipc import IPCClientSocket, IPCServerSocket, DEFAULT_IPC_PORT, DELIMITER 2 | 3 | -------------------------------------------------------------------------------- /rcode/ipc/ipc.py: -------------------------------------------------------------------------------- 1 | import selectors 2 | import socket 3 | import types 4 | import json 5 | import sys 6 | import subprocess as sp 7 | import uuid 8 | import logging 9 | 10 | import psutil 11 | 12 | from logging.handlers import RotatingFileHandler 13 | from pathlib import Path 14 | 15 | 16 | def initLogger(location: Path): 17 | if not location.parent.exists(): 18 | location.parent.mkdir(parents=True, exist_ok=True) 19 | 20 | logger = logging.getLogger() 21 | logger.setLevel(logging.INFO) 22 | formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(funcName)s - %(message)s") 23 | handler = RotatingFileHandler(location.absolute(), maxBytes=10485760, backupCount=5) 24 | handler.setFormatter(formatter) 25 | logger.addHandler(handler) 26 | 27 | return logger 28 | 29 | 30 | DEFAULT_IPC_PORT = 7532 31 | DELIMITER = b"\x1e" 32 | 33 | LOG_FILE = Path.home() / ".rssh/ipc.log" 34 | LOGGER = initLogger(LOG_FILE) 35 | 36 | 37 | class IPCAuthError(Exception): 38 | def raw_message(self): 39 | msg = json.dumps({"code": 401, "message": str(self)}) 40 | return msg.encode("utf-8") 41 | 42 | 43 | class MessageHandler: 44 | # 定义为类属性 45 | AUTH_METHODS = ["open_ide"] 46 | ANNO_METHODS = ["new_session"] 47 | RPC_METHODS = AUTH_METHODS + ANNO_METHODS 48 | 49 | def __init__(self): 50 | self.sessions = {} 51 | 52 | def handle_message(self, raw_data: bytes, key: selectors.SelectorKey): 53 | try: 54 | payload = json.loads(raw_data) 55 | method_name = payload['method'] 56 | params = payload.get('params', {}) 57 | 58 | if 'method' not in payload: 59 | raise ValueError("Missing required 'method' field in request") 60 | 61 | if not hasattr(self, method_name) or method_name not in self.RPC_METHODS: 62 | raise ValueError(f"Method '{method_name}' not found.") 63 | 64 | method_to_call = getattr(self, method_name) 65 | if method_name not in self.ANNO_METHODS: 66 | if 'sid' not in params or 'skey' not in params: 67 | raise IPCAuthError("Missing authentication credentials (sid, skey)") 68 | 69 | sid = params['sid'] 70 | skey = params['skey'] 71 | 72 | if sid not in self.sessions: 73 | LOGGER.warning(f"Invalid session ID: {sid}, sessions: {self.sessions.keys()}") 74 | raise IPCAuthError(f"Invalid session ID: {sid}") 75 | 76 | if self.sessions[sid].key != skey: 77 | LOGGER.warning(f"Invalid session key, sid: {sid}, key: {skey}") 78 | raise IPCAuthError("Invalid session key") 79 | 80 | result = method_to_call(params) 81 | else: 82 | result = method_to_call(key.data, params) 83 | 84 | response = {"code": 0, "data": result} 85 | return json.dumps(response).encode("utf-8") 86 | except json.JSONDecodeError as e: 87 | response = {"code": 1, "message": f"Invalid JSON format: {str(e)}"} 88 | return json.dumps(response).encode("utf-8") 89 | except IPCAuthError as e: 90 | raise e 91 | except Exception as e: 92 | response = {"code": 1, "message": str(e)} 93 | return json.dumps(response).encode("utf-8") 94 | 95 | def open_ide(self, params: dict): 96 | valid_bins = ["code", "cursor", "windsurf"] 97 | if params.get("bin") not in valid_bins: 98 | raise ValueError(f"Invalid bin: {params['bin']}.") 99 | 100 | if params.get("path") is None: 101 | raise ValueError("Missing required 'path' field in request") 102 | 103 | session = self.sessions[params['sid']] 104 | remote_name = session.hostname 105 | remote_dir = params['path'] 106 | is_win = sys.platform == "win32" 107 | 108 | logging.info("host: %s uri: %s", remote_name, remote_dir) 109 | ssh_remote = f"vscode-remote://ssh-remote+{remote_name}{remote_dir}" 110 | try: 111 | proc = sp.run([params["bin"], "--folder-uri", ssh_remote], shell=is_win) 112 | except Exception as e: 113 | LOGGER.error("open_ide failed, params: %s", json.dumps(params), exc_info=True) 114 | raise e 115 | 116 | return {"return_code": proc.returncode} 117 | 118 | def new_session(self, data: types.SimpleNamespace, params: dict): 119 | required_fields = ['hostname', "keyfile", "pid"] 120 | for field in required_fields: 121 | if field not in params: 122 | raise ValueError(f"Missing required field: {field}") 123 | 124 | keyfile = Path.home() / ".rssh/keyfile" 125 | if not keyfile.exists() or keyfile.read_text() != params["keyfile"]: 126 | LOGGER.error("Authentication failed, key: %s", params["keyfile"]) 127 | raise IPCAuthError("Authentication failed: Invalid key") 128 | 129 | sid = data.sid 130 | key = str(uuid.uuid4()) 131 | pid = params.get("pid", -1) 132 | self.sessions[sid] = types.SimpleNamespace( 133 | id=sid, 134 | addr=data.addr, 135 | hostname=params['hostname'], 136 | key=key, 137 | pid=pid, 138 | ) 139 | 140 | LOGGER.info("pid: %s, sid: %s", pid, sid) 141 | return {"sid": sid, "key": key} 142 | 143 | def destroy_session(self, sid: str): 144 | if sid in self.sessions: 145 | del self.sessions[sid] 146 | return True 147 | return False 148 | 149 | 150 | class IPCServerSocket: 151 | 152 | EVENT_RW = selectors.EVENT_READ | selectors.EVENT_WRITE 153 | 154 | def __init__(self, max_idle: int = 600): 155 | self.selector = selectors.DefaultSelector() 156 | self.handler = MessageHandler() 157 | self.running = False 158 | self.server_socket = None 159 | self.max_idle = max_idle 160 | 161 | def _accept(self, sock): 162 | conn, addr = sock.accept() 163 | conn.setblocking(False) 164 | data = types.SimpleNamespace( 165 | addr=addr, 166 | inb=b'', 167 | outb=b'', 168 | sid=str(uuid.uuid4()), 169 | last_write=False 170 | ) 171 | self.selector.register(conn, selectors.EVENT_READ, data=data) 172 | 173 | def _handle_connection(self, key, mask): 174 | sock = key.fileobj 175 | data = key.data 176 | 177 | if mask & selectors.EVENT_READ: 178 | recv_data = sock.recv(1024) 179 | if recv_data: 180 | delimiter_index = recv_data.find(DELIMITER) 181 | if delimiter_index != -1: 182 | raw_data = data.inb + recv_data[:delimiter_index] 183 | 184 | if delimiter_index < len(recv_data) - 1: 185 | data.inb = recv_data[delimiter_index + 1:] 186 | else: 187 | data.inb = b'' 188 | 189 | try: 190 | data.outb = self.handler.handle_message(raw_data, key) + DELIMITER 191 | self.selector.modify(sock, self.EVENT_RW, data=data) 192 | except IPCAuthError as e: 193 | data.outb = e.raw_message() 194 | data.last_write = True 195 | self.selector.modify(sock, selectors.EVENT_WRITE, data=data) 196 | else: 197 | data.inb += recv_data 198 | else: 199 | self.selector.unregister(sock) 200 | sock.close() 201 | 202 | if mask & selectors.EVENT_WRITE: 203 | if data.outb: 204 | sent = sock.send(data.outb) 205 | data.outb = data.outb[sent:] 206 | 207 | if not data.outb: 208 | if data.last_write: 209 | self.selector.unregister(sock) 210 | sock.close() 211 | else: 212 | self.selector.modify(sock, selectors.EVENT_READ, data=data) 213 | 214 | def start(self, host: str, port: int): 215 | self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 216 | self.server_socket.bind((host, port)) 217 | self.server_socket.listen() 218 | 219 | self.server_socket.setblocking(False) 220 | self.selector.register(self.server_socket, selectors.EVENT_READ, data=None) 221 | 222 | self.running = True 223 | idle = 0 224 | while self.running: 225 | events = self.selector.select(timeout=10) 226 | activated_sids, deactivated_sids = self.active_sesssions() 227 | clients = len(activated_sids) + len(deactivated_sids) 228 | if len(deactivated_sids) > 0: 229 | for sid in deactivated_sids: 230 | logging.info("remove session: %s", sid) 231 | self.handler.destroy_session(sid) 232 | 233 | logging.info( 234 | "Server state: clients %s idle %s", 235 | clients - len(deactivated_sids), idle 236 | ) 237 | 238 | idle = 0 if clients > 0 else idle + 10 239 | if not events and clients == 0 and idle > self.max_idle: 240 | self.running = False 241 | logging.info( 242 | "Server stopped: evnets %s, clients %s, idle %s", 243 | len(events), clients, idle 244 | ) 245 | break 246 | 247 | for key, mask in events: 248 | idle = 0 249 | try: 250 | if key.data is None: 251 | self._accept(key.fileobj) 252 | else: 253 | self._handle_connection(key, mask) 254 | except Exception as e: 255 | print(e) 256 | 257 | def active_sesssions(self): 258 | activated_sids = [] 259 | deactivated_sids = [] 260 | sessions = self.handler.sessions.values() 261 | if len(sessions) == 0: 262 | return activated_sids, deactivated_sids 263 | 264 | pids = psutil.pids() 265 | for s in sessions: 266 | if s.pid not in pids: 267 | deactivated_sids.append(s.id) 268 | else: 269 | activated_sids.append(s.id) 270 | 271 | return activated_sids, deactivated_sids 272 | 273 | def stop(self): 274 | if not self.running: 275 | return 276 | 277 | self.running = False 278 | if self.server_socket: 279 | self.selector.unregister(self.server_socket) 280 | self.server_socket.close() 281 | # Close all client connections 282 | for key in list(self.selector.get_map().values()): 283 | if key.data is not None: # Client socket 284 | self.selector.unregister(key.fileobj) 285 | key.fileobj.close() 286 | self.selector.close() 287 | 288 | 289 | class IPCClientSocket: 290 | def __init__(self, socket_type=socket.AF_INET): 291 | self.sock = socket.socket(socket_type, socket.SOCK_STREAM) 292 | self.connected = False 293 | 294 | def connect(self, target): 295 | self.sock.connect(target) 296 | self.connected = True 297 | 298 | def write(self, data): 299 | if not self.connected: 300 | raise RuntimeError("Not connected to server") 301 | 302 | if isinstance(data, str): 303 | data = data.encode('utf-8') 304 | elif not isinstance(data, bytes): 305 | data = json.dumps(data).encode('utf-8') 306 | 307 | if not data.endswith(DELIMITER): 308 | data += DELIMITER 309 | 310 | self.sock.sendall(data) 311 | 312 | def read(self): 313 | if not self.connected: 314 | raise RuntimeError("Not connected to server") 315 | 316 | buffer = b'' 317 | while True: 318 | chunk = self.sock.recv(1024) 319 | if not chunk: 320 | raise ConnectionError("Connection closed by server") 321 | 322 | buffer += chunk 323 | delimiter_index = buffer.find(DELIMITER) 324 | 325 | if delimiter_index != -1: 326 | message = buffer[:delimiter_index] 327 | return message 328 | 329 | def close(self): 330 | if self.sock and self.connected: 331 | self.sock.close() 332 | self.connected = False 333 | self.sock = None 334 | 335 | def __enter__(self): 336 | return self 337 | 338 | def __exit__(self, exc_type, exc_val, exc_tb): 339 | self.close() 340 | return False 341 | -------------------------------------------------------------------------------- /rcode/ipc/ipc_runner.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from . import IPCServerSocket, DEFAULT_IPC_PORT 3 | 4 | def parse_args(): 5 | parser = argparse.ArgumentParser(description="IPC Server") 6 | parser.add_argument( 7 | "--host", 8 | type=str, 9 | default="127.0.0.1", 10 | help="host to listen on (default: 127.0.0.1)", 11 | ) 12 | parser.add_argument( 13 | "--port", 14 | type=int, 15 | default=DEFAULT_IPC_PORT, 16 | help=f"Port to listen on (default: {DEFAULT_IPC_PORT})", 17 | ) 18 | parser.add_argument( 19 | "--max-idle", 20 | type=int, 21 | default=600, 22 | help="Maximum idle time in seconds (default: 600)", 23 | ) 24 | 25 | return parser.parse_args() 26 | 27 | 28 | def main(): 29 | args = parse_args() 30 | server = IPCServerSocket(args.max_idle) 31 | try: 32 | server.start(args.host, args.port) 33 | except KeyboardInterrupt: 34 | print("\nShutting down IPC server...") 35 | finally: 36 | server.stop() 37 | 38 | 39 | if __name__ == "__main__": 40 | main() 41 | -------------------------------------------------------------------------------- /rcode/rcode.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # MIT 3 | # fork from https://github.com/chvolkmann/code-connect 4 | 5 | import argparse 6 | import os 7 | import subprocess as sp 8 | import time 9 | import subprocess 10 | import sys 11 | import socket 12 | import json 13 | from functools import partial 14 | from pathlib import Path 15 | from os.path import expanduser 16 | from typing import Iterable, List, NoReturn, Sequence, Tuple 17 | 18 | from sshconf import read_ssh_config # type: ignore 19 | from rcode.ipc import IPCClientSocket 20 | 21 | # IPC sockets will be filtered based when they were last accessed 22 | # This gives an upper bound in seconds to the timestamps 23 | DEFAULT_MAX_IDLE_TIME: int = 4 * 60 * 60 24 | 25 | 26 | def fail(*msgs, retcode: int = 1) -> NoReturn: 27 | """Prints messages to stdout and exits the script.""" 28 | for msg in msgs: 29 | print(msg) 30 | exit(retcode) 31 | 32 | 33 | def is_socket_open(path: Path, timeout: int = 2) -> bool: 34 | try: 35 | socket_path = path.resolve() 36 | with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client: 37 | client.settimeout(timeout) 38 | client.connect(str(socket_path)) 39 | return True 40 | except Exception: 41 | return False 42 | 43 | 44 | def sort_by_access_timestamp(paths: Iterable[Path]) -> List[Tuple[float, Path]]: 45 | """Returns a list of tuples (last_accessed_ts, path) sorted by the former.""" 46 | paths_list = [(p.stat().st_atime, p) for p in paths] 47 | paths_list = sorted(paths_list, reverse=True) 48 | return paths_list 49 | 50 | 51 | def next_open_socket(socks: Sequence[Path], is_cursor=False) -> Path: 52 | """Iterates over the list and returns the first socket that is listening.""" 53 | for sock in socks: 54 | if is_socket_open(sock) and get_process_using_socket(sock, is_cursor=is_cursor): 55 | return sock 56 | 57 | fail( 58 | "Could not find an open VS Code IPC socket.", 59 | "", 60 | "Please make sure to connect to this machine with a standard " 61 | "VS Code remote SSH session before using this tool.", 62 | ) 63 | 64 | 65 | def is_remote_vscode() -> bool: 66 | code_repos = Path.home().glob(".vscode-server/bin/*") 67 | return len(list(code_repos)) > 0 and os.getenv("SSH_CLIENT") 68 | 69 | 70 | def is_remote_cursor() -> bool: 71 | code_repos = Path.home().glob(".cursor-server/*") 72 | return len(list(code_repos)) > 0 and os.getenv("SSH_CLIENT") 73 | 74 | 75 | IS_REMOTE_VSCODE = is_remote_vscode() or is_remote_cursor() 76 | IS_RSSH_CLIENT = os.environ.get("RSSH_SID") and os.environ.get("RSSH_SKEY") 77 | 78 | 79 | def get_code_binary() -> Path: 80 | """Returns the path to the most recently accessed code executable.""" 81 | 82 | # Every entry in ~/.vscode-server/bin corresponds to a commit id 83 | # Pick the most recent one 84 | code_repos = sort_by_access_timestamp(Path.home().glob(".vscode-server/bin/*")) 85 | if len(code_repos) == 0: 86 | fail( 87 | "No installation of VS Code Server detected!", 88 | "", 89 | "Please connect to this machine through a remote SSH session and try again.", 90 | "Afterwards there should exist a folder under ~/.vscode-server", 91 | ) 92 | 93 | _, code_repo = code_repos[0] 94 | path = code_repo / "bin" / "code" 95 | if os.path.exists(path): 96 | return path 97 | return code_repo / "bin" / "remote-cli" / "code" 98 | 99 | 100 | def get_cursor_binary() -> Path: 101 | """Returns the path to the most recently accessed code executable.""" 102 | 103 | # Every entry in ~/.vscode-server/bin corresponds to a commit id 104 | # Pick the most recent one 105 | code_repos = sort_by_access_timestamp( 106 | Path.home().glob(".cursor-server/cli/servers/*/server/bin/remote-cli") 107 | ) 108 | if len(code_repos) == 0: 109 | fail( 110 | "No installation of Cursor Server detected!", 111 | "", 112 | "Please connect to this machine through a remote SSH session and try again.", 113 | "Afterwards there should exist a folder under ~/.cursor-server/cli/servers", 114 | ) 115 | _, cursor_executable = code_repos[0] 116 | return cursor_executable / "cursor" 117 | 118 | 119 | def get_process_using_socket(socket_path, is_cursor=False): 120 | """ 121 | Finds the process using the specified socket. 122 | 123 | Args: 124 | socket_path (str): The path to the socket file. 125 | """ 126 | try: 127 | # Use lsof to find the process using the socket 128 | output = subprocess.check_output( 129 | ["lsof", "-t", socket_path], universal_newlines=True 130 | ).strip() 131 | if output: 132 | pid = int(output) 133 | # Get process details using /proc filesystem 134 | with open(f"/proc/{pid}/cmdline", "r") as f: 135 | cmdline = f.read().replace("\x00", " ").strip() 136 | if is_cursor: 137 | return cmdline.find("cursor-server") != -1 138 | else: 139 | return cmdline.find("vscode-server") != -1 140 | except subprocess.CalledProcessError: 141 | # lsof command failed, likely no process using the socket 142 | pass 143 | except FileNotFoundError: 144 | # /proc entries not found, process may have terminated 145 | pass 146 | return False 147 | 148 | 149 | def get_ipc_socket(max_idle_time: int = DEFAULT_MAX_IDLE_TIME, is_cursor=False) -> Path: 150 | """Returns the path to the most recently accessed IPC socket.""" 151 | 152 | # List all possible sockets for the current user 153 | # Some of these are obsolete and not actively listening anymore 154 | uid = os.getuid() 155 | socks = sort_by_access_timestamp( 156 | Path(f"/run/user/{uid}/").glob("vscode-ipc-*.sock") 157 | ) 158 | # Only consider the ones that were active N seconds ago 159 | now = time.time() 160 | sock_list = [sock for ts, sock in socks if now - ts <= max_idle_time] 161 | 162 | # Find the first socket that is open, most recently accessed first 163 | return next_open_socket(sock_list, is_cursor=is_cursor) 164 | 165 | 166 | def send_message(bin_name: str, dirname: str, sid: str, skey: str): 167 | ipc_sock = f"/tmp/rssh-ipc-{sid}.sock" 168 | 169 | try: 170 | sock = IPCClientSocket(socket.AF_UNIX) 171 | sock.connect(ipc_sock) 172 | 173 | payload = { 174 | "method": "open_ide", 175 | "params": { 176 | "sid": sid, 177 | "skey": skey, 178 | "bin": bin_name, 179 | "path": os.path.abspath(dirname), 180 | } 181 | } 182 | sock.write(payload) 183 | res = json.loads(sock.read()) or {} 184 | if res.get("code", -1) != 0: 185 | fail(res.get("message", "Unknown error")) 186 | 187 | finally: 188 | sock.close() 189 | 190 | 191 | def run_remote( 192 | dir_name, max_idle_time: int = DEFAULT_MAX_IDLE_TIME, is_cursor: bool = False 193 | ) -> NoReturn: 194 | if not dir_name: 195 | raise Exception("need dir name here") 196 | 197 | if IS_RSSH_CLIENT: 198 | try: 199 | # communicate with rssh's IPC Socket 200 | sid = os.environ.get("RSSH_SID") 201 | skey = os.environ.get("RSSH_SKEY") 202 | bin_name = "cursor" if is_cursor else "code" 203 | send_message(bin_name, dir_name, sid, skey) 204 | return 205 | except Exception as e: 206 | print(f"Failed to connect to rssh's IPC socket: {e}\ntrying fallback to vscode's IPC socket") 207 | 208 | if IS_REMOTE_VSCODE: 209 | # communicate with vscode's IPC socket 210 | # Fetch the path of the "code" executable 211 | # and determine an active IPC socket to use 212 | code_binary = get_code_binary() if not is_cursor else get_cursor_binary() 213 | ipc_socket = get_ipc_socket(max_idle_time, is_cursor=is_cursor) 214 | args = [str(code_binary)] 215 | args.append(dir_name) 216 | os.environ["VSCODE_IPC_HOOK_CLI"] = str(ipc_socket) 217 | 218 | # run the "code" executable with the proper environment variable set 219 | # stdout/stderr remain connected to the current process 220 | proc = sp.run(args) 221 | # return the same exit code as the wrapped process 222 | exit(proc.returncode) 223 | 224 | 225 | def run_loacl( 226 | dir_name, 227 | remote_name=None, 228 | is_latest=False, 229 | shortcut_name=None, 230 | open_shortcut_name=None, 231 | is_cursor=False, 232 | ): 233 | # run local to open remote 234 | bin_name = "code" if not is_cursor else "cursor" 235 | rcode_home = Path.home() / ".rcode" 236 | ssh_remote = "vscode-remote://ssh-remote+{remote_name}{remote_dir}" 237 | rcode_used_list = [] 238 | is_win = sys.platform == "win32" 239 | if os.path.exists(rcode_home): 240 | with open(rcode_home) as f: 241 | rcode_used_list = list(filter(len, f.read().splitlines())) 242 | if is_latest: 243 | if rcode_used_list: 244 | ssh_remote_latest = rcode_used_list[-1].split(",")[-1].strip() 245 | proc = sp.run([bin_name, "--folder-uri", ssh_remote_latest], shell=is_win) 246 | exit(proc.returncode) 247 | else: 248 | print("Not use rcode before, just use it once") 249 | return 250 | if open_shortcut_name and rcode_used_list: 251 | for l in rcode_used_list: 252 | name, server = l.split(",") 253 | if open_shortcut_name.strip() == name.strip(): 254 | proc = sp.run([bin_name, "--folder-uri", server.strip()], shell=is_win) 255 | # then add it to the latest 256 | with open(rcode_home, "a") as f: 257 | f.write(f"latest,{server}{str(os.linesep)}") 258 | exit(proc.returncode) 259 | else: 260 | raise Exception(f"no short_cut name in your added") 261 | 262 | sshs = read_ssh_config(expanduser("~/.ssh/config")) 263 | hosts = sshs.hosts() 264 | remote_name = remote_name 265 | if remote_name not in hosts: 266 | raise Exception("Please config your .ssh config to use this") 267 | dir_name = expanduser(dir_name) 268 | local_home_dir = expanduser("~") 269 | if dir_name.startswith(local_home_dir): 270 | user_name = sshs.host(remote_name).get("user", "root") 271 | # replace with the remote ~ 272 | dir_name = str(dir_name).replace(local_home_dir, f"/home/{user_name}") 273 | ssh_remote = ssh_remote.format(remote_name=remote_name, remote_dir=dir_name) 274 | with open(rcode_home, "a") as f: 275 | if shortcut_name: 276 | f.write(f"{shortcut_name},{ssh_remote}{str(os.linesep)}") 277 | else: 278 | f.write(f"latest,{ssh_remote}{str(os.linesep)}") 279 | 280 | proc = sp.run([bin_name, "--folder-uri", ssh_remote], shell=is_win) 281 | exit(proc.returncode) 282 | 283 | 284 | def main(is_cursor=False): 285 | parser = argparse.ArgumentParser( 286 | # %(prog)s 287 | usage=""" 288 | rcode 289 | """, 290 | description=""" 291 | just rcode \'file\' like your VSCode \'code\' . 292 | but you should config your ~/.ssh/config first 293 | """, 294 | ) 295 | parser.add_argument("dir", help="dir_name", nargs="?") 296 | parser.add_argument("host", help="ssh hostname", nargs="?") 297 | parser.add_argument( 298 | "-l", 299 | "--latest", 300 | dest="is_latest", 301 | action="store_true", 302 | help="if is_latest", 303 | ) 304 | parser.add_argument( 305 | "-sn", 306 | "--shortcut_name", 307 | dest="shortcut_name", 308 | help="add shortcut name to this", 309 | type=str, 310 | required=False, 311 | ) 312 | parser.add_argument( 313 | "-os", 314 | "--open_shortcut", 315 | dest="open_shortcut", 316 | help="if ", 317 | type=str, 318 | required=False, 319 | ) 320 | options = parser.parse_args() 321 | if IS_RSSH_CLIENT or IS_REMOTE_VSCODE: 322 | run_remote(options.dir, is_cursor=is_cursor) 323 | else: 324 | run_loacl( 325 | options.host, 326 | options.dir, 327 | is_latest=options.is_latest, 328 | shortcut_name=options.shortcut_name, 329 | open_shortcut_name=options.open_shortcut, 330 | is_cursor=is_cursor, 331 | ) 332 | 333 | 334 | cmain = partial(main, is_cursor=True) 335 | 336 | if __name__ == "__main__": 337 | main() 338 | -------------------------------------------------------------------------------- /rcode/rssh.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import subprocess as sp 4 | import argparse 5 | import sys 6 | import socket 7 | import time 8 | import uuid 9 | import json 10 | import os 11 | 12 | from pathlib import Path 13 | 14 | from .ipc import IPCClientSocket, DEFAULT_IPC_PORT 15 | 16 | KEY_FILE = Path.home() / ".rssh/keyfile" 17 | CONFIG_FILE = Path.home() / '.rssh/config' 18 | 19 | 20 | def init_files(): 21 | if not KEY_FILE.parent.exists(): 22 | KEY_FILE.parent.mkdir(parents=True) 23 | 24 | my_key = str(uuid.uuid4()) 25 | if not KEY_FILE.exists(): 26 | KEY_FILE.write_text(my_key) 27 | 28 | if not CONFIG_FILE.exists(): 29 | CONFIG_FILE.write_text("") 30 | 31 | 32 | def find_destination_position(args): 33 | for i, arg in enumerate(args): 34 | if not arg.startswith("-"): 35 | return i 36 | return -1 37 | 38 | 39 | def create_ssh_args(ipc_host: str, ipc_port: int, args: list): 40 | try: 41 | sock = connect_to_rpc_server(ipc_host, ipc_port) 42 | dest_pos = find_destination_position(args) 43 | if dest_pos == -1: 44 | proc = sp.run(["ssh"] + args) 45 | sys.exit(proc.returncode) 46 | 47 | hostname = args[dest_pos] 48 | pre_dest = args[:dest_pos] 49 | post_dest = args[dest_pos:] 50 | if "-t" not in pre_dest: 51 | pre_dest.append("-t") 52 | 53 | session = create_session(sock, hostname) 54 | sock.close() 55 | 56 | sid = session["sid"] 57 | key = session["key"] 58 | addr = f"{ipc_host}:{ipc_port}" 59 | ipc_sock = f"/tmp/rssh-ipc-{sid}.sock" 60 | pre_dest.extend(["-R", f"{ipc_sock}:{addr}"]) 61 | 62 | remote_command = f"export RSSH_SID={sid}; export RSSH_SKEY={key}; exec $SHELL" 63 | post_dest.append(remote_command) 64 | 65 | return pre_dest + post_dest 66 | finally: 67 | sock.close() 68 | 69 | 70 | def start_ipc_server(host: str, port: int): 71 | if sys.platform == "win32": 72 | proc = sp.Popen( 73 | ["rssh-ipc", "--host", host, "--port", str(port)], 74 | stdout=sp.DEVNULL, 75 | creationflags=sp.CREATE_NO_WINDOW, 76 | stderr=sp.STDOUT, 77 | ) 78 | 79 | else: 80 | proc = sp.Popen( 81 | ["rssh-ipc", "--host", host, "--port", str(port)], 82 | stdout=sp.DEVNULL, 83 | stderr=sp.STDOUT, 84 | start_new_session=True 85 | ) 86 | 87 | return proc 88 | 89 | 90 | def connect_to_rpc_server(host: str, port: int): 91 | socks_client = IPCClientSocket() 92 | try: 93 | socks_client.connect((host, port)) 94 | print("Connected to RPC server successfully") 95 | except socket.error: 96 | print("Starting IPC server...") 97 | start_ipc_server(host, port) 98 | time.sleep(0.2) 99 | 100 | if socks_client.connected: 101 | return socks_client 102 | 103 | for _ in range(10): 104 | try: 105 | socks_client = IPCClientSocket() 106 | socks_client.connect((host, port)) 107 | if socks_client.connected: 108 | break 109 | except socket.error: 110 | time.sleep(0.1) 111 | 112 | if not socks_client.connected: 113 | print("Error: Failed to connect to RPC server", file=sys.stderr) 114 | sys.exit(1) 115 | 116 | return socks_client 117 | 118 | 119 | def create_session(sock: IPCClientSocket, hostname: str): 120 | session_payload = { 121 | "method": "new_session", 122 | "params": { 123 | "pid": os.getpid(), 124 | "hostname": hostname, 125 | "keyfile": KEY_FILE.read_text() 126 | }, 127 | } 128 | 129 | sock.write(session_payload) 130 | res = json.loads(sock.read()) 131 | if res.get("code") != 0: 132 | print("Error: Failed to create session, ", res.get("message"), file=sys.stderr) 133 | sys.exit(1) 134 | 135 | return res.get("data") 136 | 137 | 138 | def parse_ipc_args(args): 139 | parser = argparse.ArgumentParser(description="IPC Server") 140 | parser.add_argument( 141 | "--host", 142 | type=str, 143 | default="127.0.0.1", 144 | required=False, 145 | help="host to listen on (default: 127.0.0.1)", 146 | ) 147 | parser.add_argument( 148 | "--port", 149 | type=int, 150 | default=DEFAULT_IPC_PORT, 151 | required=False, 152 | help=f"Port to listen on (default: {DEFAULT_IPC_PORT})", 153 | ) 154 | 155 | argv, args = parser.parse_known_args(args) 156 | 157 | return argv.host, argv.port, args 158 | 159 | 160 | def run_ssh(ssh_args): 161 | if sys.platform == "win32": 162 | proc = sp.run( 163 | ['ssh'] + ssh_args, 164 | stdin=sys.stdin, 165 | stdout=sys.stdout, 166 | stderr=sys.stderr 167 | ) 168 | sys.exit(proc.returncode) 169 | else: 170 | os.execvp("ssh", ["ssh"] + ssh_args) 171 | 172 | 173 | def launch(args): 174 | init_files() 175 | 176 | if "-R" in args or "-T" in args: 177 | print("Error: -R and -T isn't allowed when using rssh.", file=sys.stderr) 178 | sys.exit(1) 179 | 180 | ipc_host, ipc_port, ssh_args = parse_ipc_args(args) 181 | try: 182 | ssh_args = create_ssh_args(ipc_host, ipc_port, ssh_args) 183 | run_ssh(ssh_args) 184 | except ConnectionError: 185 | print("Error: Failed to connect to RPC server", file=sys.stderr) 186 | sys.exit(1) 187 | except KeyboardInterrupt: 188 | pass 189 | 190 | 191 | def main(): 192 | launch(sys.argv[1:]) 193 | 194 | 195 | def ssh_wrapper(): 196 | args = sys.argv[1:] 197 | 198 | if "--rssh" in args: 199 | args.remove("--rssh") 200 | print("rssh is enabled") 201 | launch(args) 202 | else: 203 | run_ssh(args) 204 | 205 | 206 | if __name__ == "__main__": 207 | main() 208 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | VERSION = "0.6.2" 4 | 5 | setup( 6 | name="rcode", 7 | version=VERSION, 8 | description="vscode remode code .", 9 | keywords="python vscode", 10 | author="chvolkmann, yihong0618", 11 | author_email="zouzou0208@gmail.com", 12 | url="https://github.com/yihong0618/code-connect", 13 | packages=find_packages(), 14 | include_package_data=True, 15 | zip_safe=True, 16 | install_requires=["sshconf", "psutil"], 17 | classifiers=[ 18 | "Development Status :: 4 - Beta", 19 | "Environment :: Console", 20 | "Intended Audience :: Developers", 21 | "Operating System :: OS Independent", 22 | "Programming Language :: Python :: 3.8", 23 | "Programming Language :: Python :: 3.9", 24 | "Programming Language :: Python :: 3.10", 25 | "Programming Language :: Python :: 3.11", 26 | "Programming Language :: Python :: 3.12", 27 | "Topic :: Software Development :: Libraries", 28 | ], 29 | entry_points={ 30 | "console_scripts": [ 31 | "rcode = rcode.rcode:main", 32 | "rcursor = rcode.rcode:cmain", 33 | "rssh = rcode.rssh:main", 34 | "rssh-ipc = rcode.ipc.ipc_runner:main", 35 | "ssh-wrapper = rcode.rssh:ssh_wrapper", 36 | ], 37 | }, 38 | ) 39 | --------------------------------------------------------------------------------