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