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