Compare commits

..

2 Commits

Author SHA1 Message Date
bellman 96fd46cfbe fix: add missing thought_signature field in tools tests
Fixes two test assertions that were missing the new Option<String>
field added to the pending_tools tuple.

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
2026-06-05 03:00:54 +09:00
sunmanbitch b119afcaca In the provider compatibility layer for Gemini (and other providers requiring ), fully support the flow, round-trip, and placeholder fallback of thought_signature. 2026-06-04 22:52:47 +08:00
36 changed files with 1088 additions and 3701 deletions
-65
View File
@@ -1,66 +1,5 @@
# Claw Code
<p align="center">
<a href="https://github.com/code-yeongyu/lazycodex">
<img src="https://img.shields.io/badge/LazyCodex-codex%20for%20no--brainers-111111?style=for-the-badge&logo=github&logoColor=white" alt="LazyCodex banner" />
</a>
<a href="https://github.com/Yeachan-Heo/gajae-code">
<img src="https://img.shields.io/badge/Gajae--Code-red--claw%20agent%20harness-B22222?style=for-the-badge&logo=github&logoColor=white" alt="Gajae-Code banner" />
</a>
</p>
<p align="center">
<a href="https://github.com/code-yeongyu/lazycodex">
<img src="https://opengraph.githubassets.com/lazycodex-card/code-yeongyu/lazycodex" alt="LazyCodex GitHub card" width="280" />
</a>
<a href="https://github.com/Yeachan-Heo/gajae-code">
<img src="https://opengraph.githubassets.com/gajae-code-card/Yeachan-Heo/gajae-code" alt="Gajae-Code GitHub card" width="280" />
</a>
</p>
<h3 align="center">start with the real crab-powered harnesses</h3>
<p align="center">
<a href="https://github.com/code-yeongyu/lazycodex"><b>github.com/code-yeongyu/lazycodex</b></a>
<br/>
<a href="https://github.com/Yeachan-Heo/gajae-code"><b>github.com/Yeachan-Heo/gajae-code</b></a>
</p>
<p align="center">
<a href="https://github.com/code-yeongyu/lazycodex">
<img src="https://img.shields.io/badge/Open-LazyCodex-111111?style=flat-square&logo=github&logoColor=white" alt="Open LazyCodex on GitHub" />
</a>
<a href="https://github.com/Yeachan-Heo/gajae-code">
<img src="https://img.shields.io/badge/Open-Gajae--Code-B22222?style=flat-square&logo=github&logoColor=white" alt="Open Gajae-Code on GitHub" />
</a>
</p>
<p align="center">
<a href="https://discord.gg/GtjhvgjnV">
<img src="https://img.shields.io/badge/Discord-join%20the%20harness%20lab-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join the harness lab on Discord" />
</a>
<a href="https://discord.gg/4Rt79F7dF">
<img src="https://img.shields.io/badge/Discord-join%20the%20crab%20tank-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join the crab tank on Discord" />
</a>
</p>
<p align="center">
Join the Discords:
<a href="https://discord.gg/GtjhvgjnV"><b>ultraworkers discord</b></a>
·
<a href="https://discord.gg/4Rt79F7dF"><b>gajae-code discord</b></a>
</p>
> [!IMPORTANT]
> **Claw Code is not the serious production project here.**
> This repository is closer to a museum exhibit than a product pitch, a crustacean-run artifact kept alive by clawed gajaes, swept and labeled by agents, and automatically maintained according to the harnesses above.
>
> As already described in the project philosophy, this is not meant to be hand-operated like a normal product repo. It is an **agent-managed exhibit**: the harnesses plan, execute, verify, label, and preserve the artifact while the crabs keep the tank running.
>
> If you want to actually run work, start with **[LazyCodex](https://github.com/code-yeongyu/lazycodex)** or **[Gajae-Code](https://github.com/Yeachan-Heo/gajae-code)**. If you want to inspect the strange little fossil of the Claw Code moment, continue below.
>
> For the longer public explanation behind this philosophy, see [here](https://x.com/realsigridjin/status/2039472968624185713).
<p align="center">
<a href="https://github.com/ultraworkers/claw-code">ultraworkers/claw-code</a>
·
@@ -134,9 +73,6 @@ export ANTHROPIC_API_KEY="sk-ant-..."
# 4. Run a prompt
./target/debug/claw prompt "say hello"
# 5. Start an interactive session
./target/debug/claw
```
> [!NOTE]
@@ -290,7 +226,6 @@ Claw Code is built in the open alongside the broader UltraWorkers toolchain:
- [oh-my-openagent](https://github.com/code-yeongyu/oh-my-openagent)
- [oh-my-claudecode](https://github.com/Yeachan-Heo/oh-my-claudecode)
- [oh-my-codex](https://github.com/Yeachan-Heo/oh-my-codex)
- [gajae-code](https://github.com/Yeachan-Heo/gajae-code)
- [UltraWorkers Discord](https://discord.gg/5TUQKqFWd)
## Ownership / affiliation disclaimer
+339 -342
View File
File diff suppressed because one or more lines are too long
+5 -7
View File
@@ -245,7 +245,6 @@ export ANTHROPIC_AUTH_TOKEN="anthropic-oauth-or-proxy-bearer-token"
| `sk-ant-*` API key | `ANTHROPIC_API_KEY` | `x-api-key: sk-ant-...` | [console.anthropic.com](https://console.anthropic.com) |
| OAuth access token (opaque) | `ANTHROPIC_AUTH_TOKEN` | `Authorization: Bearer ...` | an Anthropic-compatible proxy or OAuth flow that mints bearer tokens |
| OpenRouter key (`sk-or-v1-*`) | `OPENAI_API_KEY` + `OPENAI_BASE_URL=https://openrouter.ai/api/v1` | `Authorization: Bearer ...` | [openrouter.ai/keys](https://openrouter.ai/keys) |
| Ollama local instance | `OLLAMA_HOST` | no auth header (Ollama requires none) | local Ollama server at `http://127.0.0.1:11434` |
**Why this matters:** if you paste an `sk-ant-*` key into `ANTHROPIC_AUTH_TOKEN`, Anthropic's API will return `401 Invalid bearer token` because `sk-ant-*` keys are rejected over the Bearer header. The fix is a one-line env var swap — move the key to `ANTHROPIC_API_KEY`. Recent `claw` builds detect this exact shape (401 + `sk-ant-*` in the Bearer slot) and append a hint to the error message pointing at the fix.
@@ -306,18 +305,18 @@ cd rust
### Ollama
```bash
export OLLAMA_HOST="http://127.0.0.1:11434"
export OPENAI_BASE_URL="http://127.0.0.1:11434/v1"
unset OPENAI_API_KEY
cd rust
./target/debug/claw --model "llama3.2" prompt "summarize this repository in one sentence"
```
`OLLAMA_HOST` is the preferred env var. Claw routes all models to the local Ollama endpoint automatically, and no API key is needed. The older `OPENAI_BASE_URL` + `OPENAI_API_KEY` workaround is also supported.
For Ollama tags with punctuation (for example `qwen2.5-coder:7b`), both approaches work:
For Ollama tags with punctuation (for example `qwen2.5-coder:7b`), `OPENAI_BASE_URL` selects the local OpenAI-compatible route even when `OPENAI_API_KEY` is unset:
```bash
export OLLAMA_HOST="http://127.0.0.1:11434"
export OPENAI_BASE_URL="http://127.0.0.1:11434/v1"
unset OPENAI_API_KEY
cd rust
./target/debug/claw --model "qwen2.5-coder:7b" prompt "reply with ready"
@@ -616,7 +615,6 @@ The list is also the precedence chain: project-local settings override project s
```
Object-style matchers are optional. When present, they match tool names case-insensitively and support `*` wildcards plus comma or pipe separated alternatives. Nested hook `type` may be omitted or set to `"command"`; each nested command runs in configuration order.
Legacy bare-string hook entries still load for backward compatibility but emit deprecation warnings suggesting migration to object-style entries. Unknown hook event names (e.g. `Stop`, `Notification`) are recorded as invalid without rejecting valid hooks. `status --output-format json` mirrors partial hook validation under `hook_validation` with `valid_count`, `invalid_count`, and `invalid_hooks:[{event, index, hook_index, kind, error_field, reason, valid:false}]`. `doctor --output-format json` includes a `hook validation` check so automation can repair every rejected hook entry without losing usable hooks.
## Project instruction rules
+2 -3
View File
@@ -57,12 +57,11 @@ ollama serve
In another shell:
```bash
export OLLAMA_HOST="http://127.0.0.1:11434"
export OPENAI_BASE_URL="http://127.0.0.1:11434/v1"
unset OPENAI_API_KEY
claw --model "qwen3:latest" prompt "Reply exactly HELLO_WORLD_123"
```
`OLLAMA_HOST` is the preferred env var for Ollama. Claw routes all models to the local OpenAI-compatible endpoint automatically when this is set, and no API key is needed. The older `OPENAI_BASE_URL` + `OPENAI_API_KEY` workaround is also supported for existing setups.
If Ollama is running without auth, `unset OPENAI_API_KEY` is acceptable. Use a placeholder token rather than a real cloud API key if your local server requires an Authorization header.
## llama.cpp server
-6
View File
@@ -40,11 +40,6 @@ Or provide an OAuth bearer token directly:
export ANTHROPIC_AUTH_TOKEN="anthropic-oauth-or-proxy-bearer-token"
```
For local OpenAI-compatible servers such as Ollama, including Qwen reasoning
models, see [`../docs/local-openai-compatible-providers.md`](../docs/local-openai-compatible-providers.md).
Use the exact model tag exposed by the server, for example `qwen3:latest`, and
prefer `OLLAMA_HOST` for Ollama-specific local routing.
## Mock parity harness
The workspace now includes a deterministic Anthropic-compatible mock service and a clean-environment CLI harness for end-to-end parity checks.
@@ -156,7 +151,6 @@ Top-level commands:
`claw version --output-format json` is the provenance probe for automation: it reports full `git_sha`, derived `git_sha_short`, `is_dirty`, `branch`, `commit_date`, `commit_timestamp`, `rustc_version`, runtime `executable_path`, and `binary_provenance`; the text report is available as `human_readable` instead of a duplicate `message` field.
`status --output-format json` reports loaded project memory files under `workspace.memory_files[]` with each file's `path`, `source` (`claude_md`, `claw_md`, `agents_md`, or scoped/rule sources), `origin`, `scope_path`, `outside_project`, `chars`, and `contributes`; `claw doctor --output-format json` includes a dedicated `memory` check. Root instruction-file priority is `CLAUDE.md`, then `CLAW.md`, then `AGENTS.md`, discovery is bounded to the current git root when present (otherwise cwd only), and all non-duplicate loaded files contribute to the rendered system prompt.
`claw mcp --output-format json` reports partial MCP config success: valid servers remain in `servers[]` while malformed siblings appear in `invalid_servers[]`, with `total_configured`, `valid_count`, and `invalid_count` split out for automation. `status` mirrors this as `mcp_validation`, and doctor includes an `mcp validation` check.
`status --output-format json` also reports partial hook config success under `hook_validation`: valid hook entries are retained while malformed or unknown-event siblings appear in `invalid_hooks[]`, with `valid_count`, `invalid_count`, and typed `kind` fields (`invalid_hooks_config` or `unknown_hook_event`) for automation. `doctor --output-format json` includes a `hook validation` check, and `config --output-format json` includes `hook_validation` metadata with degraded status when invalid entries exist.
Shorthand prompt mode honors the POSIX `--` end-of-flags separator, so `claw -- "-prompt-with-dash"` and unknown dash-prefixed non-flag text stay on the prompt path instead of being treated as CLI options.
`claw dump-manifests` is self-contained: it emits the Rust resolver inventory for the selected workspace (commands, tools, agents, skills, and bootstrap phases) without requiring an upstream Claude Code TypeScript checkout. Use `--manifests-dir PATH` only to scope resolver discovery to another directory.
@@ -38,6 +38,7 @@ fn create_sample_request(message_count: usize) -> MessageRequest {
id: format!("call_{}", i),
name: "read_file".to_string(),
input: json!({"path": format!("/tmp/file{}", i)}),
thought_signature: None,
},
],
}),
@@ -57,6 +58,7 @@ fn create_sample_request(message_count: usize) -> MessageRequest {
id: format!("call_{}", i),
name: "write_file".to_string(),
input: json!({"path": format!("/tmp/out{}", i), "content": "data"}),
thought_signature: None,
}],
}),
}
@@ -105,11 +107,13 @@ fn bench_translate_message(c: &mut Criterion) {
id: "call_1".to_string(),
name: "read_file".to_string(),
input: json!({"path": "/tmp/test"}),
thought_signature: None,
},
InputContentBlock::ToolUse {
id: "call_2".to_string(),
name: "write_file".to_string(),
input: json!({"path": "/tmp/out", "content": "data"}),
thought_signature: None,
},
],
};
+10 -19
View File
@@ -32,25 +32,16 @@ impl ProviderClient {
OpenAiCompatConfig::xai(),
)?)),
ProviderKind::OpenAi => {
// OLLAMA_HOST takes priority: local Ollama needs no API key
// and ignores DashScope/OpenAI env-based dispatch.
if std::env::var_os("OLLAMA_HOST").is_some() {
Ok(Self::OpenAi(
openai_compat::OpenAiCompatClient::from_ollama_env()
.expect("from_ollama_env always returns Some"),
))
} else {
// DashScope models (qwen-*) also return ProviderKind::OpenAi because they
// speak the OpenAI wire format, but they need the DashScope config which
// reads DASHSCOPE_API_KEY and points at dashscope.aliyuncs.com.
let config = match providers::metadata_for_model(&resolved_model) {
Some(meta) if meta.auth_env == "DASHSCOPE_API_KEY" => {
OpenAiCompatConfig::dashscope()
}
_ => OpenAiCompatConfig::openai(),
};
Ok(Self::OpenAi(OpenAiCompatClient::from_env(config)?))
}
// DashScope models (qwen-*) also return ProviderKind::OpenAi because they
// speak the OpenAI wire format, but they need the DashScope config which
// reads DASHSCOPE_API_KEY and points at dashscope.aliyuncs.com.
let config = match providers::metadata_for_model(&resolved_model) {
Some(meta) if meta.auth_env == "DASHSCOPE_API_KEY" => {
OpenAiCompatConfig::dashscope()
}
_ => OpenAiCompatConfig::openai(),
};
Ok(Self::OpenAi(OpenAiCompatClient::from_env(config)?))
}
}
}
-49
View File
@@ -20,7 +20,6 @@ const CONTEXT_WINDOW_ERROR_MARKERS: &[&str] = &[
"completion tokens",
"prompt tokens",
"request is too large",
"no parseable body",
];
#[derive(Debug)]
@@ -61,9 +60,6 @@ pub enum ApiError {
retryable: bool,
/// Suggested user action based on error type (e.g., "Reduce prompt size" for 413)
suggested_action: Option<String>,
/// Parsed Retry-After header value (seconds) for 429 responses.
/// When present, overrides the exponential backoff delay.
retry_after: Option<Duration>,
},
RetriesExhausted {
attempts: u32,
@@ -132,17 +128,6 @@ impl ApiError {
}
#[must_use]
/// Return the `Retry-After` delay if this error came from a 429 response
/// that included a `retry-after` header. Callers should prefer this value
/// over the computed backoff delay when it exists.
pub fn retry_after(&self) -> Option<Duration> {
match self {
Self::Api { retry_after, .. } => *retry_after,
Self::RetriesExhausted { last_error, .. } => last_error.retry_after(),
_ => None,
}
}
pub fn is_retryable(&self) -> bool {
match self {
Self::Http(error) => error.is_connect() || error.is_timeout() || error.is_request(),
@@ -326,36 +311,6 @@ impl Display for ApiError {
f,
"failed to parse {provider} response for model {model}: {source}; first 200 chars of body: {body_snippet}"
),
// #28: enhance 401/403 errors with actionable auth guidance
Self::Api {
status,
error_type,
message,
request_id,
body,
..
} if matches!(status.as_u16(), 401 | 403) => {
if let (Some(error_type), Some(message)) = (error_type, message) {
write!(f, "api returned {status} ({error_type})")?;
if let Some(request_id) = request_id {
write!(f, " [trace {request_id}]")?;
}
write!(f, ": {message}")?;
} else {
write!(f, "api returned {status}")?;
if let Some(request_id) = request_id {
write!(f, " [trace {request_id}]")?;
}
write!(f, ": {body}")?;
}
write!(
f,
"\nhint: check that your API key is valid and matches the target provider. \
For OpenAI-compatible providers set OPENAI_API_KEY or OPENAI_BASE_URL. \
For Anthropic set ANTHROPIC_API_KEY. \
Run `claw doctor` to verify your credential configuration."
)
}
Self::Api {
status,
error_type,
@@ -544,7 +499,6 @@ mod tests {
body: String::new(),
retryable: true,
suggested_action: None,
retry_after: None,
};
assert!(error.is_generic_fatal_wrapper());
@@ -568,7 +522,6 @@ mod tests {
body: String::new(),
retryable: true,
suggested_action: None,
retry_after: None,
}),
};
@@ -590,7 +543,6 @@ mod tests {
body: String::new(),
retryable: false,
suggested_action: None,
retry_after: None,
};
assert!(error.is_context_window_failure());
@@ -611,7 +563,6 @@ mod tests {
body: String::new(),
retryable: false,
suggested_action: None,
retry_after: None,
};
assert!(error.is_context_window_failure());
+69 -104
View File
@@ -1,69 +1,9 @@
use std::time::Duration;
use crate::error::ApiError;
const HTTP_PROXY_KEYS: [&str; 2] = ["HTTP_PROXY", "http_proxy"];
const HTTPS_PROXY_KEYS: [&str; 2] = ["HTTPS_PROXY", "https_proxy"];
const NO_PROXY_KEYS: [&str; 2] = ["NO_PROXY", "no_proxy"];
/// Timeout configuration for outbound HTTP requests.
///
/// When set, the `reqwest::Client` will abort requests that take longer
/// than the configured duration and return a timeout error (which is
/// retryable by the existing exponential backoff logic).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TimeoutConfig {
/// Maximum time to wait for a connection to be established.
/// Defaults to 30 seconds.
pub connect_timeout: Duration,
/// Maximum time for the entire request (including reading the response
/// body). For streaming responses this is the timeout for the initial
/// handshake only; the stream itself is governed by SSE parsing.
/// Defaults to 5 minutes (300 seconds).
pub request_timeout: Duration,
}
impl Default for TimeoutConfig {
fn default() -> Self {
Self {
connect_timeout: Duration::from_secs(30),
request_timeout: Duration::from_secs(300),
}
}
}
impl TimeoutConfig {
/// Read timeout settings from the process environment.
/// - `CLAW_API_CONNECT_TIMEOUT` — connect timeout in seconds
/// - `CLAW_API_REQUEST_TIMEOUT` — overall request timeout in seconds
#[must_use]
pub fn from_env() -> Self {
let connect_timeout = std::env::var("CLAW_API_CONNECT_TIMEOUT")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or(Duration::from_secs(30));
let request_timeout = std::env::var("CLAW_API_REQUEST_TIMEOUT")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or(Duration::from_secs(300));
Self {
connect_timeout,
request_timeout,
}
}
/// Create from explicit second values (used by config file parsing).
#[must_use]
pub fn from_seconds(connect_secs: u64, request_secs: u64) -> Self {
Self {
connect_timeout: Duration::from_secs(connect_secs),
request_timeout: Duration::from_secs(request_secs),
}
}
}
/// Snapshot of the proxy-related environment variables that influence the
/// outbound HTTP client. Captured up front so callers can inspect, log, and
/// test the resolved configuration without re-reading the process environment.
@@ -121,7 +61,7 @@ impl ProxyConfig {
/// `HTTPS_PROXY`, and `NO_PROXY` environment variables. When no proxy is
/// configured the client behaves identically to `reqwest::Client::new()`.
pub fn build_http_client() -> Result<reqwest::Client, ApiError> {
build_http_client_with_opts(&ProxyConfig::from_env(), &TimeoutConfig::from_env())
build_http_client_with(&ProxyConfig::from_env())
}
/// Infallible counterpart to [`build_http_client`] for constructors that
@@ -131,13 +71,12 @@ pub fn build_http_client() -> Result<reqwest::Client, ApiError> {
/// first outbound request instead of at construction time.
#[must_use]
pub fn build_http_client_or_default() -> reqwest::Client {
build_http_client_with_opts(&ProxyConfig::from_env(), &TimeoutConfig::from_env())
.unwrap_or_else(|_| {
reqwest::Client::builder()
.user_agent("clawd-rust-tools/0.1")
.build()
.expect("default client with user_agent should always succeed")
})
build_http_client().unwrap_or_else(|_| {
reqwest::Client::builder()
.user_agent("clawd-rust-tools/0.1")
.build()
.expect("default client with user_agent should always succeed")
})
}
/// Build a `reqwest::Client` from an explicit [`ProxyConfig`]. Used by tests
@@ -147,20 +86,9 @@ pub fn build_http_client_or_default() -> reqwest::Client {
/// and `https_proxy` fields and is registered as both an HTTP and HTTPS
/// proxy so a single value can route every outbound request.
pub fn build_http_client_with(config: &ProxyConfig) -> Result<reqwest::Client, ApiError> {
build_http_client_with_opts(config, &TimeoutConfig::from_env())
}
/// Build a `reqwest::Client` from explicit [`ProxyConfig`] and [`TimeoutConfig`].
/// Used by callers that want to control both proxy routing and request timing.
pub fn build_http_client_with_opts(
config: &ProxyConfig,
timeout: &TimeoutConfig,
) -> Result<reqwest::Client, ApiError> {
let mut builder = reqwest::Client::builder()
.no_proxy()
.user_agent("clawd-rust-tools/0.1")
.connect_timeout(timeout.connect_timeout)
.timeout(timeout.request_timeout);
.user_agent("clawd-rust-tools/0.1");
let no_proxy = config
.no_proxy
@@ -203,7 +131,7 @@ where
mod tests {
use std::collections::HashMap;
use super::{build_http_client_with, build_http_client_with_opts, ProxyConfig, TimeoutConfig};
use super::{build_http_client_with, ProxyConfig};
fn config_from_map(pairs: &[(&str, &str)]) -> ProxyConfig {
let map: HashMap<String, String> = pairs
@@ -215,19 +143,30 @@ mod tests {
#[test]
fn proxy_config_is_empty_when_no_env_vars_are_set() {
// given
let config = config_from_map(&[]);
assert!(config.is_empty());
// when
let empty = config.is_empty();
// then
assert!(empty);
assert_eq!(config, ProxyConfig::default());
}
#[test]
fn proxy_config_reads_uppercase_http_https_and_no_proxy() {
// given
let pairs = [
("HTTP_PROXY", "http://proxy.internal:3128"),
("HTTPS_PROXY", "http://secure.internal:3129"),
("NO_PROXY", "localhost,127.0.0.1,.corp"),
];
// when
let config = config_from_map(&pairs);
// then
assert_eq!(
config.http_proxy.as_deref(),
Some("http://proxy.internal:3128")
@@ -245,12 +184,17 @@ mod tests {
#[test]
fn proxy_config_falls_back_to_lowercase_keys() {
// given
let pairs = [
("http_proxy", "http://lower.internal:3128"),
("https_proxy", "http://lower-secure.internal:3129"),
("no_proxy", ".lower"),
];
// when
let config = config_from_map(&pairs);
// then
assert_eq!(
config.http_proxy.as_deref(),
Some("http://lower.internal:3128")
@@ -264,11 +208,16 @@ mod tests {
#[test]
fn proxy_config_prefers_uppercase_over_lowercase_when_both_set() {
// given
let pairs = [
("HTTP_PROXY", "http://upper.internal:3128"),
("http_proxy", "http://lower.internal:3128"),
];
// when
let config = config_from_map(&pairs);
// then
assert_eq!(
config.http_proxy.as_deref(),
Some("http://upper.internal:3128")
@@ -277,39 +226,59 @@ mod tests {
#[test]
fn proxy_config_treats_empty_strings_as_unset() {
// given
let pairs = [("HTTP_PROXY", ""), ("http_proxy", "")];
// when
let config = config_from_map(&pairs);
// then
assert!(config.http_proxy.is_none());
}
#[test]
fn build_http_client_succeeds_when_no_proxy_is_configured() {
// given
let config = ProxyConfig::default();
// when
let result = build_http_client_with(&config);
// then
assert!(result.is_ok());
}
#[test]
fn build_http_client_succeeds_with_valid_http_and_https_proxies() {
// given
let config = ProxyConfig {
http_proxy: Some("http://proxy.internal:3128".to_string()),
https_proxy: Some("http://secure.internal:3129".to_string()),
no_proxy: Some("localhost,127.0.0.1".to_string()),
proxy_url: None,
};
// when
let result = build_http_client_with(&config);
// then
assert!(result.is_ok());
}
#[test]
fn build_http_client_returns_http_error_for_invalid_proxy_url() {
// given
let config = ProxyConfig {
http_proxy: None,
https_proxy: Some("not a url".to_string()),
no_proxy: None,
proxy_url: None,
};
// when
let result = build_http_client_with(&config);
// then
let error = result.expect_err("invalid proxy URL must be reported as a build failure");
assert!(
matches!(error, crate::error::ApiError::Http(_)),
@@ -319,7 +288,10 @@ mod tests {
#[test]
fn from_proxy_url_sets_unified_field_and_leaves_per_scheme_empty() {
// given / when
let config = ProxyConfig::from_proxy_url("http://unified.internal:3128");
// then
assert_eq!(
config.proxy_url.as_deref(),
Some("http://unified.internal:3128")
@@ -331,56 +303,49 @@ mod tests {
#[test]
fn build_http_client_succeeds_with_unified_proxy_url() {
// given
let config = ProxyConfig {
proxy_url: Some("http://unified.internal:3128".to_string()),
no_proxy: Some("localhost".to_string()),
..ProxyConfig::default()
};
// when
let result = build_http_client_with(&config);
// then
assert!(result.is_ok());
}
#[test]
fn proxy_url_takes_precedence_over_per_scheme_fields() {
// given both per-scheme and unified are set
let config = ProxyConfig {
http_proxy: Some("http://per-scheme.internal:1111".to_string()),
https_proxy: Some("http://per-scheme.internal:2222".to_string()),
no_proxy: None,
proxy_url: Some("http://unified.internal:3128".to_string()),
};
// when building succeeds (the unified URL is valid)
let result = build_http_client_with(&config);
// then
assert!(result.is_ok());
}
#[test]
fn build_http_client_returns_error_for_invalid_unified_proxy_url() {
// given
let config = ProxyConfig::from_proxy_url("not a url");
// when
let result = build_http_client_with(&config);
// then
assert!(
matches!(result, Err(crate::error::ApiError::Http(_))),
"invalid unified proxy URL should fail: {result:?}"
);
}
#[test]
fn timeout_config_defaults() {
let config = TimeoutConfig::default();
assert_eq!(config.connect_timeout, std::time::Duration::from_secs(30));
assert_eq!(config.request_timeout, std::time::Duration::from_secs(300));
}
#[test]
fn timeout_config_from_seconds() {
let config = TimeoutConfig::from_seconds(10, 60);
assert_eq!(config.connect_timeout, std::time::Duration::from_secs(10));
assert_eq!(config.request_timeout, std::time::Duration::from_secs(60));
}
#[test]
fn build_http_client_with_custom_timeouts() {
let config = ProxyConfig::default();
let timeout = TimeoutConfig::from_seconds(5, 120);
let result = build_http_client_with_opts(&config, &timeout);
assert!(result.is_ok());
}
}
+1 -2
View File
@@ -12,8 +12,7 @@ pub use client::{
};
pub use error::ApiError;
pub use http_client::{
build_http_client, build_http_client_or_default, build_http_client_with,
build_http_client_with_opts, ProxyConfig, TimeoutConfig,
build_http_client, build_http_client_or_default, build_http_client_with, ProxyConfig,
};
pub use prompt_cache::{
CacheBreakEvent, PromptCache, PromptCacheConfig, PromptCachePaths, PromptCacheRecord,
+2 -66
View File
@@ -211,19 +211,6 @@ impl AnthropicClient {
self
}
/// Replace the internal HTTP client with one that respects the given
/// timeout configuration. This controls connect and request-level
/// timeouts for all outbound API calls.
#[must_use]
pub fn with_timeout(mut self, timeout: &crate::http_client::TimeoutConfig) -> Self {
self.http = crate::http_client::build_http_client_with_opts(
&crate::http_client::ProxyConfig::from_env(),
timeout,
)
.unwrap_or_else(|_| reqwest::Client::new());
self
}
#[must_use]
pub fn with_session_tracer(mut self, session_tracer: SessionTracer) -> Self {
self.session_tracer = Some(session_tracer);
@@ -467,13 +454,7 @@ impl AnthropicClient {
break;
}
let delay = if let Some(retry_after) = last_error.as_ref().and_then(|e| e.retry_after())
{
retry_after
} else {
self.jittered_backoff_for_attempt(attempts)?
};
tokio::time::sleep(delay).await;
tokio::time::sleep(self.jittered_backoff_for_attempt(attempts)?).await;
}
Err(ApiError::RetriesExhausted {
@@ -885,12 +866,10 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
return Ok(response);
}
let headers = response.headers().clone();
let request_id = request_id_from_headers(&headers);
let request_id = request_id_from_headers(response.headers());
let body = response.text().await.unwrap_or_else(|_| String::new());
let parsed_error = serde_json::from_str::<AnthropicErrorEnvelope>(&body).ok();
let retryable = is_retryable_status(status);
let retry_after = parse_retry_after(&headers, status);
Err(ApiError::Api {
status,
@@ -904,44 +883,13 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
body,
retryable,
suggested_action: None,
retry_after,
})
}
fn parse_retry_after(
headers: &reqwest::header::HeaderMap,
status: reqwest::StatusCode,
) -> Option<std::time::Duration> {
if status != reqwest::StatusCode::TOO_MANY_REQUESTS {
return None;
}
headers
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.map(std::time::Duration::from_secs)
}
const fn is_retryable_status(status: reqwest::StatusCode) -> bool {
matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504)
}
/// Some providers return HTTP 400 with an unparseable body when a gateway
/// or proxy flakes (e.g. "HTTP 400 from backend (no parseable body)").
/// These are transient network blips, not actual bad requests, and should
/// be retried. We detect them by checking the body for known gateway error
/// phrases.
fn is_retryable_400(status: reqwest::StatusCode, body: &str) -> bool {
if status != reqwest::StatusCode::BAD_REQUEST {
return false;
}
let lowered = body.to_ascii_lowercase();
lowered.contains("no parseable body")
|| lowered.contains("connection reset")
|| lowered.contains("broken pipe")
|| lowered.contains("empty reply from server")
}
/// Anthropic API keys (`sk-ant-*`) are accepted over the `x-api-key` header
/// and rejected with HTTP 401 "Invalid bearer token" when sent as a Bearer
/// token via `ANTHROPIC_AUTH_TOKEN`. This happens often enough in the wild
@@ -960,8 +908,6 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
body,
retryable,
suggested_action,
retry_after,
..
} = error
else {
return error;
@@ -975,7 +921,6 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
body,
retryable,
suggested_action,
retry_after,
};
}
let Some(bearer_token) = auth.bearer_token() else {
@@ -987,7 +932,6 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
body,
retryable,
suggested_action,
retry_after,
};
};
if !bearer_token.starts_with("sk-ant-") {
@@ -999,7 +943,6 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
body,
retryable,
suggested_action,
retry_after,
};
}
// Only append the hint when the AuthSource is pure BearerToken. If both
@@ -1015,7 +958,6 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
body,
retryable,
suggested_action,
retry_after,
};
}
let enriched_message = match message {
@@ -1030,7 +972,6 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
body,
retryable,
suggested_action,
retry_after,
}
}
@@ -1655,7 +1596,6 @@ mod tests {
body: String::new(),
retryable: false,
suggested_action: None,
retry_after: None,
};
// when
@@ -1697,7 +1637,6 @@ mod tests {
body: String::new(),
retryable: true,
suggested_action: None,
retry_after: None,
};
// when
@@ -1727,7 +1666,6 @@ mod tests {
body: String::new(),
retryable: false,
suggested_action: None,
retry_after: None,
};
// when
@@ -1756,7 +1694,6 @@ mod tests {
body: String::new(),
retryable: false,
suggested_action: None,
retry_after: None,
};
// when
@@ -1782,7 +1719,6 @@ mod tests {
body: String::new(),
retryable: false,
suggested_action: None,
retry_after: None,
};
// when
-14
View File
@@ -296,15 +296,6 @@ pub fn metadata_for_model(model: &str) -> Option<ProviderMetadata> {
None
}
#[must_use]
pub fn strip_provider_prefix(canonical_model: &str) -> String {
if let Some(pos) = canonical_model.find('/') {
canonical_model[pos + 1..].to_string()
} else {
canonical_model.to_string()
}
}
#[must_use]
pub fn provider_diagnostics_for_model(model: &str) -> ProviderDiagnostics {
let resolved_model = resolve_model_alias(model);
@@ -360,11 +351,6 @@ fn looks_like_local_openai_model(model: &str) -> bool {
#[must_use]
pub fn detect_provider_kind(model: &str) -> ProviderKind {
// OLLAMA_HOST takes priority: if set, route all models through the local
// OpenAI-compatible endpoint regardless of model name or other env vars.
if std::env::var_os("OLLAMA_HOST").is_some() {
return ProviderKind::OpenAi;
}
let resolved_model = resolve_model_alias(model);
if let Some(metadata) = metadata_for_model(&resolved_model) {
return metadata.provider;
+119 -120
View File
@@ -16,7 +16,7 @@ use crate::types::{
ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
};
use super::{preflight_message_request, resolve_model_alias, Provider, ProviderFuture};
use super::{preflight_message_request, Provider, ProviderFuture};
pub const DEFAULT_XAI_BASE_URL: &str = "https://api.x.ai/v1";
pub const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
@@ -49,14 +49,6 @@ const XAI_MAX_REQUEST_BODY_BYTES: usize = 52_428_800; // 50MB
const OPENAI_MAX_REQUEST_BODY_BYTES: usize = 104_857_600; // 100MB
const DASHSCOPE_MAX_REQUEST_BODY_BYTES: usize = 6_291_456; // 6MB (observed limit in dogfood)
pub const OLLAMA_CONFIG: OpenAiCompatConfig = OpenAiCompatConfig {
provider_name: "Ollama",
api_key_env: "OLLAMA_HOST",
base_url_env: "OLLAMA_HOST",
default_base_url: "http://127.0.0.1:11434/v1",
max_request_body_bytes: 104_857_600,
};
impl OpenAiCompatConfig {
#[must_use]
pub const fn xai() -> Self {
@@ -157,22 +149,6 @@ impl OpenAiCompatClient {
};
Ok(Self::new(api_key, config).with_base_url(base_url))
}
/// Create an Ollama client from `OLLAMA_HOST` env var.
/// Ollama requires no API key; a placeholder is used for the Authorization header.
pub fn from_ollama_env() -> Option<Self> {
let host =
std::env::var("OLLAMA_HOST").unwrap_or_else(|_| "http://127.0.0.1:11434".to_string());
let base_url = format!("{}/v1", host.trim_end_matches('/'));
Some(Self {
http: build_http_client_or_default(),
api_key: "ollama".to_string(),
config: OLLAMA_CONFIG,
base_url,
max_retries: DEFAULT_MAX_RETRIES,
initial_backoff: DEFAULT_INITIAL_BACKOFF,
max_backoff: DEFAULT_MAX_BACKOFF,
})
}
#[must_use]
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
@@ -199,35 +175,22 @@ impl OpenAiCompatClient {
self
}
/// Replace the internal HTTP client with one that respects the given
/// timeout configuration.
#[must_use]
pub fn with_timeout(mut self, timeout: &crate::http_client::TimeoutConfig) -> Self {
self.http = crate::http_client::build_http_client_with_opts(
&crate::http_client::ProxyConfig::from_env(),
timeout,
)
.unwrap_or_else(|_| reqwest::Client::new());
self
}
pub async fn send_message(
&self,
request: &MessageRequest,
) -> Result<MessageResponse, ApiError> {
let original_model = request.model.clone();
let canonical = resolve_model_alias(&request.model);
let mut request = MessageRequest {
let request = MessageRequest {
stream: false,
..request.clone()
};
request.model = canonical;
preflight_message_request(&request)?;
let response = self.send_with_retry(&request).await?;
let request_id = request_id_from_headers(response.headers());
let body = response.text().await.map_err(ApiError::from)?;
// Some backends return {"error":{"message":"...","type":"...","code":...}}
// instead of a valid completion object. Check for this before attempting
// full deserialization so the user sees the actual error, not a cryptic
// "missing field 'id'" parse failure.
if let Ok(raw) = serde_json::from_str::<serde_json::Value>(&body) {
if let Some(err_obj) = raw.get("error") {
let msg = err_obj
@@ -254,18 +217,16 @@ impl OpenAiCompatClient {
reqwest::StatusCode::from_u16(code.unwrap_or(400))
.unwrap_or(reqwest::StatusCode::BAD_REQUEST),
),
retry_after: None,
});
}
}
let payload = serde_json::from_str::<ChatCompletionResponse>(&body).map_err(|error| {
ApiError::json_deserialize(self.config.provider_name, &original_model, &body, error)
ApiError::json_deserialize(self.config.provider_name, &request.model, &body, error)
})?;
let mut normalized = normalize_response(&request.model, payload)?;
if normalized.request_id.is_none() {
normalized.request_id = request_id;
}
normalized.model = original_model;
Ok(normalized)
}
@@ -273,25 +234,17 @@ impl OpenAiCompatClient {
&self,
request: &MessageRequest,
) -> Result<MessageStream, ApiError> {
let original_model = request.model.clone();
let canonical = resolve_model_alias(&request.model);
let mut streaming_request = request.clone().with_streaming();
streaming_request.model = canonical;
preflight_message_request(&streaming_request)?;
let response = self.send_with_retry(&streaming_request).await?;
preflight_message_request(request)?;
let response = self
.send_with_retry(&request.clone().with_streaming())
.await?;
Ok(MessageStream {
request_id: request_id_from_headers(response.headers()),
response,
parser: OpenAiSseParser::with_context(
self.config.provider_name,
original_model.clone(),
),
parser: OpenAiSseParser::with_context(self.config.provider_name, request.model.clone()),
pending: VecDeque::new(),
done: false,
state: StreamState::new(original_model),
state: StreamState::new(request.model.clone()),
})
}
@@ -317,12 +270,7 @@ impl OpenAiCompatClient {
break retryable_error;
}
let delay = if let Some(retry_after) = retryable_error.retry_after() {
retry_after
} else {
self.jittered_backoff_for_attempt(attempts)?
};
tokio::time::sleep(delay).await;
tokio::time::sleep(self.jittered_backoff_for_attempt(attempts)?).await;
};
Err(ApiError::RetriesExhausted {
@@ -572,7 +520,6 @@ impl StreamState {
.delta
.reasoning_content
.filter(|value| !value.is_empty())
.or(choice.delta.reasoning.filter(|value| !value.is_empty()))
.or(choice
.delta
.thinking
@@ -639,6 +586,21 @@ impl StreamState {
}
}
if let Some(delta_extra) = &choice.delta.extra_content {
if let Some(delta_sig) = delta_extra
.get("google")
.and_then(|g| g.get("thought_signature"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
for state in self.tool_calls.values_mut() {
if state.thought_signature.is_none() {
state.thought_signature.get_or_insert(delta_sig.to_string());
}
}
}
}
if let Some(finish_reason) = choice.finish_reason {
self.stop_reason = Some(normalize_finish_reason(&finish_reason));
if finish_reason == "tool_calls" {
@@ -746,6 +708,7 @@ struct ToolCallState {
id: Option<String>,
name: Option<String>,
arguments: String,
thought_signature: Option<String>,
emitted_len: usize,
started: bool,
stopped: bool,
@@ -763,6 +726,24 @@ impl ToolCallState {
if let Some(arguments) = tool_call.function.arguments {
self.arguments.push_str(&arguments);
}
if let Some(sig) = tool_call.thought_signature.filter(|s| !s.is_empty()) {
self.thought_signature.get_or_insert(sig);
}
// https://ai.google.dev/gemini-api/docs/thought-signatures
if self.thought_signature.is_none() {
if let Some(sig) = tool_call
.extra_content
.as_ref()
.and_then(|ec| ec.get("google"))
.and_then(|g| g.get("thought_signature"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
self.thought_signature.get_or_insert(sig.to_string());
}
}
}
const fn block_index(&self, offset: u32) -> u32 {
@@ -784,6 +765,7 @@ impl ToolCallState {
id,
name,
input: json!({}),
thought_signature: self.thought_signature.clone(),
},
}))
}
@@ -828,8 +810,6 @@ struct ChatMessage {
#[serde(default)]
reasoning_content: Option<String>,
#[serde(default)]
reasoning: Option<String>,
#[serde(default)]
tool_calls: Vec<ResponseToolCall>,
}
@@ -837,6 +817,10 @@ struct ChatMessage {
struct ResponseToolCall {
id: String,
function: ResponseToolFunction,
#[serde(default)]
thought_signature: Option<String>,
#[serde(default)]
extra_content: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
@@ -904,11 +888,11 @@ struct ChunkDelta {
#[serde(default)]
reasoning_content: Option<String>,
#[serde(default)]
reasoning: Option<String>,
#[serde(default)]
thinking: Option<ThinkingDelta>,
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
tool_calls: Vec<DeltaToolCall>,
#[serde(default)]
extra_content: Option<serde_json::Value>,
}
#[derive(Debug, Default, Deserialize)]
@@ -917,7 +901,7 @@ struct ThinkingDelta {
content: Option<String>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Default, Deserialize)]
struct DeltaToolCall {
#[serde(default)]
index: u32,
@@ -925,6 +909,10 @@ struct DeltaToolCall {
id: Option<String>,
#[serde(default)]
function: DeltaFunction,
#[serde(default)]
thought_signature: Option<String>,
#[serde(default)]
extra_content: Option<serde_json::Value>,
}
#[derive(Debug, Default, Deserialize)]
@@ -980,6 +968,22 @@ pub fn model_requires_reasoning_content_in_history(model: &str) -> bool {
canonical.starts_with("deepseek-v4")
}
/// Dummy thought signature accepted by Gemini as a validation bypass for
/// conversation history that lacks a real signature. Source:
/// - LiteLLM: https://github.com/BerriAI/litellm/pull/16812
/// - Google: https://ai.google.dev/gemini-api/docs/thought-signatures#faqs
const GEMINI_DUMMY_THOUGHT_SIGNATURE: &str = "c2tpcF90aG91Z2h0X3NpZ25hdHVyZV92YWxpZGF0b3I=";
/// Returns true if the model is a Gemini model (Gemini 2.5+, 3+ etc) that
/// requires `thought_signature` on function calls in conversation history.
#[must_use]
pub fn is_gemini_model(model: &str) -> bool {
let lowered = model.to_ascii_lowercase();
let canonical = lowered.rsplit('/').next().unwrap_or(lowered.as_str());
canonical.starts_with("gemini")
}
/// Strip routing prefix (e.g., "openai/gpt-4" → "gpt-4") for the wire.
/// The prefix is used only to select transport; the backend expects the
/// bare model id. Use `local/` to force OpenAI-compatible routing while
@@ -1273,14 +1277,32 @@ pub fn translate_message(message: &InputMessage, model: &str) -> Vec<Value> {
InputContentBlock::Thinking {
thinking: value, ..
} => reasoning.push_str(value),
InputContentBlock::ToolUse { id, name, input } => tool_calls.push(json!({
"id": id,
"type": "function",
"function": {
"name": name,
"arguments": input.to_string(),
InputContentBlock::ToolUse { id, name, input, thought_signature } => {
let mut tc = json!({
"id": id,
"type": "function",
"function": {
"name": name,
"arguments": input.to_string(),
}
});
let sig_for_gemini = thought_signature.clone().or_else(|| {
if is_gemini_model(model) {
Some(GEMINI_DUMMY_THOUGHT_SIGNATURE.to_string())
} else {
None
}
});
if let Some(sig) = sig_for_gemini {
tc["extra_content"] = json!({
"google": {
"thought_signature": sig
}
});
}
})),
tool_calls.push(tc);
}
InputContentBlock::ToolResult { .. } => {}
}
}
@@ -1515,7 +1537,6 @@ fn normalize_response(
.message
.reasoning_content
.filter(|value| !value.is_empty())
.or(choice.message.reasoning.filter(|value| !value.is_empty()))
{
content.push(OutputContentBlock::Thinking {
thinking,
@@ -1526,10 +1547,22 @@ fn normalize_response(
content.push(OutputContentBlock::Text { text });
}
for tool_call in choice.message.tool_calls {
let thought_signature = tool_call.thought_signature.or_else(|| {
tool_call
.extra_content
.as_ref()
.and_then(|ec| ec.get("google"))
.and_then(|g| g.get("thought_signature"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from)
});
content.push(OutputContentBlock::ToolUse {
id: tool_call.id,
name: tool_call.function.name,
input: parse_tool_arguments(&tool_call.function.arguments),
thought_signature,
});
}
@@ -1619,7 +1652,6 @@ fn parse_sse_frame(
body: trimmed.chars().take(500).collect(),
retryable: false,
suggested_action: suggested_action_for_status(status),
retry_after: None,
});
}
}
@@ -1635,7 +1667,6 @@ fn parse_sse_frame(
body: trimmed.chars().take(200).collect(),
retryable: false,
suggested_action: Some("verify the API endpoint URL is correct".to_string()),
retry_after: None,
});
}
return Ok(None);
@@ -1671,7 +1702,6 @@ fn parse_sse_frame(
body: payload.clone(),
retryable: false,
suggested_action: suggested_action_for_status(status),
retry_after: None,
});
}
}
@@ -1688,7 +1718,6 @@ fn parse_sse_frame(
body: payload.chars().take(200).collect(),
retryable: false,
suggested_action: Some("verify the API endpoint URL is correct".to_string()),
retry_after: None,
});
}
serde_json::from_str::<ChatCompletionChunk>(&payload)
@@ -1740,12 +1769,10 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
return Ok(response);
}
let headers = response.headers().clone();
let request_id = request_id_from_headers(&headers);
let request_id = request_id_from_headers(response.headers());
let body = response.text().await.unwrap_or_default();
let parsed_error = serde_json::from_str::<ErrorEnvelope>(&body).ok();
let retryable = is_retryable_status(status);
let retry_after = parse_retry_after(&headers, status);
let suggested_action = suggested_action_for_status(status);
@@ -1761,43 +1788,13 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
body,
retryable,
suggested_action,
retry_after,
})
}
fn parse_retry_after(
headers: &reqwest::header::HeaderMap,
status: reqwest::StatusCode,
) -> Option<std::time::Duration> {
if status != reqwest::StatusCode::TOO_MANY_REQUESTS {
return None;
}
headers
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.map(std::time::Duration::from_secs)
}
const fn is_retryable_status(status: reqwest::StatusCode) -> bool {
matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504)
}
/// Some providers return HTTP 400 with an unparseable body when a gateway
/// or proxy flakes (e.g. "HTTP 400 from backend (no parseable body)").
/// These are transient network blips, not actual bad requests, and should
/// be retried.
fn is_retryable_400(status: reqwest::StatusCode, body: &str) -> bool {
if status != reqwest::StatusCode::BAD_REQUEST {
return false;
}
let lowered = body.to_ascii_lowercase();
lowered.contains("no parseable body")
|| lowered.contains("connection reset")
|| lowered.contains("broken pipe")
|| lowered.contains("empty reply from server")
}
/// Generate a suggested user action based on the HTTP status code and error context.
/// This provides actionable guidance when API requests fail.
fn suggested_action_for_status(status: reqwest::StatusCode) -> Option<String> {
@@ -1960,6 +1957,7 @@ mod tests {
id: "call_1".to_string(),
name: "get_weather".to_string(),
input: json!({"city": "Paris"}),
thought_signature: None,
}],
}],
stream: false,
@@ -1998,7 +1996,6 @@ mod tests {
role: "assistant".to_string(),
content: Some("final answer".to_string()),
reasoning_content: Some("hidden thought".to_string()),
reasoning: None,
tool_calls: Vec::new(),
},
finish_reason: Some("stop".to_string()),
@@ -2036,9 +2033,9 @@ mod tests {
delta: super::ChunkDelta {
content: None,
reasoning_content: Some("think".to_string()),
reasoning: None,
thinking: None,
tool_calls: Vec::new(),
extra_content: None,
},
finish_reason: None,
}],
@@ -2054,9 +2051,9 @@ mod tests {
delta: super::ChunkDelta {
content: Some(" answer".to_string()),
reasoning_content: None,
reasoning: None,
thinking: None,
tool_calls: Vec::new(),
extra_content: None,
},
finish_reason: Some("stop".to_string()),
}],
@@ -2601,6 +2598,7 @@ mod tests {
id: "call_1".to_string(),
name: "read_file".to_string(),
input: serde_json::json!({"path": "/tmp/test"}),
thought_signature: None,
}],
}],
stream: false,
@@ -2819,6 +2817,7 @@ mod tests {
id: "call_1".to_string(),
name: "read_file".to_string(),
input: serde_json::json!({"path": "/tmp/test"}),
thought_signature: None,
}],
},
InputMessage {
+4
View File
@@ -100,6 +100,8 @@ pub enum InputContentBlock {
id: String,
name: String,
input: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
thought_signature: Option<String>,
},
ToolResult {
tool_use_id: String,
@@ -167,6 +169,8 @@ pub enum OutputContentBlock {
id: String,
name: String,
input: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
thought_signature: Option<String>,
},
Thinking {
#[serde(default)]
@@ -166,55 +166,6 @@ async fn send_message_preserves_deepseek_reasoning_content_before_text() {
assert_eq!(body["thinking"], json!({"type": "enabled"}));
}
#[tokio::test]
async fn send_message_preserves_ollama_reasoning_before_text() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let body = concat!(
"{",
"\"id\":\"chatcmpl_ollama_reasoning\",",
"\"model\":\"qwen3:latest\",",
"\"choices\":[{",
"\"message\":{\"role\":\"assistant\",\"reasoning\":\"Think locally\",\"content\":\"Answer locally\",\"tool_calls\":[]},",
"\"finish_reason\":\"stop\"",
"}],",
"\"usage\":{\"prompt_tokens\":11,\"completion_tokens\":5}",
"}"
);
let server = spawn_server(
state.clone(),
vec![http_response("200 OK", "application/json", body)],
)
.await;
let client = OpenAiCompatClient::new("ollama-test-key", OpenAiCompatConfig::openai())
.with_base_url(server.base_url());
let response = client
.send_message(&MessageRequest {
model: "openai/qwen3:latest".to_string(),
..sample_request(false)
})
.await
.expect("request should succeed");
assert_eq!(
response.content,
vec![
OutputContentBlock::Thinking {
thinking: "Think locally".to_string(),
signature: None,
},
OutputContentBlock::Text {
text: "Answer locally".to_string(),
},
]
);
let captured = state.lock().await;
let request = captured.first().expect("server should capture request");
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
assert_eq!(body["model"], json!("qwen3:latest"));
}
#[tokio::test]
async fn local_openai_gateway_strips_routing_prefix_and_preserves_extra_body_params() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
@@ -438,83 +389,6 @@ async fn stream_message_normalizes_text_and_multiple_tool_calls() {
assert!(request.body.contains("\"stream\":true"));
}
#[tokio::test]
async fn stream_message_preserves_ollama_reasoning_before_text() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let sse = concat!(
"data: {\"id\":\"chatcmpl_stream_ollama_reasoning\",\"model\":\"qwen3:latest\",\"choices\":[{\"delta\":{\"reasoning\":\"Think\"}}]}\n\n",
"data: {\"id\":\"chatcmpl_stream_ollama_reasoning\",\"choices\":[{\"delta\":{\"content\":\" answer\"},\"finish_reason\":\"stop\"}]}\n\n",
"data: [DONE]\n\n"
);
let server = spawn_server(
state.clone(),
vec![http_response_with_headers(
"200 OK",
"text/event-stream",
sse,
&[("x-request-id", "req_ollama_reasoning_stream")],
)],
)
.await;
let client = OpenAiCompatClient::new("ollama-test-key", OpenAiCompatConfig::openai())
.with_base_url(server.base_url());
let mut stream = client
.stream_message(&MessageRequest {
model: "openai/qwen3:latest".to_string(),
..sample_request(false)
})
.await
.expect("stream should start");
assert_eq!(stream.request_id(), Some("req_ollama_reasoning_stream"));
let mut events = Vec::new();
while let Some(event) = stream.next_event().await.expect("event should parse") {
events.push(event);
}
assert!(matches!(events[0], StreamEvent::MessageStart(_)));
assert!(matches!(
events[1],
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
index: 0,
content_block: OutputContentBlock::Thinking { .. },
})
));
assert!(matches!(
events[2],
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
index: 0,
delta: ContentBlockDelta::ThinkingDelta { .. },
})
));
assert!(matches!(
events[3],
StreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 0 })
));
assert!(matches!(
events[4],
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
index: 1,
content_block: OutputContentBlock::Text { .. },
})
));
assert!(matches!(
events[5],
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
index: 1,
delta: ContentBlockDelta::TextDelta { .. },
})
));
let captured = state.lock().await;
let request = captured.first().expect("captured request");
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
assert_eq!(body["model"], json!("qwen3:latest"));
assert_eq!(body["stream"], json!(true));
}
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn stream_message_retries_retryable_sse_handshake_failures() {
@@ -674,13 +548,12 @@ async fn openai_compatible_client_honors_http_proxy_for_requests() {
.with_base_url("http://origin.invalid/v1");
let response = client
.send_message(&MessageRequest {
model: "openai/gpt-4.1-mini".to_string(),
model: "gpt-4o".to_string(),
..sample_request(false)
})
.await
.expect("proxy should return the OpenAI-compatible response");
assert_eq!(response.model, "openai/gpt-4.1-mini");
assert_eq!(response.total_tokens(), 7);
let captured = state.lock().await;
let request = captured.first().expect("proxy should capture request");
@@ -689,8 +562,6 @@ async fn openai_compatible_client_honors_http_proxy_for_requests() {
request.headers.get("authorization").map(String::as_str),
Some("Bearer openai-test-key")
);
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
assert_eq!(body["model"], json!("openai/gpt-4.1-mini"));
}
#[allow(clippy::await_holding_lock)]
+6 -5
View File
@@ -768,7 +768,7 @@ fn tool_calls_for_json(content: &[OutputContentBlock]) -> Vec<Value> {
content
.iter()
.filter_map(|b| {
if let OutputContentBlock::ToolUse { id, name, input } = b {
if let OutputContentBlock::ToolUse { id, name, input, .. } = b {
Some(json!({
"id": id,
"name": name,
@@ -1474,7 +1474,7 @@ async fn stream_to_message_response(
block_kind.insert(index, BlockKind::Text);
text_buf.insert(index, text);
}
OutputContentBlock::ToolUse { id, name, input } => {
OutputContentBlock::ToolUse { id, name, input, .. } => {
let json = if input.as_object().is_some_and(|m| m.is_empty()) {
String::new()
} else {
@@ -1523,7 +1523,7 @@ async fn stream_to_message_response(
Some(BlockKind::Tool { id, name, json }) => {
let input = serde_json::from_str::<Value>(&json)
.unwrap_or_else(|_| json!({ "raw": json }));
finished.insert(idx, OutputContentBlock::ToolUse { id, name, input });
finished.insert(idx, OutputContentBlock::ToolUse { id, name, input, thought_signature: None });
}
None => {}
}
@@ -1581,7 +1581,7 @@ fn collect_tool_uses(content: &[OutputContentBlock]) -> Vec<ToolUse<'_>> {
content
.iter()
.filter_map(|b| {
if let OutputContentBlock::ToolUse { id, name, input } = b {
if let OutputContentBlock::ToolUse { id, name, input, .. } = b {
Some(ToolUse {
id: id.as_str(),
name: name.as_str(),
@@ -1601,10 +1601,11 @@ fn output_to_input_blocks(blocks: &[OutputContentBlock]) -> Vec<InputContentBloc
OutputContentBlock::Text { text } => {
Some(InputContentBlock::Text { text: text.clone() })
}
OutputContentBlock::ToolUse { id, name, input } => Some(InputContentBlock::ToolUse {
OutputContentBlock::ToolUse { id, name, input, .. } => Some(InputContentBlock::ToolUse {
id: id.clone(),
name: name.clone(),
input: input.clone(),
thought_signature: None,
}),
OutputContentBlock::Thinking { .. } | OutputContentBlock::RedactedThinking { .. } => {
None
+91 -380
View File
@@ -720,13 +720,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "setup",
aliases: &[],
summary: "Run the interactive provider setup wizard",
argument_hint: None,
resume_supported: false,
},
SlashCommandSpec {
name: "notifications",
aliases: &[],
@@ -1109,7 +1102,6 @@ pub enum SlashCommand {
args: Option<String>,
},
Doctor,
Setup,
Login,
Logout,
Vim,
@@ -1231,7 +1223,6 @@ impl SlashCommand {
Self::Compact { .. } => "/compact",
Self::Cost => "/cost",
Self::Doctor => "/doctor",
Self::Setup => "/setup",
Self::Config { .. } => "/config",
Self::Memory { .. } => "/memory",
Self::History { .. } => "/history",
@@ -1401,10 +1392,6 @@ pub fn validate_slash_command_input(
validate_no_args(command, &args)?;
SlashCommand::Doctor
}
"setup" => {
validate_no_args(command, &args)?;
SlashCommand::Setup
}
"login" | "logout" => {
return Err(command_error(
"This auth flow was removed. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead.",
@@ -1585,10 +1572,7 @@ fn parse_clear_args(args: &[&str]) -> Result<bool, SlashCommandParseError> {
fn parse_config_section(args: &[&str]) -> Result<Option<String>, SlashCommandParseError> {
let section = optional_single_arg("config", args, "[env|hooks|model|plugins]")?;
if let Some(section) = section {
if matches!(
section.as_str(),
"env" | "hooks" | "model" | "plugins" | "help"
) {
if matches!(section.as_str(), "env" | "hooks" | "model" | "plugins") {
return Ok(Some(section));
}
return Err(command_error(
@@ -1927,7 +1911,7 @@ fn slash_command_category(name: &str) -> &'static str {
| "stickers" | "language" | "profile" | "max-tokens" | "temperature" | "system-prompt"
| "api-key" | "terminal-setup" | "notifications" | "telemetry" | "providers" | "env"
| "project" | "reasoning" | "budget" | "rate-limit" | "workspace" | "reset" | "ide"
| "desktop" | "upgrade" | "setup" => "Config",
| "desktop" | "upgrade" => "Config",
"debug-tool-call" | "doctor" | "sandbox" | "diagnostics" | "tool-details" | "changelog"
| "metrics" => "Debug",
_ => "Tools",
@@ -2163,7 +2147,7 @@ impl DefinitionSource {
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AgentSummary {
struct AgentSummary {
name: String,
description: Option<String>,
model: Option<String>,
@@ -2174,20 +2158,6 @@ pub(crate) struct AgentSummary {
path: Option<PathBuf>,
}
/// An agent definition file that could not be loaded.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct InvalidAgentConfig {
pub(crate) path: PathBuf,
pub(crate) reason: String,
}
/// Loaded agent definitions plus any invalid entries that were skipped.
#[derive(Debug, Clone, Default)]
pub(crate) struct AgentCollection {
pub(crate) agents: Vec<AgentSummary>,
pub(crate) invalid_agents: Vec<InvalidAgentConfig>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SkillSummary {
name: String,
@@ -2197,23 +2167,6 @@ struct SkillSummary {
origin: SkillOrigin,
// #729: on-disk path parity with AgentSummary
path: Option<PathBuf>,
// #445: directory name for detecting name/dir mismatch
dir_name: Option<String>,
}
/// A skill where the frontmatter name differs from the directory name.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SkillMetadataDrift {
pub(crate) dir_name: String,
pub(crate) frontmatter_name: String,
pub(crate) path: PathBuf,
}
/// Loaded skill definitions plus any metadata drift entries.
#[derive(Debug, Clone, Default)]
pub(crate) struct SkillCollection {
pub(crate) skills: Vec<SkillSummary>,
pub(crate) metadata_drift: Vec<SkillMetadataDrift>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -2327,15 +2280,13 @@ pub fn handle_plugins_slash_command(
});
};
let plugin = resolve_plugin_target(manager, target)?;
let already_enabled = plugin.enabled;
manager.enable(&plugin.metadata.id)?;
Ok(PluginsCommandResult {
message: format!(
"Plugins\n Result {}\n Name {}\n Version {}\n Status enabled",
if already_enabled { "already enabled" } else { "enabled" },
plugin.metadata.name, plugin.metadata.version
"Plugins\n Result enabled {}\n Name {}\n Version {}\n Status enabled",
plugin.metadata.id, plugin.metadata.name, plugin.metadata.version
),
reload_runtime: !already_enabled,
reload_runtime: true,
})
}
Some("disable") => {
@@ -2346,15 +2297,13 @@ pub fn handle_plugins_slash_command(
});
};
let plugin = resolve_plugin_target(manager, target)?;
let already_disabled = !plugin.enabled;
manager.disable(&plugin.metadata.id)?;
Ok(PluginsCommandResult {
message: format!(
"Plugins\n Result {}\n Name {}\n Version {}\n Status disabled",
if already_disabled { "already disabled" } else { "disabled" },
plugin.metadata.name, plugin.metadata.version
"Plugins\n Result disabled {}\n Name {}\n Version {}\n Status disabled",
plugin.metadata.id, plugin.metadata.name, plugin.metadata.version
),
reload_runtime: !already_disabled,
reload_runtime: true,
})
}
Some("remove") | Some("uninstall") => {
@@ -2545,8 +2494,8 @@ pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
match normalize_optional_args(args) {
None | Some("list") => {
let roots = discover_definition_roots(cwd, "agents");
let collection = load_agents_from_roots_with_invalids(&roots)?;
Ok(render_agents_report_json(cwd, &collection))
let agents = load_agents_from_roots(&roots)?;
Ok(render_agents_report_json(cwd, &agents))
}
Some(args) if args.starts_with("list ") => {
let filter = args["list ".len()..].trim().to_lowercase();
@@ -2563,26 +2512,17 @@ pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
}));
}
let roots = discover_definition_roots(cwd, "agents");
let collection = load_agents_from_roots_with_invalids(&roots)?;
let filtered_agents: Vec<_> = collection
.agents
let agents = load_agents_from_roots(&roots)?;
let filtered: Vec<_> = agents
.into_iter()
.filter(|a| a.name.to_lowercase().contains(&filter))
.collect();
let filtered_collection = AgentCollection {
agents: filtered_agents,
invalid_agents: collection.invalid_agents,
};
Ok(render_agents_report_json(cwd, &filtered_collection))
Ok(render_agents_report_json(cwd, &filtered))
}
Some("show" | "info" | "describe") => {
let roots = discover_definition_roots(cwd, "agents");
let collection = load_agents_from_roots_with_invalids(&roots)?;
Ok(render_agents_report_json_with_action(
cwd,
&collection,
"show",
))
let agents = load_agents_from_roots(&roots)?;
Ok(render_agents_report_json_with_action(cwd, &agents, "show"))
}
Some(args)
if args.starts_with("show ")
@@ -2613,9 +2553,8 @@ pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
}));
}
let roots = discover_definition_roots(cwd, "agents");
let collection = load_agents_from_roots_with_invalids(&roots)?;
let matched: Vec<_> = collection
.agents
let agents = load_agents_from_roots(&roots)?;
let matched: Vec<_> = agents
.into_iter()
.filter(|a| a.name.to_lowercase() == name)
.collect();
@@ -2632,15 +2571,7 @@ pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
"hint": "Run `claw agents list` to see available agents.",
}));
}
let matched_collection = AgentCollection {
agents: matched,
invalid_agents: collection.invalid_agents,
};
Ok(render_agents_report_json_with_action(
cwd,
&matched_collection,
"show",
))
Ok(render_agents_report_json_with_action(cwd, &matched, "show"))
}
Some("create") => Ok(render_agents_missing_argument_json("create", "agent_name")),
Some(args) if args.starts_with("create ") => {
@@ -2773,26 +2704,15 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
std::io::ErrorKind::InvalidInput,
"missing_argument: skills install requires an install source.\nUsage: claw skills install <path>",
)),
// #95: support --project flag for project-level install
Some(args) if args.starts_with("install ") => {
let rest = args["install ".len()..].trim();
let (target, project_flag) = if let Some(t) = rest.strip_prefix("--project") {
(t.trim_start().trim_start_matches('=').trim(), true)
} else {
(rest, false)
};
let target = args["install ".len()..].trim();
if target.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"missing_argument: skills install requires an install source.\nUsage: claw skills install [--project] <path>",
"missing_argument: skills install requires an install source.\nUsage: claw skills install <path>",
));
}
let install = if project_flag {
let project_root = cwd.join(".claw").join("skills");
install_skill_into(target, cwd, &project_root)?
} else {
install_skill(target, cwd)?
};
let install = install_skill(target, cwd)?;
Ok(render_skill_install_report(&install))
}
Some("uninstall" | "remove" | "delete") => Err(std::io::Error::new(
@@ -2846,8 +2766,8 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
match normalize_optional_args(args) {
None | Some("list") => {
let roots = discover_skill_roots(cwd);
let collection = load_skills_from_roots_with_drift(&roots)?;
Ok(render_skills_report_json_with_action(&collection, "list"))
let skills = load_skills_from_roots(&roots)?;
Ok(render_skills_report_json_with_action(&skills, "list"))
}
Some(args) if args.starts_with("list ") => {
let filter = args["list ".len()..].trim().to_lowercase();
@@ -2864,25 +2784,17 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
}));
}
let roots = discover_skill_roots(cwd);
let collection = load_skills_from_roots_with_drift(&roots)?;
let filtered_skills: Vec<_> = collection
.skills
let skills = load_skills_from_roots(&roots)?;
let filtered: Vec<_> = skills
.into_iter()
.filter(|s| s.name.to_lowercase().contains(&filter))
.collect();
let filtered_collection = SkillCollection {
skills: filtered_skills,
metadata_drift: collection.metadata_drift,
};
Ok(render_skills_report_json_with_action(
&filtered_collection,
"list",
))
Ok(render_skills_report_json_with_action(&filtered, "list"))
}
Some("show" | "info" | "describe") => {
let roots = discover_skill_roots(cwd);
let collection = load_skills_from_roots_with_drift(&roots)?;
Ok(render_skills_report_json_with_action(&collection, "show"))
let skills = load_skills_from_roots(&roots)?;
Ok(render_skills_report_json_with_action(&skills, "show"))
}
Some(args)
if args.starts_with("show ")
@@ -2913,9 +2825,8 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
}));
}
let roots = discover_skill_roots(cwd);
let collection = load_skills_from_roots_with_drift(&roots)?;
let matched: Vec<_> = collection
.skills
let skills = load_skills_from_roots(&roots)?;
let matched: Vec<_> = skills
.into_iter()
.filter(|s| s.name.to_lowercase() == name)
.collect();
@@ -2932,42 +2843,23 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
"hint": "Run `claw skills list` to see available skills.",
}));
}
let matched_collection = SkillCollection {
skills: matched,
metadata_drift: collection.metadata_drift,
};
Ok(render_skills_report_json_with_action(
&matched_collection,
"show",
))
Ok(render_skills_report_json_with_action(&matched, "show"))
}
Some("install") => Ok(render_skills_missing_argument_json(
"install",
"install_source",
"Usage: claw skills install <path>",
)),
// #95: support --project flag for project-level install
Some(args) if args.starts_with("install ") => {
let rest = args["install ".len()..].trim();
let (target, project_flag) = if let Some(t) = rest.strip_prefix("--project") {
(t.trim_start().trim_start_matches('=').trim(), true)
} else {
(rest, false)
};
let target = args["install ".len()..].trim();
if target.is_empty() {
return Ok(render_skills_missing_argument_json(
"install",
"install_source",
"Usage: claw skills install [--project] <path>",
"Usage: claw skills install <path>",
));
}
let result = if project_flag {
let project_root = cwd.join(".claw").join("skills");
install_skill_into(target, cwd, &project_root)
} else {
install_skill(target, cwd)
};
match result {
match install_skill(target, cwd) {
Ok(install) => Ok(render_skill_install_report_json(&install)),
Err(error) => Ok(render_skill_install_error_json(target, &error)),
}
@@ -3351,15 +3243,7 @@ fn render_mcp_report_json_for(
"use `claw mcp show <server>` to inspect a server",
))
}
Some(args) => {
// #681: unsupported mutation verbs (add, remove, delete, enable, disable)
// and other unknown sub-actions return a typed error instead of help with exit 0.
let verb = args.split_whitespace().next().unwrap_or(args);
Ok(render_mcp_unsupported_action_json(
args,
&format!("`{verb}` is not a supported MCP sub-action; supported actions: list, show, help"),
))
}
Some(args) => Ok(render_mcp_usage_json(Some(args))),
}
}
@@ -4018,69 +3902,30 @@ fn push_unique_skill_root(
fn load_agents_from_roots(
roots: &[(DefinitionSource, PathBuf)],
) -> std::io::Result<Vec<AgentSummary>> {
let collection = load_agents_from_roots_with_invalids(roots)?;
Ok(collection.agents)
}
/// Load agent definitions from all roots, collecting both valid agents and
/// invalid entries (wrong extension, broken frontmatter, etc.).
fn load_agents_from_roots_with_invalids(
roots: &[(DefinitionSource, PathBuf)],
) -> std::io::Result<AgentCollection> {
let mut agents = Vec::new();
let mut invalid_agents = Vec::new();
let mut active_sources = BTreeMap::<String, DefinitionSource>::new();
for (source, root) in roots {
let mut root_agents = Vec::new();
for entry in fs::read_dir(root)? {
let entry = entry?;
let path = entry.path();
let ext = path.extension().and_then(|e| e.to_str());
match ext {
Some("toml") => {
let contents = fs::read_to_string(&path)?;
let fallback_name = path.file_stem().map_or_else(
|| entry.file_name().to_string_lossy().to_string(),
|stem| stem.to_string_lossy().to_string(),
);
root_agents.push(AgentSummary {
name: parse_toml_string(&contents, "name").unwrap_or(fallback_name),
description: parse_toml_string(&contents, "description"),
model: parse_toml_string(&contents, "model"),
reasoning_effort: parse_toml_string(&contents, "model_reasoning_effort"),
source: *source,
shadowed_by: None,
path: Some(path),
});
}
Some("md") => {
let contents = fs::read_to_string(&path)?;
let (name, description, model, reasoning_effort) =
parse_agent_frontmatter(&contents);
if name.is_none() && description.is_none() {
invalid_agents.push(InvalidAgentConfig {
path,
reason: "Markdown agent file has no YAML frontmatter with name or description fields".to_string(),
});
continue;
}
let fallback_name = path.file_stem().map_or_else(
|| entry.file_name().to_string_lossy().to_string(),
|stem| stem.to_string_lossy().to_string(),
);
root_agents.push(AgentSummary {
name: name.unwrap_or(fallback_name),
description,
model,
reasoning_effort,
source: *source,
shadowed_by: None,
path: Some(path),
});
}
_ => continue,
if entry.path().extension().is_none_or(|ext| ext != "toml") {
continue;
}
let contents = fs::read_to_string(entry.path())?;
let fallback_name = entry.path().file_stem().map_or_else(
|| entry.file_name().to_string_lossy().to_string(),
|stem| stem.to_string_lossy().to_string(),
);
root_agents.push(AgentSummary {
name: parse_toml_string(&contents, "name").unwrap_or(fallback_name),
description: parse_toml_string(&contents, "description"),
model: parse_toml_string(&contents, "model"),
reasoning_effort: parse_toml_string(&contents, "model_reasoning_effort"),
source: *source,
shadowed_by: None,
path: Some(entry.path()),
});
}
root_agents.sort_by(|left, right| left.name.cmp(&right.name));
@@ -4095,22 +3940,11 @@ fn load_agents_from_roots_with_invalids(
}
}
Ok(AgentCollection {
agents,
invalid_agents,
})
Ok(agents)
}
fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result<Vec<SkillSummary>> {
let collection = load_skills_from_roots_with_drift(roots)?;
Ok(collection.skills)
}
/// Load skill definitions from all roots, collecting metadata drift entries
/// where the frontmatter name differs from the directory name.
fn load_skills_from_roots_with_drift(roots: &[SkillRoot]) -> std::io::Result<SkillCollection> {
let mut skills = Vec::new();
let mut metadata_drift = Vec::new();
let mut active_sources = BTreeMap::<String, DefinitionSource>::new();
for root in roots {
@@ -4127,26 +3961,15 @@ fn load_skills_from_roots_with_drift(roots: &[SkillRoot]) -> std::io::Result<Ski
continue;
}
let contents = fs::read_to_string(skill_path)?;
let dir_name = entry.file_name().to_string_lossy().to_string();
let (name, description) = parse_skill_frontmatter(&contents);
// #445: detect name/dir mismatch
if let Some(ref frontmatter_name) = name {
if frontmatter_name != &dir_name {
metadata_drift.push(SkillMetadataDrift {
dir_name: dir_name.clone(),
frontmatter_name: frontmatter_name.clone(),
path: entry.path(),
});
}
}
root_skills.push(SkillSummary {
name: name.unwrap_or_else(|| dir_name.clone()),
name: name
.unwrap_or_else(|| entry.file_name().to_string_lossy().to_string()),
description,
source: root.source,
shadowed_by: None,
origin: root.origin,
path: Some(entry.path()),
dir_name: Some(dir_name),
});
}
SkillOrigin::LegacyCommandsDir => {
@@ -4179,7 +4002,6 @@ fn load_skills_from_roots_with_drift(roots: &[SkillRoot]) -> std::io::Result<Ski
shadowed_by: None,
origin: root.origin,
path: Some(markdown_path),
dir_name: None,
});
}
}
@@ -4197,10 +4019,7 @@ fn load_skills_from_roots_with_drift(roots: &[SkillRoot]) -> std::io::Result<Ski
}
}
Ok(SkillCollection {
skills,
metadata_drift,
})
Ok(skills)
}
fn parse_toml_string(contents: &str, key: &str) -> Option<String> {
@@ -4272,63 +4091,6 @@ fn unquote_frontmatter_value(value: &str) -> String {
.to_string()
}
/// Parse agent metadata from YAML frontmatter in `.md` agent files.
/// Returns (name, description, model, reasoning_effort) extracted from
/// the `---`-delimited YAML block at the top of the file.
fn parse_agent_frontmatter(
contents: &str,
) -> (
Option<String>,
Option<String>,
Option<String>,
Option<String>,
) {
let mut lines = contents.lines();
if lines.next().map(str::trim) != Some("---") {
return (None, None, None, None);
}
let mut name = None;
let mut description = None;
let mut model = None;
let mut reasoning_effort = None;
for line in lines {
let trimmed = line.trim();
if trimmed == "---" {
break;
}
if let Some(value) = trimmed.strip_prefix("name:") {
let value = unquote_frontmatter_value(value.trim());
if !value.is_empty() {
name = Some(value);
}
continue;
}
if let Some(value) = trimmed.strip_prefix("description:") {
let value = unquote_frontmatter_value(value.trim());
if !value.is_empty() {
description = Some(value);
}
continue;
}
if let Some(value) = trimmed.strip_prefix("model:") {
let value = unquote_frontmatter_value(value.trim());
if !value.is_empty() {
model = Some(value);
}
continue;
}
if let Some(value) = trimmed.strip_prefix("model_reasoning_effort:") {
let value = unquote_frontmatter_value(value.trim());
if !value.is_empty() {
reasoning_effort = Some(value);
}
}
}
(name, description, model, reasoning_effort)
}
fn render_agents_report(agents: &[AgentSummary]) -> String {
if agents.is_empty() {
return "No agents found.".to_string();
@@ -4371,42 +4133,31 @@ fn render_agents_report(agents: &[AgentSummary]) -> String {
lines.join("\n").trim_end().to_string()
}
fn render_agents_report_json(cwd: &Path, collection: &AgentCollection) -> Value {
render_agents_report_json_with_action(cwd, collection, "list")
fn render_agents_report_json(cwd: &Path, agents: &[AgentSummary]) -> Value {
render_agents_report_json_with_action(cwd, agents, "list")
}
fn render_agents_report_json_with_action(
cwd: &Path,
collection: &AgentCollection,
agents: &[AgentSummary],
action: &str,
) -> Value {
let agents = &collection.agents;
let invalid_agents = &collection.invalid_agents;
let active = agents
.iter()
.filter(|agent| agent.shadowed_by.is_none())
.count();
let has_invalids = !invalid_agents.is_empty();
let status = if has_invalids { "degraded" } else { "ok" };
json!({
"kind": "agents",
"status": status,
"status": "ok",
"action": action,
"working_directory": cwd.display().to_string(),
"count": agents.len(),
"valid_count": agents.len(),
"invalid_count": invalid_agents.len(),
"summary": {
"total": agents.len(),
"active": active,
"shadowed": agents.len().saturating_sub(active),
},
"agents": agents.iter().map(agent_summary_json).collect::<Vec<_>>(),
"invalid_agents": invalid_agents.iter().map(|invalid| json!({
"path": invalid.path.display().to_string(),
"reason": &invalid.reason,
"valid": false,
})).collect::<Vec<_>>(),
})
}
@@ -4526,34 +4277,21 @@ fn render_skills_report(skills: &[SkillSummary]) -> String {
lines.join("\n").trim_end().to_string()
}
fn render_skills_report_json_with_action(collection: &SkillCollection, action: &str) -> Value {
let skills = &collection.skills;
let metadata_drift = &collection.metadata_drift;
fn render_skills_report_json_with_action(skills: &[SkillSummary], action: &str) -> Value {
let active = skills
.iter()
.filter(|skill| skill.shadowed_by.is_none())
.count();
let has_drift = !metadata_drift.is_empty();
let status = if has_drift { "degraded" } else { "ok" };
// #410: add `count` field for polymorphic consumption parity with agents list
json!({
"kind": "skills",
"status": status,
"status": "ok",
"action": action,
"count": skills.len(),
"valid_count": skills.len(),
"metadata_drift_count": metadata_drift.len(),
"summary": {
"total": skills.len(),
"active": active,
"shadowed": skills.len().saturating_sub(active),
},
"skills": skills.iter().map(skill_summary_json).collect::<Vec<_>>(),
"metadata_drift": metadata_drift.iter().map(|drift| json!({
"dir_name": &drift.dir_name,
"frontmatter_name": &drift.frontmatter_name,
"path": drift.path.display().to_string(),
})).collect::<Vec<_>>(),
})
}
@@ -4729,7 +4467,6 @@ fn render_mcp_summary_report_json(cwd: &Path, mcp: &McpConfigCollection) -> Valu
json!({
"kind": "mcp",
"action": "list",
"count": mcp.valid_count(),
"working_directory": cwd.display().to_string(),
"configured_servers": mcp.valid_count(),
"total_configured": mcp.total_configured(),
@@ -4915,7 +4652,7 @@ fn render_agents_usage_json(unexpected: Option<&str>) -> Value {
"direct_cli": "claw agents [list|show <name>|create <name>|help]",
"format": "toml",
"create": "claw agents create <name>",
"sources": [".claw/agents", "~/.claw/agents", "~/.codex/agents", "$CLAW_CONFIG_HOME/agents"],
"sources": [".claw/agents", "~/.claw/agents", "$CLAW_CONFIG_HOME/agents"],
},
"unexpected": unexpected,
})
@@ -4924,12 +4661,12 @@ fn render_agents_usage_json(unexpected: Option<&str>) -> Value {
fn render_skills_usage(unexpected: Option<&str>) -> String {
let mut lines = vec![
"Skills".to_string(),
" Usage /skills [list|show <name>|install [--project] <path>|uninstall <name>|help|<skill> [args]]".to_string(),
" Usage /skills [list|show <name>|install <path>|uninstall <name>|help|<skill> [args]]".to_string(),
" Alias /skill".to_string(),
" Direct CLI claw skills [list|show <name>|install [--project] <path>|uninstall <name>|help|<skill> [args]]".to_string(),
" Direct CLI claw skills [list|show <name>|install <path>|uninstall <name>|help|<skill> [args]]".to_string(),
" Lifecycle install <path>, uninstall <name>".to_string(),
" Invoke /skills help overview -> $help overview".to_string(),
" Install root $CLAW_CONFIG_HOME/skills or ~/.claw/skills (use --project for .claw/skills)".to_string(),
" Install root $CLAW_CONFIG_HOME/skills or ~/.claw/skills".to_string(),
" Sources .claw/skills, .omc/skills, .agents/skills, .codex/skills, .claude/skills, ~/.claw/skills, ~/.omc/skills, ~/.claude/skills/omc-learned, ~/.codex/skills, ~/.claude/skills, legacy /commands".to_string(),
];
if let Some(args) = unexpected {
@@ -5045,7 +4782,7 @@ fn render_mcp_usage_json(unexpected: Option<&str>) -> Value {
"usage": {
"slash_command": "/mcp [list|show <server>|help]",
"direct_cli": "claw mcp [list|show <server>|help]",
"sources": [".claw.json", ".claw/settings.json", ".claw/settings.local.json"],
"sources": [".claw/settings.json", ".claw/settings.local.json"],
},
"unexpected": unexpected,
})
@@ -5233,51 +4970,34 @@ fn mcp_oauth_json(oauth: Option<&McpOAuthConfig>) -> Value {
}
fn mcp_server_details_json(config: &McpServerConfig) -> Value {
// #90: redact sensitive fields — args/url/headers_helper can contain
// credentials. Show structure without leaking secrets.
match config {
McpServerConfig::Stdio(config) => json!({
"command": &config.command,
"args_count": config.args.len(),
"args": &config.args,
"env_keys": config.env.keys().cloned().collect::<Vec<_>>(),
"tool_call_timeout_ms": config.tool_call_timeout_ms,
}),
McpServerConfig::Sse(config) | McpServerConfig::Http(config) => {
let redacted_url = redact_url(&config.url);
json!({
"url": redacted_url,
"header_keys": config.headers.keys().cloned().collect::<Vec<_>>(),
"headers_helper_configured": config.headers_helper.is_some(),
"oauth": mcp_oauth_json(config.oauth.as_ref()),
})
}
McpServerConfig::Ws(config) => {
let redacted_url = redact_url(&config.url);
json!({
"url": redacted_url,
"header_keys": config.headers.keys().cloned().collect::<Vec<_>>(),
"headers_helper_configured": config.headers_helper.is_some(),
})
}
McpServerConfig::Sse(config) | McpServerConfig::Http(config) => json!({
"url": &config.url,
"header_keys": config.headers.keys().cloned().collect::<Vec<_>>(),
"headers_helper": &config.headers_helper,
"oauth": mcp_oauth_json(config.oauth.as_ref()),
}),
McpServerConfig::Ws(config) => json!({
"url": &config.url,
"header_keys": config.headers.keys().cloned().collect::<Vec<_>>(),
"headers_helper": &config.headers_helper,
}),
McpServerConfig::Sdk(config) => json!({
"name": &config.name,
}),
McpServerConfig::ManagedProxy(config) => json!({
"url": redact_url(&config.url),
"url": &config.url,
"id": &config.id,
}),
}
}
fn redact_url(url: &str) -> String {
// #90: strip query params which may contain tokens, keep scheme+host+path
if let Some(query_start) = url.find('?') {
format!("{}?...", &url[..query_start])
} else {
url.to_string()
}
}
fn mcp_server_json(name: &str, server: &ScopedMcpServerConfig) -> Value {
json!({
"name": name,
@@ -5394,7 +5114,6 @@ pub fn handle_slash_command(
| SlashCommand::AddDir { .. }
| SlashCommand::History { .. }
| SlashCommand::Team { .. }
| SlashCommand::Setup
| SlashCommand::Unknown(_) => None,
}
}
@@ -5408,7 +5127,7 @@ mod tests {
render_agents_report_json, render_mcp_report_json_for, render_plugins_report,
render_plugins_report_with_failures, render_skills_report, render_slash_command_help,
render_slash_command_help_detail, resolve_skill_path, resume_supported_slash_commands,
slash_command_specs, suggest_slash_commands, validate_slash_command_input, AgentCollection,
slash_command_specs, suggest_slash_commands, validate_slash_command_input,
DefinitionSource, SkillOrigin, SkillRoot, SkillSlashDispatch, SlashCommand,
};
use plugins::{
@@ -6011,8 +5730,7 @@ mod tests {
assert!(help.contains("aliases: /skill"));
assert!(!help.contains("/login"));
assert!(!help.contains("/logout"));
assert!(help.contains("/setup"));
assert_eq!(slash_command_specs().len(), 140);
assert_eq!(slash_command_specs().len(), 139);
assert!(resume_supported_slash_commands().len() >= 39);
}
@@ -6403,10 +6121,7 @@ mod tests {
];
let report = render_agents_report_json(
&workspace,
&AgentCollection {
agents: load_agents_from_roots(&roots).expect("agent roots should load"),
invalid_agents: Vec::new(),
},
&load_agents_from_roots(&roots).expect("agent roots should load"),
);
assert_eq!(report["kind"], "agents");
@@ -6559,10 +6274,7 @@ mod tests {
},
];
let report = super::render_skills_report_json_with_action(
&super::SkillCollection {
skills: load_skills_from_roots(&roots).expect("skills should load"),
metadata_drift: Vec::new(),
},
&load_skills_from_roots(&roots).expect("skills should load"),
"list",
);
assert_eq!(report["kind"], "skills");
@@ -6640,13 +6352,12 @@ mod tests {
let skills_help =
super::handle_skills_slash_command(Some("--help"), &cwd).expect("skills help");
assert!(skills_help.contains(
"Usage /skills [list|show <name>|install [--project] <path>|uninstall <name>|help|<skill> [args]]"
"Usage /skills [list|show <name>|install <path>|uninstall <name>|help|<skill> [args]]"
));
assert!(skills_help.contains("Alias /skill"));
assert!(skills_help.contains("Lifecycle install <path>, uninstall <name>"));
assert!(skills_help.contains("Invoke /skills help overview -> $help overview"));
// #95: install root now mentions --project flag
assert!(skills_help.contains("Install root $CLAW_CONFIG_HOME/skills or ~/.claw/skills (use --project for .claw/skills)"));
assert!(skills_help.contains("Install root $CLAW_CONFIG_HOME/skills or ~/.claw/skills"));
assert!(skills_help.contains(".omc/skills"));
assert!(skills_help.contains(".agents/skills"));
assert!(skills_help.contains("~/.claude/skills/omc-learned"));
@@ -6659,7 +6370,7 @@ mod tests {
let skills_install_help = super::handle_skills_slash_command(Some("install --help"), &cwd)
.expect("nested skills help");
assert!(skills_install_help.contains(
"Usage /skills [list|show <name>|install [--project] <path>|uninstall <name>|help|<skill> [args]]"
"Usage /skills [list|show <name>|install <path>|uninstall <name>|help|<skill> [args]]"
));
assert!(skills_install_help.contains("Alias /skill"));
assert!(skills_install_help.contains("Unexpected install"));
@@ -6667,7 +6378,7 @@ mod tests {
let skills_unknown_help =
super::handle_skills_slash_command(Some("show --help"), &cwd).expect("skills help");
assert!(skills_unknown_help.contains(
"Usage /skills [list|show <name>|install [--project] <path>|uninstall <name>|help|<skill> [args]]"
"Usage /skills [list|show <name>|install <path>|uninstall <name>|help|<skill> [args]]"
));
assert!(skills_unknown_help.contains("Unexpected show"));
@@ -6936,7 +6647,7 @@ mod tests {
let help =
render_mcp_report_json_for(&loader, &workspace, Some("help")).expect("mcp help json");
assert_eq!(help["action"], "help");
assert_eq!(help["usage"]["sources"][0], ".claw.json");
assert_eq!(help["usage"]["sources"][0], ".claw/settings.json");
let _ = fs::remove_dir_all(workspace);
let _ = fs::remove_dir_all(config_home);
@@ -7134,7 +6845,7 @@ mod tests {
let disable = handle_plugins_slash_command(Some("disable"), Some("demo"), &mut manager)
.expect("disable command should succeed");
assert!(disable.reload_runtime);
assert!(disable.message.contains("Result disabled"));
assert!(disable.message.contains("disabled demo@external"));
assert!(disable.message.contains("Name demo"));
assert!(disable.message.contains("Status disabled"));
@@ -7146,7 +6857,7 @@ mod tests {
let enable = handle_plugins_slash_command(Some("enable"), Some("demo"), &mut manager)
.expect("enable command should succeed");
assert!(enable.reload_runtime);
assert!(enable.message.contains("Result enabled"));
assert!(enable.message.contains("enabled demo@external"));
assert!(enable.message.contains("Name demo"));
assert!(enable.message.contains("Status enabled"));
@@ -746,6 +746,7 @@ fn tool_message_response_many(id: &str, tool_uses: &[ToolUseMessage<'_>]) -> Mes
id: tool_use.tool_id.to_string(),
name: tool_use.tool_name.to_string(),
input: tool_use.input.clone(),
thought_signature: None,
})
.collect(),
model: DEFAULT_MODEL.to_string(),
+1
View File
@@ -780,6 +780,7 @@ mod tests {
id: tool_id.to_string(),
name: "search".to_string(),
input: "{\"q\":\"*.rs\"}".to_string(),
thought_signature: None,
},
]))
.unwrap();
+119 -577
View File
@@ -125,27 +125,6 @@ pub struct RuntimePluginConfig {
max_output_tokens: Option<u32>,
}
/// API timeout and retry configuration.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApiTimeoutConfig {
/// Connect timeout in seconds. Defaults to 30.
pub connect_timeout_secs: u64,
/// Request timeout in seconds. Defaults to 300 (5 minutes).
pub request_timeout_secs: u64,
/// Maximum retry attempts on transient failures. Defaults to 8.
pub max_retries: u32,
}
impl Default for ApiTimeoutConfig {
fn default() -> Self {
Self {
connect_timeout_secs: 30,
request_timeout_secs: 300,
max_retries: 8,
}
}
}
/// Structured feature configuration consumed by runtime subsystems.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuntimeFeatureConfig {
@@ -160,9 +139,7 @@ pub struct RuntimeFeatureConfig {
sandbox: SandboxConfig,
provider_fallbacks: ProviderFallbackConfig,
trusted_roots: Vec<String>,
api_timeout: ApiTimeoutConfig,
rules_import: RulesImportConfig,
provider: RuntimeProviderConfig,
}
/// Controls which external AI coding framework rules are imported into the system prompt.
@@ -190,41 +167,6 @@ impl RulesImportConfig {
}
}
/// Stored provider configuration from the setup wizard.
///
/// Represents the `provider` section in `~/.claw/settings.json`, used as a
/// fallback when environment variables are absent (3-tier resolution:
/// env var > .env file > stored config).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuntimeProviderConfig {
kind: Option<String>,
api_key: Option<String>,
base_url: Option<String>,
model: Option<String>,
}
impl RuntimeProviderConfig {
#[must_use]
pub fn kind(&self) -> Option<&str> {
self.kind.as_deref()
}
#[must_use]
pub fn api_key(&self) -> Option<&str> {
self.api_key.as_deref()
}
#[must_use]
pub fn base_url(&self) -> Option<&str> {
self.base_url.as_deref()
}
#[must_use]
pub fn model(&self) -> Option<&str> {
self.model.as_deref()
}
}
/// Ordered chain of fallback model identifiers used when the primary
/// provider returns a retryable failure (429/500/503/etc.). The chain is
/// strict: each entry is tried in order until one succeeds.
@@ -240,7 +182,6 @@ pub struct RuntimeHookConfig {
pre_tool_use: Vec<RuntimeHookCommand>,
post_tool_use: Vec<RuntimeHookCommand>,
post_tool_use_failure: Vec<RuntimeHookCommand>,
invalid_hooks: Vec<RuntimeInvalidHookConfig>,
}
/// A hook command plus optional tool matcher from object-style hook config.
@@ -250,16 +191,6 @@ pub struct RuntimeHookCommand {
matcher: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeInvalidHookConfig {
pub event: String,
pub index: Option<usize>,
pub hook_index: Option<usize>,
pub kind: String,
pub error_field: String,
pub reason: String,
}
/// Raw permission rule lists grouped by allow, deny, and ask behavior.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuntimePermissionRuleConfig {
@@ -798,9 +729,7 @@ fn build_runtime_config(
sandbox: parse_optional_sandbox_config(&merged_value)?,
provider_fallbacks: parse_optional_provider_fallbacks(&merged_value)?,
trusted_roots: parse_optional_trusted_roots(&merged_value)?,
api_timeout: parse_optional_api_timeout_config(&merged_value)?,
rules_import: parse_optional_rules_import(&merged_value)?,
provider: parse_optional_provider_config(&merged_value)?,
};
Ok(RuntimeConfig {
@@ -915,11 +844,6 @@ impl RuntimeConfig {
&self.feature_config.rules_import
}
#[must_use]
pub fn provider(&self) -> &RuntimeProviderConfig {
&self.feature_config.provider
}
/// Merge config-level default trusted roots with per-call roots.
///
/// Config roots are defaults and are kept first; per-call roots extend the
@@ -933,13 +857,6 @@ impl RuntimeConfig {
}
impl RuntimeFeatureConfig {
/// Parsed provider configuration (kind, apiKey, baseUrl, model) from
/// merged settings.
#[must_use]
pub fn provider(&self) -> &RuntimeProviderConfig {
&self.provider
}
#[must_use]
pub fn with_hooks(mut self, hooks: RuntimeHookConfig) -> Self {
self.hooks = hooks;
@@ -1281,7 +1198,6 @@ impl RuntimeHookConfig {
pre_tool_use,
post_tool_use,
post_tool_use_failure,
invalid_hooks: Vec::new(),
}
}
@@ -1319,8 +1235,6 @@ impl RuntimeHookConfig {
&mut self.post_tool_use_failure,
other.post_tool_use_failure_entries(),
);
self.invalid_hooks
.extend(other.invalid_hooks.iter().cloned());
}
#[must_use]
@@ -1332,25 +1246,6 @@ impl RuntimeHookConfig {
pub fn post_tool_use_failure_entries(&self) -> &[RuntimeHookCommand] {
&self.post_tool_use_failure
}
#[must_use]
pub fn invalid_hooks(&self) -> &[RuntimeInvalidHookConfig] {
&self.invalid_hooks
}
#[must_use]
pub fn invalid_count(&self) -> usize {
self.invalid_hooks.len()
}
#[must_use]
pub fn has_invalid_hooks(&self) -> bool {
!self.invalid_hooks.is_empty()
}
pub fn push_invalid_hook(&mut self, invalid: RuntimeInvalidHookConfig) {
self.invalid_hooks.push(invalid);
}
}
fn hook_commands(commands: &[RuntimeHookCommand]) -> Vec<String> {
@@ -1739,217 +1634,14 @@ fn parse_optional_hooks_config_object(
return Ok(RuntimeHookConfig::default());
};
let hooks = expect_object(hooks_value, context)?;
Ok(parse_hooks_object_partial(hooks, context))
}
fn parse_hooks_object_partial(
hooks: &BTreeMap<String, JsonValue>,
context: &str,
) -> RuntimeHookConfig {
let mut config = RuntimeHookConfig::default();
parse_hook_event_partial(
&mut config,
hooks,
"PreToolUse",
context,
|config, command| {
config.pre_tool_use.push(command);
},
);
parse_hook_event_partial(
&mut config,
hooks,
"PostToolUse",
context,
|config, command| {
config.post_tool_use.push(command);
},
);
parse_hook_event_partial(
&mut config,
hooks,
"PostToolUseFailure",
context,
|config, command| {
config.post_tool_use_failure.push(command);
},
);
for event in hooks.keys().filter(|event| !is_supported_hook_event(event)) {
config.push_invalid_hook(RuntimeInvalidHookConfig {
event: event.clone(),
index: None,
hook_index: None,
kind: "unknown_hook_event".to_string(),
error_field: event.clone(),
reason: format!("{context}: unknown hook event {event}"),
});
}
config
}
fn is_supported_hook_event(event: &str) -> bool {
matches!(event, "PreToolUse" | "PostToolUse" | "PostToolUseFailure")
}
fn parse_hook_event_partial(
config: &mut RuntimeHookConfig,
hooks: &BTreeMap<String, JsonValue>,
event: &str,
context: &str,
mut push_command: impl FnMut(&mut RuntimeHookConfig, RuntimeHookCommand),
) {
let Some(value) = hooks.get(event) else {
return;
};
let Some(array) = value.as_array() else {
config.push_invalid_hook(RuntimeInvalidHookConfig {
event: event.to_string(),
index: None,
hook_index: None,
kind: "invalid_hooks_config".to_string(),
error_field: event.to_string(),
reason: format!("{context}: field {event} must be an array"),
});
return;
};
for (index, item) in array.iter().enumerate() {
if let Some(command) = item.as_str() {
if command.trim().is_empty() {
config.push_invalid_hook(RuntimeInvalidHookConfig {
event: event.to_string(),
index: Some(index),
hook_index: None,
kind: "invalid_hooks_config".to_string(),
error_field: "command".to_string(),
reason: format!("{context}: field {event}[{index}] must be a non-empty string"),
});
} else {
push_command(config, RuntimeHookCommand::new(command.to_string()));
}
continue;
}
let Some(entry) = item.as_object() else {
config.push_invalid_hook(RuntimeInvalidHookConfig {
event: event.to_string(),
index: Some(index),
hook_index: None,
kind: "invalid_hooks_config".to_string(),
error_field: event.to_string(),
reason: format!(
"{context}: field {event}[{index}] must be a string or hook object"
),
});
continue;
};
let matcher = match optional_hook_matcher(entry, context, event, index) {
Ok(matcher) => matcher,
Err(error) => {
config.push_invalid_hook(runtime_invalid_hook(
event,
Some(index),
None,
"matcher",
error,
));
continue;
}
};
let Some(hook_array) = entry.get("hooks").and_then(JsonValue::as_array) else {
config.push_invalid_hook(RuntimeInvalidHookConfig {
event: event.to_string(),
index: Some(index),
hook_index: None,
kind: "invalid_hooks_config".to_string(),
error_field: "hooks".to_string(),
reason: format!("{context}: field {event}[{index}].hooks must be an array"),
});
continue;
};
for (hook_index, hook) in hook_array.iter().enumerate() {
let Some(hook_object) = hook.as_object() else {
config.push_invalid_hook(RuntimeInvalidHookConfig {
event: event.to_string(),
index: Some(index),
hook_index: Some(hook_index),
kind: "invalid_hooks_config".to_string(),
error_field: "hooks".to_string(),
reason: format!(
"{context}: field {event}[{index}].hooks[{hook_index}] must be an object"
),
});
continue;
};
if let Some(hook_type) = hook_object.get("type") {
let Some(hook_type) = hook_type.as_str() else {
config.push_invalid_hook(RuntimeInvalidHookConfig {
event: event.to_string(),
index: Some(index),
hook_index: Some(hook_index),
kind: "invalid_hooks_config".to_string(),
error_field: "type".to_string(),
reason: format!(
"{context}: field {event}[{index}].hooks[{hook_index}].type must be a string"
),
});
continue;
};
if hook_type != "command" {
config.push_invalid_hook(RuntimeInvalidHookConfig {
event: event.to_string(),
index: Some(index),
hook_index: Some(hook_index),
kind: "invalid_hooks_config".to_string(),
error_field: "type".to_string(),
reason: format!(
"{context}: field {event}[{index}].hooks[{hook_index}].type must be \"command\""
),
});
continue;
}
}
let Some(command) = hook_object
.get("command")
.and_then(JsonValue::as_str)
.filter(|command| !command.trim().is_empty())
else {
config.push_invalid_hook(RuntimeInvalidHookConfig {
event: event.to_string(),
index: Some(index),
hook_index: Some(hook_index),
kind: "invalid_hooks_config".to_string(),
error_field: "command".to_string(),
reason: format!(
"{context}: field {event}[{index}].hooks[{hook_index}].command must be a non-empty string"
),
});
continue;
};
push_command(
config,
RuntimeHookCommand::with_matcher(command.to_string(), matcher.clone()),
);
}
}
}
fn runtime_invalid_hook(
event: &str,
index: Option<usize>,
hook_index: Option<usize>,
error_field: &str,
error: ConfigError,
) -> RuntimeInvalidHookConfig {
RuntimeInvalidHookConfig {
event: event.to_string(),
index,
hook_index,
kind: "invalid_hooks_config".to_string(),
error_field: error_field.to_string(),
reason: config_error_detail(&error),
}
Ok(RuntimeHookConfig {
pre_tool_use: optional_hook_command_array(hooks, "PreToolUse", context)?
.unwrap_or_default(),
post_tool_use: optional_hook_command_array(hooks, "PostToolUse", context)?
.unwrap_or_default(),
post_tool_use_failure: optional_hook_command_array(hooks, "PostToolUseFailure", context)?
.unwrap_or_default(),
})
}
fn validate_optional_hooks_config(
@@ -2092,26 +1784,6 @@ fn parse_optional_provider_fallbacks(
Ok(ProviderFallbackConfig { primary, fallbacks })
}
fn parse_optional_api_timeout_config(root: &JsonValue) -> Result<ApiTimeoutConfig, ConfigError> {
let Some(timeout_value) = root.as_object().and_then(|obj| obj.get("apiTimeout")) else {
return Ok(ApiTimeoutConfig::default());
};
let Some(obj) = timeout_value.as_object() else {
return Ok(ApiTimeoutConfig::default());
};
let context = "merged settings.apiTimeout";
let connect_timeout_secs = optional_u64(obj, "connectTimeout", context)?.unwrap_or(30);
let request_timeout_secs = optional_u64(obj, "requestTimeout", context)?.unwrap_or(300);
let max_retries = optional_u64(obj, "maxRetries", context)?
.map(|v| v as u32)
.unwrap_or(8);
Ok(ApiTimeoutConfig {
connect_timeout_secs,
request_timeout_secs,
max_retries,
})
}
fn parse_optional_trusted_roots(root: &JsonValue) -> Result<Vec<String>, ConfigError> {
let Some(object) = root.as_object() else {
return Ok(Vec::new());
@@ -2153,25 +1825,6 @@ fn parse_optional_rules_import(root: &JsonValue) -> Result<RulesImportConfig, Co
}
}
fn parse_optional_provider_config(root: &JsonValue) -> Result<RuntimeProviderConfig, ConfigError> {
let Some(provider_value) = root.as_object().and_then(|object| object.get("provider")) else {
return Ok(RuntimeProviderConfig::default());
};
let Some(object) = provider_value.as_object() else {
return Ok(RuntimeProviderConfig::default());
};
let kind = optional_string(object, "kind", "provider")?.map(str::to_string);
let api_key = optional_string(object, "apiKey", "provider")?.map(str::to_string);
let base_url = optional_string(object, "baseUrl", "provider")?.map(str::to_string);
let model = optional_string(object, "model", "provider")?.map(str::to_string);
Ok(RuntimeProviderConfig {
kind,
api_key,
base_url,
model,
})
}
fn parse_filesystem_mode_label(value: &str) -> Result<FilesystemIsolationMode, ConfigError> {
match value {
"off" => Ok(FilesystemIsolationMode::Off),
@@ -2208,45 +1861,6 @@ fn parse_optional_oauth_config(
}))
}
/// #92: expand `${VAR}` environment variable references and `~/` home directory
/// prefix in a config string value. Returns the expanded string.
fn expand_config_value(value: &str) -> String {
// Expand ${VAR} and $VAR references from the environment
let mut result = String::with_capacity(value.len());
let mut chars = value.chars().peekable();
while let Some(c) = chars.next() {
if c == '$' {
if chars.peek() == Some(&'{') {
// ${VAR} form
chars.next(); // consume '{'
let mut var_name = String::new();
for ch in chars.by_ref() {
if ch == '}' {
break;
}
var_name.push(ch);
}
if let Ok(val) = std::env::var(&var_name) {
result.push_str(&val);
}
} else {
// Bare $ — pass through
result.push(c);
}
} else if c == '~' && result.is_empty() {
// ~/... home directory expansion
if let Ok(home) = std::env::var("HOME") {
result.push_str(&home);
} else {
result.push(c);
}
} else {
result.push(c);
}
}
result
}
fn parse_mcp_server_config(
server_name: &str,
value: &JsonValue,
@@ -2256,14 +1870,9 @@ fn parse_mcp_server_config(
let server_type =
optional_string(object, "type", context)?.unwrap_or_else(|| infer_mcp_server_type(object));
match server_type {
// #92: expand ${VAR} and ~/ in command, args, and url fields
"stdio" => Ok(McpServerConfig::Stdio(McpStdioServerConfig {
command: expand_config_value(expect_non_empty_string(object, "command", context)?),
args: optional_string_array(object, "args", context)?
.unwrap_or_default()
.iter()
.map(|a| expand_config_value(a))
.collect(),
command: expect_non_empty_string(object, "command", context)?.to_string(),
args: optional_string_array(object, "args", context)?.unwrap_or_default(),
env: optional_string_map(object, "env", context)?.unwrap_or_default(),
tool_call_timeout_ms: optional_u64(object, "toolCallTimeoutMs", context)?,
})),
@@ -2274,8 +1883,7 @@ fn parse_mcp_server_config(
object, context,
)?)),
"ws" => Ok(McpServerConfig::Ws(McpWebSocketServerConfig {
// #92: expand ${VAR} and ~/ in URL
url: expand_config_value(expect_string(object, "url", context)?),
url: expect_string(object, "url", context)?.to_string(),
headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string),
})),
@@ -2283,8 +1891,7 @@ fn parse_mcp_server_config(
name: expect_string(object, "name", context)?.to_string(),
})),
"claudeai-proxy" => Ok(McpServerConfig::ManagedProxy(McpManagedProxyServerConfig {
// #92: expand ${VAR} and ~/ in URL
url: expand_config_value(expect_string(object, "url", context)?),
url: expect_string(object, "url", context)?.to_string(),
id: expect_string(object, "id", context)?.to_string(),
})),
other => Err(ConfigError::Parse(format!(
@@ -2306,8 +1913,7 @@ fn parse_mcp_remote_server_config(
context: &str,
) -> Result<McpRemoteServerConfig, ConfigError> {
Ok(McpRemoteServerConfig {
// #92: expand ${VAR} and ~/ in URL
url: expand_config_value(expect_string(object, "url", context)?),
url: expect_string(object, "url", context)?.to_string(),
headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string),
oauth: parse_optional_mcp_oauth_config(object, context)?,
@@ -2502,6 +2108,77 @@ fn optional_string_array(
}
}
fn optional_hook_command_array(
object: &BTreeMap<String, JsonValue>,
key: &str,
context: &str,
) -> Result<Option<Vec<RuntimeHookCommand>>, ConfigError> {
let Some(value) = object.get(key) else {
return Ok(None);
};
let Some(array) = value.as_array() else {
return Err(ConfigError::Parse(format!(
"{context}: field {key} must be an array"
)));
};
let mut commands = Vec::new();
for (index, item) in array.iter().enumerate() {
if let Some(command) = item.as_str() {
commands.push(RuntimeHookCommand::new(command.to_string()));
continue;
}
let Some(entry) = item.as_object() else {
return Err(ConfigError::Parse(format!(
"{context}: field {key}[{index}] must be a string or hook object"
)));
};
let matcher = optional_hook_matcher(entry, context, key, index)?;
let hooks = entry
.get("hooks")
.and_then(JsonValue::as_array)
.ok_or_else(|| {
ConfigError::Parse(format!(
"{context}: field {key}[{index}].hooks must be an array"
))
})?;
for (hook_index, hook) in hooks.iter().enumerate() {
let Some(hook_object) = hook.as_object() else {
return Err(ConfigError::Parse(format!(
"{context}: field {key}[{index}].hooks[{hook_index}] must be an object"
)));
};
if let Some(hook_type) = hook_object.get("type") {
let Some(hook_type) = hook_type.as_str() else {
return Err(ConfigError::Parse(format!(
"{context}: field {key}[{index}].hooks[{hook_index}].type must be a string"
)));
};
if hook_type != "command" {
return Err(ConfigError::Parse(format!(
"{context}: field {key}[{index}].hooks[{hook_index}].type must be \"command\""
)));
}
}
let command = hook_object
.get("command")
.and_then(JsonValue::as_str)
.filter(|command| !command.trim().is_empty())
.ok_or_else(|| {
ConfigError::Parse(format!(
"{context}: field {key}[{index}].hooks[{hook_index}].command must be a non-empty string"
))
})?;
commands.push(RuntimeHookCommand::with_matcher(
command.to_string(),
matcher.clone(),
));
}
}
Ok(Some(commands))
}
fn optional_hook_matcher(
entry: &BTreeMap<String, JsonValue>,
context: &str,
@@ -2570,10 +2247,6 @@ fn deep_merge_objects(
(Some(JsonValue::Object(existing)), JsonValue::Object(incoming)) => {
deep_merge_objects(existing, incoming);
}
// #106: concatenate arrays instead of replacing
(Some(JsonValue::Array(existing)), JsonValue::Array(incoming)) => {
existing.extend(incoming.iter().cloned());
}
_ => {
target.insert(key.clone(), value.clone());
}
@@ -2755,7 +2428,7 @@ mod tests {
}
#[test]
fn records_object_style_hook_entries_without_command_441() {
fn rejects_object_style_hook_entries_without_command() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
@@ -2767,20 +2440,12 @@ mod tests {
)
.expect("write settings");
let loaded = ConfigLoader::new(&cwd, &home)
let error = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should load valid siblings and record malformed hook entry");
.expect_err("config should reject malformed hook entry");
assert!(loaded.hooks().pre_tool_use().is_empty());
assert_eq!(loaded.hooks().invalid_count(), 1);
assert_eq!(
loaded.hooks().invalid_hooks()[0].kind,
"invalid_hooks_config"
);
assert_eq!(loaded.hooks().invalid_hooks()[0].event, "PreToolUse");
assert_eq!(loaded.hooks().invalid_hooks()[0].error_field, "command");
assert!(loaded.hooks().invalid_hooks()[0]
.reason
assert!(error
.to_string()
.contains("command must be a non-empty string"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
@@ -3523,7 +3188,7 @@ mod tests {
}
#[test]
fn loads_valid_hook_entries_and_records_invalid_siblings_441() {
fn rejects_invalid_hook_entries_before_merge() {
// given
let root = temp_dir();
let cwd = root.join("project");
@@ -3543,26 +3208,19 @@ mod tests {
)
.expect("write invalid project settings");
let loaded = ConfigLoader::new(&cwd, &home)
// when
let error = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should load valid hook entries and record invalid siblings");
.expect_err("config should fail");
// #106: arrays now concatenate across config layers, so both "base" and "project" are present
assert_eq!(
loaded.hooks().pre_tool_use(),
&["base".to_string(), "project".to_string()]
// then — config validation now catches the mixed array before the hooks parser
let rendered = error.to_string();
assert!(
rendered.contains("hooks.PreToolUse")
&& rendered.contains("must be an array of strings"),
"expected validation error for hooks.PreToolUse, got: {rendered}"
);
assert_eq!(loaded.hooks().invalid_count(), 1);
assert_eq!(loaded.hooks().invalid_hooks()[0].event, "PreToolUse");
assert_eq!(
loaded.hooks().invalid_hooks()[0].kind,
"invalid_hooks_config"
);
// #106: invalid entry at index 2 after array concatenation
assert_eq!(loaded.hooks().invalid_hooks()[0].index, Some(2));
assert!(loaded.hooks().invalid_hooks()[0]
.reason
.contains("must be a string or hook object"));
assert!(!rendered.contains("merged settings.hooks"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
@@ -3705,7 +3363,7 @@ mod tests {
}
#[test]
fn hook_event_wrong_type_is_recorded_without_config_failure_441() {
fn validates_wrong_type_for_known_field_with_field_path() {
// given
let root = temp_dir();
let cwd = root.join("project");
@@ -3719,145 +3377,29 @@ mod tests {
)
.expect("write user settings");
let loaded = ConfigLoader::new(&cwd, &home)
// when
let error = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should record malformed hook event without failing");
.expect_err("config should fail");
assert!(loaded.hooks().pre_tool_use().is_empty());
assert_eq!(loaded.hooks().invalid_count(), 1);
assert_eq!(loaded.hooks().invalid_hooks()[0].event, "PreToolUse");
assert_eq!(
loaded.hooks().invalid_hooks()[0].kind,
"invalid_hooks_config"
);
assert_eq!(loaded.hooks().invalid_hooks()[0].index, None);
assert!(loaded.hooks().invalid_hooks()[0]
.reason
.contains("field PreToolUse must be an array"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn collects_all_invalid_hook_siblings_instead_of_halting_at_first_441() {
// ROADMAP #441 finding (c): first-error-only halting means users must fix
// one hook at a time. After #441 partial fix, all invalid entries in the
// same config are collected.
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
r#"{"hooks":{"PreToolUse":[42],"PostToolUse":"not-an-array","InvalidEvent":["cmd"]}}"#,
)
.expect("write settings");
let loaded = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should collect all invalid hooks without halting at first");
assert!(loaded.hooks().pre_tool_use().is_empty());
assert!(loaded.hooks().post_tool_use().is_empty());
// Three distinct invalid entries: 42, wrong type, unknown event
assert_eq!(loaded.hooks().invalid_count(), 3);
let invalid = loaded.hooks().invalid_hooks();
// PreToolUse[0]=42
assert_eq!(invalid[0].event, "PreToolUse");
assert_eq!(invalid[0].index, Some(0));
assert_eq!(invalid[0].kind, "invalid_hooks_config");
// PostToolUse wrong type
assert_eq!(invalid[1].event, "PostToolUse");
assert_eq!(invalid[1].index, None);
assert_eq!(invalid[1].kind, "invalid_hooks_config");
// Unknown event
assert_eq!(invalid[2].event, "InvalidEvent");
assert_eq!(invalid[2].index, None);
assert_eq!(invalid[2].kind, "unknown_hook_event");
assert!(invalid[2]
.reason
.contains("unknown hook event InvalidEvent"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn unknown_hook_events_recorded_with_correct_kind_441() {
// ROADMAP #441 finding (a): unknown event names like Stop/Notification
// should not reject entire hooks config; they are recorded as invalid.
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
r#"{"hooks":{"PreToolUse":["valid-cmd"],"Stop":"not-an-array","Notification":[{}]}}"#,
)
.expect("write settings");
let loaded = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should load valid hooks and record unknown event siblings");
// Valid PreToolUse hook should load
assert_eq!(loaded.hooks().pre_tool_use(), &["valid-cmd".to_string()]);
// Stop and Notification are unknown events; each gets one invalid entry
// Notification:[{}] also has an empty-object entry issue but since we
// don't parse unknown events, only the unknown-event invalid is recorded
let invalid = loaded.hooks().invalid_hooks();
// then
let rendered = error.to_string();
assert!(
invalid.len() >= 2,
"expected at least 2 invalid hooks, got {}",
invalid.len()
rendered.contains(&user_settings.display().to_string()),
"error should include file path, got: {rendered}"
);
let stop = invalid
.iter()
.find(|h| h.event == "Stop")
.expect("Stop invalid hook");
assert_eq!(stop.kind, "unknown_hook_event");
assert_eq!(stop.index, None);
assert!(stop.reason.contains("unknown hook event Stop"));
let notif = invalid
.iter()
.find(|h| h.event == "Notification")
.expect("Notification invalid hook");
assert_eq!(notif.kind, "unknown_hook_event");
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn documented_claude_code_hook_format_loads_without_error_441() {
// ROADMAP #441: the Claude Code documented hook format
// {"hooks":{"PreToolUse":[{"matcher":"Read","hooks":[{"type":"command","command":"..."}]}]}}
// must load without config_load_error.
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
r#"{"hooks":{"PreToolUse":[{"matcher":"Read","hooks":[{"type":"command","command":"/bin/echo pretool"}]}]}}"#,
)
.expect("write settings");
let loaded = ConfigLoader::new(&cwd, &home)
.load()
.expect("Claude Code documented hook format must load without error");
assert_eq!(
loaded.hooks().pre_tool_use(),
&["/bin/echo pretool".to_string()]
assert!(
rendered.contains("hooks"),
"error should include field path component 'hooks', got: {rendered}"
);
assert!(
rendered.contains("PreToolUse"),
"error should describe the type mismatch, got: {rendered}"
);
assert!(
rendered.contains("array"),
"error should describe the expected type, got: {rendered}"
);
assert_eq!(loaded.hooks().invalid_count(), 0);
let entries = loaded.hooks().pre_tool_use_entries();
assert_eq!(entries[0].matcher(), Some("Read"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
+12 -15
View File
@@ -118,7 +118,10 @@ impl FieldType {
Self::StringArray => value
.as_array()
.is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some())),
Self::HookArray => true,
Self::HookArray => value.as_array().is_some_and(|arr| {
arr.iter()
.all(|entry| entry.as_str().is_some() || entry.as_object().is_some())
}),
Self::RulesImport => {
value.as_str().is_some()
|| value
@@ -216,10 +219,6 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
name: "rulesImport",
expected: FieldType::RulesImport,
},
FieldSpec {
name: "subagentModel",
expected: FieldType::String,
},
];
const HOOKS_FIELDS: &[FieldSpec] = &[
@@ -425,6 +424,8 @@ fn validate_object_keys(
} else if DEPRECATED_FIELDS.iter().any(|d| d.name == key) {
// Deprecated key — handled separately, not an unknown-key error.
} else {
// Unknown key — preserve compatibility by surfacing it as a warning
// instead of blocking otherwise valid config files.
let suggestion = suggest_field(key, &known_names);
result.warnings.push(ConfigDiagnostic {
path: path_display.to_string(),
@@ -713,7 +714,7 @@ mod tests {
#[test]
fn validates_nested_hooks_keys() {
// given
let source = r#"{"hooks": {"PreToolUse": [{"hooks":[{"type":"command","command":"cmd"}]}], "BadHook": ["x"]}}"#;
let source = r#"{"hooks": {"PreToolUse": ["cmd"], "BadHook": ["x"]}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
@@ -722,12 +723,7 @@ mod tests {
// then
assert!(result.errors.is_empty());
assert_eq!(
result.warnings.len(),
1,
"expected only the unknown key warning, got {:?}",
result.warnings
);
assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].field, "hooks.BadHook");
}
@@ -743,14 +739,15 @@ mod tests {
}
#[test]
fn allows_wrong_hook_entry_types_for_partial_runtime_validation_441() {
fn rejects_wrong_hook_entry_types() {
let source = r#"{"hooks":{"PreToolUse":[42]}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
let result = validate_config_file(object, source, &test_path());
assert!(result.errors.is_empty(), "{:?}", result.errors);
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "hooks.PreToolUse");
}
#[test]
@@ -850,7 +847,7 @@ mod tests {
// given
let source = r#"{
"model": "opus",
"hooks": {"PreToolUse": [{"hooks":[{"type":"command","command":"guard"}]}]},
"hooks": {"PreToolUse": ["guard"]},
"permissions": {"defaultMode": "plan", "allow": ["Read"]},
"mcpServers": {},
"sandbox": {"enabled": false}
+13 -10
View File
@@ -37,6 +37,7 @@ pub enum AssistantEvent {
id: String,
name: String,
input: String,
thought_signature: Option<String>,
},
Usage(TokenUsage),
PromptCache(PromptCacheEvent),
@@ -204,13 +205,6 @@ where
self
}
/// Update the auto-compaction threshold after construction. This allows the
/// caller to tune the threshold based on runtime information (e.g., the
/// server-returned context window size from a 400 error).
pub fn set_auto_compaction_input_tokens_threshold(&mut self, threshold: u32) {
self.auto_compaction_input_tokens_threshold = threshold;
}
#[must_use]
pub fn with_hook_abort_signal(mut self, hook_abort_signal: HookAbortSignal) -> Self {
self.hook_abort_signal = hook_abort_signal;
@@ -388,7 +382,7 @@ where
.blocks
.iter()
.filter_map(|block| match block {
ContentBlock::ToolUse { id, name, input } => {
ContentBlock::ToolUse { id, name, input, .. } => {
Some((id.clone(), name.clone(), input.clone()))
}
_ => None,
@@ -748,9 +742,9 @@ fn build_assistant_message(
});
}
AssistantEvent::TextDelta(delta) => text.push_str(&delta),
AssistantEvent::ToolUse { id, name, input } => {
AssistantEvent::ToolUse { id, name, input, thought_signature } => {
flush_text_block(&mut text, &mut blocks);
blocks.push(ContentBlock::ToolUse { id, name, input });
blocks.push(ContentBlock::ToolUse { id, name, input, thought_signature });
}
AssistantEvent::Usage(value) => usage = Some(value),
AssistantEvent::PromptCache(event) => prompt_cache_events.push(event),
@@ -887,6 +881,7 @@ mod tests {
id: "tool-1".to_string(),
name: "add".to_string(),
input: "2,2".to_string(),
thought_signature: None,
},
AssistantEvent::Usage(TokenUsage {
input_tokens: 20,
@@ -1053,6 +1048,7 @@ mod tests {
id: "tool-1".to_string(),
name: "blocked".to_string(),
input: "secret".to_string(),
thought_signature: None,
},
AssistantEvent::MessageStop,
])
@@ -1098,6 +1094,7 @@ mod tests {
id: "tool-1".to_string(),
name: "blocked".to_string(),
input: r#"{"path":"secret.txt"}"#.to_string(),
thought_signature: None,
},
AssistantEvent::MessageStop,
])
@@ -1160,6 +1157,7 @@ mod tests {
id: "tool-1".to_string(),
name: "blocked".to_string(),
input: r#"{"path":"secret.txt"}"#.to_string(),
thought_signature: None,
},
AssistantEvent::MessageStop,
])
@@ -1220,6 +1218,7 @@ mod tests {
id: "tool-1".to_string(),
name: "add".to_string(),
input: r#"{"lhs":2,"rhs":2}"#.to_string(),
thought_signature: None,
},
AssistantEvent::MessageStop,
]),
@@ -1295,6 +1294,7 @@ mod tests {
id: "tool-1".to_string(),
name: "fail".to_string(),
input: r#"{"path":"README.md"}"#.to_string(),
thought_signature: None,
},
AssistantEvent::MessageStop,
]),
@@ -1762,6 +1762,7 @@ mod tests {
id: "tool-1".to_string(),
name: "echo".to_string(),
input: "payload".to_string(),
thought_signature: None,
},
AssistantEvent::MessageStop,
];
@@ -1785,6 +1786,7 @@ mod tests {
id: "tool-1".to_string(),
name: "echo".to_string(),
input: "payload".to_string(),
thought_signature: None,
},
]
);
@@ -1818,6 +1820,7 @@ mod tests {
id: "tool-1".to_string(),
name: "echo".to_string(),
input: "payload".to_string(),
thought_signature: None,
},
AssistantEvent::MessageStop,
])
+6 -7
View File
@@ -65,15 +65,14 @@ pub use compact::{
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
};
pub use config::{
clear_user_provider_settings, default_config_home, save_user_provider_settings,
suppress_config_warnings_for_json_mode, ApiTimeoutConfig, ConfigEntry, ConfigError,
ConfigFileReport, ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource,
McpConfigCollection, McpInvalidServerConfig, McpManagedProxyServerConfig, McpOAuthConfig,
McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport,
suppress_config_warnings_for_json_mode, ConfigEntry, ConfigError, ConfigFileReport,
ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource, McpConfigCollection,
McpInvalidServerConfig, McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig,
McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport,
McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode,
RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig,
RuntimeInvalidHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig,
RuntimeProviderConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
RuntimePermissionRuleConfig, RuntimePluginConfig, ScopedMcpServerConfig,
CLAW_SETTINGS_SCHEMA_NAME,
};
pub use config_validate::{
check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic,
+25 -178
View File
@@ -173,112 +173,32 @@ impl PermissionEnforcer {
}
}
/// Workspace boundary check.
///
/// Resolves `.` and `..` components lexically *before* comparing against the
/// workspace root, so that traversal sequences like `/workspace/../../etc`
/// cannot escape the sandbox via a naive string prefix match. Normalization is
/// lexical (it does not touch the filesystem) because the target path may not
/// exist yet on a write, and we must not depend on CWD.
/// Simple workspace boundary check via string prefix.
fn is_within_workspace(path: &str, workspace_root: &str) -> bool {
let combined = if path.starts_with('/') {
let normalized = if path.starts_with('/') {
path.to_owned()
} else {
format!("{workspace_root}/{path}")
};
let normalized = lexically_normalize(&combined);
let root = lexically_normalize(workspace_root);
let root_with_slash = if root.ends_with('/') {
root.clone()
let root = if workspace_root.ends_with('/') {
workspace_root.to_owned()
} else {
format!("{root}/")
format!("{workspace_root}/")
};
normalized == root || normalized.starts_with(&root_with_slash)
}
/// Collapse `.` and `..` segments without consulting the filesystem.
/// `..` that would climb above an absolute root is clamped at `/`, so the
/// result can never be a prefix-match for a deeper workspace root.
fn lexically_normalize(path: &str) -> String {
let is_absolute = path.starts_with('/');
let mut stack: Vec<&str> = Vec::new();
for component in path.split('/') {
match component {
"" | "." => {}
".." => {
stack.pop();
}
other => stack.push(other),
}
}
let joined = stack.join("/");
if is_absolute {
format!("/{joined}")
} else {
joined
}
normalized.starts_with(&root) || normalized == workspace_root.trim_end_matches('/')
}
/// Conservative heuristic: is this bash command read-only?
///
/// Hardening notes:
/// - Any shell metacharacter that could chain, substitute, pipe, or redirect
/// into a state-changing command rejects the whole line. This blocks
/// `cat x; rm -rf y`, `cat x | sh`, `$(...)`, backticks, redirects, and
/// subshells regardless of the leading token.
/// - Language interpreters (`python`, `node`, `ruby`) and build drivers
/// (`cargo`, `rustc`) are NOT read-only: they execute arbitrary code, so they
/// are excluded from the allow-list.
/// - `git` is allowed only for a known set of non-mutating subcommands.
/// - `find` is rejected when it carries an action that can execute or delete.
///
/// Residual known gaps (documented, not yet closed): `sed`'s `w`/`e` script
/// commands and `awk`'s `system()` can still mutate — these require quoting or
/// metacharacters that the checks above usually catch, but a dedicated parser
/// would be more robust. Tracked as follow-up.
fn is_read_only_command(command: &str) -> bool {
// Shell metacharacters that enable command chaining, substitution,
// piping, redirection, or subshells. Presence of any of these means we
// cannot reason about the command from its leading token alone.
const SHELL_METACHARS: &[char] = &[';', '|', '&', '$', '`', '>', '<', '(', ')', '{', '}', '\n'];
if command.contains(SHELL_METACHARS) {
return false;
}
let mut tokens = command.split_whitespace();
let first_token = tokens.next().unwrap_or("").rsplit('/').next().unwrap_or("");
// `git` is only read-only for a curated set of subcommands.
if first_token == "git" {
let subcommand = tokens.next().unwrap_or("");
return matches!(
subcommand,
"status"
| "log"
| "diff"
| "show"
| "branch"
| "rev-parse"
| "ls-files"
| "blame"
| "describe"
| "tag"
| "remote"
);
}
// `find` can execute or delete via actions; reject those forms.
if first_token == "find"
&& (command.contains("-exec")
|| command.contains("-execdir")
|| command.contains("-delete")
|| command.contains("-ok")
|| command.contains("-fprintf"))
{
return false;
}
let first_token = command
.split_whitespace()
.next()
.unwrap_or("")
.rsplit('/')
.next()
.unwrap_or("");
matches!(
first_token,
@@ -317,6 +237,8 @@ fn is_read_only_command(command: &str) -> bool {
| "tr"
| "cut"
| "paste"
| "tee"
| "xargs"
| "test"
| "true"
| "false"
@@ -335,8 +257,18 @@ fn is_read_only_command(command: &str) -> bool {
| "tree"
| "jq"
| "yq"
| "python3"
| "python"
| "node"
| "ruby"
| "cargo"
| "rustc"
| "git"
| "gh"
) && !command.contains("-i ")
&& !command.contains("--in-place")
&& !command.contains(" > ")
&& !command.contains(" >> ")
}
#[cfg(test)]
@@ -443,91 +375,6 @@ mod tests {
assert!(!is_read_only_command("sed -i 's/a/b/' file"));
}
// --- Hardening regression tests (#2: read-only bypasses) ---
#[test]
fn read_only_rejects_command_chaining() {
// A leading read-only token must not launder a trailing destructive one.
assert!(!is_read_only_command("cat foo; rm -rf bar"));
assert!(!is_read_only_command("cat foo && rm -rf bar"));
assert!(!is_read_only_command("ls || rm bar"));
assert!(!is_read_only_command("cat foo | sh"));
assert!(!is_read_only_command("echo `rm bar`"));
assert!(!is_read_only_command("echo $(rm bar)"));
assert!(!is_read_only_command("echo x>file")); // redirect without spaces
}
#[test]
fn read_only_rejects_interpreters_and_build_drivers() {
// These execute arbitrary code and are no longer read-only.
assert!(!is_read_only_command(
"python3 -c \"import os; os.system('rm -rf .')\""
));
assert!(!is_read_only_command("python script.py"));
assert!(!is_read_only_command("node app.js"));
assert!(!is_read_only_command("ruby x.rb"));
assert!(!is_read_only_command("cargo run"));
assert!(!is_read_only_command("rustc evil.rs"));
}
#[test]
fn read_only_gates_git_subcommands() {
// Read-only git subcommands remain allowed...
assert!(is_read_only_command("git status"));
assert!(is_read_only_command("git diff HEAD~1"));
assert!(is_read_only_command("git show abc123"));
// ...but mutating/exfiltrating ones are rejected.
assert!(!is_read_only_command("git commit -m x"));
assert!(!is_read_only_command("git push origin main"));
assert!(!is_read_only_command("git reset --hard"));
assert!(!is_read_only_command("git clean -fd"));
assert!(!is_read_only_command("git config user.email a@b.c"));
}
#[test]
fn read_only_rejects_find_actions() {
assert!(is_read_only_command("find . -name Cargo.toml"));
assert!(!is_read_only_command("find . -delete"));
// -exec uses braces/semicolon which also trip the metachar guard,
// but the explicit action check is the primary defense.
assert!(!is_read_only_command("find . -execdir rm rf"));
}
// --- Hardening regression tests (#1: workspace path traversal) ---
#[test]
fn workspace_rejects_parent_traversal() {
assert!(!is_within_workspace(
"/workspace/../etc/passwd",
"/workspace"
));
assert!(!is_within_workspace(
"/workspace/../../etc/crontab",
"/workspace"
));
assert!(!is_within_workspace("../etc/passwd", "/workspace"));
assert!(!is_within_workspace(
"/workspace/sub/../../outside",
"/workspace"
));
// Legitimate paths still resolve inside.
assert!(is_within_workspace(
"/workspace/./src/main.rs",
"/workspace"
));
assert!(is_within_workspace(
"/workspace/src/../src/main.rs",
"/workspace"
));
}
#[test]
fn workspace_write_denies_traversal_escape() {
let enforcer = make_enforcer(PermissionMode::WorkspaceWrite);
let result = enforcer.check_file_write("/workspace/../../etc/crontab", "/workspace");
assert!(matches!(result, EnforcementResult::Denied { .. }));
}
#[test]
fn active_mode_returns_policy_mode() {
// given
+3 -10
View File
@@ -149,12 +149,7 @@ impl PermissionPolicy {
.iter()
.map(|rule| PermissionRule::parse(rule))
.collect();
// #94: normalize denied tool names to lowercase to match runtime convention
self.denied_tools = config
.denied_tools()
.iter()
.map(|t| t.to_lowercase())
.collect();
self.denied_tools = config.denied_tools().to_vec();
self
}
@@ -380,8 +375,7 @@ impl PermissionRule {
let matcher = parse_rule_matcher(content);
return Self {
raw: trimmed.to_string(),
// #94: normalize tool name to lowercase to match runtime convention
tool_name: tool_name.to_lowercase(),
tool_name: tool_name.to_string(),
matcher,
};
}
@@ -390,8 +384,7 @@ impl PermissionRule {
Self {
raw: trimmed.to_string(),
// #94: normalize tool name to lowercase to match runtime convention
tool_name: trimmed.to_lowercase(),
tool_name: trimmed.to_string(),
matcher: PermissionRuleMatcher::Any,
}
}
+20 -27
View File
@@ -42,6 +42,7 @@ pub enum ContentBlock {
id: String,
name: String,
input: String,
thought_signature: Option<String>,
},
ToolResult {
tool_use_id: String,
@@ -231,31 +232,8 @@ impl Session {
pub fn save_to_path(&self, path: impl AsRef<Path>) -> Result<(), SessionError> {
let path = path.as_ref();
let snapshot = self.render_jsonl_snapshot()?;
// #112: wrap ENOENT during rotate as concurrent modification
match rotate_session_file_if_needed(path) {
Ok(()) => {}
Err(SessionError::Io(ref io_err)) if io_err.kind() == std::io::ErrorKind::NotFound => {
return Err(SessionError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!(
"session file was removed during save (possible concurrent modification): {io_err}"
),
)));
}
Err(e) => return Err(e),
}
write_atomic(path, &snapshot).map_err(|e| {
// #112: wrap ENOENT during write as concurrent modification
match &e {
SessionError::Io(io_err) if io_err.kind() == std::io::ErrorKind::NotFound => {
SessionError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("session file was removed during write (possible concurrent modification): {io_err}"),
))
}
_ => e,
}
})?;
rotate_session_file_if_needed(path)?;
write_atomic(path, &snapshot)?;
cleanup_rotated_logs(path)?;
Ok(())
}
@@ -840,7 +818,7 @@ impl ContentBlock {
);
}
}
Self::ToolUse { id, name, input } => {
Self::ToolUse { id, name, input, thought_signature } => {
object.insert(
"type".to_string(),
JsonValue::String("tool_use".to_string()),
@@ -848,6 +826,12 @@ impl ContentBlock {
object.insert("id".to_string(), JsonValue::String(id.clone()));
object.insert("name".to_string(), JsonValue::String(name.clone()));
object.insert("input".to_string(), JsonValue::String(input.clone()));
if let Some(sig) = thought_signature {
object.insert(
"thought_signature".to_string(),
JsonValue::String(sig.clone()),
);
}
}
Self::ToolResult {
tool_use_id,
@@ -897,6 +881,7 @@ impl ContentBlock {
id: required_string(object, "id")?,
name: required_string(object, "name")?,
input: required_string(object, "input")?,
thought_signature: object.get("thought_signature").and_then(JsonValue::as_str).map(String::from)
}),
"tool_result" => Ok(Self::ToolResult {
tool_use_id: required_string(object, "tool_use_id")?,
@@ -1092,7 +1077,7 @@ fn persisted_block_json(block: &ContentBlock) -> JsonValue {
);
}
}
ContentBlock::ToolUse { id, name, input } => {
ContentBlock::ToolUse { id, name, input, thought_signature } => {
object.insert(
"type".to_string(),
JsonValue::String("tool_use".to_string()),
@@ -1106,6 +1091,12 @@ fn persisted_block_json(block: &ContentBlock) -> JsonValue {
"input".to_string(),
JsonValue::String(sanitize_jsonl_field(input)),
);
if let Some(sig) = thought_signature {
object.insert(
"thought_signature".to_string(),
JsonValue::String(sanitize_jsonl_field(sig)),
);
}
}
ContentBlock::ToolResult {
tool_use_id,
@@ -1456,6 +1447,7 @@ mod tests {
id: "tool-1".to_string(),
name: "bash".to_string(),
input: "echo hi".to_string(),
thought_signature: None,
},
],
Some(TokenUsage {
@@ -1619,6 +1611,7 @@ mod tests {
id: "tool-1".to_string(),
name: "bash".to_string(),
input: format!("Authorization: Bearer {secret}"),
thought_signature: None,
},
]))
.expect("tool use should append");
+18 -218
View File
@@ -93,19 +93,8 @@ impl SessionStore {
}
pub fn resolve_reference(&self, reference: &str) -> Result<SessionHandle, SessionControlError> {
self.resolve_reference_excluding(reference, None)
}
/// Resolve a session reference, optionally excluding a session by ID.
/// When the reference is an alias, the excluded session is skipped
/// so /resume latest returns the previous session, not the current one.
pub fn resolve_reference_excluding(
&self,
reference: &str,
exclude_id: Option<&str>,
) -> Result<SessionHandle, SessionControlError> {
if is_session_reference_alias(reference) {
let latest = self.latest_session_excluding(exclude_id)?;
let latest = self.latest_session()?;
return Ok(SessionHandle {
id: latest.id,
path: latest.path,
@@ -169,45 +158,12 @@ impl SessionStore {
}
pub fn latest_session(&self) -> Result<ManagedSessionSummary, SessionControlError> {
self.latest_session_excluding(None)
}
/// Find the most recent session, optionally excluding a session by ID
/// and skipping sessions with 0 messages. Used by /resume latest to skip
/// the current empty session and find the previous session with actual
/// conversation history.
pub fn latest_session_excluding(
&self,
exclude_id: Option<&str>,
) -> Result<ManagedSessionSummary, SessionControlError> {
let exclude = exclude_id.unwrap_or("");
// First: look in the current workspace's session namespace
if let Some(latest) = self
.list_sessions()?
.into_iter()
.find(|s| s.id != exclude && s.message_count > 0)
{
if let Some(latest) = self.list_sessions()?.into_iter().next() {
return Ok(latest);
}
// Fallback: scan all workspace namespaces under ~/.claw/sessions/
// and project-local .claw/sessions/ so /resume latest finds sessions
// from other workspaces.
if let Some(latest) = self
.scan_global_sessions()?
.into_iter()
.find(|s| s.id != exclude && s.message_count > 0)
{
if let Some(latest) = self.scan_global_sessions()?.into_iter().next() {
return Ok(latest);
}
// Distinguish between "no sessions at all" and "sessions exist but
// all are empty" so the user gets a clear signal about what to do.
let has_any_session = self.list_sessions()?.iter().any(|s| s.id != exclude)
|| self.scan_global_sessions()?.iter().any(|s| s.id != exclude);
if has_any_session {
return Err(SessionControlError::Format(format_all_sessions_empty(
&self.sessions_root,
)));
}
Err(SessionControlError::Format(format_no_managed_sessions(
&self.sessions_root,
)))
@@ -248,41 +204,28 @@ impl SessionStore {
&self,
reference: &str,
) -> Result<LoadedManagedSession, SessionControlError> {
self.load_session_excluding(reference, None)
}
/// Like `load_session_loose` but also excludes a session by ID.
/// Used by /resume latest to skip the current empty session and find
/// the previous session with actual conversation history.
pub fn load_session_excluding(
&self,
reference: &str,
exclude_id: Option<&str>,
) -> Result<LoadedManagedSession, SessionControlError> {
let handle = self.resolve_reference_excluding(reference, exclude_id)?;
let session = Session::load_from_path(&handle.path)?;
// For alias references, allow cross-workspace resume
if is_session_reference_alias(reference) {
if let Err(SessionControlError::WorkspaceMismatch {
expected: _,
actual,
}) = self.validate_loaded_session(&handle.path, &session)
match self.load_session(reference) {
Ok(loaded) => Ok(loaded),
Err(SessionControlError::WorkspaceMismatch { expected, actual })
if is_session_reference_alias(reference) =>
{
let handle = self.resolve_reference(reference)?;
let session = Session::load_from_path(&handle.path)?;
eprintln!(
" Note: resuming session from a different workspace (origin: {})",
actual.display()
);
let _ = expected; // suppress unused warning
Ok(LoadedManagedSession {
handle: SessionHandle {
id: session.session_id.clone(),
path: handle.path,
},
session,
})
}
} else {
self.validate_loaded_session(&handle.path, &session)?;
Err(other) => Err(other),
}
Ok(LoadedManagedSession {
handle: SessionHandle {
id: session.session_id.clone(),
path: handle.path,
},
session,
})
}
pub fn fork_session(
@@ -783,16 +726,6 @@ fn format_no_managed_sessions(sessions_root: &Path) -> String {
)
}
fn format_all_sessions_empty(sessions_root: &Path) -> String {
let fingerprint_dir = sessions_root
.file_name()
.and_then(|f| f.to_str())
.unwrap_or("<unknown>");
format!(
"all sessions are empty (0 messages) in .claw/sessions/{fingerprint_dir}/\nThis usually means a fresh `claw` session is running but no messages have been sent yet.\nWait for a response in your other session, then try `--resume {LATEST_SESSION_REFERENCE}` again."
)
}
fn format_legacy_session_missing_workspace_root(
session_path: &Path,
workspace_root: &Path,
@@ -832,28 +765,6 @@ mod tests {
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
struct EnvVarGuard {
key: &'static str,
previous: Option<std::ffi::OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: &Path) -> Self {
let previous = std::env::var_os(key);
std::env::set_var(key, value);
Self { key, previous }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.previous {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
fn temp_dir() -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -1309,117 +1220,6 @@ mod tests {
fs::remove_dir_all(base).expect("temp dir should clean up");
}
#[test]
fn latest_session_returns_all_empty_error_when_sessions_exist_but_have_no_messages() {
// given — create sessions with 0 messages (empty)
let _env_guard = crate::test_env_lock();
let base = temp_dir();
fs::create_dir_all(&base).expect("base dir should exist");
let isolated_config_home = base.join("config-home");
let _claw_config_home = EnvVarGuard::set("CLAW_CONFIG_HOME", &isolated_config_home);
let store = SessionStore::from_cwd(&base).expect("store should build");
let empty_handle = store.create_handle("empty-session");
Session::new()
.with_persistence_path(empty_handle.path.clone())
.save_to_path(&empty_handle.path)
.expect("empty session should save");
// when — latest_session should fail with the "all sessions empty" message
let result = store.latest_session();
assert!(
result.is_err(),
"latest_session should fail when all sessions are empty"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("all sessions are empty"),
"error should mention 'all sessions are empty', got: {err_msg}"
);
assert!(
err_msg.contains("0 messages"),
"error should mention '0 messages', got: {err_msg}"
);
fs::remove_dir_all(base).expect("temp dir should clean up");
}
#[test]
fn latest_session_excluding_skips_excluded_id_and_returns_previous() {
// given — two sessions WITH messages, newest excluded
let base = temp_dir();
fs::create_dir_all(&base).expect("base dir should exist");
let store = SessionStore::from_cwd(&base).expect("store should build");
let older = persist_session_via_store(&store, "older work");
wait_for_next_millisecond();
let newer = persist_session_via_store(&store, "newer work");
// when — exclude the newest session
let latest = store
.latest_session_excluding(Some(&newer.session_id))
.expect("latest excluding newest should resolve");
// then — the older session wins because the newest is skipped
assert_eq!(
latest.id, older.session_id,
"excluded id must be skipped, returning the previous session"
);
fs::remove_dir_all(base).expect("temp dir should clean up");
}
#[test]
fn latest_session_filters_out_zero_message_sessions() {
// given — one empty (0-message) session and one non-empty session
let base = temp_dir();
fs::create_dir_all(&base).expect("base dir should exist");
let store = SessionStore::from_cwd(&base).expect("store should build");
let empty_handle = store.create_handle("empty-session");
Session::new()
.with_persistence_path(empty_handle.path.clone())
.save_to_path(&empty_handle.path)
.expect("empty session should save");
wait_for_next_millisecond();
let non_empty = persist_session_via_store(&store, "real conversation");
// when
let latest = store.latest_session().expect("latest should resolve");
// then — the non-empty session wins; the 0-message one is filtered out
assert_eq!(
latest.id, non_empty.session_id,
"0-message session must be filtered out, non-empty session wins"
);
assert!(
latest.message_count > 0,
"resolved session must have messages"
);
fs::remove_dir_all(base).expect("temp dir should clean up");
}
#[test]
fn resolve_reference_excluding_latest_skips_excluded_id() {
// given — two sessions WITH messages
let base = temp_dir();
fs::create_dir_all(&base).expect("base dir should exist");
let store = SessionStore::from_cwd(&base).expect("store should build");
let older = persist_session_via_store(&store, "older work");
wait_for_next_millisecond();
let newer = persist_session_via_store(&store, "newer work");
// when — resolve the "latest" alias while excluding the newest session
let handle = store
.resolve_reference_excluding("latest", Some(&newer.session_id))
.expect("latest alias excluding newest should resolve");
// then — the excluded id is skipped, so the older session resolves
assert_eq!(
handle.id, older.session_id,
"excluded id must be skipped when resolving the latest alias"
);
fs::remove_dir_all(base).expect("temp dir should clean up");
}
#[test]
fn session_exists_and_delete_are_scoped_to_workspace_store() {
// given
+7
View File
@@ -686,6 +686,7 @@ mod tests {
id: "1".to_string(),
name: "read_file".to_string(),
input: r#"{"path":"src/main.rs"}"#.to_string(),
thought_signature: None,
}]),
ConversationMessage::tool_result(
"1",
@@ -697,6 +698,7 @@ mod tests {
id: "2".to_string(),
name: "edit_file".to_string(),
input: r#"{"path":"src/main.rs","old":"old","new":"new"}"#.to_string(),
thought_signature: None,
}]),
ConversationMessage::tool_result(
"2",
@@ -718,6 +720,7 @@ mod tests {
id: "1".to_string(),
name: "read_file".to_string(),
input: r#"{"path":"src/main.rs"}"#.to_string(),
thought_signature: None,
}]),
ConversationMessage::tool_result(
"1",
@@ -746,6 +749,7 @@ mod tests {
id: "t".to_string(),
name: "bash".to_string(),
input: r#"{"command":"ls"}"#.to_string(),
thought_signature: None,
},
]));
@@ -764,6 +768,7 @@ mod tests {
id: format!("read_{i}"),
name: "read_file".to_string(),
input: format!(r#"{{"path":"src/{i}.rs"}}"#),
thought_signature: None,
},
]));
messages.push(ConversationMessage::tool_result(
@@ -789,6 +794,7 @@ mod tests {
id: "1".to_string(),
name: "read_file".to_string(),
input: r#"{"path":"src/main.rs"}"#.to_string(),
thought_signature: None,
}]),
ConversationMessage::tool_result(
"1",
@@ -800,6 +806,7 @@ mod tests {
id: "2".to_string(),
name: "edit_file".to_string(),
input: r#"{"path":"src/main.rs","old":"buggy","new":"fixed"}"#.to_string(),
thought_signature: None,
}]),
ConversationMessage::tool_result(
"2",
+9 -6
View File
@@ -1644,13 +1644,16 @@ mod tests {
let tmp = tempfile::tempdir().expect("tempdir");
let worktree = tmp.path().join("worktree");
let git_dir = tmp.path().join("external-gitdir");
fs::create_dir_all(&worktree).expect("worktree dir");
Command::new("git")
.arg("init")
.current_dir(&worktree)
.output()
.expect("git init should run");
let git_dir = worktree.join(".git");
fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
fs::create_dir_all(git_dir.join("refs/heads")).expect("refs dir");
fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("HEAD");
fs::write(
worktree.join(".git"),
format!("gitdir: {}\n", git_dir.display()),
)
.expect(".git file");
let original_permissions = fs::metadata(&git_dir)
.expect("gitdir metadata")
File diff suppressed because it is too large Load Diff
@@ -23,10 +23,7 @@ const DEFAULT_BASE_URLS: &[(&str, &str)] = &[
("anthropic", "https://api.anthropic.com"),
("xai", "https://api.x.ai/v1"),
("openai", "https://api.openai.com/v1"),
(
"dashscope",
"https://dashscope.aliyuncs.com/compatible-mode/v1",
),
("dashscope", "https://dashscope.aliyuncs.com/compatible-mode/v1"),
];
const API_KEY_ENV_VARS: &[(&str, &str)] = &[
@@ -54,7 +51,12 @@ pub fn run_setup_wizard() -> Result<(), Box<dyn std::error::Error>> {
let model = prompt_model(&kind, &current)?;
let fast_model = prompt_fast_model(&current, model.as_deref())?;
save_user_provider_settings(&kind, &api_key, base_url.as_deref(), model.as_deref())?;
save_user_provider_settings(
&kind,
&api_key,
base_url.as_deref(),
model.as_deref(),
)?;
if let Some(fast) = &fast_model {
save_settings_field("subagentModel", fast)?;
@@ -62,10 +64,7 @@ pub fn run_setup_wizard() -> Result<(), Box<dyn std::error::Error>> {
println!();
println!(" \x1b[32mProvider saved to ~/.claw/settings.json\x1b[0m");
println!(
" Run \x1b[1m/model {}\x1b[0m or restart claw to activate.",
model.as_deref().unwrap_or(&kind)
);
println!(" Run \x1b[1m/model {}\x1b[0m or restart claw to activate.", model.as_deref().unwrap_or(&kind));
println!();
Ok(())
@@ -83,11 +82,7 @@ fn prompt_provider(current: &RuntimeProviderConfig) -> Result<String, Box<dyn st
let current_kind = current.kind().unwrap_or("anthropic");
println!(" \x1b[1mProvider\x1b[0m");
for (num, label, kind) in PROVIDERS {
let marker = if *kind == current_kind {
" (current)"
} else {
""
};
let marker = if *kind == current_kind { " (current)" } else { "" };
println!(" [{num}] {label}{marker}");
}
let default = PROVIDERS
@@ -134,7 +129,9 @@ fn prompt_api_key(
};
// Check if env var is already set
let env_set = std::env::var(env_var).ok().is_some_and(|v| !v.is_empty());
let env_set = std::env::var(env_var)
.ok()
.is_some_and(|v| !v.is_empty());
if env_set {
println!(" {env_var} is set in environment (will take priority over stored key)");
}
@@ -147,9 +144,7 @@ fn prompt_api_key(
};
if key.is_empty() && !env_set {
eprintln!(
" \x1b[33mWarning: no API key configured. Set {env_var} or re-run setup.\x1b[0m"
);
eprintln!(" \x1b[33mWarning: no API key configured. Set {env_var} or re-run setup.\x1b[0m");
}
Ok(key)
@@ -179,7 +174,9 @@ fn prompt_base_url(
"dashscope" => "DASHSCOPE_BASE_URL",
_ => "BASE_URL",
};
let env_set = std::env::var(env_var).ok().is_some_and(|v| !v.is_empty());
let env_set = std::env::var(env_var)
.ok()
.is_some_and(|v| !v.is_empty());
if env_set {
println!(" {env_var} is set in environment (will take priority over stored URL)");
}
@@ -206,9 +203,7 @@ fn prompt_model(
.find(|(k, _)| *k == kind)
.map_or(empty, |(_, models)| *models);
let current_model = current
.model()
.unwrap_or(aliases.first().copied().unwrap_or(""));
let current_model = current.model().unwrap_or(aliases.first().copied().unwrap_or(""));
println!(" \x1b[1mModel\x1b[0m");
if !aliases.is_empty() {
@@ -240,16 +235,12 @@ fn prompt_fast_model(
println!(" Press Enter to skip (agents will use your main model).");
let current_fast = load_current_settings_field("subagentModel");
let default_hint = current_fast.as_deref().or(main_model).unwrap_or("");
let default_hint = current_fast
.as_deref()
.or(main_model)
.unwrap_or("");
let input = read_line(&format!(
" Fast model [{}]: ",
if default_hint.is_empty() {
"same as main"
} else {
default_hint
}
))?;
let input = read_line(&format!(" Fast model [{}]: ", if default_hint.is_empty() { "same as main" } else { default_hint }))?;
if input.trim().is_empty() {
Ok(current_fast)
} else {
@@ -278,10 +269,7 @@ fn save_settings_field(field: &str, value: &str) -> Result<(), Box<dyn std::erro
};
if let Some(obj) = settings.as_object_mut() {
obj.insert(
field.to_string(),
serde_json::Value::String(value.to_string()),
);
obj.insert(field.to_string(), serde_json::Value::String(value.to_string()));
}
std::fs::create_dir_all(&settings_dir)?;
@@ -112,7 +112,6 @@ fn assert_doctor_help_json_contract(parsed: &Value) {
assert!(checks.iter().any(|check| check == "boot preflight"));
assert!(checks.iter().any(|check| check == "memory"));
assert!(checks.iter().any(|check| check == "mcp validation"));
assert!(checks.iter().any(|check| check == "hook validation"));
}
#[test]
@@ -841,19 +840,14 @@ fn acp_guidance_emits_json_when_requested() {
let root = unique_temp_dir("acp-json");
fs::create_dir_all(&root).expect("temp dir should exist");
// #443: acp serve exits 2 (not implemented) instead of 0
let output = run_claw(&root, &["--output-format", "json", "acp"], &[]);
assert_eq!(
output.status.code(),
Some(2),
"acp should exit 2 (not implemented)"
);
let acp: Value =
serde_json::from_slice(&output.stdout).expect("acp stdout should be valid json");
let acp = assert_json_command(&root, &["--output-format", "json", "acp"]);
assert_eq!(acp["kind"], "acp");
assert_eq!(acp["schema_version"], "1.0");
assert_eq!(acp["status"], "not_implemented");
assert_eq!(acp["status"], "unsupported");
assert_eq!(acp["phase"], "discoverability_only");
assert_eq!(acp["supported"], false);
assert_eq!(acp["exit_code"], 0);
assert_eq!(acp["serve_alias_only"], true);
assert_eq!(acp["protocol"]["json_rpc"], false);
assert_eq!(acp["protocol"]["daemon"], false);
assert!(acp["protocol"]["endpoint"].is_null());
@@ -861,23 +855,12 @@ fn acp_guidance_emits_json_when_requested() {
acp["contracts"]["unsupported_invocation_kind"],
"unsupported_acp_invocation"
);
// #443: internal tracking IDs removed from public JSON
assert!(
acp.get("discoverability_tracking").is_none(),
"discoverability_tracking should be removed (#443)"
);
assert!(
acp.get("tracking").is_none(),
"tracking should be removed (#443)"
);
assert!(
acp.get("recommended_workflows").is_none(),
"recommended_workflows should be removed (#443)"
);
assert_eq!(acp["discoverability_tracking"], "ROADMAP #64a");
assert_eq!(acp["tracking"], "ROADMAP #76 / #3033 / #3004");
assert!(acp["message"]
.as_str()
.expect("acp message")
.contains("not implemented"));
.contains("discoverability alias"));
}
#[test]
@@ -1476,7 +1459,7 @@ fn doctor_and_resume_status_emit_json_when_requested() {
.is_some_and(|available| available.iter().any(|name| name == "web_fetch")));
let checks = doctor["checks"].as_array().expect("doctor checks");
assert_eq!(checks.len(), 12);
assert_eq!(checks.len(), 10);
let check_names = checks
.iter()
.map(|check| {
@@ -1496,10 +1479,8 @@ fn doctor_and_resume_status_emit_json_when_requested() {
check_names,
vec![
"auth",
"base urls",
"config",
"mcp validation",
"hook validation",
"install source",
"workspace",
"memory",
@@ -2082,7 +2063,7 @@ fn local_json_surfaces_have_non_empty_action_contract_714() {
&git_workspace,
strings(&["--output-format", "json", "diff"]),
),
// #443: ACP exits 2 (not implemented); tested separately in acp_guidance_emits_json_when_requested
(&workspace, strings(&["--output-format", "json", "acp"])),
(&workspace, strings(&["--output-format", "json", "config"])),
(
&workspace,
@@ -3365,7 +3346,7 @@ fn config_unsupported_section_json_hint_741() {
fs::create_dir_all(&root).expect("temp dir");
let bin = env!("CARGO_BIN_EXE_claw");
for section in &["list", "show", "bogus"] {
for section in &["list", "show", "bogus", "help"] {
let output = Command::new(bin)
.current_dir(&root)
.args(["--output-format", "json", "config", section])
@@ -3403,36 +3384,6 @@ fn config_unsupported_section_json_hint_741() {
}
}
#[test]
fn config_help_returns_structured_section_list_344() {
// #344: /config help should return a structured section list, not an error
use std::process::Command;
let root = unique_temp_dir("config-help");
fs::create_dir_all(&root).expect("temp dir");
let bin = env!("CARGO_BIN_EXE_claw");
let output = Command::new(bin)
.current_dir(&root)
.args(["--output-format", "json", "config", "help"])
.output()
.expect("claw config help should run");
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("config help should emit valid JSON");
assert_eq!(parsed["kind"], "config", "config help kind must be config");
assert_eq!(
parsed["status"], "ok",
"config help must return status:ok (#344)"
);
assert_eq!(
parsed["section"], "help",
"config help section must be help"
);
let sections = parsed["available_sections"]
.as_array()
.expect("config help must have available_sections array");
assert!(!sections.is_empty(), "available_sections must not be empty");
}
#[test]
fn export_json_has_kind_702() {
// #458/#702: `claw export --output-format json` must emit kind:export.
@@ -3915,7 +3866,7 @@ fn agents_plugins_mcp_unknown_subcommand_have_hint_774() {
};
let parsed: serde_json::Value =
serde_json::from_str(json_str.trim()).expect("mcp bogus should emit JSON");
assert_eq!(parsed["error_kind"], "unsupported_action");
assert_eq!(parsed["error_kind"], "unknown_mcp_action");
let hint = parsed["hint"].as_str().unwrap_or("");
assert!(!hint.is_empty(), "mcp bogus hint must be non-null (#774)");
}
@@ -4195,8 +4146,8 @@ fn acp_unsupported_invocation_has_hint_782() {
.expect("hint must be non-null (#782)");
assert!(!hint.is_empty(), "hint must not be empty");
assert!(
hint.contains("not implemented") || hint.contains("unsupported"),
"hint should explain the not-implemented status, got: {hint:?}"
hint.contains("discoverability") || hint.contains("ROADMAP"),
"hint should explain the discoverability-only status, got: {hint:?}"
);
}
@@ -530,9 +530,8 @@ fn resumed_help_command_emits_structured_json() {
let stdout = String::from_utf8(output.stdout).expect("utf8");
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
assert_eq!(parsed["kind"], "help");
// #338: resume help now uses 'message' field for parity with top-level help
assert!(parsed["message"].as_str().is_some());
let text = parsed["message"].as_str().unwrap();
assert!(parsed["text"].as_str().is_some());
let text = parsed["text"].as_str().unwrap();
assert!(text.contains("/status"), "help text should list /status");
}
+16 -44
View File
@@ -2701,20 +2701,6 @@ fn is_within_workspace(path: &str) -> bool {
let path = PathBuf::from(trimmed);
// Reject any parent-directory traversal. Callers never need `..` to refer
// to files inside the workspace, and `..` defeats both checks below: the
// relative branch only inspects the leading component, and the absolute
// branch's `canonicalize()` silently falls back to the literal `..` path
// when the target does not exist yet (e.g. a file about to be created).
// Returning false here is the safe direction: it classifies the command as
// requiring full-access permission rather than workspace-write.
if path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return false;
}
// If path is absolute, check if it starts with CWD
if path.is_absolute() {
if let Ok(cwd) = std::env::current_dir() {
@@ -2732,26 +2718,6 @@ fn run_powershell(input: PowerShellInput) -> Result<String, String> {
to_pretty_json(execute_powershell(input).map_err(|error| error.to_string())?)
}
#[cfg(test)]
mod workspace_traversal_guard_tests {
use super::is_within_workspace;
#[test]
fn rejects_parent_traversal_components() {
// Leading and embedded `..` must both be rejected (was previously a hole
// because only the leading component was inspected).
assert!(!is_within_workspace("../secrets"));
assert!(!is_within_workspace("src/../../etc/passwd"));
assert!(!is_within_workspace("a/b/../../../etc/crontab"));
}
#[test]
fn allows_plain_relative_paths() {
assert!(is_within_workspace("src/main.rs"));
assert!(is_within_workspace("Cargo.toml"));
}
}
fn to_pretty_json<T: serde::Serialize>(value: T) -> Result<String, String> {
serde_json::to_string_pretty(&value).map_err(|error| error.to_string())
}
@@ -5237,7 +5203,7 @@ async fn stream_with_provider(
) -> Result<Vec<AssistantEvent>, ApiError> {
let mut stream = client.stream_message(message_request).await?;
let mut events = Vec::new();
let mut pending_tools: BTreeMap<u32, (String, String, String)> = BTreeMap::new();
let mut pending_tools: BTreeMap<u32, (String, String, String, Option<String>)> = BTreeMap::new();
let mut pending_thinking: BTreeMap<u32, (String, Option<String>)> = BTreeMap::new();
let mut saw_stop = false;
@@ -5272,7 +5238,7 @@ async fn stream_with_provider(
}
}
ContentBlockDelta::InputJsonDelta { partial_json } => {
if let Some((_, _, input)) = pending_tools.get_mut(&delta.index) {
if let Some((_, _, input, _)) = pending_tools.get_mut(&delta.index) {
input.push_str(&partial_json);
}
}
@@ -5296,8 +5262,8 @@ async fn stream_with_provider(
signature,
});
}
if let Some((id, name, input)) = pending_tools.remove(&stop.index) {
events.push(AssistantEvent::ToolUse { id, name, input });
if let Some((id, name, input, thought_signature)) = pending_tools.remove(&stop.index) {
events.push(AssistantEvent::ToolUse { id, name, input, thought_signature });
}
}
ApiStreamEvent::MessageDelta(delta) => {
@@ -5405,11 +5371,12 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
thinking: thinking.clone(),
signature: signature.clone(),
},
ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
ContentBlock::ToolUse { id, name, input, thought_signature } => InputContentBlock::ToolUse {
id: id.clone(),
name: name.clone(),
input: serde_json::from_str(input)
.unwrap_or_else(|_| serde_json::json!({ "raw": input })),
thought_signature: thought_signature.clone(),
},
ContentBlock::ToolResult {
tool_use_id,
@@ -5440,7 +5407,7 @@ fn push_output_block(
block: OutputContentBlock,
block_index: u32,
events: &mut Vec<AssistantEvent>,
pending_tools: &mut BTreeMap<u32, (String, String, String)>,
pending_tools: &mut BTreeMap<u32, (String, String, String, Option<String>)>,
pending_thinking: &mut BTreeMap<u32, (String, Option<String>)>,
streaming_tool_input: bool,
) {
@@ -5450,7 +5417,7 @@ fn push_output_block(
events.push(AssistantEvent::TextDelta(text));
}
}
OutputContentBlock::ToolUse { id, name, input } => {
OutputContentBlock::ToolUse { id, name, input, thought_signature } => {
let initial_input = if streaming_tool_input
&& input.is_object()
&& input.as_object().is_some_and(serde_json::Map::is_empty)
@@ -5459,7 +5426,7 @@ fn push_output_block(
} else {
input.to_string()
};
pending_tools.insert(block_index, (id, name, initial_input));
pending_tools.insert(block_index, (id, name, initial_input, thought_signature));
}
OutputContentBlock::Thinking {
thinking,
@@ -5493,8 +5460,8 @@ fn response_to_events(response: MessageResponse) -> Vec<AssistantEvent> {
&mut pending_thinking,
false,
);
if let Some((id, name, input)) = pending_tools.remove(&index) {
events.push(AssistantEvent::ToolUse { id, name, input });
if let Some((id, name, input, thought_signature)) = pending_tools.remove(&index) {
events.push(AssistantEvent::ToolUse { id, name, input, thought_signature });
}
}
@@ -8100,6 +8067,7 @@ mod tests {
id: "tool-1".to_string(),
name: "read_file".to_string(),
input: json!({}),
thought_signature: None,
},
1,
&mut events,
@@ -8112,6 +8080,7 @@ mod tests {
id: "tool-2".to_string(),
name: "grep_search".to_string(),
input: json!({}),
thought_signature: None,
},
2,
&mut events,
@@ -8137,6 +8106,7 @@ mod tests {
"tool-1".to_string(),
"read_file".to_string(),
"{\"path\":\"src/main.rs\"}".to_string(),
None,
))
);
assert_eq!(
@@ -8145,6 +8115,7 @@ mod tests {
"tool-2".to_string(),
"grep_search".to_string(),
"{\"pattern\":\"TODO\"}".to_string(),
None,
))
);
}
@@ -9392,6 +9363,7 @@ mod tests {
id: "tool-1".to_string(),
name: "read_file".to_string(),
input: json!({ "path": self.input_path }).to_string(),
thought_signature: None,
},
AssistantEvent::MessageStop,
])
-9
View File
@@ -11,7 +11,6 @@ _GLOB_META = set('*?[')
_WINDOWS_DRIVE_RE = re.compile(r'^[A-Za-z]:[\\/]')
_WINDOWS_UNC_RE = re.compile(r'^(?:\\\\|//)[^\\/]+[\\/][^\\/]+')
_ENV_ASSIGNMENT_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*=')
_REDIRECTION_TARGET_RE = re.compile(r'^(?:\d*)?(?:<>|>>?|<)(.+)$|^&>>?(.+)$')
@dataclass(frozen=True)
@@ -119,7 +118,6 @@ def extract_path_candidates(payload: str) -> tuple[str, ...]:
for token in (*tokens, *raw_tokens):
if not token or token.startswith('-') or _ENV_ASSIGNMENT_RE.match(token):
continue
token = _strip_redirection_operator(token)
expanded = os.path.expandvars(os.path.expanduser(token))
if _looks_like_path(token) or _looks_like_path(expanded):
candidate = expanded if _looks_like_path(expanded) else token
@@ -140,13 +138,6 @@ def _looks_like_path(token: str) -> bool:
)
def _strip_redirection_operator(token: str) -> str:
match = _REDIRECTION_TARGET_RE.match(token)
if match is None:
return token
return next(group for group in match.groups() if group is not None)
def _is_windows_absolute(value: str) -> bool:
return bool(_WINDOWS_DRIVE_RE.match(value) or _WINDOWS_UNC_RE.match(value))
-22
View File
@@ -72,28 +72,6 @@ class WorkspacePathScopeTests(unittest.TestCase):
self.assertFalse(decision.allowed)
self.assertIn(str(outside.resolve()), decision.resolved or '')
def test_attached_shell_redirection_targets_are_validated(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
workspace = root / 'workspace'
outside = root / 'outside'
workspace.mkdir()
outside.mkdir()
(outside / 'secret.txt').write_text('secret')
self.assertEqual(
('../outside/secret.txt', '../outside/error.log'),
extract_path_candidates(
'cat <../outside/secret.txt 2>../outside/error.log'
),
)
decision = WorkspacePathScope.from_root(workspace).validate_payload(
'cat <../outside/secret.txt 2>../outside/error.log'
)
self.assertFalse(decision.allowed)
self.assertIn(str(outside.resolve()), decision.resolved or '')
def test_explicit_worktree_roots_are_allowed(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)