NeoVim – Editing remote files as root when required

This is kind of an esoteric niche thing – but if you face this issue, I think this is a nice tool to have. My scenario is the following:

I have several servers on my home LAN and I mostly work on them from my main desktop. Currently, my workflow is SSH into them and edit files directly there. However, if I want to use my nice NeoVim/lazyvim setup that I have on my main desktop this is not possible. While netrw exists, it is annoying in that it doesn’t allow asking for a root password and saving the file (or even opening the file when the normal user doesn’t have access to it). This lua config addition provides support for doing this.

I add the following keymaps as well to use this functionality. With all of this, my workflow now looks like:

leader ro -- open a remote file. If needed will prompt me for root password.
ctrl-s -- save a file. If needed will prompt me for root password
leader ro -- If I don't know the exact file, I can browse using netrw and use the same keys to open the file from the netrw explorer

Vibe Coding – aka state of AI prompting for building this out:

  • By and large AI helps quite a bit. I did not write what I consider the tedious bits of a lot of the code below (in fact, I wrote very little of it). But, you still need to be careful and need to know when something smells fishy. You also need to know how to exactly prompt the AI to fix it.
  • When you are new to the domain (in my case NeoVim specifics – I know enough lua to be dangerous), it is harder to catch when the AI is hallucinating or misguided.
  • For example, AI doesn’t catch the fact that assigning vim.b.remote_sudo_meta after issuing an open command seems to be prone to a race condition and the safer version is what I have below (at least, I haven’t found a bug with it yet!). But I also think it would be a tough bug for someone who doesn’t really understand programming well to figure out on their own (in other words, vibe code by all means. But you need the nose to smell out exactly why a bug can happen – in this case why would vim.b.remote_sudo_meta be nil sometimes!)
  • AI also didn’t understand the very subtle but important issue that if I simply move a new tmp file over an existing file as root in the remote machine, it potentially might change owner/group/attributes. I had to explicitly prompt it to address this part.
  • AI also has a tendency to not really understand and streamline the code. For example, my prompting was poor for the open_remote_smart and open_remote_with_sudo and I ended up having code that did complicated checks for is the file readable, is it writable and so on. A manual pass helped clean and simplify the output quite a bit. This might also be just me still learning to prompt the AI tools better.
  • Overall, more power to AI and vibe coding. I love it! While I could’ve built something like this on my own, AI definitely makes the process a lot more pleasant. For example, the annoying regex and parts parsing in parse_netrw_uri (sorry, I can do this if needed, but I’d much rather not spend time on it personally any longer), all the functions to interact with NeoVim internals, the formatting of shell commands, executing and checking for results – essentially most of the boring and irritating parts were taken care of by the AI very well.

Parting note – this entire functionality is quite questionable from a security POV (echoing the root password to sudo – hello! tmp files that are likely not cleaned up well in all scenarios). Use in security conscious environments with great caution (or not at all). You can also get this code below at https://github.com/ram-nat/nvim/blob/main/sudo_write_remote.lua

Update: You can see AI driven improvements (I identified the improvements and asked the AI to implement them with prompts) in this updated commit – https://github.com/ram-nat/nvim/commit/fe3d06617366bd54c2d80f6dea0a3156093f3662

local function parse_netrw_uri(uri)
  -- Example: scp://user@host//path/to/file
  local protocol, user, host, path = uri:match("^([%w]+)://([^@]+)@([^/]+)//(.+)$")
  if protocol and user and host and path then
    return {
      protocol = protocol,
      user = user,
      host = host,
      path = "/" .. path,
    }
  else
    error("Could not parse netrw URI: " .. tostring(uri))
  end
end

