Szymon Wilczek

research / neovim/neovim / feat(remote-ssh) / b94cdb848e08

support ":connect ssh://"

neovim/neovim

feat(remote-ssh) +1043 −45

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

Problem: - :connect does not support ssh:// URLs. - ssh:// support is "owned" by netrw in an ad-hoc fashion.

Solution: - Implement ssh config parsing. - Implement remote-ssh `:connect` support: - Detect remote OS/architecture via uname. - Bootstrap Nvim binary on remote (`~/.local/share/nvim-remote`). - Start headless, "isolated" Nvim with `NVIM_APPNAME=nvim-remote`. - Forward remote Unix socket via SSH -L tunnel. - Use SSH ControlMaster multiplexing for connection reuse. - Teardown: kill remote process + SSH mux, preserve data dir.

signed, reported, fixes

  • Signed-off-by Szymon Wilczek author
  • Merged-by Justin M. Keyes maintainer · merged it

diff

runtime/doc/news.txt +2 −0
@@ -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 
runtime/doc/remote_ssh.txt +91 −0
@@ -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:
runtime/lua/vim/_core/system.lua +58 −1
@@ -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
runtime/lua/vim/health/health.lua +55 −0
@@ -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()
runtime/lua/vim/net/_ssh.lua +380 −35
@@ -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
runtime/lua/vim/uri.lua +30 −0
@@ -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
src/nvim/ex_docmd.c +24 −1
@@ -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);
test/functional/lua/ssh_spec.lua +330 −8
@@ -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)
test/functional/lua/system_spec.lua +44 −0
@@ -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,
test/functional/lua/uri_spec.lua +29 −0
@@ -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)
12/12
salina
aurum
herbarium
mysterium
parchment
whispergrove
M-x 0/0

keybindings & navigation

M-x / Alt+x Open Emacs command runner
Shift + W Jump to writings & essays (/writings)
Shift + R Jump to research & upstream work (/research)
Shift + T Choose colour theme (au-themes-select)
j / k Scroll down / up
g g Scroll to the top of page
Shift + G Scroll to the bottom of page
? Toggle this keybindings help cheat sheet
Esc / C-g Dismiss modal or close active menu