Compare commits

...

2 Commits

Author SHA1 Message Date
e7e3332073 chore(release): v0.7.3
All checks were successful
ci / mutation test (broker) (push) Has been skipped
Release Publish (Gitea session_helper) / verify-release-tag (push) Successful in 17s
ci / test-health gate (push) Successful in 17s
ci / rust debug (push) Successful in 2m30s
Release Publish (Gitea session_helper) / publish-linux-x86_64 (push) Successful in 3m57s
ci / rust release (push) Successful in 2m21s
ci / python (push) Successful in 1m26s
Patch follow-up to v0.7.2: replaces the misguided BatchMode=yes Windows
hotfix with a real fix. v0.7.3 ships a native sessions_askpass.exe so
Windows OpenSSH's CreateProcessW can spawn it, restoring password /
keyboard-interactive auth on hosts where key-based auth is unavailable.

Users on Windows must build the workspace (cargo build --release
--workspace) so the native askpass binary lands in target/release.
The Sublime side discovers it from there and from the
sublime/sessions/bin/sessions-askpass/<platform>/ shipped layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:04:30 +09:00
b26b32fcf1 feat(rust): native sessions_askpass.exe + revert v0.7.2's BatchMode=yes hotfix
The v0.7.2 BatchMode=yes hotfix gave up password / keyboard-interactive
auth on Windows because OpenSSH's posix_spawnp shim couldn't load our
.cmd askpass via CreateProcessW. That isn't acceptable when password
is the only auth method available on the remote host. This change
restores password auth by replacing the .cmd with a native PE binary
that CreateProcessW can actually spawn.

New Rust crate sessions_askpass:
- src/main.rs implements the existing prompt-bridge protocol unchanged
  (argv[1] = prompt; SESSIONS_ASKPASS_REQUEST/RESPONSE/CANCEL env vars
  drive the rendezvous), with atomic prompt write, bounded poll loop
  (default 120s timeout, override via SESSIONS_ASKPASS_TIMEOUT_SECS),
  and best-effort cleanup of consumed files
- 7 unit tests cover timeout parsing, prompt write atomicity, and the
  three poll outcomes (response / cancel / timeout)
- builds cleanly under cargo build --release --workspace for both
  linux x86_64 and windows-gnu

Sublime integration:
- _materialize_bridge_askpass_executable: on Windows resolves the
  native sessions_askpass.exe and points SSH_ASKPASS at it; on posix
  the shell-script path is unchanged. Falls back to .cmd write on
  Windows when the binary isn't built so the failure stderr stays
  diagnostic instead of silently no-op.
- _resolve_sessions_askpass_binary_path: searches shipped sessions/bin
  per-platform-tag dirs, then cargo target/debug, then target/release
  (mirrors the local_bridge discovery pattern).
- _local_ssh_argv: drops the v0.7.2 BatchMode=yes Windows branch — back
  to BatchMode=no everywhere now that the askpass actually works.
- service_popen_with_prompt_bridge: extracted from
  _run_ssh_remote_command_with_prompt_bridge so the helper-binary push
  / probe paths in ssh_file_transport.py share the same servicing loop
  without the simple-text-stdin assumption that helper baked in.
- _needs_remote_session_helper_push and _push_session_helper_via_ssh
  spawn ssh via Popen + service_popen_with_prompt_bridge when an
  askpass dir is in scope (env carries SESSIONS_ASKPASS_REQUEST).
  Without that infrastructure they fall back to subprocess.run, which
  is what the unit tests still exercise.
- _ssh_auth_failure_hint: rewrote to surface a platform-agnostic hint
  covering password + keys + agent, plus a breadcrumb about rebuilding
  sessions_askpass when the spawn-failure stderr shape shows up on a
  stale checkout.

Tests:
- 5 Sublime tests for the new helper functions
  (_materialize_bridge_askpass_executable: posix script-write,
  Windows-with-resolved-exe, Windows-fallback; _resolve_sessions_askpass:
  cargo release-build discovery, nothing-built returns None)
- Updated 3 ssh_file_transport hint tests for the platform-agnostic
  message and the new ssh_askpass: posix_spawnp recovery hint

1176 sublime tests + Rust workspace tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:03:26 +09:00
10 changed files with 635 additions and 102 deletions

View File

@@ -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
View File

@@ -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"

View File

@@ -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"

View 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"

View 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(())
}
}

View File

@@ -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")

View File

@@ -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,

View File

@@ -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

View File

@@ -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

2
uv.lock generated
View File

@@ -854,7 +854,7 @@ wheels = [
[[package]]
name = "sessions-sublime"
version = "0.7.2"
version = "0.7.3"
source = { virtual = "." }
[package.dev-dependencies]