mirror of
https://github.com/instructkr/claw-code.git
synced 2026-06-06 01:42:47 -04:00
Compare commits
46 Commits
c8e973513c
...
eaa2e320d9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eaa2e320d9 | ||
|
|
be8112f5f5 | ||
|
|
503d515f38 | ||
|
|
114c2da9fc | ||
|
|
b90f18f75a | ||
|
|
5b15197117 | ||
|
|
61e8ad9a8e | ||
|
|
ef0d0c30a4 | ||
|
|
7305e5509a | ||
|
|
2f0b5b36eb | ||
|
|
c848eeb768 | ||
|
|
d4aad7103e | ||
|
|
b60cbebb3a | ||
|
|
7c4bcd92b6 | ||
|
|
f8822aabdb | ||
|
|
aca6584fd5 | ||
|
|
b8cbb1834f | ||
|
|
cb027adf65 | ||
|
|
5bdffbe161 | ||
|
|
04f18862a8 | ||
|
|
f0e10ff182 | ||
|
|
0cab03df75 | ||
|
|
eb86f4d2c3 | ||
|
|
6c3d7be370 | ||
|
|
e3ffaefba1 | ||
|
|
cd18cf5385 | ||
|
|
2f3120e70a | ||
|
|
f1a16398fe | ||
|
|
8d50276366 | ||
|
|
13992ade54 | ||
|
|
b04b1d6ac8 | ||
|
|
934bf2837a | ||
|
|
b94c49c323 | ||
|
|
5df485cba9 | ||
|
|
9e50cb6e20 | ||
|
|
346772a8b3 | ||
|
|
ef392e5938 | ||
|
|
41034bb3f3 | ||
|
|
76783377ec | ||
|
|
e459a727e9 | ||
|
|
04bc5f5788 | ||
|
|
571d3cdc0f | ||
|
|
414a1aca4f | ||
|
|
d8c57ed317 | ||
|
|
e8c8ef1142 | ||
|
|
1d516be779 |
@@ -226,6 +226,7 @@ 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
|
||||
|
||||
53
ROADMAP.md
53
ROADMAP.md
File diff suppressed because one or more lines are too long
11
USAGE.md
11
USAGE.md
@@ -245,6 +245,7 @@ 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.
|
||||
|
||||
@@ -305,18 +306,18 @@ cd rust
|
||||
### Ollama
|
||||
|
||||
```bash
|
||||
export OPENAI_BASE_URL="http://127.0.0.1:11434/v1"
|
||||
unset OPENAI_API_KEY
|
||||
export OLLAMA_HOST="http://127.0.0.1:11434"
|
||||
|
||||
cd rust
|
||||
./target/debug/claw --model "llama3.2" prompt "summarize this repository in one sentence"
|
||||
```
|
||||
|
||||
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:
|
||||
`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:
|
||||
|
||||
```bash
|
||||
export OPENAI_BASE_URL="http://127.0.0.1:11434/v1"
|
||||
unset OPENAI_API_KEY
|
||||
export OLLAMA_HOST="http://127.0.0.1:11434"
|
||||
|
||||
cd rust
|
||||
./target/debug/claw --model "qwen2.5-coder:7b" prompt "reply with ready"
|
||||
|
||||
@@ -57,11 +57,12 @@ ollama serve
|
||||
In another shell:
|
||||
|
||||
```bash
|
||||
export OPENAI_BASE_URL="http://127.0.0.1:11434/v1"
|
||||
unset OPENAI_API_KEY
|
||||
export OLLAMA_HOST="http://127.0.0.1:11434"
|
||||
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
|
||||
|
||||
@@ -32,6 +32,14 @@ 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.
|
||||
@@ -45,6 +53,7 @@ impl ProviderClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn provider_kind(&self) -> ProviderKind {
|
||||
|
||||
@@ -20,6 +20,7 @@ const CONTEXT_WINDOW_ERROR_MARKERS: &[&str] = &[
|
||||
"completion tokens",
|
||||
"prompt tokens",
|
||||
"request is too large",
|
||||
"no parseable body",
|
||||
];
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -60,6 +61,9 @@ 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,
|
||||
@@ -128,6 +132,17 @@ 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(),
|
||||
@@ -311,6 +326,36 @@ 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,
|
||||
@@ -499,6 +544,7 @@ mod tests {
|
||||
body: String::new(),
|
||||
retryable: true,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
|
||||
assert!(error.is_generic_fatal_wrapper());
|
||||
@@ -522,6 +568,7 @@ mod tests {
|
||||
body: String::new(),
|
||||
retryable: true,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -543,6 +590,7 @@ mod tests {
|
||||
body: String::new(),
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
|
||||
assert!(error.is_context_window_failure());
|
||||
@@ -563,6 +611,7 @@ mod tests {
|
||||
body: String::new(),
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
|
||||
assert!(error.is_context_window_failure());
|
||||
|
||||
@@ -1,9 +1,69 @@
|
||||
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.
|
||||
@@ -61,7 +121,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(&ProxyConfig::from_env())
|
||||
build_http_client_with_opts(&ProxyConfig::from_env(), &TimeoutConfig::from_env())
|
||||
}
|
||||
|
||||
/// Infallible counterpart to [`build_http_client`] for constructors that
|
||||
@@ -71,7 +131,8 @@ 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().unwrap_or_else(|_| {
|
||||
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()
|
||||
@@ -86,9 +147,20 @@ 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");
|
||||
.user_agent("clawd-rust-tools/0.1")
|
||||
.connect_timeout(timeout.connect_timeout)
|
||||
.timeout(timeout.request_timeout);
|
||||
|
||||
let no_proxy = config
|
||||
.no_proxy
|
||||
@@ -131,7 +203,7 @@ where
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::{build_http_client_with, ProxyConfig};
|
||||
use super::{build_http_client_with, build_http_client_with_opts, ProxyConfig, TimeoutConfig};
|
||||
|
||||
fn config_from_map(pairs: &[(&str, &str)]) -> ProxyConfig {
|
||||
let map: HashMap<String, String> = pairs
|
||||
@@ -143,30 +215,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn proxy_config_is_empty_when_no_env_vars_are_set() {
|
||||
// given
|
||||
let config = config_from_map(&[]);
|
||||
|
||||
// when
|
||||
let empty = config.is_empty();
|
||||
|
||||
// then
|
||||
assert!(empty);
|
||||
assert!(config.is_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")
|
||||
@@ -184,17 +245,12 @@ 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")
|
||||
@@ -208,16 +264,11 @@ 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")
|
||||
@@ -226,59 +277,39 @@ 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(_)),
|
||||
@@ -288,10 +319,7 @@ 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")
|
||||
@@ -303,49 +331,56 @@ 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ pub use client::{
|
||||
};
|
||||
pub use error::ApiError;
|
||||
pub use http_client::{
|
||||
build_http_client, build_http_client_or_default, build_http_client_with, ProxyConfig,
|
||||
build_http_client, build_http_client_or_default, build_http_client_with,
|
||||
build_http_client_with_opts, ProxyConfig, TimeoutConfig,
|
||||
};
|
||||
pub use prompt_cache::{
|
||||
CacheBreakEvent, PromptCache, PromptCacheConfig, PromptCachePaths, PromptCacheRecord,
|
||||
|
||||
@@ -211,6 +211,19 @@ 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);
|
||||
@@ -454,7 +467,13 @@ impl AnthropicClient {
|
||||
break;
|
||||
}
|
||||
|
||||
tokio::time::sleep(self.jittered_backoff_for_attempt(attempts)?).await;
|
||||
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;
|
||||
}
|
||||
|
||||
Err(ApiError::RetriesExhausted {
|
||||
@@ -866,10 +885,12 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
let request_id = request_id_from_headers(response.headers());
|
||||
let headers = response.headers().clone();
|
||||
let request_id = request_id_from_headers(&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,
|
||||
@@ -883,13 +904,44 @@ 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
|
||||
@@ -908,6 +960,8 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
..
|
||||
} = error
|
||||
else {
|
||||
return error;
|
||||
@@ -921,6 +975,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
};
|
||||
}
|
||||
let Some(bearer_token) = auth.bearer_token() else {
|
||||
@@ -932,6 +987,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
};
|
||||
};
|
||||
if !bearer_token.starts_with("sk-ant-") {
|
||||
@@ -943,6 +999,7 @@ 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
|
||||
@@ -958,6 +1015,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
};
|
||||
}
|
||||
let enriched_message = match message {
|
||||
@@ -972,6 +1030,7 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1596,6 +1655,7 @@ mod tests {
|
||||
body: String::new(),
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
|
||||
// when
|
||||
@@ -1637,6 +1697,7 @@ mod tests {
|
||||
body: String::new(),
|
||||
retryable: true,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
|
||||
// when
|
||||
@@ -1666,6 +1727,7 @@ mod tests {
|
||||
body: String::new(),
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
|
||||
// when
|
||||
@@ -1694,6 +1756,7 @@ mod tests {
|
||||
body: String::new(),
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
|
||||
// when
|
||||
@@ -1719,6 +1782,7 @@ mod tests {
|
||||
body: String::new(),
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
|
||||
// when
|
||||
|
||||
@@ -351,6 +351,11 @@ 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;
|
||||
|
||||
@@ -49,6 +49,14 @@ 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 {
|
||||
@@ -149,6 +157,22 @@ 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 {
|
||||
@@ -175,6 +199,18 @@ 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,
|
||||
@@ -217,6 +253,7 @@ impl OpenAiCompatClient {
|
||||
reqwest::StatusCode::from_u16(code.unwrap_or(400))
|
||||
.unwrap_or(reqwest::StatusCode::BAD_REQUEST),
|
||||
),
|
||||
retry_after: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -270,7 +307,12 @@ impl OpenAiCompatClient {
|
||||
break retryable_error;
|
||||
}
|
||||
|
||||
tokio::time::sleep(self.jittered_backoff_for_attempt(attempts)?).await;
|
||||
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;
|
||||
};
|
||||
|
||||
Err(ApiError::RetriesExhausted {
|
||||
@@ -1561,6 +1603,7 @@ fn parse_sse_frame(
|
||||
body: trimmed.chars().take(500).collect(),
|
||||
retryable: false,
|
||||
suggested_action: suggested_action_for_status(status),
|
||||
retry_after: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1576,6 +1619,7 @@ 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);
|
||||
@@ -1611,6 +1655,7 @@ fn parse_sse_frame(
|
||||
body: payload.clone(),
|
||||
retryable: false,
|
||||
suggested_action: suggested_action_for_status(status),
|
||||
retry_after: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1627,6 +1672,7 @@ 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)
|
||||
@@ -1678,10 +1724,12 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
let request_id = request_id_from_headers(response.headers());
|
||||
let headers = response.headers().clone();
|
||||
let request_id = request_id_from_headers(&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);
|
||||
|
||||
@@ -1697,13 +1745,43 @@ 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> {
|
||||
|
||||
@@ -1572,7 +1572,10 @@ 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") {
|
||||
if matches!(
|
||||
section.as_str(),
|
||||
"env" | "hooks" | "model" | "plugins" | "help"
|
||||
) {
|
||||
return Ok(Some(section));
|
||||
}
|
||||
return Err(command_error(
|
||||
@@ -2311,13 +2314,15 @@ 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 enabled {}\n Name {}\n Version {}\n Status enabled",
|
||||
plugin.metadata.id, plugin.metadata.name, plugin.metadata.version
|
||||
"Plugins\n Result {}\n Name {}\n Version {}\n Status enabled",
|
||||
if already_enabled { "already enabled" } else { "enabled" },
|
||||
plugin.metadata.name, plugin.metadata.version
|
||||
),
|
||||
reload_runtime: true,
|
||||
reload_runtime: !already_enabled,
|
||||
})
|
||||
}
|
||||
Some("disable") => {
|
||||
@@ -2328,13 +2333,15 @@ 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 disabled {}\n Name {}\n Version {}\n Status disabled",
|
||||
plugin.metadata.id, plugin.metadata.name, plugin.metadata.version
|
||||
"Plugins\n Result {}\n Name {}\n Version {}\n Status disabled",
|
||||
if already_disabled { "already disabled" } else { "disabled" },
|
||||
plugin.metadata.name, plugin.metadata.version
|
||||
),
|
||||
reload_runtime: true,
|
||||
reload_runtime: !already_disabled,
|
||||
})
|
||||
}
|
||||
Some("remove") | Some("uninstall") => {
|
||||
@@ -2753,15 +2760,26 @@ 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 target = args["install ".len()..].trim();
|
||||
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)
|
||||
};
|
||||
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 <path>",
|
||||
"missing_argument: skills install requires an install source.\nUsage: claw skills install [--project] <path>",
|
||||
));
|
||||
}
|
||||
let install = install_skill(target, cwd)?;
|
||||
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)?
|
||||
};
|
||||
Ok(render_skill_install_report(&install))
|
||||
}
|
||||
Some("uninstall" | "remove" | "delete") => Err(std::io::Error::new(
|
||||
@@ -2915,16 +2933,28 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
|
||||
"install_source",
|
||||
"Usage: claw skills install <path>",
|
||||
)),
|
||||
// #95: support --project flag for project-level install
|
||||
Some(args) if args.starts_with("install ") => {
|
||||
let target = args["install ".len()..].trim();
|
||||
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)
|
||||
};
|
||||
if target.is_empty() {
|
||||
return Ok(render_skills_missing_argument_json(
|
||||
"install",
|
||||
"install_source",
|
||||
"Usage: claw skills install <path>",
|
||||
"Usage: claw skills install [--project] <path>",
|
||||
));
|
||||
}
|
||||
match install_skill(target, cwd) {
|
||||
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 {
|
||||
Ok(install) => Ok(render_skill_install_report_json(&install)),
|
||||
Err(error) => Ok(render_skill_install_error_json(target, &error)),
|
||||
}
|
||||
@@ -4881,12 +4911,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 <path>|uninstall <name>|help|<skill> [args]]".to_string(),
|
||||
" Usage /skills [list|show <name>|install [--project] <path>|uninstall <name>|help|<skill> [args]]".to_string(),
|
||||
" Alias /skill".to_string(),
|
||||
" Direct CLI claw skills [list|show <name>|install <path>|uninstall <name>|help|<skill> [args]]".to_string(),
|
||||
" Direct CLI claw skills [list|show <name>|install [--project] <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".to_string(),
|
||||
" Install root $CLAW_CONFIG_HOME/skills or ~/.claw/skills (use --project for .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 {
|
||||
@@ -6595,12 +6625,13 @@ 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 <path>|uninstall <name>|help|<skill> [args]]"
|
||||
"Usage /skills [list|show <name>|install [--project] <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"));
|
||||
assert!(skills_help.contains("Install root $CLAW_CONFIG_HOME/skills or ~/.claw/skills"));
|
||||
// #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(".omc/skills"));
|
||||
assert!(skills_help.contains(".agents/skills"));
|
||||
assert!(skills_help.contains("~/.claude/skills/omc-learned"));
|
||||
@@ -6613,7 +6644,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 <path>|uninstall <name>|help|<skill> [args]]"
|
||||
"Usage /skills [list|show <name>|install [--project] <path>|uninstall <name>|help|<skill> [args]]"
|
||||
));
|
||||
assert!(skills_install_help.contains("Alias /skill"));
|
||||
assert!(skills_install_help.contains("Unexpected install"));
|
||||
@@ -6621,7 +6652,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 <path>|uninstall <name>|help|<skill> [args]]"
|
||||
"Usage /skills [list|show <name>|install [--project] <path>|uninstall <name>|help|<skill> [args]]"
|
||||
));
|
||||
assert!(skills_unknown_help.contains("Unexpected show"));
|
||||
|
||||
@@ -7088,7 +7119,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("disabled demo@external"));
|
||||
assert!(disable.message.contains("Result disabled"));
|
||||
assert!(disable.message.contains("Name demo"));
|
||||
assert!(disable.message.contains("Status disabled"));
|
||||
|
||||
@@ -7100,7 +7131,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("enabled demo@external"));
|
||||
assert!(enable.message.contains("Result enabled"));
|
||||
assert!(enable.message.contains("Name demo"));
|
||||
assert!(enable.message.contains("Status enabled"));
|
||||
|
||||
|
||||
@@ -125,6 +125,27 @@ 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 {
|
||||
@@ -139,6 +160,7 @@ pub struct RuntimeFeatureConfig {
|
||||
sandbox: SandboxConfig,
|
||||
provider_fallbacks: ProviderFallbackConfig,
|
||||
trusted_roots: Vec<String>,
|
||||
api_timeout: ApiTimeoutConfig,
|
||||
rules_import: RulesImportConfig,
|
||||
}
|
||||
|
||||
@@ -740,6 +762,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)?,
|
||||
};
|
||||
|
||||
@@ -2020,6 +2043,26 @@ 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());
|
||||
@@ -2097,6 +2140,45 @@ 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,
|
||||
@@ -2106,9 +2188,14 @@ 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: expect_non_empty_string(object, "command", context)?.to_string(),
|
||||
args: optional_string_array(object, "args", context)?.unwrap_or_default(),
|
||||
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(),
|
||||
env: optional_string_map(object, "env", context)?.unwrap_or_default(),
|
||||
tool_call_timeout_ms: optional_u64(object, "toolCallTimeoutMs", context)?,
|
||||
})),
|
||||
@@ -2119,7 +2206,8 @@ fn parse_mcp_server_config(
|
||||
object, context,
|
||||
)?)),
|
||||
"ws" => Ok(McpServerConfig::Ws(McpWebSocketServerConfig {
|
||||
url: expect_string(object, "url", context)?.to_string(),
|
||||
// #92: expand ${VAR} and ~/ in URL
|
||||
url: expand_config_value(expect_string(object, "url", context)?),
|
||||
headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
|
||||
headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string),
|
||||
})),
|
||||
@@ -2127,7 +2215,8 @@ fn parse_mcp_server_config(
|
||||
name: expect_string(object, "name", context)?.to_string(),
|
||||
})),
|
||||
"claudeai-proxy" => Ok(McpServerConfig::ManagedProxy(McpManagedProxyServerConfig {
|
||||
url: expect_string(object, "url", context)?.to_string(),
|
||||
// #92: expand ${VAR} and ~/ in URL
|
||||
url: expand_config_value(expect_string(object, "url", context)?),
|
||||
id: expect_string(object, "id", context)?.to_string(),
|
||||
})),
|
||||
other => Err(ConfigError::Parse(format!(
|
||||
@@ -2149,7 +2238,8 @@ fn parse_mcp_remote_server_config(
|
||||
context: &str,
|
||||
) -> Result<McpRemoteServerConfig, ConfigError> {
|
||||
Ok(McpRemoteServerConfig {
|
||||
url: expect_string(object, "url", context)?.to_string(),
|
||||
// #92: expand ${VAR} and ~/ in URL
|
||||
url: expand_config_value(expect_string(object, "url", context)?),
|
||||
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)?,
|
||||
@@ -2412,6 +2502,10 @@ 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());
|
||||
}
|
||||
@@ -3385,14 +3479,19 @@ mod tests {
|
||||
.load()
|
||||
.expect("config should load valid hook entries and record invalid siblings");
|
||||
|
||||
assert_eq!(loaded.hooks().pre_tool_use(), &["project".to_string()]);
|
||||
// #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()]
|
||||
);
|
||||
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, Some(1));
|
||||
// #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"));
|
||||
|
||||
@@ -204,6 +204,13 @@ 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;
|
||||
|
||||
@@ -65,10 +65,10 @@ pub use compact::{
|
||||
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
|
||||
};
|
||||
pub use config::{
|
||||
suppress_config_warnings_for_json_mode, ConfigEntry, ConfigError, ConfigFileReport,
|
||||
ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource, McpConfigCollection,
|
||||
McpInvalidServerConfig, McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig,
|
||||
McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport,
|
||||
suppress_config_warnings_for_json_mode, ApiTimeoutConfig, 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,
|
||||
|
||||
@@ -173,32 +173,112 @@ impl PermissionEnforcer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple workspace boundary check via string prefix.
|
||||
/// 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.
|
||||
fn is_within_workspace(path: &str, workspace_root: &str) -> bool {
|
||||
let normalized = if path.starts_with('/') {
|
||||
let combined = if path.starts_with('/') {
|
||||
path.to_owned()
|
||||
} else {
|
||||
format!("{workspace_root}/{path}")
|
||||
};
|
||||
|
||||
let root = if workspace_root.ends_with('/') {
|
||||
workspace_root.to_owned()
|
||||
let normalized = lexically_normalize(&combined);
|
||||
let root = lexically_normalize(workspace_root);
|
||||
let root_with_slash = if root.ends_with('/') {
|
||||
root.clone()
|
||||
} else {
|
||||
format!("{workspace_root}/")
|
||||
format!("{root}/")
|
||||
};
|
||||
|
||||
normalized.starts_with(&root) || normalized == workspace_root.trim_end_matches('/')
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let first_token = command
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
// 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;
|
||||
}
|
||||
|
||||
matches!(
|
||||
first_token,
|
||||
@@ -237,8 +317,6 @@ fn is_read_only_command(command: &str) -> bool {
|
||||
| "tr"
|
||||
| "cut"
|
||||
| "paste"
|
||||
| "tee"
|
||||
| "xargs"
|
||||
| "test"
|
||||
| "true"
|
||||
| "false"
|
||||
@@ -257,18 +335,8 @@ 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)]
|
||||
@@ -375,6 +443,91 @@ 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
|
||||
|
||||
@@ -149,7 +149,12 @@ impl PermissionPolicy {
|
||||
.iter()
|
||||
.map(|rule| PermissionRule::parse(rule))
|
||||
.collect();
|
||||
self.denied_tools = config.denied_tools().to_vec();
|
||||
// #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
|
||||
}
|
||||
|
||||
@@ -375,7 +380,8 @@ impl PermissionRule {
|
||||
let matcher = parse_rule_matcher(content);
|
||||
return Self {
|
||||
raw: trimmed.to_string(),
|
||||
tool_name: tool_name.to_string(),
|
||||
// #94: normalize tool name to lowercase to match runtime convention
|
||||
tool_name: tool_name.to_lowercase(),
|
||||
matcher,
|
||||
};
|
||||
}
|
||||
@@ -384,7 +390,8 @@ impl PermissionRule {
|
||||
|
||||
Self {
|
||||
raw: trimmed.to_string(),
|
||||
tool_name: trimmed.to_string(),
|
||||
// #94: normalize tool name to lowercase to match runtime convention
|
||||
tool_name: trimmed.to_lowercase(),
|
||||
matcher: PermissionRuleMatcher::Any,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,8 +231,31 @@ 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()?;
|
||||
rotate_session_file_if_needed(path)?;
|
||||
write_atomic(path, &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,
|
||||
}
|
||||
})?;
|
||||
cleanup_rotated_logs(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -93,8 +93,19 @@ 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()?;
|
||||
let latest = self.latest_session_excluding(exclude_id)?;
|
||||
return Ok(SessionHandle {
|
||||
id: latest.id,
|
||||
path: latest.path,
|
||||
@@ -158,12 +169,45 @@ impl SessionStore {
|
||||
}
|
||||
|
||||
pub fn latest_session(&self) -> Result<ManagedSessionSummary, SessionControlError> {
|
||||
if let Some(latest) = self.list_sessions()?.into_iter().next() {
|
||||
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)
|
||||
{
|
||||
return Ok(latest);
|
||||
}
|
||||
if let Some(latest) = self.scan_global_sessions()?.into_iter().next() {
|
||||
// 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)
|
||||
{
|
||||
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,
|
||||
)))
|
||||
@@ -204,18 +248,34 @@ impl SessionStore {
|
||||
&self,
|
||||
reference: &str,
|
||||
) -> Result<LoadedManagedSession, SessionControlError> {
|
||||
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)?;
|
||||
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)
|
||||
{
|
||||
eprintln!(
|
||||
" Note: resuming session from a different workspace (origin: {})",
|
||||
actual.display()
|
||||
);
|
||||
let _ = expected; // suppress unused warning
|
||||
}
|
||||
} else {
|
||||
self.validate_loaded_session(&handle.path, &session)?;
|
||||
}
|
||||
Ok(LoadedManagedSession {
|
||||
handle: SessionHandle {
|
||||
id: session.session_id.clone(),
|
||||
@@ -224,9 +284,6 @@ impl SessionStore {
|
||||
session,
|
||||
})
|
||||
}
|
||||
Err(other) => Err(other),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fork_session(
|
||||
&self,
|
||||
@@ -726,6 +783,16 @@ 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,
|
||||
@@ -1220,6 +1287,114 @@ 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 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");
|
||||
|
||||
// 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
|
||||
|
||||
@@ -1121,7 +1121,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("{}", render_diff_report()?);
|
||||
}
|
||||
CliOutputFormat::Json => {
|
||||
let cwd = env::current_dir()?;
|
||||
let cwd = friendly_cwd(env::current_dir()?);
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&render_diff_json_for(&cwd)?)?
|
||||
@@ -1598,6 +1598,16 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
let value = args
|
||||
.get(index + 1)
|
||||
.ok_or_else(|| "missing_flag_value: missing value for --base-commit.\nUsage: --base-commit <git-sha>".to_string())?;
|
||||
// #122: validate that base-commit looks like a git SHA (hex, 7-64 chars)
|
||||
if value.len() < 7
|
||||
|| value.len() > 64
|
||||
|| !value.chars().all(|c| c.is_ascii_hexdigit())
|
||||
{
|
||||
return Err(format!(
|
||||
"invalid_flag_value: --base-commit expects a hex SHA (7-64 chars), got '{}'.\nUsage: --base-commit <git-sha>",
|
||||
value
|
||||
));
|
||||
}
|
||||
base_commit = Some(value.clone());
|
||||
index += 2;
|
||||
}
|
||||
@@ -1909,6 +1919,25 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
.unwrap_or_else(permission_mode_provenance_for_current_dir)
|
||||
};
|
||||
|
||||
// #98: --compact is only meaningful for prompt mode. When a known non-prompt
|
||||
// subcommand is being dispatched, reject --compact so callers don't silently
|
||||
// lose the flag.
|
||||
if compact
|
||||
&& rest
|
||||
.first()
|
||||
.map(|s| s.as_str())
|
||||
.is_some_and(|s| s != "prompt")
|
||||
{
|
||||
// Allow compact for the default prompt fallback (unknown tokens).
|
||||
// Only reject for known top-level subcommands that don't use compact.
|
||||
let first = rest[0].as_str();
|
||||
if is_known_top_level_subcommand(first) && first != "prompt" {
|
||||
return Err(format!(
|
||||
"invalid_flag_value: --compact is only supported with prompt mode.\nUsage: claw --compact \"<prompt>\" or echo \"<prompt>\" | claw --compact"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
match rest[0].as_str() {
|
||||
"dump-manifests" => parse_dump_manifests_args(&rest[1..], output_format),
|
||||
"bootstrap-plan" => Ok(CliAction::BootstrapPlan { output_format }),
|
||||
@@ -2874,6 +2903,14 @@ fn resolve_model_alias_with_config(model: &str) -> String {
|
||||
/// Rejects: empty, whitespace-only, strings with spaces, or invalid chars.
|
||||
fn validate_model_syntax(model: &str) -> Result<(), String> {
|
||||
let trimmed = model.trim();
|
||||
// Ollama models use names like "qwen3:8b" that don't match provider/model
|
||||
// syntax. Skip strict validation when OLLAMA_HOST is configured.
|
||||
if std::env::var_os("OLLAMA_HOST").is_some() {
|
||||
if trimmed.is_empty() {
|
||||
return Err("invalid model syntax: model string cannot be empty.\nUsage: --model <model-name> e.g. --model qwen3:8b".to_string());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if trimmed.is_empty() {
|
||||
return Err("invalid model syntax: model string cannot be empty.\nUsage: --model <provider/model> e.g. --model anthropic/claude-opus-4-7".to_string());
|
||||
}
|
||||
@@ -3579,7 +3616,7 @@ fn render_doctor_report(
|
||||
config_warning_mode: ConfigWarningMode,
|
||||
permission_mode: PermissionModeProvenance,
|
||||
) -> Result<DoctorReport, Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
let cwd = friendly_cwd(env::current_dir()?);
|
||||
let config_loader = ConfigLoader::default_for(&cwd);
|
||||
let config = load_config_with_warning_mode(&config_loader, config_warning_mode);
|
||||
let discovered_config = config_loader.discover();
|
||||
@@ -5719,6 +5756,31 @@ struct GitWorkspaceSummary {
|
||||
unstaged_files: usize,
|
||||
untracked_files: usize,
|
||||
conflicted_files: usize,
|
||||
/// #89: detected mid-operation git state (rebase, merge, cherry-pick, bisect)
|
||||
operation: GitOperation,
|
||||
}
|
||||
|
||||
/// #89: mid-operation git states detected from branch header in `git status --short --branch`.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
enum GitOperation {
|
||||
#[default]
|
||||
None,
|
||||
Rebase,
|
||||
Merge,
|
||||
CherryPick,
|
||||
Bisect,
|
||||
}
|
||||
|
||||
impl GitOperation {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "",
|
||||
Self::Rebase => "rebase-in-progress",
|
||||
Self::Merge => "merge-in-progress",
|
||||
Self::CherryPick => "cherry-pick-in-progress",
|
||||
Self::Bisect => "bisect-in-progress",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -5754,6 +5816,8 @@ struct SessionLifecycleSummary {
|
||||
pane_path: Option<PathBuf>,
|
||||
workspace_dirty: bool,
|
||||
abandoned: bool,
|
||||
// #326: all panes matching this workspace, not just the first one
|
||||
all_panes: Vec<TmuxPaneSnapshot>,
|
||||
}
|
||||
|
||||
impl SessionLifecycleSummary {
|
||||
@@ -5779,6 +5843,14 @@ impl SessionLifecycleSummary {
|
||||
"pane_path": self.pane_path.as_ref().map(|path| path.display().to_string()),
|
||||
"workspace_dirty": self.workspace_dirty,
|
||||
"abandoned": self.abandoned,
|
||||
// #326: include all workspace panes in the JSON output
|
||||
"panes": self.all_panes.iter().map(|p| {
|
||||
json!({
|
||||
"pane_id": p.pane_id,
|
||||
"pane_command": p.current_command,
|
||||
"pane_path": p.current_path.display().to_string(),
|
||||
})
|
||||
}).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5796,8 +5868,18 @@ impl GitWorkspaceSummary {
|
||||
}
|
||||
|
||||
fn headline(self) -> String {
|
||||
// #89: prefix with operation state when mid-operation
|
||||
let op_prefix = if self.operation != GitOperation::None {
|
||||
format!("{}, ", self.operation.as_str())
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if self.is_clean() {
|
||||
if self.operation != GitOperation::None {
|
||||
format!("{op_prefix}clean")
|
||||
} else {
|
||||
"clean".to_string()
|
||||
}
|
||||
} else {
|
||||
let mut details = Vec::new();
|
||||
if self.staged_files > 0 {
|
||||
@@ -5813,7 +5895,7 @@ impl GitWorkspaceSummary {
|
||||
details.push(format!("{} conflicted", self.conflicted_files));
|
||||
}
|
||||
format!(
|
||||
"dirty · {} files · {}",
|
||||
"{op_prefix}dirty · {} files · {}",
|
||||
self.changed_files,
|
||||
details.join(", ")
|
||||
)
|
||||
@@ -5830,14 +5912,22 @@ fn classify_session_lifecycle_from_panes(
|
||||
panes: Vec<TmuxPaneSnapshot>,
|
||||
) -> SessionLifecycleSummary {
|
||||
let workspace_dirty = git_worktree_is_dirty(workspace);
|
||||
let mut idle_shell = None;
|
||||
let mut idle_shell: Option<TmuxPaneSnapshot> = None;
|
||||
let mut all_workspace_panes: Vec<TmuxPaneSnapshot> = Vec::new();
|
||||
let mut running_pane: Option<TmuxPaneSnapshot> = None;
|
||||
for pane in panes {
|
||||
if !pane_path_matches_workspace(&pane.current_path, workspace) {
|
||||
continue;
|
||||
}
|
||||
all_workspace_panes.push(pane.clone());
|
||||
if is_idle_shell_command(&pane.current_command) {
|
||||
idle_shell.get_or_insert(pane);
|
||||
} else {
|
||||
} else if running_pane.is_none() {
|
||||
running_pane = Some(pane);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pane) = running_pane {
|
||||
return SessionLifecycleSummary {
|
||||
kind: SessionLifecycleKind::RunningProcess,
|
||||
pane_id: Some(pane.pane_id),
|
||||
@@ -5845,9 +5935,9 @@ fn classify_session_lifecycle_from_panes(
|
||||
pane_path: Some(pane.current_path),
|
||||
workspace_dirty,
|
||||
abandoned: false,
|
||||
all_panes: all_workspace_panes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pane) = idle_shell {
|
||||
SessionLifecycleSummary {
|
||||
@@ -5857,6 +5947,7 @@ fn classify_session_lifecycle_from_panes(
|
||||
pane_path: Some(pane.current_path),
|
||||
workspace_dirty,
|
||||
abandoned: workspace_dirty,
|
||||
all_panes: all_workspace_panes,
|
||||
}
|
||||
} else {
|
||||
SessionLifecycleSummary {
|
||||
@@ -5866,6 +5957,7 @@ fn classify_session_lifecycle_from_panes(
|
||||
pane_path: None,
|
||||
workspace_dirty,
|
||||
abandoned: workspace_dirty,
|
||||
all_panes: all_workspace_panes,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6119,7 +6211,26 @@ fn parse_git_workspace_summary(status: Option<&str>) -> GitWorkspaceSummary {
|
||||
};
|
||||
|
||||
for line in status.lines() {
|
||||
if line.starts_with("## ") || line.trim().is_empty() {
|
||||
if line.starts_with("## ") {
|
||||
// #89: detect mid-operation states from branch header
|
||||
// git status --short --branch shows:
|
||||
// "## HEAD (no branch, rebasing feature-branch)"
|
||||
// "## main [merge-in-progress]"
|
||||
// "## HEAD (no branch, cherry-pick-in-progress)"
|
||||
// "## main (no branch, bisect-in-progress)"
|
||||
let header = line.to_ascii_lowercase();
|
||||
if header.contains("rebasing") {
|
||||
summary.operation = GitOperation::Rebase;
|
||||
} else if header.contains("merge-in-progress") {
|
||||
summary.operation = GitOperation::Merge;
|
||||
} else if header.contains("cherry-pick-in-progress") {
|
||||
summary.operation = GitOperation::CherryPick;
|
||||
} else if header.contains("bisect-in-progress") {
|
||||
summary.operation = GitOperation::Bisect;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -6394,14 +6505,16 @@ fn run_resume_command(
|
||||
});
|
||||
}
|
||||
let backup_path = write_session_clear_backup(session, session_path)?;
|
||||
// #114: preserve the session_id from the file to avoid filename/meta-header
|
||||
// divergence. /clear is "empty this session," not "fork to a new session."
|
||||
let previous_session_id = session.session_id.clone();
|
||||
let cleared = new_cli_session()?;
|
||||
let new_session_id = cleared.session_id.clone();
|
||||
let mut cleared = new_cli_session()?;
|
||||
cleared.session_id = previous_session_id.clone();
|
||||
cleared.save_to_path(session_path)?;
|
||||
Ok(ResumeCommandOutcome {
|
||||
session: cleared,
|
||||
message: Some(format!(
|
||||
"Session cleared\n Mode resumed session reset\n Previous session {previous_session_id}\n Backup {}\n Resume previous claw --resume {}\n New session {new_session_id}\n Session file {}",
|
||||
"Session cleared\n Mode resumed session reset\n Previous session {previous_session_id}\n Backup {}\n Resume previous claw --resume {}\n Session file {}",
|
||||
backup_path.display(),
|
||||
backup_path.display(),
|
||||
session_path.display()
|
||||
@@ -6409,7 +6522,7 @@ fn run_resume_command(
|
||||
json: Some(serde_json::json!({
|
||||
"kind": "clear",
|
||||
"previous_session_id": previous_session_id,
|
||||
"new_session_id": new_session_id,
|
||||
"new_session_id": previous_session_id,
|
||||
"backup": backup_path.display().to_string(),
|
||||
"session_file": session_path.display().to_string(),
|
||||
})),
|
||||
@@ -6696,6 +6809,47 @@ fn run_resume_command(
|
||||
SlashCommand::Session { action, target } => {
|
||||
run_resumed_session_command(session_path, session, action.as_deref(), target.as_deref())
|
||||
}
|
||||
// #341: /tasks is resume-supported — return a no-op with structured JSON
|
||||
SlashCommand::Tasks { args } => {
|
||||
let args_str = args.as_deref().unwrap_or_default();
|
||||
Ok(ResumeCommandOutcome {
|
||||
session: session.clone(),
|
||||
message: Some(format!(
|
||||
"Tasks\n Note Background tasks are only available in the interactive REPL.\n Command /tasks {args_str}"
|
||||
)),
|
||||
json: Some(serde_json::json!({
|
||||
"kind": "tasks",
|
||||
"action": "list",
|
||||
"status": "ok",
|
||||
"note": "Background tasks are only available in the interactive REPL.",
|
||||
"args": args_str,
|
||||
})),
|
||||
})
|
||||
}
|
||||
// #343: /model is resume-safe — returns model configuration
|
||||
SlashCommand::Model { model } => {
|
||||
let configured_model = config_model_for_current_dir();
|
||||
let resolved_config_model = configured_model
|
||||
.as_deref()
|
||||
.map(resolve_model_alias_with_config);
|
||||
Ok(ResumeCommandOutcome {
|
||||
session: session.clone(),
|
||||
message: Some(format!(
|
||||
"Models\n Default {}\n Config model {}",
|
||||
DEFAULT_MODEL,
|
||||
configured_model.as_deref().unwrap_or("<unset>")
|
||||
)),
|
||||
json: Some(serde_json::json!({
|
||||
"kind": "models",
|
||||
"action": "list",
|
||||
"status": "ok",
|
||||
"default_model": DEFAULT_MODEL,
|
||||
"configured_model": configured_model,
|
||||
"resolved_model": resolved_config_model,
|
||||
"requested_model": model,
|
||||
})),
|
||||
})
|
||||
}
|
||||
SlashCommand::Bughunter { .. }
|
||||
| SlashCommand::Commit { .. }
|
||||
| SlashCommand::Pr { .. }
|
||||
@@ -6704,7 +6858,6 @@ fn run_resume_command(
|
||||
| SlashCommand::Teleport { .. }
|
||||
| SlashCommand::DebugToolCall { .. }
|
||||
| SlashCommand::Resume { .. }
|
||||
| SlashCommand::Model { .. }
|
||||
| SlashCommand::Permissions { .. }
|
||||
| SlashCommand::Login
|
||||
| SlashCommand::Logout
|
||||
@@ -6728,7 +6881,6 @@ fn run_resume_command(
|
||||
| SlashCommand::PrivacySettings
|
||||
| SlashCommand::Plan { .. }
|
||||
| SlashCommand::Review { .. }
|
||||
| SlashCommand::Tasks { .. }
|
||||
| SlashCommand::Theme { .. }
|
||||
| SlashCommand::Voice { .. }
|
||||
| SlashCommand::Usage { .. }
|
||||
@@ -7630,11 +7782,38 @@ impl LiveCli {
|
||||
// Detect context window overflow. Some providers (e.g. OpenAI-compat backends)
|
||||
// return 400 with "no parseable body" instead of a proper context_length_exceeded
|
||||
// error when the request is too large to even parse — treat that as context overflow too.
|
||||
// Also detect model-specific context error markers (e.g. llama.cpp returns
|
||||
// "Context size has been exceeded." / "exceed_context_size_error" / "exceeds the available context size").
|
||||
let is_context_window = error_str.contains("context_window")
|
||||
|| error_str.contains("Context window")
|
||||
|| error_str.contains("no parseable body");
|
||||
|| error_str.contains("no parseable body")
|
||||
|| error_str.contains("exceed_context_size")
|
||||
|| error_str.contains("exceeds the available context size")
|
||||
|| error_str
|
||||
.to_ascii_lowercase()
|
||||
.contains("context size has been exceeded");
|
||||
|
||||
// Also treat "assistant stream produced no content" and reqwest decode failures
|
||||
// as recoverable errors that may benefit from auto-compaction. Some backends (e.g.
|
||||
// llama.cpp) return a non-SSE HTTP 500 body when context overflows, causing
|
||||
// reqwest to fail with "error decoding response body" — treat that as context overflow too.
|
||||
let is_no_content = error_str.contains("assistant stream produced no content")
|
||||
|| error_str.contains("Failed to parse input at pos")
|
||||
|| error_str.contains("error decoding response body");
|
||||
|
||||
if is_context_window || is_no_content {
|
||||
// If the error tells us the server's actual context window, adapt our
|
||||
// auto-compaction threshold so future auto-compact-trigger checks are accurate.
|
||||
if let Some(window) = extract_context_window_tokens_from_error(&error_str) {
|
||||
// Set threshold at 70% of the reported window to leave headroom.
|
||||
let threshold: u32 = (window as f64 * 0.7).round() as u32;
|
||||
println!(
|
||||
" Server context window: {} tokens — setting auto-compaction threshold to {}",
|
||||
window, threshold
|
||||
);
|
||||
runtime.set_auto_compaction_input_tokens_threshold(threshold);
|
||||
}
|
||||
|
||||
if is_context_window {
|
||||
// A single compaction pass may not free enough context space.
|
||||
// Progressive retry: each round preserves fewer recent messages (4→2→1→0),
|
||||
// trading conversation continuity for a smaller payload until it fits.
|
||||
@@ -7716,9 +7895,29 @@ impl LiveCli {
|
||||
let retry_str = retry_error.to_string();
|
||||
let still_context_window = retry_str.contains("context_window")
|
||||
|| retry_str.contains("Context window")
|
||||
|| retry_str.contains("no parseable body");
|
||||
|| retry_str.contains("no parseable body")
|
||||
|| retry_str.contains("exceed_context_size")
|
||||
|| retry_str.contains("exceeds the available context size")
|
||||
|| retry_str
|
||||
.to_ascii_lowercase()
|
||||
.contains("context size has been exceeded");
|
||||
let still_no_content = retry_str
|
||||
.contains("assistant stream produced no content")
|
||||
|| retry_str.contains("Failed to parse input at pos")
|
||||
|| retry_str.contains("error decoding response body");
|
||||
|
||||
if (still_context_window || still_no_content)
|
||||
&& round + 1 < max_compact_rounds
|
||||
{
|
||||
// If the retry error reveals the context window, adapt threshold.
|
||||
if let Some(window) =
|
||||
extract_context_window_tokens_from_error(&retry_str)
|
||||
{
|
||||
let threshold: u32 = (window as f64 * 0.7).round() as u32;
|
||||
new_runtime
|
||||
.set_auto_compaction_input_tokens_threshold(threshold);
|
||||
}
|
||||
|
||||
if still_context_window && round + 1 < max_compact_rounds {
|
||||
// The compacted session was still too large for the model's context.
|
||||
// Shut down the old runtime, adopt the partially-compacted one,
|
||||
// and loop — the next round will compact more aggressively.
|
||||
@@ -8250,7 +8449,8 @@ impl LiveCli {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let (handle, session) = load_session_reference(&session_ref)?;
|
||||
let (handle, session) =
|
||||
load_session_reference_excluding(&session_ref, Some(&self.session.id))?;
|
||||
let message_count = session.messages.len();
|
||||
let session_id = session.session_id.clone();
|
||||
let runtime = build_runtime(
|
||||
@@ -8946,17 +9146,18 @@ fn latest_managed_session() -> Result<ManagedSessionSummary, Box<dyn std::error:
|
||||
|
||||
fn load_session_reference(
|
||||
reference: &str,
|
||||
) -> Result<(SessionHandle, Session), Box<dyn std::error::Error>> {
|
||||
load_session_reference_excluding(reference, None)
|
||||
}
|
||||
|
||||
fn load_session_reference_excluding(
|
||||
reference: &str,
|
||||
exclude_id: Option<&str>,
|
||||
) -> Result<(SessionHandle, Session), Box<dyn std::error::Error>> {
|
||||
let store = current_session_store()?;
|
||||
// For alias references ("latest", "last", "recent"), allow cross-workspace
|
||||
// resume so /resume latest finds the most recent session globally.
|
||||
// For explicit references, workspace validation is enforced.
|
||||
let result = if runtime::session_control::is_session_reference_alias(reference) {
|
||||
store.load_session_loose(reference)
|
||||
} else {
|
||||
store.load_session(reference)
|
||||
};
|
||||
let loaded = result.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
||||
let loaded = store
|
||||
.load_session_excluding(reference, exclude_id)
|
||||
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
||||
Ok((
|
||||
SessionHandle {
|
||||
id: loaded.handle.id,
|
||||
@@ -9120,7 +9321,23 @@ fn run_resumed_session_command(
|
||||
})),
|
||||
})
|
||||
}
|
||||
Some("switch" | "fork") => Err("unsupported_resumed_command: /session switch and /session fork require an interactive REPL.\nUsage: claw (then /session switch <id>) or claw --resume <session>".into()),
|
||||
// #113: /session switch and /session fork require an interactive REPL —
|
||||
// return structured JSON instead of a raw error so resume callers can
|
||||
// detect the limitation programmatically.
|
||||
Some(switch_or_fork @ ("switch" | "fork")) => Ok(ResumeCommandOutcome {
|
||||
session: session.clone(),
|
||||
message: Some(format!(
|
||||
"/session {switch_or_fork} requires an interactive REPL.\nUsage: claw (then /session {switch_or_fork} <id>)"
|
||||
)),
|
||||
json: Some(serde_json::json!({
|
||||
"kind": "error",
|
||||
"error_kind": "unsupported_resumed_command",
|
||||
"status": "error",
|
||||
"action": switch_or_fork,
|
||||
"error": format!("/session {switch_or_fork} requires an interactive REPL"),
|
||||
"hint": format!("Start a new claw session and use /session {switch_or_fork} <id> interactively"),
|
||||
})),
|
||||
}),
|
||||
Some(other) => Err(format!("unsupported_resumed_command: /session {other} is not supported in resume mode.\nSupported: list, exists, delete").into()),
|
||||
}
|
||||
}
|
||||
@@ -9423,6 +9640,12 @@ fn status_json_value(
|
||||
"changed_files": context.git_summary.changed_files,
|
||||
"is_clean": context.git_summary.changed_files == 0,
|
||||
"staged_files": context.git_summary.staged_files,
|
||||
// #89: mid-operation git state (rebase, merge, cherry-pick, bisect)
|
||||
"git_operation": if context.git_summary.operation != GitOperation::None {
|
||||
Some(context.git_summary.operation.as_str())
|
||||
} else {
|
||||
None::<&str>
|
||||
},
|
||||
|
||||
"unstaged_files": context.git_summary.unstaged_files,
|
||||
"untracked_files": context.git_summary.untracked_files,
|
||||
@@ -9461,10 +9684,25 @@ fn status_json_value(
|
||||
})
|
||||
}
|
||||
|
||||
/// #421: Strip macOS `/private` symlink prefix from paths so that
|
||||
/// `status`, `doctor`, and `mcp list` JSON output matches the
|
||||
/// user-visible invocation cwd instead of the canonicalized path.
|
||||
fn friendly_cwd(path: PathBuf) -> PathBuf {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Ok(stripped) = path.strip_prefix("/private") {
|
||||
if stripped.is_absolute() {
|
||||
return stripped.to_path_buf();
|
||||
}
|
||||
}
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
fn status_context(
|
||||
session_path: Option<&Path>,
|
||||
) -> Result<StatusContext, Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
let cwd = friendly_cwd(env::current_dir()?);
|
||||
let loader = ConfigLoader::default_for(&cwd);
|
||||
// #456: count only paths that exist on disk, matching check_config_health behavior.
|
||||
let discovered_config_files = loader.discover().iter().filter(|e| e.path.exists()).count();
|
||||
@@ -10422,6 +10660,22 @@ fn render_config_report(section: Option<&str>) -> Result<String, Box<dyn std::er
|
||||
"skills" => runtime_config.get("skills").map(|value| value.render()),
|
||||
"agents" => runtime_config.get("agents").map(|value| value.render()),
|
||||
"settings" => Some(runtime_config.as_json().render()),
|
||||
// #344: /config help shows available sections
|
||||
"help" => {
|
||||
lines.push("Available config sections:".to_string());
|
||||
lines.push(" env Environment variables".to_string());
|
||||
lines.push(" hooks Hook configuration".to_string());
|
||||
lines.push(" model Model configuration".to_string());
|
||||
lines.push(" plugins Plugin configuration".to_string());
|
||||
lines.push(" mcp MCP server configuration".to_string());
|
||||
lines.push(" sandbox Sandbox configuration".to_string());
|
||||
lines.push(" permissions Permission rules".to_string());
|
||||
lines.push(" skills Skills configuration".to_string());
|
||||
lines.push(" agents Agent configuration".to_string());
|
||||
lines.push(" settings Full merged settings".to_string());
|
||||
lines.push(format!(" Loaded keys: {}", runtime_config.merged().len()));
|
||||
return Ok(lines.join("\n"));
|
||||
}
|
||||
other => {
|
||||
lines.push(format!(
|
||||
" Unsupported config section '{other}'. Use: env, hooks, model, plugins, mcp, sandbox, permissions, skills, agents, or settings."
|
||||
@@ -10534,10 +10788,21 @@ fn render_config_json(
|
||||
"skills" => runtime_config.get("skills").map(|v| v.render()),
|
||||
"agents" => runtime_config.get("agents").map(|v| v.render()),
|
||||
"settings" => Some(runtime_config.as_json().render()),
|
||||
// #344: /config help returns structured section list
|
||||
"help" => {
|
||||
return Ok(serde_json::json!({
|
||||
"kind": "config",
|
||||
"action": "help",
|
||||
"status": "ok",
|
||||
"section": "help",
|
||||
"available_sections": ["env", "hooks", "model", "plugins", "mcp", "sandbox", "permissions", "skills", "agents", "settings"],
|
||||
"loaded_keys": runtime_config.merged().len(),
|
||||
}));
|
||||
}
|
||||
other => {
|
||||
// #741: populate hint field for unsupported section errors so callers reading
|
||||
// .hint get actionable guidance instead of null
|
||||
let hint = if matches!(other, "list" | "show" | "help" | "info") {
|
||||
let hint = if matches!(other, "list" | "show" | "info") {
|
||||
format!(
|
||||
"'claw config {other}' is not a subcommand. To list all config: `claw config`. To inspect a section: `claw config <section>` where section is one of: env, hooks, model, plugins, mcp, sandbox, permissions, skills, agents, settings."
|
||||
)
|
||||
@@ -12570,6 +12835,64 @@ fn request_ends_with_tool_result(request: &ApiRequest) -> bool {
|
||||
.is_some_and(|message| message.role == MessageRole::Tool)
|
||||
}
|
||||
|
||||
/// Extract the server-reported context window size from an error message.
|
||||
/// Returns `None` if no window size can be parsed. The server must
|
||||
/// mention something like "context size (81920 tokens)" or "available
|
||||
/// context size (81920 tokens)" — the number inside parens after the
|
||||
/// parenthesised phrase is taken as the window.
|
||||
///
|
||||
/// Known formats:
|
||||
/// - "exceeds the available context size (81920 tokens)"
|
||||
/// - "context size (128000 tokens)"
|
||||
/// - "maximum context length is 200000 tokens"
|
||||
fn extract_context_window_tokens_from_error(error_str: &str) -> Option<u32> {
|
||||
// Pattern: "(NNNNNN tokens)" appearing after context-size markers
|
||||
for line in error_str.lines() {
|
||||
let lowered = line.to_ascii_lowercase();
|
||||
if lowered.contains("context size")
|
||||
|| lowered.contains("context length")
|
||||
|| lowered.contains("context window")
|
||||
{
|
||||
// Try parenthesised form: (81920 tokens)
|
||||
if let Some(start) = lowered.find('(') {
|
||||
if let Some(end) = lowered.find(")") {
|
||||
if start < end {
|
||||
let inner = &line[start + 1..end];
|
||||
let digits: String =
|
||||
inner.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
if let Ok(n) = digits.parse::<u32>() {
|
||||
if n > 1000 {
|
||||
return Some(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Try "maximum context length is NNNNNN tokens"
|
||||
if let Some(pos) = lowered.find("is ") {
|
||||
let rest = &line[pos + 3..];
|
||||
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
if let Ok(n) = digits.parse::<u32>() {
|
||||
if n > 1000 {
|
||||
return Some(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Try "configured limit of NNNNNN tokens"
|
||||
if let Some(pos) = lowered.find("of ") {
|
||||
let rest = &line[pos + 3..];
|
||||
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
if let Ok(n) = digits.parse::<u32>() {
|
||||
if n > 1000 {
|
||||
return Some(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn format_user_visible_api_error(session_id: &str, error: &api::ApiError) -> String {
|
||||
if error.is_context_window_failure() {
|
||||
format_context_window_blocked_error(session_id, error)
|
||||
@@ -13860,15 +14183,30 @@ fn print_help(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::
|
||||
let message = String::from_utf8(buffer)?;
|
||||
match output_format {
|
||||
CliOutputFormat::Text => print!("{message}"),
|
||||
CliOutputFormat::Json => println!(
|
||||
CliOutputFormat::Json => {
|
||||
// #325: include structured command list in top-level help JSON
|
||||
let commands: Vec<serde_json::Value> = commands::slash_command_specs()
|
||||
.iter()
|
||||
.map(|spec| {
|
||||
serde_json::json!({
|
||||
"name": spec.name,
|
||||
"summary": spec.summary,
|
||||
"resume_supported": spec.resume_supported,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"kind": "help",
|
||||
"action": "help",
|
||||
"status": "ok",
|
||||
"message": message,
|
||||
"commands": commands,
|
||||
"total_commands": commands.len(),
|
||||
}))?
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -13897,10 +14235,11 @@ mod tests {
|
||||
run_resume_command, short_tool_id, slash_command_completion_candidates_with_sessions,
|
||||
split_error_hint, status_context, status_json_value, summarize_tool_payload_for_markdown,
|
||||
try_resolve_bare_skill_prompt, validate_no_args, write_mcp_server_fixture, CliAction,
|
||||
CliOutputFormat, CliToolExecutor, GitWorkspaceSummary, InternalPromptProgressEvent,
|
||||
InternalPromptProgressState, LiveCli, LocalHelpTopic, PermissionModeProvenance,
|
||||
PromptHistoryEntry, SessionLifecycleKind, SessionLifecycleSummary, SlashCommand,
|
||||
StatusUsage, TmuxPaneSnapshot, DEFAULT_MODEL, LATEST_SESSION_REFERENCE, STUB_COMMANDS,
|
||||
CliOutputFormat, CliToolExecutor, GitOperation, GitWorkspaceSummary,
|
||||
InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, LocalHelpTopic,
|
||||
PermissionModeProvenance, PromptHistoryEntry, SessionLifecycleKind,
|
||||
SessionLifecycleSummary, SlashCommand, StatusUsage, TmuxPaneSnapshot, DEFAULT_MODEL,
|
||||
LATEST_SESSION_REFERENCE, STUB_COMMANDS,
|
||||
};
|
||||
use api::{ApiError, MessageResponse, OutputContentBlock, Usage};
|
||||
use plugins::{
|
||||
@@ -13958,6 +14297,7 @@ mod tests {
|
||||
body: String::new(),
|
||||
retryable: true,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
|
||||
let rendered = format_user_visible_api_error("session-issue-22", &error);
|
||||
@@ -13981,6 +14321,7 @@ mod tests {
|
||||
body: String::new(),
|
||||
retryable: true,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -14045,6 +14386,7 @@ mod tests {
|
||||
body: String::new(),
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
|
||||
let rendered = format_user_visible_api_error("session-issue-32", &error);
|
||||
@@ -14078,6 +14420,7 @@ mod tests {
|
||||
body: String::new(),
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
|
||||
let rendered = format_user_visible_api_error("session-issue-32", &error);
|
||||
@@ -14112,6 +14455,7 @@ mod tests {
|
||||
body: String::new(),
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -14847,7 +15191,7 @@ mod tests {
|
||||
let args = vec![
|
||||
"system-prompt".to_string(),
|
||||
"--cwd".to_string(),
|
||||
"/tmp/project".to_string(),
|
||||
"/tmp".to_string(),
|
||||
"--date".to_string(),
|
||||
"2026-04-01".to_string(),
|
||||
];
|
||||
@@ -14859,7 +15203,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
action,
|
||||
CliAction::PrintSystemPrompt {
|
||||
cwd: PathBuf::from("/tmp/project"),
|
||||
cwd: PathBuf::from("/tmp"),
|
||||
date: "2026-04-01".to_string(),
|
||||
model: DEFAULT_MODEL.to_string(),
|
||||
output_format: CliOutputFormat::Text,
|
||||
@@ -17240,6 +17584,7 @@ mod tests {
|
||||
unstaged_files: 1,
|
||||
untracked_files: 1,
|
||||
conflicted_files: 0,
|
||||
operation: GitOperation::None,
|
||||
},
|
||||
branch_freshness: test_branch_freshness(),
|
||||
stale_base_state: super::BaseCommitState::NoExpectedBase,
|
||||
@@ -17250,6 +17595,7 @@ mod tests {
|
||||
pane_path: Some(PathBuf::from("/tmp/project")),
|
||||
workspace_dirty: true,
|
||||
abandoned: true,
|
||||
all_panes: vec![],
|
||||
},
|
||||
boot_preflight: test_boot_preflight(),
|
||||
sandbox_status: runtime::SandboxStatus::default(),
|
||||
@@ -17404,6 +17750,7 @@ mod tests {
|
||||
pane_path: None,
|
||||
workspace_dirty: false,
|
||||
abandoned: false,
|
||||
all_panes: vec![],
|
||||
},
|
||||
boot_preflight: test_boot_preflight(),
|
||||
sandbox_status: runtime::SandboxStatus::default(),
|
||||
@@ -17457,6 +17804,7 @@ mod tests {
|
||||
pane_path: None,
|
||||
workspace_dirty: false,
|
||||
abandoned: false,
|
||||
all_panes: vec![],
|
||||
},
|
||||
boot_preflight: test_boot_preflight(),
|
||||
sandbox_status: runtime::SandboxStatus::default(),
|
||||
@@ -17502,6 +17850,7 @@ mod tests {
|
||||
pane_path: Some(PathBuf::from("/tmp/project")),
|
||||
workspace_dirty: false,
|
||||
abandoned: false,
|
||||
all_panes: vec![],
|
||||
},
|
||||
boot_preflight: test_boot_preflight(),
|
||||
sandbox_status: runtime::SandboxStatus::default(),
|
||||
@@ -17611,6 +17960,7 @@ mod tests {
|
||||
unstaged_files: 1,
|
||||
untracked_files: 0,
|
||||
conflicted_files: 0,
|
||||
operation: GitOperation::None,
|
||||
};
|
||||
|
||||
let preflight = format_commit_preflight_report(Some("feature/ux"), summary);
|
||||
@@ -17734,6 +18084,7 @@ UU conflicted.rs",
|
||||
unstaged_files: 2,
|
||||
untracked_files: 1,
|
||||
conflicted_files: 1,
|
||||
operation: GitOperation::None,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -18057,16 +18408,26 @@ UU conflicted.rs",
|
||||
std::env::set_current_dir(&workspace).expect("switch cwd");
|
||||
|
||||
let older = create_managed_session_handle("session-older").expect("older handle");
|
||||
Session::new()
|
||||
.with_persistence_path(older.path.clone())
|
||||
{
|
||||
let mut session = Session::new().with_persistence_path(older.path.clone());
|
||||
session
|
||||
.push_user_text("older session message")
|
||||
.expect("older message should save");
|
||||
session
|
||||
.save_to_path(&older.path)
|
||||
.expect("older session should save");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
let newer = create_managed_session_handle("session-newer").expect("newer handle");
|
||||
Session::new()
|
||||
.with_persistence_path(newer.path.clone())
|
||||
{
|
||||
let mut session = Session::new().with_persistence_path(newer.path.clone());
|
||||
session
|
||||
.push_user_text("newer session message")
|
||||
.expect("newer message should save");
|
||||
session
|
||||
.save_to_path(&newer.path)
|
||||
.expect("newer session should save");
|
||||
}
|
||||
|
||||
let resolved = resolve_session_reference("latest").expect("latest session should resolve");
|
||||
assert_eq!(
|
||||
@@ -19336,4 +19697,16 @@ mod alias_resolution_tests {
|
||||
assert_eq!(resolve_model_alias_with_config(model), model);
|
||||
assert!(validate_model_syntax(model).is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn test_ollama_host_bypasses_model_validation() {
|
||||
// Safety: test sets and clears env var within the test.
|
||||
std::env::set_var("OLLAMA_HOST", "http://127.0.0.1:11434");
|
||||
// Ollama model names with colons pass
|
||||
assert!(validate_model_syntax("qwen3:8b").is_ok());
|
||||
assert!(validate_model_syntax("gemma4:e2b").is_ok());
|
||||
assert!(validate_model_syntax("qwen3.6:27b-nvfp4").is_ok());
|
||||
// Empty model still rejected
|
||||
assert!(validate_model_syntax("").is_err());
|
||||
std::env::remove_var("OLLAMA_HOST");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3365,7 +3365,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", "help"] {
|
||||
for section in &["list", "show", "bogus"] {
|
||||
let output = Command::new(bin)
|
||||
.current_dir(&root)
|
||||
.args(["--output-format", "json", "config", section])
|
||||
@@ -3403,6 +3403,36 @@ 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.
|
||||
|
||||
@@ -2701,6 +2701,20 @@ 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() {
|
||||
@@ -2718,6 +2732,26 @@ 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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user