From 55944577d438af98fa8fb23f9a0e195a7eed1f2a Mon Sep 17 00:00:00 2001 From: Robert Hill Date: Thu, 10 Sep 2026 22:16:32 -0400 Subject: [PATCH] fix(ssh): refuse a forced tty where sftp and captured output run RequestTTY force in ssh_config closes the sftp channel, so the mount fails with "remote host has disconnected", and a tty would also put carriage returns in the remote-home output. Interactive paths still get their tty. --- lua/sshfs/lib/ssh.lua | 6 +++++- tests/remote_command_spec.lua | 20 ++++++++++++++++++++ tests/ssh_spec.lua | 2 +- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/lua/sshfs/lib/ssh.lua b/lua/sshfs/lib/ssh.lua index 9bd27cc..6fc50fe 100644 --- a/lua/sshfs/lib/ssh.lua +++ b/lua/sshfs/lib/ssh.lua @@ -50,7 +50,11 @@ local function get_ssh_options(auth_type) -- A config RemoteCommand cannot coexist with the command these paths append, -- and sftp needs a plain session, so it is cleared for everything but a shell. - if auth_type == "batch" or auth_type == "socket" then table.insert(options, "RemoteCommand=none") end + if auth_type == "batch" or auth_type == "socket" then + table.insert(options, "RemoteCommand=none") + -- A forced tty closes the sftp channel and puts carriage returns in captured output. + table.insert(options, "RequestTTY=no") + end -- Add ControlMaster options local control_opts = Config.get_control_master_options() diff --git a/tests/remote_command_spec.lua b/tests/remote_command_spec.lua index 7eb408d..62df5c3 100644 --- a/tests/remote_command_spec.lua +++ b/tests/remote_command_spec.lua @@ -23,6 +23,26 @@ local function clears_remote_command(cmd) return option_index(cmd, "RemoteCommand=none") ~= nil end +describe("RequestTTY handling", function() + it("refuses a forced tty on the paths that carry sftp or captured output", function() + local Ssh = load_ssh() + + expect.truthy(option_index(Ssh.build_batch_command("example.com"), "RequestTTY=no")) + expect.truthy(option_index(Ssh.build_home_command("example.com"), "RequestTTY=no"), "readlink output must be clean") + expect.contains(Ssh.build_command_string("socket"), "-o RequestTTY=no") + end) + + it("leaves the interactive authentication terminal a tty", function() + local Ssh = load_ssh() + expect.is_nil(option_index(Ssh.build_auth_command("example.com"), "RequestTTY=no"), "a password prompt needs one") + end) + + it("leaves a terminal session a tty", function() + local Ssh = load_ssh() + expect.is_nil(option_index(Ssh.build_command("example.com", "/srv/app"), "RequestTTY=no")) + end) +end) + describe("RemoteCommand handling", function() it("clears it on every command that appends a remote command", function() local Ssh = load_ssh() diff --git a/tests/ssh_spec.lua b/tests/ssh_spec.lua index 68bcc4f..ab346a9 100644 --- a/tests/ssh_spec.lua +++ b/tests/ssh_spec.lua @@ -13,7 +13,7 @@ end describe("Ssh.build_command_string", function() it("passes the control path and clears any RemoteCommand when reusing a socket", function() local Ssh = load_ssh() - expect.eq(Ssh.build_command_string("socket"), "ssh -o RemoteCommand=none -o " .. CONTROL_PATH) + expect.eq(Ssh.build_command_string("socket"), "ssh -o RemoteCommand=none -o RequestTTY=no -o " .. CONTROL_PATH) end) it("forces a master and disables prompts for batch connections", function()