-- Helper to get the sudo prefix for remote commands, prompting for password if needed.
-- If password entry is aborted, it cleans up tmp_local_to_cleanup_on_abort (if provided) and errors out.
local function get_remote_sudo_prefix(user, host, tmp_local_to_cleanup_on_abort)
  local check_sudo_cmd = string.format("ssh %s@%s 'sudo -n true'", user, host)
  local needs_password = (os.execute(check_sudo_cmd) ~= 0)
  local sudo_pass = ""
  local sudo_prefix = "sudo"
  if needs_password then
    sudo_pass = vim.fn.inputsecret("Remote sudo password: ")
    if sudo_pass == "" then
      vim.notify("No password entered. Aborting sudo operation.", vim.log.levels.WARN)
      if tmp_local_to_cleanup_on_abort then
        os.remove(tmp_local_to_cleanup_on_abort)
        vim.notify("Cleaned up temporary file: " .. tmp_local_to_cleanup_on_abort, vim.log.levels.INFO)
      end
      return nil -- Signal that the user aborted, notifications and cleanup for this path are done.
    end
    sudo_prefix = string.format("echo %s | sudo -S", vim.fn.shellescape(sudo_pass))
  end
  return sudo_prefix
end

local function sudo_write_remote()
  local uri = vim.api.nvim_buf_get_name(0)
  if vim.b.remote_sudo_meta then
    uri = vim.b.remote_sudo_meta.original_uri
  end
  local info = parse_netrw_uri(uri)

  -- Check if remote file is writable
  local check_writable_cmd = string.format("ssh %s@%s 'test -w %q'", info.user, info.host, info.path)
  local writable = (os.execute(check_writable_cmd) == 0)

  if writable then
    -- Use netrw's normal write
    vim.cmd("write")
    vim.notify(string.format("Saved %s to %s", info.path, info.host), vim.log.levels.INFO)
    return
  end

  -- Write buffer to a temporary local file
  local tmp_local = os.tmpname()
  vim.api.nvim_command("write! " .. vim.fn.fnameescape(tmp_local))

  -- Compose remote temporary file path with better uniqueness
  local filename = vim.fn.fnamemodify(info.path, ":t")
  local pid = tostring(vim.fn.getpid())
  local rand = tostring(math.random(100000, 999999))
  local remote_tmp = string.format("/tmp/%s.%s.%s.%s", filename, info.user, pid, rand)

  -- Copy local temp file to remote /tmp
  local scp_cmd = string.format(
    "scp %s %s@%s:%s",
    vim.fn.shellescape(tmp_local),
    info.user,
    info.host,
    vim.fn.shellescape(remote_tmp)
  )
  local scp_result = os.execute(scp_cmd)
  if scp_result ~= 0 then
    vim.notify("Failed to copy file to remote host!", vim.log.levels.ERROR)
    os.remove(tmp_local)
    return
  end

  -- Get sudo prefix (may prompt for password)
  local sudo_prefix = get_remote_sudo_prefix(info.user, info.host, tmp_local)
  if not sudo_prefix then
    -- If sudo_prefix is nil, it means get_remote_sudo_prefix handled user notification
    -- and cleanup (like tmp_local) for the password cancellation.
    -- So, we just abort this function.
    return -- Abort sudo_write_remote
  end

  local ssh_script_content = string.format(
    [[
#!/bin/bash
TARGET_PATH="$1"
REMOTE_TMP_PATH="$2"
  
orig_mode=$(stat -c "%%a" "${TARGET_PATH}" 2>/dev/null || echo "");
orig_owner=$(stat -c "%%u" "${TARGET_PATH}" 2>/dev/null || echo "");
orig_group=$(stat -c "%%g" "${TARGET_PATH}" 2>/dev/null || echo "");
  
# Execute move command with sudo_prefix
%s mv -f "${REMOTE_TMP_PATH}" "${TARGET_PATH}";
if [ $? -ne 0 ]; then
  # Attempt to clean up remote temp file if move failed, then exit
  %s rm -f "${REMOTE_TMP_PATH}"
  exit 1;
fi
  
# Restore owner/group if they were captured
if [ -n "$orig_owner" ] && [ -n "$orig_group" ]; then
  %s chown "$orig_owner:$orig_group" "${TARGET_PATH}";
fi;
  
# Restore mode if it was captured
if [ -n "$orig_mode" ]; then
  %s chmod "$orig_mode" "${TARGET_PATH}";
fi;
exit 0
]],
    sudo_prefix,
    sudo_prefix,
    sudo_prefix,
    sudo_prefix
  ) -- sudo_prefix is used for mv, rm (on fail), chown, chmod

  local tmp_script_file_path = nil -- Will hold the path if script file is successfully created
  local ssh_exit_code = -1 -- Initialize with a value indicating not yet run or error

  local pcall_success -- Boolean: Did pcall itself succeed (no Lua error in the anonymous function)?
  local os_exec_status -- First return from os.execute (true, false, or nil)
  local os_exec_code_or_msg -- Second return from os.execute (exit code as number, or error message as string)
  -- local os_exec_signal      -- Third return from os.execute (term signal), not used here
  pcall_success, os_exec_status, os_exec_code_or_msg = pcall(function()
    tmp_script_file_path = os.tmpname() -- Generate name for the script file
    local f = io.open(tmp_script_file_path, "w")
    if not f then
      error("Failed to create temporary script file: " .. tmp_script_file_path) -- This error is caught by pcall
    end
    -- File is open, write to it
    f:write(ssh_script_content)
    f:close()
    -- tmp_script_file_path now points to a created and populated file

    local ssh_cmd = string.format(
      "ssh %s@%s 'bash -s %s %s' < %s",
      info.user,
      info.host,
      vim.fn.shellescape(info.path),
      vim.fn.shellescape(remote_tmp),
      vim.fn.shellescape(tmp_script_file_path)
    )

    return os.execute(ssh_cmd) -- Returns (status, code_or_msg, signal)
  end)

  -- **Guaranteed Cleanup Section**

  -- 1. Clean up the temporary script file, if its path was determined and file exists.
  if tmp_script_file_path and vim.fn.filereadable(tmp_script_file_path) == 1 then
    os.remove(tmp_script_file_path)
  end

  -- 2. Clean up the local temporary content file.
  --    It should exist at this point unless get_remote_sudo_prefix errored (which would have halted execution).
  if vim.fn.filereadable(tmp_local) == 1 then
    os.remove(tmp_local)
  end

  -- **Process the result of the pcall'd operations**
  if not pcall_success then
    -- A Lua error occurred within the pcall block (e.g., io.open failed and error() was called).
    -- In this case, os_exec_status (the second value from pcall) holds the error object.
    vim.notify("Error during remote script execution preparation: " .. tostring(os_exec_status), vim.log.levels.ERROR)
    return
  end

  -- If pcall succeeded, os_exec_status and os_exec_code_or_msg are from the os.execute() call
  if os_exec_status ~= true then
    vim.notify(string.format("SSH command failed: %s", tostring(os_exec_code_or_msg)), vim.log.levels.ERROR)
    return
  end

  vim.notify(string.format("Saved %s to %s as root (attributes preserved)", info.path, info.host), vim.log.levels.INFO)
