Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e7e3332073 | |||
| b26b32fcf1 |
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "sessions-sublime"
|
||||
version = "0.7.2"
|
||||
version = "0.7.3"
|
||||
description = "Sublime-facing Python code for Sessions."
|
||||
requires-python = ">=3.8"
|
||||
license = {text = "MIT"}
|
||||
|
||||
17
rust/Cargo.lock
generated
17
rust/Cargo.lock
generated
@@ -221,7 +221,7 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "local_bridge"
|
||||
version = "0.7.2"
|
||||
version = "0.7.3"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"glob",
|
||||
@@ -432,7 +432,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "session_helper"
|
||||
version = "0.7.2"
|
||||
version = "0.7.3"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"notify",
|
||||
@@ -443,16 +443,23 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "session_protocol"
|
||||
version = "0.7.2"
|
||||
version = "0.7.3"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sessions_askpass"
|
||||
version = "0.7.3"
|
||||
dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sessions_native"
|
||||
version = "0.7.2"
|
||||
version = "0.7.3"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"session_protocol",
|
||||
@@ -763,7 +770,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "workspace_identity"
|
||||
version = "0.7.2"
|
||||
version = "0.7.3"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
|
||||
@@ -3,6 +3,7 @@ members = [
|
||||
"crates/local_bridge",
|
||||
"crates/session_protocol",
|
||||
"crates/session_helper",
|
||||
"crates/sessions_askpass",
|
||||
"crates/sessions_native",
|
||||
"crates/workspace_identity",
|
||||
]
|
||||
@@ -11,7 +12,7 @@ resolver = "2"
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
version = "0.7.2"
|
||||
version = "0.7.3"
|
||||
authors = ["Myeongseon Choi <key262yek@gmail.com>"]
|
||||
repository = "https://git.teahaven.kr/sublime-rs/sessions"
|
||||
homepage = "https://git.teahaven.kr/sublime-rs/sessions"
|
||||
|
||||
19
rust/crates/sessions_askpass/Cargo.toml
Normal file
19
rust/crates/sessions_askpass/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "sessions_askpass"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
description = "PE binary that brokers SSH_ASKPASS prompts back to the Sessions plugin via filesystem rendezvous."
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "sessions_askpass"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
261
rust/crates/sessions_askpass/src/main.rs
Normal file
261
rust/crates/sessions_askpass/src/main.rs
Normal file
@@ -0,0 +1,261 @@
|
||||
//! SSH_ASKPASS shim for the Sessions Sublime plugin.
|
||||
//!
|
||||
//! Why this exists: Windows OpenSSH spawns ``SSH_ASKPASS`` via its own
|
||||
//! ``posix_spawnp`` shim, which in turn calls ``CreateProcessW`` directly.
|
||||
//! ``CreateProcessW`` only accepts real PE binaries — ``.cmd`` / ``.bat``
|
||||
//! scripts aren't loadable that way and fail with ``ERROR_FILE_NOT_FOUND``.
|
||||
//! Shipping this tiny ``.exe`` lets the plugin's prompt-bridge protocol work
|
||||
//! on Windows without giving up password / passphrase authentication.
|
||||
//!
|
||||
//! Protocol (matched verbatim by the Sublime side in ``ssh_runner.py`` /
|
||||
//! ``ssh_file_transport.py``):
|
||||
//!
|
||||
//! - ``SESSIONS_ASKPASS_REQUEST`` — file we write the prompt text into
|
||||
//! - ``SESSIONS_ASKPASS_RESPONSE`` — file the plugin writes the answer into
|
||||
//! - ``SESSIONS_ASKPASS_CANCEL`` — file the plugin touches to cancel
|
||||
//!
|
||||
//! Behaviour:
|
||||
//!
|
||||
//! 1. Read the prompt text from ``argv[1]`` (ssh passes a single argument —
|
||||
//! the prompt to display).
|
||||
//! 2. Write that prompt to ``SESSIONS_ASKPASS_REQUEST`` (atomic write via
|
||||
//! ``tmp + rename``).
|
||||
//! 3. Poll for ``SESSIONS_ASKPASS_RESPONSE`` or ``SESSIONS_ASKPASS_CANCEL``.
|
||||
//! 4. On response: write its contents to stdout (ssh reads stdout as the
|
||||
//! password) and exit ``0``.
|
||||
//! 5. On cancel: exit ``1`` so ssh treats it as a refused prompt.
|
||||
//! 6. Bounded by ``SESSIONS_ASKPASS_TIMEOUT_SECS`` (default ``120``); if
|
||||
//! nothing arrives the process exits ``1`` so the ssh attempt fails
|
||||
//! cleanly instead of hanging.
|
||||
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 120;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let prompt = env::args().nth(1).unwrap_or_default();
|
||||
let request = match required_env_path("SESSIONS_ASKPASS_REQUEST") {
|
||||
Some(path) => path,
|
||||
None => return ExitCode::from(2),
|
||||
};
|
||||
let response = match required_env_path("SESSIONS_ASKPASS_RESPONSE") {
|
||||
Some(path) => path,
|
||||
None => return ExitCode::from(2),
|
||||
};
|
||||
let cancel = match required_env_path("SESSIONS_ASKPASS_CANCEL") {
|
||||
Some(path) => path,
|
||||
None => return ExitCode::from(2),
|
||||
};
|
||||
if let Err(_e) = write_prompt(&request, &prompt) {
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
let timeout = resolve_timeout();
|
||||
match poll_for_response(&response, &cancel, timeout) {
|
||||
PollOutcome::Response(text) => {
|
||||
// ssh expects the password on stdout. Don't append a newline:
|
||||
// some prompt flows treat trailing whitespace as part of the
|
||||
// password. The Sublime side is responsible for stripping any
|
||||
// trailing newline the user typed before writing the response.
|
||||
if std::io::stdout().write_all(text.as_bytes()).is_err() {
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
PollOutcome::Cancelled => ExitCode::from(1),
|
||||
PollOutcome::TimedOut => ExitCode::from(1),
|
||||
}
|
||||
}
|
||||
|
||||
fn required_env_path(name: &str) -> Option<PathBuf> {
|
||||
match env::var_os(name) {
|
||||
Some(value) if !value.is_empty() => Some(PathBuf::from(value)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_timeout() -> Duration {
|
||||
let raw = env::var("SESSIONS_ASKPASS_TIMEOUT_SECS").ok();
|
||||
let secs = raw
|
||||
.and_then(|s| s.trim().parse::<u64>().ok())
|
||||
.filter(|s| *s > 0)
|
||||
.unwrap_or(DEFAULT_TIMEOUT_SECS);
|
||||
Duration::from_secs(secs)
|
||||
}
|
||||
|
||||
fn write_prompt(request: &Path, prompt: &str) -> std::io::Result<()> {
|
||||
if let Some(parent) = request.parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
{
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let tmp = request.with_extension("tmp");
|
||||
fs::write(&tmp, prompt.as_bytes())?;
|
||||
fs::rename(&tmp, request)
|
||||
}
|
||||
|
||||
enum PollOutcome {
|
||||
Response(String),
|
||||
Cancelled,
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
fn poll_for_response(response: &Path, cancel: &Path, timeout: Duration) -> PollOutcome {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if let Ok(text) = fs::read_to_string(response) {
|
||||
// Best-effort cleanup: the plugin treats response.txt as
|
||||
// single-shot, but a stale file would be reused on the next
|
||||
// prompt. Ignore failures — the OS may briefly hold the
|
||||
// handle on Windows.
|
||||
let _ = fs::remove_file(response);
|
||||
return PollOutcome::Response(text);
|
||||
}
|
||||
if cancel.exists() {
|
||||
let _ = fs::remove_file(cancel);
|
||||
return PollOutcome::Cancelled;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return PollOutcome::TimedOut;
|
||||
}
|
||||
thread::sleep(POLL_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::sync::Mutex;
|
||||
|
||||
// Env-var mutation in tests must serialize: the binary reads env vars
|
||||
// at startup, but unit tests share the process, so concurrent
|
||||
// ``env::set_var`` from different test threads would race.
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[test]
|
||||
fn resolve_timeout_defaults_when_var_unset() {
|
||||
let _guard = match ENV_LOCK.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
// SAFETY: tests serialize env access via ENV_LOCK; no other thread
|
||||
// mutates SESSIONS_ASKPASS_TIMEOUT_SECS while this test runs.
|
||||
unsafe {
|
||||
env::remove_var("SESSIONS_ASKPASS_TIMEOUT_SECS");
|
||||
}
|
||||
assert_eq!(resolve_timeout(), Duration::from_secs(DEFAULT_TIMEOUT_SECS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_timeout_parses_positive_int() {
|
||||
let _guard = match ENV_LOCK.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
unsafe {
|
||||
env::set_var("SESSIONS_ASKPASS_TIMEOUT_SECS", "30");
|
||||
}
|
||||
assert_eq!(resolve_timeout(), Duration::from_secs(30));
|
||||
unsafe {
|
||||
env::remove_var("SESSIONS_ASKPASS_TIMEOUT_SECS");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_timeout_rejects_zero_and_garbage() {
|
||||
let _guard = match ENV_LOCK.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
for bad in ["0", "-5", "abc", ""] {
|
||||
unsafe {
|
||||
env::set_var("SESSIONS_ASKPASS_TIMEOUT_SECS", bad);
|
||||
}
|
||||
assert_eq!(
|
||||
resolve_timeout(),
|
||||
Duration::from_secs(DEFAULT_TIMEOUT_SECS),
|
||||
"bad value {bad:?} must fall back to default",
|
||||
);
|
||||
}
|
||||
unsafe {
|
||||
env::remove_var("SESSIONS_ASKPASS_TIMEOUT_SECS");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_prompt_creates_file_atomically() -> std::io::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let request = dir.path().join("request.txt");
|
||||
write_prompt(&request, "Password for user@host: ")?;
|
||||
let content = fs::read_to_string(&request)?;
|
||||
assert_eq!(content, "Password for user@host: ");
|
||||
// No leftover ``.tmp``.
|
||||
let tmp = request.with_extension("tmp");
|
||||
assert!(!tmp.exists(), "rename target must clean up its tmp file");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poll_returns_response_when_file_appears() -> std::io::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let response = dir.path().join("response.txt");
|
||||
let cancel = dir.path().join("cancel.txt");
|
||||
let response_for_writer = response.clone();
|
||||
let writer = thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(80));
|
||||
fs::write(&response_for_writer, "hunter2").unwrap_or(());
|
||||
});
|
||||
let outcome = poll_for_response(&response, &cancel, Duration::from_secs(5));
|
||||
writer.join().ok();
|
||||
match outcome {
|
||||
PollOutcome::Response(text) => assert_eq!(text, "hunter2"),
|
||||
PollOutcome::Cancelled => unreachable!("expected Response, got Cancelled"),
|
||||
PollOutcome::TimedOut => unreachable!("expected Response, got TimedOut"),
|
||||
}
|
||||
// Response file is consumed.
|
||||
assert!(!response.exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poll_returns_cancelled_when_cancel_file_appears() -> std::io::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let response = dir.path().join("response.txt");
|
||||
let cancel = dir.path().join("cancel.txt");
|
||||
let cancel_for_writer = cancel.clone();
|
||||
let writer = thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(80));
|
||||
fs::write(&cancel_for_writer, "").unwrap_or(());
|
||||
});
|
||||
let outcome = poll_for_response(&response, &cancel, Duration::from_secs(5));
|
||||
writer.join().ok();
|
||||
match outcome {
|
||||
PollOutcome::Cancelled => {}
|
||||
PollOutcome::Response(text) => unreachable!("expected Cancelled, got {text:?}"),
|
||||
PollOutcome::TimedOut => unreachable!("expected Cancelled, got TimedOut"),
|
||||
}
|
||||
assert!(!cancel.exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poll_times_out_when_nothing_appears() -> std::io::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let response = dir.path().join("response.txt");
|
||||
let cancel = dir.path().join("cancel.txt");
|
||||
let outcome = poll_for_response(&response, &cancel, Duration::from_millis(150));
|
||||
match outcome {
|
||||
PollOutcome::TimedOut => {}
|
||||
PollOutcome::Response(text) => unreachable!("expected TimedOut, got {text:?}"),
|
||||
PollOutcome::Cancelled => unreachable!("expected TimedOut, got Cancelled"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import platform
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
@@ -566,7 +565,11 @@ def _needs_remote_session_helper_push(
|
||||
child_env: Mapping[str, str],
|
||||
) -> bool:
|
||||
"""Return True when the remote cache is missing or the revision mismatches."""
|
||||
from .ssh_runner import _local_ssh_argv
|
||||
from .ssh_runner import (
|
||||
_default_sublime_prompt_callback,
|
||||
_local_ssh_argv,
|
||||
service_popen_with_prompt_bridge,
|
||||
)
|
||||
|
||||
script = _remote_session_helper_push_check_script(revision)
|
||||
local_argv = _local_ssh_argv(
|
||||
@@ -574,15 +577,41 @@ def _needs_remote_session_helper_push(
|
||||
["bash", "-lc", script],
|
||||
disable_connection_reuse=False,
|
||||
)
|
||||
askpass_request = child_env.get("SESSIONS_ASKPASS_REQUEST")
|
||||
temp_root = Path(askpass_request).parent if askpass_request else None
|
||||
prompt_callback = _default_sublime_prompt_callback() if temp_root else None
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
list(local_argv),
|
||||
capture_output=True,
|
||||
timeout=30.0,
|
||||
env=dict(child_env),
|
||||
check=False,
|
||||
**_subprocess_no_window_kwargs(),
|
||||
)
|
||||
if temp_root is not None and prompt_callback is not None:
|
||||
process = subprocess.Popen(
|
||||
list(local_argv),
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env=dict(child_env),
|
||||
**_subprocess_no_window_kwargs(),
|
||||
)
|
||||
stdout_b, stderr_b = service_popen_with_prompt_bridge(
|
||||
process,
|
||||
local_argv=tuple(local_argv),
|
||||
temp_root=temp_root,
|
||||
prompt_callback=prompt_callback,
|
||||
timeout_s=30.0,
|
||||
)
|
||||
completed = subprocess.CompletedProcess(
|
||||
list(local_argv),
|
||||
process.returncode,
|
||||
stdout=stdout_b or b"",
|
||||
stderr=stderr_b or b"",
|
||||
)
|
||||
else:
|
||||
completed = subprocess.run(
|
||||
list(local_argv),
|
||||
capture_output=True,
|
||||
timeout=30.0,
|
||||
env=dict(child_env),
|
||||
check=False,
|
||||
**_subprocess_no_window_kwargs(),
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as error:
|
||||
raise SessionHelperStartError(
|
||||
"SSH check for remote session_helper failed: {}".format(error)
|
||||
@@ -608,20 +637,19 @@ def _ssh_auth_failure_hint(stderr: str) -> str:
|
||||
|
||||
Returns the empty string when the failure is not auth-shaped — ``ssh``
|
||||
failures from network / DNS / port issues should surface their own
|
||||
stderr verbatim. The auth-shaped patterns we recognise:
|
||||
``Permission denied (publickey)`` (BatchMode=yes ran out of keys),
|
||||
``Permission denied (keyboard-interactive)`` (legacy non-batch path),
|
||||
and ``ssh_askpass: posix_spawnp`` (Windows .cmd-spawn failure).
|
||||
stderr verbatim. We recognise ``Permission denied (publickey)`` and
|
||||
``Permission denied (keyboard-interactive)`` shapes plus the historical
|
||||
Windows ``ssh_askpass: posix_spawnp`` shape (which the v0.7.3 native
|
||||
``sessions_askpass.exe`` is meant to eliminate; the hint stays as a
|
||||
breadcrumb in case the binary went missing).
|
||||
"""
|
||||
if "Permission denied" not in stderr and "ssh_askpass" not in stderr:
|
||||
return ""
|
||||
return (
|
||||
"Sessions requires key-based SSH auth on Windows; password / "
|
||||
"keyboard-interactive prompts are not supported. Set up "
|
||||
"~/.ssh/id_* and copy the public key to the remote "
|
||||
"~/.ssh/authorized_keys (or use ssh-agent)."
|
||||
if sys.platform.startswith("win")
|
||||
else "SSH authentication failed. Verify your keys / agent / known-hosts."
|
||||
"SSH authentication failed. Verify your password / keys / "
|
||||
"ssh-agent / known-hosts; if Windows users see "
|
||||
"``ssh_askpass: posix_spawnp``, the native ``sessions_askpass.exe`` "
|
||||
"wasn't built — run ``cargo build --release -p sessions_askpass``."
|
||||
)
|
||||
|
||||
|
||||
@@ -632,7 +660,11 @@ def _push_session_helper_via_ssh(
|
||||
child_env: Mapping[str, str],
|
||||
) -> None:
|
||||
"""Stream *local_helper* to the remote install path and write ``.revision``."""
|
||||
from .ssh_runner import _local_ssh_argv
|
||||
from .ssh_runner import (
|
||||
_default_sublime_prompt_callback,
|
||||
_local_ssh_argv,
|
||||
service_popen_with_prompt_bridge,
|
||||
)
|
||||
|
||||
rev_b64 = base64.b64encode(revision.encode("utf-8")).decode("ascii")
|
||||
# Path segment is the release revision — aligned with ``local_bridge``'s
|
||||
@@ -653,17 +685,43 @@ def _push_session_helper_via_ssh(
|
||||
["bash", "-lc", script],
|
||||
disable_connection_reuse=False,
|
||||
)
|
||||
askpass_request = child_env.get("SESSIONS_ASKPASS_REQUEST")
|
||||
temp_root = Path(askpass_request).parent if askpass_request else None
|
||||
prompt_callback = _default_sublime_prompt_callback() if temp_root else None
|
||||
try:
|
||||
with open(local_helper, "rb") as helper_fp:
|
||||
completed = subprocess.run(
|
||||
list(local_argv),
|
||||
stdin=helper_fp,
|
||||
capture_output=True,
|
||||
timeout=180.0,
|
||||
env=dict(child_env),
|
||||
check=False,
|
||||
**_subprocess_no_window_kwargs(),
|
||||
)
|
||||
if temp_root is not None and prompt_callback is not None:
|
||||
process = subprocess.Popen(
|
||||
list(local_argv),
|
||||
stdin=helper_fp,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env=dict(child_env),
|
||||
**_subprocess_no_window_kwargs(),
|
||||
)
|
||||
stdout_b, stderr_b = service_popen_with_prompt_bridge(
|
||||
process,
|
||||
local_argv=tuple(local_argv),
|
||||
temp_root=temp_root,
|
||||
prompt_callback=prompt_callback,
|
||||
timeout_s=180.0,
|
||||
)
|
||||
completed = subprocess.CompletedProcess(
|
||||
list(local_argv),
|
||||
process.returncode,
|
||||
stdout=stdout_b or b"",
|
||||
stderr=stderr_b or b"",
|
||||
)
|
||||
else:
|
||||
completed = subprocess.run(
|
||||
list(local_argv),
|
||||
stdin=helper_fp,
|
||||
capture_output=True,
|
||||
timeout=180.0,
|
||||
env=dict(child_env),
|
||||
check=False,
|
||||
**_subprocess_no_window_kwargs(),
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as error:
|
||||
raise SessionHelperStartError(
|
||||
"SSH install of session_helper failed: {}".format(error)
|
||||
@@ -1197,15 +1255,10 @@ def _persistent_bridge_for_host(
|
||||
|
||||
def _bridge_child_env(askpass_dir: Path) -> dict:
|
||||
"""Build the child env for the persistent bridge, including SSH askpass."""
|
||||
from .ssh_runner import (
|
||||
_askpass_script_name,
|
||||
_bridge_askpass_script_contents,
|
||||
)
|
||||
from .ssh_runner import _materialize_bridge_askpass_executable
|
||||
|
||||
env = os.environ.copy()
|
||||
askpass_path = askpass_dir / _askpass_script_name()
|
||||
askpass_path.write_text(_bridge_askpass_script_contents(), encoding="utf-8")
|
||||
askpass_path.chmod(0o700)
|
||||
askpass_path = _materialize_bridge_askpass_executable(askpass_dir)
|
||||
env["SSH_ASKPASS"] = str(askpass_path)
|
||||
env["SSH_ASKPASS_REQUIRE"] = "force"
|
||||
env["SESSIONS_ASKPASS_REQUEST"] = str(askpass_dir / "request.txt")
|
||||
|
||||
@@ -304,21 +304,8 @@ def _local_ssh_argv(
|
||||
*,
|
||||
disable_connection_reuse: bool,
|
||||
) -> Tuple[str, ...]:
|
||||
"""Build the local ``ssh`` argv for one remote command.
|
||||
|
||||
Batch-mode is forced on Windows: Windows OpenSSH spawns ``SSH_ASKPASS``
|
||||
via ``posix_spawnp`` which falls through to ``CreateProcessW``, and
|
||||
``CreateProcessW`` can't execute the ``.cmd`` askpass we ship (only
|
||||
real PE ``.exe`` binaries succeed). So the askpass-bridge prompt
|
||||
flow is dead on Windows today; force ``BatchMode=yes`` to fail fast
|
||||
without the spurious ``ssh_askpass: posix_spawnp: No such file or
|
||||
directory`` noise. Users on Windows must rely on key-based auth
|
||||
(``ssh-add`` / ``~/.ssh/id_*``) until we ship a native askpass.exe.
|
||||
"""
|
||||
if sys.platform.startswith("win"):
|
||||
argv = ["ssh", "-o", "BatchMode=yes"]
|
||||
else:
|
||||
argv = ["ssh", "-o", "BatchMode=no"]
|
||||
"""Build the local ``ssh`` argv for one remote command."""
|
||||
argv = ["ssh", "-o", "BatchMode=no"]
|
||||
if disable_connection_reuse:
|
||||
argv.extend(["-o", "ControlMaster=no", "-S", "none"])
|
||||
argv.append(host_alias)
|
||||
@@ -355,31 +342,63 @@ def _run_ssh_remote_command_with_prompt_bridge(
|
||||
if stdin_text:
|
||||
process.stdin.write(stdin_text)
|
||||
process.stdin.close()
|
||||
deadline = time.time() + max(0.1, timeout_s)
|
||||
while process.poll() is None:
|
||||
if time.time() >= deadline:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=1.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
raise subprocess.TimeoutExpired(
|
||||
cmd=list(local_argv),
|
||||
timeout=timeout_s,
|
||||
)
|
||||
_service_prompt_bridge_request(temp_root, prompt_callback)
|
||||
time.sleep(0.05)
|
||||
_service_prompt_bridge_request(temp_root, prompt_callback)
|
||||
stdout = process.stdout.read() if process.stdout is not None else ""
|
||||
stderr = process.stderr.read() if process.stderr is not None else ""
|
||||
stdout_b, stderr_b = service_popen_with_prompt_bridge(
|
||||
process,
|
||||
local_argv=local_argv,
|
||||
temp_root=temp_root,
|
||||
prompt_callback=prompt_callback,
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
list(local_argv),
|
||||
process.returncode,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
stdout=stdout_b or "",
|
||||
stderr=stderr_b or "",
|
||||
)
|
||||
|
||||
|
||||
def service_popen_with_prompt_bridge(
|
||||
process: "subprocess.Popen",
|
||||
*,
|
||||
local_argv: Tuple[str, ...],
|
||||
temp_root: Path,
|
||||
prompt_callback: PromptCallback,
|
||||
timeout_s: float,
|
||||
):
|
||||
"""Drive ``process`` to completion while servicing the askpass prompt bridge.
|
||||
|
||||
Caller owns the ``Popen`` (incl. stdin handoff) and ``temp_root`` (the
|
||||
askpass tempdir whose ``request.txt`` / ``response.txt`` / ``cancel.txt``
|
||||
files we ferry between OpenSSH's ``SSH_ASKPASS`` child and Sublime's UI
|
||||
via ``prompt_callback``). Returns ``(stdout, stderr)`` read off the
|
||||
process's pipes after exit; raises ``subprocess.TimeoutExpired`` when
|
||||
``timeout_s`` elapses without exit.
|
||||
|
||||
Pulled out of ``_run_ssh_remote_command_with_prompt_bridge`` so the
|
||||
bridge-helper push / probe paths in ``ssh_file_transport.py`` can share
|
||||
the same servicing loop without the simple-text-stdin assumption that
|
||||
helper baked in.
|
||||
"""
|
||||
deadline = time.time() + max(0.1, timeout_s)
|
||||
while process.poll() is None:
|
||||
if time.time() >= deadline:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=1.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
raise subprocess.TimeoutExpired(
|
||||
cmd=list(local_argv),
|
||||
timeout=timeout_s,
|
||||
)
|
||||
_service_prompt_bridge_request(temp_root, prompt_callback)
|
||||
time.sleep(0.05)
|
||||
_service_prompt_bridge_request(temp_root, prompt_callback)
|
||||
stdout = process.stdout.read() if process.stdout is not None else None
|
||||
stderr = process.stderr.read() if process.stderr is not None else None
|
||||
return stdout, stderr
|
||||
|
||||
|
||||
def _ssh_environment_with_askpass(temp_dir: Path) -> dict:
|
||||
"""Return child-process env that forces OpenSSH askpass prompts."""
|
||||
env = dict(os.environ)
|
||||
@@ -393,9 +412,7 @@ def _ssh_environment_with_askpass(temp_dir: Path) -> dict:
|
||||
|
||||
def _ssh_environment_with_prompt_bridge(temp_root: Path) -> dict:
|
||||
env = dict(os.environ)
|
||||
askpass_path = temp_root / _askpass_script_name()
|
||||
askpass_path.write_text(_bridge_askpass_script_contents(), encoding="utf-8")
|
||||
askpass_path.chmod(0o700)
|
||||
askpass_path = _materialize_bridge_askpass_executable(temp_root)
|
||||
env["SSH_ASKPASS"] = str(askpass_path)
|
||||
env["SSH_ASKPASS_REQUIRE"] = "force"
|
||||
env["SESSIONS_ASKPASS_REQUEST"] = str(temp_root / "request.txt")
|
||||
@@ -404,6 +421,71 @@ def _ssh_environment_with_prompt_bridge(temp_root: Path) -> dict:
|
||||
return env
|
||||
|
||||
|
||||
def _materialize_bridge_askpass_executable(temp_root: Path) -> Path:
|
||||
"""Return the SSH_ASKPASS executable path for the prompt-bridge protocol.
|
||||
|
||||
On Windows OpenSSH spawns ``SSH_ASKPASS`` via ``CreateProcessW``, which
|
||||
can only load real PE binaries — ``.cmd`` / ``.bat`` scripts fail with
|
||||
``ERROR_FILE_NOT_FOUND``. So we ship a tiny native ``sessions_askpass.exe``
|
||||
(Rust crate ``sessions_askpass``) and point SSH_ASKPASS at it. On posix
|
||||
platforms a shell script still works fine and keeps the package
|
||||
portable for users who don't have the Rust binary built.
|
||||
"""
|
||||
if sys.platform.startswith("win"):
|
||||
exe = _resolve_sessions_askpass_binary_path()
|
||||
if exe is not None:
|
||||
return exe
|
||||
# Fall through to script-write so a clear error surfaces in stderr
|
||||
# (the ``.cmd`` will fail to spawn, which is what users saw before
|
||||
# this fix); not raising here keeps the call site uniform across
|
||||
# platforms.
|
||||
askpass_path = temp_root / _askpass_script_name()
|
||||
askpass_path.write_text(_bridge_askpass_script_contents(), encoding="utf-8")
|
||||
askpass_path.chmod(0o700)
|
||||
return askpass_path
|
||||
|
||||
|
||||
def _resolve_sessions_askpass_binary_path() -> Optional[Path]:
|
||||
"""Return the path to the shipped or freshly-built ``sessions_askpass`` binary."""
|
||||
# Lazy-import to avoid a circular dep with ssh_file_transport at module
|
||||
# load time — the helpers we reuse there own the platform-tag and
|
||||
# workspace-root logic for our other Rust binaries.
|
||||
from .ssh_file_transport import (
|
||||
_cargo_target_debug_dir,
|
||||
_rust_binary_suffix,
|
||||
_rust_shipped_local_bridge_search_dirs,
|
||||
_rust_workspace_root,
|
||||
)
|
||||
|
||||
suffix = _rust_binary_suffix()
|
||||
name = "sessions_askpass" + suffix
|
||||
# Search shipped bin dirs first (per-platform tag), mirroring how
|
||||
# local_bridge is discovered. The shipped layout is
|
||||
# ``sublime/sessions/bin/sessions-askpass/<tag>/sessions_askpass.exe``.
|
||||
for directory in _rust_shipped_local_bridge_search_dirs():
|
||||
# The shipped-search helper points at ``local-bridge/<tag>``; swap
|
||||
# the leaf so we look under ``sessions-askpass/<tag>`` for the same
|
||||
# platform tag without rebuilding the search list.
|
||||
adjusted = (
|
||||
directory.parent / "sessions-askpass" / directory.name
|
||||
if directory.name and directory.parent.name == "local-bridge"
|
||||
else directory
|
||||
)
|
||||
candidate = adjusted / name
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
# Cargo debug + release builds (developer machines).
|
||||
for build in ("debug", "release"):
|
||||
if build == "debug":
|
||||
cargo_dir = _cargo_target_debug_dir()
|
||||
else:
|
||||
cargo_dir = _rust_workspace_root() / "target" / "release"
|
||||
candidate = cargo_dir / name
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _service_prompt_bridge_request(
|
||||
temp_root: Path,
|
||||
prompt_callback: PromptCallback,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Edge-case tests for bridge path resolution and spawn guardrails."""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -443,18 +442,14 @@ def test_needs_remote_session_helper_push_raises_on_ssh_failure(
|
||||
ssh_ft._needs_remote_session_helper_push("h", "rev", {})
|
||||
|
||||
|
||||
def test_needs_remote_session_helper_push_appends_key_auth_hint_on_windows(
|
||||
def test_needs_remote_session_helper_push_appends_auth_hint_for_permission_denied(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""``Permission denied`` stderr on Windows surfaces a key-auth setup hint.
|
||||
|
||||
Windows OpenSSH can't spawn our ``.cmd`` askpass (CreateProcessW only
|
||||
accepts real PE binaries), so password / keyboard-interactive auth dies
|
||||
before the user is prompted. The probe must point users at key-based
|
||||
auth in that exact failure shape rather than re-emitting the cryptic
|
||||
``ssh_askpass: posix_spawnp: No such file or directory`` chain.
|
||||
"""
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
"""``Permission denied`` stderr surfaces a setup hint covering both
|
||||
password and key-based paths, plus a breadcrumb about the missing
|
||||
Windows ``sessions_askpass.exe``. The hint is platform-agnostic
|
||||
because v0.7.3 supports password auth via the native askpass binary
|
||||
on every platform — what matters is which auth surface failed."""
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
@@ -466,28 +461,29 @@ def test_needs_remote_session_helper_push_appends_key_auth_hint_on_windows(
|
||||
with pytest.raises(SessionHelperStartError) as excinfo:
|
||||
ssh_ft._needs_remote_session_helper_push("h", "rev", {})
|
||||
msg = str(excinfo.value)
|
||||
assert "key-based SSH auth" in msg
|
||||
assert "authorized_keys" in msg
|
||||
assert "SSH authentication failed" in msg
|
||||
assert "password / keys" in msg
|
||||
|
||||
|
||||
def test_needs_remote_session_helper_push_hints_generic_on_posix(
|
||||
def test_needs_remote_session_helper_push_hints_when_askpass_spawn_failed(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""Posix auth-failure stderr surfaces the generic key/agent hint."""
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
"""``ssh_askpass: posix_spawnp`` stderr is the historical Windows
|
||||
failure shape pre-v0.7.3 — the hint must call it out specifically so
|
||||
users on a stale checkout know to rebuild ``sessions_askpass``."""
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
lambda *_a, **_k: SimpleNamespace(
|
||||
returncode=255,
|
||||
stderr=b"user@host: Permission denied (publickey).\n",
|
||||
stderr=b"ssh_askpass: posix_spawnp: No such file or directory\n",
|
||||
),
|
||||
)
|
||||
with pytest.raises(SessionHelperStartError) as excinfo:
|
||||
ssh_ft._needs_remote_session_helper_push("h", "rev", {})
|
||||
msg = str(excinfo.value)
|
||||
assert "SSH authentication failed" in msg
|
||||
assert "keys / agent" in msg
|
||||
assert "sessions_askpass" in msg
|
||||
assert "cargo build" in msg
|
||||
|
||||
|
||||
def test_needs_remote_session_helper_push_no_hint_for_non_auth_failure(
|
||||
@@ -505,7 +501,7 @@ def test_needs_remote_session_helper_push_no_hint_for_non_auth_failure(
|
||||
with pytest.raises(SessionHelperStartError) as excinfo:
|
||||
ssh_ft._needs_remote_session_helper_push("h", "rev", {})
|
||||
msg = str(excinfo.value)
|
||||
assert "key-based SSH auth" not in msg
|
||||
assert "SSH authentication failed" not in msg
|
||||
assert "Could not resolve hostname" in msg
|
||||
|
||||
|
||||
|
||||
@@ -551,3 +551,117 @@ def test_subprocess_no_window_kwargs_matches_platform() -> None:
|
||||
assert kwargs == {}
|
||||
else:
|
||||
assert kwargs == {}
|
||||
|
||||
|
||||
# --- _materialize_bridge_askpass_executable / sessions_askpass.exe discovery ---
|
||||
|
||||
|
||||
def test_materialize_bridge_askpass_writes_script_on_posix(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""Posix keeps the legacy ``.sh`` write — the native ``sessions_askpass``
|
||||
binary is a Windows-only fix because POSIX ssh's ``posix_spawnp`` happily
|
||||
runs shell scripts. Locking that here so a future native-binary rollout
|
||||
on Linux/macOS doesn't silently break the existing path."""
|
||||
from sessions.ssh_runner import _materialize_bridge_askpass_executable
|
||||
|
||||
monkeypatch.setattr("sessions.ssh_runner.sys.platform", "linux")
|
||||
out = _materialize_bridge_askpass_executable(tmp_path)
|
||||
assert out.name == "sessions-askpass.sh"
|
||||
assert out.exists()
|
||||
assert "SESSIONS_ASKPASS_REQUEST" in out.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_materialize_bridge_askpass_returns_exe_on_windows_when_resolved(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""Windows uses the native ``sessions_askpass.exe`` PE binary instead of
|
||||
a ``.cmd`` script. Without it, OpenSSH's ``CreateProcessW`` rejects the
|
||||
script and ssh dies before the user is ever prompted; the prompt-bridge
|
||||
protocol then has no executable to run."""
|
||||
from sessions.ssh_runner import _materialize_bridge_askpass_executable
|
||||
|
||||
monkeypatch.setattr("sessions.ssh_runner.sys.platform", "win32")
|
||||
fake_exe = tmp_path / "sessions_askpass.exe"
|
||||
fake_exe.write_bytes(b"MZ\x00") # not a real PE, but the resolver only
|
||||
# checks ``is_file``; this test pins the dispatch logic, not the spawn.
|
||||
monkeypatch.setattr(
|
||||
"sessions.ssh_runner._resolve_sessions_askpass_binary_path",
|
||||
lambda: fake_exe,
|
||||
)
|
||||
out = _materialize_bridge_askpass_executable(tmp_path / "askpass-tmp")
|
||||
assert out == fake_exe
|
||||
|
||||
|
||||
def test_materialize_bridge_askpass_falls_back_to_cmd_when_exe_missing(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""If the native binary isn't built yet (fresh dev checkout) we still
|
||||
write a ``.cmd`` so the SSH_ASKPASS env var resolves to *something* and
|
||||
the user sees the ``CreateProcessW`` failure stderr — that's a clearer
|
||||
signal to "run cargo build" than a silent no-op env var."""
|
||||
from sessions.ssh_runner import _materialize_bridge_askpass_executable
|
||||
|
||||
monkeypatch.setattr("sessions.ssh_runner.sys.platform", "win32")
|
||||
monkeypatch.setattr(
|
||||
"sessions.ssh_runner._resolve_sessions_askpass_binary_path",
|
||||
lambda: None,
|
||||
)
|
||||
out = _materialize_bridge_askpass_executable(tmp_path)
|
||||
assert out.name == "sessions-askpass.cmd"
|
||||
assert out.exists()
|
||||
|
||||
|
||||
def test_resolve_sessions_askpass_finds_cargo_release_build(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""Locked behavior: a freshly ``cargo build --release``'d binary is
|
||||
discoverable so dev iteration on Windows works without copying the
|
||||
binary into ``sublime/sessions/bin/`` first."""
|
||||
from sessions.ssh_runner import _resolve_sessions_askpass_binary_path
|
||||
|
||||
workspace_root = tmp_path / "rust"
|
||||
release_dir = workspace_root / "target" / "release"
|
||||
release_dir.mkdir(parents=True)
|
||||
binary = release_dir / "sessions_askpass"
|
||||
binary.write_bytes(b"\x7fELF\x02")
|
||||
monkeypatch.setattr(
|
||||
"sessions.ssh_file_transport._rust_workspace_root",
|
||||
lambda: workspace_root,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"sessions.ssh_file_transport._cargo_target_debug_dir",
|
||||
lambda: workspace_root / "target" / "debug",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"sessions.ssh_file_transport._rust_shipped_local_bridge_search_dirs",
|
||||
lambda: (),
|
||||
)
|
||||
monkeypatch.setattr("sessions.ssh_file_transport.os.name", "posix")
|
||||
monkeypatch.setattr("sessions.ssh_runner.sys.platform", "linux")
|
||||
found = _resolve_sessions_askpass_binary_path()
|
||||
assert found == binary
|
||||
|
||||
|
||||
def test_resolve_sessions_askpass_returns_none_when_nothing_built(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""Negative case: no shipped bin, no debug build, no release build → None.
|
||||
The caller (``_materialize_bridge_askpass_executable``) must observe this
|
||||
and fall back to script-write."""
|
||||
from sessions.ssh_runner import _resolve_sessions_askpass_binary_path
|
||||
|
||||
monkeypatch.setattr(
|
||||
"sessions.ssh_file_transport._rust_workspace_root",
|
||||
lambda: tmp_path / "nonexistent-rust",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"sessions.ssh_file_transport._cargo_target_debug_dir",
|
||||
lambda: tmp_path / "nonexistent-debug",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"sessions.ssh_file_transport._rust_shipped_local_bridge_search_dirs",
|
||||
lambda: (),
|
||||
)
|
||||
monkeypatch.setattr("sessions.ssh_runner.sys.platform", "linux")
|
||||
assert _resolve_sessions_askpass_binary_path() is None
|
||||
|
||||
Reference in New Issue
Block a user