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
7 changes: 6 additions & 1 deletion .github/workflows/rust-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ jobs:
run: cargo clippy --workspace

windows-smoke:
name: windows PowerShell smoke
name: Windows tests and PowerShell smoke
runs-on: windows-latest
defaults:
run:
Expand All @@ -138,6 +138,11 @@ jobs:
- uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c
with:
workspaces: rust -> target
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: "3.x"
- name: Run Windows workspace tests
run: cargo test --workspace --locked --no-fail-fast
- name: Build CLI for Windows smoke
run: cargo build -p rusty-claude-cli
- name: Smoke local commands without live credentials
Expand Down
2 changes: 2 additions & 0 deletions rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 1 addition & 11 deletions rust/crates/api/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,21 +281,11 @@ pub fn read_xai_base_url() -> String {

#[cfg(test)]
mod tests {
use std::sync::{Mutex, OnceLock};
use crate::test_env_lock as env_lock;

use super::{ProviderClient, ProviderConfig, ProviderConfigKind};
use crate::providers::{detect_provider_kind, resolve_model_alias, ProviderKind};

/// Serializes every test in this module that mutates process-wide
/// environment variables so concurrent test threads cannot observe
/// each other's partially-applied state.
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}

#[test]
fn resolves_existing_and_grok_aliases() {
assert_eq!(resolve_model_alias("opus"), "claude-opus-4-7");
Expand Down
10 changes: 10 additions & 0 deletions rust/crates/api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,13 @@ pub use telemetry::{
MemoryTelemetrySink, SessionTraceRecord, SessionTracer, TelemetryEvent, TelemetrySink,
DEFAULT_ANTHROPIC_VERSION,
};

// Environment variables are shared by every test module in this process.
// Module-local locks cannot protect provider tests from each other.
#[cfg(test)]
pub(crate) fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
9 changes: 1 addition & 8 deletions rust/crates/api/src/prompt_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,7 @@ fn stable_hash_bytes(bytes: &[u8]) -> u64 {

#[cfg(test)]
mod tests {
use std::sync::{Mutex, OnceLock};
use crate::test_env_lock;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use super::{
Expand All @@ -509,13 +509,6 @@ mod tests {
};
use crate::types::{InputMessage, MessageRequest, MessageResponse, OutputContentBlock, Usage};

fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}

#[test]
fn path_builder_sanitizes_session_identifier() {
let paths = PromptCachePaths::for_session("session:/with spaces");
Expand Down
9 changes: 1 addition & 8 deletions rust/crates/api/src/providers/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1083,9 +1083,9 @@ struct AnthropicErrorBody {
#[cfg(test)]
mod tests {
use super::{ALT_REQUEST_ID_HEADER, REQUEST_ID_HEADER};
use crate::test_env_lock as env_lock;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::{Mutex, OnceLock};
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

Expand All @@ -1097,13 +1097,6 @@ mod tests {
};
use crate::types::{ContentBlockDelta, MessageRequest};

fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}

fn temp_config_home() -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"api-oauth-test-{}-{}",
Expand Down
13 changes: 1 addition & 12 deletions rust/crates/api/src/providers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -891,8 +891,8 @@ pub(crate) fn dotenv_value(key: &str) -> Option<String> {

#[cfg(test)]
mod tests {
use crate::test_env_lock as env_lock;
use std::ffi::OsString;
use std::sync::{Mutex, OnceLock};

use serde_json::json;

Expand All @@ -910,17 +910,6 @@ mod tests {
ProviderKind, ProviderWireProtocol,
};

/// Serializes every test in this module that mutates process-wide
/// environment variables so concurrent test threads cannot observe
/// each other's partially-applied state while probing the foreign
/// provider credential sniffer.
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}

/// Snapshot-restore guard for a single environment variable. Captures
/// the original value on construction, applies the requested override
/// (set or remove), and restores the original on drop so tests leave
Expand Down
9 changes: 1 addition & 8 deletions rust/crates/api/src/providers/openai_compat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1863,6 +1863,7 @@ mod tests {
StreamState,
};
use crate::error::ApiError;
use crate::test_env_lock as env_lock;
use crate::types::{
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
InputContentBlock, InputMessage, MessageRequest, OutputContentBlock, StreamEvent,
Expand All @@ -1871,7 +1872,6 @@ mod tests {
use serde_json::json;
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::sync::{Mutex, OnceLock};

#[test]
fn request_translation_uses_openai_compatible_shape() {
Expand Down Expand Up @@ -2371,13 +2371,6 @@ mod tests {
}
}

fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock")
}

#[test]
fn normalizes_stop_reasons() {
assert_eq!(normalize_finish_reason("stop"), "end_turn");
Expand Down
30 changes: 22 additions & 8 deletions rust/crates/api/tests/proxy_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,13 @@ impl Drop for EnvVarGuard {
fn proxy_config_from_env_reads_uppercase_proxy_vars() {
// given
let _lock = env_lock();
let _http = EnvVarGuard::set("HTTP_PROXY", Some("http://proxy.corp:3128"));
let _https = EnvVarGuard::set("HTTPS_PROXY", Some("http://secure.corp:3129"));
let _no = EnvVarGuard::set("NO_PROXY", Some("localhost,127.0.0.1"));
// Clear aliases before setting values: environment keys are case-insensitive on Windows.
let _http_lower = EnvVarGuard::set("http_proxy", None);
let _https_lower = EnvVarGuard::set("https_proxy", None);
let _no_lower = EnvVarGuard::set("no_proxy", None);
let _http = EnvVarGuard::set("HTTP_PROXY", Some("http://proxy.corp:3128"));
let _https = EnvVarGuard::set("HTTPS_PROXY", Some("http://secure.corp:3129"));
let _no = EnvVarGuard::set("NO_PROXY", Some("localhost,127.0.0.1"));

// when
let config = ProxyConfig::from_env();
Expand Down Expand Up @@ -127,13 +128,19 @@ fn proxy_config_from_env_treats_empty_values_as_unset() {
fn build_client_with_env_proxy_config_succeeds() {
// given
let _lock = env_lock();
let _http = EnvVarGuard::set("HTTP_PROXY", Some("http://proxy.corp:3128"));
let _https = EnvVarGuard::set("HTTPS_PROXY", Some("http://secure.corp:3129"));
let _no = EnvVarGuard::set("NO_PROXY", Some("localhost"));
// Clear aliases before setting values: environment keys are case-insensitive on Windows.
let _http_lower = EnvVarGuard::set("http_proxy", None);
let _https_lower = EnvVarGuard::set("https_proxy", None);
let _no_lower = EnvVarGuard::set("no_proxy", None);
let _http = EnvVarGuard::set("HTTP_PROXY", Some("http://proxy.corp:3128"));
let _https = EnvVarGuard::set("HTTPS_PROXY", Some("http://secure.corp:3129"));
let _no = EnvVarGuard::set("NO_PROXY", Some("localhost"));
let config = ProxyConfig::from_env();
assert_eq!(config.http_proxy.as_deref(), Some("http://proxy.corp:3128"));
assert_eq!(
config.https_proxy.as_deref(),
Some("http://secure.corp:3129")
);

// when
let result = build_http_client_with(&config);
Expand All @@ -155,7 +162,7 @@ fn build_client_with_proxy_url_config_succeeds() {
}

#[test]
fn proxy_config_from_env_prefers_uppercase_over_lowercase() {
fn proxy_config_from_env_respects_platform_case_semantics() {
// given
let _lock = env_lock();
let _http_upper = EnvVarGuard::set("HTTP_PROXY", Some("http://upper.corp:3128"));
Expand All @@ -169,5 +176,12 @@ fn proxy_config_from_env_prefers_uppercase_over_lowercase() {
let config = ProxyConfig::from_env();

// then
assert_eq!(config.http_proxy.as_deref(), Some("http://upper.corp:3128"));
// Unix retains two keys and prefers uppercase. On Windows the second
// assignment replaces the same environment entry, regardless of casing.
let expected = if cfg!(windows) {
"http://lower.corp:3128"
} else {
"http://upper.corp:3128"
};
assert_eq!(config.http_proxy.as_deref(), Some(expected));
}
96 changes: 64 additions & 32 deletions rust/crates/plugins/src/hooks.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
use std::ffi::OsStr;
#[cfg(not(windows))]
use std::path::Path;
use std::process::Command;

Expand Down Expand Up @@ -282,8 +281,18 @@ fn format_hook_warning(command: &str, code: i32, stdout: Option<&str>, stderr: &
fn shell_command(command: &str) -> CommandWithStdin {
#[cfg(windows)]
let command_builder = {
use std::os::windows::process::CommandExt;

// This is a configured shell command, not a single argv value. cmd
// does not understand the C-runtime backslash escaping used by arg().
let command = if Path::new(command).is_file() {
format!("\"{command}\"")
} else {
command.to_string()
};
let mut command_builder = Command::new("cmd");
command_builder.arg("/C").arg(command);
command_builder.args(["/D", "/S", "/C"]);
command_builder.raw_arg(format!("\"{command}\""));
CommandWithStdin::new(command_builder)
};

Expand Down Expand Up @@ -381,6 +390,14 @@ mod tests {
std::env::temp_dir().join(format!("plugins-hook-runner-{label}-{nanos}"))
}

fn plugin_hook(message: &str, code: i32) -> String {
if cfg!(windows) {
format!("echo {message}& exit /b {code}")
} else {
format!("printf '%s' '{message}'; exit {code}")
}
}

fn make_executable(path: &Path) {
#[cfg(unix)]
{
Expand All @@ -403,34 +420,34 @@ mod tests {
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
fs::create_dir_all(root.join("hooks")).expect("hooks dir");

let pre_path = root.join("hooks").join("pre.sh");
fs::write(
&pre_path,
format!("#!/bin/sh\nprintf '%s\\n' '{pre_message}'\n"),
)
.expect("write pre hook");
make_executable(&pre_path);

let post_path = root.join("hooks").join("post.sh");
fs::write(
&post_path,
format!("#!/bin/sh\nprintf '%s\\n' '{post_message}'\n"),
)
.expect("write post hook");
make_executable(&post_path);

let failure_path = root.join("hooks").join("failure.sh");
fs::write(
&failure_path,
format!("#!/bin/sh\nprintf '%s\\n' '{failure_message}'\n"),
)
.expect("write failure hook");
make_executable(&failure_path);
let extension = if cfg!(windows) { "cmd" } else { "sh" };
for (phase, message) in [
("pre", pre_message),
("post", post_message),
("failure", failure_message),
] {
let path = root.join("hooks").join(format!("{phase}.{extension}"));
let script = if cfg!(windows) {
format!("@echo off\r\necho {message}\r\n")
} else {
format!("#!/bin/sh\nprintf '%s\\n' '{message}'\n")
};
fs::write(&path, script).expect("write hook");
make_executable(&path);
}
fs::write(
root.join(".claude-plugin").join("plugin.json"),
format!(
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"hook plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"],\n \"PostToolUseFailure\": [\"./hooks/failure.sh\"]\n }}\n}}"
),
serde_json::json!({
"name": name,
"version": "1.0.0",
"description": "hook plugin",
"hooks": {
"PreToolUse": [format!("./hooks/pre.{extension}")],
"PostToolUse": [format!("./hooks/post.{extension}")],
"PostToolUseFailure": [format!("./hooks/failure.{extension}")]
}
})
.to_string(),
)
.expect("write plugin manifest");
}
Expand All @@ -439,7 +456,7 @@ mod tests {
fn collects_and_runs_hooks_from_enabled_plugins() {
// given
let config_home = temp_dir("config");
let first_source_root = temp_dir("source-a");
let first_source_root = temp_dir("source a");
let second_source_root = temp_dir("source-b");
write_hook_plugin(
&first_source_root,
Expand Down Expand Up @@ -500,7 +517,7 @@ mod tests {
fn pre_tool_use_denies_when_plugin_hook_exits_two() {
// given
let runner = HookRunner::new(crate::PluginHooks {
pre_tool_use: vec!["printf 'blocked by plugin'; exit 2".to_string()],
pre_tool_use: vec![plugin_hook("blocked by plugin", 2)],
post_tool_use: Vec::new(),
post_tool_use_failure: Vec::new(),
});
Expand All @@ -513,13 +530,28 @@ mod tests {
assert_eq!(result.messages(), &["blocked by plugin".to_string()]);
}

#[test]
fn preserves_quoted_arguments_in_plugin_hooks() {
let python = if cfg!(windows) { "python" } else { "python3" };
let runner = HookRunner::new(crate::PluginHooks {
pre_tool_use: vec![format!(
"{python} -c \"import sys; sys.stdout.write('quoted plugin hook'); sys.exit(2)\""
)],
post_tool_use: Vec::new(),
post_tool_use_failure: Vec::new(),
});
let result = runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#);
assert!(result.is_denied(), "{result:?}");
assert_eq!(result.messages(), &["quoted plugin hook".to_string()]);
}

#[test]
fn propagates_plugin_hook_failures() {
// given
let runner = HookRunner::new(crate::PluginHooks {
pre_tool_use: vec![
"printf 'broken plugin hook'; exit 1".to_string(),
"printf 'later plugin hook'".to_string(),
plugin_hook("broken plugin hook", 1),
plugin_hook("later plugin hook", 0),
],
post_tool_use: Vec::new(),
post_tool_use_failure: Vec::new(),
Expand Down
Loading
Loading