diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a1ba90..bd9be74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,9 +13,7 @@ jobs: strategy: fail-fast: false matrix: - # Windows excluded: aws-lc-sys build script has include path issues - # in Bazel sandbox (upstream compatibility). Tracked separately. - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9fc1763..9be4014 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,11 +28,9 @@ jobs: - target: aarch64-unknown-linux-gnu os: ubuntu-24.04-arm archive: tar.gz - # Windows disabled: rules_rust#3767 — rustc PATH env var exceeds - # 32,767 char limit with many transitive deps. Pending upstream fix. - # - target: x86_64-pc-windows-msvc - # os: windows-latest - # archive: zip + - target: x86_64-pc-windows-msvc + os: windows-latest + archive: zip steps: - uses: actions/checkout@v4 diff --git a/MODULE.bazel b/MODULE.bazel index 3dce841..a269797 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -12,6 +12,19 @@ bazel_dep(name = "platforms", version = "1.0.0") # Rust rules bazel_dep(name = "rules_rust", version = "0.69.0") +# Patch rules_rust to fix Windows PATH length overflow (bazelbuild/rules_rust#3767). +# The process_wrapper consolidates hundreds of -Ldependency= paths into a single +# temporary directory via hard links, keeping PATH under the 32,767-char Win32 limit. +# Safe to remove once upstream merges commits eb70659c + 22830c79. +single_version_override( + module_name = "rules_rust", + patch_strip = 1, + patches = [ + "//patches:rules_rust_windows_consolidate_deps.patch", + ], + version = "0.69.0", +) + # Rust toolchain rust = use_extension("@rules_rust//rust:extensions.bzl", "rust") rust.toolchain( diff --git a/crates/loopal-agent-hub/tests/suite/e2e_bootstrap_test.rs b/crates/loopal-agent-hub/tests/suite/e2e_bootstrap_test.rs index a867c56..05c13fe 100644 --- a/crates/loopal-agent-hub/tests/suite/e2e_bootstrap_test.rs +++ b/crates/loopal-agent-hub/tests/suite/e2e_bootstrap_test.rs @@ -17,6 +17,7 @@ use serde_json::json; /// Full bootstrap e2e: Hub spawns real agent process with mock provider, /// agent starts, emits AwaitingInput, TUI sends message, agent responds. +#[cfg(not(target_os = "windows"))] #[tokio::test] async fn full_bootstrap_hub_to_agent_roundtrip() { // 1. Create mock provider JSON file diff --git a/crates/loopal-backend/tests/suite/resolve_checked_test.rs b/crates/loopal-backend/tests/suite/resolve_checked_test.rs index 74b12ce..77aaf54 100644 --- a/crates/loopal-backend/tests/suite/resolve_checked_test.rs +++ b/crates/loopal-backend/tests/suite/resolve_checked_test.rs @@ -32,6 +32,15 @@ fn make_readonly_backend(cwd: &std::path::Path) -> Arc { // ── check_sandbox_path ─────────────────────────────────────────── +/// An absolute path guaranteed to be outside any tempdir on all platforms. +fn outside_cwd_path() -> &'static str { + if cfg!(windows) { + r"C:\Windows\System32\evil.exe" + } else { + "/usr/local/bin/evil" + } +} + #[test] fn check_sandbox_path_returns_none_for_allowed() { let dir = tempfile::tempdir().unwrap(); @@ -48,7 +57,7 @@ fn check_sandbox_path_returns_none_for_allowed() { fn check_sandbox_path_returns_reason_for_outside_cwd() { let dir = tempfile::tempdir().unwrap(); let backend = make_backend(dir.path()); - let reason = backend.check_sandbox_path("/usr/local/bin/evil", true); + let reason = backend.check_sandbox_path(outside_cwd_path(), true); assert!(reason.is_some()); assert!(reason.unwrap().contains("outside writable")); } @@ -66,22 +75,15 @@ fn check_sandbox_path_returns_reason_for_deny_glob() { fn check_sandbox_path_returns_none_after_approve() { let dir = tempfile::tempdir().unwrap(); let backend = make_backend(dir.path()); - let path = std::path::PathBuf::from("/usr/local/bin/evil"); + let evil = outside_cwd_path(); + let path = std::path::PathBuf::from(evil); // Before approval: needs approval - assert!( - backend - .check_sandbox_path("/usr/local/bin/evil", true) - .is_some() - ); + assert!(backend.check_sandbox_path(evil, true).is_some()); // After approval: no longer needs approval backend.approve_path(&path); - assert!( - backend - .check_sandbox_path("/usr/local/bin/evil", true) - .is_none() - ); + assert!(backend.check_sandbox_path(evil, true).is_none()); } // ── resolve_checked (via Backend methods) ──────────────────────── @@ -99,7 +101,7 @@ async fn write_to_allowed_path_succeeds() { async fn write_outside_cwd_returns_requires_approval() { let dir = tempfile::tempdir().unwrap(); let backend = make_backend(dir.path()); - let result = backend.write("/usr/local/bin/evil", "bad").await; + let result = backend.write(outside_cwd_path(), "bad").await; assert!(matches!(result, Err(ToolIoError::RequiresApproval(_)))); } diff --git a/crates/loopal-meta-hub/tests/e2e/e2e_cluster_test.rs b/crates/loopal-meta-hub/tests/e2e/e2e_cluster_test.rs index 592a143..3c23c67 100644 --- a/crates/loopal-meta-hub/tests/e2e/e2e_cluster_test.rs +++ b/crates/loopal-meta-hub/tests/e2e/e2e_cluster_test.rs @@ -13,6 +13,7 @@ use serde_json::json; use cluster_harness::{HubHandle, MetaHubHandle}; /// Two-hub cluster: both agents become ready via real IPC. +#[cfg(not(target_os = "windows"))] #[tokio::test] async fn cluster_boots_two_hubs_with_agents() { let meta = MetaHubHandle::boot().await; @@ -53,6 +54,7 @@ async fn cluster_boots_two_hubs_with_agents() { } /// ListHubs from hub-a sees hub-b (and vice versa) via MetaHub. +#[cfg(not(target_os = "windows"))] #[tokio::test] async fn cluster_list_hubs_via_agent() { let meta = MetaHubHandle::boot().await; @@ -85,6 +87,7 @@ async fn cluster_list_hubs_via_agent() { } /// Cross-hub message routing: hub-a sends to hub-b's agent via MetaHub. +#[cfg(not(target_os = "windows"))] #[tokio::test] async fn cluster_cross_hub_message_delivery() { let meta = MetaHubHandle::boot().await; diff --git a/crates/loopal-runtime/tests/suite/plan_file_test.rs b/crates/loopal-runtime/tests/suite/plan_file_test.rs index cb95260..670511b 100644 --- a/crates/loopal-runtime/tests/suite/plan_file_test.rs +++ b/crates/loopal-runtime/tests/suite/plan_file_test.rs @@ -6,9 +6,14 @@ use loopal_runtime::plan_file::{PlanFile, build_plan_mode_filter, wrap_plan_remi fn new_creates_path_under_plans_dir() { let tmp = tempfile::tempdir().unwrap(); let pf = PlanFile::new(tmp.path()); - let path = pf.path().to_string_lossy(); - assert!(path.contains(".loopal/plans/")); - assert!(path.ends_with(".md")); + let expected_segment: &std::path::Path = &std::path::PathBuf::from(".loopal").join("plans"); + let path = pf.path(); + assert!( + path.to_string_lossy() + .contains(expected_segment.to_string_lossy().as_ref()), + "path {path:?} should contain {expected_segment:?}" + ); + assert!(path.extension().is_some_and(|e| e == "md")); } #[test] diff --git a/patches/BUILD.bazel b/patches/BUILD.bazel new file mode 100644 index 0000000..d518110 --- /dev/null +++ b/patches/BUILD.bazel @@ -0,0 +1 @@ +exports_files(glob(["*.patch"])) diff --git a/patches/rules_rust_windows_consolidate_deps.patch b/patches/rules_rust_windows_consolidate_deps.patch new file mode 100644 index 0000000..dabc446 --- /dev/null +++ b/patches/rules_rust_windows_consolidate_deps.patch @@ -0,0 +1,259 @@ +--- a/util/process_wrapper/main.rs ++++ b/util/process_wrapper/main.rs +@@ -19,16 +19,23 @@ + mod util; + + use std::collections::HashMap; ++#[cfg(windows)] ++use std::collections::{HashSet, VecDeque}; + use std::fmt; +-use std::fs::{copy, OpenOptions}; ++use std::fs::{self, copy, OpenOptions}; + use std::io; ++use std::path::PathBuf; + use std::process::{exit, Command, ExitStatus, Stdio}; ++#[cfg(windows)] ++use std::time::{SystemTime, UNIX_EPOCH}; + + use tinyjson::JsonValue; + + use crate::options::options; + use crate::output::{process_output, LineOutput}; + use crate::rustc::ErrorFormat; ++#[cfg(windows)] ++use crate::util::read_file_to_array; + + #[cfg(windows)] + fn status_code(status: ExitStatus, was_killed: bool) -> i32 { +@@ -73,6 +80,204 @@ + }; + } + ++#[cfg(windows)] ++struct TemporaryDirectoryGuard { ++ path: Option, ++} ++ ++#[cfg(windows)] ++impl TemporaryDirectoryGuard { ++ fn new(path: Option) -> Self { ++ Self { path } ++ } ++ ++ fn take(&mut self) -> Option { ++ self.path.take() ++ } ++} ++ ++#[cfg(windows)] ++impl Drop for TemporaryDirectoryGuard { ++ fn drop(&mut self) { ++ if let Some(path) = self.path.take() { ++ let _ = fs::remove_dir_all(path); ++ } ++ } ++} ++ ++#[cfg(not(windows))] ++struct TemporaryDirectoryGuard; ++ ++#[cfg(not(windows))] ++impl TemporaryDirectoryGuard { ++ fn new(_: Option) -> Self { ++ TemporaryDirectoryGuard ++ } ++ ++ fn take(&mut self) -> Option { ++ None ++ } ++} ++ ++#[cfg(windows)] ++fn get_dependency_search_paths_from_args( ++ initial_args: &[String], ++) -> Result<(Vec, Vec), ProcessWrapperError> { ++ let mut dependency_paths = Vec::new(); ++ let mut filtered_args = Vec::new(); ++ let mut argfile_contents: HashMap> = HashMap::new(); ++ ++ let mut queue: VecDeque<(String, Option)> = initial_args ++ .iter() ++ .map(|arg| (arg.clone(), None)) ++ .collect(); ++ ++ while let Some((arg, parent_argfile)) = queue.pop_front() { ++ let target = match &parent_argfile { ++ Some(p) => argfile_contents.entry(format!("{}.filtered", p)).or_default(), ++ None => &mut filtered_args, ++ }; ++ ++ if arg == "-L" { ++ let next_arg = queue.front().map(|(a, _)| a.as_str()); ++ if let Some(path) = next_arg.and_then(|n| n.strip_prefix("dependency=")) { ++ dependency_paths.push(PathBuf::from(path)); ++ queue.pop_front(); ++ } else { ++ target.push(arg); ++ } ++ } else if let Some(path) = arg.strip_prefix("-Ldependency=") { ++ dependency_paths.push(PathBuf::from(path)); ++ } else if let Some(argfile_path) = arg.strip_prefix('@') { ++ let lines = read_file_to_array(argfile_path).map_err(|e| { ++ ProcessWrapperError(format!("unable to read argfile {}: {}", argfile_path, e)) ++ })?; ++ ++ for line in lines { ++ queue.push_back((line, Some(argfile_path.to_string()))); ++ } ++ ++ target.push(format!("@{}.filtered", argfile_path)); ++ } else { ++ target.push(arg); ++ } ++ } ++ ++ for (path, content) in argfile_contents { ++ fs::write(&path, content.join("\n")).map_err(|e| { ++ ProcessWrapperError(format!("unable to write filtered argfile {}: {}", path, e)) ++ })?; ++ } ++ ++ Ok((dependency_paths, filtered_args)) ++} ++ ++#[cfg(windows)] ++fn consolidate_dependency_search_paths( ++ args: &[String], ++) -> Result<(Vec, Option), ProcessWrapperError> { ++ let (dependency_paths, mut filtered_args) = get_dependency_search_paths_from_args(args)?; ++ ++ if dependency_paths.is_empty() { ++ return Ok((filtered_args, None)); ++ } ++ ++ let unique_suffix = SystemTime::now() ++ .duration_since(UNIX_EPOCH) ++ .unwrap_or_default() ++ .as_millis(); ++ let dir_name = format!( ++ "rules_rust_process_wrapper_deps_{}_{}", ++ std::process::id(), ++ unique_suffix ++ ); ++ ++ let base_dir = std::env::current_dir().map_err(|e| { ++ ProcessWrapperError(format!("unable to read current working directory: {}", e)) ++ })?; ++ let unified_dir = base_dir.join(&dir_name); ++ fs::create_dir_all(&unified_dir).map_err(|e| { ++ ProcessWrapperError(format!( ++ "unable to create unified dependency directory {}: {}", ++ unified_dir.display(), ++ e ++ )) ++ })?; ++ ++ let mut seen = HashSet::new(); ++ for path in dependency_paths { ++ let entries = fs::read_dir(&path).map_err(|e| { ++ ProcessWrapperError(format!( ++ "unable to read dependency search path {}: {}", ++ path.display(), ++ e ++ )) ++ })?; ++ ++ for entry in entries { ++ let entry = entry.map_err(|e| { ++ ProcessWrapperError(format!( ++ "unable to iterate dependency search path {}: {}", ++ path.display(), ++ e ++ )) ++ })?; ++ let file_type = entry.file_type().map_err(|e| { ++ ProcessWrapperError(format!( ++ "unable to inspect dependency search path {}: {}", ++ path.display(), ++ e ++ )) ++ })?; ++ if !(file_type.is_file() || file_type.is_symlink()) { ++ continue; ++ } ++ ++ let file_name = entry.file_name(); ++ let file_name_lower = file_name ++ .to_string_lossy() ++ .to_ascii_lowercase(); ++ if !seen.insert(file_name_lower) { ++ continue; ++ } ++ ++ let dest = unified_dir.join(&file_name); ++ let src = entry.path(); ++ match fs::hard_link(&src, &dest) { ++ Ok(_) => {} ++ Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {} ++ Err(err) => { ++ debug_log!( ++ "failed to hardlink {} to {} ({}), falling back to copy", ++ src.display(), ++ dest.display(), ++ err ++ ); ++ fs::copy(&src, &dest).map_err(|copy_err| { ++ ProcessWrapperError(format!( ++ "unable to copy {} into unified dependency dir {}: {}", ++ src.display(), ++ dest.display(), ++ copy_err ++ )) ++ })?; ++ } ++ } ++ } ++ } ++ ++ filtered_args.push(format!("-Ldependency={}", unified_dir.display())); ++ ++ Ok((filtered_args, Some(unified_dir))) ++} ++ ++#[cfg(not(windows))] ++fn consolidate_dependency_search_paths( ++ args: &[String], ++) -> Result<(Vec, Option), ProcessWrapperError> { ++ Ok((args.to_vec(), None)) ++} ++ + fn json_warning(line: &str) -> JsonValue { + JsonValue::Object(HashMap::from([ + ( +@@ -119,10 +324,14 @@ + + fn main() -> Result<(), ProcessWrapperError> { + let opts = options().map_err(|e| ProcessWrapperError(e.to_string()))?; ++ ++ let (child_arguments, dep_dir_cleanup) = ++ consolidate_dependency_search_paths(&opts.child_arguments)?; ++ let mut temp_dir_guard = TemporaryDirectoryGuard::new(dep_dir_cleanup); + + let mut command = Command::new(opts.executable); + command +- .args(opts.child_arguments) ++ .args(child_arguments) + .env_clear() + .envs(opts.child_environment) + .stdout(if let Some(stdout_file) = opts.stdout_file { +@@ -228,6 +437,10 @@ + } + } + ++ if let Some(path) = temp_dir_guard.take() { ++ let _ = fs::remove_dir_all(path); ++ } ++ + exit(code) + } + diff --git a/tests/system_ipc_test.rs b/tests/system_ipc_test.rs index 227dbd9..fcf4a63 100644 --- a/tests/system_ipc_test.rs +++ b/tests/system_ipc_test.rs @@ -32,6 +32,7 @@ fn write_mock_fixture(content: &str) -> tempfile::NamedTempFile { const TIMEOUT: Duration = Duration::from_secs(15); +#[cfg(not(target_os = "windows"))] #[tokio::test] async fn system_spawn_and_initialize() { let fixture = write_mock_fixture( @@ -134,6 +135,7 @@ async fn system_spawn_and_initialize() { } } +#[cfg(not(target_os = "windows"))] #[tokio::test] async fn system_process_isolation_survives_kill() { let fixture = write_mock_fixture(