end

local function open_remote_with_sudo(opts)
  local uri = opts.args
  if uri == "" then
    uri = vim.fn.input("Remote URI to open (e.g. scp://user@host//path: ")
    if uri == "" then
      vim.notify("No URI provided, aborting", vim.log.levels.WARN)
      return
    end
  end
  local info = parse_netrw_uri(uri)

  -- Get sudo prefix (may prompt for password)
  local sudo_prefix = get_remote_sudo_prefix(info.user, info.host, nil) -- Pass nil for tmp_local_to_cleanup
  if not sudo_prefix then
    -- User aborted password entry, get_remote_sudo_prefix handled notifications.
    return -- Abort open_remote_with_sudo
  end

  -- Fetch file via sudo
  local tmp_file = os.tmpname()
  local fetch_cmd = string.format(
    "ssh %s@%s %s > %s",
    info.user,
    info.host,
    vim.fn.shellescape(string.format("%s cat %s", sudo_prefix, info.path)), -- Remote command
    vim.fn.shellescape(tmp_file)
  ) -- Local output file

  if os.execute(fetch_cmd) ~= 0 then
    vim.notify(string.format("Failed to fetch file %s on %s with sudo", info.path, info.host), vim.log.levels.ERROR)
    os.remove(tmp_file)
    return
  end

  vim.cmd("edit " .. vim.fn.fnameescape(tmp_file))
  local meta = {
    original_uri = uri,
    host = info.host,
    user = info.user,
    path = info.path,
    tmp_path = tmp_file,
  }
  -- Find the buffer number by file name
  local bufnr = nil
  for _, buf in ipairs(vim.api.nvim_list_bufs()) do
    if vim.api.nvim_buf_get_name(buf) == tmp_file then
      bufnr = buf
      break
    end
  end

  if bufnr then
    vim.api.nvim_buf_set_var(bufnr, "remote_sudo_meta", meta)
  else
    error(string.format("Unable to set remote metadata for %s - Try reopening the file.", tmp_file))
  end

  vim.notify(string.format("File %s opened as %s on host %s", info.path, "root", info.host), vim.log.levels.INFO)
