@@ -123,6 +123,53 @@ def _ensure_windows_askpass_cmd() -> str:
123123 return str (path )
124124
125125
126+ def _ensure_posix_askpass_sh () -> str :
127+ """Create a minimal SSH_ASKPASS helper for POSIX and return its path.
128+
129+ OpenSSH can read passwords via an external helper specified by SSH_ASKPASS.
130+ We provide a tiny, non-interactive helper that prints the password from an
131+ environment variable. This avoids fragile prompt parsing and works even
132+ when stdin is not a TTY or when ssh suppresses prompts.
133+ """
134+ path = Path (tempfile .gettempdir ()) / "ptrlib-ssh-askpass.sh"
135+
136+ content = (
137+ "#!/bin/sh\n "
138+ "# ptrlib askpass helper (POSIX)\n "
139+ "# $1 is the prompt (ignored).\n "
140+ "# Print the password from $PTRLIB_SSH_PASSWORD without a trailing newline.\n "
141+ "if [ -n \" $PTRLIB_SSH_PASSWORD\" ]; then\n "
142+ " printf '%s' \" $PTRLIB_SSH_PASSWORD\" \n "
143+ "fi\n "
144+ )
145+
146+ try :
147+ if path .is_file ():
148+ try :
149+ if path .read_text (encoding = "utf-8" , errors = "ignore" ) == content :
150+ # Ensure executable bit is set
151+ try :
152+ os .chmod (path , 0o700 )
153+ except Exception :
154+ pass
155+ return str (path )
156+ except Exception :
157+ pass
158+ path .write_text (content , encoding = "utf-8" )
159+ try :
160+ os .chmod (path , 0o700 )
161+ except Exception :
162+ pass
163+ except Exception :
164+ # Fallback to a per-process path
165+ path = Path (tempfile .gettempdir ()) / f"ptrlib-ssh-askpass-{ os .getpid ()} .sh"
166+ path .write_text (content , encoding = "utf-8" )
167+ with contextlib .suppress (Exception ):
168+ os .chmod (path , 0o700 )
169+
170+ return str (path )
171+
172+
126173def _ensure_ssh_option (options : list [str ], key : str , value : str ) -> None :
127174 """Append -oKey=Value if the key isn't already present."""
128175 prefix = f"-o{ key } ="
@@ -194,6 +241,18 @@ def SSH(host: str,
194241 env ["SSH_ASKPASS_REQUIRE" ] = "force"
195242 # Some builds still require DISPLAY to be non-empty to invoke askpass.
196243 env .setdefault ("DISPLAY" , "1" )
244+ else :
245+ # On POSIX, prefer SSH_ASKPASS to avoid fragile prompt parsing and to
246+ # work even when OpenSSH chooses not to echo prompts to the PTY.
247+ askpass = _ensure_posix_askpass_sh ()
248+ env = os .environ .copy ()
249+ env ["PTRLIB_SSH_PASSWORD" ] = password
250+ # Ensure no controlling TTY to force askpass on older OpenSSH builds
251+ env ["PTRLIB_START_NEW_SESSION" ] = "1"
252+ env ["SSH_ASKPASS" ] = askpass
253+ env ["SSH_ASKPASS_REQUIRE" ] = "force"
254+ # Historically OpenSSH only invoked askpass when DISPLAY was set; keep it.
255+ env .setdefault ("DISPLAY" , "1" )
197256
198257 argv = _build_ssh_argv (
199258 host ,
@@ -205,34 +264,39 @@ def SSH(host: str,
205264 command = command ,
206265 )
207266
208- # On POSIX, use a PTY for interactive sessions and/or password prompts.
209- need_tty = (not command ) or (password is not None )
267+ # On POSIX, use a PTY for interactive shells when no programmatic password is used.
268+ # If a password is provided, prefer pipes (no controlling TTY) so SSH_ASKPASS is
269+ # reliably used even on older OpenSSH that ignore SSH_ASKPASS_REQUIRE=force.
210270 if os .name != 'nt' :
211- sess = Process (argv , use_tty = need_tty , env = env )
271+ use_local_pty = (password is None ) and (not command )
272+ sess = Process (argv , use_tty = use_local_pty , env = env )
212273 else :
213274 sess = Process (argv , env = env )
214275
215276 sess .prompt = ""
216277
217278 if password is not None and os .name != 'nt' :
218- # Handle common prompts robustly (case-insensitive, includes hostkey prompt).
219- hostkey_re = re .compile (br"(?i)are you sure you want to continue connecting" )
220- password_re = re .compile (br"(?i)password:\s*" )
221- denied_re = re .compile (br"(?i)permission denied" )
222-
223- # Best-effort prompt dance: accept host key prompt, then send password.
224- # If nothing matches within a short time, continue and let the user drive.
225- for _ in range (3 ):
226- with contextlib .suppress (Exception ):
227- m = sess .recvregex ([hostkey_re , password_re , denied_re ], timeout = 10 )
228- if m .re is hostkey_re :
229- sess .sendline ("yes" )
230- continue
231- if m .re is password_re :
232- sess .sendline (password )
233- break
234- if m .re is denied_re :
235- raise PermissionError ("SSH authentication failed (Permission denied)" )
279+ # If we're using askpass, prompts won't appear on the TTY. Skip prompt parsing.
280+ using_askpass = (env is not None ) and (env .get ("SSH_ASKPASS_REQUIRE" ) == "force" )
281+ if not using_askpass :
282+ # Handle common prompts robustly (case-insensitive, includes hostkey prompt).
283+ hostkey_re = re .compile (br"(?i)are you sure you want to continue connecting" )
284+ password_re = re .compile (br"(?i)password:\s*" )
285+ denied_re = re .compile (br"(?i)permission denied" )
286+
287+ # Best-effort prompt dance: accept host key prompt, then send password.
288+ # If nothing matches within a short time, continue and let the user drive.
289+ for _ in range (3 ):
290+ with contextlib .suppress (Exception ):
291+ m = sess .recvregex ([hostkey_re , password_re , denied_re ], timeout = 10 )
292+ if m .re is hostkey_re :
293+ sess .sendline ("yes" )
294+ continue
295+ if m .re is password_re :
296+ sess .sendline (password )
297+ break
298+ if m .re is denied_re :
299+ raise PermissionError ("SSH authentication failed (Permission denied)" )
236300
237301 # Windows: if we forced BatchMode (password=None), fail fast on auth errors.
238302 if os .name == 'nt' and password is None :
0 commit comments