From 5c6ff3c13ef02ac29d749bd744c028dcaf32196b Mon Sep 17 00:00:00 2001 From: Jonathan Liebig Date: Wed, 28 Oct 2026 01:49:11 +0100 Subject: [PATCH] fix: wait for Windows sign-in before remote startup --- .../website/src/content/docs/windows-beta.mdx | 15 +- .../website/src/data/config-reference.json | 6 + src/cli/status.rs | 2 + src/config/model.rs | 5 + src/main.rs | 3 + src/platform/mod.rs | 7 + src/platform/windows.rs | 6 +- src/platform/windows/remote_start.rs | 88 +++++++- src/remote/attach.rs | 206 ++++++++++++++++++ src/remote/host.rs | 84 ++++++- src/server/autodetect.rs | 27 ++- 11 files changed, 438 insertions(+), 11 deletions(-) diff --git a/docs/next/website/src/content/docs/windows-beta.mdx b/docs/next/website/src/content/docs/windows-beta.mdx index b21e49e39..8d11a0197 100644 --- a/docs/next/website/src/content/docs/windows-beta.mdx +++ b/docs/next/website/src/content/docs/windows-beta.mdx @@ -49,7 +49,20 @@ For internal testing, `HERDR_MANIFEST_URL` can point the installer at a custom m Local persistent sessions continue running after the client detaches or its terminal window closes. Servers and pane processes launched through Windows OpenSSH also survive logout; run `herdr` again to reconnect. -When SSH starts a missing server, Herdr prefers an ordinary signed-in Windows session belonging to the same account. This lets tools use that login's desktop and sandbox context. If no such login exists, Herdr starts in the SSH session and reports that sandbox or desktop tools may be unavailable. If several logins belong to the account, start Herdr in the intended login first. Existing servers and their panes stay in their current session. +When SSH starts a missing server, Herdr uses an ordinary signed-in Windows session belonging to the same account. This lets tools use that login's desktop and sandbox context. If no such login exists, startup reports **Waiting for Windows sign-in** and does not start a server. Sign in to the Windows host with the same account: saved connections retry automatically, and an interactive `herdr --remote` stays open and retries before opening the TUI. A disconnected but still signed-in Windows session remains eligible. If several logins belong to the account, start Herdr in the intended login first. Existing servers and their panes stay in their current session, including servers already running in Session 0. + +While an interactive command waits, **Esc** or **Ctrl+C** cancels. **F** deliberately starts in the SSH session (normally Session 0) for that attempt; the prompt warns that this may break Codex sandbox, computer use and desktop tools. This choice does not change the host's config. Saved connections never prompt or force startup. Waiting retries use normal SSH authentication; authentication or other startup failures are reported rather than treated as a missing login. Keyboard choices are handled between SSH operations, so an in-flight SSH request retains its usual latency. Non-terminal commands report the sign-in requirement without prompting. + +Waiting and the one-attempt force choice require an updated Windows host binary. Older compatible running servers remain attachable; for an older stopped host, update Herdr on the host or start it from the Windows desktop first. + +For a host that deliberately runs without a Windows sign-in, opt into SSH-context startup in the **Windows host's** `%APPDATA%\herdr\config.toml` (or its existing `HERDR_CONFIG_PATH` / `XDG_CONFIG_HOME` override): + +```toml +[server] +allow_headless_remote_start = true +``` + +This Windows-only setting is false by default and is read on each cold start with no eligible login. Missing, unreadable or invalid config cannot enable automatic fallback; unknown config fields are tolerated. Config diagnostics remain visible even when you deliberately force a start. The fallback may lack Codex sandbox or desktop support. It does not bypass account, session or privilege checks, resolve multiple-login ambiguity, or retry a failed or uncertain desktop launch in Session 0. Local startup and already-running servers are unaffected. Elevated Windows servers require an administrator client by default, including servers started through an elevated SSH login. Ordinary servers accept clients from ordinary and administrator terminals belonging to the same account; attaching never changes the server's privileges. diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json index f06507c14..748e7239c 100644 --- a/docs/next/website/src/data/config-reference.json +++ b/docs/next/website/src/data/config-reference.json @@ -16,6 +16,12 @@ "id": "server", "title": "Server", "keys": [ + { + "key": "server.allow_headless_remote_start", + "type": "boolean", + "default": "false", + "description": "Windows only: allow a remote cold start in the SSH session when no signed-in Windows session belongs to the same account. By default saved connections retry and interactive --remote waits with a deliberate one-attempt force choice. This fallback may break Codex sandbox, computer use and desktop tools. Existing servers, multiple-login ambiguity and account/session/privilege checks are unchanged. Read on each no-login cold start from the host config." + }, { "key": "server.allow_unelevated_clients", "type": "boolean", diff --git a/src/cli/status.rs b/src/cli/status.rs index 826c8ea6d..58a57e84e 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -254,6 +254,7 @@ struct ClientStatusJson { endpoint_capabilities: Vec<&'static str>, remote_host_bridge: bool, remote_bridge_idle_timeout: bool, + remote_windows_start: bool, binary: String, session: Option, } @@ -302,6 +303,7 @@ fn client_status_json() -> ClientStatusJson { ], remote_host_bridge: true, remote_bridge_idle_timeout: crate::platform::REMOTE_BRIDGE_IDLE_TIMEOUT_SUPPORTED, + remote_windows_start: crate::platform::REMOTE_WINDOWS_START_SUPPORTED, binary: current_exe_label(), session: crate::session::active_name(), } diff --git a/src/config/model.rs b/src/config/model.rs index 29802fd9f..bc0481072 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -1039,6 +1039,9 @@ impl ImeCursorShape { #[derive(Debug, Deserialize)] #[serde(default)] pub struct ServerConfig { + /// Windows: allow remote startup without a signed-in same-account session. Default: false. + #[cfg(windows)] + pub allow_headless_remote_start: bool, /// Windows: allow ordinary same-account clients to control an elevated server. Default: false. #[cfg(windows)] pub allow_unelevated_clients: bool, @@ -1314,6 +1317,8 @@ impl<'de> Deserialize<'de> for ToastConfig { impl Default for ServerConfig { fn default() -> Self { Self { + #[cfg(windows)] + allow_headless_remote_start: false, #[cfg(windows)] allow_unelevated_clients: false, headless_cols: crate::config::DEFAULT_HEADLESS_COLS, diff --git a/src/main.rs b/src/main.rs index fc26ebe71..091e43c5c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -225,6 +225,9 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # Size of the virtual terminal used when no client is attached. # Attached clients always use their own terminal size. [server] +# Windows only: allow SSH-context startup when this account is not signed in. +# Codex sandbox and desktop tools may be unavailable in that context. +# allow_headless_remote_start = false # Windows only: allow ordinary same-account clients to control an elevated server. # Requires a server restart. # allow_unelevated_clients = false diff --git a/src/platform/mod.rs b/src/platform/mod.rs index c81c32be2..d2ecd0c0c 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -3,6 +3,13 @@ //! Centralizes OS-dependent behavior behind a clean boundary so core //! modules don't scatter `#[cfg]` branches through product logic. +pub(crate) const REMOTE_WINDOWS_START_SUPPORTED: bool = cfg!(windows); + +#[cfg(not(windows))] +pub(crate) fn is_windows_sign_in_required(_error: &std::io::Error) -> bool { + false +} + #[cfg(unix)] pub(crate) mod ssh_agent; diff --git a/src/platform/windows.rs b/src/platform/windows.rs index e0e4ce58b..ed073f5ea 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -1499,7 +1499,11 @@ pub(crate) fn configure_background_command_platform(command: &mut std::process:: } mod remote_start; -pub(crate) use remote_start::{bootstrap_remote_server, launch_remote_server_daemon_command}; +#[cfg(test)] +pub(crate) use remote_start::WindowsSignInRequired; +pub(crate) use remote_start::{ + bootstrap_remote_server, is_windows_sign_in_required, launch_remote_server_daemon_command, +}; pub fn launch_server_daemon_command(command: &mut std::process::Command) -> std::io::Result { if current_job_kills_processes_on_close()? { diff --git a/src/platform/windows/remote_start.rs b/src/platform/windows/remote_start.rs index e14c463ec..a51809bd0 100644 --- a/src/platform/windows/remote_start.rs +++ b/src/platform/windows/remote_start.rs @@ -176,11 +176,15 @@ pub(crate) fn bootstrap_remote_server(args: Vec) -> io::Result io::Result { +pub(crate) fn launch_remote_server_daemon_command( + command: &mut Command, + allow_headless: bool, +) -> io::Result { let identity = process_identity(std::process::id())?; let sessions = eligible_sessions(&identity.sid)?; let session = match sessions.as_slice() { [] => { + require_headless_remote_start(allow_headless)?; eprintln!("No signed-in Windows session is available. Starting Herdr in the SSH session; Codex sandbox and desktop tools may be unavailable."); tracing::warn!("No signed-in Windows session; starting remote server in the SSH session"); return super::launch_server_daemon_command(command); @@ -246,6 +250,38 @@ pub(crate) fn launch_remote_server_daemon_command(command: &mut Command) -> io:: outcome } +#[derive(Debug)] +pub(crate) struct WindowsSignInRequired; + +impl std::fmt::Display for WindowsSignInRequired { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("Waiting for Windows sign-in. Sign in to this host with the same Windows account. Saved connections retry automatically; interactive herdr --remote can wait or offer a deliberate Session 0 start. For intentional headless startup, set server.allow_headless_remote_start = true in this host's config.") + } +} + +impl std::error::Error for WindowsSignInRequired {} + +pub(crate) fn is_windows_sign_in_required(error: &io::Error) -> bool { + error + .get_ref() + .is_some_and(|cause| cause.is::()) +} + +fn require_headless_remote_start(allow_headless: bool) -> io::Result<()> { + let loaded = crate::config::Config::load(); + if let Some(summary) = crate::config::config_diagnostic_summary(&loaded.diagnostics) { + eprintln!("{summary}"); + } + if allow_headless || loaded.config.server.allow_headless_remote_start { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::WouldBlock, + WindowsSignInRequired, + )) + } +} + fn peer_pid(stream: &crate::ipc::LocalStream) -> io::Result { stream .peer_creds()? @@ -766,6 +802,56 @@ impl Drop for WtsMemory { mod tests { use super::*; + #[test] + fn no_login_fallback_requires_valid_host_config_opt_in() { + const CASE: &str = "HERDR_TEST_HEADLESS_REMOTE_START_CASE"; + if let Ok(case) = std::env::var(CASE) { + let result = require_headless_remote_start(false); + if case == "enabled" { + result.unwrap(); + } else { + let error = result.unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::WouldBlock); + assert!(is_windows_sign_in_required(&error)); + assert!(error.to_string().contains("Waiting for Windows sign-in")); + require_headless_remote_start(true).unwrap(); + } + return; + } + let root = std::env::temp_dir().join(format!( + "herdr-no-login-policy-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir(&root).unwrap(); + for (case, content) in [ + ("missing", None), + ("disabled", Some("[server]\nallow_headless_remote_start = false\n")), + ("enabled", Some("[server]\nallow_headless_remote_start = true\nfuture_option = true\n[future]\noption = true\n")), + ("wrong_type", Some("[server]\nallow_headless_remote_start = 'true'\n")), + ("invalid_other_field", Some("[server]\nallow_headless_remote_start = true\nheadless_cols = 'wide'\n")), + ("malformed", Some("[server]\nallow_headless_remote_start = true\n[")), + ("unreadable", None), + ] { + let path = root.join(case); + if let Some(content) = content { + std::fs::write(&path, content).unwrap(); + } else if case == "unreadable" { + std::fs::create_dir(&path).unwrap(); + } + let status = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "platform::windows::remote_start::tests::no_login_fallback_requires_valid_host_config_opt_in", "--nocapture"]) + .env(CASE, case) + .env(crate::config::CONFIG_PATH_ENV_VAR, path) + .status().unwrap(); + assert!(status.success(), "no-login policy case {case} failed"); + } + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn missing_task_registration_is_already_clean() { let (_com, service) = task_service().unwrap(); diff --git a/src/remote/attach.rs b/src/remote/attach.rs index 6a0338d6f..c4b4341fb 100644 --- a/src/remote/attach.rs +++ b/src/remote/attach.rs @@ -4,6 +4,7 @@ use super::process::wait_with_output_limit; use super::{ args::*, + host::{RemoteStartResponse, RemoteStartStatus}, process::{wait_with_output_timeout, CancellablePipe, OwnedSshChild, PipeControl}, restart_policy::*, shell_quote, @@ -86,6 +87,9 @@ pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> { remote.live_handoff, require_surface_interest, )?; + if prepared_remote.remote_herdr.platform.is_windows() { + ensure_windows_remote_server_started(&remote_ssh, &prepared_remote.remote_herdr)?; + } let _bridge = SshStdioBridge::start( remote.target.clone(), @@ -445,6 +449,25 @@ impl RemoteExecutable { } } + fn start_only_command(&self, session_name: &str, allow_headless: bool) -> String { + let args = if allow_headless { + &[ + "remote-client-bridge", + "--start-only-v1", + "--allow-headless-start-v1", + ][..] + } else { + &["remote-client-bridge", "--start-only-v1"][..] + }; + let args = Self::session_args(session_name, args); + match self { + Self::WindowsPath(path) => { + windows_powershell_streaming_application_command(path, &args) + } + Self::PosixShellPath(_) => self.command(&args), + } + } + fn live_handoff_command(&self, session_name: &str, protocol: u32, version: &str) -> String { let protocol = protocol.to_string(); match self { @@ -2183,6 +2206,113 @@ impl RemoteServerStatus { } } +fn ensure_windows_remote_server_started(ssh: &RemoteSsh, remote: &RemoteHerdr) -> io::Result<()> { + let running = matches!( + remote_server_status(ssh, remote, false)?, + RemoteServerStatus::Running { .. } + ); + let mut announced = false; + prepare_windows_remote_start( + running, + || Ok(remote_client_status(ssh, remote)?.is_some_and(|status| status.remote_windows_start)), + |allow_headless| { + let command = remote + .executable + .start_only_command(&ssh.session_name, allow_headless); + let output = ssh.shell_output(&remote.platform, &command)?; + if !output.status.success() { + return Err(command_failed("remote server startup failed", &output)); + } + let response: RemoteStartResponse = + serde_json::from_slice(&output.stdout).map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid remote startup response: {error}"), + ) + })?; + Ok(response.status) + }, + || { + if !io::stdin().is_terminal() { + return Err(io::Error::new(io::ErrorKind::WouldBlock, "Waiting for Windows sign-in. Sign in to the host with the same Windows account, or run herdr --remote from an interactive terminal to choose a Session 0 start.")); + } + if !announced { + eprintln!("The Windows account on {} is not signed in. Waiting for Windows sign-in; Herdr will retry automatically.", ssh.destination()); + eprintln!( + "Forcing Session 0 may break Codex sandbox, computer use and desktop tools." + ); + eprintln!("F: force Session 0 for this attempt · Esc/Ctrl+C: cancel · otherwise keep waiting"); + announced = true; + } + poll_windows_sign_in_choice() + }, + ) +} + +fn prepare_windows_remote_start( + running: bool, + supports_start: impl FnOnce() -> io::Result, + mut start: impl FnMut(bool) -> io::Result, + mut choose_force: impl FnMut() -> io::Result, +) -> io::Result<()> { + if running { + return Ok(()); + } + if !supports_start()? { + return Err(io::Error::new(io::ErrorKind::Unsupported, "This Windows host needs an updated Herdr to wait for sign-in or choose Session 0. Update Herdr on the host, or start it from the Windows desktop, then reconnect.")); + } + let mut allow_headless = false; + loop { + match start(allow_headless)? { + RemoteStartStatus::Ready => return Ok(()), + RemoteStartStatus::WindowsSignInRequired if allow_headless => { + return Err(io::Error::other("remote host still requires Windows sign-in after the explicit Session 0 request")); + } + RemoteStartStatus::WindowsSignInRequired => allow_headless = choose_force()?, + } + } +} + +fn poll_windows_sign_in_choice() -> io::Result { + use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; + struct RawModeGuard; + impl Drop for RawModeGuard { + fn drop(&mut self) { + let _ = crossterm::terminal::disable_raw_mode(); + } + } + crossterm::terminal::enable_raw_mode()?; + let _raw_mode = RawModeGuard; + let deadline = Instant::now() + Duration::from_secs(2); + loop { + if !crossterm::event::poll(deadline.saturating_duration_since(Instant::now()))? { + return Ok(false); + } + let Event::Key(key) = crossterm::event::read()? else { + continue; + }; + if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) { + continue; + } + match key.code { + KeyCode::Char('f' | 'F') + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + return Ok(true); + } + KeyCode::Esc => break, + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break, + _ => {} + } + } + Err(io::Error::new( + io::ErrorKind::Interrupted, + "Windows sign-in wait cancelled", + )) +} + fn ensure_remote_server_ready( ssh: &RemoteSsh, remote_herdr: &RemoteHerdr, @@ -2422,6 +2552,8 @@ struct RemoteClientStatusJson { remote_host_bridge: bool, #[serde(default)] remote_bridge_idle_timeout: bool, + #[serde(default)] + remote_windows_start: bool, } impl RemoteClientStatusJson { @@ -3677,6 +3809,79 @@ fn sanitize_path_component(input: &str) -> String { mod tests { use super::*; + #[test] + fn windows_remote_start_reuses_waits_and_only_forces_by_choice() { + if let Ok(choice) = std::env::var("HERDR_TEST_SIGN_IN_CHOICE") { + assert!(io::stdin().is_terminal()); + assert!(!crossterm::terminal::is_raw_mode_enabled().unwrap()); + let result = poll_windows_sign_in_choice(); + assert!(!crossterm::terminal::is_raw_mode_enabled().unwrap()); + match choice.as_str() { + "force" => assert!(result.unwrap()), + "cancel" => assert_eq!(result.unwrap_err().kind(), io::ErrorKind::Interrupted), + "timeout" => assert!(!result.unwrap()), + _ => panic!("unknown manual sign-in input case"), + } + return; + } + prepare_windows_remote_start( + true, + || panic!("running server needs no new capability"), + |_| panic!("must reuse running server"), + || panic!("must not prompt"), + ) + .unwrap(); + let error = prepare_windows_remote_start( + false, + || Ok(false), + |_| panic!("old stopped host must not cold start"), + || panic!("must not prompt"), + ) + .unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::Unsupported); + + for force in [false, true] { + let mut requests = Vec::new(); + prepare_windows_remote_start( + false, + || Ok(true), + |allow| { + requests.push(allow); + Ok(if requests.len() == 1 { + RemoteStartStatus::WindowsSignInRequired + } else { + RemoteStartStatus::Ready + }) + }, + || Ok(force), + ) + .unwrap(); + assert_eq!(requests, [false, force]); + } + + let error = prepare_windows_remote_start( + false, + || Ok(true), + |_| Err(io::Error::new(io::ErrorKind::TimedOut, "uncertain launch")), + || panic!("must not offer force after launch error"), + ) + .unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::TimedOut); + let mut requests = 0; + let error = prepare_windows_remote_start( + false, + || Ok(true), + |_| { + requests += 1; + Ok(RemoteStartStatus::WindowsSignInRequired) + }, + || Err(io::Error::new(io::ErrorKind::Interrupted, "cancel")), + ) + .unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::Interrupted); + assert_eq!(requests, 1); + } + #[cfg(unix)] #[test] #[ignore = "requires a private loopback OpenSSH fixture"] @@ -4559,6 +4764,7 @@ mod tests { ], remote_host_bridge: false, remote_bridge_idle_timeout: false, + remote_windows_start: false, }; assert!(status.supports_endpoint_requirement(&linux, true)); for index in 0..status.endpoint_capabilities.len() { diff --git a/src/remote/host.rs b/src/remote/host.rs index 508a509bc..e0824f0f5 100644 --- a/src/remote/host.rs +++ b/src/remote/host.rs @@ -3,14 +3,30 @@ use std::io; use std::time::Duration; +#[derive(Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum RemoteStartStatus { + Ready, + WindowsSignInRequired, +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub(super) struct RemoteStartResponse { + pub status: RemoteStartStatus, +} + pub(crate) fn run_remote_client_bridge(args: &[String]) -> io::Result<()> { - let idle_timeout = match args { - [] => false, + let (idle_timeout, start_only, allow_headless) = match args { + [] => (false, false, false), [option] if option == "--idle-timeout-v1" && crate::platform::REMOTE_BRIDGE_IDLE_TIMEOUT_SUPPORTED => { - true + (true, false, false) + } + [option] if option == "--start-only-v1" => (false, true, false), + [option, force] if option == "--start-only-v1" && force == "--allow-headless-start-v1" => { + (false, true, true) } _ => { return Err(io::Error::new( @@ -19,7 +35,18 @@ pub(crate) fn run_remote_client_bridge(args: &[String]) -> io::Result<()> { )) } }; - ensure_remote_server_running()?; + let result = ensure_remote_server_running(allow_headless); + if start_only { + let response = RemoteStartResponse { + status: remote_start_status(result)?, + }; + println!( + "{}", + serde_json::to_string(&response).map_err(io::Error::other)? + ); + return Ok(()); + } + result?; #[cfg(unix)] let _ssh_agent = super::ssh_agent::Registration::start(); @@ -37,10 +64,24 @@ pub(crate) fn run_remote_client_bridge(args: &[String]) -> io::Result<()> { crate::platform::forward_remote_bridge_stdio(stream, idle_timeout) } -fn ensure_remote_server_running() -> io::Result<()> { +fn remote_start_status(result: io::Result<()>) -> io::Result { + match result { + Ok(()) => Ok(RemoteStartStatus::Ready), + Err(error) if crate::platform::is_windows_sign_in_required(&error) => { + Ok(RemoteStartStatus::WindowsSignInRequired) + } + Err(error) => Err(error), + } +} + +fn ensure_remote_server_running(allow_headless: bool) -> io::Result<()> { let socket_path = crate::server::socket_paths::client_socket_path(); if !crate::server::autodetect::is_server_listening()? { - crate::server::autodetect::spawn_remote_server_daemon()?; + if allow_headless { + crate::server::autodetect::spawn_remote_server_daemon_with_headless(true)?; + } else { + crate::server::autodetect::spawn_remote_server_daemon()?; + } crate::server::autodetect::wait_for_server_socket(&socket_path, Duration::from_secs(5))?; } let status = @@ -58,3 +99,34 @@ fn ensure_remote_server_running() -> io::Result<()> { "remote herdr server needs one final update before this bridge can attach; rerun `herdr --remote` from an interactive terminal to approve it", )) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn start_only_never_turns_other_launch_failures_into_waiting() { + for kind in [ + io::ErrorKind::WouldBlock, + io::ErrorKind::PermissionDenied, + io::ErrorKind::TimedOut, + ] { + let error = remote_start_status(Err(io::Error::new(kind, "unrelated startup failure"))) + .unwrap_err(); + assert_eq!(error.kind(), kind); + } + assert_eq!( + remote_start_status(Ok(())).unwrap(), + RemoteStartStatus::Ready + ); + #[cfg(windows)] + assert_eq!( + remote_start_status(Err(io::Error::new( + io::ErrorKind::WouldBlock, + crate::platform::WindowsSignInRequired + ))) + .unwrap(), + RemoteStartStatus::WindowsSignInRequired + ); + } +} diff --git a/src/server/autodetect.rs b/src/server/autodetect.rs index dfd94c58d..b8ca023bd 100644 --- a/src/server/autodetect.rs +++ b/src/server/autodetect.rs @@ -207,8 +207,14 @@ pub fn spawn_server_daemon() -> io::Result { /// Remote cold starts may need a different Windows login context from SSH. pub(crate) fn spawn_remote_server_daemon() -> io::Result { + spawn_remote_server_daemon_with_headless(false) +} + +pub(crate) fn spawn_remote_server_daemon_with_headless(_allow_headless: bool) -> io::Result { #[cfg(windows)] - return spawn_server_daemon_with(crate::platform::launch_remote_server_daemon_command); + return spawn_server_daemon_with(|command| { + crate::platform::launch_remote_server_daemon_command(command, _allow_headless) + }); #[cfg(not(windows))] spawn_server_daemon() } @@ -228,7 +234,11 @@ fn spawn_server_daemon_with( let mut command = build_server_daemon_command(exe); let pid = launch(&mut command).map_err(|err: io::Error| { - io::Error::new(err.kind(), format!("failed to spawn herdr server: {err}")) + if crate::platform::is_windows_sign_in_required(&err) { + err + } else { + io::Error::new(err.kind(), format!("failed to spawn herdr server: {err}")) + } })?; info!(pid, "server daemon spawned"); @@ -363,6 +373,19 @@ mod windows_tests { use interprocess::os::windows::local_socket::ListenerOptionsExt as _; use interprocess::os::windows::security_descriptor::SecurityDescriptor; + #[test] + fn remote_sign_in_denial_survives_spawn_context() { + let error = spawn_server_daemon_with(|_| { + Err(io::Error::new( + io::ErrorKind::WouldBlock, + crate::platform::WindowsSignInRequired, + )) + }) + .unwrap_err(); + assert!(crate::platform::is_windows_sign_in_required(&error)); + assert_eq!(error.kind(), io::ErrorKind::WouldBlock); + } + #[test] fn reachability_distinguishes_denied_silent_busy_and_absent_pipes() { let path =