end

local function open_remote_smart(opts)
  local uri = opts.args
  if uri == "" then
    uri = vim.fn.input("Remote URI to open (e.g. scp://user@host//path: ")
    if uri == "" then
      vim.notify("No URI provided, aborting", vim.log.levels.WARN)
      return
    end
  end
  local info = parse_netrw_uri(uri)

  local check_readable_cmd = string.format("ssh %s@%s 'test -r %q'", info.user, info.host, info.path)
  local readable = (os.execute(check_readable_cmd) == 0)

  if readable then
    vim.cmd("edit " .. vim.fn.fnameescape(uri))
    vim.notify(string.format("File %s opened as %s on host %s", info.path, info.user, info.host), vim.log.levels.INFO)
    return
  end

  -- Check if parent directory is writable
  local dirname = vim.fn.fnamemodify(info.path, ":h")
  local check_dir_writable = string.format("ssh %s@%s 'test -w %q'", info.user, info.host, dirname)
  local dir_writable = (os.execute(check_dir_writable) == 0)

  if dir_writable then
    -- File does not exist, try to create it
    local touch_cmd = string.format("ssh %s@%s 'touch %q'", info.user, info.host, info.path)
    if os.execute(touch_cmd) == 0 then
      vim.cmd("edit " .. vim.fn.fnameescape(uri))
      vim.notify(string.format("File %s opened as %s on host %s", info.path, info.user, info.host), vim.log.levels.INFO)
      return
    else
      -- Could not create file, escalate to sudo
      vim.cmd("OpenRemoteWithSudo " .. vim.fn.fnameescape(uri))
      return
    end
  else
    -- Directory not writable, escalate to sudo
    vim.cmd("OpenRemoteWithSudo " .. vim.fn.fnameescape(uri))
    return
  end
end

vim.api.nvim_create_autocmd("FileType", {
  pattern = "netrw",
  callback = function()
    vim.keymap.set("n", "<leader>ro", function()
      local netrw_curdir = vim.b.netrw_curdir or ""
      local filename = vim.fn.expand("<cfile>")
      local needs_slash = netrw_curdir:sub(-1) ~= "/" and filename:sub(1, 1) ~= "/"
      local full_uri = needs_slash and (netrw_curdir .. "/" .. filename) or (netrw_curdir .. filename)
      if filename:sub(-1) == "/" then
        return
      end -- skip directories
      open_remote_smart({ args = full_uri })
    end, { buffer = true, noremap = true, silent = true })
  end,
})

vim.api.nvim_create_user_command("OpenRemoteSmart", open_remote_smart, { nargs = "?" })
vim.api.nvim_create_user_command("OpenRemoteWithSudo", open_remote_with_sudo, { nargs = "?" })
vim.api.nvim_create_user_command("SudoWriteRemote", sudo_write_remote, {})

Leave a Reply

Your email address will not be published. Required fields are marked *