From: Szymon Wilczek <[email protected]>
Date: 6 May 2026
Subject: [PATCH] feat(remote-ssh): support ":connect ssh://"
Merged: 3 Nov 2026, by Justin M. Keyes
signed, reported, fixes
- Signed-off-by Szymon Wilczek author
- Merged-by Justin M. Keyes maintainer · merged it
diff
@@ -345,6 +345,8 @@ EDITOR < Typing õõ in |Normal-mode| now invokes a mapping bound to ]]. • New |complete-items| field "commit_chars": characters that accept the selected completion item when typed.+• |:connect| with `ssh://` URI orchestrates a remote Nvim session over SSH.+ See |remote-ssh|. EVENTS @@ -0,0 +1,91 @@+*remote_ssh.txt* Nvim++ NVIM REFERENCE MANUAL++Remote SSH *remote-ssh* *remote-ssh-plugin*++Nvim can connect to remote SSH servers.+Using the |:connect| command with an `ssh://` URI, Nvim will automatically+connect to a host, bootstrap itself, and connect the local UI to the remote+headless server.++ Type |gO| to see the table of contents.++==============================================================================+1. Introduction *remote-ssh-introduction* *E6101*++The remote-ssh feature uses `ssh` to connect to a remote system. It:+- Detects the remote architecture.+- Installs the Nvim release matching the local version (the current nightly+ for prerelease builds) under `~/.local/share/nvim-remote/releases/`,+ leaving any Nvim already installed on the remote host untouched.+- Starts the remote Nvim with `NVIM_APPNAME=nvim-remote`, keeping remote+ state separate from any local Nvim installation on the remote host.+- Initiates a headless Nvim server and connects your local UI via a Unix+ domain socket.+- On disconnect, kills the remote headless process and SSH mux socket, but+ leaves the persistent data directory (`~/.local/share/nvim-remote`) intact+ for fast reconnects.++==============================================================================+2. Usage *remote-ssh-usage*++From the command line: ~++Connect to a remote server using `-c` with |:connect|. SSH key-based+authentication is required; password prompts are not supported.+>sh+ nvim -c 'connect ssh://[email protected]'+ nvim -c 'connect ssh://root@server:2222'+<++From a running Nvim session: ~++`:connect` ssh://{uri}++Connects to {uri} over SSH. The remote-ssh feature bootstraps the remote+host and reconnects the local UI to the remote server.+>vim+ :connect ssh://[email protected]+ :connect ssh://root@server:2222+<++`:connect` {address}++Without the `ssh://` prefix, `:connect` connects to a local server at the+given address (e.g. a Unix socket path).++==============================================================================+3. Requirements *remote-ssh-requirements*++Both local and remote systems must meet certain requirements:++Local (Host):+- `ssh` (OpenSSH 4.0+) installed and accessible in `$PATH`.+- Multiplexing support (`ControlMaster`). Note: Win32-OpenSSH does not+ support multiplexing, so Windows clients are not supported currently.+- `/tmp` directory must be writable.++Remote (Target):+- Linux (x86_64, aarch64) or macOS (arm64, x86_64).+- `tar`, `curl` to download and extract the Nvim release.+- A POSIX `sh`; the login shell itself may be any shell.+- A writable `/tmp` for the server socket.++==============================================================================+4. Remote Environment *remote-ssh-configs*++The remote Nvim starts with `NVIM_APPNAME=nvim-remote`, which isolates its+configuration and data directories from any existing Nvim installation on+the remote host. See |$NVIM_APPNAME|.++Remote paths used:+ - Config: `~/.config/nvim-remote/`+ - Data: `~/.local/share/nvim-remote/`+ - Binary: `~/.local/share/nvim-remote/releases/{version}/bin/nvim`++The data directory persists across connections for fast reconnects. On+teardown, only the headless process and SSH mux socket are cleaned up.++==============================================================================+ vim:tw=78:ts=8:ft=help:norl: @@ -1,4 +1,5 @@ local uv = vim.uv+local M = {} --- @class vim.SystemOpts --- @inlinedoc@@ -45,6 +46,12 @@ local uv = vim.uv --- @field stdout? string `nil` if stdout is disabled or has a custom handler. --- @field stderr? string `nil` if stderr is disabled or has a custom handler. +--- @class (package) vim.SystemRunWaitResult : vim.SystemObj+--- @field code? integer Exit code, if the process exited before the predicate matched.+--- @field signal? integer Exit signal, if the process exited before the predicate matched.+--- @field stdout string Captured stdout so far.+--- @field stderr string Captured stderr so far.+ --- @class (package) vim.SystemState --- @field cmd string[] --- @field handle? uv.uv_process_t@@ -78,7 +85,7 @@ end --- @class vim.SystemObj --- @field cmd string[] Command name and args --- @field pid integer Process ID---- @field private _state vim.SystemState+--- @field package _state vim.SystemState local SystemObj = {} --- @param state vim.SystemState@@ -211,6 +218,54 @@ function SystemObj:is_closing() return handle == nil or handle:is_closing() or false end +--- @nodoc+--- Runs a command and waits until it exits or streamed output satisfies a predicate.+---+--- Unlike `vim.system(...):wait()`, this can return before the process exits. That is needed for+--- long-lived commands such as `ssh -L ...`, which print a readiness marker and then keep running.+---+--- @param cmd string[] Command to execute.+--- @param opts? { stdin?: string } `stdin` is written to the process, then closed.+--- @param wait_until? fun(stdout: string, stderr: string): boolean Predicate checked after output.+--- @param timeout? integer Time limit in ms.+--- @return vim.SystemRunWaitResult+function M.run_wait(cmd, opts, wait_until, timeout)+ vim.validate('cmd', cmd, 'table')+ vim.validate('opts', opts, 'table', true)+ vim.validate('wait_until', wait_until, 'function', true)+ vim.validate('timeout', timeout, 'number', true)++ local obj = vim.system(cmd, { text = true, stdin = opts and opts.stdin }) --[[@as vim.SystemRunWaitResult]]+ local state = obj._state++ local function stdout()+ return table.concat(state.stdout_data or {})+ end++ local function stderr()+ return table.concat(state.stderr_data or {})+ end++ local success = vim.wait(timeout or vim._maxint, function()+ return state.result ~= nil or (wait_until and wait_until(stdout(), stderr())) or false+ end, 50, true)++ if not success then+ if not obj:is_closing() then+ obj:kill('sigterm')+ end+ obj:wait(1000)+ error('command timed out')+ end++ obj.code = state.result and state.result.code or nil+ obj.signal = state.result and state.result.signal or nil+ obj.stdout = state.result and state.result.stdout or stdout()+ obj.stderr = state.result and state.result.stderr or stderr()++ return obj+end+ --- @param output? fun(err: string?, data: string?)|boolean --- @param text? boolean --- @return uv.uv_stream_t? pipe@@ -542,3 +597,5 @@ function vim.system(cmd, opts, on_exit) end return run(cmd, opts, on_exit) end++return M @@ -666,6 +666,61 @@ local function check_external_tools() 'Install curl using your package manager.', }) end++ if vim.fn.executable('ssh') == 1 then+ local ssh_path = vim.fn.exepath('ssh')+ local ssh_job = vim.system({ ssh_path, '-V' }, { text = true }):wait()+ -- ssh -V prints version to stderr+ local ssh_out = vim.trim(ssh_job.stderr or '')+ if ssh_job.code == 0 then+ health.ok(('%s (%s)'):format(ssh_out, ssh_path))++ local version_str = ssh_out:match('OpenSSH_([%d%.]+)')+ local ver = version_str and vim.version.parse(version_str)+ if ver then+ if vim.fn.has('win32') == 1 then+ health.warn(+ 'Win32-OpenSSH does not support ControlMaster (multiplexing). Remote SSH features might be degraded or unsupported.'+ )+ elseif ver >= vim.version.parse('4.0') then+ health.ok('OpenSSH version supports multiplexing')+ else+ health.warn(+ 'OpenSSH version is older than 4.0. Multiplexing is not supported, remote connections may be slow or fail.'+ )+ end+ else+ health.warn('Could not determine OpenSSH version. Multiplexing support is unknown.')+ end++ if vim.fn.has('win32') == 0 then+ local ssh_g = vim.system({ ssh_path, '-G', 'example.com' }):wait()+ if ssh_g.code == 0 then+ local ssh_cfg = ssh_g.stdout or ''+ local controlmaster = ssh_cfg:match('controlmaster%s+(%S+)')+ local controlpath = ssh_cfg:match('controlpath%s+(%S+)')+ if controlmaster and controlmaster ~= 'no' then+ health.ok(('SSH multiplexing configured (ControlMaster=%s)'):format(controlmaster))+ elseif controlpath and controlpath ~= 'none' then+ health.ok(('SSH ControlPath resolved to: %s'):format(controlpath))+ else+ health.info(+ 'SSH multiplexing not configured in ssh_config. :connect will set it automatically.'+ )+ end+ else+ health.warn('`ssh -G` failed. Cannot verify SSH multiplexing configuration.')+ end+ end+ else+ health.warn('ssh is installed but failed to run `ssh -V`', { ssh_job.stderr })+ end+ else+ health.warn('ssh not found', {+ 'Required for remote SSH connections (:connect ssh://).',+ 'Install ssh using your package manager.',+ })+ end end local function detect_terminal() @@ -22,8 +22,15 @@ local function is_multi_value_directive(param) return vim.list_contains(multi_value_directives, param:lower()) end +---@class vim.net.SshHost+---@field alias string Host alias from ssh config+---@field hostname? string Resolved Hostname directive+---@field user? string User directive+---@field port? string Port directive+---@field identity_file? string IdentityFile directive+ ---@param text string The ssh configuration which needs to be parsed----@return string[] The parsed host names in the configuration+---@return vim.net.SshHost[] The parsed host configurations function M.parse_ssh_config(text) local i = 1 local line = 1@@ -176,66 +183,404 @@ function M.parse_ssh_config(text) return node end - local hostnames = {}+ ---@type vim.net.SshHost[]+ local hosts = {}+ local seen = {} ---@type table<string, boolean>+ ---@type vim.net.SshHost[]+ local current_hosts = {} ---@param value string local function is_valid(value)- return not (value:find('[?*!]') or vim.list_contains(hostnames, value))+ return not (value:find('[?*!]') or seen[value])+ end++ local function flush()+ for _, h in ipairs(current_hosts) do+ table.insert(hosts, h)+ end+ current_hosts = {}+ end++ ---@param aliases string[]+ local function add_aliases(aliases)+ flush()+ for _, alias in ipairs(aliases) do+ if is_valid(alias) then+ seen[alias] = true+ table.insert(current_hosts, { alias = alias })+ end+ end end while chr do local node = parse_line() if node then- -- This is done just to assign the type- node.value = node.value ---@type string[]- if node.param:lower() == 'match' and node.value then- local current = nil- for ind, val in ipairs(node.value) do- if- val:lower() == 'host'- and ind + 1 <= #node.value- and is_valid(assert(node.value[ind + 1]))- then- current = node.value[ind + 1]+ local lp = node.param:lower()+ if lp == 'match' and node.value then+ local values = node.value --[[@as string[] ]]+ local match_aliases = {} ---@type string[]+ for ind, val in ipairs(values) do+ if val:lower() == 'host' and ind + 1 <= #values and is_valid(assert(values[ind + 1])) then+ table.insert(match_aliases, values[ind + 1]) end end- if current then- table.insert(hostnames, current)- end- elseif node.param:lower() == 'host' and node.value then- for _, value in ipairs(node.value) do+ add_aliases(match_aliases)+ elseif lp == 'host' and node.value then+ local valid = {} ---@type string[]+ for _, value in+ ipairs(node.value --[[@as string[] ]])+ do if is_valid(value) then- table.insert(hostnames, value)+ table.insert(valid, value)+ end+ end+ add_aliases(valid)+ else+ local val = (type(node.value) == 'string' and node.value or nil) --[[@as string?]]+ if val then+ for _, h in ipairs(current_hosts) do+ if lp == 'hostname' then+ h.hostname = val+ elseif lp == 'user' then+ h.user = val+ elseif lp == 'port' then+ h.port = val+ elseif lp == 'identityfile' then+ h.identity_file = val+ end end end end end end+ flush() - return hostnames+ return hosts end ---@param filename string----@return string[] The hostnames configured in the file located at filename+---@return vim.net.SshHost[] The host configurations in the file function M.parse_config(filename)- local file = io.open(filename, 'r')- if not file then- error('Cannot read ssh configuration file')+ local text = vim.fn.readblob(filename)+ return M.parse_ssh_config(text)+end++---@param filename? string Path to the SSH config file. Defaults to ~/.ssh/config+---@return string[] The hostnames configured in the SSH config file.+--- Note: This does not currently process `Include` directives.+function M.get_hosts(filename)+ filename = filename or vim.fs.normalize('~/.ssh/config')+ local ok, hosts = pcall(M.parse_config, filename)+ if not ok then+ return {} end- local config_string = file:read('*a')- file:close()+ return vim.tbl_map(+ ---@param h vim.net.SshHost+ function(h)+ return h.alias+ end,+ hosts+ )+end++local _log --- @type vim.Log? - return M.parse_ssh_config(config_string)+--- Lazy `vim.log` instance for the remote-ssh feature. Writes to `stdpath('log')/remote-ssh.log`.+---@return vim.Log+local function log()+ if not _log then+ _log = vim.log.new({ name = 'remote-ssh', current_level = vim.log.levels.INFO })+ end+ return _log+end++---@param msg string+---@param level? integer vim.log.levels.* (default INFO)+local function notify(msg, level)+ level = level or vim.log.levels.INFO+ if level >= vim.log.levels.ERROR then+ log().error(msg)+ elseif level >= vim.log.levels.WARN then+ log().warn(msg)+ else+ log().info(msg)+ end+ local function show()+ vim.notify('remote-ssh: ' .. msg, level)+ vim.cmd.redraw()+ end+ if vim.in_fast_event() then+ vim.schedule(show)+ else+ show()+ end+end++local function core_system()+ -- Keep this lazy so parser-only users can load vim.net._ssh without editor state.+ return require('vim._core.system') end ----@return string[] The hostnames configured in the ssh configuration file---- located at "~/.ssh/config".---- Note: This does not currently process `Include` directives in the---- configuration file.-function M.get_hosts()- local config_path = vim.fs.normalize('~/.ssh/config') ---@type string+--- Builds a command for the host identified by `ssh_uri`.+---+--- `ssh_args` are inserted before the target; `remote_cmd` is appended after the target.+---+---@param ssh_uri {host:string, user?:string, port?:string}+---@param opts? { ssh_args?: string[], remote_cmd?: string[] }+---@return string[]+local function get_ssh_cmd(ssh_uri, opts)+ opts = opts or {}+ local mux_dir = vim.fn.stdpath('cache') .. '/ssh' --[[@as string]]+ vim.fn.mkdir(mux_dir, 'p')+ -- `%C` is SSH's hash of (localhost, host, port, user).+ -- Its fixed length keeps the ControlPath socket under the 104-byte `sun_path` limit,+ -- which the raw `%h_%p_%r` name plus a long base dir can exceed.+ local mux_path = mux_dir .. '/mux_%C'+ local ssh_cmd = {+ 'ssh',+ '-T',+ '-o',+ 'ControlMaster=auto',+ '-o',+ 'ControlPath=' .. mux_path,+ '-o',+ 'ControlPersist=10m',+ }+ if opts.ssh_args then+ vim.list_extend(ssh_cmd, opts.ssh_args)+ end+ if ssh_uri.port then+ table.insert(ssh_cmd, '-p')+ table.insert(ssh_cmd, ssh_uri.port)+ end+ ---@type string+ local target = ssh_uri.host+ if ssh_uri.user then+ target = ssh_uri.user .. '@' .. ssh_uri.host+ end+ table.insert(ssh_cmd, target)++ if opts.remote_cmd then+ vim.list_extend(ssh_cmd, opts.remote_cmd)+ end++ return ssh_cmd+end++--- Runs `script` via `sh -s` over stdin, so the remote login shell never re-parses it+---+---@param uri {host:string, user?:string, port?:string}+---@param script string POSIX shell script.+---@param opts? { ssh_args?: string[], wait_until?: fun(stdout: string, stderr: string): boolean }+---@return vim.SystemRunWaitResult+local function run_script(uri, script, opts)+ opts = opts or {}+ local ssh_cmd = get_ssh_cmd(uri, { ssh_args = opts.ssh_args, remote_cmd = { 'sh', '-s' } })+ log().debug('run_script: running', ssh_cmd)+ return core_system().run_wait(ssh_cmd, { stdin = script }, opts.wait_until, 300000)+end++--- Gets the operating system and architecture from the remote system.+---+---@param uri {host:string, user?:string, port?:string}+---@return string os, string arch+function M.get_system_info(uri)+ local ssh_cmd = get_ssh_cmd(uri, { remote_cmd = { 'uname -s && uname -m' } })+ log().debug('get_system_info: running', ssh_cmd)++ local obj = core_system().run_wait(ssh_cmd, nil, nil, 300000)+ log().debug('get_system_info: code', obj.code, 'stdout', obj.stdout, 'stderr', obj.stderr)+ if obj.code ~= 0 then+ error(+ 'Failed to detect remote system info: ' .. (obj.stderr ~= '' and obj.stderr or obj.stdout)+ )+ end++ local lines = vim.split(vim.trim(obj.stdout), '\n', { plain = true })+ local valid_lines = {}+ for _, line in ipairs(lines) do+ if vim.trim(line) ~= '' then+ table.insert(valid_lines, vim.trim(line))+ end+ end++ if #valid_lines < 2 then+ error('Unexpected output from system info detection: ' .. obj.stdout)+ end++ ---@type string+ local os = valid_lines[#valid_lines - 1]:lower()+ ---@type string+ local arch = valid_lines[#valid_lines]:lower()++ if os:match('msys') or os:match('windows') or os:match('mingw') or os:match('cygwin') then+ error('Not implemented yet: Windows targets are not supported.')+ end++ return os, arch+end++--- Installs the matching Nvim release on the remote host, unless present.+--- Uses a per-version dir, never touching the user's own Nvim.+---+---@param uri {host:string, user?:string, port?:string}+---@param os string+---@param arch string+---@return string nvim Remote path of the `nvim` binary, as a shell word.+local function check_and_install(uri, os, arch)+ local ver = vim.version()+ local base = ('v%d.%d.%d'):format(ver.major, ver.minor, ver.patch)+ -- prereleases: install the current nightly, once per version+ local tag = ver.prerelease and 'nightly' or base+ local install_name = ver.prerelease and ('nightly-' .. base) or base++ local os_map = { linux = 'linux', darwin = 'macos' }+ local arch_map = { x86_64 = 'x86_64', aarch64 = 'arm64', arm64 = 'arm64' }++ local target_os = os_map[os]+ local target_arch = arch_map[arch]+ if not target_os or not target_arch then+ error(string.format('Unsupported OS/Arch combination: %s/%s', os, arch))+ end++ local release_name = string.format('nvim-%s-%s', target_os, target_arch)+ local release_url = string.format(+ 'https://github.com/neovim/neovim/releases/download/%s/%s.tar.gz',+ tag,+ release_name+ )+ local dest = '"$HOME"/.local/share/nvim-remote/releases/' .. install_name++ local remote_script = string.format(+ [[+ set -eu+ dest=%s+ if [ -x "$dest/bin/nvim" ]; then+ exit 0+ fi++ echo "Installing Nvim %s..." >&2+ mkdir -p "${dest%%/*}"+ tmp=$(mktemp -d "$dest.XXXXXX")+ trap 'rm -rf "$tmp"' EXIT+ curl -fsSL -o "$tmp/nvim.tar.gz" '%s'+ tar -xzf "$tmp/nvim.tar.gz" -C "$tmp"+ # atomic rename; keep a concurrent install that finished first+ [ -e "$dest" ] || mv "$tmp/%s" "$dest"+ ]],+ dest,+ install_name,+ release_url,+ release_name+ )++ log().debug('check_and_install: install', install_name, 'os', target_os, 'arch', target_arch)++ local obj = run_script(uri, remote_script)+ log().debug('check_and_install: code', obj.code, 'stdout', obj.stdout, 'stderr', obj.stderr)+ if obj.code ~= 0 then+ error('Installation failed: ' .. (obj.stderr ~= '' and obj.stderr or obj.stdout))+ end++ return dest .. '/bin/nvim'+end++--- @param uri_str string+--- @return string local_socket path to the local forwarded socket+--- @return vim.net.SshUri uri parsed SSH URI for cleanup+--- @return fun() teardown function to close the SSH master connection+function M.start(uri_str)+ local uri = require('vim.uri')._parse_ssh_uri(uri_str)+ notify('Connecting to ' .. uri_str .. '...')+ local os, arch = M.get_system_info(uri)++ notify('Checking remote Nvim install...')+ local nvim = check_and_install(uri, os, arch)++ local local_sock = vim.fn.tempname() .. '_remote_nvim.sock'++ -- Fresh private dir per session: `mkdir` fails if it exists,+ -- so nobody can plant a socket+ local token = assert(vim.uv.random(8)):gsub('.', function(c)+ return ('%02x'):format(c:byte())+ end)+ local remote_dir = '/tmp/nvim-remote.' .. token+ local remote_sock = remote_dir .. '/nvim.sock'++ local remote_cmd = string.format(+ [[+ dir='%s'+ sock="$dir/nvim.sock"+ mkdir -m 700 "$dir" || exit 1+ NVIM_PID=+ trap 'kill $NVIM_PID 2>/dev/null; rm -rf "$dir"' EXIT+ trap 'exit 1' HUP INT TERM PIPE+ NVIM_APPNAME=nvim-remote %s --headless --listen "$sock" </dev/null &+ NVIM_PID=$!+ while [ ! -S "$sock" ]; do+ if ! kill -0 $NVIM_PID 2>/dev/null; then+ echo "NVIM_CRASHED"+ exit 1+ fi+ sleep 0.1+ done+ echo "NVIM_READY"+ # no pty, so no SIGHUP on disconnect: a failed heartbeat write triggers the EXIT trap+ while kill -0 $NVIM_PID 2>/dev/null; do+ sleep 1+ printf '\n' 2>/dev/null || exit 1+ done+ ]],+ remote_dir,+ nvim+ )++ local forward = local_sock .. ':' .. remote_sock++ notify('Establishing SSH tunnel...')+ log().debug('start: local_sock', local_sock)+ local tunnel = run_script(uri, remote_cmd, {+ ssh_args = { '-L', forward },+ wait_until = function(stdout)+ return stdout:match('NVIM_READY') ~= nil+ end,+ })+ log().debug('start: tunnel stdout', tunnel.stdout, 'stderr', tunnel.stderr)++ -- no NVIM_READY means the tunnel already exited+ if not tunnel.stdout:match('NVIM_READY') then+ -- the forward lives in the ssh master, which outlives this session+ vim.system(get_ssh_cmd(uri, { ssh_args = { '-O', 'cancel', '-L', forward } })):wait()+ local msg = tunnel.stdout:match('NVIM_CRASHED') and 'Remote Nvim crashed during startup'+ or 'SSH tunnel exited before the remote Nvim was ready'+ log().error(msg, 'code', tunnel.code, 'stderr', tunnel.stderr)+ local detail = vim.trim(tunnel.stderr)+ error(detail ~= '' and (msg .. ': ' .. detail) or msg)+ end++ notify('Connected to ' .. uri_str)++ local cleaned_up = false++ local function teardown()+ if cleaned_up then+ return+ end+ cleaned_up = true+ log().info('teardown: closing ssh mux for ' .. uri_str)+ local stop_cmd = get_ssh_cmd(uri, { ssh_args = { '-O', 'exit' } })+ vim.system(stop_cmd):wait()+ -- Wait for the original tunnel process to close its stdio handles.+ if not tunnel:is_closing() then+ tunnel:wait(1000)+ end+ end++ vim.api.nvim_create_autocmd('VimLeavePre', {+ callback = teardown,+ }) - return M.parse_config(config_path)+ return local_sock, uri, teardown end return M @@ -215,4 +215,34 @@ function M.to_buf(uri) return vim.fn.bufadd(M.to_fname(uri)) end +---@class vim.net.SshUri+---@nodoc+---@field host string+---@field user? string+---@field port? string++---@param str string+---@return vim.net.SshUri+function M._parse_ssh_uri(str)+ ---@type vim.net.SshUri+ local uri = { host = '' }+ ---@type string?+ local scheme_match = str:match('^ssh://(.*)')+ if scheme_match then+ str = scheme_match+ end+ local user, rest = str:match('^(.-)@(.*)$')+ if user and rest then+ uri.user = user+ str = rest+ end+ local port_match = str:match(':(%d+)$')+ if port_match then+ uri.port = port_match+ str = str:sub(1, -(#port_match + 2))+ end+ uri.host = str+ return uri+end+ return M @@ -5912,13 +5912,36 @@ static void ex_detach(exarg_T *eap) /// Connects the current UI to a different server /// /// ":connect <address>" detaches the current UI and connects to the given server.+/// ":connect ssh://<uri>" bootstraps a remote Nvim over SSH, then connects. /// ":connect! <address>" stops the current server if no other UIs are attached, then connects to the given server. static void ex_connect(exarg_T *eap) {+ char *addr = eap->arg;+ typval_T ssh_rettv = { .v_type = VAR_UNKNOWN };++ // SSH URI: bootstrap remote Nvim, get local forwarded socket+ if (strncmp(addr, "ssh://", 6) == 0) {+ typval_T argvars[2];+ argvars[0].v_type = VAR_STRING;+ argvars[0].vval.v_string = addr;+ argvars[1].v_type = VAR_UNKNOWN;++ nlua_call_typval("vim.net._ssh", "start", argvars, &ssh_rettv);++ if (ssh_rettv.v_type != VAR_STRING || ssh_rettv.vval.v_string == NULL) {+ emsg("E6101: SSH connection failed");+ tv_clear(&ssh_rettv);+ return;+ }++ addr = ssh_rettv.vval.v_string;+ }+ bool stop_server = eap->forceit ? (ui_active() == 1) : false; Error err = ERROR_INIT;- remote_ui_connect(current_ui, eap->arg, &err);+ remote_ui_connect(current_ui, addr, &err);+ tv_clear(&ssh_rettv); if (ERROR_SET(&err)) { emsg(err.msg); @@ -1,7 +1,12 @@ local t = require('test.testutil')+local n = require('test.functional.testnvim')() local parser = require('vim.net._ssh') local describe, it = t.describe, t.it+local before_each, after_each = t.before_each, t.after_each+local clear = n.clear local eq = t.eq+local is_os = t.is_os+local skip = t.skip describe('SSH parser', function() it('parses SSH configuration strings', function()@@ -40,13 +45,18 @@ describe('SSH parser', function() IdentitiesOnly yes ]] - eq({- 'dev',- 'prod',- 'test',- 'quoted string',- 'gh',- }, parser.parse_ssh_config(config))+ local hosts = parser.parse_ssh_config(config)+ eq(+ { 'dev', 'prod', 'test', 'quoted string', 'foo', 'gh' },+ vim.tbl_map(function(h)+ return h.alias+ end, hosts)+ )+ eq('dev.example.com', hosts[1].hostname)+ eq('devuser', hosts[1].user)+ eq('2222', hosts[1].port)+ eq('198.51.100.10', hosts[2].hostname)+ eq('admin', hosts[2].user) end) it('ignores repeated and trailing separators', function()@@ -58,7 +68,12 @@ describe('SSH parser', function() '', }, '\n') - eq({ 'alpha', 'beta' }, parser.parse_ssh_config(config))+ eq(+ { 'alpha', 'beta' },+ vim.tbl_map(function(h)+ return h.alias+ end, parser.parse_ssh_config(config))+ ) end) it('fails when a quote is not closed', function()@@ -89,3 +104,310 @@ describe('SSH parser', function() eq(false, ok) end) end)++describe('vim.net._ssh', function()+ local fake_bin_dir++ local function setup_fake_ssh(behavior)+ behavior = behavior or {}+ local is_windows = package.config:sub(1, 1) == '\\'+ fake_bin_dir = t.tmpname()+ os.remove(fake_bin_dir)+ vim.uv.fs_mkdir(fake_bin_dir, 511)+ local fake_ssh_path = fake_bin_dir .. (is_windows and '/ssh.cmd' or '/ssh')+ local fake_tunnel_pid = fake_bin_dir .. '/tunnel.pid'++ local script+ if is_windows then+ script = t.dedent([=[+ @echo off+ setlocal EnableDelayedExpansion+ set "ARGS=%*"++ echo(!ARGS! | findstr /C:"uname -s && uname -m" >nul+ if not errorlevel 1 (+ ]=] .. (behavior.uname or [=[+ echo Linux+ echo x86_64+ ]=]) .. [=[+ exit /b 0+ )++ echo(!ARGS! | findstr /C:"-L" >nul+ if not errorlevel 1 (+ echo(!ARGS! | findstr /C:"ControlMaster" >nul+ if errorlevel 1 (+ echo FAIL: Multiplexing flags missing! 1>&2+ exit /b 1+ )+ ]=] .. (behavior.tunnel or [=[+ set "SOCK="+ set "NEXT_L="+ for %%A in (%*) do (+ if defined NEXT_L (+ set "SOCK=%%~A"+ set "NEXT_L="+ ) else if "%%~A"=="-L" (+ set "NEXT_L=1"+ )+ )+ if not defined SOCK (+ echo Unexpected SSH command: !ARGS! 1>&2+ exit /b 1+ )+ > "!SOCK!" type NUL+ exit /b 0+ ]=]) .. [=[+ )++ echo(!ARGS! | findstr /C:"TARGET_VER" >nul+ if not errorlevel 1 (+ ]=] .. (behavior.installer or [=[+ echo Installing Neovim... 1>&2+ exit /b 0+ ]=]) .. [=[+ )++ echo(!ARGS! | findstr /C:"-O" >nul+ if not errorlevel 1 (+ echo(!ARGS! | findstr /C:"exit" >nul+ if not errorlevel 1 exit /b 0+ )++ echo Unexpected SSH command: !ARGS! 1>&2+ exit /b 1+ ]=])+ else+ script = t.dedent([=[+ #!/usr/bin/env bash+ ARGS="$*"+ if [[ "$ARGS" == *" sh -s" ]]; then+ ARGS="$ARGS $(cat)"+ fi+ PID_FILE=]=] .. string.format('%q', fake_tunnel_pid) .. [=[++ ARGS_FILE=]=] .. string.format('%q', fake_bin_dir .. '/tunnel.args') .. [=[++ if [[ "$ARGS" == *"uname -s && uname -m"* ]]; then+ ]=] .. (behavior.uname or [=[+ echo "Linux"+ echo "x86_64"+ ]=]) .. [=[+ exit 0+ fi++ if [[ "$ARGS" == *"-O cancel -L"* ]]; then+ touch ]=] .. string.format('%q', fake_bin_dir .. '/forward.cancelled') .. [=[++ exit 0+ fi++ if [[ "$ARGS" == *"-L"* ]]; then+ if [[ "$ARGS" != *"ControlMaster"* ]]; then+ echo "FAIL: Multiplexing flags missing!" >&2+ exit 1+ fi+ if [[ "$ARGS" != *"sh -s"*"NVIM_READY"* ]]; then+ echo "FAIL: tunnel script not fed to sh -s" >&2+ exit 1+ fi+ printf '%s' "$ARGS" > "$ARGS_FILE"+ ]=] .. (behavior.tunnel or [=[+ SOCK=$(echo "$ARGS" | grep -oE '\-L [^:]+' | cut -d' ' -f2)+ sleep 60 &+ SLEEP_PID=$!+ echo "$$ $SLEEP_PID" > "$PID_FILE"+ trap 'kill "$SLEEP_PID" 2>/dev/null || true; rm -f "$PID_FILE"; exit 0' TERM INT EXIT+ echo "NVIM_READY"+ touch "$SOCK"+ wait "$SLEEP_PID"+ ]=]) .. [=[+ fi++ if [[ "$ARGS" == *"releases/download/"* ]]; then+ printf '%s' "$ARGS" > ]=] .. string.format('%q', fake_bin_dir .. '/install.args') .. [=[++ ]=] .. (behavior.installer or [=[+ echo "Installing Neovim..." >&2+ exit 0+ ]=]) .. [=[+ fi++ if [[ "$ARGS" == *"-O"*"exit"* ]]; then+ if [[ -f "$PID_FILE" ]]; then+ set -- $(cat "$PID_FILE")+ kill "$2" 2>/dev/null || true+ kill "$1" 2>/dev/null || true+ fi+ exit 0+ fi++ echo "Unexpected SSH command: $ARGS" >&2+ exit 1+ ]=])+ end+ t.write_file(fake_ssh_path, script)+ vim.uv.fs_chmod(fake_ssh_path, 493)++ local path_sep = is_windows and ';' or ':'+ n.exec_lua(string.format(+ [[+ _G.orig_path = vim.fn.getenv('PATH')+ vim.fn.setenv('PATH', %q .. %q .. _G.orig_path)+ ]],+ fake_bin_dir,+ path_sep+ ))+ end++ local function teardown_fake_ssh()+ if fake_bin_dir then+ n.exec_lua([[+ if _G.orig_path then+ vim.fn.setenv('PATH', _G.orig_path)+ end+ ]])+ end+ end++ before_each(function()+ clear()+ end)++ after_each(function()+ teardown_fake_ssh()+ end)++ describe('remote-ssh (Introspection)', function()+ it('detects linux x86_64 successfully', function()+ skip(is_os('win'), 'remote-ssh engine is POSIX-only')+ setup_fake_ssh({+ uname = [[+ echo "Linux"+ echo "x86_64"+ ]],+ })+ local res = n.exec_lua([[+ return { require('vim.net._ssh').get_system_info({ host = 'server' }) }+ ]])+ eq('linux', res[1])+ eq('x86_64', res[2])+ end)++ it('detects macos arm64 successfully', function()+ skip(is_os('win'), 'remote-ssh engine is POSIX-only')+ setup_fake_ssh({+ uname = [[+ echo "Darwin"+ echo "arm64"+ ]],+ })+ local res = n.exec_lua([[+ return { require('vim.net._ssh').get_system_info({ host = 'server' }) }+ ]])+ eq('darwin', res[1])+ eq('arm64', res[2])+ end)++ it('fails fast on Windows', function()+ skip(is_os('win'), 'remote-ssh engine is POSIX-only')+ setup_fake_ssh({+ uname = [[+ echo "MSYS_NT-10.0-19045"+ echo "x86_64"+ ]],+ })+ local status, err = pcall(function()+ n.exec_lua([[+ require('vim.net._ssh').get_system_info({ host = 'server' })+ ]])+ end)+ eq(false, status)+ assert(string.match(err, 'Windows targets are not supported'))+ end)+ end)++ describe('remote-ssh (Orchestration)', function()+ it('returns local socket with key-based auth', function()+ skip(is_os('win'), 'remote-ssh engine is POSIX-only')+ setup_fake_ssh()+ local sock = n.exec_lua([[+ local sock, _, teardown = require('vim.net._ssh').start('user@test-server')+ teardown()+ return sock+ ]])++ assert(sock:match('_remote_nvim%.sock$'))+ end)++ it('installs the matching release privately and runs it', function()+ skip(is_os('win'), 'remote-ssh engine is POSIX-only')+ setup_fake_ssh()+ local release = n.exec_lua([[+ local _, _, teardown = require('vim.net._ssh').start('user@test-server')+ teardown()+ local v = vim.version()+ local base = ('v%d.%d.%d'):format(v.major, v.minor, v.patch)+ return v.prerelease and ('nightly-' .. base) or base+ ]])+ local install = t.read_file(fake_bin_dir .. '/install.args')+ local tag = release:match('^nightly') and 'nightly' or release+ t.matches('releases/download/' .. vim.pesc(tag) .. '/nvim%-linux%-x86_64%.tar%.gz', install)+ t.matches('dest="%$HOME"/%.local/share/nvim%-remote/releases/' .. vim.pesc(release), install)+ eq(nil, install:find('.local/bin', 1, true))+ t.matches(+ '"%$HOME"/%.local/share/nvim%-remote/releases/'+ .. vim.pesc(release)+ .. '/bin/nvim %-%-headless',+ t.read_file(fake_bin_dir .. '/tunnel.args')+ )+ end)++ it('fails when ssh exits before the remote Nvim is ready', function()+ skip(is_os('win'), 'remote-ssh engine is POSIX-only')+ setup_fake_ssh({+ tunnel = [[+ echo "channel 0: open failed: administratively prohibited" >&2+ exit 255+ ]],+ })+ t.matches(+ 'SSH tunnel exited before the remote Nvim was ready: channel 0: open failed',+ t.pcall_err(n.exec_lua, [[require('vim.net._ssh').start('user@test-server')]])+ )+ eq(true, vim.uv.fs_stat(fake_bin_dir .. '/forward.cancelled') ~= nil)+ end)++ it('fails when the remote Nvim crashes during startup', function()+ skip(is_os('win'), 'remote-ssh engine is POSIX-only')+ setup_fake_ssh({+ tunnel = [[+ echo "NVIM_CRASHED"+ exit 1+ ]],+ })+ t.matches(+ 'Remote Nvim crashed during startup',+ t.pcall_err(n.exec_lua, [[require('vim.net._ssh').start('user@test-server')]])+ )+ end)++ it('serves each session from a fresh private remote directory', function()+ skip(is_os('win'), 'remote-ssh engine is POSIX-only')+ setup_fake_ssh()+ local function remote_dir()+ n.exec_lua([[+ local _, _, teardown = require('vim.net._ssh').start('user@test-server')+ teardown()+ ]])+ local args = t.read_file(fake_bin_dir .. '/tunnel.args')+ t.matches('mkdir %-m 700 "%$dir"', args)+ return args:match(':(/tmp/nvim%-remote%.%x+)/nvim%.sock ')+ end++ local dir1, dir2 = remote_dir(), remote_dir()+ assert(dir1 and dir2, 'remote socket path not found')+ assert(dir1 ~= dir2, 'remote socket directory reused across sessions')+ end)+ end)+end) @@ -136,6 +136,50 @@ describe('vim.system', function() end) end) + it('run_wait() can return before process exit', function()+ t.skip(t.is_os('win'), 'shell command is POSIX-only')++ exec_lua(function()+ local res+ local ok, err = pcall(function()+ res = require('vim._core.system').run_wait(+ { 'bash', '-c', 'echo READY; sleep 10' },+ nil,+ function(stdout)+ return stdout:match('READY') ~= nil+ end,+ 10000+ )++ assert(res)+ assert(res.code == nil)+ assert(res.stdout == 'READY\n')+ assert(vim.api.nvim_get_proc(res.pid), 'process already exited')+ end)++ if res then+ if not res:is_closing() then+ res:kill('sigterm')+ end+ res:wait(1000)+ end+ if not ok then+ error(err)+ end+ end)+ end)++ it('run_wait() writes stdin', function()+ t.skip(t.is_os('win'), 'shell command is POSIX-only')++ eq(+ 'hello',+ exec_lua(function()+ return require('vim._core.system').run_wait({ 'cat' }, { stdin = 'hello' }, nil, 10000).stdout+ end)+ )+ end)+ it('SystemObj:wait() does not process non-fast events #27292', function() eq( false, @@ -362,4 +362,33 @@ describe('vim.uri', function() {}, }, parsed) end)++ describe('SSH URI parser', function()+ it('parses ssh://user@host:port', function()+ local uri = exec_lua(function()+ return require('vim.uri')._parse_ssh_uri('ssh://root@localhost:2222')+ end)+ eq('root', uri.user)+ eq('localhost', uri.host)+ eq('2222', uri.port)+ end)++ it('parses user@host', function()+ local uri = exec_lua(function()+ return require('vim.uri')._parse_ssh_uri('[email protected]')+ end)+ eq('admin', uri.user)+ eq('server.local', uri.host)+ eq(nil, uri.port)+ end)++ it('parses host only', function()+ local uri = exec_lua(function()+ return require('vim.uri')._parse_ssh_uri('myalias')+ end)+ eq(nil, uri.user)+ eq('myalias', uri.host)+ eq(nil, uri.port)+ end)+ end) end)