feat(remote-ssh): support ":connect ssh://"

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-off-by: Szymon Wilczek <swilczek.lx@gmail.com>
This commit is contained in:
Szymon Wilczek authored and Justin M. Keyes committed 2026-11-03 18:03:19 +01:00
1 parent b4d549f439
commit b94cdb848e
10 files changed
+1045 -47

No files matched your search

+2
View File
@@ -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
+91
View File
@@ -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://user@192.168.1.100'
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://user@192.168.1.100
: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:
+58 -1
View File
@@ -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
+55
View File
@@ -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()
+382 -37
View File
@@ -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')
end
local config_string = file:read('*a')
file:close()
return M.parse_ssh_config(config_string)
local text = vim.fn.readblob(filename)
return M.parse_ssh_config(text)
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
---@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
return vim.tbl_map(
---@param h vim.net.SshHost
function(h)
return h.alias
end,
hosts
)
end
return M.parse_config(config_path)
local _log --- @type vim.Log?
--- 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
--- 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 local_sock, uri, teardown
end
return M
+30
View File
@@ -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
+24 -1
View File
@@ -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);
+330 -8
View File
@@ -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)
+44
View File
@@ -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,
+29
View File
@@ -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('admin@server.local')
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)