Compare commits

...
19 Commits
Author SHA1 Message Date
Bellman 6a29e08883 Merge pull request #3382 from ultraworkers/release/v0.1.13
release: v0.1.13
2026-11-09 12:07:40 +09:00
clawdbot 7041894439 release: v0.1.13 2026-11-09 03:01:44 +00:00
generaltony007andgeneraltony007 333298ae53 fix(api): retry 529 overloaded and gateway-flake 400 responses (#3381)
* fix(api): retry 529 + gateway-400 in anthropic provider

Co-authored-by: generaltony007 <144252170+GeneralTony007@users.noreply.github.com>

* fix(api): retry 529 + gateway-400 in openai_compat provider

Co-authored-by: generaltony007 <144252170+GeneralTony007@users.noreply.github.com>

* fix(api): correct EOF whitespace in openai_compat

Co-authored-by: generaltony007 <144252170+GeneralTony007@users.noreply.github.com>

---------

Co-authored-by: generaltony007 <144252170+GeneralTony007@users.noreply.github.com>
2026-11-09 02:07:20 +00:00
Bellmanandclawdbot fe2b64ae9e ci(release): build macOS arm64 on macos-15; macos-14 runners are no longer supported (#3380)
Co-authored-by: clawdbot <clawdbot@ultraworkers.dev>
2026-11-08 20:21:40 +00:00
Bellman bd4043a83e Merge pull request #3379 from ultraworkers/release/v0.1.12
release: v0.1.12
2026-11-09 05:08:25 +09:00
clawdbot 072892ff1e release: v0.1.12 2026-11-08 20:02:31 +00:00
Dror Meirovichandclawdbot 407a6c62f8 fix(windows): fix native runtime compatibility and enable workspace t… (#3378)
* fix(windows): fix native runtime compatibility and enable workspace tests

Fix shell discovery, hook quoting, native path handling and OAuth
randomness on Windows, make the test fixtures portable, and run the
full workspace test suite in the Windows CI job.

* test(plugins): make lifecycle fixtures Windows-compatible

* fix(windows): address workspace test failures

---------

Co-authored-by: clawdbot <clawdbot@ultraworkers.dev>
2026-11-08 19:45:52 +00:00
Rohan BhartiandClaude ca48ec7500 test(tools): cover NotebookEdit cell-type switching, id collisions and unknown ids (#3377)
Co-authored-by: Claude <noreply@anthropic.com>
2026-11-06 21:56:45 +00:00
Penguin Secret Agencyandsang nguyen ab8561dc37 docs: document UberLLM OpenAI-compatible gateway setup (#3376)
Co-authored-by: sang nguyen <sangnguyen@sangs-MacBook-Pro-5.local>
2026-11-05 07:01:42 +09:00
Bellman 747e160b59 Merge pull request #3374 from ultraworkers/release/v0.1.11
release: v0.1.11
2026-11-04 22:02:52 +09:00
knowhyandknowhy 6ac59ab90b docs: document LLMTR OpenAI-compatible gateway setup (#3373)
Co-authored-by: knowhy <194148385+knowhycodata@users.noreply.github.com>
2026-11-04 13:00:45 +00:00
clawdbot 67e5783894 release: v0.1.11 2026-11-04 12:59:09 +00:00
Iain db58b190bd fix(tools): stop NotebookEdit reusing cell ids after a delete (#3371)
Inserted cells were named cell-{len+1}, which is still taken once any
cell but the last has been deleted, so a later edit by the returned id
went to the older cell instead.
2026-11-04 12:02:30 +00:00
Bellman 51cfca114c Merge pull request #3370 from ultraworkers/release/v0.1.10
release: v0.1.10
2026-11-02 10:23:26 +09:00
clawdbot 1cae56fd3f release: v0.1.10 2026-11-02 01:14:53 +00:00
Iainandclawdbot a289024b35 fix(tools): cut RemoteTrigger responses on a char boundary (#3369)
* fix(tools): cut RemoteTrigger responses on a char boundary

A response over 8192 bytes with a multi-byte character across byte 8192 panicked on the slice.

* test(tools): add multibyte char boundary truncation test for RemoteTrigger

---------

Co-authored-by: clawdbot <clawdbot@ultraworkers.dev>
2026-11-02 00:03:39 +00:00
Bellman f4fff38de1 Merge pull request #3368 from ultraworkers/release/v0.1.9
release: v0.1.9
2026-10-29 04:59:12 +09:00
clawdbot 3a802f7bf9 release: v0.1.9 2026-10-28 19:58:30 +00:00
Bougie 8523d59a84 fix(runtime): keep tool-use/tool-result pairs paired in Trident compaction (#3367)
Trident stage 1 dropped the assistant message that carried a file tool
use while keeping the tool result that answered it, so the compacted
session ended up with orphaned `tool_result` blocks. Providers reject
such requests with HTTP 400 ("tool_result blocks must be a response to
a preceding tool_use"), which breaks the auto-compact retry path that
runs Trident before resending a request.

Stage 1 also counted a tool result as an independent file operation,
which pushed `last_write` past the write the result belonged to and made
the newest write look obsolete.

Only tool uses count as file operations now, and a tool result is
dropped together with the tool use that produced it.
2026-10-28 19:01:54 +00:00
45 changed files with 1585 additions and 596 deletions

No files matched your search

+1 -1
View File
@@ -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
+6 -1
View File
@@ -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
+39
View File
@@ -5,6 +5,45 @@ 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
- RemoteTrigger no longer panics when a webhook response over 8 KiB has a multibyte character across the truncation point; it is now cut on a character boundary (#3369)
## [0.1.9] - 2026-10-29
### Fixed
- Trident compaction no longer leaves orphaned tool_result blocks after superseding a file tool use, which made providers reject auto-compact retries with HTTP 400 (#3367)
## [0.1.8] - 2026-10-23
### Fixed
+42
View File
@@ -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/`.
+13 -11
View File
@@ -93,7 +93,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "api"
version = "0.1.8"
version = "0.1.13"
dependencies = [
"criterion",
"reqwest",
@@ -478,7 +478,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "claw-analog"
version = "0.1.8"
version = "0.1.13"
dependencies = [
"api",
"clap",
@@ -498,7 +498,7 @@ dependencies = [
[[package]]
name = "claw-rag-service"
version = "0.1.8"
version = "0.1.13"
dependencies = [
"axum 0.8.9",
"blake3",
@@ -531,7 +531,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "commands"
version = "0.1.8"
version = "0.1.13"
dependencies = [
"plugins",
"runtime",
@@ -540,7 +540,7 @@ dependencies = [
[[package]]
name = "compat-harness"
version = "0.1.8"
version = "0.1.13"
dependencies = [
"commands",
"runtime",
@@ -1568,7 +1568,7 @@ dependencies = [
[[package]]
name = "mock-anthropic-service"
version = "0.1.8"
version = "0.1.13"
dependencies = [
"api",
"serde_json",
@@ -1761,7 +1761,7 @@ dependencies = [
[[package]]
name = "plugins"
version = "0.1.8"
version = "0.1.13"
dependencies = [
"serde",
"serde_json",
@@ -2136,11 +2136,13 @@ dependencies = [
[[package]]
name = "runtime"
version = "0.1.8"
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.8"
version = "0.1.13"
dependencies = [
"api",
"commands",
@@ -2622,7 +2624,7 @@ dependencies = [
[[package]]
name = "telemetry"
version = "0.1.8"
version = "0.1.13"
dependencies = [
"serde",
"serde_json",
@@ -2902,7 +2904,7 @@ dependencies = [
[[package]]
name = "tools"
version = "0.1.8"
version = "0.1.13"
dependencies = [
"api",
"aspect-core",
+1 -1
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "0.1.8"
version = "0.1.13"
edition = "2021"
license = "MIT"
publish = false
+1 -11
View File
@@ -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");
+31 -8
View File
@@ -56,14 +56,14 @@ pub enum ApiError {
status: reqwest::StatusCode,
error_type: Option<String>,
message: Option<String>,
request_id: Option<String>,
request_id: Option<Box<str>>,
body: String,
retryable: bool,
/// Suggested user action based on error type (e.g., "Reduce prompt size" for 413)
suggested_action: Option<String>,
suggested_action: Option<&'static str>,
/// Parsed Retry-After header value (seconds) for 429 responses.
/// When present, overrides the exponential backoff delay.
retry_after: Option<Duration>,
retry_after: Option<Box<Duration>>,
},
RetriesExhausted {
attempts: u32,
@@ -137,7 +137,7 @@ impl ApiError {
/// over the computed backoff delay when it exists.
pub fn retry_after(&self) -> Option<Duration> {
match self {
Self::Api { retry_after, .. } => *retry_after,
Self::Api { retry_after, .. } => retry_after.as_deref().copied(),
Self::RetriesExhausted { last_error, .. } => last_error.retry_after(),
_ => None,
}
@@ -531,6 +531,29 @@ mod tests {
assert_eq!(snippet, "한글한글…");
}
#[test]
fn retry_after_survives_retry_exhaustion_wrapping() {
let delay = std::time::Duration::from_secs(42);
let error = ApiError::Api {
status: reqwest::StatusCode::TOO_MANY_REQUESTS,
error_type: None,
message: None,
request_id: Some("retry-trace".into()),
body: String::new(),
retryable: true,
suggested_action: Some("Wait before retrying"),
retry_after: Some(Box::new(delay)),
};
assert_eq!(error.retry_after(), Some(delay));
let wrapped = ApiError::RetriesExhausted {
attempts: 2,
last_error: Box::new(error),
};
assert_eq!(wrapped.retry_after(), Some(delay));
assert_eq!(wrapped.request_id(), Some("retry-trace"));
assert!(wrapped.is_retryable());
}
#[test]
fn detects_generic_fatal_wrapper_and_classifies_it_as_provider_internal() {
let error = ApiError::Api {
@@ -540,7 +563,7 @@ mod tests {
"Something went wrong while processing your request. Please try again, or use /new to start a fresh session."
.to_string(),
),
request_id: Some("req_jobdori_123".to_string()),
request_id: Some("req_jobdori_123".into()),
body: String::new(),
retryable: true,
suggested_action: None,
@@ -564,7 +587,7 @@ mod tests {
"Something went wrong while processing your request. Please try again, or use /new to start a fresh session."
.to_string(),
),
request_id: Some("req_nested_456".to_string()),
request_id: Some("req_nested_456".into()),
body: String::new(),
retryable: true,
suggested_action: None,
@@ -586,7 +609,7 @@ mod tests {
"This model's maximum context length is 200000 tokens, but your request used 230000 tokens."
.to_string(),
),
request_id: Some("req_ctx_123".to_string()),
request_id: Some("req_ctx_123".into()),
body: String::new(),
retryable: false,
suggested_action: None,
@@ -607,7 +630,7 @@ mod tests {
"Input tokens exceed the configured limit of 922000 tokens. Your messages resulted in 1860900 tokens. Please reduce the length of the messages."
.to_string(),
),
request_id: Some("req_ctx_openai_123".to_string()),
request_id: Some("req_ctx_openai_123".into()),
body: String::new(),
retryable: false,
suggested_action: None,
+10
View File
@@ -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)
}
+1 -8
View File
@@ -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");
+93 -13
View File
@@ -886,11 +886,11 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
}
let headers = response.headers().clone();
let request_id = request_id_from_headers(&headers);
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 retry_after = parse_retry_after(&headers, 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 {
status,
@@ -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]
@@ -1651,7 +1731,7 @@ mod tests {
status: reqwest::StatusCode::UNAUTHORIZED,
error_type: Some("authentication_error".to_string()),
message: Some("Invalid bearer token".to_string()),
request_id: Some("req_varleg_001".to_string()),
request_id: Some("req_varleg_001".into()),
body: String::new(),
retryable: false,
suggested_action: None,
+1 -12
View File
@@ -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
+112 -25
View File
@@ -261,7 +261,7 @@ impl OpenAiCompatClient {
.and_then(|t| t.as_str())
.map(str::to_owned),
message: Some(msg),
request_id,
request_id: request_id.map(String::into_boxed_str),
body,
retryable: false,
suggested_action: suggested_action_for_status(
@@ -1653,7 +1653,7 @@ fn parse_sse_frame(
request_id: None,
body: trimmed.chars().take(200).collect(),
retryable: false,
suggested_action: Some("verify the API endpoint URL is correct".to_string()),
suggested_action: Some("verify the API endpoint URL is correct"),
retry_after: None,
});
}
@@ -1706,7 +1706,7 @@ fn parse_sse_frame(
request_id: None,
body: payload.chars().take(200).collect(),
retryable: false,
suggested_action: Some("verify the API endpoint URL is correct".to_string()),
suggested_action: Some("verify the API endpoint URL is correct"),
retry_after: None,
});
}
@@ -1760,11 +1760,11 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
}
let headers = response.headers().clone();
let request_id = request_id_from_headers(&headers);
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 retry_after = parse_retry_after(&headers, 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
@@ -1819,14 +1822,14 @@ fn is_retryable_400(status: reqwest::StatusCode, body: &str) -> bool {
/// Generate a suggested user action based on the HTTP status code and error context.
/// This provides actionable guidance when API requests fail.
fn suggested_action_for_status(status: reqwest::StatusCode) -> Option<String> {
fn suggested_action_for_status(status: reqwest::StatusCode) -> Option<&'static str> {
match status.as_u16() {
401 => Some("Check API key is set correctly and has not expired".to_string()),
403 => Some("Verify API key has required permissions for this operation".to_string()),
413 => Some("Reduce prompt size or context window before retrying".to_string()),
429 => Some("Wait a moment before retrying; consider reducing request rate".to_string()),
500 => Some("Provider server error - retry after a brief wait".to_string()),
502..=504 => Some("Provider gateway error - retry after a brief wait".to_string()),
401 => Some("Check API key is set correctly and has not expired"),
403 => Some("Verify API key has required permissions for this operation"),
413 => Some("Reduce prompt size or context window before retrying"),
429 => Some("Wait a moment before retrying; consider reducing request rate"),
500 => Some("Provider server error - retry after a brief wait"),
502..=504 => Some("Provider gateway error - retry after a brief wait"),
_ => None,
}
}
@@ -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");
+1 -1
View File
@@ -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.8")
Some("claude-code/0.1.13")
);
assert_eq!(
request.headers.get("anthropic-beta").map(String::as_str),
+22 -8
View File
@@ -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));
}
+2 -2
View File
@@ -166,8 +166,8 @@ pub fn blob_to_f32_vec(blob: &[u8], dim: usize) -> Option<Vec<f32>> {
return None;
}
let mut v = Vec::with_capacity(dim);
for chunk in blob.chunks_exact(4) {
v.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
for chunk in blob.as_chunks::<4>().0 {
v.push(f32::from_le_bytes(*chunk));
}
Some(v)
}
+1 -1
View File
@@ -66,7 +66,7 @@ async fn flush_path_batch(
#[cfg(feature = "qdrant-index")]
let mut qdrant_points: Vec<ChunkPoint> = Vec::with_capacity(batch.len());
for ((ord, t), vec) in batch.drain(..).zip(vecs.into_iter()) {
for ((ord, t), vec) in batch.drain(..).zip(vecs) {
let dim = vec.len();
let cid = insert_chunk(conn, path, ord, &t)?;
insert_embedding(conn, cid, dim, &vec)?;
+2 -2
View File
@@ -7328,8 +7328,8 @@ mod tests {
.find(|r| r.origin == SkillOrigin::Bundled)
.expect("should have bundled skills root");
let loaded_skills =
load_skills_from_roots(&[bundled_root.clone()]).expect("should load bundled skills");
let loaded_skills = load_skills_from_roots(std::slice::from_ref(bundled_root))
.expect("should load bundled skills");
assert!(
!loaded_skills.is_empty() || bundled_root.path.exists(),
+64 -32
View File
@@ -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(),
+49 -24
View File
@@ -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);
}
}
+2
View File
@@ -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"
+37 -2
View File
@@ -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"));
+1 -1
View File
@@ -826,7 +826,7 @@ mod tests {
"message[{}] is a ToolResult but message[{}] has no ToolUse: {:?}",
i,
i - 1,
&messages[i - 1].blocks
messages[i - 1].blocks
);
}
}
+2 -2
View File
@@ -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");
+9 -19
View File
@@ -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;
+179 -24
View File
@@ -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.
@@ -368,12 +442,10 @@ fn extract_merge_conflicts(lines: &[&str]) -> Vec<ConflictBlock> {
}
// Check for separator
if current.trim() == SEPARATOR {
if separator_line.is_none() {
separator_line = Some(j + 1);
j += 1;
continue;
}
if current.trim() == SEPARATOR && separator_line.is_none() {
separator_line = Some(j + 1);
j += 1;
continue;
}
// Check for end marker
@@ -419,13 +491,12 @@ fn extract_merge_conflicts(lines: &[&str]) -> Vec<ConflictBlock> {
/// Returns the label text (stripped of leading/trailing spaces) if the line matches the prefix,
/// or None otherwise.
fn extract_marker_label<'a>(line: &'a str, prefix: &str) -> Option<&'a str> {
if line.starts_with(prefix) {
let rest = &line[prefix.len()..];
if let Some(rest) = line.strip_prefix(prefix) {
// Accept either just the prefix or prefix + single space + label
if rest.is_empty() {
Some("")
} else if rest.starts_with(' ') {
Some(rest[1..].trim())
} else if let Some(label) = rest.strip_prefix(' ') {
Some(label.trim())
} else {
None
}
@@ -652,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.
@@ -1126,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 {
@@ -1220,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"));
}
@@ -1341,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() {
@@ -1371,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);
}
@@ -1479,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()
+44 -35
View File
@@ -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()
}
}
+14
View File
@@ -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)
)
}
@@ -58,6 +58,11 @@ impl LineRange {
}
}
/// Check if this range contains no lines.
pub fn is_empty(&self) -> bool {
self.start_line > self.end_line
}
/// Check if this range is valid.
pub fn is_valid(&self) -> bool {
self.start_line > 0 && self.end_line > 0 && self.start_line <= self.end_line
@@ -344,4 +349,13 @@ mod tests {
assert_eq!(LineRange::new(50, 200).len(), 151);
assert_eq!(LineRange::new(50, 50).len(), 1);
}
#[test]
fn test_line_range_is_empty() {
assert!(LineRange::new(51, 50).is_empty());
assert_eq!(LineRange::new(51, 50).len(), 0);
assert!(!LineRange::new(50, 50).is_empty());
assert!(!LineRange::new(50, 200).is_empty());
assert!(!LineRange::new(0, 0).is_empty());
}
}
+24 -30
View File
@@ -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,
}),
+4 -5
View File
@@ -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()),
+3 -3
View File
@@ -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))
}
+6
View File
@@ -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)
+140 -40
View File
@@ -29,7 +29,7 @@ impl Default for TridentConfig {
}
/// Statistics from a Trident compaction run.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TridentStats {
pub superseded_count: usize,
pub collapsed_chains: usize,
@@ -41,21 +41,6 @@ pub struct TridentStats {
pub final_message_count: usize,
}
impl Default for TridentStats {
fn default() -> Self {
Self {
superseded_count: 0,
collapsed_chains: 0,
messages_collapsed: 0,
clusters_found: 0,
messages_clustered: 0,
tokens_saved_estimate: 0,
original_message_count: 0,
final_message_count: 0,
}
}
}
impl TridentStats {
pub fn format_report(&self) -> String {
let compression = if self.final_message_count > 0 {
@@ -192,7 +177,7 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec<ConversationMessag
let mut obsolete_indices: BTreeSet<usize> = BTreeSet::new();
for (_path, ops) in &file_ops {
for ops in file_ops.values() {
if ops.len() < 2 {
continue;
}
@@ -205,17 +190,55 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec<ConversationMessag
if let Some(last_write) = last_write_idx {
for op in ops {
if op.op_type == FileOp::Read && op.index < last_write {
obsolete_indices.insert(op.index);
} else if (op.op_type == FileOp::Write || op.op_type == FileOp::Edit)
&& op.index < last_write
{
if op.index < last_write {
obsolete_indices.insert(op.index);
}
}
}
}
// A tool result is only meaningful together with the tool use that
// produced it. Dropping the tool use while keeping its result leaves an
// orphaned `tool_result` block, which providers reject with
// "tool_result blocks must be a response to a preceding tool_use".
let dropped_tool_use_ids: BTreeSet<&str> = obsolete_indices
.iter()
.filter_map(|index| messages.get(*index))
.flat_map(|message| message.blocks.iter())
.filter_map(|block| match block {
ContentBlock::ToolUse { id, .. } => Some(id.as_str()),
_ => None,
})
.collect();
for (index, message) in messages.iter().enumerate() {
if obsolete_indices.contains(&index) {
continue;
}
let answers_dropped_tool_use = message.blocks.iter().any(|block| {
matches!(
block,
ContentBlock::ToolResult { tool_use_id, .. }
if dropped_tool_use_ids.contains(tool_use_id.as_str())
)
});
if !answers_dropped_tool_use {
continue;
}
// Keep a message that still carries a surviving tool use: dropping it
// would orphan that tool use instead.
let keeps_surviving_tool_use = message.blocks.iter().any(|block| {
matches!(
block,
ContentBlock::ToolUse { id, .. }
if !dropped_tool_use_ids.contains(id.as_str())
)
});
if !keeps_surviving_tool_use {
obsolete_indices.insert(index);
}
}
let superseded_count = obsolete_indices.len();
let kept: Vec<ConversationMessage> = messages
.iter()
@@ -239,18 +262,11 @@ fn extract_file_operation(block: &ContentBlock) -> Option<(String, FileOp)> {
};
Some((path, op_type))
}
ContentBlock::ToolResult {
tool_name, output, ..
} => {
let path = extract_path_from_tool_output(tool_name, output)?;
let op_type = match tool_name.as_str() {
"read_file" | "Read" => FileOp::Read,
"write_file" | "Write" => FileOp::Write,
"edit_file" | "Edit" => FileOp::Edit,
_ => return None,
};
Some((path, op_type))
}
// A tool result is the answer half of a tool use, not an independent
// file operation. Counting it as one shifts `last_write` past the
// write it belongs to, which makes the newest write look obsolete
// and splits its tool-use/tool-result pair.
ContentBlock::ToolResult { .. } => None,
ContentBlock::Text { .. } => None,
ContentBlock::Thinking { .. } => None,
}
@@ -325,7 +341,7 @@ fn stage2_collapse(
usage: None,
});
} else {
result.extend(buffer.drain(..));
result.append(&mut buffer);
}
buffer.clear();
result.push(msg.clone());
@@ -476,7 +492,7 @@ fn stage3_cluster(
}
let total_clustered: usize = cluster_assignments.len();
let clusters_found = cluster_id as usize;
let clusters_found = cluster_id;
let mut result: Vec<ConversationMessage> = Vec::new();
let mut cluster_buffers: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
@@ -677,7 +693,7 @@ fn truncate_text(text: &str, max_chars: usize) -> String {
mod tests {
use super::*;
use crate::compact::CompactionConfig;
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
use crate::session::{ContentBlock, ConversationMessage, Session};
#[test]
fn stage1_removes_obsolete_file_reads() {
@@ -732,11 +748,95 @@ mod tests {
assert_eq!(kept.len(), messages.len());
}
#[test]
fn stage1_keeps_tool_use_and_result_paired() {
// Realistic transcript: the model reads the same file twice and then
// rewrites it. Stage 1 must drop the obsolete reads *and* their tool
// results, but it must never leave a `tool_result` behind without the
// `tool_use` that produced it: providers reject such requests with
// "tool_result blocks must be a response to a preceding tool_use".
let messages = vec![
ConversationMessage::assistant(vec![ContentBlock::ToolUse {
id: "1".to_string(),
name: "read_file".to_string(),
input: r#"{"path":"src/main.rs"}"#.to_string(),
}]),
ConversationMessage::tool_result(
"1",
"read_file",
r#"{"type":"text","file":{"filePath":"/ws/src/main.rs","content":"old"}}"#,
false,
),
ConversationMessage::assistant(vec![ContentBlock::ToolUse {
id: "2".to_string(),
name: "read_file".to_string(),
input: r#"{"path":"src/main.rs"}"#.to_string(),
}]),
ConversationMessage::tool_result(
"2",
"read_file",
r#"{"type":"text","file":{"filePath":"/ws/src/main.rs","content":"old"}}"#,
false,
),
ConversationMessage::assistant(vec![ContentBlock::ToolUse {
id: "3".to_string(),
name: "write_file".to_string(),
input: r#"{"path":"src/main.rs","content":"new"}"#.to_string(),
}]),
ConversationMessage::tool_result(
"3",
"write_file",
r#"{"type":"update","filePath":"/ws/src/main.rs","content":"new"}"#,
false,
),
];
let (kept, superseded) = stage1_supersede(&messages);
assert!(superseded > 0, "obsolete reads should be superseded");
let tool_use_ids: BTreeSet<&str> = kept
.iter()
.flat_map(|message| message.blocks.iter())
.filter_map(|block| match block {
ContentBlock::ToolUse { id, .. } => Some(id.as_str()),
_ => None,
})
.collect();
for message in &kept {
for block in &message.blocks {
if let ContentBlock::ToolResult { tool_use_id, .. } = block {
assert!(
tool_use_ids.contains(tool_use_id.as_str()),
"orphaned tool_result for tool_use {tool_use_id}"
);
}
}
}
// The most recent write survives, together with its tool result.
let has_tool_use = |id: &str| {
kept.iter().any(|message| {
message.blocks.iter().any(
|block| matches!(block, ContentBlock::ToolUse { id: tool_use_id, .. } if tool_use_id == id),
)
})
};
let has_tool_result = |id: &str| {
kept.iter().any(|message| {
message.blocks.iter().any(
|block| matches!(block, ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == id),
)
})
};
assert!(has_tool_use("3"), "the newest write must survive");
assert!(has_tool_result("3"), "its tool result must survive too");
}
#[test]
fn stage2_collapses_chatty_messages() {
let mut messages = vec![];
for i in 0..6 {
messages.push(ConversationMessage::user_text(&format!("ok {i}")));
messages.push(ConversationMessage::user_text(format!("ok {i}")));
messages.push(ConversationMessage::assistant(vec![ContentBlock::Text {
text: format!("got {i}"),
}]));
@@ -767,9 +867,9 @@ mod tests {
},
]));
messages.push(ConversationMessage::tool_result(
&format!("read_{i}"),
format!("read_{i}"),
"read_file",
&format!(r#"{{"path":"src/{i}.rs","content":"data {i}"}}"#),
format!(r#"{{"path":"src/{i}.rs","content":"data {i}"}}"#),
false,
));
}
+120 -87
View File
@@ -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") {
@@ -1950,9 +1956,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
// Only reject for known top-level subcommands that don't use compact.
let first = rest[0].as_str();
if is_known_top_level_subcommand(first) && first != "prompt" {
return Err(format!(
"invalid_flag_value: --compact is only supported with prompt mode.\nUsage: claw --compact \"<prompt>\" or echo \"<prompt>\" | claw --compact"
));
return Err("invalid_flag_value: --compact is only supported with prompt mode.\nUsage: claw --compact \"<prompt>\" or echo \"<prompt>\" | claw --compact".to_string());
}
}
@@ -3237,9 +3241,7 @@ fn parse_system_prompt_args(
})?;
// #99: validate --date is a plausible date string (no newlines, reasonable length)
if value.contains('\n') || value.contains('\r') {
return Err(format!(
"invalid_flag_value: --date value contains invalid characters.\nUsage: --date <YYYY-MM-DD>"
));
return Err("invalid_flag_value: --date value contains invalid characters.\nUsage: --date <YYYY-MM-DD>".to_string());
}
if value.len() > 20 {
return Err(format!(
@@ -3476,11 +3478,7 @@ impl DiagnosticCheck {
fn json_value(&self) -> Value {
// Derive a stable snake_case id from the check name for machine-readable keying (#704).
let id = self
.name
.to_ascii_lowercase()
.replace(' ', "_")
.replace('-', "_");
let id = self.name.to_ascii_lowercase().replace([' ', '-'], "_");
let mut value = Map::from_iter([
("id".to_string(), Value::String(id.clone())),
(
@@ -6974,16 +6972,15 @@ fn run_resume_command(
}
SlashCommand::Plugins { action, target } => {
// Only list is supported in resume mode (no runtime to reload)
match action.as_deref() {
Some(action @ ("install" | "uninstall" | "enable" | "disable" | "update")) => {
// #777: use interactive_only: prefix + \n hint so #776's classify/split
// emits error_kind:interactive_only + non-null hint instead of unknown+null.
// Orchestrators can now detect this and switch to a live REPL instead of retrying.
return Err(format!(
"interactive_only: /plugins {action} requires a live session to reload the plugin runtime.\nStart `claw` and run `/plugins {action}` inside the REPL, or use `claw plugins {action}` as a direct CLI command."
).into());
}
_ => {}
if let Some(action @ ("install" | "uninstall" | "enable" | "disable" | "update")) =
action.as_deref()
{
// #777: use interactive_only: prefix + \n hint so #776's classify/split
// emits error_kind:interactive_only + non-null hint instead of unknown+null.
// Orchestrators can now detect this and switch to a live REPL instead of retrying.
return Err(format!(
"interactive_only: /plugins {action} requires a live session to reload the plugin runtime.\nStart `claw` and run `/plugins {action}` inside the REPL, or use `claw plugins {action}` as a direct CLI command."
).into());
}
let cwd = env::current_dir()?;
let payload = plugins_command_payload_for(
@@ -8166,8 +8163,7 @@ impl LiveCli {
let max_compact_rounds = 4;
let preserve_schedule = [4, 2, 1, 0];
for round in 0..max_compact_rounds {
let preserve = preserve_schedule[round];
for (round, preserve) in preserve_schedule.into_iter().enumerate() {
println!(
" Auto-compacting session (round {}/{}, preserving {} recent messages)...",
round + 1,
@@ -8995,8 +8991,8 @@ impl LiveCli {
let cwd = env::current_dir()?;
// #803: reject flag-shaped tokens in list filter for BOTH text and JSON modes.
// Previously the guard was JSON-only (#793); text mode silently returned empty success.
if action.as_deref() == Some("list") {
if let Some(filter) = target.as_deref() {
if action == Some("list") {
if let Some(filter) = target {
if filter.starts_with('-') {
if matches!(output_format, CliOutputFormat::Json) {
// ROADMAP #817: this is a handled local inventory parse error.
@@ -10002,6 +9998,8 @@ fn print_status_snapshot(
Ok(())
}
// Keep the independently optional provenance fields explicit at rendering callsites.
#[allow(clippy::too_many_arguments)]
fn status_json_value(
model: Option<&str>,
usage: StatusUsage,
@@ -10498,9 +10496,7 @@ fn sandbox_json_value(status: &runtime::SandboxStatus) -> serde_json::Value {
// (#731: "not supported on macOS" is a degraded state, not a hard error;
// filesystem_active:true means partial containment is working)
// error = enabled but unsupported AND no filesystem sandbox either (nothing active)
let top_status = if !status.enabled {
"ok"
} else if status.active {
let top_status = if !status.enabled || status.active {
"ok"
} else if status.supported {
"warn"
@@ -10874,10 +10870,7 @@ fn render_doctor_help_json() -> serde_json::Value {
})
}
/// #683-#692: extract structured metadata from help prose
fn extract_help_metadata(
topic: LocalHelpTopic,
) -> (
type HelpMetadata = (
Option<String>, // usage
Option<String>, // purpose
Option<String>, // output description
@@ -10886,7 +10879,10 @@ fn extract_help_metadata(
Option<Vec<String>>, // aliases
bool, // local_only
bool, // requires_credentials
) {
);
/// #683-#692: extract structured metadata from help prose
fn extract_help_metadata(topic: LocalHelpTopic) -> HelpMetadata {
let text = render_help_topic(topic);
let mut usage = None;
let mut purpose = None;
@@ -11945,7 +11941,7 @@ fn render_prompt_history_report(entries: &[PromptHistoryEntry], limit: usize) ->
"Prompt history".to_string(),
format!(" Total {total}"),
format!(" Showing {} most recent", shown.len()),
format!(" Reverse search Ctrl-R in the REPL"),
" Reverse search Ctrl-R in the REPL".to_string(),
String::new(),
];
for (offset, entry) in shown.iter().enumerate() {
@@ -13204,6 +13200,8 @@ struct AnthropicRuntimeClient {
}
impl AnthropicRuntimeClient {
// These independent runtime dependencies are supplied together at construction.
#[allow(clippy::too_many_arguments)]
fn new(
session_id: &str,
model: String,
@@ -14743,37 +14741,37 @@ fn convert_message(message: &ConversationMessage) -> Option<InputMessage> {
let content = message
.blocks
.iter()
.filter_map(|block| match block {
ContentBlock::Text { text } => Some(InputContentBlock::Text { text: text.clone() }),
.map(|block| match block {
ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
ContentBlock::Thinking {
thinking,
signature,
} => {
// 保留 Thinking 块:OpenAI 兼容协议会把它转成 reasoning_content 字段
// 回传给 DeepSeek V4(避免 400 "reasoning_content must be passed back" 错误)
Some(InputContentBlock::Thinking {
InputContentBlock::Thinking {
thinking: thinking.clone(),
signature: signature.clone(),
})
}
}
ContentBlock::ToolUse { id, name, input } => Some(InputContentBlock::ToolUse {
ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
id: id.clone(),
name: name.clone(),
input: serde_json::from_str(input)
.unwrap_or_else(|_| serde_json::json!({ "raw": input })),
}),
},
ContentBlock::ToolResult {
tool_use_id,
output,
is_error,
..
} => Some(InputContentBlock::ToolResult {
} => InputContentBlock::ToolResult {
tool_use_id: tool_use_id.clone(),
content: vec![ToolResultContentBlock::Text {
text: output.clone(),
}],
is_error: *is_error,
}),
},
})
.collect::<Vec<_>>();
(!content.is_empty()).then(|| InputMessage {
@@ -15160,7 +15158,7 @@ mod tests {
"Something went wrong while processing your request. Please try again, or use /new to start a fresh session."
.to_string(),
),
request_id: Some("req_jobdori_789".to_string()),
request_id: Some("req_jobdori_789".into()),
body: String::new(),
retryable: true,
suggested_action: None,
@@ -15184,7 +15182,7 @@ mod tests {
"Something went wrong while processing your request. Please try again, or use /new to start a fresh session."
.to_string(),
),
request_id: Some("req_jobdori_790".to_string()),
request_id: Some("req_jobdori_790".into()),
body: String::new(),
retryable: true,
suggested_action: None,
@@ -15249,7 +15247,7 @@ mod tests {
"This model's maximum context length is 200000 tokens, but your request used 230000 tokens."
.to_string(),
),
request_id: Some("req_ctx_456".to_string()),
request_id: Some("req_ctx_456".into()),
body: String::new(),
retryable: false,
suggested_action: None,
@@ -15283,7 +15281,7 @@ mod tests {
"Input tokens exceed the configured limit of 922000 tokens. Your messages resulted in 1860900 tokens. Please reduce the length of the messages."
.to_string(),
),
request_id: Some("req_ctx_openai_456".to_string()),
request_id: Some("req_ctx_openai_456".into()),
body: String::new(),
retryable: false,
suggested_action: None,
@@ -15318,7 +15316,7 @@ mod tests {
status: "413".parse().expect("status"),
error_type: Some("invalid_request_error".to_string()),
message: Some("Request is too large for this model's context window.".to_string()),
request_id: Some("req_ctx_retry_789".to_string()),
request_id: Some("req_ctx_retry_789".into()),
body: String::new(),
retryable: false,
suggested_action: None,
@@ -15411,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!(
@@ -16055,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(),
];
@@ -16070,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,
@@ -16705,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"
@@ -17046,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"
@@ -17787,7 +17812,7 @@ mod tests {
for action in ["remove", "uninstall", "delete"] {
assert_eq!(
parse_args(&["skills".to_string(), action.to_string()])
.expect(&format!("skills {action} should parse")),
.unwrap_or_else(|_| panic!("skills {action} should parse")),
CliAction::Skills {
args: Some(action.to_string()),
output_format: CliOutputFormat::Text,
@@ -19338,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}"
);
@@ -20323,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");
@@ -20526,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"
);
@@ -20535,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"
);
+8 -16
View File
@@ -632,7 +632,7 @@ impl TerminalRenderer {
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct MarkdownStreamState {
pending: String,
/// Track whether the last emitted output ended with a newline.
@@ -640,15 +640,6 @@ pub struct MarkdownStreamState {
last_output_ends_with_newline: bool,
}
impl Default for MarkdownStreamState {
fn default() -> Self {
Self {
pending: String::new(),
last_output_ends_with_newline: false,
}
}
}
impl MarkdownStreamState {
#[must_use]
pub fn push(&mut self, renderer: &TerminalRenderer, delta: &str) -> Option<String> {
@@ -662,16 +653,16 @@ impl MarkdownStreamState {
// If the previous output didn't end with a newline and this chunk starts
// with a block-level element, ensure proper spacing before it.
if !self.last_output_ends_with_newline && !rendered.is_empty() {
if stripped.starts_with("\u{256d}\u{2500}")
if !self.last_output_ends_with_newline
&& !rendered.is_empty()
&& (stripped.starts_with("\u{256d}\u{2500}")
|| stripped.starts_with("```")
|| stripped.starts_with("|")
|| stripped.starts_with("• ")
|| stripped.starts_with("1. ")
|| stripped.starts_with("-")
{
rendered = format!("\n\n{}", rendered);
}
|| stripped.starts_with("-"))
{
rendered = format!("\n\n{}", rendered);
}
// When markdown ends with a code block closing marker or other block-level element,
@@ -916,6 +907,7 @@ fn normalize_nested_fences(markdown: &str) -> String {
/// Safe boundaries are:
/// - After a closing fence line (when fence is closed)
/// - After an empty line
///
/// This function returns the position of the last safe boundary found.
fn find_stream_safe_boundary(markdown: &str) -> Option<usize> {
let mut open_fence: Option<FenceMarker> = None;
@@ -2,8 +2,6 @@ use std::io::{self, IsTerminal, Write};
use runtime::{save_user_provider_settings, ConfigLoader, RuntimeProviderConfig};
use serde_json;
const PROVIDERS: &[(&str, &str, &str)] = &[
("1", "Anthropic", "anthropic"),
("2", "xAI / Grok", "xai"),
@@ -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]
@@ -1016,13 +1034,13 @@ fn inventory_commands_emit_structured_json_when_requested() {
assert!(
!plugins
.as_object()
.map_or(false, |o| o.contains_key("reload_runtime")),
.is_some_and(|o| o.contains_key("reload_runtime")),
"plugins list should not include reload_runtime"
);
assert!(
!plugins
.as_object()
.map_or(false, |o| o.contains_key("target")),
.is_some_and(|o| o.contains_key("target")),
"plugins list should not include target"
);
// #703: structured summary replaces prose message
@@ -1716,13 +1734,13 @@ fn resumed_inventory_commands_emit_structured_json_when_requested() {
assert!(
!plugins
.as_object()
.map_or(false, |o| o.contains_key("reload_runtime")),
.is_some_and(|o| o.contains_key("reload_runtime")),
"plugins list should not include reload_runtime"
);
assert!(
!plugins
.as_object()
.map_or(false, |o| o.contains_key("target")),
.is_some_and(|o| o.contains_key("target")),
"plugins list should not include target"
);
assert!(
@@ -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");
@@ -2955,7 +2973,7 @@ fn prompt_empty_arg_json_stdout_missing_prompt_823() {
"claw prompt empty arg must retain abort action (#823); got: {parsed}"
);
assert!(
parsed["hint"].as_str().map_or(false, |h| !h.is_empty()),
parsed["hint"].as_str().is_some_and(|h| !h.is_empty()),
"claw prompt empty arg missing_prompt hint must be non-empty (#823)"
);
}
@@ -2993,9 +3011,9 @@ fn flag_value_errors_have_error_kind_and_hint_756() {
"invalid --reasoning-effort must be invalid_flag_value (#756): {parsed}"
);
assert!(
parsed["hint"].as_str().map_or(false, |h| h.contains("low")
|| h.contains("medium")
|| h.contains("high")),
parsed["hint"]
.as_str()
.is_some_and(|h| h.contains("low") || h.contains("medium") || h.contains("high")),
"hint must mention valid values (#756): {parsed}"
);
@@ -3021,7 +3039,7 @@ fn flag_value_errors_have_error_kind_and_hint_756() {
"missing --model value must be missing_flag_value (#756): {parsed2}"
);
assert!(
parsed2["hint"].as_str().map_or(false, |h| !h.is_empty()),
parsed2["hint"].as_str().is_some_and(|h| !h.is_empty()),
"missing --model hint must be non-empty (#756): {parsed2}"
);
}
@@ -3265,7 +3283,7 @@ fn short_p_flag_swallows_no_flags_755() {
"flag-like token after -p must be rejected as missing_prompt (#755): {parsed2}"
);
assert!(
parsed2["hint"].as_str().map_or(false, |h| !h.is_empty()),
parsed2["hint"].as_str().is_some_and(|h| !h.is_empty()),
"missing_prompt hint must be non-empty (#755)"
);
}
@@ -3407,7 +3425,7 @@ fn config_unsupported_section_json_hint_741() {
assert!(
parsed["supported_sections"]
.as_array()
.map_or(false, |a| !a.is_empty()),
.is_some_and(|a| !a.is_empty()),
"config {section} JSON must include supported_sections (#741)"
);
}
@@ -3464,11 +3482,6 @@ fn export_json_has_kind_702() {
// On success stdout has kind:export; on failure stderr has type:error.
// Either way, both envelopes must be valid JSON.
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr)
.lines()
.filter(|l| l.starts_with('{'))
.collect::<Vec<_>>()
.join("");
if output.status.success() {
let parsed: serde_json::Value =
@@ -3557,8 +3570,6 @@ fn config_parse_error_has_typed_error_kind_and_hint_764() {
"malformed settings.json should cause non-zero exit"
);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let json_line = stdout
.lines()
.find(|l| l.trim_start().starts_with('{'))
@@ -3592,8 +3603,6 @@ fn login_logout_removed_subcommands_have_error_kind_and_hint_765() {
"claw {subcmd} should exit non-zero"
);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let json_line = stdout
.lines()
.find(|l| l.trim_start().starts_with('{'))
@@ -3732,8 +3741,6 @@ fn resume_non_slash_trailing_arg_has_typed_error_kind_and_hint_768() {
"claw --resume latest compact should exit non-zero"
);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let json_line = stdout
.lines()
.find(|l| l.trim_start().starts_with('{'))
@@ -3772,8 +3779,6 @@ fn session_with_unknown_subcommand_returns_interactive_only_not_credentials_767(
"claw session {sub} should exit non-zero"
);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let json_line = stdout
.lines()
.find(|l| l.trim_start().starts_with('{'))
@@ -3825,7 +3830,6 @@ fn slash_only_verbs_with_args_return_interactive_only_not_credentials_770() {
"claw {} should exit non-zero",
args.join(" ")
);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let json_line = stdout
@@ -3868,8 +3872,6 @@ fn agents_plugins_mcp_unknown_subcommand_have_hint_774() {
let output = run_claw(&root, &["--output-format", "json", "agents", "bogus"], &[]);
assert!(!output.status.success(), "agents bogus should fail");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let json_line = stdout
.lines()
.find(|l| l.trim_start().starts_with('{'))
@@ -3892,8 +3894,6 @@ fn agents_plugins_mcp_unknown_subcommand_have_hint_774() {
let output = run_claw(&root, &["--output-format", "json", "plugins", "bogus"], &[]);
assert!(!output.status.success(), "plugins bogus should fail");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let json_line = stdout
.lines()
.find(|l| l.trim_start().starts_with('{'))
@@ -3911,7 +3911,6 @@ fn agents_plugins_mcp_unknown_subcommand_have_hint_774() {
{
let output = run_claw(&root, &["--output-format", "json", "mcp", "bogus"], &[]);
assert!(!output.status.success(), "mcp bogus should fail");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let json_str = if stdout.trim().starts_with('{') {
@@ -4026,7 +4025,6 @@ fn interactive_only_guard_batch_769_to_771() {
"claw {} should exit non-zero",
args.join(" ")
);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let json_line = stdout
@@ -4090,7 +4088,6 @@ fn resume_plugin_mutations_are_typed_interactive_only_777() {
!output.status.success(),
"/plugins {mutation} in resume mode should exit non-zero"
);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let json_line = stdout
@@ -4147,7 +4144,6 @@ fn resume_skills_invocation_is_typed_interactive_only_779() {
!output.status.success(),
"/skills <skill> in resume mode should exit non-zero"
);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let json_line = stdout
@@ -4189,8 +4185,6 @@ fn acp_unsupported_invocation_has_hint_782() {
let output = run_claw(&root, &["--output-format", "json", "acp", "start"], &[]);
assert!(!output.status.success(), "acp start should fail");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let json_line = stdout
.lines()
.find(|l| l.trim_start().starts_with('{'))
@@ -4226,7 +4220,6 @@ fn init_json_envelope_has_hint_and_already_initialized_783() {
// Fresh init — already_initialized should be false, hint should mention CLAUDE.md
let output = run_claw(&root, &["--output-format", "json", "init"], &[]);
assert!(output.status.success(), "init should succeed");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let raw = if stdout.trim_start().starts_with('{') {
@@ -4295,7 +4288,6 @@ fn init_json_envelope_has_hint_and_already_initialized_783() {
// Idempotent re-init — already_initialized should be true
let output2 = run_claw(&root, &["--output-format", "json", "init"], &[]);
assert!(output2.status.success(), "re-init should succeed");
let stdout2 = String::from_utf8_lossy(&output2.stdout);
let stderr2 = String::from_utf8_lossy(&output2.stderr);
let stdout2 = String::from_utf8_lossy(&output2.stdout);
let raw2 = if stdout2.trim_start().starts_with('{') {
@@ -4376,7 +4368,6 @@ fn export_arg_errors_have_typed_kind_and_hint_784() {
&[],
);
assert!(!out1.status.success(), "--output with no value should fail");
let stderr1 = String::from_utf8_lossy(&out1.stderr);
let stdout1 = String::from_utf8_lossy(&out1.stdout);
let j1: serde_json::Value = stdout1
.lines()
@@ -4403,7 +4394,6 @@ fn export_arg_errors_have_typed_kind_and_hint_784() {
&[],
);
assert!(!out2.status.success(), "extra positional should fail");
let stderr2 = String::from_utf8_lossy(&out2.stderr);
let stdout2 = String::from_utf8_lossy(&out2.stdout);
let j2: serde_json::Value = stdout2
.lines()
@@ -4440,7 +4430,6 @@ fn unknown_subcommand_returns_typed_kind_785() {
// "dump" is close enough to "dump-manifests" to trigger the typo suggestion path
let output = run_claw(&root, &["--output-format", "json", "dump"], &[]);
assert!(!output.status.success(), "unknown subcommand should fail");
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let j: serde_json::Value = stdout
.lines()
@@ -4488,7 +4477,6 @@ fn dump_manifests_missing_dir_has_typed_kind_and_hint_786() {
&[],
);
assert!(!out1.status.success());
let stderr1 = String::from_utf8_lossy(&out1.stderr);
let stdout1 = String::from_utf8_lossy(&out1.stdout);
let j1: serde_json::Value = stdout1
.lines()
@@ -4520,7 +4508,6 @@ fn dump_manifests_missing_dir_has_typed_kind_and_hint_786() {
&[],
);
assert!(!out2.status.success());
let stderr2 = String::from_utf8_lossy(&out2.stderr);
let stdout2 = String::from_utf8_lossy(&out2.stdout);
let j2: serde_json::Value = stdout2
.lines()
@@ -4570,7 +4557,6 @@ fn resume_directory_path_returns_typed_kind_and_hint_787() {
!output.status.success(),
"resume with directory should fail"
);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let j: serde_json::Value = stdout
.lines()
@@ -4620,7 +4606,6 @@ fn skills_show_not_found_emits_single_json_object_788() {
assert!(!output.status.success(), "skills show unknown should fail");
// Skills handler emits JSON to stdout; the duplicate was on stderr from the main error path.
// After fix: stdout has 1 JSON object, stderr has none (no duplicate).
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
@@ -4782,7 +4767,6 @@ fn system_prompt_unknown_option_returns_typed_kind_790() {
&[],
);
assert!(!out1.status.success());
let stderr1 = String::from_utf8_lossy(&out1.stderr);
let stdout1 = String::from_utf8_lossy(&out1.stdout);
let j1: serde_json::Value = stdout1
.lines()
@@ -4809,7 +4793,6 @@ fn system_prompt_unknown_option_returns_typed_kind_790() {
&[],
);
assert!(!out2.status.success());
let stderr2 = String::from_utf8_lossy(&out2.stderr);
let stdout2 = String::from_utf8_lossy(&out2.stdout);
let j2: serde_json::Value = stdout2
.lines()
@@ -4847,7 +4830,6 @@ fn config_extra_args_have_non_null_hint_791() {
&[],
);
assert!(!out1.status.success());
let stderr1 = String::from_utf8_lossy(&out1.stderr);
let stdout1 = String::from_utf8_lossy(&out1.stdout);
let j1: serde_json::Value = stdout1
.lines()
@@ -4881,7 +4863,6 @@ fn config_extra_args_have_non_null_hint_791() {
&[],
);
assert!(!out2.status.success());
let stderr2 = String::from_utf8_lossy(&out2.stderr);
let stdout2 = String::from_utf8_lossy(&out2.stdout);
let j2: serde_json::Value = stdout2
.lines()
@@ -5067,7 +5048,6 @@ fn plugins_uninstall_not_found_has_hint_793() {
"plugins uninstall not-found must exit non-zero (#793)"
);
// Error envelope goes to stderr (propagated via ? to main error handler)
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let j: serde_json::Value = stdout
.lines()
@@ -5112,7 +5092,6 @@ fn plugins_install_not_found_path_returns_typed_kind_794() {
!output.status.success(),
"plugins install not-found-path must exit non-zero (#794)"
);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let j: serde_json::Value = stdout
.lines()
@@ -5359,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());
@@ -5376,7 +5355,8 @@ fn agents_create_scaffolds_toml_and_lists_locally_431() {
.iter()
.any(|agent| {
agent["name"] == "my-agent"
&& PathBuf::from(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")
}));
}
@@ -5501,7 +5481,6 @@ fn plugins_extra_args_have_non_null_hint_797() {
!output.status.success(),
"plugins show with extra arg must exit non-zero (#797)"
);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let j: serde_json::Value = stdout
.lines()
@@ -5567,7 +5546,6 @@ fn plugins_list_trailing_dash_text_error_stays_on_stderr_817() {
String::from_utf8_lossy(&output.stdout)
);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stderr.contains("[error-kind: cli_parse]"), "{stderr}");
assert!(
stderr.contains("unknown option for `claw plugins list`: --"),
@@ -5592,7 +5570,6 @@ fn empty_prompt_has_non_null_hint_798() {
!output.status.success(),
"empty prompt must exit non-zero (#798)"
);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let j: serde_json::Value = stdout
.lines()
@@ -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};
@@ -109,7 +111,7 @@ fn parallel_tool_calls_are_answered_in_one_following_message() {
}
/// Writes the permission preset the mock harness runs with.
fn prepare_config_home(config_home: &PathBuf) {
fn prepare_config_home(config_home: &std::path::Path) {
fs::write(
config_home.join("settings.json"),
json!({ "permissions": { "defaultMode": "read-only" } }).to_string(),
@@ -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)
+237 -39
View File
@@ -2096,9 +2096,13 @@ fn run_remote_trigger(input: RemoteTriggerInput) -> Result<String, String> {
let status = response.status().as_u16();
let body = response.text().unwrap_or_default();
let truncated_body = if body.len() > 8192 {
let mut end = 8192;
while !body.is_char_boundary(end) {
end -= 1;
}
format!(
"{}\n\n[response truncated — {} bytes total]",
&body[..8192],
&body[..end],
body.len()
)
} else {
@@ -2724,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;
}
@@ -5993,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);
@@ -6429,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 {
@@ -6454,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)]
@@ -6782,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>,
@@ -7018,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> {
@@ -7345,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
@@ -8596,6 +8629,7 @@ mod tests {
assert!(output["path"]
.as_str()
.expect("path")
.replace('\\', "/")
.ends_with("/help/SKILL.md"));
assert!(output["prompt"]
.as_str()
@@ -8615,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 {
@@ -8654,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" }))
@@ -8663,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");
@@ -8701,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");
@@ -8763,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"],
@@ -8823,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");
@@ -8882,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");
@@ -8892,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"],
@@ -8949,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");
@@ -9865,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");
@@ -9877,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");
@@ -10194,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": "[" }))
@@ -10646,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
@@ -10656,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",
@@ -11437,4 +11591,48 @@ printf 'pwsh:%s' "$1"
.collect::<Vec<_>>();
assert_eq!(answered, vec!["toolu_multi_read", "toolu_multi_grep"]);
}
#[test]
fn remote_trigger_truncates_multibyte_char_at_boundary() {
// This test verifies that run_remote_trigger safely truncates a body
// when a multibyte UTF-8 character spans the 8192-byte boundary.
// Without the char_boundary check, &body[..8192] would panic.
let server = TestServer::spawn(Arc::new(|_request| {
// Create a body that's >8192 bytes with a 4-byte character (🦀) at byte 8192.
// Strategy: fill with ASCII to reach byte 8189, then place the 4-byte crab emoji
// so that byte 8192 falls in the middle of it.
let mut body = String::new();
// 8189 ASCII 'a' characters = 8189 bytes
for _ in 0..8189 {
body.push('a');
}
// Crab emoji (🦀) is 4 bytes in UTF-8: F0 9F A6 80
// Placing it now puts byte 8192 inside it (byte 8189+3=8192 is the 4th byte of the emoji)
body.push('\u{1F980}');
// Add more content to make it clear we truncated
body.push_str("extra_content_after_boundary");
HttpResponse::text(200, "OK", &body)
}));
let input = json!({
"url": format!("http://{}/test", server.addr()),
"method": "GET"
});
// This should not panic on the current head (with char_boundary check).
// On main (with &body[..8192]), this would panic because byte 8192
// is in the middle of the 4-byte crab emoji.
let result = execute_tool("RemoteTrigger", &input).expect("tool should succeed");
let output: serde_json::Value =
serde_json::from_str(&result).expect("result should be valid JSON");
// Verify the response was truncated
let body = output["body"].as_str().expect("body should be string");
assert!(body.contains("[response truncated"));
// The body should not contain the full extra_content_after_boundary
assert!(!body.contains("extra_content_after_boundary"));
// The truncation should have left us with valid UTF-8 (no partial emoji)
let _ = body.to_string(); // This validates it's valid Rust string (which is valid UTF-8)
}
}