mirror of
https://github.com/instructkr/claw-code.git
synced 2026-11-15 00:01:11 +08:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
072892ff1e | ||
|
|
407a6c62f8 | ||
|
|
ca48ec7500 | ||
|
|
ab8561dc37 | ||
|
|
747e160b59 | ||
|
|
6ac59ab90b | ||
|
|
67e5783894 | ||
|
|
db58b190bd | ||
|
|
51cfca114c | ||
|
|
1cae56fd3f | ||
|
|
a289024b35 | ||
|
|
f4fff38de1 | ||
|
|
3a802f7bf9 | ||
|
|
8523d59a84 | ||
|
|
8aab1d4e54 | ||
|
|
26611f44c5 | ||
|
|
fd8527f899 |
No files matched your search
@@ -126,7 +126,7 @@ jobs:
|
||||
run: cargo clippy --workspace
|
||||
|
||||
windows-smoke:
|
||||
name: windows PowerShell smoke
|
||||
name: Windows tests and PowerShell smoke
|
||||
runs-on: windows-latest
|
||||
defaults:
|
||||
run:
|
||||
@@ -138,6 +138,11 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c
|
||||
with:
|
||||
workspaces: rust -> target
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||
with:
|
||||
python-version: "3.x"
|
||||
- name: Run Windows workspace tests
|
||||
run: cargo test --workspace --locked --no-fail-fast
|
||||
- name: Build CLI for Windows smoke
|
||||
run: cargo build -p rusty-claude-cli
|
||||
- name: Smoke local commands without live credentials
|
||||
|
||||
@@ -5,6 +5,46 @@ 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.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
|
||||
|
||||
- Web search error handling: explicit 'Web search unavailable (HTTP N)' error on non-2xx responses instead of junk results (#3365)
|
||||
- Web search result filtering: drop links back to search engine host (#3365)
|
||||
|
||||
## [0.1.7] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
@@ -149,6 +149,48 @@ curl -sS "$OPENAI_BASE_URL/models" \
|
||||
|
||||
For local configuration, see your gateway's documentation for the correct base URL, API key format, and available model IDs. The same OPENAI_BASE_URL + OPENAI_API_KEY pattern applies to any OpenAI-compatible remote gateway.
|
||||
|
||||
## LLMTR
|
||||
|
||||
[LLMTR](https://llmtr.com) is an OpenAI-compatible gateway. Use the same client: set `OPENAI_BASE_URL` to `https://llmtr.com/v1` and `OPENAI_API_KEY` to a bearer key (`llmtr-...`).
|
||||
|
||||
```bash
|
||||
export OPENAI_BASE_URL="https://llmtr.com/v1"
|
||||
export OPENAI_API_KEY="llmtr-..."
|
||||
claw --model "local/llmtr/qwen3-8-27b" prompt "Reply exactly HELLO_WORLD_123"
|
||||
```
|
||||
|
||||
Send the catalog id unchanged. `llmtr/qwen3-8-27b` already starts with the gateway name, so that prefix stays on the wire. The `local/` hatch only selects the OpenAI-compatible transport and is removed before the request. A bare `qwen/` or `qwen-` name is a DashScope route and will not reach LLMTR, so vendor-prefixed ids need the same hatch: `local/<vendor>/<model>`.
|
||||
|
||||
Claw asks for up to 64,000 output tokens on these routes. LLMTR rejects a request whose `max_tokens` is above the model's output limit, so pick a model that allows at least that much. `llmtr/qwen3-8-27b` allows 262,144. Each model's limit is listed as `top_provider.max_completion_tokens` in the catalog below.
|
||||
|
||||
The public model list does not need a key:
|
||||
|
||||
```bash
|
||||
curl -sS "https://llmtr.com/v1/models" \
|
||||
| jq -r '.data[] | "\(.id)\t\(.top_provider.max_completion_tokens)"'
|
||||
```
|
||||
|
||||
## UberLLM
|
||||
|
||||
[UberLLM](https://uberllm.dev) is a pay-as-you-go OpenAI-compatible gateway (prepaid, no free tier). Set `OPENAI_BASE_URL` to `https://api.uberllm.dev/v1` and `OPENAI_API_KEY` to a bearer key (`ull_...`).
|
||||
|
||||
```bash
|
||||
export OPENAI_BASE_URL="https://api.uberllm.dev/v1"
|
||||
export OPENAI_API_KEY="ull_..."
|
||||
claw --model "local/deepseek/deepseek-v4-flash" prompt "Reply exactly HELLO_WORLD_123"
|
||||
```
|
||||
|
||||
UberLLM ids are vendor-prefixed (`deepseek/deepseek-v4-flash`, `moonshotai/kimi-k2.6`, `qwen/qwen3.8-flash`). Put `local/` in front of every one: the hatch selects the OpenAI-compatible transport and is removed, so the catalog id goes out unchanged. Some prefixes would otherwise pick a native route; a bare `qwen/qwen3.8-flash`, for example, goes to DashScope and asks for `DASHSCOPE_API_KEY`.
|
||||
|
||||
Claw asks for up to 64,000 output tokens on these routes. UberLLM does not reject that on a model with a smaller output limit (for example `aion-labs/aion-3-0-mini`, 32,768); the answer is simply bounded by the model's limit, listed as `max_output_tokens` in the catalog below.
|
||||
|
||||
The public model list does not need a key:
|
||||
|
||||
```bash
|
||||
curl -sS "https://api.uberllm.dev/v1/models" \
|
||||
| jq -r '.data[] | "\(.id)\t\(.max_output_tokens)"'
|
||||
```
|
||||
|
||||
## Local skills install from disk
|
||||
|
||||
Skills are discovered from Claw skill roots such as `.claw/skills/` in a workspace and `~/.claw/skills/` for user-level installs. Legacy `.codex/skills/` roots may also be scanned for compatibility, but new local Claw projects should prefer `.claw/skills/`.
|
||||
|
||||
Generated
+13
-11
@@ -93,7 +93,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "api"
|
||||
version = "0.1.6"
|
||||
version = "0.1.12"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"reqwest",
|
||||
@@ -478,7 +478,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "claw-analog"
|
||||
version = "0.1.6"
|
||||
version = "0.1.12"
|
||||
dependencies = [
|
||||
"api",
|
||||
"clap",
|
||||
@@ -498,7 +498,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "claw-rag-service"
|
||||
version = "0.1.6"
|
||||
version = "0.1.12"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"blake3",
|
||||
@@ -531,7 +531,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "commands"
|
||||
version = "0.1.6"
|
||||
version = "0.1.12"
|
||||
dependencies = [
|
||||
"plugins",
|
||||
"runtime",
|
||||
@@ -540,7 +540,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "compat-harness"
|
||||
version = "0.1.6"
|
||||
version = "0.1.12"
|
||||
dependencies = [
|
||||
"commands",
|
||||
"runtime",
|
||||
@@ -1568,7 +1568,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mock-anthropic-service"
|
||||
version = "0.1.6"
|
||||
version = "0.1.12"
|
||||
dependencies = [
|
||||
"api",
|
||||
"serde_json",
|
||||
@@ -1761,7 +1761,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "plugins"
|
||||
version = "0.1.6"
|
||||
version = "0.1.12"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2136,11 +2136,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "runtime"
|
||||
version = "0.1.6"
|
||||
version = "0.1.12"
|
||||
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.6"
|
||||
version = "0.1.12"
|
||||
dependencies = [
|
||||
"api",
|
||||
"commands",
|
||||
@@ -2622,7 +2624,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "telemetry"
|
||||
version = "0.1.6"
|
||||
version = "0.1.12"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2902,7 +2904,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tools"
|
||||
version = "0.1.6"
|
||||
version = "0.1.12"
|
||||
dependencies = [
|
||||
"api",
|
||||
"aspect-core",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.7"
|
||||
version = "0.1.12"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
@@ -281,21 +281,11 @@ pub fn read_xai_base_url() -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use crate::test_env_lock as env_lock;
|
||||
|
||||
use super::{ProviderClient, ProviderConfig, ProviderConfigKind};
|
||||
use crate::providers::{detect_provider_kind, resolve_model_alias, ProviderKind};
|
||||
|
||||
/// Serializes every test in this module that mutates process-wide
|
||||
/// environment variables so concurrent test threads cannot observe
|
||||
/// each other's partially-applied state.
|
||||
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_existing_and_grok_aliases() {
|
||||
assert_eq!(resolve_model_alias("opus"), "claude-opus-4-7");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -46,3 +46,13 @@ pub use telemetry::{
|
||||
MemoryTelemetrySink, SessionTraceRecord, SessionTracer, TelemetryEvent, TelemetrySink,
|
||||
DEFAULT_ANTHROPIC_VERSION,
|
||||
};
|
||||
|
||||
// Environment variables are shared by every test module in this process.
|
||||
// Module-local locks cannot protect provider tests from each other.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
|
||||
LOCK.get_or_init(|| std::sync::Mutex::new(()))
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
@@ -500,7 +500,7 @@ fn stable_hash_bytes(bytes: &[u8]) -> u64 {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use crate::test_env_lock;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use super::{
|
||||
@@ -509,13 +509,6 @@ mod tests {
|
||||
};
|
||||
use crate::types::{InputMessage, MessageRequest, MessageResponse, OutputContentBlock, Usage};
|
||||
|
||||
fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_builder_sanitizes_session_identifier() {
|
||||
let paths = PromptCachePaths::for_session("session:/with spaces");
|
||||
|
||||
@@ -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 retry_after = parse_retry_after(&headers, status).map(Box::new);
|
||||
|
||||
Err(ApiError::Api {
|
||||
status,
|
||||
@@ -1083,9 +1083,9 @@ struct AnthropicErrorBody {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ALT_REQUEST_ID_HEADER, REQUEST_ID_HEADER};
|
||||
use crate::test_env_lock as env_lock;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::thread;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -1097,13 +1097,6 @@ mod tests {
|
||||
};
|
||||
use crate::types::{ContentBlockDelta, MessageRequest};
|
||||
|
||||
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn temp_config_home() -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"api-oauth-test-{}-{}",
|
||||
@@ -1651,7 +1644,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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 retry_after = parse_retry_after(&headers, status).map(Box::new);
|
||||
|
||||
let suggested_action = suggested_action_for_status(status);
|
||||
|
||||
@@ -1819,14 +1819,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,
|
||||
}
|
||||
}
|
||||
@@ -1863,6 +1863,7 @@ mod tests {
|
||||
StreamState,
|
||||
};
|
||||
use crate::error::ApiError;
|
||||
use crate::test_env_lock as env_lock;
|
||||
use crate::types::{
|
||||
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
|
||||
InputContentBlock, InputMessage, MessageRequest, OutputContentBlock, StreamEvent,
|
||||
@@ -1871,7 +1872,6 @@ mod tests {
|
||||
use serde_json::json;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
#[test]
|
||||
fn request_translation_uses_openai_compatible_shape() {
|
||||
@@ -2371,13 +2371,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.expect("env lock")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_stop_reasons() {
|
||||
assert_eq!(normalize_finish_reason("stop"), "end_turn");
|
||||
|
||||
@@ -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.7")
|
||||
Some("claude-code/0.1.12")
|
||||
);
|
||||
assert_eq!(
|
||||
request.headers.get("anthropic-beta").map(String::as_str),
|
||||
|
||||
@@ -39,12 +39,13 @@ impl Drop for EnvVarGuard {
|
||||
fn proxy_config_from_env_reads_uppercase_proxy_vars() {
|
||||
// given
|
||||
let _lock = env_lock();
|
||||
let _http = EnvVarGuard::set("HTTP_PROXY", Some("http://proxy.corp:3128"));
|
||||
let _https = EnvVarGuard::set("HTTPS_PROXY", Some("http://secure.corp:3129"));
|
||||
let _no = EnvVarGuard::set("NO_PROXY", Some("localhost,127.0.0.1"));
|
||||
// Clear aliases before setting values: environment keys are case-insensitive on Windows.
|
||||
let _http_lower = EnvVarGuard::set("http_proxy", None);
|
||||
let _https_lower = EnvVarGuard::set("https_proxy", None);
|
||||
let _no_lower = EnvVarGuard::set("no_proxy", None);
|
||||
let _http = EnvVarGuard::set("HTTP_PROXY", Some("http://proxy.corp:3128"));
|
||||
let _https = EnvVarGuard::set("HTTPS_PROXY", Some("http://secure.corp:3129"));
|
||||
let _no = EnvVarGuard::set("NO_PROXY", Some("localhost,127.0.0.1"));
|
||||
|
||||
// when
|
||||
let config = ProxyConfig::from_env();
|
||||
@@ -127,13 +128,19 @@ fn proxy_config_from_env_treats_empty_values_as_unset() {
|
||||
fn build_client_with_env_proxy_config_succeeds() {
|
||||
// given
|
||||
let _lock = env_lock();
|
||||
let _http = EnvVarGuard::set("HTTP_PROXY", Some("http://proxy.corp:3128"));
|
||||
let _https = EnvVarGuard::set("HTTPS_PROXY", Some("http://secure.corp:3129"));
|
||||
let _no = EnvVarGuard::set("NO_PROXY", Some("localhost"));
|
||||
// Clear aliases before setting values: environment keys are case-insensitive on Windows.
|
||||
let _http_lower = EnvVarGuard::set("http_proxy", None);
|
||||
let _https_lower = EnvVarGuard::set("https_proxy", None);
|
||||
let _no_lower = EnvVarGuard::set("no_proxy", None);
|
||||
let _http = EnvVarGuard::set("HTTP_PROXY", Some("http://proxy.corp:3128"));
|
||||
let _https = EnvVarGuard::set("HTTPS_PROXY", Some("http://secure.corp:3129"));
|
||||
let _no = EnvVarGuard::set("NO_PROXY", Some("localhost"));
|
||||
let config = ProxyConfig::from_env();
|
||||
assert_eq!(config.http_proxy.as_deref(), Some("http://proxy.corp:3128"));
|
||||
assert_eq!(
|
||||
config.https_proxy.as_deref(),
|
||||
Some("http://secure.corp:3129")
|
||||
);
|
||||
|
||||
// when
|
||||
let result = build_http_client_with(&config);
|
||||
@@ -155,7 +162,7 @@ fn build_client_with_proxy_url_config_succeeds() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_config_from_env_prefers_uppercase_over_lowercase() {
|
||||
fn proxy_config_from_env_respects_platform_case_semantics() {
|
||||
// given
|
||||
let _lock = env_lock();
|
||||
let _http_upper = EnvVarGuard::set("HTTP_PROXY", Some("http://upper.corp:3128"));
|
||||
@@ -169,5 +176,12 @@ fn proxy_config_from_env_prefers_uppercase_over_lowercase() {
|
||||
let config = ProxyConfig::from_env();
|
||||
|
||||
// then
|
||||
assert_eq!(config.http_proxy.as_deref(), Some("http://upper.corp:3128"));
|
||||
// Unix retains two keys and prefers uppercase. On Windows the second
|
||||
// assignment replaces the same environment entry, regardless of casing.
|
||||
let expected = if cfg!(windows) {
|
||||
"http://lower.corp:3128"
|
||||
} else {
|
||||
"http://upper.corp:3128"
|
||||
};
|
||||
assert_eq!(config.http_proxy.as_deref(), Some(expected));
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use std::ffi::OsStr;
|
||||
#[cfg(not(windows))]
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
@@ -282,8 +281,18 @@ fn format_hook_warning(command: &str, code: i32, stdout: Option<&str>, stderr: &
|
||||
fn shell_command(command: &str) -> CommandWithStdin {
|
||||
#[cfg(windows)]
|
||||
let command_builder = {
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
// This is a configured shell command, not a single argv value. cmd
|
||||
// does not understand the C-runtime backslash escaping used by arg().
|
||||
let command = if Path::new(command).is_file() {
|
||||
format!("\"{command}\"")
|
||||
} else {
|
||||
command.to_string()
|
||||
};
|
||||
let mut command_builder = Command::new("cmd");
|
||||
command_builder.arg("/C").arg(command);
|
||||
command_builder.args(["/D", "/S", "/C"]);
|
||||
command_builder.raw_arg(format!("\"{command}\""));
|
||||
CommandWithStdin::new(command_builder)
|
||||
};
|
||||
|
||||
@@ -381,6 +390,14 @@ mod tests {
|
||||
std::env::temp_dir().join(format!("plugins-hook-runner-{label}-{nanos}"))
|
||||
}
|
||||
|
||||
fn plugin_hook(message: &str, code: i32) -> String {
|
||||
if cfg!(windows) {
|
||||
format!("echo {message}& exit /b {code}")
|
||||
} else {
|
||||
format!("printf '%s' '{message}'; exit {code}")
|
||||
}
|
||||
}
|
||||
|
||||
fn make_executable(path: &Path) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -403,34 +420,34 @@ mod tests {
|
||||
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
|
||||
fs::create_dir_all(root.join("hooks")).expect("hooks dir");
|
||||
|
||||
let pre_path = root.join("hooks").join("pre.sh");
|
||||
fs::write(
|
||||
&pre_path,
|
||||
format!("#!/bin/sh\nprintf '%s\\n' '{pre_message}'\n"),
|
||||
)
|
||||
.expect("write pre hook");
|
||||
make_executable(&pre_path);
|
||||
|
||||
let post_path = root.join("hooks").join("post.sh");
|
||||
fs::write(
|
||||
&post_path,
|
||||
format!("#!/bin/sh\nprintf '%s\\n' '{post_message}'\n"),
|
||||
)
|
||||
.expect("write post hook");
|
||||
make_executable(&post_path);
|
||||
|
||||
let failure_path = root.join("hooks").join("failure.sh");
|
||||
fs::write(
|
||||
&failure_path,
|
||||
format!("#!/bin/sh\nprintf '%s\\n' '{failure_message}'\n"),
|
||||
)
|
||||
.expect("write failure hook");
|
||||
make_executable(&failure_path);
|
||||
let extension = if cfg!(windows) { "cmd" } else { "sh" };
|
||||
for (phase, message) in [
|
||||
("pre", pre_message),
|
||||
("post", post_message),
|
||||
("failure", failure_message),
|
||||
] {
|
||||
let path = root.join("hooks").join(format!("{phase}.{extension}"));
|
||||
let script = if cfg!(windows) {
|
||||
format!("@echo off\r\necho {message}\r\n")
|
||||
} else {
|
||||
format!("#!/bin/sh\nprintf '%s\\n' '{message}'\n")
|
||||
};
|
||||
fs::write(&path, script).expect("write hook");
|
||||
make_executable(&path);
|
||||
}
|
||||
fs::write(
|
||||
root.join(".claude-plugin").join("plugin.json"),
|
||||
format!(
|
||||
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"hook plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"],\n \"PostToolUseFailure\": [\"./hooks/failure.sh\"]\n }}\n}}"
|
||||
),
|
||||
serde_json::json!({
|
||||
"name": name,
|
||||
"version": "1.0.0",
|
||||
"description": "hook plugin",
|
||||
"hooks": {
|
||||
"PreToolUse": [format!("./hooks/pre.{extension}")],
|
||||
"PostToolUse": [format!("./hooks/post.{extension}")],
|
||||
"PostToolUseFailure": [format!("./hooks/failure.{extension}")]
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("write plugin manifest");
|
||||
}
|
||||
@@ -439,7 +456,7 @@ mod tests {
|
||||
fn collects_and_runs_hooks_from_enabled_plugins() {
|
||||
// given
|
||||
let config_home = temp_dir("config");
|
||||
let first_source_root = temp_dir("source-a");
|
||||
let first_source_root = temp_dir("source a");
|
||||
let second_source_root = temp_dir("source-b");
|
||||
write_hook_plugin(
|
||||
&first_source_root,
|
||||
@@ -500,7 +517,7 @@ mod tests {
|
||||
fn pre_tool_use_denies_when_plugin_hook_exits_two() {
|
||||
// given
|
||||
let runner = HookRunner::new(crate::PluginHooks {
|
||||
pre_tool_use: vec!["printf 'blocked by plugin'; exit 2".to_string()],
|
||||
pre_tool_use: vec![plugin_hook("blocked by plugin", 2)],
|
||||
post_tool_use: Vec::new(),
|
||||
post_tool_use_failure: Vec::new(),
|
||||
});
|
||||
@@ -513,13 +530,28 @@ mod tests {
|
||||
assert_eq!(result.messages(), &["blocked by plugin".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_quoted_arguments_in_plugin_hooks() {
|
||||
let python = if cfg!(windows) { "python" } else { "python3" };
|
||||
let runner = HookRunner::new(crate::PluginHooks {
|
||||
pre_tool_use: vec![format!(
|
||||
"{python} -c \"import sys; sys.stdout.write('quoted plugin hook'); sys.exit(2)\""
|
||||
)],
|
||||
post_tool_use: Vec::new(),
|
||||
post_tool_use_failure: Vec::new(),
|
||||
});
|
||||
let result = runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#);
|
||||
assert!(result.is_denied(), "{result:?}");
|
||||
assert_eq!(result.messages(), &["quoted plugin hook".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagates_plugin_hook_failures() {
|
||||
// given
|
||||
let runner = HookRunner::new(crate::PluginHooks {
|
||||
pre_tool_use: vec![
|
||||
"printf 'broken plugin hook'; exit 1".to_string(),
|
||||
"printf 'later plugin hook'".to_string(),
|
||||
plugin_hook("broken plugin hook", 1),
|
||||
plugin_hook("later plugin hook", 0),
|
||||
],
|
||||
post_tool_use: Vec::new(),
|
||||
post_tool_use_failure: Vec::new(),
|
||||
|
||||
@@ -2502,20 +2502,42 @@ mod tests {
|
||||
|
||||
fn write_lifecycle_plugin(root: &Path, name: &str, version: &str) -> PathBuf {
|
||||
let log_path = root.join("lifecycle.log");
|
||||
let extension = if cfg!(windows) { "cmd" } else { "sh" };
|
||||
let (init_script, shutdown_script) = if cfg!(windows) {
|
||||
(
|
||||
"@echo off\r\necho init>>lifecycle.log\r\n",
|
||||
"@echo off\r\necho shutdown>>lifecycle.log\r\n",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"#!/bin/sh\nprintf 'init\\n' >> lifecycle.log\n",
|
||||
"#!/bin/sh\nprintf 'shutdown\\n' >> lifecycle.log\n",
|
||||
)
|
||||
};
|
||||
write_file(
|
||||
root.join("lifecycle").join("init.sh").as_path(),
|
||||
"#!/bin/sh\nprintf 'init\\n' >> lifecycle.log\n",
|
||||
root.join("lifecycle")
|
||||
.join(format!("init.{extension}"))
|
||||
.as_path(),
|
||||
init_script,
|
||||
);
|
||||
write_file(
|
||||
root.join("lifecycle").join("shutdown.sh").as_path(),
|
||||
"#!/bin/sh\nprintf 'shutdown\\n' >> lifecycle.log\n",
|
||||
root.join("lifecycle")
|
||||
.join(format!("shutdown.{extension}"))
|
||||
.as_path(),
|
||||
shutdown_script,
|
||||
);
|
||||
write_file(
|
||||
root.join(MANIFEST_RELATIVE_PATH).as_path(),
|
||||
format!(
|
||||
"{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"lifecycle plugin\",\n \"lifecycle\": {{\n \"Init\": [\"./lifecycle/init.sh\"],\n \"Shutdown\": [\"./lifecycle/shutdown.sh\"]\n }}\n}}"
|
||||
)
|
||||
.as_str(),
|
||||
&serde_json::json!({
|
||||
"name": name,
|
||||
"version": version,
|
||||
"description": "lifecycle plugin",
|
||||
"lifecycle": {
|
||||
"Init": [format!("./lifecycle/init.{extension}")],
|
||||
"Shutdown": [format!("./lifecycle/shutdown.{extension}")]
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
log_path
|
||||
}
|
||||
@@ -2525,25 +2547,28 @@ mod tests {
|
||||
}
|
||||
|
||||
fn write_tool_plugin_with_name(root: &Path, name: &str, version: &str, tool_name: &str) {
|
||||
let script_path = root.join("tools").join("echo-json.sh");
|
||||
let script_path = root.join("tools").join("echo-json.py");
|
||||
write_file(
|
||||
&script_path,
|
||||
"#!/bin/sh\nINPUT=$(cat)\nprintf '{\"plugin\":\"%s\",\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_PLUGIN_ID\" \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n",
|
||||
r#"import json, os, sys
|
||||
print(json.dumps({"plugin": os.environ["CLAWD_PLUGIN_ID"], "tool": os.environ["CLAWD_TOOL_NAME"], "input": json.load(sys.stdin)}))
|
||||
"#,
|
||||
);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let mut permissions = fs::metadata(&script_path).expect("metadata").permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, permissions).expect("chmod");
|
||||
}
|
||||
write_file(
|
||||
root.join(MANIFEST_RELATIVE_PATH).as_path(),
|
||||
format!(
|
||||
"{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"tool plugin\",\n \"tools\": [\n {{\n \"name\": \"{tool_name}\",\n \"description\": \"Echo JSON input\",\n \"inputSchema\": {{\"type\": \"object\", \"properties\": {{\"message\": {{\"type\": \"string\"}}}}, \"required\": [\"message\"], \"additionalProperties\": false}},\n \"command\": \"./tools/echo-json.sh\",\n \"requiredPermission\": \"workspace-write\"\n }}\n ]\n}}"
|
||||
)
|
||||
.as_str(),
|
||||
&serde_json::json!({
|
||||
"name": name,
|
||||
"version": version,
|
||||
"description": "tool plugin",
|
||||
"tools": [{
|
||||
"name": tool_name,
|
||||
"description": "Echo JSON input",
|
||||
"inputSchema": {"type": "object", "properties": {"message": {"type": "string"}}, "required": ["message"], "additionalProperties": false},
|
||||
"command": if cfg!(windows) { "python" } else { "python3" },
|
||||
"args": ["tools/echo-json.py"],
|
||||
"requiredPermission": "workspace-write"
|
||||
}]
|
||||
}).to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3623,7 +3648,7 @@ mod tests {
|
||||
registry.shutdown().expect("shutdown should succeed");
|
||||
|
||||
let log = fs::read_to_string(&log_path).expect("lifecycle log should exist");
|
||||
assert_eq!(log, "init\nshutdown\n");
|
||||
assert_eq!(log.replace("\r\n", "\n"), "init\nshutdown\n");
|
||||
|
||||
let _ = fs::remove_dir_all(config_home);
|
||||
let _ = fs::remove_dir_all(source_root);
|
||||
@@ -3820,7 +3845,7 @@ mod tests {
|
||||
if registry.initialize().is_ok() && registry.shutdown().is_ok() {
|
||||
// Verify lifecycle.log exists and has expected content
|
||||
if let Ok(log) = fs::read_to_string(&log_path) {
|
||||
if log == "init\nshutdown\n" {
|
||||
if log.replace("\r\n", "\n") == "init\nshutdown\n" {
|
||||
success_count.fetch_add(1, AtomicOrdering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,13 @@ publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
sha2 = "0.10"
|
||||
getrandom = "0.2"
|
||||
glob = "0.3"
|
||||
plugins = { path = "../plugins" }
|
||||
regex = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
same-file = "1.0.6"
|
||||
telemetry = { path = "../telemetry" }
|
||||
tokio = { version = "1", features = ["io-std", "io-util", "macros", "process", "rt", "rt-multi-thread", "time"] }
|
||||
walkdir = "2"
|
||||
|
||||
@@ -293,6 +293,41 @@ fn sandbox_status_for_input(input: &BashCommandInput, cwd: &std::path::Path) ->
|
||||
resolve_sandbox_status_for_request(&request, cwd)
|
||||
}
|
||||
|
||||
// Git for Windows normally adds Git/cmd, but not Git/bin, to PATH.
|
||||
// Prefer a native shell there rather than the Windows WSL bash launcher.
|
||||
fn posix_shell() -> std::path::PathBuf {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(path) = env::var_os("PATH") {
|
||||
for directory in env::split_paths(&path) {
|
||||
let shell = directory.join("sh.exe");
|
||||
if shell.is_file() {
|
||||
return shell;
|
||||
}
|
||||
if directory.join("git.exe").is_file() {
|
||||
if let Some(root) = directory.parent() {
|
||||
let shell = root.join("bin").join("sh.exe");
|
||||
if shell.is_file() {
|
||||
return shell;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for variable in ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"] {
|
||||
if let Some(root) = env::var_os(variable) {
|
||||
for relative in ["Git/bin/sh.exe", "Programs/Git/bin/sh.exe"] {
|
||||
let shell = std::path::PathBuf::from(&root).join(relative);
|
||||
if shell.is_file() {
|
||||
return shell;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
std::path::PathBuf::from("sh")
|
||||
}
|
||||
|
||||
fn prepare_command(
|
||||
command: &str,
|
||||
cwd: &std::path::Path,
|
||||
@@ -311,7 +346,7 @@ fn prepare_command(
|
||||
return prepared;
|
||||
}
|
||||
|
||||
let mut prepared = Command::new("sh");
|
||||
let mut prepared = Command::new(posix_shell());
|
||||
prepared.arg("-lc").arg(command).current_dir(cwd);
|
||||
if sandbox_status.filesystem_active {
|
||||
prepared.env("HOME", cwd.join(".sandbox-home"));
|
||||
@@ -337,7 +372,7 @@ fn prepare_tokio_command(
|
||||
cmd.envs(launcher.env);
|
||||
cmd
|
||||
} else {
|
||||
let mut cmd = TokioCommand::new("sh");
|
||||
let mut cmd = TokioCommand::new(posix_shell());
|
||||
cmd.arg("-lc").arg(command);
|
||||
if sandbox_status.filesystem_active {
|
||||
cmd.env("HOME", cwd.join(".sandbox-home"));
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2817,12 +2817,12 @@ mod tests {
|
||||
let command = format!("echo compromised >> {}", marker.display());
|
||||
fs::write(
|
||||
&project_legacy_settings,
|
||||
format!(r#"{{"hooks":{{"PreToolUse":["{command}"]}}}}"#),
|
||||
serde_json::json!({"hooks": {"PreToolUse": [command]}}).to_string(),
|
||||
)
|
||||
.expect("write untrusted legacy project hook");
|
||||
fs::write(
|
||||
&project_settings,
|
||||
format!(r#"{{"hooks":{{"PreToolUse":["{command}"]}}}}"#),
|
||||
serde_json::json!({"hooks": {"PreToolUse": [command]}}).to_string(),
|
||||
)
|
||||
.expect("write untrusted project hook");
|
||||
|
||||
|
||||
@@ -1705,9 +1705,9 @@ mod tests {
|
||||
));
|
||||
let hook_command = format!("echo compromised >> \"{}\"", marker.display());
|
||||
let features = RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
|
||||
vec![shell_snippet(&hook_command)],
|
||||
vec![shell_snippet(&hook_command)],
|
||||
vec![shell_snippet(&hook_command)],
|
||||
vec![hook_command.clone()],
|
||||
vec![hook_command.clone()],
|
||||
vec![hook_command.clone()],
|
||||
));
|
||||
let mut runtime = ConversationRuntime::new_with_features(
|
||||
Session::new(),
|
||||
@@ -1858,7 +1858,7 @@ mod tests {
|
||||
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
||||
vec!["system".to_string()],
|
||||
&RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
|
||||
vec![shell_snippet("printf 'blocked by hook'; exit 2")],
|
||||
vec![crate::test_hook("blocked by hook", "", 2, 0)],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
)),
|
||||
@@ -1921,7 +1921,7 @@ mod tests {
|
||||
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
||||
vec!["system".to_string()],
|
||||
&RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
|
||||
vec![shell_snippet("printf 'broken hook'; exit 1")],
|
||||
vec![crate::test_hook("broken hook", "", 1, 0)],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
)),
|
||||
@@ -1990,8 +1990,8 @@ mod tests {
|
||||
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
||||
vec!["system".to_string()],
|
||||
&RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
|
||||
vec![shell_snippet("printf 'pre hook ran'")],
|
||||
vec![shell_snippet("printf 'post hook ran'")],
|
||||
vec![crate::test_hook("pre hook ran", "", 0, 0)],
|
||||
vec![crate::test_hook("post hook ran", "", 0, 0)],
|
||||
Vec::new(),
|
||||
)),
|
||||
);
|
||||
@@ -2068,8 +2068,8 @@ mod tests {
|
||||
vec!["system".to_string()],
|
||||
&RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
|
||||
Vec::new(),
|
||||
vec![shell_snippet("printf 'post hook should not run'")],
|
||||
vec![shell_snippet("printf 'failure hook ran'")],
|
||||
vec![crate::test_hook("post hook should not run", "", 0, 0)],
|
||||
vec![crate::test_hook("failure hook ran", "", 0, 0)],
|
||||
)),
|
||||
);
|
||||
|
||||
@@ -2263,16 +2263,6 @@ mod tests {
|
||||
std::env::temp_dir().join(format!("runtime-conversation-{label}-{nanos}.json"))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn shell_snippet(script: &str) -> String {
|
||||
script.replace('\'', "\"")
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn shell_snippet(script: &str) -> String {
|
||||
script.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_compacts_when_cumulative_input_threshold_is_crossed() {
|
||||
struct SimpleApi;
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::cmp::Reverse;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
use glob::Pattern;
|
||||
@@ -44,7 +44,7 @@ fn is_binary_file(path: &Path) -> io::Result<bool> {
|
||||
fn validate_workspace_boundary(resolved: &Path, workspace_root: &Path) -> io::Result<()> {
|
||||
let workspace_root = normalize_for_comparison(workspace_root);
|
||||
let comparable = normalize_for_comparison(resolved);
|
||||
if comparable.starts_with(&workspace_root) || resolves_within(resolved, &workspace_root) {
|
||||
if resolves_within(resolved, &workspace_root) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(io::Error::new(
|
||||
@@ -61,13 +61,87 @@ fn validate_workspace_boundary(resolved: &Path, workspace_root: &Path) -> io::Re
|
||||
/// filesystem resolves it. Windows accepts several spellings of one location
|
||||
/// that [`Path::starts_with`] treats as different: letter case (a working
|
||||
/// directory typed as `c:\users\...` for `C:\Users\...`), the `\\.\` device
|
||||
/// namespace, and 8.3 short names. Only consulted after the lexical comparison
|
||||
/// fails, so it can accept such a spelling but never widens what already
|
||||
/// compared inside, and a path whose canonical form is outside stays rejected.
|
||||
/// namespace, and 8.3 short names. Existing ancestors are canonicalized so
|
||||
/// equivalent names are accepted while symlink and parent traversal escapes
|
||||
/// remain outside the workspace.
|
||||
fn resolves_within(resolved: &Path, workspace_root: &Path) -> bool {
|
||||
resolved
|
||||
.canonicalize()
|
||||
.is_ok_and(|canonical| normalize_for_comparison(&canonical).starts_with(workspace_root))
|
||||
if let Ok(canonical) = resolved.canonicalize() {
|
||||
if normalize_for_comparison(&canonical).starts_with(workspace_root) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Writes can target a path whose parent directories do not exist yet, so
|
||||
// canonicalizing the whole path may not be possible. Resolve the nearest
|
||||
// existing ancestor (including symlinks), then check its canonical path
|
||||
// and file identity before applying only the missing suffix.
|
||||
let mut existing_ancestor = resolved;
|
||||
loop {
|
||||
match fs::symlink_metadata(existing_ancestor) {
|
||||
Ok(_) => break,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||||
let Some(parent) = existing_ancestor.parent() else {
|
||||
return false;
|
||||
};
|
||||
existing_ancestor = parent;
|
||||
}
|
||||
Err(_) => return false,
|
||||
}
|
||||
}
|
||||
|
||||
let Ok(canonical_ancestor) = existing_ancestor.canonicalize() else {
|
||||
// In particular, do not treat a dangling symlink as a missing path.
|
||||
return false;
|
||||
};
|
||||
#[cfg(windows)]
|
||||
if has_workspace_ancestor(&canonical_ancestor, workspace_root) {
|
||||
return resolved
|
||||
.strip_prefix(existing_ancestor)
|
||||
.is_ok_and(|suffix| {
|
||||
suffix.components().all(|component| {
|
||||
!matches!(
|
||||
component,
|
||||
Component::ParentDir | Component::Prefix(_) | Component::RootDir
|
||||
)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
let Ok(missing_suffix) = resolved.strip_prefix(existing_ancestor) else {
|
||||
return false;
|
||||
};
|
||||
let mut canonical_path = normalize_for_comparison(&canonical_ancestor);
|
||||
for component in missing_suffix.components() {
|
||||
match component {
|
||||
Component::CurDir => {}
|
||||
// A parent component after a missing component could later
|
||||
// traverse a symlink once the missing directory is created.
|
||||
Component::ParentDir => return false,
|
||||
Component::Normal(name) => canonical_path.push(name),
|
||||
Component::Prefix(_) | Component::RootDir => return false,
|
||||
}
|
||||
}
|
||||
|
||||
canonical_path.starts_with(workspace_root)
|
||||
}
|
||||
|
||||
/// Compare Windows directory identities when canonical paths use different
|
||||
/// long/8.3 spellings. Path strings alone cannot establish this relationship.
|
||||
#[cfg(windows)]
|
||||
fn has_workspace_ancestor(path: &Path, workspace_root: &Path) -> bool {
|
||||
let mut ancestor = path;
|
||||
loop {
|
||||
if same_file::is_same_file(ancestor, workspace_root).unwrap_or(false) {
|
||||
return true;
|
||||
}
|
||||
let Some(parent) = ancestor.parent() else {
|
||||
return false;
|
||||
};
|
||||
if parent == ancestor {
|
||||
return false;
|
||||
}
|
||||
ancestor = parent;
|
||||
}
|
||||
}
|
||||
|
||||
/// Text payload returned by file-reading operations.
|
||||
@@ -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()
|
||||
|
||||
@@ -742,8 +742,13 @@ fn format_hook_failure(command: &str, code: i32, stdout: Option<&str>, stderr: &
|
||||
fn shell_command(command: &str) -> CommandWithStdin {
|
||||
#[cfg(windows)]
|
||||
let command_builder = {
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
// This is a configured shell command, not a single argv value. cmd
|
||||
// does not understand the C-runtime backslash escaping used by arg().
|
||||
let mut command_builder = Command::new("cmd");
|
||||
command_builder.arg("/C").arg(command);
|
||||
command_builder.args(["/D", "/S", "/C"]);
|
||||
command_builder.raw_arg(format!("\"{command}\""));
|
||||
CommandWithStdin::new(command_builder)
|
||||
};
|
||||
|
||||
@@ -845,7 +850,7 @@ mod tests {
|
||||
#[test]
|
||||
fn allows_exit_code_zero_and_captures_stdout() {
|
||||
let runner = HookRunner::new(RuntimeHookConfig::new(
|
||||
vec![shell_snippet("printf 'pre ok'")],
|
||||
vec![crate::test_hook("pre ok", "", 0, 0)],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
));
|
||||
@@ -859,13 +864,13 @@ mod tests {
|
||||
fn object_style_hook_matchers_filter_runtime_execution() {
|
||||
let runner = HookRunner::new(RuntimeHookConfig::from_hook_commands(
|
||||
vec![
|
||||
RuntimeHookCommand::new(shell_snippet("printf 'legacy'")),
|
||||
RuntimeHookCommand::new(crate::test_hook("legacy", "", 0, 0)),
|
||||
RuntimeHookCommand::with_matcher(
|
||||
shell_snippet("printf 'bash only'"),
|
||||
crate::test_hook("bash only", "", 0, 0),
|
||||
Some("Bash".to_string()),
|
||||
),
|
||||
RuntimeHookCommand::with_matcher(
|
||||
shell_snippet("printf 'read only'"),
|
||||
crate::test_hook("read only", "", 0, 0),
|
||||
Some("Read*".to_string()),
|
||||
),
|
||||
],
|
||||
@@ -889,7 +894,7 @@ mod tests {
|
||||
#[test]
|
||||
fn denies_exit_code_two() {
|
||||
let runner = HookRunner::new(RuntimeHookConfig::new(
|
||||
vec![shell_snippet("printf 'blocked by hook'; exit 2")],
|
||||
vec![crate::test_hook("blocked by hook", "", 2, 0)],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
));
|
||||
@@ -904,7 +909,7 @@ mod tests {
|
||||
fn propagates_other_non_zero_statuses_as_failures() {
|
||||
let runner = HookRunner::from_feature_config(&RuntimeFeatureConfig::default().with_hooks(
|
||||
RuntimeHookConfig::new(
|
||||
vec![shell_snippet("printf 'warning hook'; exit 1")],
|
||||
vec![crate::test_hook("warning hook", "", 1, 0)],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
),
|
||||
@@ -925,8 +930,11 @@ mod tests {
|
||||
#[test]
|
||||
fn parses_pre_hook_permission_override_and_updated_input() {
|
||||
let runner = HookRunner::new(RuntimeHookConfig::new(
|
||||
vec![shell_snippet(
|
||||
r#"printf '%s' '{"systemMessage":"updated","hookSpecificOutput":{"permissionDecision":"allow","permissionDecisionReason":"hook ok","updatedInput":{"command":"git status"}}}'"#,
|
||||
vec![crate::test_hook(
|
||||
r#"{"systemMessage":"updated","hookSpecificOutput":{"permissionDecision":"allow","permissionDecisionReason":"hook ok","updatedInput":{"command":"git status"}}}"#,
|
||||
"",
|
||||
0,
|
||||
0,
|
||||
)],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
@@ -949,7 +957,7 @@ mod tests {
|
||||
let runner = HookRunner::new(RuntimeHookConfig::new(
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
vec![shell_snippet("printf 'failure hook ran'")],
|
||||
vec![crate::test_hook("failure hook ran", "", 0, 0)],
|
||||
));
|
||||
|
||||
// when
|
||||
@@ -968,8 +976,8 @@ mod tests {
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
vec![
|
||||
shell_snippet("printf 'broken failure hook'; exit 1"),
|
||||
shell_snippet("printf 'later failure hook'"),
|
||||
crate::test_hook("broken failure hook", "", 1, 0),
|
||||
crate::test_hook("later failure hook", "", 0, 0),
|
||||
],
|
||||
));
|
||||
|
||||
@@ -994,8 +1002,8 @@ mod tests {
|
||||
// given
|
||||
let runner = HookRunner::new(RuntimeHookConfig::new(
|
||||
vec![
|
||||
shell_snippet("printf 'first'"),
|
||||
shell_snippet("printf 'second'"),
|
||||
crate::test_hook("first", "", 0, 0),
|
||||
crate::test_hook("second", "", 0, 0),
|
||||
],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
@@ -1022,7 +1030,7 @@ mod tests {
|
||||
event: HookEvent::PreToolUse,
|
||||
command,
|
||||
..
|
||||
} if command == "printf 'first'"
|
||||
} if command == &crate::test_hook("first", "", 0, 0)
|
||||
));
|
||||
assert!(matches!(
|
||||
&reporter.events[1],
|
||||
@@ -1030,7 +1038,7 @@ mod tests {
|
||||
event: HookEvent::PreToolUse,
|
||||
command,
|
||||
..
|
||||
} if command == "printf 'first'"
|
||||
} if command == &crate::test_hook("first", "", 0, 0)
|
||||
));
|
||||
assert!(matches!(
|
||||
&reporter.events[2],
|
||||
@@ -1038,7 +1046,7 @@ mod tests {
|
||||
event: HookEvent::PreToolUse,
|
||||
command,
|
||||
..
|
||||
} if command == "printf 'second'"
|
||||
} if command == &crate::test_hook("second", "", 0, 0)
|
||||
));
|
||||
assert!(matches!(
|
||||
&reporter.events[3],
|
||||
@@ -1046,7 +1054,7 @@ mod tests {
|
||||
event: HookEvent::PreToolUse,
|
||||
command,
|
||||
..
|
||||
} if command == "printf 'second'"
|
||||
} if command == &crate::test_hook("second", "", 0, 0)
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1055,8 +1063,8 @@ mod tests {
|
||||
// given
|
||||
let runner = HookRunner::new(RuntimeHookConfig::new(
|
||||
vec![
|
||||
shell_snippet("printf 'broken'; exit 1"),
|
||||
shell_snippet("printf 'later'"),
|
||||
crate::test_hook("broken", "", 1, 0),
|
||||
crate::test_hook("later", "", 0, 0),
|
||||
],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
@@ -1077,8 +1085,11 @@ mod tests {
|
||||
#[test]
|
||||
fn malformed_nonempty_hook_output_reports_explicit_diagnostic_with_previews() {
|
||||
let runner = HookRunner::new(RuntimeHookConfig::new(
|
||||
vec![shell_snippet(
|
||||
"printf '{not-json\nsecond line'; printf 'stderr warning' >&2; exit 1",
|
||||
vec![crate::test_hook(
|
||||
"{not-json\nsecond line",
|
||||
"stderr warning",
|
||||
1,
|
||||
0,
|
||||
)],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
@@ -1091,8 +1102,16 @@ mod tests {
|
||||
assert!(rendered.contains("hook_invalid_json:"));
|
||||
assert!(rendered.contains("phase=PreToolUse"));
|
||||
assert!(rendered.contains("tool=Edit"));
|
||||
assert!(rendered.contains("command=printf '{not-json"));
|
||||
assert!(rendered.contains("printf 'stderr warning' >&2; exit 1"));
|
||||
assert!(rendered.contains(&format!(
|
||||
"command={}",
|
||||
super::bounded_hook_preview(&crate::test_hook(
|
||||
"{not-json\nsecond line",
|
||||
"stderr warning",
|
||||
1,
|
||||
0
|
||||
))
|
||||
.expect("command preview")
|
||||
)));
|
||||
assert!(rendered.contains("detail=key must be a string"));
|
||||
assert!(rendered.contains("stdout_preview={not-json"));
|
||||
assert!(rendered.contains("second line stderr_preview=stderr warning"));
|
||||
@@ -1102,7 +1121,7 @@ mod tests {
|
||||
#[test]
|
||||
fn abort_signal_cancels_long_running_hook_and_reports_progress() {
|
||||
let runner = HookRunner::new(RuntimeHookConfig::new(
|
||||
vec![shell_snippet("sleep 5")],
|
||||
vec![crate::test_hook("", "", 0, 5)],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
));
|
||||
@@ -1138,14 +1157,4 @@ mod tests {
|
||||
}
|
||||
)));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn shell_snippet(script: &str) -> String {
|
||||
script.replace('\'', "\"")
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn shell_snippet(script: &str) -> String {
|
||||
script.to_string()
|
||||
}
|
||||
}
|
||||
@@ -214,3 +214,17 @@ pub(crate) fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_hook(stdout: &str, stderr: &str, exit_code: i32, delay: u32) -> String {
|
||||
let python = if cfg!(windows) { "python" } else { "python3" };
|
||||
let encode = |text: &str| {
|
||||
text.bytes()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>()
|
||||
};
|
||||
format!(
|
||||
"{python} -c \"import sys,time; time.sleep({delay}); sys.stdout.write(bytes.fromhex('{}').decode()); sys.stderr.write(bytes.fromhex('{}').decode()); sys.exit({exit_code})\"",
|
||||
encode(stdout), encode(stderr)
|
||||
)
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -1443,7 +1443,6 @@ mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io::ErrorKind;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
@@ -1466,6 +1465,9 @@ mod tests {
|
||||
};
|
||||
use crate::McpLifecyclePhase;
|
||||
|
||||
// Fixtures are interpreter inputs, not directly executable Unix scripts.
|
||||
const TEST_PYTHON: &str = if cfg!(windows) { "python" } else { "python3" };
|
||||
|
||||
fn temp_dir() -> PathBuf {
|
||||
static NEXT_TEMP_DIR_ID: AtomicU64 = AtomicU64::new(0);
|
||||
let nanos = SystemTime::now()
|
||||
@@ -1479,15 +1481,19 @@ mod tests {
|
||||
fn write_echo_script() -> PathBuf {
|
||||
let root = temp_dir();
|
||||
fs::create_dir_all(&root).expect("temp dir");
|
||||
let script_path = root.join("echo-mcp.sh");
|
||||
fs::write(
|
||||
&script_path,
|
||||
"#!/bin/sh\nprintf 'READY:%s\\n' \"$MCP_TEST_TOKEN\"\nIFS= read -r line\nprintf 'ECHO:%s\\n' \"$line\"\n",
|
||||
)
|
||||
.expect("write script");
|
||||
let mut permissions = fs::metadata(&script_path).expect("metadata").permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, permissions).expect("chmod");
|
||||
let script_path = root.join("echo-mcp.py");
|
||||
let script = [
|
||||
"import os, sys",
|
||||
r"ready = ('READY:' + os.environ['MCP_TEST_TOKEN'] + '\n').encode()",
|
||||
"sys.stdout.buffer.write(ready)",
|
||||
"sys.stdout.buffer.flush()",
|
||||
"line = sys.stdin.buffer.readline()",
|
||||
r"sys.stdout.buffer.write(b'ECHO:' + line.rstrip(b'\r\n') + b'\n')",
|
||||
"sys.stdout.buffer.flush()",
|
||||
"",
|
||||
]
|
||||
.join("\n");
|
||||
fs::write(&script_path, script).expect("write script");
|
||||
script_path
|
||||
}
|
||||
|
||||
@@ -1531,9 +1537,6 @@ mod tests {
|
||||
]
|
||||
.join("\n");
|
||||
fs::write(&script_path, script).expect("write script");
|
||||
let mut permissions = fs::metadata(&script_path).expect("metadata").permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, permissions).expect("chmod");
|
||||
script_path
|
||||
}
|
||||
|
||||
@@ -1665,9 +1668,6 @@ mod tests {
|
||||
]
|
||||
.join("\n");
|
||||
fs::write(&script_path, script).expect("write script");
|
||||
let mut permissions = fs::metadata(&script_path).expect("metadata").permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, permissions).expect("chmod");
|
||||
script_path
|
||||
}
|
||||
|
||||
@@ -1790,9 +1790,6 @@ mod tests {
|
||||
]
|
||||
.join("\n");
|
||||
fs::write(&script_path, script).expect("write script");
|
||||
let mut permissions = fs::metadata(&script_path).expect("metadata").permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, permissions).expect("chmod");
|
||||
script_path
|
||||
}
|
||||
|
||||
@@ -1801,7 +1798,7 @@ mod tests {
|
||||
required: false,
|
||||
scope: ConfigSource::Local,
|
||||
config: McpServerConfig::Stdio(McpStdioServerConfig {
|
||||
command: "/bin/sh".to_string(),
|
||||
command: TEST_PYTHON.to_string(),
|
||||
args: vec![script_path.to_string_lossy().into_owned()],
|
||||
env: BTreeMap::from([("MCP_TEST_TOKEN".to_string(), "secret-value".to_string())]),
|
||||
tool_call_timeout_ms: None,
|
||||
@@ -1819,7 +1816,7 @@ mod tests {
|
||||
env: BTreeMap<String, String>,
|
||||
) -> crate::mcp_client::McpStdioTransport {
|
||||
crate::mcp_client::McpStdioTransport {
|
||||
command: "python3".to_string(),
|
||||
command: TEST_PYTHON.to_string(),
|
||||
args: vec![script_path.to_string_lossy().into_owned()],
|
||||
env,
|
||||
tool_call_timeout_ms: None,
|
||||
@@ -1869,7 +1866,7 @@ mod tests {
|
||||
required: false,
|
||||
scope: ConfigSource::Local,
|
||||
config: McpServerConfig::Stdio(McpStdioServerConfig {
|
||||
command: "python3".to_string(),
|
||||
command: TEST_PYTHON.to_string(),
|
||||
args: vec![script_path.to_string_lossy().into_owned()],
|
||||
env,
|
||||
tool_call_timeout_ms: None,
|
||||
@@ -2089,7 +2086,7 @@ mod tests {
|
||||
runtime.block_on(async {
|
||||
let script_path = write_echo_script();
|
||||
let transport = crate::mcp_client::McpStdioTransport {
|
||||
command: "/bin/sh".to_string(),
|
||||
command: TEST_PYTHON.to_string(),
|
||||
args: vec![script_path.to_string_lossy().into_owned()],
|
||||
env: BTreeMap::from([("MCP_TEST_TOKEN".to_string(), "direct-secret".to_string())]),
|
||||
tool_call_timeout_ms: None,
|
||||
@@ -2349,7 +2346,7 @@ mod tests {
|
||||
required: false,
|
||||
scope: ConfigSource::Local,
|
||||
config: McpServerConfig::Stdio(McpStdioServerConfig {
|
||||
command: "python3".to_string(),
|
||||
command: TEST_PYTHON.to_string(),
|
||||
args: vec![script_path.to_string_lossy().into_owned()],
|
||||
env: BTreeMap::from([(
|
||||
"MCP_TOOL_CALL_DELAY_MS".to_string(),
|
||||
@@ -2403,7 +2400,7 @@ mod tests {
|
||||
required: false,
|
||||
scope: ConfigSource::Local,
|
||||
config: McpServerConfig::Stdio(McpStdioServerConfig {
|
||||
command: "python3".to_string(),
|
||||
command: TEST_PYTHON.to_string(),
|
||||
args: vec![script_path.to_string_lossy().into_owned()],
|
||||
env: BTreeMap::from([(
|
||||
"MCP_INVALID_TOOL_CALL_RESPONSE".to_string(),
|
||||
@@ -2714,9 +2711,6 @@ mod tests {
|
||||
]
|
||||
.join("\n");
|
||||
fs::write(&script_path, script).expect("write script");
|
||||
let mut permissions = fs::metadata(&script_path).expect("metadata").permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, permissions).expect("chmod");
|
||||
script_path
|
||||
}
|
||||
|
||||
@@ -2742,8 +2736,8 @@ mod tests {
|
||||
required: true,
|
||||
scope: ConfigSource::Local,
|
||||
config: McpServerConfig::Stdio(McpStdioServerConfig {
|
||||
command: broken_script_path.display().to_string(),
|
||||
args: Vec::new(),
|
||||
command: TEST_PYTHON.to_string(),
|
||||
args: vec![broken_script_path.to_string_lossy().into_owned()],
|
||||
env: BTreeMap::new(),
|
||||
tool_call_timeout_ms: None,
|
||||
}),
|
||||
|
||||
@@ -314,7 +314,6 @@ impl McpToolRegistry {
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
@@ -324,6 +323,9 @@ mod tests {
|
||||
ConfigSource, McpServerConfig, McpStdioServerConfig, ScopedMcpServerConfig,
|
||||
};
|
||||
|
||||
// Fixtures are interpreter inputs, not directly executable Unix scripts.
|
||||
const TEST_PYTHON: &str = if cfg!(windows) { "python" } else { "python3" };
|
||||
|
||||
fn temp_dir() -> PathBuf {
|
||||
static NEXT_TEMP_DIR_ID: AtomicU64 = AtomicU64::new(0);
|
||||
let nanos = SystemTime::now()
|
||||
@@ -430,9 +432,6 @@ mod tests {
|
||||
]
|
||||
.join("\n");
|
||||
fs::write(&script_path, script).expect("write script");
|
||||
let mut permissions = fs::metadata(&script_path).expect("metadata").permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, permissions).expect("chmod");
|
||||
script_path
|
||||
}
|
||||
|
||||
@@ -445,7 +444,7 @@ mod tests {
|
||||
required: false,
|
||||
scope: ConfigSource::Local,
|
||||
config: McpServerConfig::Stdio(McpStdioServerConfig {
|
||||
command: "python3".to_string(),
|
||||
command: TEST_PYTHON.to_string(),
|
||||
args: vec![script_path.to_string_lossy().into_owned()],
|
||||
env: BTreeMap::from([
|
||||
("MCP_SERVER_LABEL".to_string(), server_name.to_string()),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, Read};
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -326,7 +326,7 @@ pub fn parse_oauth_callback_query(query: &str) -> Result<OAuthCallbackParams, St
|
||||
|
||||
fn generate_random_token(bytes: usize) -> io::Result<String> {
|
||||
let mut buffer = vec![0_u8; bytes];
|
||||
File::open("/dev/urandom")?.read_exact(&mut buffer)?;
|
||||
getrandom::getrandom(&mut buffer).map_err(|error| io::Error::other(error.to_string()))?;
|
||||
Ok(base64url_encode(&buffer))
|
||||
}
|
||||
|
||||
|
||||
@@ -262,6 +262,12 @@ impl Session {
|
||||
|
||||
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, SessionError> {
|
||||
let path = path.as_ref();
|
||||
if fs::metadata(path)?.is_dir() {
|
||||
return Err(SessionError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::IsADirectory,
|
||||
format!("session_path_is_directory: {}", path.display()),
|
||||
)));
|
||||
}
|
||||
let contents = fs::read_to_string(path)?;
|
||||
let session = match JsonValue::parse(&contents) {
|
||||
Ok(value)
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
|
||||
@@ -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)
|
||||
+375
-42
@@ -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;
|
||||
}
|
||||
|
||||
@@ -3528,16 +3532,26 @@ fn execute_web_search(input: &WebSearchInput) -> Result<WebSearchOutput, String>
|
||||
let client = build_http_client()?;
|
||||
let search_url = build_search_url(&input.query)?;
|
||||
let response = client
|
||||
.get(search_url)
|
||||
.get(search_url.as_str())
|
||||
.send()
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
// DuckDuckGo answers its bot challenge with HTTP 202 and a page without
|
||||
// results, so anything but 200 is an unusable results page.
|
||||
let status = response.status();
|
||||
if status != reqwest::StatusCode::OK {
|
||||
return Err(format!(
|
||||
"Web search unavailable (HTTP {}) - the search provider is blocked or unreachable",
|
||||
status.as_u16()
|
||||
));
|
||||
}
|
||||
|
||||
let final_url = response.url().clone();
|
||||
let html = response.text().map_err(|error| error.to_string())?;
|
||||
let mut hits = extract_search_hits(&html);
|
||||
|
||||
if hits.is_empty() && final_url.host_str().is_some() {
|
||||
hits = extract_search_hits_from_generic_links(&html);
|
||||
hits = extract_search_hits_from_generic_links(&html, search_url.host_str());
|
||||
}
|
||||
|
||||
if let Some(allowed) = input.allowed_domains.as_ref() {
|
||||
@@ -3760,7 +3774,7 @@ fn extract_search_hits(html: &str) -> Vec<SearchHit> {
|
||||
hits
|
||||
}
|
||||
|
||||
fn extract_search_hits_from_generic_links(html: &str) -> Vec<SearchHit> {
|
||||
fn extract_search_hits_from_generic_links(html: &str, search_host: Option<&str>) -> Vec<SearchHit> {
|
||||
let mut hits = Vec::new();
|
||||
let mut remaining = html;
|
||||
|
||||
@@ -3791,6 +3805,11 @@ fn extract_search_hits_from_generic_links(html: &str) -> Vec<SearchHit> {
|
||||
}
|
||||
let decoded_url = decode_duckduckgo_redirect(&url).unwrap_or(url);
|
||||
if decoded_url.starts_with("http://") || decoded_url.starts_with("https://") {
|
||||
if is_search_engine_link(&decoded_url, search_host) {
|
||||
remaining = &after_tag[end_anchor_idx + 4..];
|
||||
continue;
|
||||
}
|
||||
|
||||
hits.push(SearchHit {
|
||||
title: title.trim().to_string(),
|
||||
url: decoded_url,
|
||||
@@ -3873,6 +3892,20 @@ fn normalize_domain_filter(domain: &str) -> String {
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
/// A generic-fallback link that points back at DuckDuckGo or at the configured
|
||||
/// search host is page chrome (home, settings, challenge), never a result.
|
||||
fn is_search_engine_link(url: &str, search_host: Option<&str>) -> bool {
|
||||
let Some(host) = reqwest::Url::parse(url)
|
||||
.ok()
|
||||
.and_then(|parsed| parsed.host_str().map(str::to_ascii_lowercase))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
host == "duckduckgo.com"
|
||||
|| host.ends_with(".duckduckgo.com")
|
||||
|| search_host.is_some_and(|search_host| host.eq_ignore_ascii_case(search_host))
|
||||
}
|
||||
|
||||
fn dedupe_hits(hits: &mut Vec<SearchHit>) {
|
||||
let mut seen = BTreeSet::new();
|
||||
hits.retain(|hit| seen.insert(hit.url.clone()));
|
||||
@@ -5964,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);
|
||||
@@ -6400,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 {
|
||||
@@ -6425,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)]
|
||||
@@ -6753,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>,
|
||||
@@ -6989,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> {
|
||||
@@ -7316,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
|
||||
@@ -8273,6 +8335,112 @@ mod tests {
|
||||
assert_eq!(content[0]["url"], "https://docs.rs/tokio");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_search_returns_error_on_non_2xx_response() {
|
||||
let _guard = env_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let server = TestServer::spawn(Arc::new(|request_line: &str| {
|
||||
assert!(request_line.contains("GET /search?q=test"));
|
||||
// Simulate rate-limited/blocked response (HTTP 429)
|
||||
HttpResponse::html(
|
||||
429,
|
||||
"Too Many Requests",
|
||||
"<html><body>\n <p>Too many requests. Please try again later.</p>\n </body></html>",
|
||||
)
|
||||
}));
|
||||
|
||||
std::env::set_var(
|
||||
"CLAWD_WEB_SEARCH_BASE_URL",
|
||||
format!("http://{}/search", server.addr()),
|
||||
);
|
||||
let error = execute_tool("WebSearch", &json!({ "query": "test" }))
|
||||
.expect_err("non-2xx response should fail");
|
||||
std::env::remove_var("CLAWD_WEB_SEARCH_BASE_URL");
|
||||
|
||||
assert!(error.contains("Web search unavailable"));
|
||||
assert!(error.contains("429"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_search_rejects_duckduckgo_202_challenge_page() {
|
||||
let _guard = env_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let server = TestServer::spawn(Arc::new(|request_line: &str| {
|
||||
assert!(request_line.contains("GET /html/?q=challenge "));
|
||||
HttpResponse::html(
|
||||
202,
|
||||
"Accepted",
|
||||
r#"
|
||||
<html><body>
|
||||
<a href="/html/">DuckDuckGo</a>
|
||||
<a href="https://duckduckgo.com/">Home</a>
|
||||
<p>Unfortunately, bots use DuckDuckGo too.</p>
|
||||
</body></html>
|
||||
"#,
|
||||
)
|
||||
}));
|
||||
|
||||
std::env::set_var(
|
||||
"CLAWD_WEB_SEARCH_BASE_URL",
|
||||
format!("http://{}/html/", server.addr()),
|
||||
);
|
||||
let error = execute_tool("WebSearch", &json!({ "query": "challenge" }))
|
||||
.expect_err("a 202 challenge page must not produce results");
|
||||
std::env::remove_var("CLAWD_WEB_SEARCH_BASE_URL");
|
||||
|
||||
assert!(
|
||||
error.contains("Web search unavailable (HTTP 202)"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_search_generic_fallback_drops_search_engine_links() {
|
||||
let _guard = env_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let server = TestServer::spawn(Arc::new(|request_line: &str| {
|
||||
assert!(request_line.contains("GET /search?q=chrome+links "));
|
||||
HttpResponse::html(
|
||||
200,
|
||||
"OK",
|
||||
r#"
|
||||
<html><body>
|
||||
<a href="/html/">DuckDuckGo</a>
|
||||
<a href="https://html.duckduckgo.com/html/?q=x">Next page</a>
|
||||
<a href="https://duckduckgo.com/settings">Settings</a>
|
||||
<a href="https://docs.rs/serde">Serde Docs</a>
|
||||
<a href="https://tokio.rs/">Tokio</a>
|
||||
</body></html>
|
||||
"#,
|
||||
)
|
||||
}));
|
||||
|
||||
std::env::set_var(
|
||||
"CLAWD_WEB_SEARCH_BASE_URL",
|
||||
format!("http://{}/search", server.addr()),
|
||||
);
|
||||
let result = execute_tool("WebSearch", &json!({ "query": "chrome links" }))
|
||||
.expect("WebSearch fallback parsing should succeed");
|
||||
std::env::remove_var("CLAWD_WEB_SEARCH_BASE_URL");
|
||||
|
||||
let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
|
||||
let content = output["results"]
|
||||
.as_array()
|
||||
.expect("results array")
|
||||
.iter()
|
||||
.find_map(|item| item.get("content"))
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.expect("search result block present");
|
||||
let urls: Vec<&str> = content
|
||||
.iter()
|
||||
.map(|hit| hit["url"].as_str().expect("url"))
|
||||
.collect();
|
||||
assert_eq!(urls, ["https://docs.rs/serde", "https://tokio.rs/"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_tools_preserve_multiple_streaming_tool_calls_by_index() {
|
||||
let mut events = Vec::new();
|
||||
@@ -8461,6 +8629,7 @@ mod tests {
|
||||
assert!(output["path"]
|
||||
.as_str()
|
||||
.expect("path")
|
||||
.replace('\\', "/")
|
||||
.ends_with("/help/SKILL.md"));
|
||||
assert!(output["prompt"]
|
||||
.as_str()
|
||||
@@ -8480,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 {
|
||||
@@ -8519,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" }))
|
||||
@@ -8528,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");
|
||||
@@ -8566,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");
|
||||
|
||||
@@ -8628,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"],
|
||||
@@ -8688,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");
|
||||
|
||||
@@ -8747,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");
|
||||
|
||||
@@ -8757,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"],
|
||||
@@ -8814,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");
|
||||
|
||||
@@ -9730,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");
|
||||
@@ -9742,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");
|
||||
@@ -10059,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": "[" }))
|
||||
@@ -10511,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
|
||||
@@ -10521,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",
|
||||
@@ -11302,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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user