Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/loopal-backend/tests/suite/process_group_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ async fn terminate_grace_covers_descendant_after_leader_exits() {
let descendant = wait_for_pid(&pid_file).await;
let started = std::time::Instant::now();

let termination = spawned.terminate(Duration::from_millis(500)).await;
let termination = spawned.terminate(Duration::from_secs(2)).await;
assert_eq!(termination.outcome, KillOutcome::Terminated);
assert!(started.elapsed() >= Duration::from_millis(180));
wait_until_terminal(descendant).await;
Expand Down
28 changes: 6 additions & 22 deletions crates/loopal-runtime/tests/suite/unresolved_secret_effect_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,22 +72,13 @@ fn context() -> ToolContext {
.with_protected_effect_audit(Arc::new(loopal_tool_api::NoopProtectedEffectAudit))
}

async fn bash_action_at(
kernel: &Kernel,
marker: &std::path::Path,
) -> loopal_runtime::tool_action::PreparedToolAction {
let command = if cfg!(windows) {
format!("echo effect>\"{}\"", marker.display())
} else {
let marker = marker.to_string_lossy().replace('\'', "'\\''");
format!("printf effect > '{marker}'")
};
async fn bash_action(kernel: &Kernel) -> loopal_runtime::tool_action::PreparedToolAction {
prepare_tool_action(
kernel,
"id",
"Bash",
json!({
"command": command,
"command": "exit 0",
"env": {"TOKEN": "<secret_ref:missing>"}
}),
)
Expand All @@ -99,37 +90,30 @@ async fn bash_action_at(

#[tokio::test]
async fn unresolved_wire_ref_fails_closed_but_missing_marker_can_execute() {
let root = tempfile::tempdir().unwrap();
let path = root.path().join("unresolved-secret-effect");
let kernel = Kernel::new(Settings::default()).unwrap();
let action = bash_action_at(&kernel, &path).await;
let action = bash_action(&kernel).await;
let error = execute_tool(&kernel, action, &context(), &AgentMode::Act)
.await
.expect_err("missing secret client must fail closed");
assert!(error.to_string().contains("secret resolution failed"));
assert!(!path.exists());

let action = bash_action_at(&kernel, &path).await;
let action = bash_action(&kernel).await;
let ctx = context().with_secret_client(Arc::new(MissingSecret));
let result = execute_tool(&kernel, action, &ctx, &AgentMode::Act)
.await
.expect("resolved missing-secret marker is safe literal input");
assert!(!result.is_error);
assert!(path.exists());
assert!(!result.is_error, "{}", result.content);
}

#[tokio::test]
async fn denied_secret_resolution_cannot_execute_the_effect() {
let root = tempfile::tempdir().unwrap();
let path = root.path().join("denied-secret-effect");
let kernel = Kernel::new(Settings::default()).unwrap();
let action = bash_action_at(&kernel, &path).await;
let action = bash_action(&kernel).await;
let ctx = context().with_secret_client(Arc::new(DeniedSecret));

let error = execute_tool(&kernel, action, &ctx, &AgentMode::Act)
.await
.expect_err("permission-denied secret resolution must fail closed");

assert!(error.to_string().contains("secret resolution failed"));
assert!(!path.exists());
}
4 changes: 2 additions & 2 deletions crates/loopal-storage/src/resources/file_io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,11 @@ fn retryable_replace_error(_error: &std::io::Error) -> bool {
false
}

fn retryable_verification_error(error: &StorageError) -> bool {
pub(super) fn retryable_verification_error(error: &StorageError) -> bool {
matches!(error, StorageError::Io(error) if retryable_replace_error(error))
}

fn replace_retry_delay(retry: usize) -> std::time::Duration {
pub(super) fn replace_retry_delay(retry: usize) -> std::time::Duration {
let shift = u32::try_from(retry.min(5)).unwrap_or(5);
std::time::Duration::from_millis(1u64 << shift)
}
Expand Down
51 changes: 50 additions & 1 deletion crates/loopal-storage/src/resources/file_io_tests.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use loopal_error::StorageError;

use super::file_io::{read_regular_bounded, replace_file};
use super::file_io::{
read_regular_bounded, replace_file, replace_retry_delay, retryable_verification_error,
};

#[cfg(unix)]
use super::file_io::existing_matches;
Expand All @@ -14,6 +16,25 @@ async fn bounded_read_rejects_a_directory() {
));
}

#[test]
fn replace_retry_delay_is_exponential_and_capped() {
assert_eq!(replace_retry_delay(0), std::time::Duration::from_millis(1));
assert_eq!(replace_retry_delay(3), std::time::Duration::from_millis(8));
assert_eq!(
replace_retry_delay(usize::MAX),
std::time::Duration::from_millis(32)
);
}

#[test]
fn non_retryable_verification_errors_are_rejected() {
let io_error = StorageError::Io(std::io::Error::other("not retryable"));
assert!(!retryable_verification_error(&io_error));
assert!(!retryable_verification_error(
&StorageError::ResourceIntegrity
));
}

#[tokio::test]
async fn failed_replace_accepts_an_existing_matching_winner() {
let temp = tempfile::tempdir().unwrap();
Expand All @@ -26,6 +47,8 @@ async fn failed_replace_accepts_an_existing_matching_winner() {
assert_eq!(std::fs::read(target).unwrap(), b"expected");
}

// This invalid-temp fixture relies on Unix directory rename/unlink semantics.
#[cfg(unix)]
#[tokio::test]
async fn matching_winner_reports_loser_cleanup_failure() {
let root = tempfile::tempdir().unwrap();
Expand Down Expand Up @@ -94,6 +117,32 @@ async fn matching_locked_winner_resolves_replace_competition_and_cleans_temp() {
drop(locked_target);
}

#[cfg(windows)]
#[tokio::test]
async fn matching_winner_reports_locked_temp_cleanup_failure() {
use std::os::windows::fs::OpenOptionsExt;
use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_READ, FILE_SHARE_WRITE};

let root = tempfile::tempdir().unwrap();
let temp = root.path().join("prepared-temp");
let target = root.path().join("resource");
std::fs::write(&temp, b"expected").unwrap();
std::fs::write(&target, b"expected").unwrap();
let locked_temp = std::fs::OpenOptions::new()
.read(true)
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
.open(&temp)
.unwrap();

assert!(matches!(
replace_file(&temp, &target, b"expected").await,
Err(StorageError::Io(_))
));
assert!(temp.exists());
assert_eq!(std::fs::read(&target).unwrap(), b"expected");
drop(locked_temp);
}

#[cfg(windows)]
#[tokio::test]
async fn mismatched_locked_winner_fails_closed_after_bounded_retries() {
Expand Down
Loading