From 426c91667dd73bf3ecd6939b0382b6c8531d81fc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Feb 2026 15:44:00 +0000 Subject: [PATCH 01/11] feat: Add Windows platform support - IPC: On Windows, use TCP localhost with a port file for daemon discovery instead of Unix domain sockets. On Unix, behavior is unchanged. - Process detection: Use Windows OpenProcess API via windows-sys crate to check if a daemon PID is still running. - Cross-platform fixes: Use `where` instead of `which` on Windows for binary detection, add Windows DLL path for vectorlite, use platform-appropriate temp dirs and test paths. - All existing Unix/macOS behavior is preserved behind #[cfg] guards. https://claude.ai/code/session_01Mmu98Tc4ZbqpRzMCsVCkXd --- Cargo.lock | 1 + Cargo.toml | 6 +- src/evaluation/detector.rs | 12 ++- src/ipc/client.rs | 83 +++++++++++++--- src/ipc/mod.rs | 192 +++++++++++++++++++++++++++++-------- src/ipc/server.rs | 75 ++++++++++++++- src/storage/vectorlite.rs | 2 + 7 files changed, 308 insertions(+), 63 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5005acd8..4ca2aa03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -805,6 +805,7 @@ dependencies = [ "urlencoding", "uuid", "walkdir", + "windows-sys 0.59.0", "zeroize", ] diff --git a/Cargo.toml b/Cargo.toml index 08b06e2b..69b31d95 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ sqlite = ["dep:rusqlite"] [dependencies] # Async runtime -tokio = { version = "1", features = ["full", "process", "signal"] } +tokio = { version = "1", features = ["full", "process", "signal", "net"] } # HTTP server & client axum = { version = "0.8", features = ["macros", "ws", "multipart"] } @@ -162,6 +162,10 @@ openssl = { version = "0.10", features = ["vendored"] } [target.'cfg(unix)'.dependencies] libc = "0.2" +# Process checking on Windows +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Threading"] } + # tempfile is used by both dev tests and the claudear-e2e binary [dependencies.tempfile] version = "3" diff --git a/src/evaluation/detector.rs b/src/evaluation/detector.rs index 60660a22..606beb4d 100644 --- a/src/evaluation/detector.rs +++ b/src/evaluation/detector.rs @@ -598,7 +598,12 @@ pub fn detect_tools(project_dir: &Path, overrides: &ToolOverrides) -> Vec bool { - std::process::Command::new("which") + #[cfg(not(windows))] + let cmd = "which"; + #[cfg(windows)] + let cmd = "where"; + + std::process::Command::new(cmd) .arg(binary) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) @@ -878,8 +883,11 @@ mod tests { #[test] fn test_which_exists_for_known_binary() { - // "ls" should exist on all Unix systems + // Use a binary that exists on all platforms + #[cfg(not(windows))] assert!(which_exists("ls")); + #[cfg(windows)] + assert!(which_exists("cmd")); } #[test] diff --git a/src/ipc/client.rs b/src/ipc/client.rs index 70cd68b3..a8979d29 100644 --- a/src/ipc/client.rs +++ b/src/ipc/client.rs @@ -1,4 +1,7 @@ //! IPC client for communicating with the watcher daemon. +//! +//! On Unix, connects via Unix domain sockets. +//! On Windows, connects via TCP to localhost. use super::default_socket_path; use super::protocol::{IpcCommand, IpcData, IpcResponse}; @@ -7,13 +10,16 @@ use crate::error::{Error, Result}; use std::path::PathBuf; use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +#[cfg(windows)] +use tokio::net::TcpStream; +#[cfg(not(windows))] use tokio::net::UnixStream; use tokio::time::timeout; /// Default timeout for IPC operations. const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); -/// Client for communicating with the watcher daemon via Unix socket. +/// Client for communicating with the watcher daemon. pub struct IpcClient { socket_path: PathBuf, timeout: Duration, @@ -28,7 +34,7 @@ impl IpcClient { } } - /// Create a client with a custom socket path. + /// Create a client with a custom socket/port file path. pub fn with_socket_path(socket_path: PathBuf) -> Self { Self { socket_path, @@ -44,8 +50,23 @@ impl IpcClient { /// Check if the daemon is running. pub fn is_daemon_running(&self) -> bool { - self.socket_path.exists() - && std::os::unix::net::UnixStream::connect(&self.socket_path).is_ok() + #[cfg(not(windows))] + { + self.socket_path.exists() + && std::os::unix::net::UnixStream::connect(&self.socket_path).is_ok() + } + #[cfg(windows)] + { + if !self.socket_path.exists() { + return false; + } + if let Ok(contents) = std::fs::read_to_string(&self.socket_path) { + if let Ok(port) = contents.trim().parse::() { + return std::net::TcpStream::connect(("127.0.0.1", port)).is_ok(); + } + } + false + } } /// Send a command and receive a response. @@ -58,6 +79,7 @@ impl IpcClient { } } + #[cfg(not(windows))] async fn send_internal(&self, command: IpcCommand) -> Result { let stream = UnixStream::connect(&self.socket_path) .await @@ -78,6 +100,35 @@ impl IpcClient { Ok(response) } + #[cfg(windows)] + async fn send_internal(&self, command: IpcCommand) -> Result { + // Read port from port file + let port_str = std::fs::read_to_string(&self.socket_path) + .map_err(|e| Error::Other(format!("Failed to read port file: {}", e)))?; + let port: u16 = port_str + .trim() + .parse() + .map_err(|e| Error::Other(format!("Invalid port in port file: {}", e)))?; + + let stream = TcpStream::connect(("127.0.0.1", port)) + .await + .map_err(|e| Error::Other(format!("Failed to connect to daemon: {}", e)))?; + + let (reader, mut writer) = stream.into_split(); + let mut reader = BufReader::new(reader); + + // Send command + let json = serde_json::to_string(&command)? + "\n"; + writer.write_all(json.as_bytes()).await?; + + // Read response + let mut line = String::new(); + reader.read_line(&mut line).await?; + + let response: IpcResponse = serde_json::from_str(line.trim())?; + Ok(response) + } + /// Ping the daemon. pub async fn ping(&self) -> Result { match self.send(IpcCommand::Ping).await { @@ -283,7 +334,7 @@ mod tests { #[test] fn test_client_with_socket_path() { - let path = PathBuf::from("/tmp/test.sock"); + let path = std::env::temp_dir().join("test-claudear.sock"); let client = IpcClient::with_socket_path(path.clone()); assert_eq!(client.socket_path, path); } @@ -299,7 +350,7 @@ mod tests { #[test] fn test_with_socket_path_uses_custom_path() { - let custom = PathBuf::from("/var/run/custom-claudear.sock"); + let custom = std::env::temp_dir().join("custom-claudear.sock"); let client = IpcClient::with_socket_path(custom.clone()); assert_eq!(client.socket_path, custom); } @@ -312,7 +363,7 @@ mod tests { #[test] fn test_with_socket_path_uses_default_timeout() { - let client = IpcClient::with_socket_path(PathBuf::from("/tmp/x.sock")); + let client = IpcClient::with_socket_path(std::env::temp_dir().join("x.sock")); assert_eq!(client.timeout, Duration::from_secs(30)); } @@ -326,7 +377,7 @@ mod tests { #[test] fn test_with_timeout_chained_with_socket_path() { - let path = PathBuf::from("/tmp/chained.sock"); + let path = std::env::temp_dir().join("chained.sock"); let client = IpcClient::with_socket_path(path.clone()).with_timeout(Duration::from_millis(500)); assert_eq!(client.socket_path, path); @@ -343,8 +394,9 @@ mod tests { #[test] fn test_is_daemon_running_nonexistent_socket() { - let client = - IpcClient::with_socket_path(PathBuf::from("/tmp/nonexistent-claudear-test.sock")); + let client = IpcClient::with_socket_path( + std::env::temp_dir().join("nonexistent-claudear-test.sock"), + ); assert!(!client.is_daemon_running()); } @@ -363,12 +415,13 @@ mod tests { #[tokio::test] async fn test_send_to_nonexistent_socket_returns_error() { let client = - IpcClient::with_socket_path(PathBuf::from("/tmp/claudear-no-such-socket.sock")); + IpcClient::with_socket_path(std::env::temp_dir().join("claudear-no-such-socket.sock")); let result = client.send(IpcCommand::Ping).await; assert!(result.is_err()); let err_msg = result.unwrap_err().to_string(); assert!( - err_msg.contains("Failed to connect to daemon"), + err_msg.contains("Failed to connect to daemon") + || err_msg.contains("Failed to read port file"), "Unexpected error message: {}", err_msg ); @@ -377,7 +430,7 @@ mod tests { #[tokio::test] async fn test_ping_nonexistent_socket_returns_false() { let client = - IpcClient::with_socket_path(PathBuf::from("/tmp/claudear-no-such-socket.sock")); + IpcClient::with_socket_path(std::env::temp_dir().join("claudear-no-such-socket.sock")); let result = client.ping().await; assert!(result.is_ok()); assert!(!result.unwrap()); @@ -386,7 +439,7 @@ mod tests { #[tokio::test] async fn test_status_nonexistent_socket_returns_error() { let client = - IpcClient::with_socket_path(PathBuf::from("/tmp/claudear-no-such-socket.sock")); + IpcClient::with_socket_path(std::env::temp_dir().join("claudear-no-such-socket.sock")); let result = client.status().await; assert!(result.is_err()); } @@ -394,7 +447,7 @@ mod tests { #[tokio::test] async fn test_send_trigger_nonexistent_socket_returns_error() { let client = - IpcClient::with_socket_path(PathBuf::from("/tmp/claudear-no-such-socket.sock")); + IpcClient::with_socket_path(std::env::temp_dir().join("claudear-no-such-socket.sock")); let result = client.trigger("linear", "LIN-1").await; assert!(result.is_err()); } diff --git a/src/ipc/mod.rs b/src/ipc/mod.rs index a9178e4f..acc26902 100644 --- a/src/ipc/mod.rs +++ b/src/ipc/mod.rs @@ -1,6 +1,7 @@ -//! Inter-process communication via Unix socket. +//! Inter-process communication for the watcher daemon. //! -//! Enables the CLI to communicate with a running watcher daemon. +//! On Unix, uses Unix domain sockets for IPC. +//! On Windows, uses TCP on localhost with a port file for daemon discovery. mod client; mod protocol; @@ -15,43 +16,79 @@ use std::path::PathBuf; /// Returns a private runtime directory for IPC files, scoped to the current user. /// /// On Linux, prefers `XDG_RUNTIME_DIR` (already user-private, typically mode 0700). +/// On Windows, uses `%LOCALAPPDATA%\claudear` or falls back to `%TEMP%\claudear`. /// Otherwise, creates a subdirectory `claudear-{uid}` under the system temp dir /// with mode 0700 to prevent other users from accessing the socket/PID files. fn ipc_runtime_dir() -> PathBuf { - // On Linux, XDG_RUNTIME_DIR is already user-private - if !cfg!(target_os = "macos") { - if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") { - return PathBuf::from(xdg); + #[cfg(windows)] + { + // Prefer %LOCALAPPDATA%\claudear (user-private on Windows) + if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") { + let dir = PathBuf::from(local_app_data).join("claudear"); + if !dir.exists() { + if let Err(e) = std::fs::create_dir_all(&dir) { + tracing::error!( + "Failed to create IPC runtime dir {:?}: {} — falling back to temp dir", + dir, + e + ); + return std::env::temp_dir().join("claudear"); + } + } + return dir; + } + // Fallback: %TEMP%\claudear + let dir = std::env::temp_dir().join("claudear"); + if !dir.exists() { + let _ = std::fs::create_dir_all(&dir); } + dir } - // Fallback: create a user-scoped subdirectory in the temp dir with restricted permissions - let uid = unsafe { libc::getuid() }; - let dir = std::env::temp_dir().join(format!("claudear-{}", uid)); - if !dir.exists() { - if let Err(e) = std::fs::create_dir_all(&dir) { - // SECURITY: Falling back to the system temp dir is unsafe because it is - // world-readable, which could allow other users to access or tamper with - // the IPC socket. This should be treated as a critical failure. - tracing::error!("Failed to create IPC runtime dir {:?}: {} — falling back to world-readable temp dir", dir, e); - return std::env::temp_dir(); + #[cfg(not(windows))] + { + // On Linux, XDG_RUNTIME_DIR is already user-private + if !cfg!(target_os = "macos") { + if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") { + return PathBuf::from(xdg); + } } - // Set directory permissions to 0700 (owner-only) - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let perms = std::fs::Permissions::from_mode(0o700); - if let Err(e) = std::fs::set_permissions(&dir, perms) { - tracing::warn!("Failed to set IPC runtime dir permissions: {}", e); + + // Fallback: create a user-scoped subdirectory in the temp dir with restricted permissions + let uid = unsafe { libc::getuid() }; + let dir = std::env::temp_dir().join(format!("claudear-{}", uid)); + if !dir.exists() { + if let Err(e) = std::fs::create_dir_all(&dir) { + // SECURITY: Falling back to the system temp dir is unsafe because it is + // world-readable, which could allow other users to access or tamper with + // the IPC socket. This should be treated as a critical failure. + tracing::error!("Failed to create IPC runtime dir {:?}: {} — falling back to world-readable temp dir", dir, e); + return std::env::temp_dir(); + } + // Set directory permissions to 0700 (owner-only) + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o700); + if let Err(e) = std::fs::set_permissions(&dir, perms) { + tracing::warn!("Failed to set IPC runtime dir permissions: {}", e); + } } } + dir } - dir } -/// Default socket path for the IPC server. +/// Default socket path for the IPC server (Unix) or port file path (Windows). pub fn default_socket_path() -> PathBuf { - ipc_runtime_dir().join("claudear.sock") + #[cfg(windows)] + { + ipc_runtime_dir().join("claudear.port") + } + #[cfg(not(windows))] + { + ipc_runtime_dir().join("claudear.sock") + } } /// Default PID file path. @@ -61,8 +98,24 @@ pub fn default_pid_path() -> PathBuf { /// Check if a watcher daemon is running. pub fn is_daemon_running() -> bool { - let socket_path = default_socket_path(); - socket_path.exists() && std::os::unix::net::UnixStream::connect(&socket_path).is_ok() + #[cfg(windows)] + { + let port_path = default_socket_path(); + if !port_path.exists() { + return false; + } + if let Ok(contents) = std::fs::read_to_string(&port_path) { + if let Ok(port) = contents.trim().parse::() { + return std::net::TcpStream::connect(("127.0.0.1", port)).is_ok(); + } + } + false + } + #[cfg(not(windows))] + { + let socket_path = default_socket_path(); + socket_path.exists() && std::os::unix::net::UnixStream::connect(&socket_path).is_ok() + } } /// Get the PID of the running daemon, if any. @@ -89,7 +142,7 @@ pub fn remove_pid_file() { let _ = std::fs::remove_file(&pid_path); } -/// Remove the socket file. +/// Remove the socket file (Unix) or port file (Windows). pub fn remove_socket_file() { let socket_path = default_socket_path(); let _ = std::fs::remove_file(&socket_path); @@ -98,7 +151,6 @@ pub fn remove_socket_file() { /// Clean up stale socket/pid files from a previous crash. pub fn cleanup_stale_files() { if let Some(pid) = get_daemon_pid() { - // Check if process is still running by trying to read /proc/{pid} or using kill -0 let is_running = is_process_running(pid); if !is_running { tracing::info!("Cleaning up stale files from previous run (PID {})", pid); @@ -106,11 +158,31 @@ pub fn cleanup_stale_files() { remove_socket_file(); } } else { - // No PID file but socket exists - stale + // No PID file but socket/port file exists - check if stale let socket_path = default_socket_path(); - if socket_path.exists() && std::os::unix::net::UnixStream::connect(&socket_path).is_err() { - tracing::info!("Cleaning up stale socket file"); - remove_socket_file(); + if socket_path.exists() { + let is_stale = { + #[cfg(windows)] + { + if let Ok(contents) = std::fs::read_to_string(&socket_path) { + if let Ok(port) = contents.trim().parse::() { + std::net::TcpStream::connect(("127.0.0.1", port)).is_err() + } else { + true // Invalid port file content + } + } else { + true + } + } + #[cfg(not(windows))] + { + std::os::unix::net::UnixStream::connect(&socket_path).is_err() + } + }; + if is_stale { + tracing::info!("Cleaning up stale socket file"); + remove_socket_file(); + } } } } @@ -135,8 +207,29 @@ fn is_process_running(pid: u32) -> bool { } } + // On Windows, use OpenProcess to check if a process handle can be obtained + #[cfg(target_os = "windows")] + { + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + // SAFETY: OpenProcess with PROCESS_QUERY_LIMITED_INFORMATION is a read-only check. + // If the process exists and we have permission, it returns a valid handle. + // We immediately close the handle after the check. + unsafe { + let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if handle != 0 { + CloseHandle(handle); + true + } else { + false + } + } + } + // Fallback for other platforms - #[cfg(not(any(target_os = "linux", target_os = "macos")))] + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] { let _ = pid; // Suppress unused warning // Assume running if we can't check @@ -144,13 +237,36 @@ fn is_process_running(pid: u32) -> bool { } } +/// Read the TCP port from the port file (Windows only). +#[cfg(windows)] +pub(crate) fn read_port_from_file() -> Option { + let port_path = default_socket_path(); + std::fs::read_to_string(&port_path) + .ok() + .and_then(|s| s.trim().parse().ok()) +} + +/// Write the TCP port to the port file (Windows only). +#[cfg(windows)] +pub(crate) fn write_port_file(port: u16) -> std::io::Result<()> { + let port_path = default_socket_path(); + std::fs::write(&port_path, port.to_string()) +} + #[cfg(test)] mod tests { use super::*; #[test] - fn test_default_socket_path_ends_with_claudear_sock() { + fn test_default_socket_path_has_expected_extension() { let path = default_socket_path(); + #[cfg(windows)] + assert!( + path.ends_with("claudear.port"), + "Expected socket path to end with 'claudear.port', got: {:?}", + path + ); + #[cfg(not(windows))] assert!( path.ends_with("claudear.sock"), "Expected socket path to end with 'claudear.sock', got: {:?}", @@ -236,9 +352,6 @@ mod tests { fn test_remove_socket_file_does_not_panic_when_no_socket() { // remove_socket_file should silently succeed even if no socket file exists. // We cannot unconditionally call it because a daemon might be using the socket. - // Instead, we verify a remove on a non-existent path is safe by checking the - // implementation pattern (let _ = remove_file), or we call it only when no daemon - // is running. if !is_daemon_running() { // The socket file may or may not exist; either way this should not panic. remove_socket_file(); @@ -250,7 +363,6 @@ mod tests { // When no daemon is running, this should return false. // If a daemon happens to be running (e.g., in a dev environment), true is also valid. let result = is_daemon_running(); - // We simply verify the function completes and returns a bool. let _: bool = result; } diff --git a/src/ipc/server.rs b/src/ipc/server.rs index 19569e27..5e49871d 100644 --- a/src/ipc/server.rs +++ b/src/ipc/server.rs @@ -1,4 +1,7 @@ -//! IPC server implementation using Unix sockets. +//! IPC server implementation. +//! +//! On Unix, uses Unix domain sockets. +//! On Windows, uses TCP on localhost with a port file for discovery. use super::protocol::{ ActivityEntry, ActivityType, IpcCommand, IpcData, IpcResponse, WatcherState, @@ -19,6 +22,9 @@ use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::Arc; use std::time::Instant; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +#[cfg(windows)] +use tokio::net::{TcpListener, TcpStream}; +#[cfg(not(windows))] use tokio::net::{UnixListener, UnixStream}; use tokio::sync::{broadcast, Mutex, RwLock, Semaphore}; @@ -28,7 +34,7 @@ const DEFAULT_MAX_ACTIVITY_ENTRIES: usize = 10_000; /// Maximum number of concurrent IPC connections. const MAX_CONCURRENT_CONNECTIONS: usize = 64; -/// IPC server that listens on a Unix socket. +/// IPC server that listens on a Unix socket (or TCP on Windows). pub struct IpcServer { socket_path: PathBuf, tracker: Arc, @@ -237,7 +243,7 @@ impl IpcServer { // Clean up any stale files from previous runs cleanup_stale_files(); - // Remove existing socket file if present + // Remove existing socket/port file if present if self.socket_path.exists() { std::fs::remove_file(&self.socket_path)?; } @@ -245,11 +251,23 @@ impl IpcServer { // Write PID file write_pid_file()?; - // Bind to socket + // Bind to socket (Unix) or TCP (Windows) + #[cfg(not(windows))] let listener = UnixListener::bind(&self.socket_path)?; + #[cfg(not(windows))] tracing::info!("IPC server listening on {:?}", self.socket_path); - // Set permissions (owner only) + #[cfg(windows)] + let listener = TcpListener::bind("127.0.0.1:0").await?; + #[cfg(windows)] + { + let local_addr = listener.local_addr()?; + let port = local_addr.port(); + super::write_port_file(port)?; + tracing::info!("IPC server listening on 127.0.0.1:{}", port); + } + + // Set permissions (owner only) on Unix #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -316,6 +334,7 @@ impl IpcServer { } /// Handle a single IPC connection. +#[cfg(not(windows))] async fn handle_connection( stream: UnixStream, tracker: Arc, @@ -360,6 +379,52 @@ async fn handle_connection( Ok(()) } +/// Handle a single IPC connection (Windows: TCP stream). +#[cfg(windows)] +async fn handle_connection( + stream: TcpStream, + tracker: Arc, + sources: Vec>, + notifier: Arc, + watcher: Option>, + state: Arc, + shutdown_tx: broadcast::Sender<()>, +) -> Result<()> { + let (reader, mut writer) = stream.into_split(); + let mut reader = BufReader::new(reader); + let mut line = String::new(); + + while reader.read_line(&mut line).await? > 0 { + let command: IpcCommand = match serde_json::from_str(line.trim()) { + Ok(cmd) => cmd, + Err(e) => { + let response = IpcResponse::error(format!("Invalid command: {}", e)); + let json = serde_json::to_string(&response)? + "\n"; + writer.write_all(json.as_bytes()).await?; + line.clear(); + continue; + } + }; + + let response = handle_command( + command, + &tracker, + &sources, + ¬ifier, + &watcher, + &state, + &shutdown_tx, + ) + .await; + + let json = serde_json::to_string(&response)? + "\n"; + writer.write_all(json.as_bytes()).await?; + line.clear(); + } + + Ok(()) +} + /// Handle a single IPC command. async fn handle_command( command: IpcCommand, diff --git a/src/storage/vectorlite.rs b/src/storage/vectorlite.rs index 6e8009a3..52e84ca6 100644 --- a/src/storage/vectorlite.rs +++ b/src/storage/vectorlite.rs @@ -45,6 +45,8 @@ pub fn try_load_vectorlite(conn: &Connection) -> Result { // Local development "./vectorlite.so", "./vectorlite.dylib", + // Windows paths + "./vectorlite.dll", ]; for path in common_paths { From b48b337660bd49dd68bcb106b174a4c9591a5ba3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 00:50:01 +0000 Subject: [PATCH 02/11] ci: Add Windows build/test jobs and release target - ci.yml: Add Windows test job and Windows build to matrix - release.yml: Add x86_64-pc-windows-msvc target producing .zip archive https://claude.ai/code/session_01Mmu98Tc4ZbqpRzMCsVCkXd --- .github/workflows/ci.yml | 56 +++++++++++++++++++++++++++++++---- .github/workflows/release.yml | 56 +++++++++++++++++++++++++++++++---- 2 files changed, 101 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 635fa04a..1efb60a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,12 +9,13 @@ on: env: CARGO_TERM_COLOR: always RUSTFLAGS: "-Dwarnings" - FASTEMBED_CACHE_DIR: /home/runner/.cache/fastembed jobs: lint: name: Lint runs-on: ubuntu-latest + env: + FASTEMBED_CACHE_DIR: /home/runner/.cache/fastembed steps: - uses: actions/checkout@v6 @@ -43,6 +44,8 @@ jobs: test: name: Test runs-on: ubuntu-latest + env: + FASTEMBED_CACHE_DIR: /home/runner/.cache/fastembed steps: - uses: actions/checkout@v6 @@ -84,8 +87,20 @@ jobs: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} build: - name: Build (macOS) - runs-on: macos-latest + name: Build (${{ matrix.name }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - name: macOS + os: macos-latest + artifact: claudear-macos + binary_path: target/release/claudear + - name: Windows + os: windows-latest + artifact: claudear-windows + binary_path: target/release/claudear.exe steps: - uses: actions/checkout@v6 @@ -109,8 +124,39 @@ jobs: - name: Upload artifact uses: actions/upload-artifact@v6 with: - name: claudear-macos - path: target/release/claudear + name: ${{ matrix.artifact }} + path: ${{ matrix.binary_path }} + + test-windows: + name: Test (Windows) + runs-on: windows-latest + env: + FASTEMBED_CACHE_DIR: C:\Users\runneradmin\.cache\fastembed + steps: + - uses: actions/checkout@v6 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Cache fastembed model + uses: actions/cache@v5 + with: + path: C:\Users\runneradmin\.cache\fastembed + key: fastembed-nomic-v15-windows + + - name: Run tests + run: cargo test --all-features dashboard: name: Dashboard diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2efd532d..8ea67a52 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ env: jobs: build-release: - name: Build Release + name: Build Release (${{ matrix.asset_name }}) runs-on: ${{ matrix.os }} strategy: matrix: @@ -21,15 +21,22 @@ jobs: - os: ubuntu-latest target: x86_64-unknown-linux-gnu asset_name: claudear-linux-amd64 + binary_name: claudear - os: macos-latest target: aarch64-apple-darwin asset_name: claudear-macos-arm64 + binary_name: claudear + - os: windows-latest + target: x86_64-pc-windows-msvc + asset_name: claudear-windows-amd64 + binary_name: claudear.exe steps: - uses: actions/checkout@v6 - - name: Set version from tag - id: version + - name: Set version from tag (Unix) + if: runner.os != 'Windows' + id: version_unix env: RELEASE_TAG: ${{ github.event.release.tag_name }} run: | @@ -37,6 +44,27 @@ jobs: echo "version=${VERSION}" >> $GITHUB_OUTPUT sed -i.bak '/^\[package\]/,/^$/ s/^version = .*/version = "'"${VERSION}"'"/' Cargo.toml && rm -f Cargo.toml.bak + - name: Set version from tag (Windows) + if: runner.os == 'Windows' + id: version_windows + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + shell: pwsh + run: | + $VERSION = $env:RELEASE_TAG -replace '^v', '' + echo "version=$VERSION" >> $env:GITHUB_OUTPUT + (Get-Content Cargo.toml) -replace '^version = ".*"', "version = `"$VERSION`"" | Set-Content Cargo.toml + + - name: Resolve version + id: version + shell: bash + run: | + if [ -n "${{ steps.version_unix.outputs.version }}" ]; then + echo "version=${{ steps.version_unix.outputs.version }}" >> $GITHUB_OUTPUT + else + echo "version=${{ steps.version_windows.outputs.version }}" >> $GITHUB_OUTPUT + fi + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: @@ -64,18 +92,34 @@ jobs: - name: Build release binary run: cargo build --release --target ${{ matrix.target }} - - name: Package binary + - name: Package binary (Unix) + if: runner.os != 'Windows' run: | mkdir -p release - cp target/${{ matrix.target }}/release/claudear release/${{ matrix.asset_name }} + cp target/${{ matrix.target }}/release/${{ matrix.binary_name }} release/${{ matrix.asset_name }} cd release tar -czvf ${{ matrix.asset_name }}.tar.gz ${{ matrix.asset_name }} - - name: Upload Release Asset + - name: Package binary (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path release + Copy-Item "target/${{ matrix.target }}/release/${{ matrix.binary_name }}" "release/${{ matrix.asset_name }}.exe" + Compress-Archive -Path "release/${{ matrix.asset_name }}.exe" -DestinationPath "release/${{ matrix.asset_name }}.zip" + + - name: Upload Release Asset (Unix) + if: runner.os != 'Windows' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: gh release upload "${{ github.ref_name }}" release/${{ matrix.asset_name }}.tar.gz --clobber + - name: Upload Release Asset (Windows) + if: runner.os == 'Windows' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release upload "${{ github.ref_name }}" release/${{ matrix.asset_name }}.zip --clobber + publish-docker: name: Publish Docker Image runs-on: ubuntu-latest From 86d2bbd9218ea14532c91bce6f02cb6ba44542d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 01:05:43 +0000 Subject: [PATCH 03/11] refactor: Extract platform abstraction layer to reduce #[cfg] sprawl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce two new modules that centralise all OS-specific logic: - `platform.rs` — file permissions, process detection, command existence - `ipc/transport.rs` — IpcStream/IpcListener type aliases, connect/bind/ check_connection helpers This eliminates duplicated #[cfg] blocks across env_writer, encryption, routes, detector, and collapses the two platform-specific handle_connection and send_internal implementations in the IPC server/client into single platform-agnostic functions. Net effect: -335 lines, +46 lines across 8 existing files. https://claude.ai/code/session_01Mmu98Tc4ZbqpRzMCsVCkXd --- src/env_writer.rs | 9 +- src/evaluation/detector.rs | 13 +-- src/github_app/routes.rs | 9 +- src/ipc/client.rs | 59 +---------- src/ipc/mod.rs | 195 +++++-------------------------------- src/ipc/server.rs | 86 ++-------------- src/ipc/transport.rs | 121 +++++++++++++++++++++++ src/lib.rs | 1 + src/platform.rs | 151 ++++++++++++++++++++++++++++ src/secret/encryption.rs | 9 +- 10 files changed, 318 insertions(+), 335 deletions(-) create mode 100644 src/ipc/transport.rs create mode 100644 src/platform.rs diff --git a/src/env_writer.rs b/src/env_writer.rs index b63d6423..f198538b 100644 --- a/src/env_writer.rs +++ b/src/env_writer.rs @@ -26,13 +26,8 @@ pub fn update_env_file(path: &Path, updates: &HashMap) -> Result .map_err(|e| Error::config(format!("Failed to write .env file at {:?}: {}", path, e)))?; // Set restrictive permissions on the .env file (owner read/write only) - // since it may contain secrets like webhook secrets and API tokens - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let perms = std::fs::Permissions::from_mode(0o600); - fs::set_permissions(path, perms).ok(); - } + // since it may contain secrets like webhook secrets and API tokens. + crate::platform::set_file_permissions_secure(path).ok(); Ok(()) } diff --git a/src/evaluation/detector.rs b/src/evaluation/detector.rs index 606beb4d..0329f147 100644 --- a/src/evaluation/detector.rs +++ b/src/evaluation/detector.rs @@ -598,18 +598,7 @@ pub fn detect_tools(project_dir: &Path, overrides: &ToolOverrides) -> Vec bool { - #[cfg(not(windows))] - let cmd = "which"; - #[cfg(windows)] - let cmd = "where"; - - std::process::Command::new(cmd) - .arg(binary) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) + crate::platform::command_exists(binary) } fn shell_words(cmd: &str) -> Vec { diff --git a/src/github_app/routes.rs b/src/github_app/routes.rs index 176abedd..3bd86117 100644 --- a/src/github_app/routes.rs +++ b/src/github_app/routes.rs @@ -286,13 +286,8 @@ fn save_credentials(creds: &ManifestConversionResponse, base_url: &str) -> Resul )) })?; - // Set restrictive permissions on the PEM file (Unix only) - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let perms = std::fs::Permissions::from_mode(0o600); - fs::set_permissions(pem_path, perms).ok(); - } + // Set restrictive permissions on the PEM file + crate::platform::set_file_permissions_secure(pem_path.as_ref()).ok(); // Update .env file let env_path = Path::new(".env"); diff --git a/src/ipc/client.rs b/src/ipc/client.rs index a8979d29..754e17a2 100644 --- a/src/ipc/client.rs +++ b/src/ipc/client.rs @@ -1,19 +1,16 @@ //! IPC client for communicating with the watcher daemon. //! -//! On Unix, connects via Unix domain sockets. -//! On Windows, connects via TCP to localhost. +//! Transport details are handled by [`super::transport`] — this file is +//! platform-agnostic. use super::default_socket_path; use super::protocol::{IpcCommand, IpcData, IpcResponse}; +use super::transport; use crate::error::{Error, Result}; use std::path::PathBuf; use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -#[cfg(windows)] -use tokio::net::TcpStream; -#[cfg(not(windows))] -use tokio::net::UnixStream; use tokio::time::timeout; /// Default timeout for IPC operations. @@ -50,23 +47,7 @@ impl IpcClient { /// Check if the daemon is running. pub fn is_daemon_running(&self) -> bool { - #[cfg(not(windows))] - { - self.socket_path.exists() - && std::os::unix::net::UnixStream::connect(&self.socket_path).is_ok() - } - #[cfg(windows)] - { - if !self.socket_path.exists() { - return false; - } - if let Ok(contents) = std::fs::read_to_string(&self.socket_path) { - if let Ok(port) = contents.trim().parse::() { - return std::net::TcpStream::connect(("127.0.0.1", port)).is_ok(); - } - } - false - } + transport::check_connection(&self.socket_path) } /// Send a command and receive a response. @@ -79,38 +60,8 @@ impl IpcClient { } } - #[cfg(not(windows))] - async fn send_internal(&self, command: IpcCommand) -> Result { - let stream = UnixStream::connect(&self.socket_path) - .await - .map_err(|e| Error::Other(format!("Failed to connect to daemon: {}", e)))?; - - let (reader, mut writer) = stream.into_split(); - let mut reader = BufReader::new(reader); - - // Send command - let json = serde_json::to_string(&command)? + "\n"; - writer.write_all(json.as_bytes()).await?; - - // Read response - let mut line = String::new(); - reader.read_line(&mut line).await?; - - let response: IpcResponse = serde_json::from_str(line.trim())?; - Ok(response) - } - - #[cfg(windows)] async fn send_internal(&self, command: IpcCommand) -> Result { - // Read port from port file - let port_str = std::fs::read_to_string(&self.socket_path) - .map_err(|e| Error::Other(format!("Failed to read port file: {}", e)))?; - let port: u16 = port_str - .trim() - .parse() - .map_err(|e| Error::Other(format!("Invalid port in port file: {}", e)))?; - - let stream = TcpStream::connect(("127.0.0.1", port)) + let stream = transport::connect(&self.socket_path) .await .map_err(|e| Error::Other(format!("Failed to connect to daemon: {}", e)))?; diff --git a/src/ipc/mod.rs b/src/ipc/mod.rs index acc26902..03c611e8 100644 --- a/src/ipc/mod.rs +++ b/src/ipc/mod.rs @@ -1,24 +1,27 @@ //! Inter-process communication for the watcher daemon. //! -//! On Unix, uses Unix domain sockets for IPC. -//! On Windows, uses TCP on localhost with a port file for daemon discovery. +//! Platform-specific transport details (Unix domain sockets vs TCP) live in +//! the [`transport`] module. This file exposes the high-level helpers that the +//! rest of the crate uses for daemon lifecycle management. mod client; mod protocol; mod server; +pub(crate) mod transport; pub use client::{print_response, IpcClient}; pub use protocol::{IpcCommand, IpcData, IpcResponse, WatcherState}; pub use server::IpcServer; +use crate::platform; use std::path::PathBuf; /// Returns a private runtime directory for IPC files, scoped to the current user. /// -/// On Linux, prefers `XDG_RUNTIME_DIR` (already user-private, typically mode 0700). -/// On Windows, uses `%LOCALAPPDATA%\claudear` or falls back to `%TEMP%\claudear`. -/// Otherwise, creates a subdirectory `claudear-{uid}` under the system temp dir -/// with mode 0700 to prevent other users from accessing the socket/PID files. +/// * **Linux** — prefers `XDG_RUNTIME_DIR` (already user-private, mode 0700). +/// * **Windows** — uses `%LOCALAPPDATA%\claudear` or falls back to `%TEMP%\claudear`. +/// * **macOS / other** — creates `claudear-{uid}` under the system temp dir with +/// mode 0700. fn ipc_runtime_dir() -> PathBuf { #[cfg(windows)] { @@ -37,7 +40,6 @@ fn ipc_runtime_dir() -> PathBuf { } return dir; } - // Fallback: %TEMP%\claudear let dir = std::env::temp_dir().join("claudear"); if !dir.exists() { let _ = std::fs::create_dir_all(&dir); @@ -54,25 +56,20 @@ fn ipc_runtime_dir() -> PathBuf { } } - // Fallback: create a user-scoped subdirectory in the temp dir with restricted permissions + // Fallback: create a user-scoped subdirectory in the temp dir let uid = unsafe { libc::getuid() }; let dir = std::env::temp_dir().join(format!("claudear-{}", uid)); if !dir.exists() { if let Err(e) = std::fs::create_dir_all(&dir) { - // SECURITY: Falling back to the system temp dir is unsafe because it is - // world-readable, which could allow other users to access or tamper with - // the IPC socket. This should be treated as a critical failure. - tracing::error!("Failed to create IPC runtime dir {:?}: {} — falling back to world-readable temp dir", dir, e); + tracing::error!( + "Failed to create IPC runtime dir {:?}: {} — falling back to world-readable temp dir", + dir, + e + ); return std::env::temp_dir(); } - // Set directory permissions to 0700 (owner-only) - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let perms = std::fs::Permissions::from_mode(0o700); - if let Err(e) = std::fs::set_permissions(&dir, perms) { - tracing::warn!("Failed to set IPC runtime dir permissions: {}", e); - } + if let Err(e) = platform::set_dir_permissions_secure(&dir) { + tracing::warn!("Failed to set IPC runtime dir permissions: {}", e); } } dir @@ -98,24 +95,7 @@ pub fn default_pid_path() -> PathBuf { /// Check if a watcher daemon is running. pub fn is_daemon_running() -> bool { - #[cfg(windows)] - { - let port_path = default_socket_path(); - if !port_path.exists() { - return false; - } - if let Ok(contents) = std::fs::read_to_string(&port_path) { - if let Ok(port) = contents.trim().parse::() { - return std::net::TcpStream::connect(("127.0.0.1", port)).is_ok(); - } - } - false - } - #[cfg(not(windows))] - { - let socket_path = default_socket_path(); - socket_path.exists() && std::os::unix::net::UnixStream::connect(&socket_path).is_ok() - } + transport::check_connection(&default_socket_path()) } /// Get the PID of the running daemon, if any. @@ -151,106 +131,19 @@ pub fn remove_socket_file() { /// Clean up stale socket/pid files from a previous crash. pub fn cleanup_stale_files() { if let Some(pid) = get_daemon_pid() { - let is_running = is_process_running(pid); - if !is_running { + if !platform::is_process_running(pid) { tracing::info!("Cleaning up stale files from previous run (PID {})", pid); remove_pid_file(); remove_socket_file(); } } else { - // No PID file but socket/port file exists - check if stale + // No PID file but socket/port file exists — check if stale let socket_path = default_socket_path(); - if socket_path.exists() { - let is_stale = { - #[cfg(windows)] - { - if let Ok(contents) = std::fs::read_to_string(&socket_path) { - if let Ok(port) = contents.trim().parse::() { - std::net::TcpStream::connect(("127.0.0.1", port)).is_err() - } else { - true // Invalid port file content - } - } else { - true - } - } - #[cfg(not(windows))] - { - std::os::unix::net::UnixStream::connect(&socket_path).is_err() - } - }; - if is_stale { - tracing::info!("Cleaning up stale socket file"); - remove_socket_file(); - } - } - } -} - -/// Check if a process with the given PID is running. -fn is_process_running(pid: u32) -> bool { - // Try to check /proc on Linux - #[cfg(target_os = "linux")] - { - std::path::Path::new(&format!("/proc/{}", pid)).exists() - } - - // On macOS/BSD, use kill(pid, 0) to check if process exists - #[cfg(target_os = "macos")] - { - // SAFETY: kill with signal 0 doesn't actually send a signal, - // it just checks if the process exists and we have permission to signal it. - // Returns 0 if process exists, -1 if not (with errno set to ESRCH). - match i32::try_from(pid) { - Ok(pid_i32) => unsafe { libc::kill(pid_i32, 0) == 0 }, - Err(_) => false, // PID exceeds i32::MAX, cannot be valid - } - } - - // On Windows, use OpenProcess to check if a process handle can be obtained - #[cfg(target_os = "windows")] - { - use windows_sys::Win32::Foundation::CloseHandle; - use windows_sys::Win32::System::Threading::{ - OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, - }; - // SAFETY: OpenProcess with PROCESS_QUERY_LIMITED_INFORMATION is a read-only check. - // If the process exists and we have permission, it returns a valid handle. - // We immediately close the handle after the check. - unsafe { - let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); - if handle != 0 { - CloseHandle(handle); - true - } else { - false - } + if transport::is_stale(&socket_path) { + tracing::info!("Cleaning up stale socket file"); + remove_socket_file(); } } - - // Fallback for other platforms - #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] - { - let _ = pid; // Suppress unused warning - // Assume running if we can't check - true - } -} - -/// Read the TCP port from the port file (Windows only). -#[cfg(windows)] -pub(crate) fn read_port_from_file() -> Option { - let port_path = default_socket_path(); - std::fs::read_to_string(&port_path) - .ok() - .and_then(|s| s.trim().parse().ok()) -} - -/// Write the TCP port to the port file (Windows only). -#[cfg(windows)] -pub(crate) fn write_port_file(port: u16) -> std::io::Result<()> { - let port_path = default_socket_path(); - std::fs::write(&port_path, port.to_string()) } #[cfg(test)] @@ -296,12 +189,7 @@ mod tests { #[test] fn test_get_daemon_pid_returns_option() { - // This tests that get_daemon_pid does not panic and returns an Option. - // If no PID file exists, it returns None. If one does exist (from a running - // daemon), it returns Some(pid). Either outcome is acceptable. let result = get_daemon_pid(); - // Just verify the function completes without panicking. - // If a daemon happens to be running, the PID should be > 0. if let Some(pid) = result { assert!(pid > 0, "PID should be positive, got: {}", pid); } @@ -309,23 +197,16 @@ mod tests { #[test] fn test_write_and_remove_pid_file_does_not_panic() { - // We avoid actually writing the PID file if a daemon is running, - // as that could interfere with the running daemon. Instead, we test - // the functions only when no daemon is active. if is_daemon_running() { - // A daemon is running; skip this test to avoid interfering. return; } - // Save any existing PID file content so we can restore it. let pid_path = default_pid_path(); let existing_content = std::fs::read_to_string(&pid_path).ok(); - // Write our PID file. let write_result = write_pid_file(); assert!(write_result.is_ok(), "write_pid_file should succeed"); - // Verify get_daemon_pid returns our PID. let read_pid = get_daemon_pid(); assert_eq!( read_pid, @@ -333,12 +214,9 @@ mod tests { "get_daemon_pid should return our process PID after write_pid_file" ); - // Remove the PID file. remove_pid_file(); - // Verify the PID file is gone (or restore the original if there was one). if let Some(content) = existing_content { - // Restore original content for the running daemon. let _ = std::fs::write(&pid_path, content); } else { assert!( @@ -350,46 +228,19 @@ mod tests { #[test] fn test_remove_socket_file_does_not_panic_when_no_socket() { - // remove_socket_file should silently succeed even if no socket file exists. - // We cannot unconditionally call it because a daemon might be using the socket. if !is_daemon_running() { - // The socket file may or may not exist; either way this should not panic. remove_socket_file(); } } #[test] fn test_is_daemon_running_returns_bool() { - // When no daemon is running, this should return false. - // If a daemon happens to be running (e.g., in a dev environment), true is also valid. let result = is_daemon_running(); let _: bool = result; } - #[test] - fn test_is_process_running_with_own_pid() { - // Our own process is guaranteed to be running and we have permission to signal it. - let own_pid = std::process::id(); - assert!( - is_process_running(own_pid), - "Our own PID ({}) should be reported as running", - own_pid - ); - } - - #[test] - fn test_is_process_running_with_invalid_pid() { - // u32::MAX is extremely unlikely to be a valid PID on any system. - assert!( - !is_process_running(u32::MAX), - "PID u32::MAX should not be reported as running" - ); - } - #[test] fn test_cleanup_stale_files_does_not_panic() { - // cleanup_stale_files should handle all cases gracefully: - // no PID file, stale socket, running daemon, etc. cleanup_stale_files(); } } diff --git a/src/ipc/server.rs b/src/ipc/server.rs index 5e49871d..475cef68 100644 --- a/src/ipc/server.rs +++ b/src/ipc/server.rs @@ -1,16 +1,18 @@ //! IPC server implementation. //! -//! On Unix, uses Unix domain sockets. -//! On Windows, uses TCP on localhost with a port file for discovery. +//! Transport details (Unix sockets vs TCP) are abstracted by the +//! [`super::transport`] module — this file is platform-agnostic. use super::protocol::{ ActivityEntry, ActivityType, IpcCommand, IpcData, IpcResponse, WatcherState, }; +use super::transport::{self, IpcStream}; use super::{ cleanup_stale_files, default_socket_path, remove_pid_file, remove_socket_file, write_pid_file, }; use crate::error::Result; use crate::notifier::Notifier; +use crate::platform; use crate::source::IssueSource; use crate::storage::FixAttemptTracker; use crate::types::{ActivityLogEntry, FixAttemptStatus}; @@ -22,10 +24,6 @@ use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::Arc; use std::time::Instant; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -#[cfg(windows)] -use tokio::net::{TcpListener, TcpStream}; -#[cfg(not(windows))] -use tokio::net::{UnixListener, UnixStream}; use tokio::sync::{broadcast, Mutex, RwLock, Semaphore}; /// Default maximum number of activity entries to keep (can be overridden via config). @@ -251,29 +249,12 @@ impl IpcServer { // Write PID file write_pid_file()?; - // Bind to socket (Unix) or TCP (Windows) - #[cfg(not(windows))] - let listener = UnixListener::bind(&self.socket_path)?; - #[cfg(not(windows))] + // Bind the IPC listener (Unix socket or TCP — handled by transport) + let listener = transport::bind(&self.socket_path)?; tracing::info!("IPC server listening on {:?}", self.socket_path); - #[cfg(windows)] - let listener = TcpListener::bind("127.0.0.1:0").await?; - #[cfg(windows)] - { - let local_addr = listener.local_addr()?; - let port = local_addr.port(); - super::write_port_file(port)?; - tracing::info!("IPC server listening on 127.0.0.1:{}", port); - } - - // Set permissions (owner only) on Unix - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let perms = std::fs::Permissions::from_mode(0o600); - std::fs::set_permissions(&self.socket_path, perms)?; - } + // Restrict socket file permissions on Unix (no-op on Windows) + platform::set_file_permissions_secure(&self.socket_path)?; let mut shutdown_rx = self.shutdown_tx.subscribe(); let conn_semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT_CONNECTIONS)); @@ -333,56 +314,9 @@ impl IpcServer { } } -/// Handle a single IPC connection. -#[cfg(not(windows))] -async fn handle_connection( - stream: UnixStream, - tracker: Arc, - sources: Vec>, - notifier: Arc, - watcher: Option>, - state: Arc, - shutdown_tx: broadcast::Sender<()>, -) -> Result<()> { - let (reader, mut writer) = stream.into_split(); - let mut reader = BufReader::new(reader); - let mut line = String::new(); - - while reader.read_line(&mut line).await? > 0 { - let command: IpcCommand = match serde_json::from_str(line.trim()) { - Ok(cmd) => cmd, - Err(e) => { - let response = IpcResponse::error(format!("Invalid command: {}", e)); - let json = serde_json::to_string(&response)? + "\n"; - writer.write_all(json.as_bytes()).await?; - line.clear(); - continue; - } - }; - - let response = handle_command( - command, - &tracker, - &sources, - ¬ifier, - &watcher, - &state, - &shutdown_tx, - ) - .await; - - let json = serde_json::to_string(&response)? + "\n"; - writer.write_all(json.as_bytes()).await?; - line.clear(); - } - - Ok(()) -} - -/// Handle a single IPC connection (Windows: TCP stream). -#[cfg(windows)] +/// Handle a single IPC connection (platform-agnostic via [`IpcStream`]). async fn handle_connection( - stream: TcpStream, + stream: IpcStream, tracker: Arc, sources: Vec>, notifier: Arc, diff --git a/src/ipc/transport.rs b/src/ipc/transport.rs new file mode 100644 index 00000000..dbbca6d3 --- /dev/null +++ b/src/ipc/transport.rs @@ -0,0 +1,121 @@ +//! Platform-abstracted IPC transport. +//! +//! On Unix, uses Unix domain sockets. On Windows, uses TCP on localhost with +//! a port file for daemon discovery. +//! +//! Consumers import [`IpcListener`], [`IpcStream`], [`connect`], [`bind`], and +//! [`check_connection`] without any `#[cfg]` in their own code. + +use std::io; +use std::path::Path; + +// --------------------------------------------------------------------------- +// Type aliases — one concrete type per platform, transparent to callers +// --------------------------------------------------------------------------- + +#[cfg(not(windows))] +pub type IpcListener = tokio::net::UnixListener; +#[cfg(windows)] +pub type IpcListener = tokio::net::TcpListener; + +#[cfg(not(windows))] +pub type IpcStream = tokio::net::UnixStream; +#[cfg(windows)] +pub type IpcStream = tokio::net::TcpStream; + +// --------------------------------------------------------------------------- +// Connection helpers +// --------------------------------------------------------------------------- + +/// Connect to an IPC endpoint at `path`. +/// +/// * **Unix** — connects to the Unix domain socket at `path`. +/// * **Windows** — reads a TCP port number from `path` and connects to +/// `127.0.0.1:`. +pub async fn connect(path: &Path) -> io::Result { + #[cfg(not(windows))] + { + IpcStream::connect(path).await + } + #[cfg(windows)] + { + let port = read_port(path)?; + IpcStream::connect(("127.0.0.1", port)).await + } +} + +/// Bind an IPC listener at `path`. +/// +/// * **Unix** — binds a Unix domain socket at `path`. +/// * **Windows** — binds TCP on `127.0.0.1:0` (ephemeral port) and writes the +/// assigned port to `path`. +pub fn bind(path: &Path) -> io::Result { + #[cfg(not(windows))] + { + IpcListener::bind(path) + } + #[cfg(windows)] + { + // Use std to bind synchronously so we get the port immediately. + let std_listener = std::net::TcpListener::bind("127.0.0.1:0")?; + let port = std_listener.local_addr()?.port(); + write_port(path, port)?; + std_listener.set_nonblocking(true)?; + IpcListener::from_std(std_listener) + } +} + +/// Synchronously check whether a daemon is reachable at `path`. +/// +/// Returns `true` if a connection can be established. +pub fn check_connection(path: &Path) -> bool { + if !path.exists() { + return false; + } + #[cfg(not(windows))] + { + std::os::unix::net::UnixStream::connect(path).is_ok() + } + #[cfg(windows)] + { + read_port(path) + .map(|port| std::net::TcpStream::connect(("127.0.0.1", port)).is_ok()) + .unwrap_or(false) + } +} + +/// Check whether the IPC endpoint at `path` is stale (file exists but nobody +/// is listening). +pub fn is_stale(path: &Path) -> bool { + if !path.exists() { + return false; + } + #[cfg(not(windows))] + { + std::os::unix::net::UnixStream::connect(path).is_err() + } + #[cfg(windows)] + { + read_port(path) + .map(|port| std::net::TcpStream::connect(("127.0.0.1", port)).is_err()) + .unwrap_or(true) + } +} + +// --------------------------------------------------------------------------- +// Port file helpers (Windows only) +// --------------------------------------------------------------------------- + +#[cfg(windows)] +pub(crate) fn read_port(path: &Path) -> io::Result { + let contents = std::fs::read_to_string(path)?; + contents + .trim() + .parse() + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) +} + +#[cfg(windows)] +fn write_port(path: &Path, port: u16) -> io::Result<()> { + std::fs::write(path, port.to_string()) +} diff --git a/src/lib.rs b/src/lib.rs index f3850b5e..627ba1ea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,6 +44,7 @@ pub mod inference; pub mod ipc; pub mod learning; pub mod notifier; +pub mod platform; pub mod prioritisation; pub mod processing; pub mod qa; diff --git a/src/platform.rs b/src/platform.rs new file mode 100644 index 00000000..cfc4df48 --- /dev/null +++ b/src/platform.rs @@ -0,0 +1,151 @@ +//! Platform abstraction layer. +//! +//! Centralises all OS-specific logic so that the rest of the codebase can stay +//! platform-agnostic. Every function is a no-op or a sensible default on +//! platforms where the underlying primitive does not exist. + +use std::path::Path; + +// --------------------------------------------------------------------------- +// File permissions +// --------------------------------------------------------------------------- + +/// Set restrictive **file** permissions (Unix `0o600` — owner read/write only). +/// +/// On Windows this is a no-op; NTFS ACLs are inherited from the parent +/// directory and are typically sufficient for single-user machines. +pub fn set_file_permissions_secure(path: &Path) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + } + #[cfg(not(unix))] + { + let _ = path; + Ok(()) + } +} + +/// Set restrictive **directory** permissions (Unix `0o700` — owner only). +/// +/// On Windows this is a no-op. +pub fn set_dir_permissions_secure(path: &Path) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + } + #[cfg(not(unix))] + { + let _ = path; + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Process detection +// --------------------------------------------------------------------------- + +/// Check whether a process with the given PID is still alive. +pub fn is_process_running(pid: u32) -> bool { + #[cfg(target_os = "linux")] + { + std::path::Path::new(&format!("/proc/{}", pid)).exists() + } + + #[cfg(target_os = "macos")] + { + // SAFETY: kill with signal 0 doesn't actually send a signal, + // it just checks if the process exists and we have permission to signal it. + match i32::try_from(pid) { + Ok(pid_i32) => unsafe { libc::kill(pid_i32, 0) == 0 }, + Err(_) => false, + } + } + + #[cfg(target_os = "windows")] + { + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + // SAFETY: OpenProcess with PROCESS_QUERY_LIMITED_INFORMATION is a + // read-only check. We immediately close the handle afterwards. + unsafe { + let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if handle != 0 { + CloseHandle(handle); + true + } else { + false + } + } + } + + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + let _ = pid; + true // assume running when we cannot check + } +} + +// --------------------------------------------------------------------------- +// Command / binary detection +// --------------------------------------------------------------------------- + +/// Check whether a command exists on the system PATH. +/// +/// Uses `which` on Unix and `where` on Windows. +pub fn command_exists(binary: &str) -> bool { + #[cfg(not(windows))] + let cmd = "which"; + #[cfg(windows)] + let cmd = "where"; + + std::process::Command::new(cmd) + .arg(binary) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_process_running_own_pid() { + assert!(is_process_running(std::process::id())); + } + + #[test] + fn test_is_process_running_invalid_pid() { + assert!(!is_process_running(u32::MAX)); + } + + #[test] + fn test_command_exists_known_binary() { + #[cfg(not(windows))] + assert!(command_exists("ls")); + #[cfg(windows)] + assert!(command_exists("cmd")); + } + + #[test] + fn test_command_exists_nonexistent() { + assert!(!command_exists("__nonexistent_binary_12345__")); + } + + #[test] + fn test_set_file_permissions_secure_nonexistent_path() { + let result = set_file_permissions_secure(Path::new("/tmp/__does_not_exist_12345__")); + // On Unix this should fail; on Windows it's a no-op (Ok). + #[cfg(unix)] + assert!(result.is_err()); + #[cfg(not(unix))] + assert!(result.is_ok()); + } +} diff --git a/src/secret/encryption.rs b/src/secret/encryption.rs index f55eb2a2..9df6f0fa 100644 --- a/src/secret/encryption.rs +++ b/src/secret/encryption.rs @@ -196,13 +196,8 @@ pub fn write_key_file(path: &Path, key: &MasterKey) -> Result<(), String> { .map_err(|e| format!("Failed to write key file '{}': {}", path.display(), e))?; // Set restrictive permissions (owner read/write only) - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let perms = std::fs::Permissions::from_mode(0o600); - std::fs::set_permissions(path, perms) - .map_err(|e| format!("Failed to set key file permissions: {}", e))?; - } + crate::platform::set_file_permissions_secure(path) + .map_err(|e| format!("Failed to set key file permissions: {}", e))?; Ok(()) } From 163c3a2856e8b1858818ff8a221157528d242dbe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 11:21:12 +0000 Subject: [PATCH 04/11] fix: Windows test compatibility - Add file_url() helper for git tests to produce valid file:// URLs on Windows (forward slashes, triple-slash prefix for drive letters) - Normalise backslashes in find_by_vendor_path() so Windows paths match the /vendor/ pattern - Add .gitattributes with eol=lf to prevent autocrlf line-ending issues in CI - CI: add git autocrlf=false config and embedding model warmup step to the Windows test job; use runner.temp for cache paths https://claude.ai/code/session_01Mmu98Tc4ZbqpRzMCsVCkXd --- .gitattributes | 20 ++++++++++++++++++++ .github/workflows/ci.yml | 10 ++++++++-- src/repo/git.rs | 38 ++++++++++++++++++++++++++------------ src/repo/index.rs | 7 +++++-- 4 files changed, 59 insertions(+), 16 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..e0036590 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,20 @@ +# Ensure consistent line endings across all platforms. +# This prevents Windows autocrlf from changing file contents and +# causing test failures or unexpected diffs. +* text=auto eol=lf + +# Explicitly mark binary files +*.png binary +*.ico binary +*.jpg binary +*.jpeg binary +*.gif binary +*.woff binary +*.woff2 binary +*.ttf binary +*.eot binary + +# Windows-specific files keep CRLF +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1efb60a2..e5da5b41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,8 +131,11 @@ jobs: name: Test (Windows) runs-on: windows-latest env: - FASTEMBED_CACHE_DIR: C:\Users\runneradmin\.cache\fastembed + FASTEMBED_CACHE_DIR: ${{ runner.temp }}\.cache\fastembed steps: + - name: Configure git + run: git config --global core.autocrlf false + - uses: actions/checkout@v6 - name: Install Rust toolchain @@ -152,9 +155,12 @@ jobs: - name: Cache fastembed model uses: actions/cache@v5 with: - path: C:\Users\runneradmin\.cache\fastembed + path: ${{ runner.temp }}\.cache\fastembed key: fastembed-nomic-v15-windows + - name: Warmup embedding model + run: cargo test --all-features -- feedback::embeddings::tests::test_warmup_downloads_model --exact --nocapture + - name: Run tests run: cargo test --all-features diff --git a/src/repo/git.rs b/src/repo/git.rs index e28a73a5..8f6d90e1 100644 --- a/src/repo/git.rs +++ b/src/repo/git.rs @@ -452,6 +452,20 @@ mod tests { use std::process::Command as StdCommand; use tempfile::TempDir; + /// Build a `file://` URL from a local path. + /// + /// On Windows, `Path::display()` emits backslashes (e.g. `C:\Users\...`), + /// but `file://` URLs require forward slashes (`file:///C:/Users/...`). + fn file_url(path: &Path) -> String { + let s = path.display().to_string().replace('\\', "/"); + if s.starts_with('/') { + format!("file://{s}") + } else { + // Windows absolute path like C:/Users/... needs an extra / + format!("file:///{s}") + } + } + /// Create a bare-minimum git repo in `path` with one commit on "main". fn init_git_repo(path: &Path) { StdCommand::new("git") @@ -1010,7 +1024,7 @@ mod tests { let target_dir = TempDir::new().unwrap(); let target = target_dir.path().join("cloned"); - let url = format!("file://{}", origin.path().display()); + let url = file_url(origin.path()); let result = GitOps::ensure_repo_at_path(&target, &url, "main").await; assert!(result.is_ok(), "clone failed: {:?}", result.unwrap_err()); assert!(target.join(".git").exists()); @@ -1025,7 +1039,7 @@ mod tests { let target_dir = TempDir::new().unwrap(); let target = target_dir.path().join("cloned"); - let url = format!("file://{}", origin.path().display()); + let url = file_url(origin.path()); GitOps::ensure_repo_at_path(&target, &url, "main") .await .unwrap(); @@ -1060,7 +1074,7 @@ mod tests { let target_dir = TempDir::new().unwrap(); let target = target_dir.path().join("cloned"); - let url = format!("file://{}", origin.path().display()); + let url = file_url(origin.path()); // Clone first GitOps::ensure_repo_at_path(&target, &url, "main") .await @@ -1112,7 +1126,7 @@ mod tests { let target_dir = TempDir::new().unwrap(); let target = target_dir.path().join("cloned"); - let url = format!("file://{}", origin.path().display()); + let url = file_url(origin.path()); GitOps::ensure_repo_at_path(&target, &url, "main") .await .unwrap(); @@ -1133,7 +1147,7 @@ mod tests { let target_dir = TempDir::new().unwrap(); let target = target_dir.path().join("cloned"); - let url = format!("file://{}", origin.path().display()); + let url = file_url(origin.path()); GitOps::ensure_repo_at_path(&target, &url, "main") .await .unwrap(); @@ -1159,7 +1173,7 @@ mod tests { let target_dir = TempDir::new().unwrap(); let target = target_dir.path().join("cloned"); - let url = format!("file://{}", origin.path().display()); + let url = file_url(origin.path()); GitOps::ensure_repo_at_path(&target, &url, "main") .await .unwrap(); @@ -1420,7 +1434,7 @@ mod tests { let target_dir = TempDir::new().unwrap(); let target = target_dir.path().join("cloned"); - let url = format!("file://{}", origin.path().display()); + let url = file_url(origin.path()); // Clone GitOps::ensure_repo_at_path(&target, &url, "main") @@ -1614,7 +1628,7 @@ mod tests { let target_dir = TempDir::new().unwrap(); let target = target_dir.path().join("cloned"); - let url = format!("file://{}", origin.path().display()); + let url = file_url(origin.path()); GitOps::ensure_repo_at_path(&target, &url, "main") .await @@ -1670,7 +1684,7 @@ mod tests { let target_dir = TempDir::new().unwrap(); let target = target_dir.path().join("cloned"); - let url = format!("file://{}", origin.path().display()); + let url = file_url(origin.path()); GitOps::ensure_repo_at_path(&target, &url, "main") .await @@ -1915,7 +1929,7 @@ mod tests { let workspace = TempDir::new().unwrap(); let repo_path = workspace.path().join("repo"); - let url = format!("file://{}", origin.path().display()); + let url = file_url(origin.path()); // 1) Clone GitOps::ensure_repo_at_path(&repo_path, &url, "main") @@ -2054,7 +2068,7 @@ mod tests { let target_dir = TempDir::new().unwrap(); let target = target_dir.path().join("cloned"); - let url = format!("file://{}", origin.path().display()); + let url = file_url(origin.path()); // Clone via ensure_repo_at_path GitOps::ensure_repo_at_path(&target, &url, "main") @@ -2154,7 +2168,7 @@ mod tests { // Clone let target_dir = TempDir::new().unwrap(); let target = target_dir.path().join("cloned"); - let url = format!("file://{}", origin.path().display()); + let url = file_url(origin.path()); GitOps::ensure_repo_at_path(&target, &url, "main") .await .unwrap(); diff --git a/src/repo/index.rs b/src/repo/index.rs index 508d89f1..b7f95468 100644 --- a/src/repo/index.rs +++ b/src/repo/index.rs @@ -428,9 +428,12 @@ impl RepoIndex { /// Vendor paths follow the pattern: .../vendor/{org}/{repo}/... /// This extracts {org}/{repo} and looks it up in the index. fn find_by_vendor_path(&self, filename: &str) -> Option<&IndexedRepo> { + // Normalise to forward slashes so Windows paths (`\vendor\`) are handled. + let normalised = filename.replace('\\', "/"); + // Look for /vendor/ in the path - let vendor_idx = filename.find("/vendor/")?; - let after_vendor = &filename[vendor_idx + 8..]; // Skip "/vendor/" + let vendor_idx = normalised.find("/vendor/")?; + let after_vendor = &normalised[vendor_idx + 8..]; // Skip "/vendor/" // Split the remainder to get org/repo let parts: Vec<&str> = after_vendor.split('/').collect(); From 352597ba891dbe19b61593308729d527321c475e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 07:40:44 +0000 Subject: [PATCH 05/11] fix: Move FASTEMBED_CACHE_DIR to step-level env in Windows CI job The `runner` context is not available in job-level `env` blocks, which caused the entire workflow to fail with "Unrecognized named-value: runner". Move the env var to the individual steps that need it. https://claude.ai/code/session_01Mmu98Tc4ZbqpRzMCsVCkXd --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5da5b41..b1c2c893 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,8 +130,6 @@ jobs: test-windows: name: Test (Windows) runs-on: windows-latest - env: - FASTEMBED_CACHE_DIR: ${{ runner.temp }}\.cache\fastembed steps: - name: Configure git run: git config --global core.autocrlf false @@ -160,9 +158,13 @@ jobs: - name: Warmup embedding model run: cargo test --all-features -- feedback::embeddings::tests::test_warmup_downloads_model --exact --nocapture + env: + FASTEMBED_CACHE_DIR: ${{ runner.temp }}\.cache\fastembed - name: Run tests run: cargo test --all-features + env: + FASTEMBED_CACHE_DIR: ${{ runner.temp }}\.cache\fastembed dashboard: name: Dashboard From 655bd5224da880eff4219694689045ec230b1904 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 08:24:37 +0000 Subject: [PATCH 06/11] fix: Add AWS_LC_SYS_PREBUILT_NASM=1 for Windows CI builds aws-lc-sys (pulled in by rustls-acme with aws-lc-rs) requires NASM to compile assembly on Windows. The CI runners don't have NASM installed, so use the prebuilt NASM objects instead. https://claude.ai/code/session_01Mmu98Tc4ZbqpRzMCsVCkXd --- .github/workflows/ci.yml | 4 ++++ .github/workflows/release.yml | 1 + 2 files changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1c2c893..8d15b925 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,6 +101,8 @@ jobs: os: windows-latest artifact: claudear-windows binary_path: target/release/claudear.exe + env: + AWS_LC_SYS_PREBUILT_NASM: 1 steps: - uses: actions/checkout@v6 @@ -130,6 +132,8 @@ jobs: test-windows: name: Test (Windows) runs-on: windows-latest + env: + AWS_LC_SYS_PREBUILT_NASM: 1 steps: - name: Configure git run: git config --global core.autocrlf false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8ea67a52..610bc504 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,7 @@ permissions: env: CARGO_TERM_COLOR: always + AWS_LC_SYS_PREBUILT_NASM: 1 jobs: build-release: From c0c8d43b8d242aeb1bf08ffdc30b18d28f4a9538 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 08:16:35 +0000 Subject: [PATCH 07/11] Initial plan From 2e783e6700c6639ecbb0315090b9e6b14cc1df09 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 08:30:27 +0000 Subject: [PATCH 08/11] fix: Windows compilation error and test compatibility Fix HANDLE type comparison in platform.rs - use is_null() instead of comparing with 0, since HANDLE is *mut c_void in windows-sys 0.59+. Fix test_validate_model_path_directory to use std::env::temp_dir() instead of hardcoded /tmp which doesn't exist on Windows. Co-authored-by: abnegate <5857008+abnegate@users.noreply.github.com> --- src/chat/llm.rs | 2 +- src/platform.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/chat/llm.rs b/src/chat/llm.rs index de08f1d9..6cb55913 100644 --- a/src/chat/llm.rs +++ b/src/chat/llm.rs @@ -384,7 +384,7 @@ mod tests { #[test] fn test_validate_model_path_directory() { - let result = validate_model_path(&PathBuf::from("/tmp")); + let result = validate_model_path(&std::env::temp_dir()); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("not a file")); } diff --git a/src/platform.rs b/src/platform.rs index cfc4df48..ef68a972 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -74,7 +74,7 @@ pub fn is_process_running(pid: u32) -> bool { // read-only check. We immediately close the handle afterwards. unsafe { let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); - if handle != 0 { + if !handle.is_null() { CloseHandle(handle); true } else { From 618a4c1ccba3dcb04a4f68ad206a7dc0c8671677 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Mar 2026 22:17:49 +0000 Subject: [PATCH 09/11] fix: Replace aws-lc-rs with ring for Windows build compatibility aws-lc-sys has known MSVC compilation issues on Windows (pthread_rwlock_t, C11 detection failures). Switch to ring as the crypto provider for rustls which has reliable cross-platform support. Changes: - rustls-acme: use ring instead of aws-lc-rs feature - reqwest: disable default TLS (which hardcodes aws-lc-rs) and use rustls-no-provider so ring from rustls-acme is used - Add 10s SMTP transport timeout to prevent test hangs on Windows - Add timeout-minutes: 30 to Windows CI jobs - Remove AWS_LC_SYS_PREBUILT_NASM env (no longer needed) https://claude.ai/code/session_01Mmu98Tc4ZbqpRzMCsVCkXd --- .github/workflows/ci.yml | 6 +-- .github/workflows/release.yml | 1 - Cargo.lock | 90 ++--------------------------------- Cargo.toml | 4 +- src/notifier/email.rs | 8 +++- 5 files changed, 15 insertions(+), 94 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d15b925..94ec8190 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,6 +89,7 @@ jobs: build: name: Build (${{ matrix.name }}) runs-on: ${{ matrix.os }} + timeout-minutes: 30 strategy: fail-fast: false matrix: @@ -101,8 +102,6 @@ jobs: os: windows-latest artifact: claudear-windows binary_path: target/release/claudear.exe - env: - AWS_LC_SYS_PREBUILT_NASM: 1 steps: - uses: actions/checkout@v6 @@ -132,8 +131,7 @@ jobs: test-windows: name: Test (Windows) runs-on: windows-latest - env: - AWS_LC_SYS_PREBUILT_NASM: 1 + timeout-minutes: 30 steps: - name: Configure git run: git config --global core.autocrlf false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 610bc504..8ea67a52 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,7 +10,6 @@ permissions: env: CARGO_TERM_COLOR: always - AWS_LC_SYS_PREBUILT_NASM: 1 jobs: build-release: diff --git a/Cargo.lock b/Cargo.lock index 4ca2aa03..625f7a1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -310,28 +310,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "aws-lc-rs" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.37.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - [[package]] name = "axum" version = "0.8.8" @@ -901,16 +879,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation" version = "0.10.1" @@ -1232,12 +1200,6 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "ecdsa" version = "0.16.9" @@ -1542,12 +1504,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "futures" version = "0.3.32" @@ -2045,11 +2001,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", - "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -3500,7 +3454,6 @@ version = "0.11.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" dependencies = [ - "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", @@ -3670,8 +3623,8 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" dependencies = [ - "aws-lc-rs", "pem", + "ring", "rustls-pki-types", "time", "yasna", @@ -3797,7 +3750,6 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", @@ -3970,7 +3922,6 @@ version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ - "aws-lc-rs", "log", "once_cell", "ring", @@ -3989,7 +3940,6 @@ dependencies = [ "async-io", "async-trait", "async-web-client", - "aws-lc-rs", "axum-server", "base64 0.22.1", "blocking", @@ -4000,6 +3950,7 @@ dependencies = [ "log", "pem", "rcgen", + "ring", "serde", "serde_json", "thiserror 2.0.18", @@ -4038,7 +3989,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ - "core-foundation 0.10.1", + "core-foundation", "core-foundation-sys", "jni", "log", @@ -4065,7 +4016,6 @@ version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -4139,7 +4089,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.0", - "core-foundation 0.10.1", + "core-foundation", "core-foundation-sys", "libc", "security-framework-sys", @@ -4608,27 +4558,6 @@ dependencies = [ "windows", ] -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags 2.11.0", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "tempfile" version = "3.25.0" @@ -5775,17 +5704,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result 0.4.1", - "windows-strings", -] - [[package]] name = "windows-result" version = "0.1.2" diff --git a/Cargo.toml b/Cargo.toml index 69b31d95..b454787b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,12 +28,12 @@ tokio = { version = "1", features = ["full", "process", "signal", "net"] } # HTTP server & client axum = { version = "0.8", features = ["macros", "ws", "multipart"] } axum-server = { version = "0.8", features = ["tls-rustls-no-provider"] } -reqwest = { version = "0.13", features = ["json", "form", "stream"] } +reqwest = { version = "0.13", default-features = false, features = ["json", "form", "stream", "rustls-no-provider", "charset", "http2"] } tower = { version = "0.5", features = ["limit"] } tower-http = { version = "0.6", features = ["trace", "cors", "fs", "limit"] } # TLS auto-provisioning (Let's Encrypt ACME) -rustls-acme = { version = "0.15", features = ["axum", "aws-lc-rs"] } +rustls-acme = { version = "0.15", default-features = false, features = ["axum", "ring", "tls12", "webpki-roots"] } # Database rusqlite = { version = "0.38", features = ["bundled", "load_extension"], optional = true } diff --git a/src/notifier/email.rs b/src/notifier/email.rs index bbd0242b..b330ef15 100644 --- a/src/notifier/email.rs +++ b/src/notifier/email.rs @@ -39,7 +39,13 @@ impl EmailNotifier { AsyncSmtpTransport::::builder_dangerous(host) }; - Some(builder.port(config.smtp_port).credentials(creds).build()) + Some( + builder + .port(config.smtp_port) + .credentials(creds) + .timeout(Some(std::time::Duration::from_secs(10))) + .build(), + ) } else { None }; From 3fdf4304df8e18dc61538fabbe0c09502c0b270f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Mar 2026 22:27:41 +0000 Subject: [PATCH 10/11] fix: Add step-level timeouts for Windows test steps Add timeout-minutes to warmup (10min) and test run (20min) steps to prevent indefinite hangs in the Windows CI. https://claude.ai/code/session_01Mmu98Tc4ZbqpRzMCsVCkXd --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94ec8190..f16228e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,12 +158,17 @@ jobs: path: ${{ runner.temp }}\.cache\fastembed key: fastembed-nomic-v15-windows + - name: Compile tests + run: cargo test --all-features --no-run + - name: Warmup embedding model + timeout-minutes: 5 run: cargo test --all-features -- feedback::embeddings::tests::test_warmup_downloads_model --exact --nocapture env: FASTEMBED_CACHE_DIR: ${{ runner.temp }}\.cache\fastembed - name: Run tests + timeout-minutes: 10 run: cargo test --all-features env: FASTEMBED_CACHE_DIR: ${{ runner.temp }}\.cache\fastembed From 64c2745d0b03537cdfaeb9c5dc87883c0cd7d1d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Mar 2026 01:52:35 +0000 Subject: [PATCH 11/11] fix: Add rustls with ring as direct dependency and install crypto provider The reqwest crate with rustls-no-provider requires a crypto provider to be installed before any TLS connections. Add rustls with ring feature as a direct dependency so that rustls auto-detects ring via get_default_or_install_from_crate_features(). Also explicitly install ring as the default provider in main() and e2e binary for safety. This ensures all rustls consumers (reqwest, axum-server, rustls-acme) use ring instead of aws-lc-rs, fixing Windows build failures caused by aws-lc-sys compilation issues with MSVC. https://claude.ai/code/session_01Mmu98Tc4ZbqpRzMCsVCkXd --- Cargo.lock | 1 + Cargo.toml | 3 +++ src/bin/e2e/main.rs | 2 ++ src/lib.rs | 9 +++++++++ src/main.rs | 3 +++ 5 files changed, 18 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 58205b50..951309e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -743,6 +743,7 @@ dependencies = [ "reqwest 0.13.2", "rusqlite", "rust-embed", + "rustls", "rustls-acme", "semver", "sentry", diff --git a/Cargo.toml b/Cargo.toml index 7527eb9a..6d7d3af6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,9 @@ reqwest = { version = "0.13", default-features = false, features = ["json", "for tower = { version = "0.5", features = ["limit"] } tower-http = { version = "0.6", features = ["trace", "cors", "fs", "limit"] } +# TLS (ring crypto provider for all rustls consumers) +rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } + # TLS auto-provisioning (Let's Encrypt ACME) rustls-acme = { version = "0.15", default-features = false, features = ["axum", "ring", "tls12", "webpki-roots"] } diff --git a/src/bin/e2e/main.rs b/src/bin/e2e/main.rs index 44fdd5cc..4e433ef1 100644 --- a/src/bin/e2e/main.rs +++ b/src/bin/e2e/main.rs @@ -252,6 +252,8 @@ fn reviewer_token(scm: &str) -> Option { #[tokio::main] async fn main() -> Result<()> { + claudear::init_tls(); + tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() diff --git a/src/lib.rs b/src/lib.rs index 627ba1ea..c89ed9a0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,6 +25,15 @@ //! claudear webhook //! ``` +/// Install the ring crypto provider for all rustls consumers (reqwest, axum-server, etc.). +/// +/// Must be called once before any TLS connections are made. Subsequent calls +/// are harmless (the function is idempotent). +pub fn init_tls() { + // `install_default` returns Err if a provider is already installed – that's fine. + let _ = rustls::crypto::ring::default_provider().install_default(); +} + pub mod api; pub mod api_events; pub(crate) mod ask_reply_inbox; diff --git a/src/main.rs b/src/main.rs index bd4cd627..8d4de473 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1330,6 +1330,9 @@ fn start_regression_monitoring( } fn main() -> anyhow::Result<()> { + // Install ring as the global TLS crypto provider (must happen before any HTTPS calls) + claudear::init_tls(); + // Initialize Sentry before the async runtime to ensure proper flushing on shutdown let _sentry_guard = sentry::init(( std::env::var("CLAUDEAR_SENTRY_DSN").unwrap_or_default(),