mirror of
https://github.com/instructkr/claw-code.git
synced 2026-11-15 00:01:11 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a29e08883 | ||
|
|
7041894439 | ||
|
|
333298ae53 | ||
|
|
fe2b64ae9e | ||
|
|
bd4043a83e | ||
|
|
072892ff1e | ||
|
|
407a6c62f8 | ||
|
|
ca48ec7500 | ||
|
|
ab8561dc37 | ||
|
|
747e160b59 | ||
|
|
6ac59ab90b | ||
|
|
67e5783894 | ||
|
|
db58b190bd |
No files matched your search
@@ -29,7 +29,7 @@ jobs:
|
||||
bin: claw
|
||||
artifact_name: claw-linux-x64
|
||||
- name: macos-arm64
|
||||
os: macos-14
|
||||
os: macos-15
|
||||
bin: claw
|
||||
artifact_name: claw-macos-arm64
|
||||
- name: windows-x64
|
||||
|
||||
@@ -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:
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.1.13] - 2026-11-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- Anthropic and OpenAI-compatible providers now retry HTTP 529 (overloaded) responses and transient gateway HTTP 400 responses (empty or unparseable body, connection reset, broken pipe, empty reply) with the existing bounded backoff, instead of failing the request immediately (#3381)
|
||||
|
||||
## [0.1.12] - 2026-11-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- Git Bash discovery works when only Git's `cmd` directory is on `PATH` (#3378)
|
||||
- REPL and PowerShell executables resolve from `PATH`, honoring Windows `PATHEXT` (#3378)
|
||||
- Quoted hook and plugin script paths containing spaces are preserved through `cmd /D /S /C` (#3378)
|
||||
- Windows verbatim and native paths are handled correctly in glob matching and workspace-containment checks (#3378)
|
||||
- OAuth PKCE and state use OS-backed randomness instead of `/dev/urandom` (#3378)
|
||||
|
||||
### Changed
|
||||
|
||||
- Windows hooks now run with `cmd /D`, which disables AutoRun (#3378)
|
||||
- CI runs the full Rust workspace test suite on Windows (#3378)
|
||||
|
||||
## [0.1.11] - 2026-11-04
|
||||
|
||||
### Fixed
|
||||
|
||||
- NotebookEdit no longer reuses an existing cell id for a newly inserted cell after a cell has been deleted, so later edits by id target the intended cell (#3371)
|
||||
|
||||
## [0.1.10] - 2026-11-02
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -149,6 +149,48 @@ curl -sS "$OPENAI_BASE_URL/models" \
|
||||
|
||||
For local configuration, see your gateway's documentation for the correct base URL, API key format, and available model IDs. The same OPENAI_BASE_URL + OPENAI_API_KEY pattern applies to any OpenAI-compatible remote gateway.
|
||||
|
||||
## LLMTR
|
||||
|
||||
[LLMTR](https://llmtr.com) is an OpenAI-compatible gateway. Use the same client: set `OPENAI_BASE_URL` to `https://llmtr.com/v1` and `OPENAI_API_KEY` to a bearer key (`llmtr-...`).
|
||||
|
||||
```bash
|
||||
export OPENAI_BASE_URL="https://llmtr.com/v1"
|
||||
export OPENAI_API_KEY="llmtr-..."
|
||||
claw --model "local/llmtr/qwen3-8-27b" prompt "Reply exactly HELLO_WORLD_123"
|
||||
```
|
||||
|
||||
Send the catalog id unchanged. `llmtr/qwen3-8-27b` already starts with the gateway name, so that prefix stays on the wire. The `local/` hatch only selects the OpenAI-compatible transport and is removed before the request. A bare `qwen/` or `qwen-` name is a DashScope route and will not reach LLMTR, so vendor-prefixed ids need the same hatch: `local/<vendor>/<model>`.
|
||||
|
||||
Claw asks for up to 64,000 output tokens on these routes. LLMTR rejects a request whose `max_tokens` is above the model's output limit, so pick a model that allows at least that much. `llmtr/qwen3-8-27b` allows 262,144. Each model's limit is listed as `top_provider.max_completion_tokens` in the catalog below.
|
||||
|
||||
The public model list does not need a key:
|
||||
|
||||
```bash
|
||||
curl -sS "https://llmtr.com/v1/models" \
|
||||
| jq -r '.data[] | "\(.id)\t\(.top_provider.max_completion_tokens)"'
|
||||
```
|
||||
|
||||
## UberLLM
|
||||
|
||||
[UberLLM](https://uberllm.dev) is a pay-as-you-go OpenAI-compatible gateway (prepaid, no free tier). Set `OPENAI_BASE_URL` to `https://api.uberllm.dev/v1` and `OPENAI_API_KEY` to a bearer key (`ull_...`).
|
||||
|
||||
```bash
|
||||
export OPENAI_BASE_URL="https://api.uberllm.dev/v1"
|
||||
export OPENAI_API_KEY="ull_..."
|
||||
claw --model "local/deepseek/deepseek-v4-flash" prompt "Reply exactly HELLO_WORLD_123"
|
||||
```
|
||||
|
||||
UberLLM ids are vendor-prefixed (`deepseek/deepseek-v4-flash`, `moonshotai/kimi-k2.6`, `qwen/qwen3.8-flash`). Put `local/` in front of every one: the hatch selects the OpenAI-compatible transport and is removed, so the catalog id goes out unchanged. Some prefixes would otherwise pick a native route; a bare `qwen/qwen3.8-flash`, for example, goes to DashScope and asks for `DASHSCOPE_API_KEY`.
|
||||
|
||||
Claw asks for up to 64,000 output tokens on these routes. UberLLM does not reject that on a model with a smaller output limit (for example `aion-labs/aion-3-0-mini`, 32,768); the answer is simply bounded by the model's limit, listed as `max_output_tokens` in the catalog below.
|
||||
|
||||
The public model list does not need a key:
|
||||
|
||||
```bash
|
||||
curl -sS "https://api.uberllm.dev/v1/models" \
|
||||
| jq -r '.data[] | "\(.id)\t\(.max_output_tokens)"'
|
||||
```
|
||||
|
||||
## Local skills install from disk
|
||||
|
||||
Skills are discovered from Claw skill roots such as `.claw/skills/` in a workspace and `~/.claw/skills/` for user-level installs. Legacy `.codex/skills/` roots may also be scanned for compatibility, but new local Claw projects should prefer `.claw/skills/`.
|
||||
|
||||
Generated
+13
-11
@@ -93,7 +93,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "api"
|
||||
version = "0.1.10"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"reqwest",
|
||||
@@ -478,7 +478,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "claw-analog"
|
||||
version = "0.1.10"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"api",
|
||||
"clap",
|
||||
@@ -498,7 +498,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "claw-rag-service"
|
||||
version = "0.1.10"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"blake3",
|
||||
@@ -531,7 +531,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "commands"
|
||||
version = "0.1.10"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"plugins",
|
||||
"runtime",
|
||||
@@ -540,7 +540,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "compat-harness"
|
||||
version = "0.1.10"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"commands",
|
||||
"runtime",
|
||||
@@ -1568,7 +1568,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mock-anthropic-service"
|
||||
version = "0.1.10"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"api",
|
||||
"serde_json",
|
||||
@@ -1761,7 +1761,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "plugins"
|
||||
version = "0.1.10"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2136,11 +2136,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "runtime"
|
||||
version = "0.1.10"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"glob",
|
||||
"plugins",
|
||||
"regex",
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -2261,7 +2263,7 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "rusty-claude-cli"
|
||||
version = "0.1.10"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"api",
|
||||
"commands",
|
||||
@@ -2622,7 +2624,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "telemetry"
|
||||
version = "0.1.10"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2902,7 +2904,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tools"
|
||||
version = "0.1.10"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"api",
|
||||
"aspect-core",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.10"
|
||||
version = "0.1.13"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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::{
|
||||
@@ -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");
|
||||
|
||||
@@ -889,7 +889,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
||||
let request_id = request_id_from_headers(&headers).map(String::into_boxed_str);
|
||||
let body = response.text().await.unwrap_or_else(|_| String::new());
|
||||
let parsed_error = serde_json::from_str::<AnthropicErrorEnvelope>(&body).ok();
|
||||
let retryable = is_retryable_status(status);
|
||||
let retryable = is_retryable_status(status) || is_retryable_400(status, &body);
|
||||
let retry_after = parse_retry_after(&headers, status).map(Box::new);
|
||||
|
||||
Err(ApiError::Api {
|
||||
@@ -923,7 +923,10 @@ fn parse_retry_after(
|
||||
}
|
||||
|
||||
const fn is_retryable_status(status: reqwest::StatusCode) -> bool {
|
||||
matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504)
|
||||
matches!(
|
||||
status.as_u16(),
|
||||
408 | 409 | 429 | 500 | 502 | 503 | 504 | 529
|
||||
)
|
||||
}
|
||||
|
||||
/// Some providers return HTTP 400 with an unparseable body when a gateway
|
||||
@@ -1083,9 +1086,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};
|
||||
|
||||
@@ -1097,13 +1100,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-{}-{}",
|
||||
@@ -1153,6 +1149,56 @@ mod tests {
|
||||
format!("http://{address}/oauth/token")
|
||||
}
|
||||
|
||||
fn spawn_status_server(status: u16, body: &'static str) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
|
||||
let address = listener.local_addr().expect("local addr");
|
||||
thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().expect("accept connection");
|
||||
let mut buffer = [0_u8; 4096];
|
||||
let _ = stream.read(&mut buffer).expect("read request");
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status} STATUS\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
stream
|
||||
.write_all(response.as_bytes())
|
||||
.expect("write response");
|
||||
});
|
||||
format!("http://{address}/v1/messages")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expect_success_marks_529_overloaded_retryable() {
|
||||
let url = spawn_status_server(
|
||||
529,
|
||||
r#"{"error":{"type":"overloaded_error","message":"Overloaded"}}"#,
|
||||
);
|
||||
let response = reqwest::get(&url).await.expect("response");
|
||||
let error = super::expect_success(response)
|
||||
.await
|
||||
.expect_err("529 should error");
|
||||
assert!(error.is_retryable(), "529 overloaded must be retryable");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expect_success_marks_gateway_flake_400_retryable() {
|
||||
let url = spawn_status_server(400, "HTTP 400 from backend (no parseable body)");
|
||||
let response = reqwest::get(&url).await.expect("response");
|
||||
let error = super::expect_success(response)
|
||||
.await
|
||||
.expect_err("400 should error");
|
||||
assert!(
|
||||
error.is_retryable(),
|
||||
"gateway-flake 400 body must be retryable"
|
||||
);
|
||||
let url = spawn_status_server(400, "invalid request: max_tokens must be positive");
|
||||
let response = reqwest::get(&url).await.expect("response");
|
||||
let error = super::expect_success(response)
|
||||
.await
|
||||
.expect_err("400 should error");
|
||||
assert!(!error.is_retryable(), "a real 400 must not be retried");
|
||||
}
|
||||
#[test]
|
||||
fn read_api_key_requires_presence() {
|
||||
let _guard = env_lock();
|
||||
@@ -1452,6 +1498,40 @@ mod tests {
|
||||
assert!(!super::is_retryable_status(
|
||||
reqwest::StatusCode::UNAUTHORIZED
|
||||
));
|
||||
// Anthropic returns the non-standard 529 when the API is overloaded;
|
||||
// it is transient and must be retried like a 503.
|
||||
assert!(super::is_retryable_status(
|
||||
reqwest::StatusCode::from_u16(529).expect("529 is a valid status")
|
||||
));
|
||||
assert!(!super::is_retryable_status(
|
||||
reqwest::StatusCode::BAD_REQUEST
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_flake_400_bodies_are_retryable() {
|
||||
// Gateways and proxies surface transient network failures as HTTP 400
|
||||
// with an unparseable body; is_retryable_400 detects them so
|
||||
// expect_success marks the resulting error retryable.
|
||||
for body in [
|
||||
"HTTP 400 from backend (no parseable body)",
|
||||
"upstream connection reset by peer",
|
||||
"Broken pipe while reading response",
|
||||
"Empty reply from server",
|
||||
] {
|
||||
assert!(
|
||||
super::is_retryable_400(reqwest::StatusCode::BAD_REQUEST, body),
|
||||
"body should be retryable: {body}"
|
||||
);
|
||||
}
|
||||
assert!(!super::is_retryable_400(
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"invalid request: max_tokens must be positive"
|
||||
));
|
||||
assert!(!super::is_retryable_400(
|
||||
reqwest::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"connection reset"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1763,7 +1763,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
||||
let request_id = request_id_from_headers(&headers).map(String::into_boxed_str);
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
let parsed_error = serde_json::from_str::<ErrorEnvelope>(&body).ok();
|
||||
let retryable = is_retryable_status(status);
|
||||
let retryable = is_retryable_status(status) || is_retryable_400(status, &body);
|
||||
let retry_after = parse_retry_after(&headers, status).map(Box::new);
|
||||
|
||||
let suggested_action = suggested_action_for_status(status);
|
||||
@@ -1799,7 +1799,10 @@ fn parse_retry_after(
|
||||
}
|
||||
|
||||
const fn is_retryable_status(status: reqwest::StatusCode) -> bool {
|
||||
matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504)
|
||||
matches!(
|
||||
status.as_u16(),
|
||||
408 | 409 | 429 | 500 | 502 | 503 | 504 | 529
|
||||
)
|
||||
}
|
||||
|
||||
/// Some providers return HTTP 400 with an unparseable body when a gateway
|
||||
@@ -1858,11 +1861,12 @@ impl StringExt for String {
|
||||
mod tests {
|
||||
use super::{
|
||||
build_chat_completion_request, chat_completions_endpoint, is_reasoning_model,
|
||||
model_requires_reasoning_content_in_history, normalize_finish_reason, normalize_response,
|
||||
openai_tool_choice, parse_tool_arguments, OpenAiCompatClient, OpenAiCompatConfig,
|
||||
StreamState,
|
||||
is_retryable_400, is_retryable_status, model_requires_reasoning_content_in_history,
|
||||
normalize_finish_reason, normalize_response, openai_tool_choice, parse_tool_arguments,
|
||||
OpenAiCompatClient, OpenAiCompatConfig, 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,
|
||||
@@ -1871,7 +1875,97 @@ mod tests {
|
||||
use serde_json::json;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::thread;
|
||||
|
||||
fn spawn_status_server(status: u16, body: &'static str) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
|
||||
let address = listener.local_addr().expect("local addr");
|
||||
thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().expect("accept connection");
|
||||
let mut buffer = [0_u8; 4096];
|
||||
let _ = stream.read(&mut buffer).expect("read request");
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status} STATUS\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
stream
|
||||
.write_all(response.as_bytes())
|
||||
.expect("write response");
|
||||
});
|
||||
format!("http://{address}/v1/messages")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expect_success_marks_529_overloaded_retryable() {
|
||||
let url = spawn_status_server(
|
||||
529,
|
||||
r#"{"error":{"type":"overloaded_error","message":"Overloaded"}}"#,
|
||||
);
|
||||
let response = reqwest::get(&url).await.expect("response");
|
||||
let error = super::expect_success(response)
|
||||
.await
|
||||
.expect_err("529 should error");
|
||||
assert!(error.is_retryable(), "529 overloaded must be retryable");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expect_success_marks_gateway_flake_400_retryable() {
|
||||
let url = spawn_status_server(400, "HTTP 400 from backend (no parseable body)");
|
||||
let response = reqwest::get(&url).await.expect("response");
|
||||
let error = super::expect_success(response)
|
||||
.await
|
||||
.expect_err("400 should error");
|
||||
assert!(
|
||||
error.is_retryable(),
|
||||
"gateway-flake 400 body must be retryable"
|
||||
);
|
||||
let url = spawn_status_server(400, "invalid request: max_tokens must be positive");
|
||||
let response = reqwest::get(&url).await.expect("response");
|
||||
let error = super::expect_success(response)
|
||||
.await
|
||||
.expect_err("400 should error");
|
||||
assert!(!error.is_retryable(), "a real 400 must not be retried");
|
||||
}
|
||||
#[test]
|
||||
fn retryable_statuses_are_detected() {
|
||||
assert!(is_retryable_status(reqwest::StatusCode::TOO_MANY_REQUESTS));
|
||||
assert!(is_retryable_status(
|
||||
reqwest::StatusCode::INTERNAL_SERVER_ERROR
|
||||
));
|
||||
assert!(!is_retryable_status(reqwest::StatusCode::UNAUTHORIZED));
|
||||
// Several OpenAI-compatible gateways (and Anthropic) signal overload
|
||||
// with the non-standard 529; it is transient and must be retried.
|
||||
assert!(is_retryable_status(
|
||||
reqwest::StatusCode::from_u16(529).expect("529 is a valid status")
|
||||
));
|
||||
assert!(!is_retryable_status(reqwest::StatusCode::BAD_REQUEST));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_flake_400_bodies_are_retryable() {
|
||||
for body in [
|
||||
"HTTP 400 from backend (no parseable body)",
|
||||
"upstream connection reset by peer",
|
||||
"Broken pipe while reading response",
|
||||
"Empty reply from server",
|
||||
] {
|
||||
assert!(
|
||||
is_retryable_400(reqwest::StatusCode::BAD_REQUEST, body),
|
||||
"body should be retryable: {body}"
|
||||
);
|
||||
}
|
||||
assert!(!is_retryable_400(
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"invalid request: max_tokens must be positive"
|
||||
));
|
||||
assert!(!is_retryable_400(
|
||||
reqwest::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"connection reset"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_translation_uses_openai_compatible_shape() {
|
||||
@@ -2371,13 +2465,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");
|
||||
|
||||
@@ -82,7 +82,7 @@ async fn send_message_posts_json_and_parses_response() {
|
||||
);
|
||||
assert_eq!(
|
||||
request.headers.get("user-agent").map(String::as_str),
|
||||
Some("claude-code/0.1.10")
|
||||
Some("claude-code/0.1.13")
|
||||
);
|
||||
assert_eq!(
|
||||
request.headers.get("anthropic-beta").map(String::as_str),
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
@@ -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"));
|
||||
@@ -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));
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
use std::ffi::OsStr;
|
||||
#[cfg(not(windows))]
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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)]
|
||||
{
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -2502,20 +2502,42 @@ mod tests {
|
||||
|
||||
fn write_lifecycle_plugin(root: &Path, name: &str, version: &str) -> PathBuf {
|
||||
let log_path = root.join("lifecycle.log");
|
||||
let extension = if cfg!(windows) { "cmd" } else { "sh" };
|
||||
let (init_script, shutdown_script) = if cfg!(windows) {
|
||||
(
|
||||
"@echo off\r\necho init>>lifecycle.log\r\n",
|
||||
"@echo off\r\necho shutdown>>lifecycle.log\r\n",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"#!/bin/sh\nprintf 'init\\n' >> lifecycle.log\n",
|
||||
"#!/bin/sh\nprintf 'shutdown\\n' >> lifecycle.log\n",
|
||||
)
|
||||
};
|
||||
write_file(
|
||||
root.join("lifecycle").join("init.sh").as_path(),
|
||||
"#!/bin/sh\nprintf 'init\\n' >> lifecycle.log\n",
|
||||
root.join("lifecycle")
|
||||
.join(format!("init.{extension}"))
|
||||
.as_path(),
|
||||
init_script,
|
||||
);
|
||||
write_file(
|
||||
root.join("lifecycle").join("shutdown.sh").as_path(),
|
||||
"#!/bin/sh\nprintf 'shutdown\\n' >> lifecycle.log\n",
|
||||
root.join("lifecycle")
|
||||
.join(format!("shutdown.{extension}"))
|
||||
.as_path(),
|
||||
shutdown_script,
|
||||
);
|
||||
write_file(
|
||||
root.join(MANIFEST_RELATIVE_PATH).as_path(),
|
||||
format!(
|
||||
"{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"lifecycle plugin\",\n \"lifecycle\": {{\n \"Init\": [\"./lifecycle/init.sh\"],\n \"Shutdown\": [\"./lifecycle/shutdown.sh\"]\n }}\n}}"
|
||||
)
|
||||
.as_str(),
|
||||
&serde_json::json!({
|
||||
"name": name,
|
||||
"version": version,
|
||||
"description": "lifecycle plugin",
|
||||
"lifecycle": {
|
||||
"Init": [format!("./lifecycle/init.{extension}")],
|
||||
"Shutdown": [format!("./lifecycle/shutdown.{extension}")]
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
log_path
|
||||
}
|
||||
@@ -2525,25 +2547,28 @@ mod tests {
|
||||
}
|
||||
|
||||
fn write_tool_plugin_with_name(root: &Path, name: &str, version: &str, tool_name: &str) {
|
||||
let script_path = root.join("tools").join("echo-json.sh");
|
||||
let script_path = root.join("tools").join("echo-json.py");
|
||||
write_file(
|
||||
&script_path,
|
||||
"#!/bin/sh\nINPUT=$(cat)\nprintf '{\"plugin\":\"%s\",\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_PLUGIN_ID\" \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n",
|
||||
r#"import json, os, sys
|
||||
print(json.dumps({"plugin": os.environ["CLAWD_PLUGIN_ID"], "tool": os.environ["CLAWD_TOOL_NAME"], "input": json.load(sys.stdin)}))
|
||||
"#,
|
||||
);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let mut permissions = fs::metadata(&script_path).expect("metadata").permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, permissions).expect("chmod");
|
||||
}
|
||||
write_file(
|
||||
root.join(MANIFEST_RELATIVE_PATH).as_path(),
|
||||
format!(
|
||||
"{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"tool plugin\",\n \"tools\": [\n {{\n \"name\": \"{tool_name}\",\n \"description\": \"Echo JSON input\",\n \"inputSchema\": {{\"type\": \"object\", \"properties\": {{\"message\": {{\"type\": \"string\"}}}}, \"required\": [\"message\"], \"additionalProperties\": false}},\n \"command\": \"./tools/echo-json.sh\",\n \"requiredPermission\": \"workspace-write\"\n }}\n ]\n}}"
|
||||
)
|
||||
.as_str(),
|
||||
&serde_json::json!({
|
||||
"name": name,
|
||||
"version": version,
|
||||
"description": "tool plugin",
|
||||
"tools": [{
|
||||
"name": tool_name,
|
||||
"description": "Echo JSON input",
|
||||
"inputSchema": {"type": "object", "properties": {"message": {"type": "string"}}, "required": ["message"], "additionalProperties": false},
|
||||
"command": if cfg!(windows) { "python" } else { "python3" },
|
||||
"args": ["tools/echo-json.py"],
|
||||
"requiredPermission": "workspace-write"
|
||||
}]
|
||||
}).to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3623,7 +3648,7 @@ mod tests {
|
||||
registry.shutdown().expect("shutdown should succeed");
|
||||
|
||||
let log = fs::read_to_string(&log_path).expect("lifecycle log should exist");
|
||||
assert_eq!(log, "init\nshutdown\n");
|
||||
assert_eq!(log.replace("\r\n", "\n"), "init\nshutdown\n");
|
||||
|
||||
let _ = fs::remove_dir_all(config_home);
|
||||
let _ = fs::remove_dir_all(source_root);
|
||||
@@ -3820,7 +3845,7 @@ mod tests {
|
||||
if registry.initialize().is_ok() && registry.shutdown().is_ok() {
|
||||
// Verify lifecycle.log exists and has expected content
|
||||
if let Ok(log) = fs::read_to_string(&log_path) {
|
||||
if log == "init\nshutdown\n" {
|
||||
if log.replace("\r\n", "\n") == "init\nshutdown\n" {
|
||||
success_count.fetch_add(1, AtomicOrdering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,13 @@ publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
sha2 = "0.10"
|
||||
getrandom = "0.2"
|
||||
glob = "0.3"
|
||||
plugins = { path = "../plugins" }
|
||||
regex = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
same-file = "1.0.6"
|
||||
telemetry = { path = "../telemetry" }
|
||||
tokio = { version = "1", features = ["io-std", "io-util", "macros", "process", "rt", "rt-multi-thread", "time"] }
|
||||
walkdir = "2"
|
||||
|
||||
@@ -293,6 +293,41 @@ fn sandbox_status_for_input(input: &BashCommandInput, cwd: &std::path::Path) ->
|
||||
resolve_sandbox_status_for_request(&request, cwd)
|
||||
}
|
||||
|
||||
// Git for Windows normally adds Git/cmd, but not Git/bin, to PATH.
|
||||
// Prefer a native shell there rather than the Windows WSL bash launcher.
|
||||
fn posix_shell() -> std::path::PathBuf {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(path) = env::var_os("PATH") {
|
||||
for directory in env::split_paths(&path) {
|
||||
let shell = directory.join("sh.exe");
|
||||
if shell.is_file() {
|
||||
return shell;
|
||||
}
|
||||
if directory.join("git.exe").is_file() {
|
||||
if let Some(root) = directory.parent() {
|
||||
let shell = root.join("bin").join("sh.exe");
|
||||
if shell.is_file() {
|
||||
return shell;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for variable in ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"] {
|
||||
if let Some(root) = env::var_os(variable) {
|
||||
for relative in ["Git/bin/sh.exe", "Programs/Git/bin/sh.exe"] {
|
||||
let shell = std::path::PathBuf::from(&root).join(relative);
|
||||
if shell.is_file() {
|
||||
return shell;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
std::path::PathBuf::from("sh")
|
||||
}
|
||||
|
||||
fn prepare_command(
|
||||
command: &str,
|
||||
cwd: &std::path::Path,
|
||||
@@ -311,7 +346,7 @@ fn prepare_command(
|
||||
return prepared;
|
||||
}
|
||||
|
||||
let mut prepared = Command::new("sh");
|
||||
let mut prepared = Command::new(posix_shell());
|
||||
prepared.arg("-lc").arg(command).current_dir(cwd);
|
||||
if sandbox_status.filesystem_active {
|
||||
prepared.env("HOME", cwd.join(".sandbox-home"));
|
||||
@@ -337,7 +372,7 @@ fn prepare_tokio_command(
|
||||
cmd.envs(launcher.env);
|
||||
cmd
|
||||
} else {
|
||||
let mut cmd = TokioCommand::new("sh");
|
||||
let mut cmd = TokioCommand::new(posix_shell());
|
||||
cmd.arg("-lc").arg(command);
|
||||
if sandbox_status.filesystem_active {
|
||||
cmd.env("HOME", cwd.join(".sandbox-home"));
|
||||
|
||||
@@ -2817,12 +2817,12 @@ mod tests {
|
||||
let command = format!("echo compromised >> {}", marker.display());
|
||||
fs::write(
|
||||
&project_legacy_settings,
|
||||
format!(r#"{{"hooks":{{"PreToolUse":["{command}"]}}}}"#),
|
||||
serde_json::json!({"hooks": {"PreToolUse": [command]}}).to_string(),
|
||||
)
|
||||
.expect("write untrusted legacy project hook");
|
||||
fs::write(
|
||||
&project_settings,
|
||||
format!(r#"{{"hooks":{{"PreToolUse":["{command}"]}}}}"#),
|
||||
serde_json::json!({"hooks": {"PreToolUse": [command]}}).to_string(),
|
||||
)
|
||||
.expect("write untrusted project hook");
|
||||
|
||||
|
||||
@@ -1705,9 +1705,9 @@ mod tests {
|
||||
));
|
||||
let hook_command = format!("echo compromised >> \"{}\"", marker.display());
|
||||
let features = RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
|
||||
vec![shell_snippet(&hook_command)],
|
||||
vec![shell_snippet(&hook_command)],
|
||||
vec![shell_snippet(&hook_command)],
|
||||
vec![hook_command.clone()],
|
||||
vec![hook_command.clone()],
|
||||
vec![hook_command.clone()],
|
||||
));
|
||||
let mut runtime = ConversationRuntime::new_with_features(
|
||||
Session::new(),
|
||||
@@ -1858,7 +1858,7 @@ mod tests {
|
||||
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
||||
vec!["system".to_string()],
|
||||
&RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
|
||||
vec![shell_snippet("printf 'blocked by hook'; exit 2")],
|
||||
vec![crate::test_hook("blocked by hook", "", 2, 0)],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
)),
|
||||
@@ -1921,7 +1921,7 @@ mod tests {
|
||||
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
||||
vec!["system".to_string()],
|
||||
&RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
|
||||
vec![shell_snippet("printf 'broken hook'; exit 1")],
|
||||
vec![crate::test_hook("broken hook", "", 1, 0)],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
)),
|
||||
@@ -1990,8 +1990,8 @@ mod tests {
|
||||
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
||||
vec!["system".to_string()],
|
||||
&RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
|
||||
vec![shell_snippet("printf 'pre hook ran'")],
|
||||
vec![shell_snippet("printf 'post hook ran'")],
|
||||
vec![crate::test_hook("pre hook ran", "", 0, 0)],
|
||||
vec![crate::test_hook("post hook ran", "", 0, 0)],
|
||||
Vec::new(),
|
||||
)),
|
||||
);
|
||||
@@ -2068,8 +2068,8 @@ mod tests {
|
||||
vec!["system".to_string()],
|
||||
&RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
|
||||
Vec::new(),
|
||||
vec![shell_snippet("printf 'post hook should not run'")],
|
||||
vec![shell_snippet("printf 'failure hook ran'")],
|
||||
vec![crate::test_hook("post hook should not run", "", 0, 0)],
|
||||
vec![crate::test_hook("failure hook ran", "", 0, 0)],
|
||||
)),
|
||||
);
|
||||
|
||||
@@ -2263,16 +2263,6 @@ mod tests {
|
||||
std::env::temp_dir().join(format!("runtime-conversation-{label}-{nanos}.json"))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn shell_snippet(script: &str) -> String {
|
||||
script.replace('\'', "\"")
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn shell_snippet(script: &str) -> String {
|
||||
script.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_compacts_when_cumulative_input_threshold_is_crossed() {
|
||||
struct SimpleApi;
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::cmp::Reverse;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
use glob::Pattern;
|
||||
@@ -44,7 +44,7 @@ fn is_binary_file(path: &Path) -> io::Result<bool> {
|
||||
fn validate_workspace_boundary(resolved: &Path, workspace_root: &Path) -> io::Result<()> {
|
||||
let workspace_root = normalize_for_comparison(workspace_root);
|
||||
let comparable = normalize_for_comparison(resolved);
|
||||
if comparable.starts_with(&workspace_root) || resolves_within(resolved, &workspace_root) {
|
||||
if resolves_within(resolved, &workspace_root) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(io::Error::new(
|
||||
@@ -61,13 +61,87 @@ fn validate_workspace_boundary(resolved: &Path, workspace_root: &Path) -> io::Re
|
||||
/// filesystem resolves it. Windows accepts several spellings of one location
|
||||
/// that [`Path::starts_with`] treats as different: letter case (a working
|
||||
/// directory typed as `c:\users\...` for `C:\Users\...`), the `\\.\` device
|
||||
/// namespace, and 8.3 short names. Only consulted after the lexical comparison
|
||||
/// fails, so it can accept such a spelling but never widens what already
|
||||
/// compared inside, and a path whose canonical form is outside stays rejected.
|
||||
/// namespace, and 8.3 short names. Existing ancestors are canonicalized so
|
||||
/// equivalent names are accepted while symlink and parent traversal escapes
|
||||
/// remain outside the workspace.
|
||||
fn resolves_within(resolved: &Path, workspace_root: &Path) -> bool {
|
||||
resolved
|
||||
.canonicalize()
|
||||
.is_ok_and(|canonical| normalize_for_comparison(&canonical).starts_with(workspace_root))
|
||||
if let Ok(canonical) = resolved.canonicalize() {
|
||||
if normalize_for_comparison(&canonical).starts_with(workspace_root) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Writes can target a path whose parent directories do not exist yet, so
|
||||
// canonicalizing the whole path may not be possible. Resolve the nearest
|
||||
// existing ancestor (including symlinks), then check its canonical path
|
||||
// and file identity before applying only the missing suffix.
|
||||
let mut existing_ancestor = resolved;
|
||||
loop {
|
||||
match fs::symlink_metadata(existing_ancestor) {
|
||||
Ok(_) => break,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||||
let Some(parent) = existing_ancestor.parent() else {
|
||||
return false;
|
||||
};
|
||||
existing_ancestor = parent;
|
||||
}
|
||||
Err(_) => return false,
|
||||
}
|
||||
}
|
||||
|
||||
let Ok(canonical_ancestor) = existing_ancestor.canonicalize() else {
|
||||
// In particular, do not treat a dangling symlink as a missing path.
|
||||
return false;
|
||||
};
|
||||
#[cfg(windows)]
|
||||
if has_workspace_ancestor(&canonical_ancestor, workspace_root) {
|
||||
return resolved
|
||||
.strip_prefix(existing_ancestor)
|
||||
.is_ok_and(|suffix| {
|
||||
suffix.components().all(|component| {
|
||||
!matches!(
|
||||
component,
|
||||
Component::ParentDir | Component::Prefix(_) | Component::RootDir
|
||||
)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
let Ok(missing_suffix) = resolved.strip_prefix(existing_ancestor) else {
|
||||
return false;
|
||||
};
|
||||
let mut canonical_path = normalize_for_comparison(&canonical_ancestor);
|
||||
for component in missing_suffix.components() {
|
||||
match component {
|
||||
Component::CurDir => {}
|
||||
// A parent component after a missing component could later
|
||||
// traverse a symlink once the missing directory is created.
|
||||
Component::ParentDir => return false,
|
||||
Component::Normal(name) => canonical_path.push(name),
|
||||
Component::Prefix(_) | Component::RootDir => return false,
|
||||
}
|
||||
}
|
||||
|
||||
canonical_path.starts_with(workspace_root)
|
||||
}
|
||||
|
||||
/// Compare Windows directory identities when canonical paths use different
|
||||
/// long/8.3 spellings. Path strings alone cannot establish this relationship.
|
||||
#[cfg(windows)]
|
||||
fn has_workspace_ancestor(path: &Path, workspace_root: &Path) -> bool {
|
||||
let mut ancestor = path;
|
||||
loop {
|
||||
if same_file::is_same_file(ancestor, workspace_root).unwrap_or(false) {
|
||||
return true;
|
||||
}
|
||||
let Some(parent) = ancestor.parent() else {
|
||||
return false;
|
||||
};
|
||||
if parent == ancestor {
|
||||
return false;
|
||||
}
|
||||
ancestor = parent;
|
||||
}
|
||||
}
|
||||
|
||||
/// Text payload returned by file-reading operations.
|
||||
@@ -649,6 +723,11 @@ fn glob_search_impl(
|
||||
base_dir.join(pattern).to_string_lossy().into_owned()
|
||||
};
|
||||
|
||||
#[cfg(windows)]
|
||||
let search_pattern = normalize_for_comparison(Path::new(&search_pattern))
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
|
||||
// The `glob` crate does not support brace expansion ({a,b,c}).
|
||||
// Expand braces into multiple patterns so patterns like
|
||||
// `Assets/**/*.{cs,uxml,uss}` work correctly.
|
||||
@@ -1123,14 +1202,15 @@ fn expand_braces(pattern: &str) -> Vec<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[cfg(any(unix, windows))]
|
||||
use super::write_file_in_workspace;
|
||||
use super::{
|
||||
component_contains_glob, derive_glob_walk_root, edit_file, expand_braces, glob_search,
|
||||
grep_search, is_symlink_escape, normalize_for_comparison, read_file,
|
||||
read_file_in_workspace, write_file, write_file_in_workspace, GrepSearchInput,
|
||||
MAX_WRITE_SIZE,
|
||||
read_file_in_workspace, write_file, GrepSearchInput, MAX_WRITE_SIZE,
|
||||
};
|
||||
|
||||
fn temp_path(name: &str) -> std::path::PathBuf {
|
||||
@@ -1217,9 +1297,9 @@ mod tests {
|
||||
let outside = temp_path("symlink-target.txt");
|
||||
std::fs::write(&outside, "target content").expect("target should write");
|
||||
|
||||
let link_path = workspace.join("escape-link.txt");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let link_path = workspace.join("escape-link.txt");
|
||||
std::os::unix::fs::symlink(&outside, &link_path).expect("symlink should create");
|
||||
assert!(is_symlink_escape(&link_path, &workspace).expect("check should succeed"));
|
||||
}
|
||||
@@ -1338,6 +1418,69 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&workspace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn writes_inside_workspace_through_short_path_with_missing_nested_directories() {
|
||||
let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") else {
|
||||
return;
|
||||
};
|
||||
let long_temp = PathBuf::from(local_app_data).join("Temp");
|
||||
let short_temp = std::env::temp_dir();
|
||||
if !long_temp.is_dir()
|
||||
|| !short_temp.is_dir()
|
||||
|| long_temp == short_temp
|
||||
|| !same_file::is_same_file(&long_temp, &short_temp).unwrap_or(false)
|
||||
{
|
||||
// Some Windows hosts disable short-name generation or configure
|
||||
// TEMP outside the user's local application data directory.
|
||||
return;
|
||||
}
|
||||
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("time should move forward")
|
||||
.as_nanos();
|
||||
let workspace = long_temp.join(format!("clawd-native-short-path-{unique}"));
|
||||
std::fs::create_dir_all(&workspace).expect("workspace dir should be created");
|
||||
let root = workspace
|
||||
.canonicalize()
|
||||
.expect("workspace should canonicalize");
|
||||
let short_workspace = short_temp.join(
|
||||
workspace
|
||||
.file_name()
|
||||
.expect("workspace should have a file name"),
|
||||
);
|
||||
let target = short_workspace.join("nested").join("demo.txt");
|
||||
|
||||
write_file_in_workspace(&target.to_string_lossy(), "safe content", &root)
|
||||
.expect("an equivalent short path inside the workspace should be accepted");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(workspace.join("nested/demo.txt"))
|
||||
.expect("written file should be readable"),
|
||||
"safe content"
|
||||
);
|
||||
|
||||
let update = write_file_in_workspace(&target.to_string_lossy(), "updated content", &root)
|
||||
.expect("an existing file through an equivalent short path should be accepted");
|
||||
assert_eq!(update.kind, "update");
|
||||
assert_eq!(update.original_file.as_deref(), Some("safe content"));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(workspace.join("nested/demo.txt"))
|
||||
.expect("updated file should be readable"),
|
||||
"updated content"
|
||||
);
|
||||
|
||||
let escaping_target = short_workspace
|
||||
.join("not-created")
|
||||
.join("..")
|
||||
.join("..")
|
||||
.join("escape.txt");
|
||||
write_file_in_workspace(&escaping_target.to_string_lossy(), "must not escape", &root)
|
||||
.expect_err("parent traversal from a missing path must stay outside the boundary");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&workspace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn workspace_write_rejects_parent_symlink_escape_regression_3007_class() {
|
||||
@@ -1368,6 +1511,21 @@ mod tests {
|
||||
"write should not create through an escaping symlink"
|
||||
);
|
||||
|
||||
let escaped_nested_child = link_dir.join("not-created").join("nested.txt");
|
||||
let result = write_file_in_workspace(
|
||||
escaped_nested_child.to_string_lossy().as_ref(),
|
||||
"must not escape",
|
||||
&workspace,
|
||||
);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"a missing descendant under an escaping symlink must be rejected"
|
||||
);
|
||||
assert!(
|
||||
!outside.join("not-created").exists(),
|
||||
"write should not create missing directories through an escaping symlink"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&workspace);
|
||||
let _ = std::fs::remove_dir_all(&outside);
|
||||
}
|
||||
@@ -1476,11 +1634,11 @@ mod tests {
|
||||
assert!(result
|
||||
.filenames
|
||||
.iter()
|
||||
.any(|path| path.ends_with("src/AGENTS.md")));
|
||||
.any(|path| Path::new(path).ends_with("src/AGENTS.md")));
|
||||
assert!(result
|
||||
.filenames
|
||||
.iter()
|
||||
.any(|path| path.ends_with("docs/AGENTS.md")));
|
||||
.any(|path| Path::new(path).ends_with("docs/AGENTS.md")));
|
||||
assert!(!result
|
||||
.filenames
|
||||
.iter()
|
||||
|
||||
@@ -742,8 +742,13 @@ fn format_hook_failure(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 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)
|
||||
};
|
||||
|
||||
@@ -845,7 +850,7 @@ mod tests {
|
||||
#[test]
|
||||
fn allows_exit_code_zero_and_captures_stdout() {
|
||||
let runner = HookRunner::new(RuntimeHookConfig::new(
|
||||
vec![shell_snippet("printf 'pre ok'")],
|
||||
vec![crate::test_hook("pre ok", "", 0, 0)],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
));
|
||||
@@ -859,13 +864,13 @@ mod tests {
|
||||
fn object_style_hook_matchers_filter_runtime_execution() {
|
||||
let runner = HookRunner::new(RuntimeHookConfig::from_hook_commands(
|
||||
vec![
|
||||
RuntimeHookCommand::new(shell_snippet("printf 'legacy'")),
|
||||
RuntimeHookCommand::new(crate::test_hook("legacy", "", 0, 0)),
|
||||
RuntimeHookCommand::with_matcher(
|
||||
shell_snippet("printf 'bash only'"),
|
||||
crate::test_hook("bash only", "", 0, 0),
|
||||
Some("Bash".to_string()),
|
||||
),
|
||||
RuntimeHookCommand::with_matcher(
|
||||
shell_snippet("printf 'read only'"),
|
||||
crate::test_hook("read only", "", 0, 0),
|
||||
Some("Read*".to_string()),
|
||||
),
|
||||
],
|
||||
@@ -889,7 +894,7 @@ mod tests {
|
||||
#[test]
|
||||
fn denies_exit_code_two() {
|
||||
let runner = HookRunner::new(RuntimeHookConfig::new(
|
||||
vec![shell_snippet("printf 'blocked by hook'; exit 2")],
|
||||
vec![crate::test_hook("blocked by hook", "", 2, 0)],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
));
|
||||
@@ -904,7 +909,7 @@ mod tests {
|
||||
fn propagates_other_non_zero_statuses_as_failures() {
|
||||
let runner = HookRunner::from_feature_config(&RuntimeFeatureConfig::default().with_hooks(
|
||||
RuntimeHookConfig::new(
|
||||
vec![shell_snippet("printf 'warning hook'; exit 1")],
|
||||
vec![crate::test_hook("warning hook", "", 1, 0)],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
),
|
||||
@@ -925,8 +930,11 @@ mod tests {
|
||||
#[test]
|
||||
fn parses_pre_hook_permission_override_and_updated_input() {
|
||||
let runner = HookRunner::new(RuntimeHookConfig::new(
|
||||
vec![shell_snippet(
|
||||
r#"printf '%s' '{"systemMessage":"updated","hookSpecificOutput":{"permissionDecision":"allow","permissionDecisionReason":"hook ok","updatedInput":{"command":"git status"}}}'"#,
|
||||
vec![crate::test_hook(
|
||||
r#"{"systemMessage":"updated","hookSpecificOutput":{"permissionDecision":"allow","permissionDecisionReason":"hook ok","updatedInput":{"command":"git status"}}}"#,
|
||||
"",
|
||||
0,
|
||||
0,
|
||||
)],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
@@ -949,7 +957,7 @@ mod tests {
|
||||
let runner = HookRunner::new(RuntimeHookConfig::new(
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
vec![shell_snippet("printf 'failure hook ran'")],
|
||||
vec![crate::test_hook("failure hook ran", "", 0, 0)],
|
||||
));
|
||||
|
||||
// when
|
||||
@@ -968,8 +976,8 @@ mod tests {
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
vec![
|
||||
shell_snippet("printf 'broken failure hook'; exit 1"),
|
||||
shell_snippet("printf 'later failure hook'"),
|
||||
crate::test_hook("broken failure hook", "", 1, 0),
|
||||
crate::test_hook("later failure hook", "", 0, 0),
|
||||
],
|
||||
));
|
||||
|
||||
@@ -994,8 +1002,8 @@ mod tests {
|
||||
// given
|
||||
let runner = HookRunner::new(RuntimeHookConfig::new(
|
||||
vec![
|
||||
shell_snippet("printf 'first'"),
|
||||
shell_snippet("printf 'second'"),
|
||||
crate::test_hook("first", "", 0, 0),
|
||||
crate::test_hook("second", "", 0, 0),
|
||||
],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
@@ -1022,7 +1030,7 @@ mod tests {
|
||||
event: HookEvent::PreToolUse,
|
||||
command,
|
||||
..
|
||||
} if command == "printf 'first'"
|
||||
} if command == &crate::test_hook("first", "", 0, 0)
|
||||
));
|
||||
assert!(matches!(
|
||||
&reporter.events[1],
|
||||
@@ -1030,7 +1038,7 @@ mod tests {
|
||||
event: HookEvent::PreToolUse,
|
||||
command,
|
||||
..
|
||||
} if command == "printf 'first'"
|
||||
} if command == &crate::test_hook("first", "", 0, 0)
|
||||
));
|
||||
assert!(matches!(
|
||||
&reporter.events[2],
|
||||
@@ -1038,7 +1046,7 @@ mod tests {
|
||||
event: HookEvent::PreToolUse,
|
||||
command,
|
||||
..
|
||||
} if command == "printf 'second'"
|
||||
} if command == &crate::test_hook("second", "", 0, 0)
|
||||
));
|
||||
assert!(matches!(
|
||||
&reporter.events[3],
|
||||
@@ -1046,7 +1054,7 @@ mod tests {
|
||||
event: HookEvent::PreToolUse,
|
||||
command,
|
||||
..
|
||||
} if command == "printf 'second'"
|
||||
} if command == &crate::test_hook("second", "", 0, 0)
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1055,8 +1063,8 @@ mod tests {
|
||||
// given
|
||||
let runner = HookRunner::new(RuntimeHookConfig::new(
|
||||
vec![
|
||||
shell_snippet("printf 'broken'; exit 1"),
|
||||
shell_snippet("printf 'later'"),
|
||||
crate::test_hook("broken", "", 1, 0),
|
||||
crate::test_hook("later", "", 0, 0),
|
||||
],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
@@ -1077,8 +1085,11 @@ mod tests {
|
||||
#[test]
|
||||
fn malformed_nonempty_hook_output_reports_explicit_diagnostic_with_previews() {
|
||||
let runner = HookRunner::new(RuntimeHookConfig::new(
|
||||
vec![shell_snippet(
|
||||
"printf '{not-json\nsecond line'; printf 'stderr warning' >&2; exit 1",
|
||||
vec![crate::test_hook(
|
||||
"{not-json\nsecond line",
|
||||
"stderr warning",
|
||||
1,
|
||||
0,
|
||||
)],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
@@ -1091,8 +1102,16 @@ mod tests {
|
||||
assert!(rendered.contains("hook_invalid_json:"));
|
||||
assert!(rendered.contains("phase=PreToolUse"));
|
||||
assert!(rendered.contains("tool=Edit"));
|
||||
assert!(rendered.contains("command=printf '{not-json"));
|
||||
assert!(rendered.contains("printf 'stderr warning' >&2; exit 1"));
|
||||
assert!(rendered.contains(&format!(
|
||||
"command={}",
|
||||
super::bounded_hook_preview(&crate::test_hook(
|
||||
"{not-json\nsecond line",
|
||||
"stderr warning",
|
||||
1,
|
||||
0
|
||||
))
|
||||
.expect("command preview")
|
||||
)));
|
||||
assert!(rendered.contains("detail=key must be a string"));
|
||||
assert!(rendered.contains("stdout_preview={not-json"));
|
||||
assert!(rendered.contains("second line stderr_preview=stderr warning"));
|
||||
@@ -1102,7 +1121,7 @@ mod tests {
|
||||
#[test]
|
||||
fn abort_signal_cancels_long_running_hook_and_reports_progress() {
|
||||
let runner = HookRunner::new(RuntimeHookConfig::new(
|
||||
vec![shell_snippet("sleep 5")],
|
||||
vec![crate::test_hook("", "", 0, 5)],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
));
|
||||
@@ -1138,14 +1157,4 @@ mod tests {
|
||||
}
|
||||
)));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn shell_snippet(script: &str) -> String {
|
||||
script.replace('\'', "\"")
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn shell_snippet(script: &str) -> String {
|
||||
script.to_string()
|
||||
}
|
||||
}
|
||||
@@ -214,3 +214,17 @@ pub(crate) fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_hook(stdout: &str, stderr: &str, exit_code: i32, delay: u32) -> String {
|
||||
let python = if cfg!(windows) { "python" } else { "python3" };
|
||||
let encode = |text: &str| {
|
||||
text.bytes()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>()
|
||||
};
|
||||
format!(
|
||||
"{python} -c \"import sys,time; time.sleep({delay}); sys.stdout.write(bytes.fromhex('{}').decode()); sys.stderr.write(bytes.fromhex('{}').decode()); sys.exit({exit_code})\"",
|
||||
encode(stdout), encode(stderr)
|
||||
)
|
||||
}
|
||||
@@ -1443,7 +1443,6 @@ mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io::ErrorKind;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
@@ -1466,6 +1465,9 @@ mod tests {
|
||||
};
|
||||
use crate::McpLifecyclePhase;
|
||||
|
||||
// Fixtures are interpreter inputs, not directly executable Unix scripts.
|
||||
const TEST_PYTHON: &str = if cfg!(windows) { "python" } else { "python3" };
|
||||
|
||||
fn temp_dir() -> PathBuf {
|
||||
static NEXT_TEMP_DIR_ID: AtomicU64 = AtomicU64::new(0);
|
||||
let nanos = SystemTime::now()
|
||||
@@ -1479,15 +1481,19 @@ mod tests {
|
||||
fn write_echo_script() -> PathBuf {
|
||||
let root = temp_dir();
|
||||
fs::create_dir_all(&root).expect("temp dir");
|
||||
let script_path = root.join("echo-mcp.sh");
|
||||
fs::write(
|
||||
&script_path,
|
||||
"#!/bin/sh\nprintf 'READY:%s\\n' \"$MCP_TEST_TOKEN\"\nIFS= read -r line\nprintf 'ECHO:%s\\n' \"$line\"\n",
|
||||
)
|
||||
.expect("write script");
|
||||
let mut permissions = fs::metadata(&script_path).expect("metadata").permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, permissions).expect("chmod");
|
||||
let script_path = root.join("echo-mcp.py");
|
||||
let script = [
|
||||
"import os, sys",
|
||||
r"ready = ('READY:' + os.environ['MCP_TEST_TOKEN'] + '\n').encode()",
|
||||
"sys.stdout.buffer.write(ready)",
|
||||
"sys.stdout.buffer.flush()",
|
||||
"line = sys.stdin.buffer.readline()",
|
||||
r"sys.stdout.buffer.write(b'ECHO:' + line.rstrip(b'\r\n') + b'\n')",
|
||||
"sys.stdout.buffer.flush()",
|
||||
"",
|
||||
]
|
||||
.join("\n");
|
||||
fs::write(&script_path, script).expect("write script");
|
||||
script_path
|
||||
}
|
||||
|
||||
@@ -1531,9 +1537,6 @@ mod tests {
|
||||
]
|
||||
.join("\n");
|
||||
fs::write(&script_path, script).expect("write script");
|
||||
let mut permissions = fs::metadata(&script_path).expect("metadata").permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, permissions).expect("chmod");
|
||||
script_path
|
||||
}
|
||||
|
||||
@@ -1665,9 +1668,6 @@ mod tests {
|
||||
]
|
||||
.join("\n");
|
||||
fs::write(&script_path, script).expect("write script");
|
||||
let mut permissions = fs::metadata(&script_path).expect("metadata").permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, permissions).expect("chmod");
|
||||
script_path
|
||||
}
|
||||
|
||||
@@ -1790,9 +1790,6 @@ mod tests {
|
||||
]
|
||||
.join("\n");
|
||||
fs::write(&script_path, script).expect("write script");
|
||||
let mut permissions = fs::metadata(&script_path).expect("metadata").permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, permissions).expect("chmod");
|
||||
script_path
|
||||
}
|
||||
|
||||
@@ -1801,7 +1798,7 @@ mod tests {
|
||||
required: false,
|
||||
scope: ConfigSource::Local,
|
||||
config: McpServerConfig::Stdio(McpStdioServerConfig {
|
||||
command: "/bin/sh".to_string(),
|
||||
command: TEST_PYTHON.to_string(),
|
||||
args: vec![script_path.to_string_lossy().into_owned()],
|
||||
env: BTreeMap::from([("MCP_TEST_TOKEN".to_string(), "secret-value".to_string())]),
|
||||
tool_call_timeout_ms: None,
|
||||
@@ -1819,7 +1816,7 @@ mod tests {
|
||||
env: BTreeMap<String, String>,
|
||||
) -> crate::mcp_client::McpStdioTransport {
|
||||
crate::mcp_client::McpStdioTransport {
|
||||
command: "python3".to_string(),
|
||||
command: TEST_PYTHON.to_string(),
|
||||
args: vec![script_path.to_string_lossy().into_owned()],
|
||||
env,
|
||||
tool_call_timeout_ms: None,
|
||||
@@ -1869,7 +1866,7 @@ mod tests {
|
||||
required: false,
|
||||
scope: ConfigSource::Local,
|
||||
config: McpServerConfig::Stdio(McpStdioServerConfig {
|
||||
command: "python3".to_string(),
|
||||
command: TEST_PYTHON.to_string(),
|
||||
args: vec![script_path.to_string_lossy().into_owned()],
|
||||
env,
|
||||
tool_call_timeout_ms: None,
|
||||
@@ -2089,7 +2086,7 @@ mod tests {
|
||||
runtime.block_on(async {
|
||||
let script_path = write_echo_script();
|
||||
let transport = crate::mcp_client::McpStdioTransport {
|
||||
command: "/bin/sh".to_string(),
|
||||
command: TEST_PYTHON.to_string(),
|
||||
args: vec![script_path.to_string_lossy().into_owned()],
|
||||
env: BTreeMap::from([("MCP_TEST_TOKEN".to_string(), "direct-secret".to_string())]),
|
||||
tool_call_timeout_ms: None,
|
||||
@@ -2349,7 +2346,7 @@ mod tests {
|
||||
required: false,
|
||||
scope: ConfigSource::Local,
|
||||
config: McpServerConfig::Stdio(McpStdioServerConfig {
|
||||
command: "python3".to_string(),
|
||||
command: TEST_PYTHON.to_string(),
|
||||
args: vec![script_path.to_string_lossy().into_owned()],
|
||||
env: BTreeMap::from([(
|
||||
"MCP_TOOL_CALL_DELAY_MS".to_string(),
|
||||
@@ -2403,7 +2400,7 @@ mod tests {
|
||||
required: false,
|
||||
scope: ConfigSource::Local,
|
||||
config: McpServerConfig::Stdio(McpStdioServerConfig {
|
||||
command: "python3".to_string(),
|
||||
command: TEST_PYTHON.to_string(),
|
||||
args: vec![script_path.to_string_lossy().into_owned()],
|
||||
env: BTreeMap::from([(
|
||||
"MCP_INVALID_TOOL_CALL_RESPONSE".to_string(),
|
||||
@@ -2714,9 +2711,6 @@ mod tests {
|
||||
]
|
||||
.join("\n");
|
||||
fs::write(&script_path, script).expect("write script");
|
||||
let mut permissions = fs::metadata(&script_path).expect("metadata").permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, permissions).expect("chmod");
|
||||
script_path
|
||||
}
|
||||
|
||||
@@ -2742,8 +2736,8 @@ mod tests {
|
||||
required: true,
|
||||
scope: ConfigSource::Local,
|
||||
config: McpServerConfig::Stdio(McpStdioServerConfig {
|
||||
command: broken_script_path.display().to_string(),
|
||||
args: Vec::new(),
|
||||
command: TEST_PYTHON.to_string(),
|
||||
args: vec![broken_script_path.to_string_lossy().into_owned()],
|
||||
env: BTreeMap::new(),
|
||||
tool_call_timeout_ms: None,
|
||||
}),
|
||||
|
||||
@@ -314,7 +314,6 @@ impl McpToolRegistry {
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
@@ -324,6 +323,9 @@ mod tests {
|
||||
ConfigSource, McpServerConfig, McpStdioServerConfig, ScopedMcpServerConfig,
|
||||
};
|
||||
|
||||
// Fixtures are interpreter inputs, not directly executable Unix scripts.
|
||||
const TEST_PYTHON: &str = if cfg!(windows) { "python" } else { "python3" };
|
||||
|
||||
fn temp_dir() -> PathBuf {
|
||||
static NEXT_TEMP_DIR_ID: AtomicU64 = AtomicU64::new(0);
|
||||
let nanos = SystemTime::now()
|
||||
@@ -430,9 +432,6 @@ mod tests {
|
||||
]
|
||||
.join("\n");
|
||||
fs::write(&script_path, script).expect("write script");
|
||||
let mut permissions = fs::metadata(&script_path).expect("metadata").permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, permissions).expect("chmod");
|
||||
script_path
|
||||
}
|
||||
|
||||
@@ -445,7 +444,7 @@ mod tests {
|
||||
required: false,
|
||||
scope: ConfigSource::Local,
|
||||
config: McpServerConfig::Stdio(McpStdioServerConfig {
|
||||
command: "python3".to_string(),
|
||||
command: TEST_PYTHON.to_string(),
|
||||
args: vec![script_path.to_string_lossy().into_owned()],
|
||||
env: BTreeMap::from([
|
||||
("MCP_SERVER_LABEL".to_string(), server_name.to_string()),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, Read};
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -326,7 +326,7 @@ pub fn parse_oauth_callback_query(query: &str) -> Result<OAuthCallbackParams, St
|
||||
|
||||
fn generate_random_token(bytes: usize) -> io::Result<String> {
|
||||
let mut buffer = vec![0_u8; bytes];
|
||||
File::open("/dev/urandom")?.read_exact(&mut buffer)?;
|
||||
getrandom::getrandom(&mut buffer).map_err(|error| io::Error::other(error.to_string()))?;
|
||||
Ok(base64url_encode(&buffer))
|
||||
}
|
||||
|
||||
|
||||
@@ -262,6 +262,12 @@ impl Session {
|
||||
|
||||
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, SessionError> {
|
||||
let path = path.as_ref();
|
||||
if fs::metadata(path)?.is_dir() {
|
||||
return Err(SessionError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::IsADirectory,
|
||||
format!("session_path_is_directory: {}", path.display()),
|
||||
)));
|
||||
}
|
||||
let contents = fs::read_to_string(path)?;
|
||||
let session = match JsonValue::parse(&contents) {
|
||||
Ok(value)
|
||||
|
||||
@@ -460,7 +460,10 @@ fn classify_error_kind(message: &str) -> &'static str {
|
||||
// error message is "failed to restore session: legacy session is missing workspace
|
||||
// binding: ...", so the specific arm must be checked first.
|
||||
"legacy_session_no_workspace_binding"
|
||||
} else if message.contains("Is a directory") || message.contains("os error 21") {
|
||||
} else if message.contains("session_path_is_directory:")
|
||||
|| message.contains("Is a directory")
|
||||
|| message.contains("os error 21")
|
||||
{
|
||||
// #787: --resume given a directory path instead of a .jsonl file
|
||||
"session_path_is_directory"
|
||||
} else if message.contains("failed to restore session") {
|
||||
@@ -517,7 +520,10 @@ fn classify_error_kind(message: &str) -> &'static str {
|
||||
"api_http_error"
|
||||
} else if message.contains("mcpServers") {
|
||||
"malformed_mcp_config"
|
||||
} else if message.contains(".claw/settings.json") || message.contains(".claw.json") {
|
||||
} else if message.contains(".claw/settings.json")
|
||||
|| message.contains(r".claw\settings.json")
|
||||
|| message.contains(".claw.json")
|
||||
{
|
||||
// #763: config file JSON parse / validation errors (e.g. unterminated string, type mismatch)
|
||||
"config_parse_error"
|
||||
} else if message.starts_with("empty prompt") {
|
||||
@@ -15403,30 +15409,43 @@ mod tests {
|
||||
)
|
||||
.expect("write hook");
|
||||
}
|
||||
if include_lifecycle {
|
||||
let lifecycle = if include_lifecycle {
|
||||
fs::create_dir_all(root.join("lifecycle")).expect("lifecycle dir");
|
||||
let (extension, init_script, shutdown_script) = if cfg!(windows) {
|
||||
(
|
||||
"cmd",
|
||||
"@echo off\r\necho init>>lifecycle.log\r\n",
|
||||
"@echo off\r\necho shutdown>>lifecycle.log\r\n",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"sh",
|
||||
"#!/bin/sh\nprintf 'init\\n' >> lifecycle.log\n",
|
||||
"#!/bin/sh\nprintf 'shutdown\\n' >> lifecycle.log\n",
|
||||
)
|
||||
};
|
||||
fs::write(
|
||||
root.join("lifecycle").join("init.sh"),
|
||||
"#!/bin/sh\nprintf 'init\\n' >> lifecycle.log\n",
|
||||
root.join("lifecycle").join(format!("init.{extension}")),
|
||||
init_script,
|
||||
)
|
||||
.expect("write init lifecycle");
|
||||
fs::write(
|
||||
root.join("lifecycle").join("shutdown.sh"),
|
||||
"#!/bin/sh\nprintf 'shutdown\\n' >> lifecycle.log\n",
|
||||
root.join("lifecycle").join(format!("shutdown.{extension}")),
|
||||
shutdown_script,
|
||||
)
|
||||
.expect("write shutdown lifecycle");
|
||||
}
|
||||
format!(
|
||||
",\n \"lifecycle\": {{\n \"Init\": [\"./lifecycle/init.{extension}\"],\n \"Shutdown\": [\"./lifecycle/shutdown.{extension}\"]\n }}"
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let hooks = if include_hooks {
|
||||
",\n \"hooks\": {\n \"PreToolUse\": [\"./hooks/pre.sh\"]\n }"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let lifecycle = if include_lifecycle {
|
||||
",\n \"lifecycle\": {\n \"Init\": [\"./lifecycle/init.sh\"],\n \"Shutdown\": [\"./lifecycle/shutdown.sh\"]\n }"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
fs::write(
|
||||
root.join(".claude-plugin").join("plugin.json"),
|
||||
format!(
|
||||
@@ -16047,10 +16066,11 @@ mod tests {
|
||||
#[test]
|
||||
fn parses_system_prompt_options() {
|
||||
// given: system-prompt options for cwd and date
|
||||
let cwd = std::env::temp_dir();
|
||||
let args = vec![
|
||||
"system-prompt".to_string(),
|
||||
"--cwd".to_string(),
|
||||
"/tmp".to_string(),
|
||||
cwd.to_string_lossy().into_owned(),
|
||||
"--date".to_string(),
|
||||
"2026-04-01".to_string(),
|
||||
];
|
||||
@@ -16062,7 +16082,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
action,
|
||||
CliAction::PrintSystemPrompt {
|
||||
cwd: PathBuf::from("/tmp"),
|
||||
cwd,
|
||||
date: "2026-04-01".to_string(),
|
||||
model: DEFAULT_MODEL.to_string(),
|
||||
output_format: CliOutputFormat::Text,
|
||||
@@ -16697,7 +16717,10 @@ mod tests {
|
||||
|
||||
// Phase 1 contract: workspace/git/sandbox fields are still populated
|
||||
// (independent of config parse). Sandbox falls back to defaults.
|
||||
assert_eq!(context.cwd, cwd.canonicalize().unwrap_or(cwd.clone()));
|
||||
assert_eq!(
|
||||
context.cwd.canonicalize().expect("canonical cwd"),
|
||||
cwd.canonicalize().expect("canonical workspace")
|
||||
);
|
||||
assert_eq!(
|
||||
context.loaded_config_files, 0,
|
||||
"loaded_config_files should be 0 when config parse fails"
|
||||
@@ -17038,6 +17061,16 @@ mod tests {
|
||||
classify_error_kind("failed to restore session: Is a directory (os error 21)"),
|
||||
"session_path_is_directory"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_error_kind(
|
||||
r"failed to restore session: session_path_is_directory: C:\sessions"
|
||||
),
|
||||
"session_path_is_directory"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_error_kind(r"C:\workspace\.claw\settings.json: model must be a string"),
|
||||
"config_parse_error"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_error_kind("unrecognized argument `--foo` for subcommand `doctor`"),
|
||||
"cli_parse"
|
||||
@@ -19330,16 +19363,22 @@ UU conflicted.rs",
|
||||
error.to_string().contains("session workspace mismatch"),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
let error = error.to_string();
|
||||
let workspace_a_name = workspace_a
|
||||
.file_name()
|
||||
.expect("workspace a should have a name")
|
||||
.to_string_lossy();
|
||||
let workspace_b_name = workspace_b
|
||||
.file_name()
|
||||
.expect("workspace b should have a name")
|
||||
.to_string_lossy();
|
||||
assert_ne!(workspace_a_name, workspace_b_name);
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains(&workspace_b.display().to_string()),
|
||||
error.contains(workspace_b_name.as_ref()),
|
||||
"expected current workspace in error: {error}"
|
||||
);
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains(&workspace_a.display().to_string()),
|
||||
error.contains(workspace_a_name.as_ref()),
|
||||
"expected originating workspace in error: {error}"
|
||||
);
|
||||
|
||||
@@ -20315,21 +20354,19 @@ UU conflicted.rs",
|
||||
write_mcp_server_fixture(&script_path);
|
||||
fs::write(
|
||||
config_home.join("settings.json"),
|
||||
format!(
|
||||
r#"{{
|
||||
"mcpServers": {{
|
||||
"alpha": {{
|
||||
"command": "python3",
|
||||
"args": ["{}"]
|
||||
}},
|
||||
"broken": {{
|
||||
"command": "python3",
|
||||
"args": ["-c", "import sys; sys.exit(0)"]
|
||||
}}
|
||||
}}
|
||||
}}"#,
|
||||
script_path.to_string_lossy()
|
||||
),
|
||||
serde_json::json!({
|
||||
"mcpServers": {
|
||||
"alpha": {
|
||||
"command": if cfg!(windows) { "python" } else { "python3" },
|
||||
"args": [script_path]
|
||||
},
|
||||
"broken": {
|
||||
"command": if cfg!(windows) { "python" } else { "python3" },
|
||||
"args": ["-c", "import sys; sys.exit(0)"]
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("write mcp settings");
|
||||
|
||||
@@ -20518,7 +20555,9 @@ UU conflicted.rs",
|
||||
.expect("runtime should build");
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(&log_path).expect("init log should exist"),
|
||||
fs::read_to_string(&log_path)
|
||||
.expect("init log should exist")
|
||||
.replace("\r\n", "\n"),
|
||||
"init\n"
|
||||
);
|
||||
|
||||
@@ -20527,7 +20566,9 @@ UU conflicted.rs",
|
||||
.expect("plugin shutdown should succeed");
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(&log_path).expect("shutdown log should exist"),
|
||||
fs::read_to_string(&log_path)
|
||||
.expect("shutdown log should exist")
|
||||
.replace("\r\n", "\n"),
|
||||
"init\nshutdown\n"
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#![allow(clippy::while_let_on_iterator)]
|
||||
|
||||
mod support;
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
@@ -655,12 +657,12 @@ fn run_claw(
|
||||
command
|
||||
.current_dir(cwd)
|
||||
.env_clear()
|
||||
.envs(support::process_environment())
|
||||
.env("ANTHROPIC_API_KEY", "test-compact-key")
|
||||
.env("ANTHROPIC_BASE_URL", base_url)
|
||||
.env("CLAW_CONFIG_HOME", config_home)
|
||||
.env("HOME", home)
|
||||
.env("NO_COLOR", "1")
|
||||
.env("PATH", "/usr/bin:/bin")
|
||||
.args(args);
|
||||
command.output().expect("claw should launch")
|
||||
}
|
||||
@@ -676,12 +678,12 @@ fn run_claw_with_stdin(
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_claw"))
|
||||
.current_dir(cwd)
|
||||
.env_clear()
|
||||
.envs(support::process_environment())
|
||||
.env("ANTHROPIC_API_KEY", "test-compact-key")
|
||||
.env("ANTHROPIC_BASE_URL", base_url)
|
||||
.env("CLAW_CONFIG_HOME", config_home)
|
||||
.env("HOME", home)
|
||||
.env("NO_COLOR", "1")
|
||||
.env("PATH", "/usr/bin:/bin")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
@@ -708,10 +710,10 @@ fn run_claw_closed_stdin_with_timeout(
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_claw"))
|
||||
.current_dir(cwd)
|
||||
.env_clear()
|
||||
.envs(support::process_environment())
|
||||
.env("CLAW_CONFIG_HOME", config_home)
|
||||
.env("HOME", home)
|
||||
.env("NO_COLOR", "1")
|
||||
.env("PATH", "/usr/bin:/bin")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
mod support;
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
@@ -55,15 +57,15 @@ fn run_claw_repl(
|
||||
home: &std::path::Path,
|
||||
stdin: &str,
|
||||
) -> Output {
|
||||
let mut command = python_pty_command(env!("CARGO_BIN_EXE_claw"));
|
||||
let mut command = python_terminal_command(env!("CARGO_BIN_EXE_claw"));
|
||||
let mut child = command
|
||||
.current_dir(cwd)
|
||||
.env_clear()
|
||||
.envs(support::process_environment())
|
||||
.env("ANTHROPIC_API_KEY", "test-compact-repl-key")
|
||||
.env("CLAW_CONFIG_HOME", config_home)
|
||||
.env("HOME", home)
|
||||
.env("NO_COLOR", "1")
|
||||
.env("PATH", "/usr/bin:/bin")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
@@ -80,30 +82,12 @@ fn run_claw_repl(
|
||||
child.wait_with_output().expect("claw should finish")
|
||||
}
|
||||
|
||||
fn python_pty_command(claw: &str) -> Command {
|
||||
let mut command = Command::new("python3");
|
||||
command.args([
|
||||
"-c",
|
||||
r#"
|
||||
import os
|
||||
import pty
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
claw = sys.argv[1]
|
||||
payload = sys.stdin.buffer.read()
|
||||
master, slave = pty.openpty()
|
||||
child = subprocess.Popen([claw], stdin=slave, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
os.close(slave)
|
||||
os.write(master, payload)
|
||||
stdout, stderr = child.communicate(timeout=30)
|
||||
os.close(master)
|
||||
sys.stdout.buffer.write(stdout)
|
||||
sys.stderr.buffer.write(stderr)
|
||||
raise SystemExit(child.returncode)
|
||||
"#,
|
||||
claw,
|
||||
]);
|
||||
fn python_terminal_command(claw: &str) -> Command {
|
||||
let mut command = Command::new(if cfg!(windows) { "python" } else { "python3" });
|
||||
command.arg(
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/support/repl_console.py"),
|
||||
);
|
||||
command.arg(claw);
|
||||
command
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
mod support;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
@@ -312,12 +314,12 @@ fn run_case(case: ScenarioCase, workspace: &HarnessWorkspace, base_url: &str) ->
|
||||
command
|
||||
.current_dir(&workspace.root)
|
||||
.env_clear()
|
||||
.envs(support::process_environment())
|
||||
.env("ANTHROPIC_API_KEY", "test-parity-key")
|
||||
.env("ANTHROPIC_BASE_URL", base_url)
|
||||
.env("CLAW_CONFIG_HOME", &workspace.config_home)
|
||||
.env("HOME", &workspace.home)
|
||||
.env("NO_COLOR", "1")
|
||||
.env("PATH", "/usr/bin:/bin")
|
||||
.args([
|
||||
"--model",
|
||||
"sonnet",
|
||||
@@ -411,6 +413,33 @@ fn prepare_multi_tool_fixture(workspace: &HarnessWorkspace) {
|
||||
}
|
||||
|
||||
fn prepare_plugin_fixture(workspace: &HarnessWorkspace) {
|
||||
// A Windows `python` command can be an install-manager launcher whose
|
||||
// bootstrap messages go to stdout. Resolve it before entering the isolated
|
||||
// CLI environment, then execute the interpreter itself for the JSON tool.
|
||||
let python = Command::new(if cfg!(windows) { "python" } else { "python3" })
|
||||
.args([
|
||||
"-I",
|
||||
"-c",
|
||||
"import json, sys; print(json.dumps(sys.executable))",
|
||||
])
|
||||
.output()
|
||||
.expect("Python interpreter discovery should launch");
|
||||
assert!(
|
||||
python.status.success(),
|
||||
"Python interpreter discovery failed: {}",
|
||||
String::from_utf8_lossy(&python.stderr)
|
||||
);
|
||||
let discovery_stdout = String::from_utf8(python.stdout).expect("Python discovery UTF-8");
|
||||
let interpreter: String = serde_json::from_str(
|
||||
discovery_stdout
|
||||
.lines()
|
||||
.last()
|
||||
.expect("Python interpreter path"),
|
||||
)
|
||||
.expect("Python discovery must end with its JSON-encoded executable path");
|
||||
assert!(Path::new(&interpreter).is_absolute());
|
||||
assert!(Path::new(&interpreter).is_file());
|
||||
|
||||
let plugin_root = workspace
|
||||
.root
|
||||
.join("external-plugins")
|
||||
@@ -420,45 +449,36 @@ fn prepare_plugin_fixture(workspace: &HarnessWorkspace) {
|
||||
fs::create_dir_all(&tool_dir).expect("plugin tools dir");
|
||||
fs::create_dir_all(&manifest_dir).expect("plugin manifest dir");
|
||||
|
||||
let script_path = tool_dir.join("echo-json.sh");
|
||||
let script_path = tool_dir.join("echo-json.py");
|
||||
fs::write(
|
||||
&script_path,
|
||||
"#!/bin/sh\nINPUT=$(cat)\nprintf '{\"plugin\":\"%s\",\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_PLUGIN_ID\" \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n",
|
||||
r#"import json, os, sys
|
||||
print(json.dumps({"plugin": os.environ["CLAWD_PLUGIN_ID"], "tool": os.environ["CLAWD_TOOL_NAME"], "input": json.load(sys.stdin)}))
|
||||
"#,
|
||||
)
|
||||
.expect("plugin script should write");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut permissions = fs::metadata(&script_path)
|
||||
.expect("plugin script metadata")
|
||||
.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, permissions).expect("plugin script should be executable");
|
||||
}
|
||||
|
||||
fs::write(
|
||||
manifest_dir.join("plugin.json"),
|
||||
r#"{
|
||||
"name": "parity-plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "mock parity plugin",
|
||||
"tools": [
|
||||
{
|
||||
"name": "plugin_echo",
|
||||
"description": "Echo JSON input",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": { "type": "string" }
|
||||
},
|
||||
"required": ["message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"command": "./tools/echo-json.sh",
|
||||
"requiredPermission": "workspace-write"
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
json!({
|
||||
"name": "parity-plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "mock parity plugin",
|
||||
"tools": [{
|
||||
"name": "plugin_echo",
|
||||
"description": "Echo JSON input",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"message": {"type": "string"}},
|
||||
"required": ["message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"command": interpreter,
|
||||
"args": ["-I", "tools/echo-json.py"],
|
||||
"requiredPermission": "workspace-write"
|
||||
}]
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("plugin manifest should write");
|
||||
|
||||
@@ -504,8 +524,16 @@ fn assert_read_file_roundtrip(workspace: &HarnessWorkspace, run: &ScenarioRun) {
|
||||
let output = run.response["tool_results"][0]["output"]
|
||||
.as_str()
|
||||
.expect("tool output");
|
||||
assert!(output.contains(&workspace.root.join("fixture.txt").display().to_string()));
|
||||
assert!(output.contains("alpha parity line"));
|
||||
let payload: Value = serde_json::from_str(output).expect("read_file output JSON");
|
||||
let actual_path = payload["file"]["filePath"].as_str().expect("filePath");
|
||||
assert_eq!(
|
||||
fs::canonicalize(actual_path).expect("canonical reported file"),
|
||||
fs::canonicalize(workspace.root.join("fixture.txt")).expect("canonical fixture file")
|
||||
);
|
||||
assert!(payload["file"]["content"]
|
||||
.as_str()
|
||||
.expect("file content")
|
||||
.contains("alpha parity line"));
|
||||
}
|
||||
|
||||
fn assert_grep_chunk_assembly(_: &HarnessWorkspace, run: &ScenarioRun) {
|
||||
@@ -536,10 +564,11 @@ fn assert_write_file_allowed(workspace: &HarnessWorkspace, run: &ScenarioRun) {
|
||||
run.response["tool_uses"][0]["name"],
|
||||
Value::String("write_file".to_string())
|
||||
);
|
||||
assert!(run.response["message"]
|
||||
.as_str()
|
||||
.expect("message text")
|
||||
.contains("generated/output.txt"));
|
||||
let message = run.response["message"].as_str().expect("message text");
|
||||
assert!(
|
||||
message.contains("write_file succeeded"),
|
||||
"expected successful write confirmation in assistant message: {message:?}"
|
||||
);
|
||||
let generated = workspace.root.join("generated").join("output.txt");
|
||||
let contents = fs::read_to_string(&generated).expect("generated file should exist");
|
||||
assert_eq!(contents, "created by mock service\n");
|
||||
@@ -676,7 +705,13 @@ fn assert_plugin_tool_roundtrip(_: &HarnessWorkspace, run: &ScenarioRun) {
|
||||
let tool_output = run.response["tool_results"][0]["output"]
|
||||
.as_str()
|
||||
.expect("tool output");
|
||||
let parsed: Value = serde_json::from_str(tool_output).expect("plugin output json");
|
||||
assert_eq!(
|
||||
run.response["tool_results"][0]["is_error"],
|
||||
Value::Bool(false),
|
||||
"plugin tool failed: {tool_output}"
|
||||
);
|
||||
let parsed: Value = serde_json::from_str(tool_output)
|
||||
.unwrap_or_else(|err| panic!("plugin output is not JSON: {err}; output: {tool_output:?}"));
|
||||
assert_eq!(
|
||||
parsed["plugin"],
|
||||
Value::String("parity-plugin@external".to_string())
|
||||
|
||||
@@ -638,13 +638,25 @@ fn global_cwd_flag_routes_status_workspace_and_short_alias_429() {
|
||||
&["--cwd", workspace_str, "--output-format", "json", "status"],
|
||||
);
|
||||
assert_eq!(status["kind"], "status");
|
||||
assert_eq!(status["workspace"]["cwd"], expected_cwd);
|
||||
assert_eq!(
|
||||
fs::canonicalize(status["workspace"]["cwd"].as_str().expect("cwd"))
|
||||
.expect("canonical cwd")
|
||||
.display()
|
||||
.to_string(),
|
||||
expected_cwd
|
||||
);
|
||||
|
||||
let short_status = assert_json_command(
|
||||
&launcher,
|
||||
&["-C", workspace_str, "status", "--output-format", "json"],
|
||||
);
|
||||
assert_eq!(short_status["workspace"]["cwd"], expected_cwd);
|
||||
assert_eq!(
|
||||
fs::canonicalize(short_status["workspace"]["cwd"].as_str().expect("cwd"))
|
||||
.expect("canonical cwd")
|
||||
.display()
|
||||
.to_string(),
|
||||
expected_cwd
|
||||
);
|
||||
|
||||
let directory_status = assert_json_command(
|
||||
&launcher,
|
||||
@@ -655,7 +667,13 @@ fn global_cwd_flag_routes_status_workspace_and_short_alias_429() {
|
||||
"status",
|
||||
],
|
||||
);
|
||||
assert_eq!(directory_status["workspace"]["cwd"], expected_cwd);
|
||||
assert_eq!(
|
||||
fs::canonicalize(directory_status["workspace"]["cwd"].as_str().expect("cwd"))
|
||||
.expect("canonical cwd")
|
||||
.display()
|
||||
.to_string(),
|
||||
expected_cwd
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2295,7 +2313,7 @@ fn config_json_attributes_precedence_and_shadowed_keys_425() {
|
||||
file["source"] == "project"
|
||||
&& file["path"]
|
||||
.as_str()
|
||||
.is_some_and(|path| path.ends_with(".claw/settings.json"))
|
||||
.is_some_and(|path| std::path::Path::new(path).ends_with(".claw/settings.json"))
|
||||
})
|
||||
.expect("project .claw/settings.json entry");
|
||||
|
||||
@@ -5320,7 +5338,7 @@ fn agents_create_scaffolds_toml_and_lists_locally_431() {
|
||||
assert_eq!(create_json["status"], "ok");
|
||||
assert_eq!(create_json["format"], "toml");
|
||||
assert_eq!(
|
||||
reported_agent_path,
|
||||
fs::canonicalize(&reported_agent_path).expect("canonical reported agent path"),
|
||||
fs::canonicalize(&agent_path).expect("canonical agent path")
|
||||
);
|
||||
assert!(agent_path.is_file());
|
||||
@@ -5337,7 +5355,8 @@ fn agents_create_scaffolds_toml_and_lists_locally_431() {
|
||||
.iter()
|
||||
.any(|agent| {
|
||||
agent["name"] == "my-agent"
|
||||
&& *agent["path"].as_str().expect("listed agent path")
|
||||
&& fs::canonicalize(agent["path"].as_str().expect("listed agent path"))
|
||||
.expect("canonical reported list path")
|
||||
== fs::canonicalize(&agent_path).expect("canonical listed agent path")
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
//! mock service does not validate pairing itself, which is why the assertion
|
||||
//! lives here.
|
||||
|
||||
mod support;
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Output};
|
||||
@@ -121,12 +123,12 @@ fn run_claw(workspace: &PathBuf, config_home: &PathBuf, home: &PathBuf, base_url
|
||||
Command::new(env!("CARGO_BIN_EXE_claw"))
|
||||
.current_dir(workspace)
|
||||
.env_clear()
|
||||
.envs(support::process_environment())
|
||||
.env("ANTHROPIC_API_KEY", "test-parallel-tool-key")
|
||||
.env("ANTHROPIC_BASE_URL", base_url)
|
||||
.env("CLAW_CONFIG_HOME", config_home)
|
||||
.env("HOME", home)
|
||||
.env("NO_COLOR", "1")
|
||||
.env("PATH", "/usr/bin:/bin")
|
||||
.args([
|
||||
"--model",
|
||||
"sonnet",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
use std::ffi::OsString;
|
||||
|
||||
// Keep credentials, proxy variables and user configuration out of subprocess
|
||||
// tests, but preserve the operating-system environment needed to load DLLs,
|
||||
// initialize networking and find the installed test interpreters on Windows.
|
||||
pub fn process_environment() -> Vec<(&'static str, OsString)> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
[
|
||||
"PATH",
|
||||
"SYSTEMROOT",
|
||||
"WINDIR",
|
||||
"SYSTEMDRIVE",
|
||||
"COMSPEC",
|
||||
"PATHEXT",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|name| std::env::var_os(name).map(|value| (name, value)))
|
||||
.collect()
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
vec![("PATH", OsString::from("/usr/bin:/bin"))]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Give the CLI terminal stdin and captured stdout, using only Python's stdlib."""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def run_unix(claw, payload):
|
||||
import pty
|
||||
master, slave = pty.openpty()
|
||||
try:
|
||||
with subprocess.Popen([claw], stdin=slave, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE) as child:
|
||||
os.close(slave)
|
||||
slave = None
|
||||
os.write(master, payload)
|
||||
try:
|
||||
stdout, stderr = child.communicate(timeout=30)
|
||||
except subprocess.TimeoutExpired:
|
||||
child.kill()
|
||||
child.communicate()
|
||||
raise
|
||||
return child.returncode, stdout, stderr
|
||||
finally:
|
||||
if slave is not None:
|
||||
os.close(slave)
|
||||
os.close(master)
|
||||
|
||||
|
||||
def run_windows(claw, payload):
|
||||
import ctypes
|
||||
import msvcrt
|
||||
from ctypes import wintypes
|
||||
|
||||
class KeyEvent(ctypes.Structure):
|
||||
_fields_ = [("down", wintypes.BOOL), ("repeat", wintypes.WORD),
|
||||
("virtual_key", wintypes.WORD), ("scan", wintypes.WORD),
|
||||
("char", wintypes.WCHAR), ("control", wintypes.DWORD)]
|
||||
|
||||
class Event(ctypes.Union):
|
||||
_fields_ = [("key", KeyEvent), ("storage", ctypes.c_byte * 16)]
|
||||
|
||||
class InputRecord(ctypes.Structure):
|
||||
_fields_ = [("kind", wintypes.WORD), ("event", Event)]
|
||||
|
||||
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
kernel.AllocConsole.argtypes = []
|
||||
kernel.AllocConsole.restype = wintypes.BOOL
|
||||
kernel.FreeConsole.argtypes = []
|
||||
kernel.FreeConsole.restype = wintypes.BOOL
|
||||
kernel.WriteConsoleInputW.argtypes = [wintypes.HANDLE, ctypes.POINTER(InputRecord),
|
||||
wintypes.DWORD, ctypes.POINTER(wintypes.DWORD)]
|
||||
kernel.WriteConsoleInputW.restype = wintypes.BOOL
|
||||
# This affects only this helper process, never the test runner's console.
|
||||
kernel.FreeConsole()
|
||||
if not kernel.AllocConsole():
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
try:
|
||||
with open("CONIN$", "r+b", buffering=0) as console:
|
||||
records = []
|
||||
for char in payload.decode("utf-8").replace("\r\n", "\n").replace("\n", "\r"):
|
||||
for down in (True, False):
|
||||
record = InputRecord()
|
||||
record.kind = 1 # KEY_EVENT
|
||||
record.event.key = KeyEvent(down, 1, 13 if char == "\r" else 0,
|
||||
0, char, 0)
|
||||
records.append(record)
|
||||
inputs = (InputRecord * len(records))(*records)
|
||||
written = wintypes.DWORD()
|
||||
if not kernel.WriteConsoleInputW(msvcrt.get_osfhandle(console.fileno()),
|
||||
inputs, len(inputs), ctypes.byref(written)):
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
if written.value != len(inputs):
|
||||
raise RuntimeError("incomplete console input")
|
||||
with subprocess.Popen([claw], stdin=console, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE) as child:
|
||||
try:
|
||||
stdout, stderr = child.communicate(timeout=30)
|
||||
except subprocess.TimeoutExpired:
|
||||
child.kill()
|
||||
child.communicate()
|
||||
raise
|
||||
return child.returncode, stdout, stderr
|
||||
finally:
|
||||
kernel.FreeConsole()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run = run_windows if os.name == "nt" else run_unix
|
||||
code, stdout, stderr = run(sys.argv[1], sys.stdin.buffer.read())
|
||||
sys.stdout.buffer.write(stdout)
|
||||
sys.stderr.buffer.write(stderr)
|
||||
raise SystemExit(code)
|
||||
+188
-38
@@ -2728,7 +2728,7 @@ fn path_within_current_workspace(path: &str, allow_missing: bool) -> bool {
|
||||
'"' | '\'' | '`' | ',' | ';' | ')' | '(' | '[' | ']' | '{' | '}'
|
||||
)
|
||||
});
|
||||
if looks_like_windows_absolute_path(trimmed) {
|
||||
if !cfg!(windows) && looks_like_windows_absolute_path(trimmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -5997,7 +5997,7 @@ fn execute_notebook_edit(input: NotebookEditInput) -> Result<NotebookEditOutput,
|
||||
NotebookEditMode::Insert => {
|
||||
let resolved_cell_type = resolved_cell_type
|
||||
.ok_or_else(|| String::from("insert mode requires a cell type"))?;
|
||||
let new_id = make_cell_id(cells.len());
|
||||
let new_id = make_cell_id(cells);
|
||||
let new_cell = build_notebook_cell(&new_id, resolved_cell_type, &new_source);
|
||||
let insert_at = target_index.map_or(cells.len(), |index| index + 1);
|
||||
cells.insert(insert_at, new_cell);
|
||||
@@ -6433,15 +6433,19 @@ fn execute_repl(input: ReplInput) -> Result<ReplOutput, String> {
|
||||
}
|
||||
|
||||
struct ReplRuntime {
|
||||
program: &'static str,
|
||||
program: PathBuf,
|
||||
args: &'static [&'static str],
|
||||
}
|
||||
|
||||
fn resolve_repl_runtime(language: &str) -> Result<ReplRuntime, String> {
|
||||
match language.trim().to_ascii_lowercase().as_str() {
|
||||
"python" | "py" => Ok(ReplRuntime {
|
||||
program: detect_first_command(&["python3", "python"])
|
||||
.ok_or_else(|| String::from("python runtime not found"))?,
|
||||
program: detect_first_command(if cfg!(windows) {
|
||||
&["python", "python3"]
|
||||
} else {
|
||||
&["python3", "python"]
|
||||
})
|
||||
.ok_or_else(|| String::from("python runtime not found"))?,
|
||||
args: &["-c"],
|
||||
}),
|
||||
"javascript" | "js" | "node" => Ok(ReplRuntime {
|
||||
@@ -6458,11 +6462,8 @@ fn resolve_repl_runtime(language: &str) -> Result<ReplRuntime, String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_first_command(commands: &[&'static str]) -> Option<&'static str> {
|
||||
commands
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|command| command_exists(command))
|
||||
fn detect_first_command(commands: &[&str]) -> Option<PathBuf> {
|
||||
commands.iter().find_map(|command| resolve_command(command))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -6786,37 +6787,62 @@ fn execute_powershell(input: PowerShellInput) -> std::io::Result<runtime::BashCo
|
||||
}
|
||||
let shell = detect_powershell_shell()?;
|
||||
execute_shell_command(
|
||||
shell,
|
||||
&shell,
|
||||
&input.command,
|
||||
input.timeout,
|
||||
input.run_in_background,
|
||||
)
|
||||
}
|
||||
|
||||
fn detect_powershell_shell() -> std::io::Result<&'static str> {
|
||||
if command_exists("pwsh") {
|
||||
Ok("pwsh")
|
||||
} else if command_exists("powershell") {
|
||||
Ok("powershell")
|
||||
} else {
|
||||
Err(std::io::Error::new(
|
||||
fn detect_powershell_shell() -> std::io::Result<PathBuf> {
|
||||
detect_first_command(&["pwsh", "powershell"]).ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"PowerShell executable not found (expected `pwsh` or `powershell` in PATH)",
|
||||
))
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn command_exists(command: &str) -> bool {
|
||||
std::process::Command::new("sh")
|
||||
.arg("-lc")
|
||||
.arg(format!("command -v {command} >/dev/null 2>&1"))
|
||||
.status()
|
||||
.is_ok_and(|status| status.success())
|
||||
// Resolve the same executable that we will launch, without invoking a login
|
||||
// shell (which can replace PATH). Windows also needs the selected extension:
|
||||
// Command does not apply PATHEXT when given a bare program name.
|
||||
fn resolve_command(command: &str) -> Option<PathBuf> {
|
||||
let path = std::env::var_os("PATH")?;
|
||||
#[cfg(windows)]
|
||||
let extensions = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string());
|
||||
for directory in std::env::split_paths(&path) {
|
||||
let candidate = directory.join(command);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if candidate.is_file() {
|
||||
return Some(candidate);
|
||||
}
|
||||
for extension in extensions
|
||||
.split(';')
|
||||
.filter(|extension| !extension.is_empty())
|
||||
{
|
||||
let candidate = directory.join(format!("{command}{extension}"));
|
||||
if candidate.is_file() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if std::fs::metadata(&candidate).is_ok_and(|metadata| {
|
||||
metadata.is_file() && metadata.permissions().mode() & 0o111 != 0
|
||||
}) {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn execute_shell_command(
|
||||
shell: &str,
|
||||
shell: &Path,
|
||||
command: &str,
|
||||
timeout: Option<u64>,
|
||||
run_in_background: Option<bool>,
|
||||
@@ -7022,8 +7048,11 @@ fn format_notebook_edit_mode(mode: NotebookEditMode) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn make_cell_id(index: usize) -> String {
|
||||
format!("cell-{}", index + 1)
|
||||
fn make_cell_id(cells: &[Value]) -> String {
|
||||
(cells.len() + 1..)
|
||||
.map(|n| format!("cell-{n}"))
|
||||
.find(|id| !cells.iter().any(|cell| cell["id"] == id.as_str()))
|
||||
.expect("there is always a free id")
|
||||
}
|
||||
|
||||
fn parse_skill_description(contents: &str) -> Option<String> {
|
||||
@@ -7349,7 +7378,7 @@ mod tests {
|
||||
fs::create_dir_all(&claw_dir).expect("create .claw dir");
|
||||
// Use the actual OS temp dir so the worktree path matches the allowlist
|
||||
let tmp_root = std::env::temp_dir().to_str().expect("utf-8").to_string();
|
||||
let settings = format!("{{\"trustedRoots\": [\"{tmp_root}\"]}}");
|
||||
let settings = json!({"trustedRoots": [tmp_root]}).to_string();
|
||||
fs::write(claw_dir.join("settings.json"), settings).expect("write settings");
|
||||
|
||||
// WorkerCreate with no per-call trusted_roots — config should supply them
|
||||
@@ -8600,6 +8629,7 @@ mod tests {
|
||||
assert!(output["path"]
|
||||
.as_str()
|
||||
.expect("path")
|
||||
.replace('\\', "/")
|
||||
.ends_with("/help/SKILL.md"));
|
||||
assert!(output["prompt"]
|
||||
.as_str()
|
||||
@@ -8619,6 +8649,7 @@ mod tests {
|
||||
assert!(dollar_output["path"]
|
||||
.as_str()
|
||||
.expect("path")
|
||||
.replace('\\', "/")
|
||||
.ends_with("/help/SKILL.md"));
|
||||
|
||||
if let Some(home) = original_home {
|
||||
@@ -8658,6 +8689,7 @@ mod tests {
|
||||
assert!(skill_output["path"]
|
||||
.as_str()
|
||||
.expect("path")
|
||||
.replace('\\', "/")
|
||||
.ends_with(".claw/skills/plan/SKILL.md"));
|
||||
|
||||
let command_result = execute_tool("Skill", &json!({ "skill": "/handoff" }))
|
||||
@@ -8667,6 +8699,7 @@ mod tests {
|
||||
assert!(command_output["path"]
|
||||
.as_str()
|
||||
.expect("path")
|
||||
.replace('\\', "/")
|
||||
.ends_with(".claw/commands/handoff.md"));
|
||||
|
||||
std::env::set_current_dir(&original_dir).expect("restore cwd");
|
||||
@@ -8705,6 +8738,7 @@ mod tests {
|
||||
assert!(output["path"]
|
||||
.as_str()
|
||||
.expect("path")
|
||||
.replace('\\', "/")
|
||||
.ends_with(".claude/skills/trace/SKILL.md"));
|
||||
assert_eq!(output["description"], "Project-local trace helper");
|
||||
|
||||
@@ -8767,11 +8801,13 @@ mod tests {
|
||||
assert!(omc_output["path"]
|
||||
.as_str()
|
||||
.expect("path")
|
||||
.replace('\\', "/")
|
||||
.ends_with(".omc/skills/hud/SKILL.md"));
|
||||
assert_eq!(omc_output["description"], "Project-local OMC HUD helper");
|
||||
assert!(agents_output["path"]
|
||||
.as_str()
|
||||
.expect("path")
|
||||
.replace('\\', "/")
|
||||
.ends_with(".agents/skills/trace/SKILL.md"));
|
||||
assert_eq!(
|
||||
agents_output["description"],
|
||||
@@ -8827,6 +8863,7 @@ mod tests {
|
||||
assert!(output["path"]
|
||||
.as_str()
|
||||
.expect("path")
|
||||
.replace('\\', "/")
|
||||
.ends_with("skills/omc-learned/learned/SKILL.md"));
|
||||
assert_eq!(output["description"], "Learned OMC skill");
|
||||
|
||||
@@ -8886,6 +8923,7 @@ mod tests {
|
||||
assert!(direct_skill_output["path"]
|
||||
.as_str()
|
||||
.expect("path")
|
||||
.replace('\\', "/")
|
||||
.ends_with("skills/statusline/SKILL.md"));
|
||||
assert_eq!(direct_skill_output["description"], "Claude config skill");
|
||||
|
||||
@@ -8896,6 +8934,7 @@ mod tests {
|
||||
assert!(legacy_command_output["path"]
|
||||
.as_str()
|
||||
.expect("path")
|
||||
.replace('\\', "/")
|
||||
.ends_with("commands/doctor-check.md"));
|
||||
assert_eq!(
|
||||
legacy_command_output["description"],
|
||||
@@ -8953,6 +8992,7 @@ mod tests {
|
||||
assert!(output["path"]
|
||||
.as_str()
|
||||
.expect("path")
|
||||
.replace('\\', "/")
|
||||
.ends_with(".claude/commands/team.md"));
|
||||
assert_eq!(output["description"], "Legacy team workflow");
|
||||
|
||||
@@ -9869,11 +9909,23 @@ mod tests {
|
||||
assert!(deleted_output["cell_type"].is_null());
|
||||
assert_eq!(deleted_output["new_source"], "");
|
||||
|
||||
let reinserted = execute_tool(
|
||||
"NotebookEdit",
|
||||
&json!({
|
||||
"notebook_path": path.display().to_string(),
|
||||
"new_source": "print(4)\n",
|
||||
"edit_mode": "insert"
|
||||
}),
|
||||
)
|
||||
.expect("NotebookEdit insert after delete should succeed");
|
||||
let reinserted_output: serde_json::Value = serde_json::from_str(&reinserted).expect("json");
|
||||
assert_eq!(reinserted_output["cell_id"], "cell-4");
|
||||
|
||||
let final_notebook: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&path).expect("read notebook"))
|
||||
.expect("valid notebook json");
|
||||
let cells = final_notebook["cells"].as_array().expect("cells array");
|
||||
assert_eq!(cells.len(), 2);
|
||||
assert_eq!(cells.len(), 3);
|
||||
assert_eq!(cells[0]["cell_type"], "markdown");
|
||||
assert!(cells[0].get("outputs").is_none());
|
||||
assert_eq!(cells[1]["cell_type"], "code");
|
||||
@@ -9881,6 +9933,94 @@ mod tests {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notebook_edit_replace_switches_cell_type_and_rejects_unknown_ids() {
|
||||
let path = temp_path("retype.ipynb");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r##"{
|
||||
"cells": [
|
||||
{"cell_type": "markdown", "id": "cell-2", "metadata": {}, "source": ["# title\n"]}
|
||||
],
|
||||
"metadata": {},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}"##,
|
||||
)
|
||||
.expect("write notebook");
|
||||
|
||||
let kept_type = execute_tool(
|
||||
"NotebookEdit",
|
||||
&json!({
|
||||
"notebook_path": path.display().to_string(),
|
||||
"cell_id": "cell-2",
|
||||
"new_source": "# renamed\n"
|
||||
}),
|
||||
)
|
||||
.expect("NotebookEdit default replace should succeed");
|
||||
let kept_output: serde_json::Value = serde_json::from_str(&kept_type).expect("json");
|
||||
assert_eq!(kept_output["edit_mode"], "replace");
|
||||
assert_eq!(kept_output["cell_type"], "markdown");
|
||||
assert_eq!(kept_output["language"], "python");
|
||||
|
||||
let retyped = execute_tool(
|
||||
"NotebookEdit",
|
||||
&json!({
|
||||
"notebook_path": path.display().to_string(),
|
||||
"cell_id": "cell-2",
|
||||
"new_source": "print(1)\n",
|
||||
"cell_type": "code",
|
||||
"edit_mode": "replace"
|
||||
}),
|
||||
)
|
||||
.expect("NotebookEdit replace to code should succeed");
|
||||
let retyped_output: serde_json::Value = serde_json::from_str(&retyped).expect("json");
|
||||
assert_eq!(retyped_output["cell_type"], "code");
|
||||
|
||||
let inserted = execute_tool(
|
||||
"NotebookEdit",
|
||||
&json!({
|
||||
"notebook_path": path.display().to_string(),
|
||||
"new_source": "print(2)\n",
|
||||
"edit_mode": "insert"
|
||||
}),
|
||||
)
|
||||
.expect("NotebookEdit insert should succeed");
|
||||
let inserted_output: serde_json::Value = serde_json::from_str(&inserted).expect("json");
|
||||
assert_eq!(inserted_output["cell_id"], "cell-3");
|
||||
|
||||
let before_missing = std::fs::read_to_string(&path).expect("read notebook");
|
||||
let missing = execute_tool(
|
||||
"NotebookEdit",
|
||||
&json!({
|
||||
"notebook_path": path.display().to_string(),
|
||||
"cell_id": "missing",
|
||||
"new_source": "print(3)\n",
|
||||
"edit_mode": "replace"
|
||||
}),
|
||||
)
|
||||
.expect_err("unknown cell id should fail");
|
||||
assert!(missing.contains("Cell id not found: missing"));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&path).expect("read notebook"),
|
||||
before_missing
|
||||
);
|
||||
|
||||
let final_notebook: serde_json::Value =
|
||||
serde_json::from_str(&before_missing).expect("valid notebook json");
|
||||
let cells = final_notebook["cells"].as_array().expect("cells array");
|
||||
assert_eq!(cells.len(), 2);
|
||||
assert_eq!(cells[0]["cell_type"], "code");
|
||||
assert_eq!(cells[0]["source"], json!(["print(1)\n"]));
|
||||
assert_eq!(cells[0]["outputs"], json!([]));
|
||||
assert_eq!(
|
||||
cells[0].get("execution_count"),
|
||||
Some(&serde_json::Value::Null)
|
||||
);
|
||||
assert_eq!(cells[1]["id"], "cell-3");
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notebook_edit_rejects_invalid_inputs() {
|
||||
let text_path = temp_path("notebook.txt");
|
||||
@@ -10198,6 +10338,7 @@ mod tests {
|
||||
assert!(globbed_output["filenames"][0]
|
||||
.as_str()
|
||||
.expect("filename")
|
||||
.replace('\\', "/")
|
||||
.ends_with("nested/lib.rs"));
|
||||
|
||||
let glob_error = execute_tool("glob_search", &json!({ "pattern": "[" }))
|
||||
@@ -10650,7 +10791,14 @@ mod tests {
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).expect("create dir");
|
||||
let script = dir.join("pwsh");
|
||||
let script = dir.join(if cfg!(windows) { "pwsh.cmd" } else { "pwsh" });
|
||||
#[cfg(windows)]
|
||||
std::fs::write(
|
||||
&script,
|
||||
"@echo off\r\n<nul set /p \"=pwsh:%~4\"\r\nexit /b 0\r\n",
|
||||
)
|
||||
.expect("write Windows shell stub");
|
||||
#[cfg(unix)]
|
||||
std::fs::write(
|
||||
&script,
|
||||
r#"#!/bin/sh
|
||||
@@ -10660,13 +10808,15 @@ printf 'pwsh:%s' "$1"
|
||||
"#,
|
||||
)
|
||||
.expect("write script");
|
||||
std::process::Command::new("/bin/chmod")
|
||||
.arg("+x")
|
||||
.arg(&script)
|
||||
.status()
|
||||
.expect("chmod");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755))
|
||||
.expect("make shell stub executable");
|
||||
}
|
||||
let original_path = std::env::var("PATH").unwrap_or_default();
|
||||
std::env::set_var("PATH", format!("{}:{}", dir.display(), original_path));
|
||||
let paths = std::iter::once(dir.clone()).chain(std::env::split_paths(&original_path));
|
||||
std::env::set_var("PATH", std::env::join_paths(paths).expect("fixture PATH"));
|
||||
|
||||
let result = execute_tool(
|
||||
"PowerShell",
|
||||
|
||||
Reference in new issue
Block a user