{"id":538,"date":"2025-05-20T19:17:28","date_gmt":"2025-05-21T03:17:28","guid":{"rendered":"https:\/\/nramkumar.org\/tech\/?p=538"},"modified":"2025-05-21T19:25:40","modified_gmt":"2025-05-22T03:25:40","slug":"neovim-editing-remote-files-as-root-when-required","status":"publish","type":"post","link":"https:\/\/nramkumar.org\/tech\/blog\/2025\/05\/20\/neovim-editing-remote-files-as-root-when-required\/","title":{"rendered":"NeoVim &#8211; Editing remote files as root when required"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">This is kind of an esoteric niche thing &#8211; but if you face this issue, I think this is a nice tool to have. My scenario is the following:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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&#8217;t allow asking for a root password and saving the file (or even opening the file when the normal user doesn&#8217;t have access to it). This lua config addition provides support for doing this.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I add the following keymaps as well to use this functionality. With all of this, my workflow now looks like:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>leader ro -- open a remote file. If needed will prompt me for root password.\nctrl-s -- save a file. If needed will prompt me for root password\nleader 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<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Vibe Coding &#8211; aka state of AI prompting for building this out:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>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.<\/li>\n\n\n\n<li>When you are new to the domain (in my case NeoVim specifics &#8211; I know enough lua to be dangerous), it is harder to catch when the AI is hallucinating or misguided.<\/li>\n\n\n\n<li>For example, AI doesn&#8217;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&#8217;t found a bug with it yet!). But I also think it would be a tough bug for someone who doesn&#8217;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 &#8211; in this case why would vim.b.remote_sudo_meta be nil sometimes!)<\/li>\n\n\n\n<li>AI also didn&#8217;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.<\/li>\n\n\n\n<li>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.<\/li>\n\n\n\n<li>Overall, more power to AI and vibe coding. I love it! While I could&#8217;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&#8217;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 &#8211; essentially most of the boring and irritating parts were taken care of by the AI very well.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Parting note &#8211; this entire functionality is quite questionable from a security POV (echoing the root password to sudo &#8211; 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 <a href=\"https:\/\/github.com\/ram-nat\/nvim\/blob\/main\/sudo_write_remote.lua\">https:\/\/github.com\/ram-nat\/nvim\/blob\/main\/sudo_write_remote.lua<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Update: You can see AI driven improvements (I identified the improvements and asked the AI to implement them with prompts) in this updated commit &#8211; <a href=\"https:\/\/github.com\/ram-nat\/nvim\/commit\/fe3d06617366bd54c2d80f6dea0a3156093f3662\">https:\/\/github.com\/ram-nat\/nvim\/commit\/fe3d06617366bd54c2d80f6dea0a3156093f3662<\/a><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>local function parse_netrw_uri(uri)\n  -- Example: scp:\/\/user@host\/\/path\/to\/file\n  local protocol, user, host, path = uri:match(\"^(&#91;%w]+):\/\/(&#91;^@]+)@(&#91;^\/]+)\/\/(.+)$\")\n  if protocol and user and host and path then\n    return {\n      protocol = protocol,\n      user = user,\n      host = host,\n      path = \"\/\" .. path,\n    }\n  else\n    error(\"Could not parse netrw URI: \" .. tostring(uri))\n  end\nend\n\n-- Helper to get the sudo prefix for remote commands, prompting for password if needed.\n-- If password entry is aborted, it cleans up tmp_local_to_cleanup_on_abort (if provided) and errors out.\nlocal function get_remote_sudo_prefix(user, host, tmp_local_to_cleanup_on_abort)\n  local check_sudo_cmd = string.format(\"ssh %s@%s 'sudo -n true'\", user, host)\n  local needs_password = (os.execute(check_sudo_cmd) ~= 0)\n  local sudo_pass = \"\"\n  local sudo_prefix = \"sudo\"\n  if needs_password then\n    sudo_pass = vim.fn.inputsecret(\"Remote sudo password: \")\n    if sudo_pass == \"\" then\n      vim.notify(\"No password entered. Aborting sudo operation.\", vim.log.levels.WARN)\n      if tmp_local_to_cleanup_on_abort then\n        os.remove(tmp_local_to_cleanup_on_abort)\n        vim.notify(\"Cleaned up temporary file: \" .. tmp_local_to_cleanup_on_abort, vim.log.levels.INFO)\n      end\n      return nil -- Signal that the user aborted, notifications and cleanup for this path are done.\n    end\n    sudo_prefix = string.format(\"echo %s | sudo -S\", vim.fn.shellescape(sudo_pass))\n  end\n  return sudo_prefix\nend\n\nlocal function sudo_write_remote()\n  local uri = vim.api.nvim_buf_get_name(0)\n  if vim.b.remote_sudo_meta then\n    uri = vim.b.remote_sudo_meta.original_uri\n  end\n  local info = parse_netrw_uri(uri)\n\n  -- Check if remote file is writable\n  local check_writable_cmd = string.format(\"ssh %s@%s 'test -w %q'\", info.user, info.host, info.path)\n  local writable = (os.execute(check_writable_cmd) == 0)\n\n  if writable then\n    -- Use netrw's normal write\n    vim.cmd(\"write\")\n    vim.notify(string.format(\"Saved %s to %s\", info.path, info.host), vim.log.levels.INFO)\n    return\n  end\n\n  -- Write buffer to a temporary local file\n  local tmp_local = os.tmpname()\n  vim.api.nvim_command(\"write! \" .. vim.fn.fnameescape(tmp_local))\n\n  -- Compose remote temporary file path with better uniqueness\n  local filename = vim.fn.fnamemodify(info.path, \":t\")\n  local pid = tostring(vim.fn.getpid())\n  local rand = tostring(math.random(100000, 999999))\n  local remote_tmp = string.format(\"\/tmp\/%s.%s.%s.%s\", filename, info.user, pid, rand)\n\n  -- Copy local temp file to remote \/tmp\n  local scp_cmd = string.format(\n    \"scp %s %s@%s:%s\",\n    vim.fn.shellescape(tmp_local),\n    info.user,\n    info.host,\n    vim.fn.shellescape(remote_tmp)\n  )\n  local scp_result = os.execute(scp_cmd)\n  if scp_result ~= 0 then\n    vim.notify(\"Failed to copy file to remote host!\", vim.log.levels.ERROR)\n    os.remove(tmp_local)\n    return\n  end\n\n  -- Get sudo prefix (may prompt for password)\n  local sudo_prefix = get_remote_sudo_prefix(info.user, info.host, tmp_local)\n  if not sudo_prefix then\n    -- If sudo_prefix is nil, it means get_remote_sudo_prefix handled user notification\n    -- and cleanup (like tmp_local) for the password cancellation.\n    -- So, we just abort this function.\n    return -- Abort sudo_write_remote\n  end\n\n  local ssh_script_content = string.format(\n    &#91;&#91;\n#!\/bin\/bash\nTARGET_PATH=\"$1\"\nREMOTE_TMP_PATH=\"$2\"\n  \norig_mode=$(stat -c \"%%a\" \"${TARGET_PATH}\" 2>\/dev\/null || echo \"\");\norig_owner=$(stat -c \"%%u\" \"${TARGET_PATH}\" 2>\/dev\/null || echo \"\");\norig_group=$(stat -c \"%%g\" \"${TARGET_PATH}\" 2>\/dev\/null || echo \"\");\n  \n# Execute move command with sudo_prefix\n%s mv -f \"${REMOTE_TMP_PATH}\" \"${TARGET_PATH}\";\nif &#91; $? -ne 0 ]; then\n  # Attempt to clean up remote temp file if move failed, then exit\n  %s rm -f \"${REMOTE_TMP_PATH}\"\n  exit 1;\nfi\n  \n# Restore owner\/group if they were captured\nif &#91; -n \"$orig_owner\" ] &amp;&amp; &#91; -n \"$orig_group\" ]; then\n  %s chown \"$orig_owner:$orig_group\" \"${TARGET_PATH}\";\nfi;\n  \n# Restore mode if it was captured\nif &#91; -n \"$orig_mode\" ]; then\n  %s chmod \"$orig_mode\" \"${TARGET_PATH}\";\nfi;\nexit 0\n]],\n    sudo_prefix,\n    sudo_prefix,\n    sudo_prefix,\n    sudo_prefix\n  ) -- sudo_prefix is used for mv, rm (on fail), chown, chmod\n\n  local tmp_script_file_path = nil -- Will hold the path if script file is successfully created\n  local ssh_exit_code = -1 -- Initialize with a value indicating not yet run or error\n\n  local pcall_success -- Boolean: Did pcall itself succeed (no Lua error in the anonymous function)?\n  local os_exec_status -- First return from os.execute (true, false, or nil)\n  local os_exec_code_or_msg -- Second return from os.execute (exit code as number, or error message as string)\n  -- local os_exec_signal      -- Third return from os.execute (term signal), not used here\n  pcall_success, os_exec_status, os_exec_code_or_msg = pcall(function()\n    tmp_script_file_path = os.tmpname() -- Generate name for the script file\n    local f = io.open(tmp_script_file_path, \"w\")\n    if not f then\n      error(\"Failed to create temporary script file: \" .. tmp_script_file_path) -- This error is caught by pcall\n    end\n    -- File is open, write to it\n    f:write(ssh_script_content)\n    f:close()\n    -- tmp_script_file_path now points to a created and populated file\n\n    local ssh_cmd = string.format(\n      \"ssh %s@%s 'bash -s %s %s' &lt; %s\",\n      info.user,\n      info.host,\n      vim.fn.shellescape(info.path),\n      vim.fn.shellescape(remote_tmp),\n      vim.fn.shellescape(tmp_script_file_path)\n    )\n\n    return os.execute(ssh_cmd) -- Returns (status, code_or_msg, signal)\n  end)\n\n  -- **Guaranteed Cleanup Section**\n\n  -- 1. Clean up the temporary script file, if its path was determined and file exists.\n  if tmp_script_file_path and vim.fn.filereadable(tmp_script_file_path) == 1 then\n    os.remove(tmp_script_file_path)\n  end\n\n  -- 2. Clean up the local temporary content file.\n  --    It should exist at this point unless get_remote_sudo_prefix errored (which would have halted execution).\n  if vim.fn.filereadable(tmp_local) == 1 then\n    os.remove(tmp_local)\n  end\n\n  -- **Process the result of the pcall'd operations**\n  if not pcall_success then\n    -- A Lua error occurred within the pcall block (e.g., io.open failed and error() was called).\n    -- In this case, os_exec_status (the second value from pcall) holds the error object.\n    vim.notify(\"Error during remote script execution preparation: \" .. tostring(os_exec_status), vim.log.levels.ERROR)\n    return\n  end\n\n  -- If pcall succeeded, os_exec_status and os_exec_code_or_msg are from the os.execute() call\n  if os_exec_status ~= true then\n    vim.notify(string.format(\"SSH command failed: %s\", tostring(os_exec_code_or_msg)), vim.log.levels.ERROR)\n    return\n  end\n\n  vim.notify(string.format(\"Saved %s to %s as root (attributes preserved)\", info.path, info.host), vim.log.levels.INFO)\nend\n\nlocal function open_remote_with_sudo(opts)\n  local uri = opts.args\n  if uri == \"\" then\n    uri = vim.fn.input(\"Remote URI to open (e.g. scp:\/\/user@host\/\/path: \")\n    if uri == \"\" then\n      vim.notify(\"No URI provided, aborting\", vim.log.levels.WARN)\n      return\n    end\n  end\n  local info = parse_netrw_uri(uri)\n\n  -- Get sudo prefix (may prompt for password)\n  local sudo_prefix = get_remote_sudo_prefix(info.user, info.host, nil) -- Pass nil for tmp_local_to_cleanup\n  if not sudo_prefix then\n    -- User aborted password entry, get_remote_sudo_prefix handled notifications.\n    return -- Abort open_remote_with_sudo\n  end\n\n  -- Fetch file via sudo\n  local tmp_file = os.tmpname()\n  local fetch_cmd = string.format(\n    \"ssh %s@%s %s > %s\",\n    info.user,\n    info.host,\n    vim.fn.shellescape(string.format(\"%s cat %s\", sudo_prefix, info.path)), -- Remote command\n    vim.fn.shellescape(tmp_file)\n  ) -- Local output file\n\n  if os.execute(fetch_cmd) ~= 0 then\n    vim.notify(string.format(\"Failed to fetch file %s on %s with sudo\", info.path, info.host), vim.log.levels.ERROR)\n    os.remove(tmp_file)\n    return\n  end\n\n  vim.cmd(\"edit \" .. vim.fn.fnameescape(tmp_file))\n  local meta = {\n    original_uri = uri,\n    host = info.host,\n    user = info.user,\n    path = info.path,\n    tmp_path = tmp_file,\n  }\n  -- Find the buffer number by file name\n  local bufnr = nil\n  for _, buf in ipairs(vim.api.nvim_list_bufs()) do\n    if vim.api.nvim_buf_get_name(buf) == tmp_file then\n      bufnr = buf\n      break\n    end\n  end\n\n  if bufnr then\n    vim.api.nvim_buf_set_var(bufnr, \"remote_sudo_meta\", meta)\n  else\n    error(string.format(\"Unable to set remote metadata for %s - Try reopening the file.\", tmp_file))\n  end\n\n  vim.notify(string.format(\"File %s opened as %s on host %s\", info.path, \"root\", info.host), vim.log.levels.INFO)\nend\n\nlocal function open_remote_smart(opts)\n  local uri = opts.args\n  if uri == \"\" then\n    uri = vim.fn.input(\"Remote URI to open (e.g. scp:\/\/user@host\/\/path: \")\n    if uri == \"\" then\n      vim.notify(\"No URI provided, aborting\", vim.log.levels.WARN)\n      return\n    end\n  end\n  local info = parse_netrw_uri(uri)\n\n  local check_readable_cmd = string.format(\"ssh %s@%s 'test -r %q'\", info.user, info.host, info.path)\n  local readable = (os.execute(check_readable_cmd) == 0)\n\n  if readable then\n    vim.cmd(\"edit \" .. vim.fn.fnameescape(uri))\n    vim.notify(string.format(\"File %s opened as %s on host %s\", info.path, info.user, info.host), vim.log.levels.INFO)\n    return\n  end\n\n  -- Check if parent directory is writable\n  local dirname = vim.fn.fnamemodify(info.path, \":h\")\n  local check_dir_writable = string.format(\"ssh %s@%s 'test -w %q'\", info.user, info.host, dirname)\n  local dir_writable = (os.execute(check_dir_writable) == 0)\n\n  if dir_writable then\n    -- File does not exist, try to create it\n    local touch_cmd = string.format(\"ssh %s@%s 'touch %q'\", info.user, info.host, info.path)\n    if os.execute(touch_cmd) == 0 then\n      vim.cmd(\"edit \" .. vim.fn.fnameescape(uri))\n      vim.notify(string.format(\"File %s opened as %s on host %s\", info.path, info.user, info.host), vim.log.levels.INFO)\n      return\n    else\n      -- Could not create file, escalate to sudo\n      vim.cmd(\"OpenRemoteWithSudo \" .. vim.fn.fnameescape(uri))\n      return\n    end\n  else\n    -- Directory not writable, escalate to sudo\n    vim.cmd(\"OpenRemoteWithSudo \" .. vim.fn.fnameescape(uri))\n    return\n  end\nend\n\nvim.api.nvim_create_autocmd(\"FileType\", {\n  pattern = \"netrw\",\n  callback = function()\n    vim.keymap.set(\"n\", \"&lt;leader>ro\", function()\n      local netrw_curdir = vim.b.netrw_curdir or \"\"\n      local filename = vim.fn.expand(\"&lt;cfile>\")\n      local needs_slash = netrw_curdir:sub(-1) ~= \"\/\" and filename:sub(1, 1) ~= \"\/\"\n      local full_uri = needs_slash and (netrw_curdir .. \"\/\" .. filename) or (netrw_curdir .. filename)\n      if filename:sub(-1) == \"\/\" then\n        return\n      end -- skip directories\n      open_remote_smart({ args = full_uri })\n    end, { buffer = true, noremap = true, silent = true })\n  end,\n})\n\nvim.api.nvim_create_user_command(\"OpenRemoteSmart\", open_remote_smart, { nargs = \"?\" })\nvim.api.nvim_create_user_command(\"OpenRemoteWithSudo\", open_remote_with_sudo, { nargs = \"?\" })\nvim.api.nvim_create_user_command(\"SudoWriteRemote\", sudo_write_remote, {})\n<\/code><\/pre>\n","protected":false},"excerpt":{"rendered":"<p>This is kind of an esoteric niche thing &#8211; 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&#8230;<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[1],"tags":[],"class_list":["post-538","post","type-post","status-publish","format-standard","hentry","category-uncategorized"],"_links":{"self":[{"href":"https:\/\/nramkumar.org\/tech\/wp-json\/wp\/v2\/posts\/538","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/nramkumar.org\/tech\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/nramkumar.org\/tech\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/nramkumar.org\/tech\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/nramkumar.org\/tech\/wp-json\/wp\/v2\/comments?post=538"}],"version-history":[{"count":2,"href":"https:\/\/nramkumar.org\/tech\/wp-json\/wp\/v2\/posts\/538\/revisions"}],"predecessor-version":[{"id":541,"href":"https:\/\/nramkumar.org\/tech\/wp-json\/wp\/v2\/posts\/538\/revisions\/541"}],"wp:attachment":[{"href":"https:\/\/nramkumar.org\/tech\/wp-json\/wp\/v2\/media?parent=538"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/nramkumar.org\/tech\/wp-json\/wp\/v2\/categories?post=538"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/nramkumar.org\/tech\/wp-json\/wp\/v2\/tags?post=538"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}