mirror of
https://github.com/instructkr/claude-code.git
synced 2026-06-05 12:06:43 +00:00
Compare commits
147 Commits
fix/direct
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eaa2e320d9 | ||
|
|
be8112f5f5 | ||
|
|
503d515f38 | ||
|
|
114c2da9fc | ||
|
|
b90f18f75a | ||
|
|
5b15197117 | ||
|
|
61e8ad9a8e | ||
|
|
ef0d0c30a4 | ||
|
|
7305e5509a | ||
|
|
2f0b5b36eb | ||
|
|
c848eeb768 | ||
|
|
d4aad7103e | ||
|
|
b60cbebb3a | ||
|
|
7c4bcd92b6 | ||
|
|
f8822aabdb | ||
|
|
aca6584fd5 | ||
|
|
b8cbb1834f | ||
|
|
cb027adf65 | ||
|
|
5bdffbe161 | ||
|
|
04f18862a8 | ||
|
|
f0e10ff182 | ||
|
|
0cab03df75 | ||
|
|
eb86f4d2c3 | ||
|
|
6c3d7be370 | ||
|
|
e3ffaefba1 | ||
|
|
cd18cf5385 | ||
|
|
2f3120e70a | ||
|
|
f1a16398fe | ||
|
|
8d50276366 | ||
|
|
13992ade54 | ||
|
|
b04b1d6ac8 | ||
|
|
934bf2837a | ||
|
|
b94c49c323 | ||
|
|
5df485cba9 | ||
|
|
c8e973513c | ||
|
|
8f9315bdc9 | ||
|
|
12a091afe6 | ||
|
|
9f0cf3b888 | ||
|
|
6e94c1a97f | ||
|
|
38626cad20 | ||
|
|
e84f7c8034 | ||
|
|
b8f066347b | ||
|
|
5adc751053 | ||
|
|
adf5bd165e | ||
|
|
b220366176 | ||
|
|
b5d67ef249 | ||
|
|
9a40568e1c | ||
|
|
41b3566468 | ||
|
|
b86159cbb7 | ||
|
|
5e5b9966c0 | ||
|
|
33f771f3f5 | ||
|
|
3fbfcc40ca | ||
|
|
c7c5c11d1e | ||
|
|
c0447e2be1 | ||
|
|
1b22ed700a | ||
|
|
76f9a134ae | ||
|
|
9c11325e83 | ||
|
|
4708ab1611 | ||
|
|
311e719e5d | ||
|
|
b926a9d25f | ||
|
|
61d641d722 | ||
|
|
2f8679bd15 | ||
|
|
9ef21e23f3 | ||
|
|
6ac0386094 | ||
|
|
4c939c0ad6 | ||
|
|
4d41ab37e1 | ||
|
|
662a50bdc0 | ||
|
|
1da4aa454f | ||
|
|
3f50f33407 | ||
|
|
0ce4168c93 | ||
|
|
7f1dd0c116 | ||
|
|
42f56e7f77 | ||
|
|
f25fae6d22 | ||
|
|
be66d961bf | ||
|
|
b1a40a2364 | ||
|
|
726d55d1ea | ||
|
|
e4b8f9c07f | ||
|
|
b3a5a74237 | ||
|
|
ad76389b31 | ||
|
|
e68733d72e | ||
|
|
4d4d72cd49 | ||
|
|
9bc2f3631d | ||
|
|
f40927ba97 | ||
|
|
5bcbc2f874 | ||
|
|
a671969688 | ||
|
|
6757ebde74 | ||
|
|
2447273d09 | ||
|
|
5a76ecb760 | ||
|
|
db56498460 | ||
|
|
6fcd0c57ae | ||
|
|
de66bfc082 | ||
|
|
5d85739358 | ||
|
|
8fd11e82c4 | ||
|
|
9f9b14a76d | ||
|
|
78c2a49ec8 | ||
|
|
0e54ec4c04 | ||
|
|
58a30f6ab8 | ||
|
|
453d8945bb | ||
|
|
9e50cb6e20 | ||
|
|
346772a8b3 | ||
|
|
ef392e5938 | ||
|
|
4619375c14 | ||
|
|
10fe72498a | ||
|
|
5b22bc0480 | ||
|
|
ae7da0ec74 | ||
|
|
7dd17c6344 | ||
|
|
d8535bf938 | ||
|
|
b45c61eff9 | ||
|
|
7cfd83f66a | ||
|
|
b5bead9028 | ||
|
|
41678eb097 | ||
|
|
ecd3e4ceb9 | ||
|
|
22fdaeae2c | ||
|
|
41034bb3f3 | ||
|
|
76783377ec | ||
|
|
4522490bd5 | ||
|
|
cd58c054ca | ||
|
|
94579eace5 | ||
|
|
2ab2f44e1d | ||
|
|
fa35018769 | ||
|
|
94be902ce1 | ||
|
|
bcc5bfde9c | ||
|
|
9522674c87 | ||
|
|
c91a3062d5 | ||
|
|
54d785d0c0 | ||
|
|
36218ac1b1 | ||
|
|
6388a2ba3f | ||
|
|
9c8375da99 | ||
|
|
0cef5390f7 | ||
|
|
1bd18be372 | ||
|
|
d07664b44c | ||
|
|
ce116d9dfa | ||
|
|
372ec09c47 | ||
|
|
78f446f68e | ||
|
|
55da189315 | ||
|
|
e752b05425 | ||
|
|
0c83a26dc7 | ||
|
|
286638fa04 | ||
|
|
47d6c3d5d3 | ||
|
|
f529fb0e55 | ||
|
|
e459a727e9 | ||
|
|
04bc5f5788 | ||
|
|
571d3cdc0f | ||
|
|
414a1aca4f | ||
|
|
d8c57ed317 | ||
|
|
e8c8ef1142 | ||
|
|
1d516be779 |
2
.github/hooks/pre-push
vendored
2
.github/hooks/pre-push
vendored
@@ -13,7 +13,7 @@ cd "$repo_root"
|
|||||||
|
|
||||||
if [[ -x scripts/roadmap-check-ids.sh ]]; then
|
if [[ -x scripts/roadmap-check-ids.sh ]]; then
|
||||||
echo "pre-push: scripts/roadmap-check-ids.sh" >&2
|
echo "pre-push: scripts/roadmap-check-ids.sh" >&2
|
||||||
scripts/roadmap-check-ids.sh
|
scripts/roadmap-check-ids.sh >&2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "${SKIP_CLAW_PRE_PUSH_BUILD:-}" == "1" ]]; then
|
if [[ "${SKIP_CLAW_PRE_PUSH_BUILD:-}" == "1" ]]; then
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,6 +8,7 @@ archive/
|
|||||||
# Claw Code local artifacts
|
# Claw Code local artifacts
|
||||||
.claw/settings.local.json
|
.claw/settings.local.json
|
||||||
.claw/sessions/
|
.claw/sessions/
|
||||||
|
.claw/rules.local/
|
||||||
.clawhip/
|
.clawhip/
|
||||||
status-help.txt
|
status-help.txt
|
||||||
# Legacy Python port session scratch artifacts
|
# Legacy Python port session scratch artifacts
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ Claw Code is built in the open alongside the broader UltraWorkers toolchain:
|
|||||||
- [oh-my-openagent](https://github.com/code-yeongyu/oh-my-openagent)
|
- [oh-my-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
|
||||||
|
|||||||
882
ROADMAP.md
882
ROADMAP.md
File diff suppressed because one or more lines are too long
156
USAGE.md
156
USAGE.md
@@ -51,26 +51,27 @@ cd rust
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Note:** Diagnostic verbs (`doctor`, `status`, `sandbox`, `version`) support `--output-format json` for machine-readable output. Invalid suffix arguments (e.g., `--json`) are now rejected at parse time rather than falling through to prompt dispatch.
|
**Note:** Diagnostic verbs (`doctor`, `status`, `sandbox`, `version`) support `--output-format json` for machine-readable output. Invalid suffix arguments (e.g., `--json`) are now rejected at parse time rather than falling through to prompt dispatch.
|
||||||
|
`version --output-format json` reports structured build provenance including full `git_sha`, derived `git_sha_short`, `is_dirty`, `branch`, `commit_date`, `commit_timestamp`, `rustc_version`, runtime `executable_path`, and `binary_provenance`; JSON keeps the prose report in `human_readable` instead of duplicating it under `message`. `status --output-format json` exposes `workspace.memory_files[]` with `path`, `source`, `origin`, `scope_path`, `outside_project`, `chars`, and `contributes` for every loaded project memory file.
|
||||||
|
|
||||||
### Initialize a repository
|
### Initialize a repository
|
||||||
|
|
||||||
Set up a new repository with `.claw` config, `.claw.json`, `.gitignore` entries, and a `CLAUDE.md` guidance file:
|
Set up a new repository with `.claw/settings.json`, `.claw.json`, `.gitignore` entries, and a `CLAUDE.md` guidance file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /path/to/your/repo
|
cd /path/to/your/repo
|
||||||
./target/debug/claw init
|
./target/debug/claw init
|
||||||
```
|
```
|
||||||
|
|
||||||
Text mode (human-readable) shows artifact creation summary with project path and next steps. Idempotent — running multiple times in the same repo marks already-created files as "skipped".
|
Text mode (human-readable) shows artifact creation summary with project path and next steps. Idempotent — running multiple times in the same repo marks already-created files as "skipped", reports `.claw/` as "partial" when missing sub-files are materialized, and keeps `.claw/sessions/` deferred until the first successful session save.
|
||||||
|
|
||||||
JSON mode for scripting:
|
JSON mode for scripting:
|
||||||
```bash
|
```bash
|
||||||
./target/debug/claw init --output-format json
|
./target/debug/claw init --output-format json
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns structured output with `project_path`, `created[]`, `updated[]`, `skipped[]` arrays (one per artifact), and `artifacts[]` carrying each file's `name` and machine-stable `status` tag. The legacy `message` field preserves backward compatibility.
|
Returns structured output with `project_path`, `created[]`, `updated[]`, `partial[]`, `deferred[]`, and `skipped[]` arrays (one per artifact status), and `artifacts[]` carrying each file's `name` and machine-stable `status` tag. The legacy `message` field preserves backward compatibility.
|
||||||
|
|
||||||
**Why structured fields matter:** Claws can detect per-artifact state (`created` vs `updated` vs `skipped`) without substring-matching human prose. Use the `created[]`, `updated[]`, and `skipped[]` arrays for conditional follow-up logic (e.g., only commit if files were actually created, not just updated).
|
**Why structured fields matter:** Claws can detect per-artifact state (`created`, `updated`, `partial`, `deferred`, or `skipped`) without substring-matching human prose. Use the status arrays for conditional follow-up logic (e.g., only commit if files were actually created, not just updated).
|
||||||
|
|
||||||
### Interactive REPL
|
### Interactive REPL
|
||||||
|
|
||||||
@@ -86,6 +87,12 @@ cd rust
|
|||||||
./target/debug/claw prompt "summarize this repository"
|
./target/debug/claw prompt "summarize this repository"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Pipe prompt text through stdin when automation already produces the prompt body:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
printf 'summarize this repository\n' | ./target/debug/claw prompt --output-format json
|
||||||
|
```
|
||||||
|
|
||||||
### Shorthand prompt mode
|
### Shorthand prompt mode
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -93,6 +100,12 @@ cd rust
|
|||||||
./target/debug/claw "explain rust/crates/runtime/src/lib.rs"
|
./target/debug/claw "explain rust/crates/runtime/src/lib.rs"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Use the POSIX `--` end-of-flags separator when the shorthand prompt itself begins with `-` or `--`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./target/debug/claw -- "-summarize this dash-prefixed text"
|
||||||
|
```
|
||||||
|
|
||||||
### JSON output for scripting
|
### JSON output for scripting
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -187,17 +200,24 @@ cd rust
|
|||||||
./target/debug/claw --permission-mode read-only prompt "summarize Cargo.toml"
|
./target/debug/claw --permission-mode read-only prompt "summarize Cargo.toml"
|
||||||
./target/debug/claw --permission-mode workspace-write prompt "update README.md"
|
./target/debug/claw --permission-mode workspace-write prompt "update README.md"
|
||||||
./target/debug/claw --allowedTools read,glob "inspect the runtime crate"
|
./target/debug/claw --allowedTools read,glob "inspect the runtime crate"
|
||||||
|
./target/debug/claw --cwd ../other-workspace status --output-format json
|
||||||
```
|
```
|
||||||
|
|
||||||
Supported permission modes:
|
Global workspace override flags: `--cwd PATH`, `-C PATH`, and `--directory PATH` are accepted before any subcommand. They are validated before command dispatch and take precedence over the process `$PWD`; invalid paths return typed `invalid_cwd` JSON errors in JSON mode.
|
||||||
|
|
||||||
- `read-only`
|
`--allowedTools` accepts canonical snake_case tool names (for example `read_file`, `glob_search`, `web_fetch`) plus documented aliases such as `read`, `glob`, `Read`, and `WebFetch`. `claw status --output-format json` exposes `allowed_tools.available` and `allowed_tools.aliases`, and invalid values return typed `invalid_tool_name` JSON with `tool_name`, `available`, and `tool_aliases`. A missing value before a subcommand or another flag returns `missing_argument` with `argument:"--allowedTools"`.
|
||||||
- `workspace-write`
|
|
||||||
- `danger-full-access`
|
`--output-format` accepts `text` or `json` case-insensitively and normalizes to the canonical lowercase modes. `CLAW_OUTPUT_FORMAT=json` sets the default output format for scripts, while an explicit `--output-format` flag takes precedence. Repeating the flag emits a stderr warning and JSON status envelopes expose `format_source`, `format_raw`, and `format_overridden` so composed flag arrays are auditable; invalid values return typed `invalid_output_format` JSON with `value` and `expected:["text","json"]`.
|
||||||
|
|
||||||
|
Supported permission modes (default: `workspace-write`):
|
||||||
|
|
||||||
|
- `read-only` allows inspection-only local tools such as file reads, glob/grep searches, local skills, and status-style reporting. It does not allow workspace mutation, network-fetch/search tools, or arbitrary command execution.
|
||||||
|
- `workspace-write` is the safe default. It allows reads plus direct file-editing tools inside the current workspace, including write/edit/notebook/config/plan-mode updates, while still gating network-fetch/search tools, arbitrary shell execution, subagent launches, REPL subprocesses, and other full-access tools behind an explicit escalation.
|
||||||
|
- `danger-full-access` allows every registered tool requirement, including arbitrary command execution, web fetch/search, subagent launches, subprocess REPLs, and unrestricted tool access. Select it only with an explicit `--permission-mode danger-full-access`, `--dangerously-skip-permissions`, `--skip-permissions`, env, or config opt-in.
|
||||||
|
|
||||||
Model aliases currently supported by the CLI:
|
Model aliases currently supported by the CLI:
|
||||||
|
|
||||||
- `opus` → `claude-opus-4-6`
|
- `opus` → `claude-opus-4-7`
|
||||||
- `sonnet` → `claude-sonnet-4-6`
|
- `sonnet` → `claude-sonnet-4-6`
|
||||||
- `haiku` → `claude-haiku-4-5-20251213`
|
- `haiku` → `claude-haiku-4-5-20251213`
|
||||||
|
|
||||||
@@ -225,6 +245,7 @@ export ANTHROPIC_AUTH_TOKEN="anthropic-oauth-or-proxy-bearer-token"
|
|||||||
| `sk-ant-*` API key | `ANTHROPIC_API_KEY` | `x-api-key: sk-ant-...` | [console.anthropic.com](https://console.anthropic.com) |
|
| `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.
|
||||||
|
|
||||||
@@ -285,13 +306,25 @@ cd rust
|
|||||||
### Ollama
|
### Ollama
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export OPENAI_BASE_URL="http://127.0.0.1:11434/v1"
|
export OLLAMA_HOST="http://127.0.0.1:11434"
|
||||||
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`), both approaches work:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export OLLAMA_HOST="http://127.0.0.1:11434"
|
||||||
|
|
||||||
|
cd rust
|
||||||
|
./target/debug/claw --model "qwen2.5-coder:7b" prompt "reply with ready"
|
||||||
|
```
|
||||||
|
|
||||||
|
If the local server exposes a slash-containing model ID, prefix it with `local/` so Claw selects the OpenAI-compatible transport while sending the remainder verbatim on the wire: `--model "local/Qwen/Qwen3.6-27B-FP8"`.
|
||||||
|
|
||||||
### OpenRouter
|
### OpenRouter
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -334,7 +367,7 @@ Reasoning variants (`qwen-qwq-*`, `qwq-*`, `*-thinking`) automatically strip `te
|
|||||||
|
|
||||||
The OpenAI-compatible backend also serves as the gateway for **OpenRouter**, **Ollama**, and any other service that speaks the OpenAI `/v1/chat/completions` wire format — just point `OPENAI_BASE_URL` at the service.
|
The OpenAI-compatible backend also serves as the gateway for **OpenRouter**, **Ollama**, and any other service that speaks the OpenAI `/v1/chat/completions` wire format — just point `OPENAI_BASE_URL` at the service.
|
||||||
|
|
||||||
**Model-name prefix routing:** If a model name starts with `openai/`, `gpt-`, `qwen/`, `qwen-`, `kimi/`, or `kimi-`, the provider is selected by the prefix regardless of which env vars are set. This prevents accidental misrouting to Anthropic when multiple credentials exist in the environment. For the default OpenAI API, `openai/` is a routing prefix and is stripped before the request hits the wire. For a custom `OPENAI_BASE_URL`, slash-containing OpenAI-compatible slugs (for example OpenRouter-style `openai/gpt-4.1-mini`) are preserved so the gateway receives the model ID it expects.
|
**Model-name prefix routing:** If a model name starts with `openai/`, `local/`, `gpt-`, `qwen/`, `qwen-`, `kimi/`, or `kimi-`, the provider is selected by the prefix regardless of which env vars are set. This prevents accidental misrouting to Anthropic when multiple credentials exist in the environment. For the default OpenAI API and local/private OpenAI-compatible endpoints, `openai/` is a routing prefix and is stripped before the request hits the wire. For non-local custom `OPENAI_BASE_URL` gateways, slash-containing OpenAI-compatible slugs (for example OpenRouter-style `openai/gpt-4.1-mini`) are preserved so the gateway receives the model ID it expects. The `local/` prefix is an explicit escape hatch for local slash-containing model IDs: it is stripped while the rest of the model ID is sent verbatim.
|
||||||
|
|
||||||
### Tested models and aliases
|
### Tested models and aliases
|
||||||
|
|
||||||
@@ -342,17 +375,19 @@ These are the models registered in the built-in alias table with known token lim
|
|||||||
|
|
||||||
| Alias | Resolved model name | Provider | Max output tokens | Context window |
|
| Alias | Resolved model name | Provider | Max output tokens | Context window |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `opus` | `claude-opus-4-6` | Anthropic | 32 000 | 200 000 |
|
| `opus` | `claude-opus-4-7` | Anthropic | 32 000 | 200 000 |
|
||||||
| `sonnet` | `claude-sonnet-4-6` | Anthropic | 64 000 | 200 000 |
|
| `sonnet` | `claude-sonnet-4-6` | Anthropic | 64 000 | 200 000 |
|
||||||
| `haiku` | `claude-haiku-4-5-20251213` | Anthropic | 64 000 | 200 000 |
|
| `haiku` | `claude-haiku-4-5-20251213` | Anthropic | 64 000 | 200 000 |
|
||||||
| `grok` / `grok-3` | `grok-3` | xAI | 64 000 | 131 072 |
|
| `grok` / `grok-3` | `grok-3` | xAI | 64 000 | 131 072 |
|
||||||
| `grok-mini` / `grok-3-mini` | `grok-3-mini` | xAI | 64 000 | 131 072 |
|
| `grok-mini` / `grok-3-mini` | `grok-3-mini` | xAI | 64 000 | 131 072 |
|
||||||
| `grok-2` | `grok-2` | xAI | — | — |
|
| `grok-2` | `grok-2` | xAI | — | — |
|
||||||
| `kimi` | `kimi-k2.5` | DashScope | 16 384 | 256 000 |
|
| `kimi` | `kimi-k2.5` | DashScope | 16 384 | 256 000 |
|
||||||
|
| `qwen-max` | `qwen-max` | DashScope | 8 192 | 131 072 |
|
||||||
|
| `qwen-plus` | `qwen-plus` | DashScope | 8 192 | 131 072 |
|
||||||
| `gpt-4.1` / `gpt-4.1-mini` / `gpt-4.1-nano` | same | OpenAI-compatible | 32 768 | 1 047 576 |
|
| `gpt-4.1` / `gpt-4.1-mini` / `gpt-4.1-nano` | same | OpenAI-compatible | 32 768 | 1 047 576 |
|
||||||
| `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.4-nano` | same | OpenAI-compatible | 128 000 | 1 000 000 / 400 000 |
|
| `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.4-nano` | same | OpenAI-compatible | 128 000 | 1 000 000 / 400 000 |
|
||||||
|
|
||||||
Any model name that does not match an alias is passed through verbatim after provider routing is resolved. This is how you use OpenRouter model slugs (`openai/gpt-4.1-mini` with a custom `OPENAI_BASE_URL`), Ollama tags (`llama3.2`), or full Anthropic model IDs (`claude-sonnet-4-20250514`).
|
Any model name that does not match an alias is passed through verbatim after provider routing is resolved. This is how you use OpenRouter model slugs (`openai/gpt-4.1-mini` with a custom `OPENAI_BASE_URL`), Ollama tags (`llama3.2` or `qwen2.5-coder:7b`), slash-containing local IDs (`local/Qwen/Qwen3.6-27B-FP8`), or full Anthropic model IDs (`claude-sonnet-4-20250514`).
|
||||||
|
|
||||||
### User-defined aliases
|
### User-defined aliases
|
||||||
|
|
||||||
@@ -362,7 +397,7 @@ You can add custom aliases in any settings file (`~/.claw/settings.json`, `.claw
|
|||||||
{
|
{
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"fast": "claude-haiku-4-5-20251213",
|
"fast": "claude-haiku-4-5-20251213",
|
||||||
"smart": "claude-opus-4-6",
|
"smart": "claude-opus-4-7",
|
||||||
"cheap": "grok-3-mini"
|
"cheap": "grok-3-mini"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -370,13 +405,15 @@ You can add custom aliases in any settings file (`~/.claw/settings.json`, `.claw
|
|||||||
|
|
||||||
Local project settings override user-level settings. Aliases resolve through the built-in table, so `"fast": "haiku"` also works.
|
Local project settings override user-level settings. Aliases resolve through the built-in table, so `"fast": "haiku"` also works.
|
||||||
|
|
||||||
|
Model selection precedence is CLI flag, environment, config, then default. The environment model slot accepts `CLAW_MODEL`, `ANTHROPIC_MODEL`, and `ANTHROPIC_DEFAULT_MODEL` in that order; aliases from those variables are resolved and validated before provider startup. `claw --output-format json status` exposes `model_raw`, `model_alias_resolved_to`, and `model_env_var` so automation can see the winning value.
|
||||||
|
|
||||||
### How provider detection works
|
### How provider detection works
|
||||||
|
|
||||||
1. If the resolved model name starts with `claude` → Anthropic.
|
1. If the resolved model name starts with `claude` → Anthropic.
|
||||||
2. If it starts with `grok` → xAI.
|
2. If it starts with `grok` → xAI.
|
||||||
3. If it starts with `openai/` or `gpt-` → OpenAI-compatible.
|
3. If it starts with `openai/`, `local/`, or `gpt-` → OpenAI-compatible.
|
||||||
4. If it starts with `qwen/`, `qwen-`, `kimi/`, or `kimi-` → DashScope-compatible OpenAI wire format.
|
4. If it starts with `qwen/`, `qwen-`, `kimi/`, or `kimi-` → DashScope-compatible OpenAI wire format.
|
||||||
5. If `OPENAI_BASE_URL` and `OPENAI_API_KEY` are set, unknown model names route to the OpenAI-compatible client for local/gateway servers.
|
5. If `OPENAI_BASE_URL` is set, local-looking unknown model names such as `llama3.2` or `qwen2.5-coder:7b` route to the OpenAI-compatible client for local/gateway servers.
|
||||||
6. Otherwise, `claw` checks which credential is set: Anthropic first, then OpenAI, then xAI. If only `OPENAI_BASE_URL` is set, it still routes to OpenAI-compatible for authless local servers.
|
6. Otherwise, `claw` checks which credential is set: Anthropic first, then OpenAI, then xAI. If only `OPENAI_BASE_URL` is set, it still routes to OpenAI-compatible for authless local servers.
|
||||||
7. If nothing matches, it defaults to Anthropic.
|
7. If nothing matches, it defaults to Anthropic.
|
||||||
|
|
||||||
@@ -417,6 +454,9 @@ The name "codex" appears in the Claw Code ecosystem but it does **not** refer to
|
|||||||
export HTTPS_PROXY="http://proxy.corp.example:3128"
|
export HTTPS_PROXY="http://proxy.corp.example:3128"
|
||||||
export HTTP_PROXY="http://proxy.corp.example:3128"
|
export HTTP_PROXY="http://proxy.corp.example:3128"
|
||||||
export NO_PROXY="localhost,127.0.0.1,.corp.example"
|
export NO_PROXY="localhost,127.0.0.1,.corp.example"
|
||||||
|
export CLAW_OUTPUT_FORMAT="json" # default non-interactive output format; flags override it
|
||||||
|
export CLAW_LOG="debug" # claw-specific log level selector surfaced by help/doctor
|
||||||
|
export RUST_LOG="claw=debug" # Rust logging convention surfaced by help/doctor
|
||||||
|
|
||||||
cd rust
|
cd rust
|
||||||
./target/debug/claw prompt "hello via the corporate proxy"
|
./target/debug/claw prompt "hello via the corporate proxy"
|
||||||
@@ -452,11 +492,12 @@ let client = build_http_client_with(&config).expect("proxy client");
|
|||||||
|
|
||||||
## Skills
|
## Skills
|
||||||
|
|
||||||
Use `/skills list` in the interactive REPL or `claw skills --output-format json` from the direct CLI to inspect installed skills. For offline/local installs, install the directory that contains `SKILL.md`, then verify the discovered name before invoking it:
|
Use `/skills list` in the interactive REPL or `claw skills --output-format json` from the direct CLI to inspect installed skills. For offline/local installs, install the directory that contains `SKILL.md`, then verify the discovered name before invoking it. `skills install`, `skills uninstall`, and `agents create` are local filesystem lifecycle commands; they do not require provider credentials.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/skills install /absolute/path/to/my-skill
|
/skills install /absolute/path/to/my-skill
|
||||||
/skills list
|
/skills list
|
||||||
|
/skills uninstall my-skill
|
||||||
/skills my-skill
|
/skills my-skill
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -469,6 +510,7 @@ cd rust
|
|||||||
./target/debug/claw status
|
./target/debug/claw status
|
||||||
./target/debug/claw sandbox
|
./target/debug/claw sandbox
|
||||||
./target/debug/claw agents
|
./target/debug/claw agents
|
||||||
|
./target/debug/claw agents create my-agent
|
||||||
./target/debug/claw mcp
|
./target/debug/claw mcp
|
||||||
./target/debug/claw skills
|
./target/debug/claw skills
|
||||||
./target/debug/claw system-prompt --cwd .. --date 2026-04-04
|
./target/debug/claw system-prompt --cwd .. --date 2026-04-04
|
||||||
@@ -488,6 +530,7 @@ git clone https://github.com/Xquik-dev/tweetclaw
|
|||||||
cd claw-code/rust
|
cd claw-code/rust
|
||||||
./target/debug/claw skills install ../../tweetclaw/skills/tweetclaw
|
./target/debug/claw skills install ../../tweetclaw/skills/tweetclaw
|
||||||
./target/debug/claw skills show tweetclaw
|
./target/debug/claw skills show tweetclaw
|
||||||
|
./target/debug/claw skills uninstall tweetclaw
|
||||||
```
|
```
|
||||||
|
|
||||||
TweetClaw gives `claw` users a local skill guide for OpenClaw/Xquik workflows
|
TweetClaw gives `claw` users a local skill guide for OpenClaw/Xquik workflows
|
||||||
@@ -495,6 +538,15 @@ such as tweet search, reply search, follower export, monitors, webhooks, and
|
|||||||
approval-gated posting. Configure any Xquik credentials outside the prompt and
|
approval-gated posting. Configure any Xquik credentials outside the prompt and
|
||||||
avoid pasting API keys into chat.
|
avoid pasting API keys into chat.
|
||||||
|
|
||||||
|
## Author a local agent
|
||||||
|
|
||||||
|
`claw agents create <name>` scaffolds a local `.claw/agents/<name>.toml` file for the current workspace. The scaffold is intentionally small so you can edit the description, model, and reasoning effort before listing or invoking agents:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./target/debug/claw agents create release-checker
|
||||||
|
./target/debug/claw agents list
|
||||||
|
```
|
||||||
|
|
||||||
## Session management
|
## Session management
|
||||||
|
|
||||||
REPL turns are persisted under `.claw/sessions/` in the current workspace.
|
REPL turns are persisted under `.claw/sessions/` in the current workspace.
|
||||||
@@ -517,6 +569,74 @@ Runtime config is loaded in this order, with later entries overriding earlier on
|
|||||||
4. `<repo>/.claw/settings.json`
|
4. `<repo>/.claw/settings.json`
|
||||||
5. `<repo>/.claw/settings.local.json`
|
5. `<repo>/.claw/settings.local.json`
|
||||||
|
|
||||||
|
The list is also the precedence chain: project-local settings override project settings, project settings override the legacy project `.claw.json`, and project files override user files. `claw --output-format json config` includes each discovered file's `precedence_rank`, `wins_for_keys`, and `shadowed_keys` so automation can see which file controls each effective key without reimplementing the merge order.
|
||||||
|
|
||||||
|
## MCP server validation
|
||||||
|
|
||||||
|
`claw mcp --output-format json` loads valid `mcpServers` entries even when sibling entries are malformed. The JSON list envelope distinguishes the total configured entries from the valid and invalid subsets:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"configured_servers": 1,
|
||||||
|
"total_configured": 2,
|
||||||
|
"valid_count": 1,
|
||||||
|
"invalid_count": 1,
|
||||||
|
"servers": [{ "name": "valid-server", "valid": true }],
|
||||||
|
"invalid_servers": [
|
||||||
|
{
|
||||||
|
"name": "missing-command",
|
||||||
|
"error_field": "command",
|
||||||
|
"reason": ".claw.json: mcpServers.missing-command: missing string field command",
|
||||||
|
"valid": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`status --output-format json` mirrors this under `mcp_validation`, and `doctor --output-format json` includes an `mcp validation` check so automation can repair every rejected server entry without losing usable MCP servers.
|
||||||
|
|
||||||
|
## Hook configuration
|
||||||
|
|
||||||
|
`hooks.PreToolUse`, `hooks.PostToolUse`, and `hooks.PostToolUseFailure` accept either legacy command strings or object-style entries with a `matcher` and nested command hooks:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [
|
||||||
|
"echo legacy hook",
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{ "type": "command", "command": "scripts/audit-bash.sh" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
In addition to root instruction files such as `CLAUDE.md`, `CLAW.md`, `AGENTS.md`, `.claw/CLAUDE.md`, `.claude/CLAUDE.md`, and `.claw/instructions.md`, `claw` loads sorted Markdown/text rule files from:
|
||||||
|
|
||||||
|
- `<repo>/.claw/rules/` (`.md`, `.txt`, `.mdc`) for shared project rules.
|
||||||
|
- `<repo>/.claw/rules.local/` for personal local rules; this path is gitignored.
|
||||||
|
|
||||||
|
Root instruction-file priority is `CLAUDE.md`, then `CLAW.md`, then `AGENTS.md` for each discovered directory. Discovery is bounded to the current git root when one exists, otherwise to the current directory only, so stale parent files outside the project do not silently bleed into the prompt. All loaded files contribute to the system prompt and to `status --output-format json` as `workspace.memory_files:[{path, source, origin, scope_path, outside_project, chars, contributes}]`; `claw doctor --output-format json` includes a `memory` check so automation can detect loaded and unexpected unloaded memory-file candidates without parsing prompt text.
|
||||||
|
|
||||||
|
By default, `claw` also imports detected rules from common AI coding tools such as Cursor (`.cursorrules`, `.cursor/rules/`), GitHub Copilot (`.github/copilot-instructions.md`), Windsurf, Plandex, and Crush. Control this with `rulesImport` in any settings file:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rulesImport": "none"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `"auto"` (the default) to import every supported framework, `"none"` to load only Claw instruction/rules files, or an array such as `["cursor", "copilot"]` to import selected frameworks.
|
||||||
|
|
||||||
## Mock parity harness
|
## Mock parity harness
|
||||||
|
|
||||||
The workspace includes a deterministic Anthropic-compatible mock service and parity harness.
|
The workspace includes a deterministic Anthropic-compatible mock service and parity harness.
|
||||||
|
|||||||
@@ -148,12 +148,12 @@ pub const DEFAULT_DASHSCOPE_BASE_URL: &str = "https://dashscope.aliyuncs.com/com
|
|||||||
**Affected models:** Slash-containing model IDs routed through the OpenAI-compatible provider, especially custom gateways configured with `OPENAI_BASE_URL` such as OpenRouter, local routers, or other `/v1/chat/completions` services.
|
**Affected models:** Slash-containing model IDs routed through the OpenAI-compatible provider, especially custom gateways configured with `OPENAI_BASE_URL` such as OpenRouter, local routers, or other `/v1/chat/completions` services.
|
||||||
|
|
||||||
**Behavior:**
|
**Behavior:**
|
||||||
- The default OpenAI API treats `openai/` as a routing prefix and sends the bare model name on the wire.
|
- The default OpenAI API and local/private OpenAI-compatible base URLs treat `openai/` as a routing prefix and send the bare model name on the wire.
|
||||||
- Custom OpenAI-compatible base URLs preserve slash-containing slugs such as `openai/gpt-4.1-mini` so the gateway receives the exact model ID it expects.
|
- Non-local custom OpenAI-compatible base URLs preserve slash-containing slugs such as `openai/gpt-4.1-mini` so gateways like OpenRouter receive the exact model ID they expect. Local slash-containing model IDs can use `local/`, which strips only that escape-hatch prefix and sends the remainder verbatim.
|
||||||
- `MessageRequest::extra_body` passes through custom request JSON after core fields are populated. This supports provider-specific options such as `web_search_options` and `parallel_tool_calls`.
|
- `MessageRequest::extra_body` passes through custom request JSON after core fields are populated. This supports provider-specific options such as `web_search_options` and `parallel_tool_calls`.
|
||||||
- Protected core fields (`model`, `messages`, `stream`, `tools`, `tool_choice`, `max_tokens`, `max_completion_tokens`) cannot be overridden through `extra_body`.
|
- Protected core fields (`model`, `messages`, `stream`, `tools`, `tool_choice`, `max_tokens`, `max_completion_tokens`) cannot be overridden through `extra_body`.
|
||||||
|
|
||||||
**Testing:** See `custom_openai_gateway_preserves_slash_model_ids_and_extra_body_params` in `openai_compat_integration.rs` and `extra_body_params_are_passed_through_without_overriding_core_fields` in `openai_compat.rs`.
|
**Testing:** See `custom_openai_gateway_preserves_slash_model_ids_and_extra_body_params` in `openai_compat_integration.rs`, `wire_model_strips_openai_prefix_for_default_and_local_preserves_custom_gateways`, `local_routing_prefix_strips_only_escape_hatch`, and `extra_body_params_are_passed_through_without_overriding_core_fields` in `openai_compat.rs`.
|
||||||
|
|
||||||
## Implementation Details
|
## Implementation Details
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ If you need the most polished daily-driver experience for a specific non-Claude
|
|||||||
|
|
||||||
## OpenAI-compatible routing basics
|
## OpenAI-compatible routing basics
|
||||||
|
|
||||||
Set `OPENAI_BASE_URL` to the server’s `/v1` endpoint and set `OPENAI_API_KEY` to either the required token or a harmless placeholder for local servers that expect an Authorization header. The model name must match what the server exposes.
|
Set `OPENAI_BASE_URL` to the server’s `/v1` endpoint and set `OPENAI_API_KEY` to either the required token or a harmless placeholder for local servers that expect an Authorization header. Authless local/private OpenAI-compatible servers can leave `OPENAI_API_KEY` unset. The model name must match what the server exposes.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export OPENAI_BASE_URL="http://127.0.0.1:11434/v1"
|
export OPENAI_BASE_URL="http://127.0.0.1:11434/v1"
|
||||||
@@ -24,8 +24,8 @@ claw --model "qwen3:latest" prompt "Reply exactly HELLO_WORLD_123"
|
|||||||
Routing notes:
|
Routing notes:
|
||||||
|
|
||||||
- Use the `openai/` prefix for OpenAI-compatible gateways when you need prefix routing to win over ambient Anthropic credentials, for example `--model "openai/gpt-4.1-mini"` with OpenRouter.
|
- Use the `openai/` prefix for OpenAI-compatible gateways when you need prefix routing to win over ambient Anthropic credentials, for example `--model "openai/gpt-4.1-mini"` with OpenRouter.
|
||||||
- For local servers, prefer the exact model ID reported by the server (`qwen3:latest`, `llama3.2`, `Qwen/Qwen2.5-Coder-7B-Instruct`, etc.). If your local gateway exposes slash-containing IDs, use that exact slug.
|
- For local servers, prefer the exact model ID reported by the server (`qwen3:latest`, `llama3.2`, etc.). If your local gateway exposes slash-containing IDs, prefix the exact slug with `local/` so Claw routes through OpenAI-compatible transport while sending the rest verbatim, for example `--model "local/Qwen/Qwen2.5-Coder-7B-Instruct"`.
|
||||||
- If you have multiple provider keys in your environment, remove unrelated keys while smoke-testing a local route or choose a model prefix that unambiguously selects the intended provider.
|
- If you have multiple provider keys in your environment, `OPENAI_BASE_URL` plus local-looking tags such as `llama3.2` or `qwen2.5-coder:7b` selects the local OpenAI-compatible route; use `local/` for slash-containing local IDs.
|
||||||
- Tool workflows need model/server support for OpenAI-compatible tool calls. Plain prompt smoke tests can pass even when slash/tool workflows still fail because the server returns an incompatible tool-call shape.
|
- Tool workflows need model/server support for OpenAI-compatible tool calls. Plain prompt smoke tests can pass even when slash/tool workflows still fail because the server returns an incompatible tool-call shape.
|
||||||
|
|
||||||
## Raw `/v1/chat/completions` smoke test
|
## Raw `/v1/chat/completions` smoke test
|
||||||
@@ -57,12 +57,13 @@ ollama serve
|
|||||||
In another shell:
|
In another shell:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export OPENAI_BASE_URL="http://127.0.0.1:11434/v1"
|
export OLLAMA_HOST="http://127.0.0.1:11434"
|
||||||
export OPENAI_API_KEY="local-dev-token"
|
|
||||||
claw --model "qwen3:latest" prompt "Reply exactly HELLO_WORLD_123"
|
claw --model "qwen3:latest" prompt "Reply exactly HELLO_WORLD_123"
|
||||||
```
|
```
|
||||||
|
|
||||||
If Ollama is running without auth and your build accepts authless local OpenAI-compatible servers, `unset OPENAI_API_KEY` is also acceptable. Use a placeholder token rather than a real cloud API key for local testing.
|
`OLLAMA_HOST` is the preferred env var for Ollama. Claw routes all models to the local OpenAI-compatible endpoint automatically when this is set, and no API key is needed. The older `OPENAI_BASE_URL` + `OPENAI_API_KEY` workaround is also supported for existing setups.
|
||||||
|
|
||||||
|
If Ollama is running without auth, `unset OPENAI_API_KEY` is acceptable. Use a placeholder token rather than a real cloud API key if your local server requires an Authorization header.
|
||||||
|
|
||||||
## llama.cpp server
|
## llama.cpp server
|
||||||
|
|
||||||
|
|||||||
1
rust/Cargo.lock
generated
1
rust/Cargo.lock
generated
@@ -2244,7 +2244,6 @@ version = "0.1.3"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"api",
|
"api",
|
||||||
"commands",
|
"commands",
|
||||||
"compat-harness",
|
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"log",
|
"log",
|
||||||
"mock-anthropic-service",
|
"mock-anthropic-service",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ cargo run -p rusty-claude-cli -- --help
|
|||||||
cargo build --workspace
|
cargo build --workspace
|
||||||
|
|
||||||
# Run the interactive REPL
|
# Run the interactive REPL
|
||||||
cargo run -p rusty-claude-cli -- --model claude-opus-4-6
|
cargo run -p rusty-claude-cli -- --model claude-opus-4-7
|
||||||
|
|
||||||
# One-shot prompt
|
# One-shot prompt
|
||||||
cargo run -p rusty-claude-cli -- prompt "explain this codebase"
|
cargo run -p rusty-claude-cli -- prompt "explain this codebase"
|
||||||
@@ -87,7 +87,7 @@ Primary artifacts:
|
|||||||
| Sub-agent / agent surfaces | ✅ |
|
| Sub-agent / agent surfaces | ✅ |
|
||||||
| Todo tracking | ✅ |
|
| Todo tracking | ✅ |
|
||||||
| Notebook editing | ✅ |
|
| Notebook editing | ✅ |
|
||||||
| CLAUDE.md / project memory | ✅ |
|
| CLAUDE.md / CLAW.md / AGENTS.md project memory | ✅ |
|
||||||
| Config file hierarchy (`.claw.json` + merged config sections) | ✅ |
|
| Config file hierarchy (`.claw.json` + merged config sections) | ✅ |
|
||||||
| Permission system | ✅ |
|
| Permission system | ✅ |
|
||||||
| MCP server lifecycle + inspection | ✅ |
|
| MCP server lifecycle + inspection | ✅ |
|
||||||
@@ -100,7 +100,7 @@ Primary artifacts:
|
|||||||
| Slash commands (including `/skills`, `/agents`, `/mcp`, `/doctor`, `/plugin`, `/subagent`) | ✅ |
|
| Slash commands (including `/skills`, `/agents`, `/mcp`, `/doctor`, `/plugin`, `/subagent`) | ✅ |
|
||||||
| Hooks (`/hooks`, config-backed lifecycle hooks) | ✅ |
|
| Hooks (`/hooks`, config-backed lifecycle hooks) | ✅ |
|
||||||
| Plugin management surfaces | ✅ |
|
| Plugin management surfaces | ✅ |
|
||||||
| Skills inventory / install surfaces | ✅ |
|
| Skills inventory / install / uninstall surfaces | ✅ |
|
||||||
| Machine-readable JSON output across core CLI surfaces | ✅ |
|
| Machine-readable JSON output across core CLI surfaces | ✅ |
|
||||||
|
|
||||||
## Model Aliases
|
## Model Aliases
|
||||||
@@ -109,7 +109,7 @@ Short names resolve to the latest model versions:
|
|||||||
|
|
||||||
| Alias | Resolves To |
|
| Alias | Resolves To |
|
||||||
|-------|------------|
|
|-------|------------|
|
||||||
| `opus` | `claude-opus-4-6` |
|
| `opus` | `claude-opus-4-7` |
|
||||||
| `sonnet` | `claude-sonnet-4-6` |
|
| `sonnet` | `claude-sonnet-4-6` |
|
||||||
| `haiku` | `claude-haiku-4-5-20251213` |
|
| `haiku` | `claude-haiku-4-5-20251213` |
|
||||||
|
|
||||||
@@ -122,10 +122,11 @@ claw [OPTIONS] [COMMAND]
|
|||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
--model MODEL
|
--model MODEL
|
||||||
--output-format text|json
|
--output-format text|json (case-insensitive; CLAW_OUTPUT_FORMAT supplies the default, flags override env)
|
||||||
--permission-mode MODE
|
--permission-mode MODE
|
||||||
--dangerously-skip-permissions
|
--cwd PATH, -C PATH, --directory PATH
|
||||||
--allowedTools TOOLS
|
--dangerously-skip-permissions, --skip-permissions
|
||||||
|
--allowedTools TOOLS canonical snake_case names or aliases; status JSON exposes allowed_tools.available/aliases
|
||||||
--resume [SESSION.jsonl|session-id|latest]
|
--resume [SESSION.jsonl|session-id|latest]
|
||||||
--version, -V
|
--version, -V
|
||||||
|
|
||||||
@@ -146,6 +147,13 @@ Top-level commands:
|
|||||||
```
|
```
|
||||||
|
|
||||||
`claw acp` is a local discoverability surface for editor-first users: it reports the current ACP/Zed status without starting the runtime. As of April 16, 2026, claw-code does **not** ship an ACP/Zed daemon or JSON-RPC entrypoint yet, and `claw acp serve` is only a status alias until the real protocol surface lands. Status queries exit 0 and expose the same machine-readable contract via `--output-format json`; malformed ACP invocations exit 1 with `kind: unsupported_acp_invocation`.
|
`claw acp` is a local discoverability surface for editor-first users: it reports the current ACP/Zed status without starting the runtime. As of April 16, 2026, claw-code does **not** ship an ACP/Zed daemon or JSON-RPC entrypoint yet, and `claw acp serve` is only a status alias until the real protocol surface lands. Status queries exit 0 and expose the same machine-readable contract via `--output-format json`; malformed ACP invocations exit 1 with `kind: unsupported_acp_invocation`.
|
||||||
|
`--output-format` accepts `text` or `json` in any casing. `CLAW_OUTPUT_FORMAT=json` selects JSON as the default for non-interactive commands, explicit flags override it, repeated flags warn on stderr, and status JSON exposes `format_source`, `format_raw`, and `format_overridden`. Help and doctor output also surface `CLAW_LOG` / `RUST_LOG` as the logging environment knobs.
|
||||||
|
`claw version --output-format json` is the provenance probe for automation: it reports full `git_sha`, derived `git_sha_short`, `is_dirty`, `branch`, `commit_date`, `commit_timestamp`, `rustc_version`, runtime `executable_path`, and `binary_provenance`; the text report is available as `human_readable` instead of a duplicate `message` field.
|
||||||
|
`status --output-format json` reports loaded project memory files under `workspace.memory_files[]` with each file's `path`, `source` (`claude_md`, `claw_md`, `agents_md`, or scoped/rule sources), `origin`, `scope_path`, `outside_project`, `chars`, and `contributes`; `claw doctor --output-format json` includes a dedicated `memory` check. Root instruction-file priority is `CLAUDE.md`, then `CLAW.md`, then `AGENTS.md`, discovery is bounded to the current git root when present (otherwise cwd only), and all non-duplicate loaded files contribute to the rendered system prompt.
|
||||||
|
`claw mcp --output-format json` reports partial MCP config success: valid servers remain in `servers[]` while malformed siblings appear in `invalid_servers[]`, with `total_configured`, `valid_count`, and `invalid_count` split out for automation. `status` mirrors this as `mcp_validation`, and doctor includes an `mcp validation` check.
|
||||||
|
`status --output-format json` also reports partial hook config success under `hook_validation`: valid hook entries are retained while malformed or unknown-event siblings appear in `invalid_hooks[]`, with `valid_count`, `invalid_count`, and typed `kind` fields (`invalid_hooks_config` or `unknown_hook_event`) for automation. `doctor --output-format json` includes a `hook validation` check, and `config --output-format json` includes `hook_validation` metadata with degraded status when invalid entries exist.
|
||||||
|
Shorthand prompt mode honors the POSIX `--` end-of-flags separator, so `claw -- "-prompt-with-dash"` and unknown dash-prefixed non-flag text stay on the prompt path instead of being treated as CLI options.
|
||||||
|
`claw dump-manifests` is self-contained: it emits the Rust resolver inventory for the selected workspace (commands, tools, agents, skills, and bootstrap phases) without requiring an upstream Claude Code TypeScript checkout. Use `--manifests-dir PATH` only to scope resolver discovery to another directory.
|
||||||
|
|
||||||
The command surface is moving quickly. For the canonical live help text, run:
|
The command surface is moving quickly. For the canonical live help text, run:
|
||||||
|
|
||||||
@@ -166,8 +174,8 @@ The REPL now exposes a much broader surface than the original minimal shell:
|
|||||||
- plugin management: `/plugin` (with aliases `/plugins`, `/marketplace`)
|
- plugin management: `/plugin` (with aliases `/plugins`, `/marketplace`)
|
||||||
|
|
||||||
Notable claw-first surfaces now available directly in slash form:
|
Notable claw-first surfaces now available directly in slash form:
|
||||||
- `/skills [list|install <path>|help]`
|
- `/skills [list|show <name>|install <path>|uninstall <name>|help]`
|
||||||
- `/agents [list|help]`
|
- `/agents [list|show <name>|create <name>|help]`
|
||||||
- `/mcp [list|show <server>|help]`
|
- `/mcp [list|show <server>|help]`
|
||||||
- `/doctor`
|
- `/doctor`
|
||||||
- `/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]`
|
- `/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]`
|
||||||
@@ -184,7 +192,7 @@ rust/
|
|||||||
└── crates/
|
└── crates/
|
||||||
├── api/ # Provider clients + streaming + request preflight
|
├── api/ # Provider clients + streaming + request preflight
|
||||||
├── commands/ # Shared slash-command registry + help rendering
|
├── commands/ # Shared slash-command registry + help rendering
|
||||||
├── compat-harness/ # TS manifest extraction harness
|
├── compat-harness/ # Compatibility/parity harness utilities
|
||||||
├── mock-anthropic-service/ # Deterministic local Anthropic-compatible mock
|
├── mock-anthropic-service/ # Deterministic local Anthropic-compatible mock
|
||||||
├── plugins/ # Plugin metadata, manager, install/enable/disable surfaces
|
├── plugins/ # Plugin metadata, manager, install/enable/disable surfaces
|
||||||
├── runtime/ # Session, config, permissions, MCP, prompts, auth/runtime loop
|
├── runtime/ # Session, config, permissions, MCP, prompts, auth/runtime loop
|
||||||
@@ -197,7 +205,7 @@ rust/
|
|||||||
|
|
||||||
- **api** — provider clients, SSE streaming, request/response types, auth (`ANTHROPIC_API_KEY` + bearer-token support), request-size/context-window preflight
|
- **api** — provider clients, SSE streaming, request/response types, auth (`ANTHROPIC_API_KEY` + bearer-token support), request-size/context-window preflight
|
||||||
- **commands** — slash command definitions, parsing, help text generation, JSON/text command rendering
|
- **commands** — slash command definitions, parsing, help text generation, JSON/text command rendering
|
||||||
- **compat-harness** — extracts tool/prompt manifests from upstream TS source
|
- **compat-harness** — compatibility and parity helpers for comparing behavior with upstream fixtures
|
||||||
- **mock-anthropic-service** — deterministic `/v1/messages` mock for CLI parity tests and local harness runs
|
- **mock-anthropic-service** — deterministic `/v1/messages` mock for CLI parity tests and local harness runs
|
||||||
- **plugins** — plugin metadata, install/enable/disable/update flows, plugin tool definitions, hook integration surfaces
|
- **plugins** — plugin metadata, install/enable/disable/update flows, plugin tool definitions, hook integration surfaces
|
||||||
- **runtime** — `ConversationRuntime`, config loading, session persistence, permission policy, MCP client lifecycle, system prompt assembly, usage tracking
|
- **runtime** — `ConversationRuntime`, config loading, session persistence, permission policy, MCP client lifecycle, system prompt assembly, usage tracking
|
||||||
@@ -210,8 +218,8 @@ rust/
|
|||||||
- **~20K lines** of Rust
|
- **~20K lines** of Rust
|
||||||
- **9 crates** in workspace
|
- **9 crates** in workspace
|
||||||
- **Binary name:** `claw`
|
- **Binary name:** `claw`
|
||||||
- **Default model:** `claude-opus-4-6`
|
- **Default model:** `claude-opus-4-7`
|
||||||
- **Default permissions:** `danger-full-access`
|
- **Default permissions:** `workspace-write`
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ impl ProviderClient {
|
|||||||
OpenAiCompatConfig::xai(),
|
OpenAiCompatConfig::xai(),
|
||||||
)?)),
|
)?)),
|
||||||
ProviderKind::OpenAi => {
|
ProviderKind::OpenAi => {
|
||||||
|
// OLLAMA_HOST takes priority: local Ollama needs no API key
|
||||||
|
// and ignores DashScope/OpenAI env-based dispatch.
|
||||||
|
if std::env::var_os("OLLAMA_HOST").is_some() {
|
||||||
|
Ok(Self::OpenAi(
|
||||||
|
openai_compat::OpenAiCompatClient::from_ollama_env()
|
||||||
|
.expect("from_ollama_env always returns Some"),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
// DashScope models (qwen-*) also return ProviderKind::OpenAi because they
|
// DashScope models (qwen-*) also return ProviderKind::OpenAi because they
|
||||||
// speak the OpenAI wire format, but they need the DashScope config which
|
// speak the OpenAI wire format, but they need the DashScope config which
|
||||||
// reads DASHSCOPE_API_KEY and points at dashscope.aliyuncs.com.
|
// reads DASHSCOPE_API_KEY and points at dashscope.aliyuncs.com.
|
||||||
@@ -45,6 +53,7 @@ impl ProviderClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn provider_kind(&self) -> ProviderKind {
|
pub const fn provider_kind(&self) -> ProviderKind {
|
||||||
@@ -161,7 +170,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolves_existing_and_grok_aliases() {
|
fn resolves_existing_and_grok_aliases() {
|
||||||
assert_eq!(resolve_model_alias("opus"), "claude-opus-4-6");
|
assert_eq!(resolve_model_alias("opus"), "claude-opus-4-7");
|
||||||
assert_eq!(resolve_model_alias("grok"), "grok-3");
|
assert_eq!(resolve_model_alias("grok"), "grok-3");
|
||||||
assert_eq!(resolve_model_alias("grok-mini"), "grok-3-mini");
|
assert_eq!(resolve_model_alias("grok-mini"), "grok-3-mini");
|
||||||
}
|
}
|
||||||
@@ -235,4 +244,22 @@ mod tests {
|
|||||||
other => panic!("Expected ProviderClient::OpenAi for qwen-plus, got: {other:?}"),
|
other => panic!("Expected ProviderClient::OpenAi for qwen-plus, got: {other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_openai_base_url_routes_authless_ollama_models() {
|
||||||
|
let _lock = env_lock();
|
||||||
|
let _base_url = EnvVarGuard::set("OPENAI_BASE_URL", Some("http://127.0.0.1:11434/v1"));
|
||||||
|
let _openai_key = EnvVarGuard::set("OPENAI_API_KEY", None);
|
||||||
|
let _anthropic_key = EnvVarGuard::set("ANTHROPIC_API_KEY", Some("test-anthropic-key"));
|
||||||
|
let _anthropic_token = EnvVarGuard::set("ANTHROPIC_AUTH_TOKEN", None);
|
||||||
|
|
||||||
|
let client = ProviderClient::from_model("qwen2.5-coder:7b")
|
||||||
|
.expect("local model should route to OpenAI-compatible client without auth");
|
||||||
|
match client {
|
||||||
|
ProviderClient::OpenAi(openai_client) => {
|
||||||
|
assert_eq!(openai_client.base_url(), "http://127.0.0.1:11434/v1")
|
||||||
|
}
|
||||||
|
other => panic!("Expected ProviderClient::OpenAi for local model, got: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ 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)]
|
||||||
@@ -60,6 +61,9 @@ 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,
|
||||||
@@ -128,6 +132,17 @@ 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(),
|
||||||
@@ -311,6 +326,36 @@ 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,
|
||||||
@@ -499,6 +544,7 @@ 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());
|
||||||
@@ -522,6 +568,7 @@ mod tests {
|
|||||||
body: String::new(),
|
body: String::new(),
|
||||||
retryable: true,
|
retryable: true,
|
||||||
suggested_action: None,
|
suggested_action: None,
|
||||||
|
retry_after: None,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -543,6 +590,7 @@ 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());
|
||||||
@@ -563,6 +611,7 @@ 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,9 +1,69 @@
|
|||||||
|
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.
|
||||||
@@ -61,7 +121,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(&ProxyConfig::from_env())
|
build_http_client_with_opts(&ProxyConfig::from_env(), &TimeoutConfig::from_env())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Infallible counterpart to [`build_http_client`] for constructors that
|
/// Infallible counterpart to [`build_http_client`] for constructors that
|
||||||
@@ -71,7 +131,8 @@ pub fn build_http_client() -> Result<reqwest::Client, ApiError> {
|
|||||||
/// first outbound request instead of at construction time.
|
/// 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().unwrap_or_else(|_| {
|
build_http_client_with_opts(&ProxyConfig::from_env(), &TimeoutConfig::from_env())
|
||||||
|
.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()
|
||||||
@@ -86,9 +147,20 @@ pub fn build_http_client_or_default() -> reqwest::Client {
|
|||||||
/// and `https_proxy` fields and is registered as both an HTTP and HTTPS
|
/// 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
|
||||||
@@ -131,7 +203,7 @@ where
|
|||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use super::{build_http_client_with, ProxyConfig};
|
use super::{build_http_client_with, build_http_client_with_opts, ProxyConfig, TimeoutConfig};
|
||||||
|
|
||||||
fn config_from_map(pairs: &[(&str, &str)]) -> ProxyConfig {
|
fn config_from_map(pairs: &[(&str, &str)]) -> ProxyConfig {
|
||||||
let map: HashMap<String, String> = pairs
|
let map: HashMap<String, String> = pairs
|
||||||
@@ -143,30 +215,19 @@ 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")
|
||||||
@@ -184,17 +245,12 @@ 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")
|
||||||
@@ -208,16 +264,11 @@ 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")
|
||||||
@@ -226,59 +277,39 @@ 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(_)),
|
||||||
@@ -288,10 +319,7 @@ 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")
|
||||||
@@ -303,49 +331,56 @@ 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,7 +12,8 @@ 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, ProxyConfig,
|
build_http_client, build_http_client_or_default, build_http_client_with,
|
||||||
|
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,6 +211,19 @@ 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);
|
||||||
@@ -454,7 +467,13 @@ impl AnthropicClient {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
tokio::time::sleep(self.jittered_backoff_for_attempt(attempts)?).await;
|
let delay = if let Some(retry_after) = last_error.as_ref().and_then(|e| e.retry_after())
|
||||||
|
{
|
||||||
|
retry_after
|
||||||
|
} else {
|
||||||
|
self.jittered_backoff_for_attempt(attempts)?
|
||||||
|
};
|
||||||
|
tokio::time::sleep(delay).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(ApiError::RetriesExhausted {
|
Err(ApiError::RetriesExhausted {
|
||||||
@@ -468,8 +487,7 @@ impl AnthropicClient {
|
|||||||
request: &MessageRequest,
|
request: &MessageRequest,
|
||||||
) -> Result<reqwest::Response, ApiError> {
|
) -> Result<reqwest::Response, ApiError> {
|
||||||
let request_url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
|
let request_url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
|
||||||
let mut request_body = self.request_profile.render_json_body(request)?;
|
let request_body = render_standard_messages_body(&self.request_profile, request)?;
|
||||||
strip_unsupported_beta_body_fields(&mut request_body);
|
|
||||||
let request_builder = self.build_request(&request_url).json(&request_body);
|
let request_builder = self.build_request(&request_url).json(&request_body);
|
||||||
request_builder.send().await.map_err(ApiError::from)
|
request_builder.send().await.map_err(ApiError::from)
|
||||||
}
|
}
|
||||||
@@ -529,8 +547,7 @@ impl AnthropicClient {
|
|||||||
"{}/v1/messages/count_tokens",
|
"{}/v1/messages/count_tokens",
|
||||||
self.base_url.trim_end_matches('/')
|
self.base_url.trim_end_matches('/')
|
||||||
);
|
);
|
||||||
let mut request_body = self.request_profile.render_json_body(request)?;
|
let request_body = render_standard_messages_body(&self.request_profile, request)?;
|
||||||
strip_unsupported_beta_body_fields(&mut request_body);
|
|
||||||
let response = self
|
let response = self
|
||||||
.build_request(&request_url)
|
.build_request(&request_url)
|
||||||
.json(&request_body)
|
.json(&request_body)
|
||||||
@@ -868,10 +885,12 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
|||||||
return Ok(response);
|
return Ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
let request_id = request_id_from_headers(response.headers());
|
let headers = response.headers().clone();
|
||||||
|
let request_id = request_id_from_headers(&headers);
|
||||||
let body = response.text().await.unwrap_or_else(|_| String::new());
|
let 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,
|
||||||
@@ -885,13 +904,44 @@ 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
|
||||||
@@ -910,6 +960,8 @@ 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;
|
||||||
@@ -923,6 +975,7 @@ 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 {
|
||||||
@@ -934,6 +987,7 @@ 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-") {
|
||||||
@@ -945,6 +999,7 @@ 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
|
||||||
@@ -960,6 +1015,7 @@ 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 {
|
||||||
@@ -974,9 +1030,25 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
|
|||||||
body,
|
body,
|
||||||
retryable,
|
retryable,
|
||||||
suggested_action,
|
suggested_action,
|
||||||
|
retry_after,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn anthropic_wire_model(model: &str) -> &str {
|
||||||
|
model.strip_prefix("anthropic/").unwrap_or(model)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_standard_messages_body(
|
||||||
|
request_profile: &AnthropicRequestProfile,
|
||||||
|
request: &MessageRequest,
|
||||||
|
) -> Result<Value, serde_json::Error> {
|
||||||
|
let mut wire_request = request.clone();
|
||||||
|
wire_request.model = anthropic_wire_model(&request.model).to_string();
|
||||||
|
let mut body = request_profile.render_json_body(&wire_request)?;
|
||||||
|
strip_unsupported_beta_body_fields(&mut body);
|
||||||
|
Ok(body)
|
||||||
|
}
|
||||||
|
|
||||||
/// Remove beta-only body fields that the standard `/v1/messages` and
|
/// Remove beta-only body fields that the standard `/v1/messages` and
|
||||||
/// `/v1/messages/count_tokens` endpoints reject as `Extra inputs are not
|
/// `/v1/messages/count_tokens` endpoints reject as `Extra inputs are not
|
||||||
/// permitted`. The `betas` opt-in is communicated via the `anthropic-beta`
|
/// permitted`. The `betas` opt-in is communicated via the `anthropic-beta`
|
||||||
@@ -1550,6 +1622,27 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn standard_messages_body_strips_anthropic_routing_prefix() {
|
||||||
|
let client = AnthropicClient::new("test-key");
|
||||||
|
let request = MessageRequest {
|
||||||
|
model: "anthropic/claude-opus-4-6".to_string(),
|
||||||
|
max_tokens: 64,
|
||||||
|
messages: vec![],
|
||||||
|
system: None,
|
||||||
|
tools: None,
|
||||||
|
tool_choice: None,
|
||||||
|
stream: false,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let rendered = super::render_standard_messages_body(client.request_profile(), &request)
|
||||||
|
.expect("body should render");
|
||||||
|
|
||||||
|
assert_eq!(rendered["model"], serde_json::json!("claude-opus-4-6"));
|
||||||
|
assert!(rendered.get("betas").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn enrich_bearer_auth_error_appends_sk_ant_hint_on_401_with_pure_bearer_token() {
|
fn enrich_bearer_auth_error_appends_sk_ant_hint_on_401_with_pure_bearer_token() {
|
||||||
// given
|
// given
|
||||||
@@ -1562,6 +1655,7 @@ mod tests {
|
|||||||
body: String::new(),
|
body: String::new(),
|
||||||
retryable: false,
|
retryable: false,
|
||||||
suggested_action: None,
|
suggested_action: None,
|
||||||
|
retry_after: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -1603,6 +1697,7 @@ mod tests {
|
|||||||
body: String::new(),
|
body: String::new(),
|
||||||
retryable: true,
|
retryable: true,
|
||||||
suggested_action: None,
|
suggested_action: None,
|
||||||
|
retry_after: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -1632,6 +1727,7 @@ mod tests {
|
|||||||
body: String::new(),
|
body: String::new(),
|
||||||
retryable: false,
|
retryable: false,
|
||||||
suggested_action: None,
|
suggested_action: None,
|
||||||
|
retry_after: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -1660,6 +1756,7 @@ mod tests {
|
|||||||
body: String::new(),
|
body: String::new(),
|
||||||
retryable: false,
|
retryable: false,
|
||||||
suggested_action: None,
|
suggested_action: None,
|
||||||
|
retry_after: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// when
|
// when
|
||||||
@@ -1685,6 +1782,7 @@ mod tests {
|
|||||||
body: String::new(),
|
body: String::new(),
|
||||||
retryable: false,
|
retryable: false,
|
||||||
suggested_action: None,
|
suggested_action: None,
|
||||||
|
retry_after: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// when
|
// when
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ pub fn resolve_model_alias(model: &str) -> String {
|
|||||||
.find_map(|(alias, metadata)| {
|
.find_map(|(alias, metadata)| {
|
||||||
(*alias == lower).then_some(match metadata.provider {
|
(*alias == lower).then_some(match metadata.provider {
|
||||||
ProviderKind::Anthropic => match *alias {
|
ProviderKind::Anthropic => match *alias {
|
||||||
"opus" => "claude-opus-4-6",
|
"opus" => "claude-opus-4-7",
|
||||||
"sonnet" => "claude-sonnet-4-6",
|
"sonnet" => "claude-sonnet-4-6",
|
||||||
"haiku" => "claude-haiku-4-5-20251213",
|
"haiku" => "claude-haiku-4-5-20251213",
|
||||||
_ => trimmed,
|
_ => trimmed,
|
||||||
@@ -262,6 +262,14 @@ pub fn metadata_for_model(model: &str) -> Option<ProviderMetadata> {
|
|||||||
default_base_url: openai_compat::DEFAULT_OPENAI_BASE_URL,
|
default_base_url: openai_compat::DEFAULT_OPENAI_BASE_URL,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if canonical.starts_with("local/") {
|
||||||
|
return Some(ProviderMetadata {
|
||||||
|
provider: ProviderKind::OpenAi,
|
||||||
|
auth_env: "OPENAI_API_KEY",
|
||||||
|
base_url_env: "OPENAI_BASE_URL",
|
||||||
|
default_base_url: openai_compat::DEFAULT_OPENAI_BASE_URL,
|
||||||
|
});
|
||||||
|
}
|
||||||
// Alibaba DashScope compatible-mode endpoint. Routes qwen/* and bare
|
// Alibaba DashScope compatible-mode endpoint. Routes qwen/* and bare
|
||||||
// qwen-* model names (qwen-max, qwen-plus, qwen-turbo, qwen-qwq, etc.)
|
// qwen-* model names (qwen-max, qwen-plus, qwen-turbo, qwen-qwq, etc.)
|
||||||
// to the OpenAI-compat client pointed at DashScope's /compatible-mode/v1.
|
// to the OpenAI-compat client pointed at DashScope's /compatible-mode/v1.
|
||||||
@@ -337,17 +345,26 @@ pub fn provider_diagnostics_for_model(model: &str) -> ProviderDiagnostics {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn looks_like_local_openai_model(model: &str) -> bool {
|
||||||
|
model.contains(':') || model.contains('.')
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn detect_provider_kind(model: &str) -> ProviderKind {
|
pub fn detect_provider_kind(model: &str) -> ProviderKind {
|
||||||
if let Some(metadata) = metadata_for_model(model) {
|
// OLLAMA_HOST takes priority: if set, route all models through the local
|
||||||
|
// OpenAI-compatible endpoint regardless of model name or other env vars.
|
||||||
|
if std::env::var_os("OLLAMA_HOST").is_some() {
|
||||||
|
return ProviderKind::OpenAi;
|
||||||
|
}
|
||||||
|
let resolved_model = resolve_model_alias(model);
|
||||||
|
if let Some(metadata) = metadata_for_model(&resolved_model) {
|
||||||
return metadata.provider;
|
return metadata.provider;
|
||||||
}
|
}
|
||||||
// When OPENAI_BASE_URL is set, the user explicitly configured an
|
// When OPENAI_BASE_URL is set and the unknown model name looks like a
|
||||||
// OpenAI-compatible endpoint. Prefer it over the Anthropic fallback
|
// local server tag (for example `llama3.2` or `qwen2.5-coder:7b`), prefer
|
||||||
// even when the model name has no recognized prefix — this is the
|
// the OpenAI-compatible endpoint over ambient Anthropic credentials.
|
||||||
// common case for local providers (Ollama, LM Studio, vLLM, etc.)
|
if std::env::var_os("OPENAI_BASE_URL").is_some()
|
||||||
// where model names like "qwen2.5-coder:7b" don't match any prefix.
|
&& looks_like_local_openai_model(&resolved_model)
|
||||||
if std::env::var_os("OPENAI_BASE_URL").is_some() && openai_compat::has_api_key("OPENAI_API_KEY")
|
|
||||||
{
|
{
|
||||||
return ProviderKind::OpenAi;
|
return ProviderKind::OpenAi;
|
||||||
}
|
}
|
||||||
@@ -608,7 +625,7 @@ pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
|
|||||||
let canonical = resolve_model_alias(model);
|
let canonical = resolve_model_alias(model);
|
||||||
let base_model = canonical.rsplit('/').next().unwrap_or(canonical.as_str());
|
let base_model = canonical.rsplit('/').next().unwrap_or(canonical.as_str());
|
||||||
match base_model {
|
match base_model {
|
||||||
"claude-opus-4-6" => Some(ModelTokenLimit {
|
"claude-opus-4-7" | "claude-opus-4-6" => Some(ModelTokenLimit {
|
||||||
max_output_tokens: 32_000,
|
max_output_tokens: 32_000,
|
||||||
context_window_tokens: 200_000,
|
context_window_tokens: 200_000,
|
||||||
}),
|
}),
|
||||||
@@ -1042,6 +1059,18 @@ mod tests {
|
|||||||
assert_eq!(kind2, ProviderKind::OpenAi);
|
assert_eq!(kind2, ProviderKind::OpenAi);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_prefix_routes_to_openai_not_anthropic() {
|
||||||
|
let meta = super::metadata_for_model("local/Qwen/Qwen3.6-27B-FP8")
|
||||||
|
.expect("local/ prefix must resolve to OpenAI-compatible metadata");
|
||||||
|
assert_eq!(meta.provider, ProviderKind::OpenAi);
|
||||||
|
assert_eq!(meta.auth_env, "OPENAI_API_KEY");
|
||||||
|
assert_eq!(meta.base_url_env, "OPENAI_BASE_URL");
|
||||||
|
|
||||||
|
let kind = detect_provider_kind("local/Qwen/Qwen3.6-27B-FP8");
|
||||||
|
assert_eq!(kind, ProviderKind::OpenAi);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn qwen_prefix_routes_to_dashscope_not_anthropic() {
|
fn qwen_prefix_routes_to_dashscope_not_anthropic() {
|
||||||
// User request from Discord #clawcode-get-help: web3g wants to use
|
// User request from Discord #clawcode-get-help: web3g wants to use
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
use std::collections::{BTreeMap, VecDeque};
|
use std::collections::{BTreeMap, VecDeque};
|
||||||
|
use std::net::Ipv4Addr;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
@@ -48,6 +49,14 @@ const XAI_MAX_REQUEST_BODY_BYTES: usize = 52_428_800; // 50MB
|
|||||||
const OPENAI_MAX_REQUEST_BODY_BYTES: usize = 104_857_600; // 100MB
|
const 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 {
|
||||||
@@ -131,13 +140,38 @@ impl OpenAiCompatClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_env(config: OpenAiCompatConfig) -> Result<Self, ApiError> {
|
pub fn from_env(config: OpenAiCompatConfig) -> Result<Self, ApiError> {
|
||||||
let Some(api_key) = read_env_non_empty(config.api_key_env)? else {
|
let base_url = read_base_url(config);
|
||||||
|
let api_key = match read_env_non_empty(config.api_key_env)? {
|
||||||
|
Some(api_key) => api_key,
|
||||||
|
None if config.provider_name == "OpenAI"
|
||||||
|
&& is_local_openai_compatible_base_url(&base_url) =>
|
||||||
|
{
|
||||||
|
"local-dev-token".to_string()
|
||||||
|
}
|
||||||
|
None => {
|
||||||
return Err(ApiError::missing_credentials(
|
return Err(ApiError::missing_credentials(
|
||||||
config.provider_name,
|
config.provider_name,
|
||||||
config.credential_env_vars(),
|
config.credential_env_vars(),
|
||||||
));
|
));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
Ok(Self::new(api_key, config))
|
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]
|
||||||
@@ -165,6 +199,18 @@ 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,
|
||||||
@@ -207,6 +253,7 @@ 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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,7 +307,12 @@ impl OpenAiCompatClient {
|
|||||||
break retryable_error;
|
break retryable_error;
|
||||||
}
|
}
|
||||||
|
|
||||||
tokio::time::sleep(self.jittered_backoff_for_attempt(attempts)?).await;
|
let delay = if let Some(retry_after) = retryable_error.retry_after() {
|
||||||
|
retry_after
|
||||||
|
} else {
|
||||||
|
self.jittered_backoff_for_attempt(attempts)?
|
||||||
|
};
|
||||||
|
tokio::time::sleep(delay).await;
|
||||||
};
|
};
|
||||||
|
|
||||||
Err(ApiError::RetriesExhausted {
|
Err(ApiError::RetriesExhausted {
|
||||||
@@ -915,14 +967,18 @@ pub fn model_requires_reasoning_content_in_history(model: &str) -> bool {
|
|||||||
|
|
||||||
/// 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.
|
/// bare model id. Use `local/` to force OpenAI-compatible routing while
|
||||||
|
/// preserving any slashes that follow the prefix.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
fn strip_routing_prefix(model: &str) -> &str {
|
fn strip_routing_prefix(model: &str) -> &str {
|
||||||
if let Some(pos) = model.find('/') {
|
if let Some(pos) = model.find('/') {
|
||||||
let prefix = &model[..pos];
|
let prefix = &model[..pos];
|
||||||
// Only strip if the prefix before "/" is a known routing prefix,
|
// Only strip if the prefix before "/" is a known routing prefix,
|
||||||
// not if "/" appears in the middle of the model name for other reasons.
|
// not if "/" appears in the middle of the model name for other reasons.
|
||||||
if matches!(prefix, "openai" | "xai" | "grok" | "qwen" | "kimi") {
|
if matches!(
|
||||||
|
prefix,
|
||||||
|
"openai" | "xai" | "grok" | "qwen" | "kimi" | "local"
|
||||||
|
) {
|
||||||
&model[pos + 1..]
|
&model[pos + 1..]
|
||||||
} else {
|
} else {
|
||||||
model
|
model
|
||||||
@@ -932,6 +988,44 @@ fn strip_routing_prefix(model: &str) -> &str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_base_url_for_model_routing(url: &str) -> &str {
|
||||||
|
let trimmed = url.trim_end_matches('/');
|
||||||
|
trimmed
|
||||||
|
.strip_suffix("/chat/completions")
|
||||||
|
.map(|value| value.trim_end_matches('/'))
|
||||||
|
.unwrap_or(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url_host(url: &str) -> &str {
|
||||||
|
let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
|
||||||
|
let authority = after_scheme.split(['/', '?', '#']).next().unwrap_or("");
|
||||||
|
let host_port = authority
|
||||||
|
.rsplit_once('@')
|
||||||
|
.map_or(authority, |(_, host_port)| host_port);
|
||||||
|
if host_port.starts_with('[') {
|
||||||
|
return host_port
|
||||||
|
.split(']')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim_start_matches('[');
|
||||||
|
}
|
||||||
|
host_port.split(':').next().unwrap_or("")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_local_openai_compatible_base_url(url: &str) -> bool {
|
||||||
|
let host = url_host(url.trim());
|
||||||
|
if host.eq_ignore_ascii_case("localhost") || host == "::1" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let Ok(address) = host.parse::<Ipv4Addr>() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let [first, second, ..] = address.octets();
|
||||||
|
matches!(first, 10 | 127)
|
||||||
|
|| first == 192 && second == 168
|
||||||
|
|| first == 172 && (16..=31).contains(&second)
|
||||||
|
}
|
||||||
|
|
||||||
fn wire_model_for_base_url<'a>(
|
fn wire_model_for_base_url<'a>(
|
||||||
model: &'a str,
|
model: &'a str,
|
||||||
config: OpenAiCompatConfig,
|
config: OpenAiCompatConfig,
|
||||||
@@ -944,26 +1038,22 @@ fn wire_model_for_base_url<'a>(
|
|||||||
let lowered_prefix = prefix.to_ascii_lowercase();
|
let lowered_prefix = prefix.to_ascii_lowercase();
|
||||||
|
|
||||||
if lowered_prefix == "openai" {
|
if lowered_prefix == "openai" {
|
||||||
let trimmed_base_url = base_url.trim_end_matches('/');
|
let normalized_base_url = normalize_base_url_for_model_routing(base_url);
|
||||||
let default_openai = DEFAULT_OPENAI_BASE_URL.trim_end_matches('/');
|
let default_base_url = normalize_base_url_for_model_routing(config.default_base_url);
|
||||||
if matches!(
|
if normalized_base_url.eq_ignore_ascii_case(default_base_url)
|
||||||
lowered_prefix.as_str(),
|
|| is_local_openai_compatible_base_url(base_url)
|
||||||
"xai" | "grok" | "kimi" | "gemini" | "gemma"
|
{
|
||||||
) {
|
|
||||||
return Cow::Borrowed(&model[pos + 1..]);
|
return Cow::Borrowed(&model[pos + 1..]);
|
||||||
}
|
}
|
||||||
if config.provider_name == "OpenAI" && trimmed_base_url != default_openai {
|
|
||||||
// Only preserve the full slug if it's NOT a model we want to strip
|
|
||||||
if !model.contains("gemini") && !model.contains("gemma") {
|
|
||||||
return Cow::Borrowed(model);
|
return Cow::Borrowed(model);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return Cow::Borrowed(&model[pos + 1..]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if matches!(lowered_prefix.as_str(), "xai" | "grok" | "qwen" | "kimi") {
|
if matches!(lowered_prefix.as_str(), "xai" | "grok" | "qwen" | "kimi") {
|
||||||
return Cow::Borrowed(&model[pos + 1..]);
|
return Cow::Borrowed(&model[pos + 1..]);
|
||||||
}
|
}
|
||||||
|
if lowered_prefix == "local" {
|
||||||
|
return Cow::Borrowed(&model[pos + 1..]);
|
||||||
|
}
|
||||||
|
|
||||||
Cow::Borrowed(model)
|
Cow::Borrowed(model)
|
||||||
}
|
}
|
||||||
@@ -1115,6 +1205,13 @@ fn build_chat_completion_request_for_base_url(
|
|||||||
payload[key] = value.clone();
|
payload[key] = value.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeepSeek V4 Pro/Flash thinking mode requires this provider-specific opt-in
|
||||||
|
// and also requires assistant reasoning history to be echoed as `reasoning_content`.
|
||||||
|
// Apply it after extra_body so callers cannot accidentally override the required shape.
|
||||||
|
if model_requires_reasoning_content_in_history(wire_model) {
|
||||||
|
payload["thinking"] = json!({"type": "enabled"});
|
||||||
|
}
|
||||||
|
|
||||||
payload
|
payload
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1172,16 +1269,19 @@ pub fn translate_message(message: &InputMessage, model: &str) -> Vec<Value> {
|
|||||||
InputContentBlock::ToolResult { .. } => {}
|
InputContentBlock::ToolResult { .. } => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let include_reasoning =
|
let needs_reasoning = model_requires_reasoning_content_in_history(model);
|
||||||
model_requires_reasoning_content_in_history(model) && !reasoning.is_empty();
|
if text.is_empty() && tool_calls.is_empty() && reasoning.is_empty() {
|
||||||
if text.is_empty() && tool_calls.is_empty() && !include_reasoning {
|
|
||||||
Vec::new()
|
Vec::new()
|
||||||
} else {
|
} else {
|
||||||
let mut msg = serde_json::json!({
|
let mut msg = serde_json::json!({
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": (!text.is_empty()).then_some(text),
|
|
||||||
});
|
});
|
||||||
if include_reasoning {
|
if !text.is_empty() {
|
||||||
|
msg["content"] = json!(text);
|
||||||
|
} else if !needs_reasoning {
|
||||||
|
msg["content"] = Value::Null;
|
||||||
|
}
|
||||||
|
if needs_reasoning {
|
||||||
msg["reasoning_content"] = json!(reasoning);
|
msg["reasoning_content"] = json!(reasoning);
|
||||||
}
|
}
|
||||||
// Only include tool_calls when non-empty: some providers reject
|
// Only include tool_calls when non-empty: some providers reject
|
||||||
@@ -1503,6 +1603,7 @@ 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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1518,6 +1619,7 @@ 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);
|
||||||
@@ -1553,6 +1655,7 @@ 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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1569,6 +1672,7 @@ 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)
|
||||||
@@ -1620,10 +1724,12 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
|||||||
return Ok(response);
|
return Ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
let request_id = request_id_from_headers(response.headers());
|
let headers = response.headers().clone();
|
||||||
|
let request_id = request_id_from_headers(&headers);
|
||||||
let body = response.text().await.unwrap_or_default();
|
let 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);
|
||||||
|
|
||||||
@@ -1639,13 +1745,43 @@ 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> {
|
||||||
@@ -1698,6 +1834,7 @@ mod tests {
|
|||||||
ToolChoice, ToolDefinition, ToolResultContentBlock,
|
ToolChoice, ToolDefinition, ToolResultContentBlock,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use std::borrow::Cow;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
@@ -1796,6 +1933,31 @@ mod tests {
|
|||||||
assert_eq!(assistant["content"], json!("answer"));
|
assert_eq!(assistant["content"], json!("answer"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deepseek_v4_assistant_with_only_tool_calls_omits_content_and_includes_reasoning() {
|
||||||
|
let request = MessageRequest {
|
||||||
|
model: "deepseek-v4-pro".to_string(),
|
||||||
|
max_tokens: 100,
|
||||||
|
messages: vec![InputMessage {
|
||||||
|
role: "assistant".to_string(),
|
||||||
|
content: vec![InputContentBlock::ToolUse {
|
||||||
|
id: "call_1".to_string(),
|
||||||
|
name: "get_weather".to_string(),
|
||||||
|
input: json!({"city": "Paris"}),
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
stream: false,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
|
||||||
|
let assistant = &payload["messages"][0];
|
||||||
|
|
||||||
|
assert!(assistant.get("content").is_none());
|
||||||
|
assert_eq!(assistant["reasoning_content"], json!(""));
|
||||||
|
assert_eq!(assistant["tool_calls"].as_array().map(Vec::len), Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deepseek_v4_flash_request_includes_reasoning_content_for_assistant_history() {
|
fn deepseek_v4_flash_request_includes_reasoning_content_for_assistant_history() {
|
||||||
// Given an assistant history turn containing thinking.
|
// Given an assistant history turn containing thinking.
|
||||||
@@ -1982,6 +2144,49 @@ mod tests {
|
|||||||
assert_eq!(payload["reasoning_effort"], json!("high"));
|
assert_eq!(payload["reasoning_effort"], json!("high"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deepseek_v4_request_includes_thinking_parameter() {
|
||||||
|
let payload = build_chat_completion_request(
|
||||||
|
&MessageRequest {
|
||||||
|
model: "deepseek-v4-pro".to_string(),
|
||||||
|
max_tokens: 1024,
|
||||||
|
messages: vec![InputMessage::user_text("hello")],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
OpenAiCompatConfig::openai(),
|
||||||
|
);
|
||||||
|
assert_eq!(payload["thinking"], json!({"type": "enabled"}));
|
||||||
|
assert_eq!(payload["model"], json!("deepseek-v4-pro"));
|
||||||
|
|
||||||
|
let mut extra_body = BTreeMap::new();
|
||||||
|
extra_body.insert("thinking".to_string(), json!({"type": "disabled"}));
|
||||||
|
let payload_with_override = build_chat_completion_request(
|
||||||
|
&MessageRequest {
|
||||||
|
model: "openai/deepseek-v4-flash".to_string(),
|
||||||
|
max_tokens: 1024,
|
||||||
|
messages: vec![InputMessage::user_text("hello")],
|
||||||
|
extra_body,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
OpenAiCompatConfig::openai(),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload_with_override["thinking"],
|
||||||
|
json!({"type": "enabled"})
|
||||||
|
);
|
||||||
|
|
||||||
|
let non_deepseek_payload = build_chat_completion_request(
|
||||||
|
&MessageRequest {
|
||||||
|
model: "gpt-4o".to_string(),
|
||||||
|
max_tokens: 64,
|
||||||
|
messages: vec![InputMessage::user_text("hello")],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
OpenAiCompatConfig::openai(),
|
||||||
|
);
|
||||||
|
assert!(non_deepseek_payload.get("thinking").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reasoning_effort_omitted_when_not_set() {
|
fn reasoning_effort_omitted_when_not_set() {
|
||||||
let payload = build_chat_completion_request(
|
let payload = build_chat_completion_request(
|
||||||
@@ -2069,6 +2274,28 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_openai_base_url_does_not_require_api_key() {
|
||||||
|
let _lock = env_lock();
|
||||||
|
let original_base_url = std::env::var_os("OPENAI_BASE_URL");
|
||||||
|
let original_api_key = std::env::var_os("OPENAI_API_KEY");
|
||||||
|
std::env::set_var("OPENAI_BASE_URL", "http://127.0.0.1:11434/v1");
|
||||||
|
std::env::remove_var("OPENAI_API_KEY");
|
||||||
|
|
||||||
|
let client = OpenAiCompatClient::from_env(OpenAiCompatConfig::openai())
|
||||||
|
.expect("local OpenAI-compatible endpoint should not require an API key");
|
||||||
|
assert_eq!(client.base_url(), "http://127.0.0.1:11434/v1");
|
||||||
|
|
||||||
|
match original_base_url {
|
||||||
|
Some(value) => std::env::set_var("OPENAI_BASE_URL", value),
|
||||||
|
None => std::env::remove_var("OPENAI_BASE_URL"),
|
||||||
|
}
|
||||||
|
match original_api_key {
|
||||||
|
Some(value) => std::env::set_var("OPENAI_API_KEY", value),
|
||||||
|
None => std::env::remove_var("OPENAI_API_KEY"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn endpoint_builder_accepts_base_urls_and_full_endpoints() {
|
fn endpoint_builder_accepts_base_urls_and_full_endpoints() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -2684,6 +2911,66 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wire_model_strips_openai_prefix_for_default_and_local_preserves_custom_gateways() {
|
||||||
|
assert_eq!(
|
||||||
|
super::wire_model_for_base_url(
|
||||||
|
"openai/gpt-4o",
|
||||||
|
OpenAiCompatConfig::openai(),
|
||||||
|
super::DEFAULT_OPENAI_BASE_URL,
|
||||||
|
),
|
||||||
|
Cow::Borrowed("gpt-4o")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
super::wire_model_for_base_url(
|
||||||
|
"openai/qwen2.5-coder:7b",
|
||||||
|
OpenAiCompatConfig::openai(),
|
||||||
|
"http://127.0.0.1:11434/v1",
|
||||||
|
),
|
||||||
|
Cow::Borrowed("qwen2.5-coder:7b")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
super::wire_model_for_base_url(
|
||||||
|
"openai/llama3.2",
|
||||||
|
OpenAiCompatConfig::openai(),
|
||||||
|
"http://localhost:11434/v1/chat/completions",
|
||||||
|
),
|
||||||
|
Cow::Borrowed("llama3.2")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
super::wire_model_for_base_url(
|
||||||
|
"openai/gpt-4.1-mini",
|
||||||
|
OpenAiCompatConfig::openai(),
|
||||||
|
"https://openrouter.ai/api/v1",
|
||||||
|
),
|
||||||
|
Cow::Borrowed("openai/gpt-4.1-mini")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
super::wire_model_for_base_url(
|
||||||
|
"openai/gpt-4.1-mini",
|
||||||
|
OpenAiCompatConfig::openai(),
|
||||||
|
"https://not-localhost.example.com/v1",
|
||||||
|
),
|
||||||
|
Cow::Borrowed("openai/gpt-4.1-mini")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_routing_prefix_strips_only_escape_hatch() {
|
||||||
|
assert_eq!(
|
||||||
|
super::strip_routing_prefix("local/Qwen/Qwen3.6-27B-FP8"),
|
||||||
|
"Qwen/Qwen3.6-27B-FP8"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
super::wire_model_for_base_url(
|
||||||
|
"local/Qwen/Qwen3.6-27B-FP8",
|
||||||
|
OpenAiCompatConfig::openai(),
|
||||||
|
"http://127.0.0.1:8000/v1",
|
||||||
|
),
|
||||||
|
Cow::Borrowed("Qwen/Qwen3.6-27B-FP8")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn check_request_body_size_allows_large_requests_for_openai() {
|
fn check_request_body_size_allows_large_requests_for_openai() {
|
||||||
// Create a request that exceeds DashScope's limit but is under OpenAI's 100MB limit
|
// Create a request that exceeds DashScope's limit but is under OpenAI's 100MB limit
|
||||||
|
|||||||
@@ -103,6 +103,58 @@ async fn send_message_posts_json_and_parses_response() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn send_message_strips_anthropic_routing_prefix_on_wire() {
|
||||||
|
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||||
|
let server = spawn_server(
|
||||||
|
state.clone(),
|
||||||
|
vec![
|
||||||
|
http_response("200 OK", "application/json", "{\"input_tokens\":1}"),
|
||||||
|
http_response(
|
||||||
|
"200 OK",
|
||||||
|
"application/json",
|
||||||
|
concat!(
|
||||||
|
"{",
|
||||||
|
"\"id\":\"msg_prefixed\",",
|
||||||
|
"\"type\":\"message\",",
|
||||||
|
"\"role\":\"assistant\",",
|
||||||
|
"\"content\":[{\"type\":\"text\",\"text\":\"ok\"}],",
|
||||||
|
"\"model\":\"claude-opus-4-6\",",
|
||||||
|
"\"stop_reason\":\"end_turn\",",
|
||||||
|
"\"stop_sequence\":null,",
|
||||||
|
"\"usage\":{\"input_tokens\":1,\"output_tokens\":1}",
|
||||||
|
"}"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = AnthropicClient::new("test-key").with_base_url(server.base_url());
|
||||||
|
client
|
||||||
|
.send_message(&MessageRequest {
|
||||||
|
model: "anthropic/claude-opus-4-6".to_string(),
|
||||||
|
..sample_request(false)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
let captured = state.lock().await;
|
||||||
|
assert_eq!(
|
||||||
|
captured.len(),
|
||||||
|
2,
|
||||||
|
"count_tokens and messages requests should be captured"
|
||||||
|
);
|
||||||
|
let count_tokens_body: serde_json::Value =
|
||||||
|
serde_json::from_str(&captured[0].body).expect("count_tokens body should be json");
|
||||||
|
let messages_body: serde_json::Value =
|
||||||
|
serde_json::from_str(&captured[1].body).expect("request body should be json");
|
||||||
|
assert_eq!(captured[0].path, "/v1/messages/count_tokens");
|
||||||
|
assert_eq!(captured[1].path, "/v1/messages");
|
||||||
|
assert_eq!(count_tokens_body["model"], json!("claude-opus-4-6"));
|
||||||
|
assert_eq!(messages_body["model"], json!("claude-opus-4-6"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn send_message_blocks_oversized_requests_before_the_http_call() {
|
async fn send_message_blocks_oversized_requests_before_the_http_call() {
|
||||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||||
|
|||||||
@@ -159,10 +159,15 @@ async fn send_message_preserves_deepseek_reasoning_content_before_text() {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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["thinking"], json!({"type": "enabled"}));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn custom_openai_gateway_preserves_slash_model_ids_and_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()));
|
||||||
let body = concat!(
|
let body = concat!(
|
||||||
"{",
|
"{",
|
||||||
@@ -206,7 +211,7 @@ async fn custom_openai_gateway_preserves_slash_model_ids_and_extra_body_params()
|
|||||||
let captured = state.lock().await;
|
let captured = state.lock().await;
|
||||||
let request = captured.first().expect("captured request");
|
let request = captured.first().expect("captured request");
|
||||||
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
|
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
|
||||||
assert_eq!(body["model"], json!("openai/gpt-4.1-mini"));
|
assert_eq!(body["model"], json!("gpt-4.1-mini"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
body["web_search_options"],
|
body["web_search_options"],
|
||||||
json!({"search_context_size": "low"})
|
json!({"search_context_size": "low"})
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -330,20 +330,24 @@ fn prepare_tokio_command(
|
|||||||
prepare_sandbox_dirs(cwd);
|
prepare_sandbox_dirs(cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut prepared =
|
||||||
if let Some(launcher) = build_linux_sandbox_command(command, cwd, sandbox_status) {
|
if let Some(launcher) = build_linux_sandbox_command(command, cwd, sandbox_status) {
|
||||||
let mut prepared = TokioCommand::new(launcher.program);
|
let mut cmd = TokioCommand::new(launcher.program);
|
||||||
prepared.args(launcher.args);
|
cmd.args(launcher.args);
|
||||||
prepared.current_dir(cwd);
|
cmd.envs(launcher.env);
|
||||||
prepared.envs(launcher.env);
|
cmd
|
||||||
return prepared;
|
} else {
|
||||||
}
|
let mut cmd = TokioCommand::new("sh");
|
||||||
|
cmd.arg("-lc").arg(command);
|
||||||
let mut prepared = TokioCommand::new("sh");
|
|
||||||
prepared.arg("-lc").arg(command).current_dir(cwd);
|
|
||||||
if sandbox_status.filesystem_active {
|
if sandbox_status.filesystem_active {
|
||||||
prepared.env("HOME", cwd.join(".sandbox-home"));
|
cmd.env("HOME", cwd.join(".sandbox-home"));
|
||||||
prepared.env("TMPDIR", cwd.join(".sandbox-tmp"));
|
cmd.env("TMPDIR", cwd.join(".sandbox-tmp"));
|
||||||
}
|
}
|
||||||
|
cmd
|
||||||
|
};
|
||||||
|
|
||||||
|
prepared.current_dir(cwd);
|
||||||
|
prepared.stdin(Stdio::null());
|
||||||
prepared
|
prepared
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,6 +423,27 @@ mod tests {
|
|||||||
assert_eq!(structured[0]["event"], "test.hung");
|
assert_eq!(structured[0]["event"], "test.hung");
|
||||||
assert_eq!(structured[0]["data"]["provenance"], "bash.timeout");
|
assert_eq!(structured[0]["data"]["provenance"], "bash.timeout");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prevents_stdin_hangs_by_redirecting_to_null() {
|
||||||
|
let output = execute_bash(BashCommandInput {
|
||||||
|
command: String::from("cat"),
|
||||||
|
timeout: Some(2_000),
|
||||||
|
description: None,
|
||||||
|
run_in_background: Some(false),
|
||||||
|
dangerously_disable_sandbox: Some(true),
|
||||||
|
namespace_restrictions: None,
|
||||||
|
isolate_network: None,
|
||||||
|
filesystem_mode: None,
|
||||||
|
allowed_mounts: None,
|
||||||
|
})
|
||||||
|
.expect("bash command should execute cleanly");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!output.interrupted,
|
||||||
|
"Command hung and was cut off by the timeout!"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maximum output bytes before truncation (16 KiB, matching upstream).
|
/// Maximum output bytes before truncation (16 KiB, matching upstream).
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -92,6 +92,8 @@ enum FieldType {
|
|||||||
Bool,
|
Bool,
|
||||||
Object,
|
Object,
|
||||||
StringArray,
|
StringArray,
|
||||||
|
HookArray,
|
||||||
|
RulesImport,
|
||||||
Number,
|
Number,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +104,8 @@ impl FieldType {
|
|||||||
Self::Bool => "a boolean",
|
Self::Bool => "a boolean",
|
||||||
Self::Object => "an object",
|
Self::Object => "an object",
|
||||||
Self::StringArray => "an array of strings",
|
Self::StringArray => "an array of strings",
|
||||||
|
Self::RulesImport => "a string or an array of strings",
|
||||||
|
Self::HookArray => "an array of strings or hook objects",
|
||||||
Self::Number => "a number",
|
Self::Number => "a number",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,6 +118,13 @@ 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::RulesImport => {
|
||||||
|
value.as_str().is_some()
|
||||||
|
|| value
|
||||||
|
.as_array()
|
||||||
|
.is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some()))
|
||||||
|
}
|
||||||
Self::Number => value.as_i64().is_some(),
|
Self::Number => value.as_i64().is_some(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,20 +212,24 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
|
|||||||
name: "provider",
|
name: "provider",
|
||||||
expected: FieldType::Object,
|
expected: FieldType::Object,
|
||||||
},
|
},
|
||||||
|
FieldSpec {
|
||||||
|
name: "rulesImport",
|
||||||
|
expected: FieldType::RulesImport,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const HOOKS_FIELDS: &[FieldSpec] = &[
|
const HOOKS_FIELDS: &[FieldSpec] = &[
|
||||||
FieldSpec {
|
FieldSpec {
|
||||||
name: "PreToolUse",
|
name: "PreToolUse",
|
||||||
expected: FieldType::StringArray,
|
expected: FieldType::HookArray,
|
||||||
},
|
},
|
||||||
FieldSpec {
|
FieldSpec {
|
||||||
name: "PostToolUse",
|
name: "PostToolUse",
|
||||||
expected: FieldType::StringArray,
|
expected: FieldType::HookArray,
|
||||||
},
|
},
|
||||||
FieldSpec {
|
FieldSpec {
|
||||||
name: "PostToolUseFailure",
|
name: "PostToolUseFailure",
|
||||||
expected: FieldType::StringArray,
|
expected: FieldType::HookArray,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -406,9 +421,10 @@ 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.
|
// 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.errors.push(ConfigDiagnostic {
|
result.warnings.push(ConfigDiagnostic {
|
||||||
path: path_display.to_string(),
|
path: path_display.to_string(),
|
||||||
field: field_path,
|
field: field_path,
|
||||||
line: find_key_line(source, key),
|
line: find_key_line(source, key),
|
||||||
@@ -420,8 +436,56 @@ fn validate_object_keys(
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Emit deprecation warnings for bare string hook entries in the hooks object.
|
||||||
|
/// Legacy `["command-string"]` arrays still load but suggest migration to the
|
||||||
|
/// structured `{matcher, hooks:[{type, command}]}` form.
|
||||||
|
fn validate_hook_entry_format(
|
||||||
|
hooks: &BTreeMap<String, JsonValue>,
|
||||||
|
source: &str,
|
||||||
|
path_display: &str,
|
||||||
|
) -> ValidationResult {
|
||||||
|
let mut result = ValidationResult {
|
||||||
|
errors: Vec::new(),
|
||||||
|
warnings: Vec::new(),
|
||||||
|
};
|
||||||
|
for spec in HOOKS_FIELDS {
|
||||||
|
let Some(value) = hooks.get(spec.name) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(array) = value.as_array() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for item in array {
|
||||||
|
if item.as_str().is_some() {
|
||||||
|
result.warnings.push(ConfigDiagnostic {
|
||||||
|
path: path_display.to_string(),
|
||||||
|
field: format!("hooks.{}", spec.name),
|
||||||
|
line: find_key_line(source, spec.name),
|
||||||
|
kind: DiagnosticKind::Deprecated {
|
||||||
|
replacement: "object-style hook entries with hooks:[{type:\"command\",command:\"...\"}]",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// One deprecation warning per event is enough
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
fn suggest_field(input: &str, candidates: &[&str]) -> Option<String> {
|
fn suggest_field(input: &str, candidates: &[&str]) -> Option<String> {
|
||||||
let input_lower = input.to_ascii_lowercase();
|
let input_lower = input.to_ascii_lowercase();
|
||||||
|
// #461: prefix-aware matching — if input is a prefix of a candidate,
|
||||||
|
// treat it as distance 0 (perfect prefix match) to avoid edit-distance
|
||||||
|
// misranking (e.g., "mcp" → "env" instead of "mcpServers").
|
||||||
|
let prefix_match = candidates
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.to_ascii_lowercase().starts_with(&input_lower))
|
||||||
|
.min_by_key(|c| c.len())
|
||||||
|
.map(|name| name.to_string());
|
||||||
|
if prefix_match.is_some() {
|
||||||
|
return prefix_match;
|
||||||
|
}
|
||||||
candidates
|
candidates
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|candidate| {
|
.filter_map(|candidate| {
|
||||||
@@ -491,6 +555,7 @@ pub fn validate_config_file(
|
|||||||
source,
|
source,
|
||||||
&path_display,
|
&path_display,
|
||||||
));
|
));
|
||||||
|
result.merge(validate_hook_entry_format(hooks, source, &path_display));
|
||||||
}
|
}
|
||||||
if let Some(permissions) = object.get("permissions").and_then(JsonValue::as_object) {
|
if let Some(permissions) = object.get("permissions").and_then(JsonValue::as_object) {
|
||||||
result.merge(validate_object_keys(
|
result.merge(validate_object_keys(
|
||||||
@@ -587,10 +652,11 @@ mod tests {
|
|||||||
let result = validate_config_file(object, source, &test_path());
|
let result = validate_config_file(object, source, &test_path());
|
||||||
|
|
||||||
// then
|
// then
|
||||||
assert_eq!(result.errors.len(), 1);
|
assert!(result.errors.is_empty());
|
||||||
assert_eq!(result.errors[0].field, "unknownField");
|
assert_eq!(result.warnings.len(), 1);
|
||||||
|
assert_eq!(result.warnings[0].field, "unknownField");
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
result.errors[0].kind,
|
result.warnings[0].kind,
|
||||||
DiagnosticKind::UnknownKey { .. }
|
DiagnosticKind::UnknownKey { .. }
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -670,9 +736,10 @@ mod tests {
|
|||||||
let result = validate_config_file(object, source, &test_path());
|
let result = validate_config_file(object, source, &test_path());
|
||||||
|
|
||||||
// then
|
// then
|
||||||
assert_eq!(result.errors.len(), 1);
|
assert!(result.errors.is_empty());
|
||||||
assert_eq!(result.errors[0].line, Some(3));
|
assert_eq!(result.warnings.len(), 1);
|
||||||
assert_eq!(result.errors[0].field, "badKey");
|
assert_eq!(result.warnings[0].line, Some(3));
|
||||||
|
assert_eq!(result.warnings[0].field, "badKey");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -693,7 +760,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn validates_nested_hooks_keys() {
|
fn validates_nested_hooks_keys() {
|
||||||
// given
|
// given
|
||||||
let source = r#"{"hooks": {"PreToolUse": ["cmd"], "BadHook": ["x"]}}"#;
|
let source = r#"{"hooks": {"PreToolUse": [{"hooks":[{"type":"command","command":"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");
|
||||||
|
|
||||||
@@ -701,8 +768,64 @@ mod tests {
|
|||||||
let result = validate_config_file(object, source, &test_path());
|
let result = validate_config_file(object, source, &test_path());
|
||||||
|
|
||||||
// then
|
// then
|
||||||
|
assert!(result.errors.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
result.warnings.len(),
|
||||||
|
1,
|
||||||
|
"expected only the unknown key warning, got {:?}",
|
||||||
|
result.warnings
|
||||||
|
);
|
||||||
|
assert_eq!(result.warnings[0].field, "hooks.BadHook");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_object_style_hook_entries() {
|
||||||
|
let source = r#"{"hooks":{"PreToolUse":["legacy",{"matcher":"Bash","hooks":[{"type":"command","command":"echo ok"}]}]}}"#;
|
||||||
|
let parsed = JsonValue::parse(source).expect("valid json");
|
||||||
|
let object = parsed.as_object().expect("object");
|
||||||
|
|
||||||
|
let result = validate_config_file(object, source, &test_path());
|
||||||
|
|
||||||
|
assert!(result.errors.is_empty(), "{:?}", result.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allows_wrong_hook_entry_types_for_partial_runtime_validation_441() {
|
||||||
|
let source = r#"{"hooks":{"PreToolUse":[42]}}"#;
|
||||||
|
let parsed = JsonValue::parse(source).expect("valid json");
|
||||||
|
let object = parsed.as_object().expect("object");
|
||||||
|
|
||||||
|
let result = validate_config_file(object, source, &test_path());
|
||||||
|
|
||||||
|
assert!(result.errors.is_empty(), "{:?}", result.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_rules_import_string_and_array_forms() {
|
||||||
|
for source in [
|
||||||
|
r#"{"rulesImport":"auto"}"#,
|
||||||
|
r#"{"rulesImport":"none"}"#,
|
||||||
|
r#"{"rulesImport":["cursor","copilot"]}"#,
|
||||||
|
] {
|
||||||
|
let parsed = JsonValue::parse(source).expect("valid json");
|
||||||
|
let object = parsed.as_object().expect("object");
|
||||||
|
|
||||||
|
let result = validate_config_file(object, source, &test_path());
|
||||||
|
|
||||||
|
assert!(result.errors.is_empty(), "{source}: {:?}", result.errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_rules_import_wrong_type() {
|
||||||
|
let source = r#"{"rulesImport":42}"#;
|
||||||
|
let parsed = JsonValue::parse(source).expect("valid json");
|
||||||
|
let object = parsed.as_object().expect("object");
|
||||||
|
|
||||||
|
let result = validate_config_file(object, source, &test_path());
|
||||||
|
|
||||||
assert_eq!(result.errors.len(), 1);
|
assert_eq!(result.errors.len(), 1);
|
||||||
assert_eq!(result.errors[0].field, "hooks.BadHook");
|
assert_eq!(result.errors[0].field, "rulesImport");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -716,8 +839,9 @@ mod tests {
|
|||||||
let result = validate_config_file(object, source, &test_path());
|
let result = validate_config_file(object, source, &test_path());
|
||||||
|
|
||||||
// then
|
// then
|
||||||
assert_eq!(result.errors.len(), 1);
|
assert!(result.errors.is_empty());
|
||||||
assert_eq!(result.errors[0].field, "permissions.denyAll");
|
assert_eq!(result.warnings.len(), 1);
|
||||||
|
assert_eq!(result.warnings[0].field, "permissions.denyAll");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -731,8 +855,9 @@ mod tests {
|
|||||||
let result = validate_config_file(object, source, &test_path());
|
let result = validate_config_file(object, source, &test_path());
|
||||||
|
|
||||||
// then
|
// then
|
||||||
assert_eq!(result.errors.len(), 1);
|
assert!(result.errors.is_empty());
|
||||||
assert_eq!(result.errors[0].field, "sandbox.containerMode");
|
assert_eq!(result.warnings.len(), 1);
|
||||||
|
assert_eq!(result.warnings[0].field, "sandbox.containerMode");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -746,8 +871,9 @@ mod tests {
|
|||||||
let result = validate_config_file(object, source, &test_path());
|
let result = validate_config_file(object, source, &test_path());
|
||||||
|
|
||||||
// then
|
// then
|
||||||
assert_eq!(result.errors.len(), 1);
|
assert!(result.errors.is_empty());
|
||||||
assert_eq!(result.errors[0].field, "plugins.autoUpdate");
|
assert_eq!(result.warnings.len(), 1);
|
||||||
|
assert_eq!(result.warnings[0].field, "plugins.autoUpdate");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -761,8 +887,9 @@ mod tests {
|
|||||||
let result = validate_config_file(object, source, &test_path());
|
let result = validate_config_file(object, source, &test_path());
|
||||||
|
|
||||||
// then
|
// then
|
||||||
assert_eq!(result.errors.len(), 1);
|
assert!(result.errors.is_empty());
|
||||||
assert_eq!(result.errors[0].field, "oauth.secret");
|
assert_eq!(result.warnings.len(), 1);
|
||||||
|
assert_eq!(result.warnings[0].field, "oauth.secret");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -770,7 +897,7 @@ mod tests {
|
|||||||
// given
|
// given
|
||||||
let source = r#"{
|
let source = r#"{
|
||||||
"model": "opus",
|
"model": "opus",
|
||||||
"hooks": {"PreToolUse": ["guard"]},
|
"hooks": {"PreToolUse": [{"hooks":[{"type":"command","command":"guard"}]}]},
|
||||||
"permissions": {"defaultMode": "plan", "allow": ["Read"]},
|
"permissions": {"defaultMode": "plan", "allow": ["Read"]},
|
||||||
"mcpServers": {},
|
"mcpServers": {},
|
||||||
"sandbox": {"enabled": false}
|
"sandbox": {"enabled": false}
|
||||||
@@ -797,8 +924,9 @@ mod tests {
|
|||||||
let result = validate_config_file(object, source, &test_path());
|
let result = validate_config_file(object, source, &test_path());
|
||||||
|
|
||||||
// then
|
// then
|
||||||
assert_eq!(result.errors.len(), 1);
|
assert!(result.errors.is_empty());
|
||||||
match &result.errors[0].kind {
|
assert_eq!(result.warnings.len(), 1);
|
||||||
|
match &result.warnings[0].kind {
|
||||||
DiagnosticKind::UnknownKey {
|
DiagnosticKind::UnknownKey {
|
||||||
suggestion: Some(s),
|
suggestion: Some(s),
|
||||||
} => assert_eq!(s, "model"),
|
} => assert_eq!(s, "model"),
|
||||||
@@ -809,7 +937,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn format_diagnostics_includes_all_entries() {
|
fn format_diagnostics_includes_all_entries() {
|
||||||
// given
|
// given
|
||||||
let source = r#"{"permissionMode": "plan", "badKey": 1}"#;
|
let source = r#"{"model": 42, "badKey": 1}"#;
|
||||||
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());
|
||||||
@@ -821,7 +949,7 @@ mod tests {
|
|||||||
assert!(output.contains("warning:"));
|
assert!(output.contains("warning:"));
|
||||||
assert!(output.contains("error:"));
|
assert!(output.contains("error:"));
|
||||||
assert!(output.contains("badKey"));
|
assert!(output.contains("badKey"));
|
||||||
assert!(output.contains("permissionMode"));
|
assert!(output.contains("model"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -204,6 +204,13 @@ 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;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
|
use crate::config::{RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig};
|
||||||
use crate::permissions::PermissionOverride;
|
use crate::permissions::PermissionOverride;
|
||||||
|
|
||||||
const HOOK_PREVIEW_CHAR_LIMIT: usize = 160;
|
const HOOK_PREVIEW_CHAR_LIMIT: usize = 160;
|
||||||
@@ -182,7 +182,7 @@ impl HookRunner {
|
|||||||
) -> HookRunResult {
|
) -> HookRunResult {
|
||||||
Self::run_commands(
|
Self::run_commands(
|
||||||
HookEvent::PreToolUse,
|
HookEvent::PreToolUse,
|
||||||
self.config.pre_tool_use(),
|
self.config.pre_tool_use_entries(),
|
||||||
tool_name,
|
tool_name,
|
||||||
tool_input,
|
tool_input,
|
||||||
None,
|
None,
|
||||||
@@ -232,7 +232,7 @@ impl HookRunner {
|
|||||||
) -> HookRunResult {
|
) -> HookRunResult {
|
||||||
Self::run_commands(
|
Self::run_commands(
|
||||||
HookEvent::PostToolUse,
|
HookEvent::PostToolUse,
|
||||||
self.config.post_tool_use(),
|
self.config.post_tool_use_entries(),
|
||||||
tool_name,
|
tool_name,
|
||||||
tool_input,
|
tool_input,
|
||||||
Some(tool_output),
|
Some(tool_output),
|
||||||
@@ -282,7 +282,7 @@ impl HookRunner {
|
|||||||
) -> HookRunResult {
|
) -> HookRunResult {
|
||||||
Self::run_commands(
|
Self::run_commands(
|
||||||
HookEvent::PostToolUseFailure,
|
HookEvent::PostToolUseFailure,
|
||||||
self.config.post_tool_use_failure(),
|
self.config.post_tool_use_failure_entries(),
|
||||||
tool_name,
|
tool_name,
|
||||||
tool_input,
|
tool_input,
|
||||||
Some(tool_error),
|
Some(tool_error),
|
||||||
@@ -312,7 +312,7 @@ impl HookRunner {
|
|||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn run_commands(
|
fn run_commands(
|
||||||
event: HookEvent,
|
event: HookEvent,
|
||||||
commands: &[String],
|
commands: &[RuntimeHookCommand],
|
||||||
tool_name: &str,
|
tool_name: &str,
|
||||||
tool_input: &str,
|
tool_input: &str,
|
||||||
tool_output: Option<&str>,
|
tool_output: Option<&str>,
|
||||||
@@ -342,17 +342,21 @@ impl HookRunner {
|
|||||||
let payload = hook_payload(event, tool_name, tool_input, tool_output, is_error).to_string();
|
let payload = hook_payload(event, tool_name, tool_input, tool_output, is_error).to_string();
|
||||||
let mut result = HookRunResult::allow(Vec::new());
|
let mut result = HookRunResult::allow(Vec::new());
|
||||||
|
|
||||||
for command in commands {
|
for command in commands
|
||||||
|
.iter()
|
||||||
|
.filter(|command| command.matches_tool(tool_name))
|
||||||
|
{
|
||||||
|
let command_text = command.command();
|
||||||
if let Some(reporter) = reporter.as_deref_mut() {
|
if let Some(reporter) = reporter.as_deref_mut() {
|
||||||
reporter.on_event(&HookProgressEvent::Started {
|
reporter.on_event(&HookProgressEvent::Started {
|
||||||
event,
|
event,
|
||||||
tool_name: tool_name.to_string(),
|
tool_name: tool_name.to_string(),
|
||||||
command: command.clone(),
|
command: command_text.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
match Self::run_command(
|
match Self::run_command(
|
||||||
command,
|
command_text,
|
||||||
event,
|
event,
|
||||||
tool_name,
|
tool_name,
|
||||||
tool_input,
|
tool_input,
|
||||||
@@ -366,7 +370,7 @@ impl HookRunner {
|
|||||||
reporter.on_event(&HookProgressEvent::Completed {
|
reporter.on_event(&HookProgressEvent::Completed {
|
||||||
event,
|
event,
|
||||||
tool_name: tool_name.to_string(),
|
tool_name: tool_name.to_string(),
|
||||||
command: command.clone(),
|
command: command_text.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
merge_parsed_hook_output(&mut result, parsed);
|
merge_parsed_hook_output(&mut result, parsed);
|
||||||
@@ -376,7 +380,7 @@ impl HookRunner {
|
|||||||
reporter.on_event(&HookProgressEvent::Completed {
|
reporter.on_event(&HookProgressEvent::Completed {
|
||||||
event,
|
event,
|
||||||
tool_name: tool_name.to_string(),
|
tool_name: tool_name.to_string(),
|
||||||
command: command.clone(),
|
command: command_text.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
merge_parsed_hook_output(&mut result, parsed);
|
merge_parsed_hook_output(&mut result, parsed);
|
||||||
@@ -388,7 +392,7 @@ impl HookRunner {
|
|||||||
reporter.on_event(&HookProgressEvent::Completed {
|
reporter.on_event(&HookProgressEvent::Completed {
|
||||||
event,
|
event,
|
||||||
tool_name: tool_name.to_string(),
|
tool_name: tool_name.to_string(),
|
||||||
command: command.clone(),
|
command: command_text.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
merge_parsed_hook_output(&mut result, parsed);
|
merge_parsed_hook_output(&mut result, parsed);
|
||||||
@@ -400,7 +404,7 @@ impl HookRunner {
|
|||||||
reporter.on_event(&HookProgressEvent::Cancelled {
|
reporter.on_event(&HookProgressEvent::Cancelled {
|
||||||
event,
|
event,
|
||||||
tool_name: tool_name.to_string(),
|
tool_name: tool_name.to_string(),
|
||||||
command: command.clone(),
|
command: command_text.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
result.cancelled = true;
|
result.cancelled = true;
|
||||||
@@ -737,7 +741,7 @@ fn format_hook_failure(command: &str, code: i32, stdout: Option<&str>, stderr: &
|
|||||||
|
|
||||||
fn shell_command(command: &str) -> CommandWithStdin {
|
fn shell_command(command: &str) -> CommandWithStdin {
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
let mut command_builder = {
|
let command_builder = {
|
||||||
let mut command_builder = Command::new("cmd");
|
let mut command_builder = Command::new("cmd");
|
||||||
command_builder.arg("/C").arg(command);
|
command_builder.arg("/C").arg(command);
|
||||||
CommandWithStdin::new(command_builder)
|
CommandWithStdin::new(command_builder)
|
||||||
@@ -825,7 +829,7 @@ mod tests {
|
|||||||
HookAbortSignal, HookEvent, HookProgressEvent, HookProgressReporter, HookRunResult,
|
HookAbortSignal, HookEvent, HookProgressEvent, HookProgressReporter, HookRunResult,
|
||||||
HookRunner,
|
HookRunner,
|
||||||
};
|
};
|
||||||
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
|
use crate::config::{RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig};
|
||||||
use crate::permissions::PermissionOverride;
|
use crate::permissions::PermissionOverride;
|
||||||
|
|
||||||
struct RecordingReporter {
|
struct RecordingReporter {
|
||||||
@@ -851,6 +855,37 @@ mod tests {
|
|||||||
assert_eq!(result, HookRunResult::allow(vec!["pre ok".to_string()]));
|
assert_eq!(result, HookRunResult::allow(vec!["pre ok".to_string()]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_style_hook_matchers_filter_runtime_execution() {
|
||||||
|
let runner = HookRunner::new(RuntimeHookConfig::from_hook_commands(
|
||||||
|
vec![
|
||||||
|
RuntimeHookCommand::new(shell_snippet("printf 'legacy'")),
|
||||||
|
RuntimeHookCommand::with_matcher(
|
||||||
|
shell_snippet("printf 'bash only'"),
|
||||||
|
Some("Bash".to_string()),
|
||||||
|
),
|
||||||
|
RuntimeHookCommand::with_matcher(
|
||||||
|
shell_snippet("printf 'read only'"),
|
||||||
|
Some("Read*".to_string()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let read_result = runner.run_pre_tool_use("ReadFile", r#"{"path":"README.md"}"#);
|
||||||
|
let bash_result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
read_result,
|
||||||
|
HookRunResult::allow(vec!["legacy".to_string(), "read only".to_string()])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
bash_result,
|
||||||
|
HookRunResult::allow(vec!["legacy".to_string(), "bash only".to_string()])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn denies_exit_code_two() {
|
fn denies_exit_code_two() {
|
||||||
let runner = HookRunner::new(RuntimeHookConfig::new(
|
let runner = HookRunner::new(RuntimeHookConfig::new(
|
||||||
|
|||||||
@@ -65,12 +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::{
|
||||||
suppress_config_warnings_for_json_mode, ConfigEntry, ConfigError, ConfigLoader, ConfigSource,
|
suppress_config_warnings_for_json_mode, ApiTimeoutConfig, ConfigEntry, ConfigError,
|
||||||
McpConfigCollection, McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig,
|
ConfigFileReport, ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource,
|
||||||
McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport,
|
McpConfigCollection, McpInvalidServerConfig, McpManagedProxyServerConfig, McpOAuthConfig,
|
||||||
|
McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport,
|
||||||
McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode,
|
McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode,
|
||||||
RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig, RuntimePermissionRuleConfig,
|
RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig,
|
||||||
RuntimePluginConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
|
RuntimeInvalidHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig,
|
||||||
|
ScopedMcpServerConfig, 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,
|
||||||
@@ -141,8 +143,9 @@ pub use policy_engine::{
|
|||||||
PolicyEvaluation, PolicyRule, ReconcileReason, ReviewStatus,
|
PolicyEvaluation, PolicyRule, ReconcileReason, ReviewStatus,
|
||||||
};
|
};
|
||||||
pub use prompt::{
|
pub use prompt::{
|
||||||
load_system_prompt, prepend_bullets, ContextFile, ModelFamilyIdentity, ProjectContext,
|
load_system_prompt, load_system_prompt_with_context, prepend_bullets, ContextFile,
|
||||||
PromptBuildError, SystemPromptBuilder, FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
ModelFamilyIdentity, ProjectContext, PromptBuildError, SystemPromptBuilder,
|
||||||
|
FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
||||||
};
|
};
|
||||||
pub use recovery_recipes::{
|
pub use recovery_recipes::{
|
||||||
attempt_recovery, recipe_for, EscalationPolicy, FailureScenario, RecoveryAttemptState,
|
attempt_recovery, recipe_for, EscalationPolicy, FailureScenario, RecoveryAttemptState,
|
||||||
|
|||||||
@@ -173,32 +173,112 @@ impl PermissionEnforcer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simple workspace boundary check via string prefix.
|
/// Workspace boundary check.
|
||||||
|
///
|
||||||
|
/// Resolves `.` and `..` components lexically *before* comparing against the
|
||||||
|
/// workspace root, so that traversal sequences like `/workspace/../../etc`
|
||||||
|
/// cannot escape the sandbox via a naive string prefix match. Normalization is
|
||||||
|
/// lexical (it does not touch the filesystem) because the target path may not
|
||||||
|
/// exist yet on a write, and we must not depend on CWD.
|
||||||
fn is_within_workspace(path: &str, workspace_root: &str) -> bool {
|
fn is_within_workspace(path: &str, workspace_root: &str) -> bool {
|
||||||
let normalized = if path.starts_with('/') {
|
let combined = if path.starts_with('/') {
|
||||||
path.to_owned()
|
path.to_owned()
|
||||||
} else {
|
} else {
|
||||||
format!("{workspace_root}/{path}")
|
format!("{workspace_root}/{path}")
|
||||||
};
|
};
|
||||||
|
|
||||||
let root = if workspace_root.ends_with('/') {
|
let normalized = lexically_normalize(&combined);
|
||||||
workspace_root.to_owned()
|
let root = lexically_normalize(workspace_root);
|
||||||
|
let root_with_slash = if root.ends_with('/') {
|
||||||
|
root.clone()
|
||||||
} else {
|
} else {
|
||||||
format!("{workspace_root}/")
|
format!("{root}/")
|
||||||
};
|
};
|
||||||
|
|
||||||
normalized.starts_with(&root) || normalized == workspace_root.trim_end_matches('/')
|
normalized == root || normalized.starts_with(&root_with_slash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collapse `.` and `..` segments without consulting the filesystem.
|
||||||
|
/// `..` that would climb above an absolute root is clamped at `/`, so the
|
||||||
|
/// result can never be a prefix-match for a deeper workspace root.
|
||||||
|
fn lexically_normalize(path: &str) -> String {
|
||||||
|
let is_absolute = path.starts_with('/');
|
||||||
|
let mut stack: Vec<&str> = Vec::new();
|
||||||
|
for component in path.split('/') {
|
||||||
|
match component {
|
||||||
|
"" | "." => {}
|
||||||
|
".." => {
|
||||||
|
stack.pop();
|
||||||
|
}
|
||||||
|
other => stack.push(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let joined = stack.join("/");
|
||||||
|
if is_absolute {
|
||||||
|
format!("/{joined}")
|
||||||
|
} else {
|
||||||
|
joined
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Conservative heuristic: is this bash command read-only?
|
/// 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 {
|
||||||
let first_token = command
|
// Shell metacharacters that enable command chaining, substitution,
|
||||||
.split_whitespace()
|
// piping, redirection, or subshells. Presence of any of these means we
|
||||||
.next()
|
// cannot reason about the command from its leading token alone.
|
||||||
.unwrap_or("")
|
const SHELL_METACHARS: &[char] = &[';', '|', '&', '$', '`', '>', '<', '(', ')', '{', '}', '\n'];
|
||||||
.rsplit('/')
|
if command.contains(SHELL_METACHARS) {
|
||||||
.next()
|
return false;
|
||||||
.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,
|
||||||
@@ -237,8 +317,6 @@ fn is_read_only_command(command: &str) -> bool {
|
|||||||
| "tr"
|
| "tr"
|
||||||
| "cut"
|
| "cut"
|
||||||
| "paste"
|
| "paste"
|
||||||
| "tee"
|
|
||||||
| "xargs"
|
|
||||||
| "test"
|
| "test"
|
||||||
| "true"
|
| "true"
|
||||||
| "false"
|
| "false"
|
||||||
@@ -257,18 +335,8 @@ 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)]
|
||||||
@@ -375,6 +443,91 @@ 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,7 +149,12 @@ impl PermissionPolicy {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|rule| PermissionRule::parse(rule))
|
.map(|rule| PermissionRule::parse(rule))
|
||||||
.collect();
|
.collect();
|
||||||
self.denied_tools = config.denied_tools().to_vec();
|
// #94: normalize denied tool names to lowercase to match runtime convention
|
||||||
|
self.denied_tools = config
|
||||||
|
.denied_tools()
|
||||||
|
.iter()
|
||||||
|
.map(|t| t.to_lowercase())
|
||||||
|
.collect();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,7 +380,8 @@ 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(),
|
||||||
tool_name: tool_name.to_string(),
|
// #94: normalize tool name to lowercase to match runtime convention
|
||||||
|
tool_name: tool_name.to_lowercase(),
|
||||||
matcher,
|
matcher,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -384,7 +390,8 @@ impl PermissionRule {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
raw: trimmed.to_string(),
|
raw: trimmed.to_string(),
|
||||||
tool_name: trimmed.to_string(),
|
// #94: normalize tool name to lowercase to match runtime convention
|
||||||
|
tool_name: trimmed.to_lowercase(),
|
||||||
matcher: PermissionRuleMatcher::Any,
|
matcher: PermissionRuleMatcher::Any,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::hash::{Hash, Hasher};
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
use crate::config::{ConfigError, ConfigLoader, RuntimeConfig};
|
use crate::config::{ConfigError, ConfigLoader, RulesImportConfig, RuntimeConfig};
|
||||||
use crate::git_context::GitContext;
|
use crate::git_context::GitContext;
|
||||||
|
|
||||||
/// Errors raised while assembling the final system prompt.
|
/// Errors raised while assembling the final system prompt.
|
||||||
@@ -69,6 +69,18 @@ pub struct ContextFile {
|
|||||||
pub content: String,
|
pub content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ContextFile {
|
||||||
|
#[must_use]
|
||||||
|
pub fn source(&self) -> &'static str {
|
||||||
|
instruction_file_source(&self.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn char_count(&self) -> usize {
|
||||||
|
self.content.chars().count()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Project-local context injected into the rendered system prompt.
|
/// Project-local context injected into the rendered system prompt.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
pub struct ProjectContext {
|
pub struct ProjectContext {
|
||||||
@@ -86,7 +98,24 @@ impl ProjectContext {
|
|||||||
current_date: impl Into<String>,
|
current_date: impl Into<String>,
|
||||||
) -> std::io::Result<Self> {
|
) -> std::io::Result<Self> {
|
||||||
let cwd = cwd.into();
|
let cwd = cwd.into();
|
||||||
let instruction_files = discover_instruction_files(&cwd)?;
|
let instruction_files = discover_instruction_files(&cwd, &RulesImportConfig::default())?;
|
||||||
|
Ok(Self {
|
||||||
|
cwd,
|
||||||
|
current_date: current_date.into(),
|
||||||
|
git_status: None,
|
||||||
|
git_diff: None,
|
||||||
|
git_context: None,
|
||||||
|
instruction_files,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn discover_with_rules_import(
|
||||||
|
cwd: impl Into<PathBuf>,
|
||||||
|
current_date: impl Into<String>,
|
||||||
|
rules_import: &RulesImportConfig,
|
||||||
|
) -> std::io::Result<Self> {
|
||||||
|
let cwd = cwd.into();
|
||||||
|
let instruction_files = discover_instruction_files(&cwd, rules_import)?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
cwd,
|
cwd,
|
||||||
current_date: current_date.into(),
|
current_date: current_date.into(),
|
||||||
@@ -109,6 +138,18 @@ impl ProjectContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn discover_with_git_and_rules_import(
|
||||||
|
cwd: impl Into<PathBuf>,
|
||||||
|
current_date: impl Into<String>,
|
||||||
|
rules_import: &RulesImportConfig,
|
||||||
|
) -> std::io::Result<ProjectContext> {
|
||||||
|
let mut context = ProjectContext::discover_with_rules_import(cwd, current_date, rules_import)?;
|
||||||
|
context.git_status = read_git_status(&context.cwd);
|
||||||
|
context.git_diff = read_git_diff(&context.cwd);
|
||||||
|
context.git_context = GitContext::detect(&context.cwd);
|
||||||
|
Ok(context)
|
||||||
|
}
|
||||||
|
|
||||||
/// Builder for the runtime system prompt and dynamic environment sections.
|
/// Builder for the runtime system prompt and dynamic environment sections.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
pub struct SystemPromptBuilder {
|
pub struct SystemPromptBuilder {
|
||||||
@@ -227,30 +268,81 @@ pub fn prepend_bullets(items: Vec<String>) -> Vec<String> {
|
|||||||
items.into_iter().map(|item| format!(" - {item}")).collect()
|
items.into_iter().map(|item| format!(" - {item}")).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
|
fn instruction_file_source(path: &Path) -> &'static str {
|
||||||
let mut directories = Vec::new();
|
let file_name = path.file_name().and_then(|name| name.to_str());
|
||||||
let mut cursor = Some(cwd);
|
let parent_name = path
|
||||||
while let Some(dir) = cursor {
|
.parent()
|
||||||
directories.push(dir.to_path_buf());
|
.and_then(|parent| parent.file_name())
|
||||||
cursor = dir.parent();
|
.and_then(|name| name.to_str());
|
||||||
|
|
||||||
|
match (parent_name, file_name) {
|
||||||
|
(Some(".claw"), Some("CLAUDE.md")) => "claw_claude_md",
|
||||||
|
(Some(".claude"), Some("CLAUDE.md")) => "claude_claude_md",
|
||||||
|
(_, Some("CLAUDE.md")) => "claude_md",
|
||||||
|
(_, Some("CLAW.md")) => "claw_md",
|
||||||
|
(_, Some("AGENTS.md")) => "agents_md",
|
||||||
|
(_, Some("CLAUDE.local.md")) => "claude_local_md",
|
||||||
|
(Some(".claw"), Some("instructions.md")) => "claw_instructions",
|
||||||
|
_ => "rule_file",
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
fn discover_instruction_files(
|
||||||
|
cwd: &Path,
|
||||||
|
rules_import: &RulesImportConfig,
|
||||||
|
) -> std::io::Result<Vec<ContextFile>> {
|
||||||
|
let mut directories = instruction_discovery_dirs(cwd);
|
||||||
directories.reverse();
|
directories.reverse();
|
||||||
|
|
||||||
let mut files = Vec::new();
|
let mut files = Vec::new();
|
||||||
for dir in directories {
|
for dir in directories {
|
||||||
for candidate in [
|
for candidate in [
|
||||||
dir.join("CLAUDE.md"),
|
dir.join("CLAUDE.md"),
|
||||||
|
dir.join("CLAW.md"),
|
||||||
|
dir.join("AGENTS.md"),
|
||||||
dir.join("CLAUDE.local.md"),
|
dir.join("CLAUDE.local.md"),
|
||||||
dir.join(".claw").join("CLAUDE.md"),
|
dir.join(".claw").join("CLAUDE.md"),
|
||||||
|
dir.join(".claude").join("CLAUDE.md"),
|
||||||
dir.join(".claw").join("instructions.md"),
|
dir.join(".claw").join("instructions.md"),
|
||||||
] {
|
] {
|
||||||
push_context_file(&mut files, candidate)?;
|
push_context_file(&mut files, candidate)?;
|
||||||
}
|
}
|
||||||
|
push_rules_dir(&mut files, dir.join(".claw").join("rules"))?;
|
||||||
|
push_rules_dir(&mut files, dir.join(".claw").join("rules.local"))?;
|
||||||
|
push_framework_imports(&mut files, &dir, rules_import)?
|
||||||
}
|
}
|
||||||
Ok(dedupe_instruction_files(files))
|
Ok(dedupe_instruction_files(files))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn instruction_discovery_dirs(cwd: &Path) -> Vec<PathBuf> {
|
||||||
|
let boundary = nearest_git_root(cwd).unwrap_or_else(|| cwd.to_path_buf());
|
||||||
|
let mut directories = Vec::new();
|
||||||
|
let mut cursor = Some(cwd);
|
||||||
|
while let Some(dir) = cursor {
|
||||||
|
directories.push(dir.to_path_buf());
|
||||||
|
if dir == boundary {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cursor = dir.parent();
|
||||||
|
}
|
||||||
|
directories
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nearest_git_root(cwd: &Path) -> Option<PathBuf> {
|
||||||
|
let mut cursor = Some(cwd);
|
||||||
|
while let Some(dir) = cursor {
|
||||||
|
let git_marker = dir.join(".git");
|
||||||
|
if git_marker.is_dir() || git_marker.is_file() {
|
||||||
|
return Some(dir.to_path_buf());
|
||||||
|
}
|
||||||
|
cursor = dir.parent();
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
fn push_context_file(files: &mut Vec<ContextFile>, path: PathBuf) -> std::io::Result<()> {
|
fn push_context_file(files: &mut Vec<ContextFile>, path: PathBuf) -> std::io::Result<()> {
|
||||||
|
if path.is_dir() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
match fs::read_to_string(&path) {
|
match fs::read_to_string(&path) {
|
||||||
Ok(content) if !content.trim().is_empty() => {
|
Ok(content) if !content.trim().is_empty() => {
|
||||||
files.push(ContextFile { path, content });
|
files.push(ContextFile { path, content });
|
||||||
@@ -262,6 +354,64 @@ fn push_context_file(files: &mut Vec<ContextFile>, path: PathBuf) -> std::io::Re
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn push_rules_dir(files: &mut Vec<ContextFile>, dir: PathBuf) -> std::io::Result<()> {
|
||||||
|
if dir.is_file() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let entries = match fs::read_dir(&dir) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
let mut paths = entries
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.map(|entry| entry.path())
|
||||||
|
.filter(|path| path.is_file() && is_supported_rule_file(path))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
paths.sort();
|
||||||
|
for path in paths {
|
||||||
|
push_context_file(files, path)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_supported_rule_file(path: &Path) -> bool {
|
||||||
|
path.extension()
|
||||||
|
.and_then(|extension| extension.to_str())
|
||||||
|
.is_some_and(|extension| {
|
||||||
|
matches!(
|
||||||
|
extension.to_ascii_lowercase().as_str(),
|
||||||
|
"md" | "txt" | "mdc"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_framework_imports(
|
||||||
|
files: &mut Vec<ContextFile>,
|
||||||
|
dir: &Path,
|
||||||
|
rules_import: &RulesImportConfig,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
if rules_import.should_import("cursor") {
|
||||||
|
push_context_file(files, dir.join(".cursorrules"))?;
|
||||||
|
push_rules_dir(files, dir.join(".cursor").join("rules"))?;
|
||||||
|
}
|
||||||
|
if rules_import.should_import("copilot") {
|
||||||
|
push_context_file(files, dir.join(".github").join("copilot-instructions.md"))?;
|
||||||
|
}
|
||||||
|
if rules_import.should_import("windsurf") {
|
||||||
|
push_context_file(files, dir.join(".windsurfrules"))?;
|
||||||
|
push_rules_dir(files, dir.join(".windsurfrules"))?;
|
||||||
|
}
|
||||||
|
if rules_import.should_import("plandex") {
|
||||||
|
push_context_file(files, dir.join(".plandex").join("instructions.md"))?;
|
||||||
|
}
|
||||||
|
if rules_import.should_import("crush") {
|
||||||
|
push_context_file(files, dir.join(".crush").join("CLAUDE.md"))?;
|
||||||
|
push_rules_dir(files, dir.join(".crush").join("rules"))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn read_git_status(cwd: &Path) -> Option<String> {
|
fn read_git_status(cwd: &Path) -> Option<String> {
|
||||||
let output = Command::new("git")
|
let output = Command::new("git")
|
||||||
.args(["--no-optional-locks", "status", "--short", "--branch"])
|
.args(["--no-optional-locks", "status", "--short", "--branch"])
|
||||||
@@ -332,7 +482,7 @@ fn render_project_context(project_context: &ProjectContext) -> String {
|
|||||||
];
|
];
|
||||||
if !project_context.instruction_files.is_empty() {
|
if !project_context.instruction_files.is_empty() {
|
||||||
bullets.push(format!(
|
bullets.push(format!(
|
||||||
"Claude instruction files discovered: {}.",
|
"Project instruction files discovered: {}.",
|
||||||
project_context.instruction_files.len()
|
project_context.instruction_files.len()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -367,7 +517,7 @@ fn render_project_context(project_context: &ProjectContext) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_instruction_files(files: &[ContextFile]) -> String {
|
fn render_instruction_files(files: &[ContextFile]) -> String {
|
||||||
let mut sections = vec!["# Claude instructions".to_string()];
|
let mut sections = vec!["# Project instructions".to_string()];
|
||||||
let mut remaining_chars = MAX_TOTAL_INSTRUCTION_CHARS;
|
let mut remaining_chars = MAX_TOTAL_INSTRUCTION_CHARS;
|
||||||
for file in files {
|
for file in files {
|
||||||
if remaining_chars == 0 {
|
if remaining_chars == 0 {
|
||||||
@@ -476,14 +626,30 @@ pub fn load_system_prompt(
|
|||||||
model_family: ModelFamilyIdentity,
|
model_family: ModelFamilyIdentity,
|
||||||
) -> Result<Vec<String>, PromptBuildError> {
|
) -> Result<Vec<String>, PromptBuildError> {
|
||||||
let cwd = cwd.into();
|
let cwd = cwd.into();
|
||||||
let project_context = ProjectContext::discover_with_git(&cwd, current_date.into())?;
|
let (sections, _) =
|
||||||
|
load_system_prompt_with_context(cwd, current_date, os_name, os_version, model_family)?;
|
||||||
|
Ok(sections)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads config and project context, then renders the system prompt text plus metadata.
|
||||||
|
pub fn load_system_prompt_with_context(
|
||||||
|
cwd: impl Into<PathBuf>,
|
||||||
|
current_date: impl Into<String>,
|
||||||
|
os_name: impl Into<String>,
|
||||||
|
os_version: impl Into<String>,
|
||||||
|
model_family: ModelFamilyIdentity,
|
||||||
|
) -> Result<(Vec<String>, ProjectContext), PromptBuildError> {
|
||||||
|
let cwd = cwd.into();
|
||||||
let config = ConfigLoader::default_for(&cwd).load()?;
|
let config = ConfigLoader::default_for(&cwd).load()?;
|
||||||
Ok(SystemPromptBuilder::new()
|
let project_context =
|
||||||
|
discover_with_git_and_rules_import(&cwd, current_date.into(), config.rules_import())?;
|
||||||
|
let sections = SystemPromptBuilder::new()
|
||||||
.with_os(os_name, os_version)
|
.with_os(os_name, os_version)
|
||||||
.with_model_family(model_family)
|
.with_model_family(model_family)
|
||||||
.with_project_context(project_context)
|
.with_project_context(project_context.clone())
|
||||||
.with_runtime_config(config)
|
.with_runtime_config(config)
|
||||||
.build())
|
.build();
|
||||||
|
Ok((sections, project_context))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_config_section(config: &RuntimeConfig) -> String {
|
fn render_config_section(config: &RuntimeConfig) -> String {
|
||||||
@@ -590,11 +756,84 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discovers_claw_rules_files_in_sorted_order() {
|
||||||
|
let root = temp_dir();
|
||||||
|
let rules = root.join(".claw").join("rules");
|
||||||
|
let local_rules = root.join(".claw").join("rules.local");
|
||||||
|
fs::create_dir_all(&rules).expect("rules dir");
|
||||||
|
fs::create_dir_all(&local_rules).expect("local rules dir");
|
||||||
|
fs::write(rules.join("b.txt"), "b rule").expect("write b rule");
|
||||||
|
fs::write(rules.join("a.md"), "a rule").expect("write a rule");
|
||||||
|
fs::write(rules.join("ignored.json"), "ignored rule").expect("write ignored");
|
||||||
|
fs::write(local_rules.join("c.mdc"), "c local rule").expect("write local rule");
|
||||||
|
|
||||||
|
let context = ProjectContext::discover(&root, "2026-03-31").expect("context should load");
|
||||||
|
let contents = context
|
||||||
|
.instruction_files
|
||||||
|
.iter()
|
||||||
|
.map(|file| file.content.as_str())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
assert_eq!(contents, vec!["a rule", "b rule", "c local rule"]);
|
||||||
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rules_import_none_suppresses_external_framework_rules() {
|
||||||
|
let root = temp_dir();
|
||||||
|
fs::create_dir_all(root.join(".claw").join("rules")).expect("rules dir");
|
||||||
|
fs::write(
|
||||||
|
root.join(".claw").join("rules").join("project.md"),
|
||||||
|
"claw rule",
|
||||||
|
)
|
||||||
|
.expect("write claw rule");
|
||||||
|
fs::write(root.join(".cursorrules"), "cursor rule").expect("write cursor rule");
|
||||||
|
|
||||||
|
let context = ProjectContext::discover_with_rules_import(
|
||||||
|
&root,
|
||||||
|
"2026-03-31",
|
||||||
|
&crate::config::RulesImportConfig::None,
|
||||||
|
)
|
||||||
|
.expect("context should load");
|
||||||
|
let rendered = render_instruction_files(&context.instruction_files);
|
||||||
|
|
||||||
|
assert!(rendered.contains("claw rule"));
|
||||||
|
assert!(!rendered.contains("cursor rule"));
|
||||||
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rules_import_list_loads_only_selected_framework_rules() {
|
||||||
|
let root = temp_dir();
|
||||||
|
fs::create_dir_all(&root).expect("root dir");
|
||||||
|
fs::write(root.join(".cursorrules"), "cursor rule").expect("write cursor rule");
|
||||||
|
fs::create_dir_all(root.join(".github")).expect("github dir");
|
||||||
|
fs::write(
|
||||||
|
root.join(".github").join("copilot-instructions.md"),
|
||||||
|
"copilot rule",
|
||||||
|
)
|
||||||
|
.expect("write copilot rule");
|
||||||
|
|
||||||
|
let context = ProjectContext::discover_with_rules_import(
|
||||||
|
&root,
|
||||||
|
"2026-03-31",
|
||||||
|
&crate::config::RulesImportConfig::List(vec!["copilot".to_string()]),
|
||||||
|
)
|
||||||
|
.expect("context should load");
|
||||||
|
let rendered = render_instruction_files(&context.instruction_files);
|
||||||
|
|
||||||
|
assert!(rendered.contains("copilot rule"));
|
||||||
|
assert!(!rendered.contains("cursor rule"));
|
||||||
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn discovers_instruction_files_from_ancestor_chain() {
|
fn discovers_instruction_files_from_ancestor_chain() {
|
||||||
let root = temp_dir();
|
let root = temp_dir();
|
||||||
let nested = root.join("apps").join("api");
|
let nested = root.join("apps").join("api");
|
||||||
fs::create_dir_all(nested.join(".claw")).expect("nested claw dir");
|
fs::create_dir_all(nested.join(".claw")).expect("nested claw dir");
|
||||||
|
fs::create_dir(root.join(".git")).expect("git boundary");
|
||||||
fs::write(root.join("CLAUDE.md"), "root instructions").expect("write root instructions");
|
fs::write(root.join("CLAUDE.md"), "root instructions").expect("write root instructions");
|
||||||
fs::write(root.join("CLAUDE.local.md"), "local instructions")
|
fs::write(root.join("CLAUDE.local.md"), "local instructions")
|
||||||
.expect("write local instructions");
|
.expect("write local instructions");
|
||||||
@@ -636,11 +875,80 @@ mod tests {
|
|||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discovers_agents_markdown_instruction_file() {
|
||||||
|
let root = temp_dir();
|
||||||
|
fs::create_dir_all(&root).expect("root dir");
|
||||||
|
fs::write(root.join("AGENTS.md"), "agents-only instructions").expect("write AGENTS.md");
|
||||||
|
|
||||||
|
let context = ProjectContext::discover(&root, "2026-03-31").expect("context should load");
|
||||||
|
|
||||||
|
assert_eq!(context.instruction_files.len(), 1);
|
||||||
|
assert!(context.instruction_files[0].path.ends_with("AGENTS.md"));
|
||||||
|
assert!(render_instruction_files(&context.instruction_files)
|
||||||
|
.contains("agents-only instructions"));
|
||||||
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discovers_scoped_dot_claude_claude_markdown_instruction_file() {
|
||||||
|
let root = temp_dir();
|
||||||
|
fs::create_dir_all(root.join(".claude")).expect("dot claude dir");
|
||||||
|
fs::write(
|
||||||
|
root.join(".claude").join("CLAUDE.md"),
|
||||||
|
"dot-claude-only instructions",
|
||||||
|
)
|
||||||
|
.expect("write .claude/CLAUDE.md");
|
||||||
|
|
||||||
|
let context = ProjectContext::discover(&root, "2026-03-31").expect("context should load");
|
||||||
|
|
||||||
|
assert_eq!(context.instruction_files.len(), 1);
|
||||||
|
assert!(context.instruction_files[0]
|
||||||
|
.path
|
||||||
|
.ends_with(".claude/CLAUDE.md"));
|
||||||
|
assert!(render_instruction_files(&context.instruction_files)
|
||||||
|
.contains("dot-claude-only instructions"));
|
||||||
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discovers_claude_claw_agents_and_dot_claude_instruction_files_together() {
|
||||||
|
let root = temp_dir();
|
||||||
|
fs::create_dir_all(root.join(".claude")).expect("dot claude dir");
|
||||||
|
fs::write(root.join("CLAUDE.md"), "claude instructions").expect("write CLAUDE.md");
|
||||||
|
fs::write(root.join("CLAW.md"), "claw instructions").expect("write CLAW.md");
|
||||||
|
fs::write(root.join("AGENTS.md"), "agents instructions").expect("write AGENTS.md");
|
||||||
|
fs::write(
|
||||||
|
root.join(".claude").join("CLAUDE.md"),
|
||||||
|
"dot claude instructions",
|
||||||
|
)
|
||||||
|
.expect("write .claude/CLAUDE.md");
|
||||||
|
|
||||||
|
let context = ProjectContext::discover(&root, "2026-03-31").expect("context should load");
|
||||||
|
let rendered = render_instruction_files(&context.instruction_files);
|
||||||
|
let sources = context
|
||||||
|
.instruction_files
|
||||||
|
.iter()
|
||||||
|
.map(ContextFile::source)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
sources,
|
||||||
|
vec!["claude_md", "claw_md", "agents_md", "claude_claude_md"]
|
||||||
|
);
|
||||||
|
assert!(rendered.contains("claude instructions"));
|
||||||
|
assert!(rendered.contains("claw instructions"));
|
||||||
|
assert!(rendered.contains("agents instructions"));
|
||||||
|
assert!(rendered.contains("dot claude instructions"));
|
||||||
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dedupes_identical_instruction_content_across_scopes() {
|
fn dedupes_identical_instruction_content_across_scopes() {
|
||||||
let root = temp_dir();
|
let root = temp_dir();
|
||||||
let nested = root.join("apps").join("api");
|
let nested = root.join("apps").join("api");
|
||||||
fs::create_dir_all(&nested).expect("nested dir");
|
fs::create_dir_all(&nested).expect("nested dir");
|
||||||
|
fs::create_dir(root.join(".git")).expect("git boundary");
|
||||||
fs::write(root.join("CLAUDE.md"), "same rules\n\n").expect("write root");
|
fs::write(root.join("CLAUDE.md"), "same rules\n\n").expect("write root");
|
||||||
fs::write(nested.join("CLAUDE.md"), "same rules\n").expect("write nested");
|
fs::write(nested.join("CLAUDE.md"), "same rules\n").expect("write nested");
|
||||||
|
|
||||||
@@ -653,6 +961,50 @@ mod tests {
|
|||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discovery_stops_at_git_root_boundary_439() {
|
||||||
|
let root = temp_dir();
|
||||||
|
let repo = root.join("repo");
|
||||||
|
let nested = repo.join("subproj").join("deep").join("nest");
|
||||||
|
fs::create_dir_all(&nested).expect("nested dir");
|
||||||
|
fs::create_dir(repo.join(".git")).expect("git boundary");
|
||||||
|
fs::write(root.join("CLAUDE.md"), "PARENT_CLAUDE").expect("write parent");
|
||||||
|
fs::write(repo.join("CLAUDE.md"), "REPO_CLAUDE").expect("write repo");
|
||||||
|
fs::write(repo.join("subproj").join("CLAUDE.md"), "CHILD_CLAUDE").expect("write child");
|
||||||
|
fs::write(
|
||||||
|
repo.join("subproj").join("deep").join("CLAUDE.md"),
|
||||||
|
"DEEP_CLAUDE",
|
||||||
|
)
|
||||||
|
.expect("write deep");
|
||||||
|
|
||||||
|
let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load");
|
||||||
|
let rendered = render_instruction_files(&context.instruction_files);
|
||||||
|
|
||||||
|
assert!(!rendered.contains("PARENT_CLAUDE"));
|
||||||
|
assert!(rendered.contains("REPO_CLAUDE"));
|
||||||
|
assert!(rendered.contains("CHILD_CLAUDE"));
|
||||||
|
assert!(rendered.contains("DEEP_CLAUDE"));
|
||||||
|
assert_eq!(context.instruction_files.len(), 3);
|
||||||
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discovery_without_git_root_stays_cwd_local_439() {
|
||||||
|
let root = temp_dir();
|
||||||
|
let nested = root.join("scratch");
|
||||||
|
fs::create_dir_all(&nested).expect("nested dir");
|
||||||
|
fs::write(root.join("CLAUDE.md"), "PARENT_CLAUDE").expect("write parent");
|
||||||
|
fs::write(nested.join("CLAUDE.md"), "SCRATCH_CLAUDE").expect("write scratch");
|
||||||
|
|
||||||
|
let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load");
|
||||||
|
let rendered = render_instruction_files(&context.instruction_files);
|
||||||
|
|
||||||
|
assert!(!rendered.contains("PARENT_CLAUDE"));
|
||||||
|
assert!(rendered.contains("SCRATCH_CLAUDE"));
|
||||||
|
assert_eq!(context.instruction_files.len(), 1);
|
||||||
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn truncates_large_instruction_content_for_rendering() {
|
fn truncates_large_instruction_content_for_rendering() {
|
||||||
let rendered = render_instruction_content(&"x".repeat(4500));
|
let rendered = render_instruction_content(&"x".repeat(4500));
|
||||||
@@ -876,6 +1228,51 @@ mod tests {
|
|||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_system_prompt_respects_rules_import_config() {
|
||||||
|
let root = temp_dir();
|
||||||
|
fs::create_dir_all(root.join(".claw")).expect("claw dir");
|
||||||
|
fs::write(root.join(".cursorrules"), "cursor rule").expect("write cursor rule");
|
||||||
|
fs::write(
|
||||||
|
root.join(".claw").join("settings.json"),
|
||||||
|
r#"{"rulesImport":"none"}"#,
|
||||||
|
)
|
||||||
|
.expect("write settings");
|
||||||
|
|
||||||
|
let _guard = env_lock();
|
||||||
|
ensure_valid_cwd();
|
||||||
|
let previous = std::env::current_dir().expect("cwd");
|
||||||
|
let original_home = std::env::var("HOME").ok();
|
||||||
|
let original_claw_home = std::env::var("CLAW_CONFIG_HOME").ok();
|
||||||
|
std::env::set_var("HOME", &root);
|
||||||
|
std::env::set_var("CLAW_CONFIG_HOME", root.join("missing-home"));
|
||||||
|
std::env::set_current_dir(&root).expect("change cwd");
|
||||||
|
let prompt = super::load_system_prompt(
|
||||||
|
&root,
|
||||||
|
"2026-03-31",
|
||||||
|
"linux",
|
||||||
|
"6.8",
|
||||||
|
ModelFamilyIdentity::Claude,
|
||||||
|
)
|
||||||
|
.expect("system prompt should load")
|
||||||
|
.join("\n\n");
|
||||||
|
std::env::set_current_dir(previous).expect("restore cwd");
|
||||||
|
if let Some(value) = original_home {
|
||||||
|
std::env::set_var("HOME", value);
|
||||||
|
} else {
|
||||||
|
std::env::remove_var("HOME");
|
||||||
|
}
|
||||||
|
if let Some(value) = original_claw_home {
|
||||||
|
std::env::set_var("CLAW_CONFIG_HOME", value);
|
||||||
|
} else {
|
||||||
|
std::env::remove_var("CLAW_CONFIG_HOME");
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(!prompt.contains("cursor rule"));
|
||||||
|
assert!(prompt.contains("rulesImport"));
|
||||||
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn renders_default_claude_model_family_identity() {
|
fn renders_default_claude_model_family_identity() {
|
||||||
// given: a prompt builder without an explicit model family override
|
// given: a prompt builder without an explicit model family override
|
||||||
@@ -945,7 +1342,7 @@ mod tests {
|
|||||||
|
|
||||||
assert!(prompt.contains("# System"));
|
assert!(prompt.contains("# System"));
|
||||||
assert!(prompt.contains("# Project context"));
|
assert!(prompt.contains("# Project context"));
|
||||||
assert!(prompt.contains("# Claude instructions"));
|
assert!(prompt.contains("# Project instructions"));
|
||||||
assert!(prompt.contains("Project rules"));
|
assert!(prompt.contains("Project rules"));
|
||||||
assert!(prompt.contains("permissionMode"));
|
assert!(prompt.contains("permissionMode"));
|
||||||
assert!(prompt.contains(SYSTEM_PROMPT_DYNAMIC_BOUNDARY));
|
assert!(prompt.contains(SYSTEM_PROMPT_DYNAMIC_BOUNDARY));
|
||||||
@@ -990,7 +1387,7 @@ mod tests {
|
|||||||
path: PathBuf::from("/tmp/project/CLAUDE.md"),
|
path: PathBuf::from("/tmp/project/CLAUDE.md"),
|
||||||
content: "Project rules".to_string(),
|
content: "Project rules".to_string(),
|
||||||
}]);
|
}]);
|
||||||
assert!(rendered.contains("# Claude instructions"));
|
assert!(rendered.contains("# Project instructions"));
|
||||||
assert!(rendered.contains("scope: /tmp/project"));
|
assert!(rendered.contains("scope: /tmp/project"));
|
||||||
assert!(rendered.contains("Project rules"));
|
assert!(rendered.contains("Project rules"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,8 +231,31 @@ 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()?;
|
||||||
rotate_session_file_if_needed(path)?;
|
// #112: wrap ENOENT during rotate as concurrent modification
|
||||||
write_atomic(path, &snapshot)?;
|
match rotate_session_file_if_needed(path) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(SessionError::Io(ref io_err)) if io_err.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
return Err(SessionError::Io(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotFound,
|
||||||
|
format!(
|
||||||
|
"session file was removed during save (possible concurrent modification): {io_err}"
|
||||||
|
),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
write_atomic(path, &snapshot).map_err(|e| {
|
||||||
|
// #112: wrap ENOENT during write as concurrent modification
|
||||||
|
match &e {
|
||||||
|
SessionError::Io(io_err) if io_err.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
SessionError::Io(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotFound,
|
||||||
|
format!("session file was removed during write (possible concurrent modification): {io_err}"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ => e,
|
||||||
|
}
|
||||||
|
})?;
|
||||||
cleanup_rotated_logs(path)?;
|
cleanup_rotated_logs(path)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ pub struct SessionStore {
|
|||||||
impl SessionStore {
|
impl SessionStore {
|
||||||
/// Build a store from the server's current working directory.
|
/// Build a store from the server's current working directory.
|
||||||
///
|
///
|
||||||
/// The on-disk layout becomes `<cwd>/.claw/sessions/<workspace_hash>/`.
|
/// The on-disk layout is `<cwd>/.claw/sessions/<workspace_hash>/`,
|
||||||
|
/// created lazily on first successful session save.
|
||||||
pub fn from_cwd(cwd: impl AsRef<Path>) -> Result<Self, SessionControlError> {
|
pub fn from_cwd(cwd: impl AsRef<Path>) -> Result<Self, SessionControlError> {
|
||||||
let cwd = cwd.as_ref();
|
let cwd = cwd.as_ref();
|
||||||
// #151: canonicalize so equivalent paths (symlinks, relative vs
|
// #151: canonicalize so equivalent paths (symlinks, relative vs
|
||||||
@@ -40,7 +41,6 @@ impl SessionStore {
|
|||||||
.join(".claw")
|
.join(".claw")
|
||||||
.join("sessions")
|
.join("sessions")
|
||||||
.join(workspace_fingerprint(&canonical_cwd));
|
.join(workspace_fingerprint(&canonical_cwd));
|
||||||
fs::create_dir_all(&sessions_root)?;
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
sessions_root,
|
sessions_root,
|
||||||
workspace_root: canonical_cwd,
|
workspace_root: canonical_cwd,
|
||||||
@@ -49,7 +49,8 @@ impl SessionStore {
|
|||||||
|
|
||||||
/// Build a store from an explicit `--data-dir` flag.
|
/// Build a store from an explicit `--data-dir` flag.
|
||||||
///
|
///
|
||||||
/// The on-disk layout becomes `<data_dir>/sessions/<workspace_hash>/`
|
/// The on-disk layout is `<data_dir>/sessions/<workspace_hash>/`,
|
||||||
|
/// created lazily on first successful session save.
|
||||||
/// where `<workspace_hash>` is derived from `workspace_root`.
|
/// where `<workspace_hash>` is derived from `workspace_root`.
|
||||||
pub fn from_data_dir(
|
pub fn from_data_dir(
|
||||||
data_dir: impl AsRef<Path>,
|
data_dir: impl AsRef<Path>,
|
||||||
@@ -64,7 +65,6 @@ impl SessionStore {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.join("sessions")
|
.join("sessions")
|
||||||
.join(workspace_fingerprint(&canonical_workspace));
|
.join(workspace_fingerprint(&canonical_workspace));
|
||||||
fs::create_dir_all(&sessions_root)?;
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
sessions_root,
|
sessions_root,
|
||||||
workspace_root: canonical_workspace,
|
workspace_root: canonical_workspace,
|
||||||
@@ -93,8 +93,19 @@ 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()?;
|
let latest = self.latest_session_excluding(exclude_id)?;
|
||||||
return Ok(SessionHandle {
|
return Ok(SessionHandle {
|
||||||
id: latest.id,
|
id: latest.id,
|
||||||
path: latest.path,
|
path: latest.path,
|
||||||
@@ -158,12 +169,45 @@ impl SessionStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn latest_session(&self) -> Result<ManagedSessionSummary, SessionControlError> {
|
pub fn latest_session(&self) -> Result<ManagedSessionSummary, SessionControlError> {
|
||||||
if let Some(latest) = self.list_sessions()?.into_iter().next() {
|
self.latest_session_excluding(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the most recent session, optionally excluding a session by ID
|
||||||
|
/// and skipping sessions with 0 messages. Used by /resume latest to skip
|
||||||
|
/// the current empty session and find the previous session with actual
|
||||||
|
/// conversation history.
|
||||||
|
pub fn latest_session_excluding(
|
||||||
|
&self,
|
||||||
|
exclude_id: Option<&str>,
|
||||||
|
) -> Result<ManagedSessionSummary, SessionControlError> {
|
||||||
|
let exclude = exclude_id.unwrap_or("");
|
||||||
|
// First: look in the current workspace's session namespace
|
||||||
|
if let Some(latest) = self
|
||||||
|
.list_sessions()?
|
||||||
|
.into_iter()
|
||||||
|
.find(|s| s.id != exclude && s.message_count > 0)
|
||||||
|
{
|
||||||
return Ok(latest);
|
return Ok(latest);
|
||||||
}
|
}
|
||||||
if let Some(latest) = self.scan_global_sessions()?.into_iter().next() {
|
// Fallback: scan all workspace namespaces under ~/.claw/sessions/
|
||||||
|
// and project-local .claw/sessions/ so /resume latest finds sessions
|
||||||
|
// from other workspaces.
|
||||||
|
if let Some(latest) = self
|
||||||
|
.scan_global_sessions()?
|
||||||
|
.into_iter()
|
||||||
|
.find(|s| s.id != exclude && s.message_count > 0)
|
||||||
|
{
|
||||||
return Ok(latest);
|
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,
|
||||||
)))
|
)))
|
||||||
@@ -204,18 +248,34 @@ impl SessionStore {
|
|||||||
&self,
|
&self,
|
||||||
reference: &str,
|
reference: &str,
|
||||||
) -> Result<LoadedManagedSession, SessionControlError> {
|
) -> Result<LoadedManagedSession, SessionControlError> {
|
||||||
match self.load_session(reference) {
|
self.load_session_excluding(reference, None)
|
||||||
Ok(loaded) => Ok(loaded),
|
}
|
||||||
Err(SessionControlError::WorkspaceMismatch { expected, actual })
|
|
||||||
if is_session_reference_alias(reference) =>
|
/// Like `load_session_loose` but also excludes a session by ID.
|
||||||
{
|
/// Used by /resume latest to skip the current empty session and find
|
||||||
let handle = self.resolve_reference(reference)?;
|
/// 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)?;
|
let session = Session::load_from_path(&handle.path)?;
|
||||||
|
// For alias references, allow cross-workspace resume
|
||||||
|
if is_session_reference_alias(reference) {
|
||||||
|
if let Err(SessionControlError::WorkspaceMismatch {
|
||||||
|
expected: _,
|
||||||
|
actual,
|
||||||
|
}) = self.validate_loaded_session(&handle.path, &session)
|
||||||
|
{
|
||||||
eprintln!(
|
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
|
}
|
||||||
|
} else {
|
||||||
|
self.validate_loaded_session(&handle.path, &session)?;
|
||||||
|
}
|
||||||
Ok(LoadedManagedSession {
|
Ok(LoadedManagedSession {
|
||||||
handle: SessionHandle {
|
handle: SessionHandle {
|
||||||
id: session.session_id.clone(),
|
id: session.session_id.clone(),
|
||||||
@@ -224,9 +284,6 @@ impl SessionStore {
|
|||||||
session,
|
session,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Err(other) => Err(other),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn fork_session(
|
pub fn fork_session(
|
||||||
&self,
|
&self,
|
||||||
@@ -726,6 +783,16 @@ fn format_no_managed_sessions(sessions_root: &Path) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_all_sessions_empty(sessions_root: &Path) -> String {
|
||||||
|
let fingerprint_dir = sessions_root
|
||||||
|
.file_name()
|
||||||
|
.and_then(|f| f.to_str())
|
||||||
|
.unwrap_or("<unknown>");
|
||||||
|
format!(
|
||||||
|
"all sessions are empty (0 messages) in .claw/sessions/{fingerprint_dir}/\nThis usually means a fresh `claw` session is running but no messages have been sent yet.\nWait for a response in your other session, then try `--resume {LATEST_SESSION_REFERENCE}` again."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn format_legacy_session_missing_workspace_root(
|
fn format_legacy_session_missing_workspace_root(
|
||||||
session_path: &Path,
|
session_path: &Path,
|
||||||
workspace_root: &Path,
|
workspace_root: &Path,
|
||||||
@@ -760,14 +827,21 @@ mod tests {
|
|||||||
use crate::session::Session;
|
use crate::session::Session;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
fn temp_dir() -> PathBuf {
|
fn temp_dir() -> PathBuf {
|
||||||
let nanos = SystemTime::now()
|
let nanos = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.expect("time should be after epoch")
|
.expect("time should be after epoch")
|
||||||
.as_nanos();
|
.as_nanos();
|
||||||
std::env::temp_dir().join(format!("runtime-session-control-{nanos}"))
|
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
|
std::env::temp_dir().join(format!(
|
||||||
|
"runtime-session-control-{}-{nanos}-{counter}",
|
||||||
|
std::process::id()
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn persist_session(root: &Path, text: &str) -> Session {
|
fn persist_session(root: &Path, text: &str) -> Session {
|
||||||
@@ -981,6 +1055,38 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_store_from_cwd_is_side_effect_free_until_save() {
|
||||||
|
// given
|
||||||
|
let base = temp_dir();
|
||||||
|
let workspace = base.join("fresh-workspace");
|
||||||
|
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||||
|
|
||||||
|
// when
|
||||||
|
let store = SessionStore::from_cwd(&workspace).expect("store should build");
|
||||||
|
|
||||||
|
// then — resolving the store must not create .claw/session partitions.
|
||||||
|
assert!(
|
||||||
|
!workspace.join(".claw").exists(),
|
||||||
|
"session store construction must not create .claw side effects"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!store.sessions_dir().exists(),
|
||||||
|
"session partition should be created lazily on save"
|
||||||
|
);
|
||||||
|
|
||||||
|
let session = persist_session_via_store(&store, "first saved turn");
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.sessions_dir()
|
||||||
|
.join(format!("{}.jsonl", session.session_id))
|
||||||
|
.exists(),
|
||||||
|
"saving a managed session should create the lazy session partition"
|
||||||
|
);
|
||||||
|
|
||||||
|
fs::remove_dir_all(base).expect("temp dir should clean up");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn session_store_from_cwd_isolates_sessions_by_workspace() {
|
fn session_store_from_cwd_isolates_sessions_by_workspace() {
|
||||||
// given
|
// given
|
||||||
@@ -1181,6 +1287,114 @@ 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 base = temp_dir();
|
||||||
|
fs::create_dir_all(&base).expect("base dir should exist");
|
||||||
|
let store = SessionStore::from_cwd(&base).expect("store should build");
|
||||||
|
|
||||||
|
let empty_handle = store.create_handle("empty-session");
|
||||||
|
Session::new()
|
||||||
|
.with_persistence_path(empty_handle.path.clone())
|
||||||
|
.save_to_path(&empty_handle.path)
|
||||||
|
.expect("empty session should save");
|
||||||
|
|
||||||
|
// when — latest_session should fail with the "all sessions empty" message
|
||||||
|
let result = store.latest_session();
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"latest_session should fail when all sessions are empty"
|
||||||
|
);
|
||||||
|
let err_msg = result.unwrap_err().to_string();
|
||||||
|
assert!(
|
||||||
|
err_msg.contains("all sessions are empty"),
|
||||||
|
"error should mention 'all sessions are empty', got: {err_msg}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
err_msg.contains("0 messages"),
|
||||||
|
"error should mention '0 messages', got: {err_msg}"
|
||||||
|
);
|
||||||
|
|
||||||
|
fs::remove_dir_all(base).expect("temp dir should clean up");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn latest_session_excluding_skips_excluded_id_and_returns_previous() {
|
||||||
|
// given — two sessions WITH messages, newest excluded
|
||||||
|
let base = temp_dir();
|
||||||
|
fs::create_dir_all(&base).expect("base dir should exist");
|
||||||
|
let store = SessionStore::from_cwd(&base).expect("store should build");
|
||||||
|
let older = persist_session_via_store(&store, "older work");
|
||||||
|
wait_for_next_millisecond();
|
||||||
|
let newer = persist_session_via_store(&store, "newer work");
|
||||||
|
|
||||||
|
// when — exclude the newest session
|
||||||
|
let latest = store
|
||||||
|
.latest_session_excluding(Some(&newer.session_id))
|
||||||
|
.expect("latest excluding newest should resolve");
|
||||||
|
|
||||||
|
// then — the older session wins because the newest is skipped
|
||||||
|
assert_eq!(
|
||||||
|
latest.id, older.session_id,
|
||||||
|
"excluded id must be skipped, returning the previous session"
|
||||||
|
);
|
||||||
|
fs::remove_dir_all(base).expect("temp dir should clean up");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn latest_session_filters_out_zero_message_sessions() {
|
||||||
|
// given — one empty (0-message) session and one non-empty session
|
||||||
|
let base = temp_dir();
|
||||||
|
fs::create_dir_all(&base).expect("base dir should exist");
|
||||||
|
let store = SessionStore::from_cwd(&base).expect("store should build");
|
||||||
|
|
||||||
|
let empty_handle = store.create_handle("empty-session");
|
||||||
|
Session::new()
|
||||||
|
.with_persistence_path(empty_handle.path.clone())
|
||||||
|
.save_to_path(&empty_handle.path)
|
||||||
|
.expect("empty session should save");
|
||||||
|
wait_for_next_millisecond();
|
||||||
|
let non_empty = persist_session_via_store(&store, "real conversation");
|
||||||
|
|
||||||
|
// when
|
||||||
|
let latest = store.latest_session().expect("latest should resolve");
|
||||||
|
|
||||||
|
// then — the non-empty session wins; the 0-message one is filtered out
|
||||||
|
assert_eq!(
|
||||||
|
latest.id, non_empty.session_id,
|
||||||
|
"0-message session must be filtered out, non-empty session wins"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
latest.message_count > 0,
|
||||||
|
"resolved session must have messages"
|
||||||
|
);
|
||||||
|
fs::remove_dir_all(base).expect("temp dir should clean up");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_reference_excluding_latest_skips_excluded_id() {
|
||||||
|
// given — two sessions WITH messages
|
||||||
|
let base = temp_dir();
|
||||||
|
fs::create_dir_all(&base).expect("base dir should exist");
|
||||||
|
let store = SessionStore::from_cwd(&base).expect("store should build");
|
||||||
|
let older = persist_session_via_store(&store, "older work");
|
||||||
|
wait_for_next_millisecond();
|
||||||
|
let newer = persist_session_via_store(&store, "newer work");
|
||||||
|
|
||||||
|
// when — resolve the "latest" alias while excluding the newest session
|
||||||
|
let handle = store
|
||||||
|
.resolve_reference_excluding("latest", Some(&newer.session_id))
|
||||||
|
.expect("latest alias excluding newest should resolve");
|
||||||
|
|
||||||
|
// then — the excluded id is skipped, so the older session resolves
|
||||||
|
assert_eq!(
|
||||||
|
handle.id, older.session_id,
|
||||||
|
"excluded id must be skipped when resolving the latest alias"
|
||||||
|
);
|
||||||
|
fs::remove_dir_all(base).expect("temp dir should clean up");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn session_exists_and_delete_are_scoped_to_workspace_store() {
|
fn session_exists_and_delete_are_scoped_to_workspace_store() {
|
||||||
// given
|
// given
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ path = "src/main.rs"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
api = { path = "../api" }
|
api = { path = "../api" }
|
||||||
commands = { path = "../commands" }
|
commands = { path = "../commands" }
|
||||||
compat-harness = { path = "../compat-harness" }
|
|
||||||
crossterm = "0.28"
|
crossterm = "0.28"
|
||||||
pulldown-cmark = "0.13"
|
pulldown-cmark = "0.13"
|
||||||
rustyline = "15"
|
rustyline = "15"
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
use std::env;
|
use std::env;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
fn main() {
|
fn command_output(program: &str, args: &[&str]) -> Option<String> {
|
||||||
// Get git SHA (short hash)
|
Command::new(program)
|
||||||
let git_sha = Command::new("git")
|
.args(args)
|
||||||
.args(["rev-parse", "--short", "HEAD"])
|
|
||||||
.output()
|
.output()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|output| {
|
.and_then(|output| {
|
||||||
@@ -14,11 +13,37 @@ fn main() {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.map_or_else(|| "unknown".to_string(), |s| s.trim().to_string());
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let git_sha =
|
||||||
|
command_output("git", &["rev-parse", "HEAD"]).unwrap_or_else(|| "unknown".to_string());
|
||||||
|
let git_sha_short = command_output("git", &["rev-parse", "--short=12", "HEAD"])
|
||||||
|
.or_else(|| git_sha.get(..git_sha.len().min(12)).map(str::to_string))
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
let git_dirty = command_output("git", &["status", "--porcelain"])
|
||||||
|
.map(|status| (!status.trim().is_empty()).to_string())
|
||||||
|
.unwrap_or_else(|| "false".to_string());
|
||||||
|
let git_branch = command_output("git", &["branch", "--show-current"])
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
let git_commit_date = command_output("git", &["show", "-s", "--format=%cI", "HEAD"])
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
let git_commit_timestamp = command_output("git", &["show", "-s", "--format=%ct", "HEAD"])
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
let rustc_version =
|
||||||
|
command_output("rustc", &["--version"]).unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
println!("cargo:rustc-env=GIT_SHA={git_sha}");
|
println!("cargo:rustc-env=GIT_SHA={git_sha}");
|
||||||
|
println!("cargo:rustc-env=GIT_SHA_SHORT={git_sha_short}");
|
||||||
|
println!("cargo:rustc-env=GIT_DIRTY={git_dirty}");
|
||||||
|
println!("cargo:rustc-env=GIT_BRANCH={git_branch}");
|
||||||
|
println!("cargo:rustc-env=GIT_COMMIT_DATE={git_commit_date}");
|
||||||
|
println!("cargo:rustc-env=GIT_COMMIT_TIMESTAMP={git_commit_timestamp}");
|
||||||
|
println!("cargo:rustc-env=RUSTC_VERSION={rustc_version}");
|
||||||
|
|
||||||
// TARGET is always set by Cargo during build
|
// TARGET is always set by Cargo during build.
|
||||||
let target = env::var("TARGET").unwrap_or_else(|_| "unknown".to_string());
|
let target = env::var("TARGET").unwrap_or_else(|_| "unknown".to_string());
|
||||||
println!("cargo:rustc-env=TARGET={target}");
|
println!("cargo:rustc-env=TARGET={target}");
|
||||||
|
|
||||||
@@ -35,23 +60,12 @@ fn main() {
|
|||||||
})
|
})
|
||||||
.or_else(|| std::env::var("BUILD_DATE").ok())
|
.or_else(|| std::env::var("BUILD_DATE").ok())
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
// Fall back to current date via `date` command
|
command_output("date", &["+%Y-%m-%d"]).unwrap_or_else(|| "unknown".to_string())
|
||||||
Command::new("date")
|
|
||||||
.args(["+%Y-%m-%d"])
|
|
||||||
.output()
|
|
||||||
.ok()
|
|
||||||
.and_then(|o| {
|
|
||||||
if o.status.success() {
|
|
||||||
String::from_utf8(o.stdout).ok()
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.map_or_else(|| "unknown".to_string(), |s| s.trim().to_string())
|
|
||||||
});
|
});
|
||||||
println!("cargo:rustc-env=BUILD_DATE={build_date}");
|
println!("cargo:rustc-env=BUILD_DATE={build_date}");
|
||||||
|
|
||||||
// Rerun if git state changes
|
// Rerun if git state changes. Paths are relative to this package root.
|
||||||
println!("cargo:rerun-if-changed=.git/HEAD");
|
println!("cargo:rerun-if-changed=../../../.git/HEAD");
|
||||||
println!("cargo:rerun-if-changed=.git/refs");
|
println!("cargo:rerun-if-changed=../../../.git/refs");
|
||||||
|
println!("cargo:rerun-if-changed=../../../.git/index");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ use std::path::{Path, PathBuf};
|
|||||||
const STARTER_CLAW_JSON: &str = concat!(
|
const STARTER_CLAW_JSON: &str = concat!(
|
||||||
"{\n",
|
"{\n",
|
||||||
" \"permissions\": {\n",
|
" \"permissions\": {\n",
|
||||||
" \"defaultMode\": \"dontAsk\"\n",
|
" \"defaultMode\": \"acceptEdits\"\n",
|
||||||
|
" }\n",
|
||||||
|
"}\n",
|
||||||
|
);
|
||||||
|
const STARTER_SETTINGS_JSON: &str = concat!(
|
||||||
|
"{\n",
|
||||||
|
" \"permissions\": {\n",
|
||||||
|
" \"defaultMode\": \"acceptEdits\"\n",
|
||||||
" }\n",
|
" }\n",
|
||||||
"}\n",
|
"}\n",
|
||||||
);
|
);
|
||||||
@@ -15,6 +22,8 @@ const GITIGNORE_ENTRIES: [&str; 3] = [".claw/settings.local.json", ".claw/sessio
|
|||||||
pub(crate) enum InitStatus {
|
pub(crate) enum InitStatus {
|
||||||
Created,
|
Created,
|
||||||
Updated,
|
Updated,
|
||||||
|
Partial,
|
||||||
|
Deferred,
|
||||||
Skipped,
|
Skipped,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,6 +33,8 @@ impl InitStatus {
|
|||||||
match self {
|
match self {
|
||||||
Self::Created => "created",
|
Self::Created => "created",
|
||||||
Self::Updated => "updated",
|
Self::Updated => "updated",
|
||||||
|
Self::Partial => "partial (created missing sub-files)",
|
||||||
|
Self::Deferred => "deferred (created on first session save)",
|
||||||
Self::Skipped => "skipped (already exists)",
|
Self::Skipped => "skipped (already exists)",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,6 +47,8 @@ impl InitStatus {
|
|||||||
match self {
|
match self {
|
||||||
Self::Created => "created",
|
Self::Created => "created",
|
||||||
Self::Updated => "updated",
|
Self::Updated => "updated",
|
||||||
|
Self::Partial => "partial",
|
||||||
|
Self::Deferred => "deferred",
|
||||||
Self::Skipped => "skipped",
|
Self::Skipped => "skipped",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,9 +136,30 @@ pub(crate) fn initialize_repo(cwd: &Path) -> Result<InitReport, Box<dyn std::err
|
|||||||
let mut artifacts = Vec::new();
|
let mut artifacts = Vec::new();
|
||||||
|
|
||||||
let claw_dir = cwd.join(".claw");
|
let claw_dir = cwd.join(".claw");
|
||||||
|
let claw_dir_status = ensure_dir(&claw_dir)?;
|
||||||
|
let settings_json = claw_dir.join("settings.json");
|
||||||
|
let settings_status = write_file_if_missing(&settings_json, STARTER_SETTINGS_JSON)?;
|
||||||
|
let claw_dir_status =
|
||||||
|
if claw_dir_status == InitStatus::Skipped && settings_status == InitStatus::Created {
|
||||||
|
InitStatus::Partial
|
||||||
|
} else {
|
||||||
|
claw_dir_status
|
||||||
|
};
|
||||||
artifacts.push(InitArtifact {
|
artifacts.push(InitArtifact {
|
||||||
name: ".claw/",
|
name: ".claw/",
|
||||||
status: ensure_dir(&claw_dir)?,
|
status: claw_dir_status,
|
||||||
|
});
|
||||||
|
artifacts.push(InitArtifact {
|
||||||
|
name: ".claw/settings.json",
|
||||||
|
status: settings_status,
|
||||||
|
});
|
||||||
|
artifacts.push(InitArtifact {
|
||||||
|
name: ".claw/sessions/",
|
||||||
|
status: if claw_dir.join("sessions").is_dir() {
|
||||||
|
InitStatus::Skipped
|
||||||
|
} else {
|
||||||
|
InitStatus::Deferred
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let claw_json = cwd.join(".claw.json");
|
let claw_json = cwd.join(".claw.json");
|
||||||
@@ -414,11 +448,26 @@ mod tests {
|
|||||||
concat!(
|
concat!(
|
||||||
"{\n",
|
"{\n",
|
||||||
" \"permissions\": {\n",
|
" \"permissions\": {\n",
|
||||||
" \"defaultMode\": \"dontAsk\"\n",
|
" \"defaultMode\": \"acceptEdits\"\n",
|
||||||
" }\n",
|
" }\n",
|
||||||
"}\n",
|
"}\n",
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(root.join(".claw").join("settings.json"))
|
||||||
|
.expect("read project settings"),
|
||||||
|
concat!(
|
||||||
|
"{\n",
|
||||||
|
" \"permissions\": {\n",
|
||||||
|
" \"defaultMode\": \"acceptEdits\"\n",
|
||||||
|
" }\n",
|
||||||
|
"}\n",
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!root.join(".claw").join("sessions").exists(),
|
||||||
|
"sessions directory should be deferred until first session save"
|
||||||
|
);
|
||||||
let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
|
let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
|
||||||
assert!(gitignore.contains(".claw/settings.local.json"));
|
assert!(gitignore.contains(".claw/settings.local.json"));
|
||||||
assert!(gitignore.contains(".claw/sessions/"));
|
assert!(gitignore.contains(".claw/sessions/"));
|
||||||
@@ -436,14 +485,24 @@ mod tests {
|
|||||||
fs::create_dir_all(&root).expect("create root");
|
fs::create_dir_all(&root).expect("create root");
|
||||||
fs::write(root.join("CLAUDE.md"), "custom guidance\n").expect("write existing claude md");
|
fs::write(root.join("CLAUDE.md"), "custom guidance\n").expect("write existing claude md");
|
||||||
fs::write(root.join(".gitignore"), ".claw/settings.local.json\n").expect("write gitignore");
|
fs::write(root.join(".gitignore"), ".claw/settings.local.json\n").expect("write gitignore");
|
||||||
|
fs::create_dir_all(root.join(".claw")).expect("create existing .claw dir");
|
||||||
|
|
||||||
let first = initialize_repo(&root).expect("first init should succeed");
|
let first = initialize_repo(&root).expect("first init should succeed");
|
||||||
assert!(first
|
assert!(first
|
||||||
.render()
|
.render()
|
||||||
.contains("CLAUDE.md skipped (already exists)"));
|
.contains("CLAUDE.md skipped (already exists)"));
|
||||||
|
assert_eq!(
|
||||||
|
first.artifacts_with_status(InitStatus::Partial),
|
||||||
|
vec![".claw/".to_string()],
|
||||||
|
"existing .claw/ should report partial when init creates missing settings.json"
|
||||||
|
);
|
||||||
|
assert!(root.join(".claw").join("settings.json").is_file());
|
||||||
|
|
||||||
let second = initialize_repo(&root).expect("second init should succeed");
|
let second = initialize_repo(&root).expect("second init should succeed");
|
||||||
let second_rendered = second.render();
|
let second_rendered = second.render();
|
||||||
assert!(second_rendered.contains(".claw/"));
|
assert!(second_rendered.contains(".claw/"));
|
||||||
|
assert!(second_rendered.contains(".claw/settings.json"));
|
||||||
|
assert!(second_rendered.contains(".claw/sessions/"));
|
||||||
assert!(second_rendered.contains(".claw.json"));
|
assert!(second_rendered.contains(".claw.json"));
|
||||||
assert!(second_rendered.contains("skipped (already exists)"));
|
assert!(second_rendered.contains("skipped (already exists)"));
|
||||||
assert!(second_rendered.contains(".gitignore skipped (already exists)"));
|
assert!(second_rendered.contains(".gitignore skipped (already exists)"));
|
||||||
@@ -474,16 +533,22 @@ mod tests {
|
|||||||
created_names,
|
created_names,
|
||||||
vec![
|
vec![
|
||||||
".claw/".to_string(),
|
".claw/".to_string(),
|
||||||
|
".claw/settings.json".to_string(),
|
||||||
".claw.json".to_string(),
|
".claw.json".to_string(),
|
||||||
".gitignore".to_string(),
|
".gitignore".to_string(),
|
||||||
"CLAUDE.md".to_string(),
|
"CLAUDE.md".to_string(),
|
||||||
],
|
],
|
||||||
"fresh init should place all four artifacts in created[]"
|
"fresh init should place created artifacts in created[]"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
fresh.artifacts_with_status(InitStatus::Skipped).is_empty(),
|
fresh.artifacts_with_status(InitStatus::Skipped).is_empty(),
|
||||||
"fresh init should have no skipped artifacts"
|
"fresh init should have no skipped artifacts"
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fresh.artifacts_with_status(InitStatus::Deferred),
|
||||||
|
vec![".claw/sessions/".to_string()],
|
||||||
|
"fresh init should report session storage as deferred"
|
||||||
|
);
|
||||||
|
|
||||||
let second = initialize_repo(&root).expect("second init should succeed");
|
let second = initialize_repo(&root).expect("second init should succeed");
|
||||||
let skipped_names = second.artifacts_with_status(InitStatus::Skipped);
|
let skipped_names = second.artifacts_with_status(InitStatus::Skipped);
|
||||||
@@ -491,28 +556,39 @@ mod tests {
|
|||||||
skipped_names,
|
skipped_names,
|
||||||
vec![
|
vec![
|
||||||
".claw/".to_string(),
|
".claw/".to_string(),
|
||||||
|
".claw/settings.json".to_string(),
|
||||||
".claw.json".to_string(),
|
".claw.json".to_string(),
|
||||||
".gitignore".to_string(),
|
".gitignore".to_string(),
|
||||||
"CLAUDE.md".to_string(),
|
"CLAUDE.md".to_string(),
|
||||||
],
|
],
|
||||||
"idempotent init should place all four artifacts in skipped[]"
|
"idempotent init should place existing artifacts in skipped[]"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
second.artifacts_with_status(InitStatus::Created).is_empty(),
|
second.artifacts_with_status(InitStatus::Created).is_empty(),
|
||||||
"idempotent init should have no created artifacts"
|
"idempotent init should have no created artifacts"
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
second.artifacts_with_status(InitStatus::Deferred),
|
||||||
|
vec![".claw/sessions/".to_string()],
|
||||||
|
"idempotent init should keep session storage deferred until first save"
|
||||||
|
);
|
||||||
|
|
||||||
// artifact_json_entries() uses the machine-stable `json_tag()` which
|
// artifact_json_entries() uses the machine-stable `json_tag()` which
|
||||||
// never changes wording (unlike `label()` which says "skipped (already exists)").
|
// never changes wording (unlike `label()` which says "skipped (already exists)").
|
||||||
let entries = second.artifact_json_entries();
|
let entries = second.artifact_json_entries();
|
||||||
assert_eq!(entries.len(), 4);
|
assert_eq!(entries.len(), 6);
|
||||||
for entry in &entries {
|
for entry in &entries {
|
||||||
|
let name = entry.get("name").and_then(|v| v.as_str()).unwrap();
|
||||||
let status = entry.get("status").and_then(|v| v.as_str()).unwrap();
|
let status = entry.get("status").and_then(|v| v.as_str()).unwrap();
|
||||||
|
if name == ".claw/sessions/" {
|
||||||
|
assert_eq!(status, "deferred");
|
||||||
|
} else {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
status, "skipped",
|
status, "skipped",
|
||||||
"machine status tag should be the bare word 'skipped', not label()'s 'skipped (already exists)'"
|
"machine status tag should be the bare word 'skipped', not label()'s 'skipped (already exists)'"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
#![allow(clippy::while_let_on_iterator)]
|
#![allow(clippy::while_let_on_iterator)]
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::{Command, Output, Stdio};
|
use std::process::{Command, Output, Stdio};
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
@@ -246,8 +247,121 @@ stderr:
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn compact_subcommand_json_help_fails_fast_when_stdin_closed() {
|
fn prompt_subcommand_reads_prompt_from_stdin_when_no_positional_arg_423() {
|
||||||
let workspace = unique_temp_dir("compact-nontty-json-help");
|
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
||||||
|
let server = runtime
|
||||||
|
.block_on(MockAnthropicService::spawn())
|
||||||
|
.expect("mock service should start");
|
||||||
|
let base_url = server.base_url();
|
||||||
|
|
||||||
|
let workspace = unique_temp_dir("prompt-stdin-423");
|
||||||
|
let config_home = workspace.join("config-home");
|
||||||
|
let home = workspace.join("home");
|
||||||
|
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||||
|
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||||
|
fs::create_dir_all(&home).expect("home should exist");
|
||||||
|
|
||||||
|
let prompt = format!("{SCENARIO_PREFIX}streaming_text\n");
|
||||||
|
let output = run_claw_with_stdin(
|
||||||
|
&workspace,
|
||||||
|
&config_home,
|
||||||
|
&home,
|
||||||
|
&base_url,
|
||||||
|
&[
|
||||||
|
"prompt",
|
||||||
|
"--output-format",
|
||||||
|
"json",
|
||||||
|
"--compact",
|
||||||
|
"--permission-mode",
|
||||||
|
"read-only",
|
||||||
|
"--model",
|
||||||
|
"sonnet",
|
||||||
|
],
|
||||||
|
&prompt,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"prompt stdin run should succeed\nstdout:\n{}\n\nstderr:\n{}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr),
|
||||||
|
);
|
||||||
|
let parsed: Value = serde_json::from_slice(&output.stdout).expect("stdout should parse");
|
||||||
|
assert_eq!(
|
||||||
|
parsed["message"],
|
||||||
|
"Mock streaming says hello from the parity harness."
|
||||||
|
);
|
||||||
|
let captured = runtime.block_on(server.captured_requests());
|
||||||
|
assert!(
|
||||||
|
captured
|
||||||
|
.iter()
|
||||||
|
.any(|request| request.raw_body.contains("PARITY_SCENARIO:streaming_text")),
|
||||||
|
"stdin prompt should reach the provider request: {captured:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_subcommand_stdin_flag_appends_pipe_context_423() {
|
||||||
|
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
||||||
|
let server = runtime
|
||||||
|
.block_on(MockAnthropicService::spawn())
|
||||||
|
.expect("mock service should start");
|
||||||
|
let base_url = server.base_url();
|
||||||
|
|
||||||
|
let workspace = unique_temp_dir("prompt-stdin-flag-423");
|
||||||
|
let config_home = workspace.join("config-home");
|
||||||
|
let home = workspace.join("home");
|
||||||
|
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||||
|
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||||
|
fs::create_dir_all(&home).expect("home should exist");
|
||||||
|
|
||||||
|
let prompt_context = format!("{SCENARIO_PREFIX}streaming_text\n");
|
||||||
|
let output = run_claw_with_stdin(
|
||||||
|
&workspace,
|
||||||
|
&config_home,
|
||||||
|
&home,
|
||||||
|
&base_url,
|
||||||
|
&[
|
||||||
|
"prompt",
|
||||||
|
"Use stdin context",
|
||||||
|
"--stdin",
|
||||||
|
"--output-format",
|
||||||
|
"json",
|
||||||
|
"--compact",
|
||||||
|
"--permission-mode",
|
||||||
|
"read-only",
|
||||||
|
"--model",
|
||||||
|
"sonnet",
|
||||||
|
],
|
||||||
|
&prompt_context,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"prompt --stdin run should succeed\nstdout:\n{}\n\nstderr:\n{}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr),
|
||||||
|
);
|
||||||
|
let captured = runtime.block_on(server.captured_requests());
|
||||||
|
let provider_body = captured
|
||||||
|
.iter()
|
||||||
|
.find(|request| request.raw_body.contains("Use stdin context"))
|
||||||
|
.expect("merged prompt should reach provider");
|
||||||
|
assert!(
|
||||||
|
provider_body
|
||||||
|
.raw_body
|
||||||
|
.contains("PARITY_SCENARIO:streaming_text"),
|
||||||
|
"merged prompt should include stdin context: {provider_body:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compact_subcommand_json_fails_fast_when_stdin_closed() {
|
||||||
|
let workspace = unique_temp_dir("compact-nontty-json");
|
||||||
let config_home = workspace.join("config-home");
|
let config_home = workspace.join("config-home");
|
||||||
let home = workspace.join("home");
|
let home = workspace.join("home");
|
||||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||||
@@ -258,19 +372,19 @@ fn compact_subcommand_json_help_fails_fast_when_stdin_closed() {
|
|||||||
&workspace,
|
&workspace,
|
||||||
&config_home,
|
&config_home,
|
||||||
&home,
|
&home,
|
||||||
&["compact", "--output-format", "json", "--help"],
|
&["compact", "--output-format", "json"],
|
||||||
Duration::from_secs(2),
|
Duration::from_secs(2),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
!output.status.success(),
|
!output.status.success(),
|
||||||
"compact json help should fail non-zero"
|
"compact json should fail non-zero"
|
||||||
);
|
);
|
||||||
// #819/#820/#823: JSON abort envelopes route to stdout
|
// #819/#820/#823: JSON abort envelopes route to stdout
|
||||||
let stderr = String::from_utf8(output.stderr).expect("stderr should be utf8");
|
let stderr = String::from_utf8(output.stderr).expect("stderr should be utf8");
|
||||||
assert!(
|
assert!(
|
||||||
stderr.trim().is_empty() || !stderr.trim_start().starts_with('{'),
|
stderr.trim().is_empty() || !stderr.trim_start().starts_with('{'),
|
||||||
"compact json help should not emit JSON envelope to stderr (#819/#820/#823): {stderr}"
|
"compact json should not emit JSON envelope to stderr (#819/#820/#823): {stderr}"
|
||||||
);
|
);
|
||||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||||
let parsed: Value =
|
let parsed: Value =
|
||||||
@@ -356,6 +470,39 @@ fn run_claw(
|
|||||||
command.output().expect("claw should launch")
|
command.output().expect("claw should launch")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_claw_with_stdin(
|
||||||
|
cwd: &std::path::Path,
|
||||||
|
config_home: &std::path::Path,
|
||||||
|
home: &std::path::Path,
|
||||||
|
base_url: &str,
|
||||||
|
args: &[&str],
|
||||||
|
stdin: &str,
|
||||||
|
) -> Output {
|
||||||
|
let mut child = Command::new(env!("CARGO_BIN_EXE_claw"))
|
||||||
|
.current_dir(cwd)
|
||||||
|
.env_clear()
|
||||||
|
.env("ANTHROPIC_API_KEY", "test-compact-key")
|
||||||
|
.env("ANTHROPIC_BASE_URL", base_url)
|
||||||
|
.env("CLAW_CONFIG_HOME", config_home)
|
||||||
|
.env("HOME", home)
|
||||||
|
.env("NO_COLOR", "1")
|
||||||
|
.env("PATH", "/usr/bin:/bin")
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.args(args)
|
||||||
|
.spawn()
|
||||||
|
.expect("claw should launch");
|
||||||
|
child
|
||||||
|
.stdin
|
||||||
|
.as_mut()
|
||||||
|
.expect("stdin should be piped")
|
||||||
|
.write_all(stdin.as_bytes())
|
||||||
|
.expect("stdin should write");
|
||||||
|
child.stdin.take();
|
||||||
|
child.wait_with_output().expect("output should collect")
|
||||||
|
}
|
||||||
|
|
||||||
fn run_claw_closed_stdin_with_timeout(
|
fn run_claw_closed_stdin_with_timeout(
|
||||||
cwd: &std::path::Path,
|
cwd: &std::path::Path,
|
||||||
config_home: &std::path::Path,
|
config_home: &std::path::Path,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -222,6 +222,73 @@ fn resume_latest_restores_the_most_recent_managed_session() {
|
|||||||
assert!(stdout.contains(newer_path.to_str().expect("utf8 path")));
|
assert!(stdout.contains(newer_path.to_str().expect("utf8 path")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resume_latest_missing_session_fails_without_creating_session_dirs_435() {
|
||||||
|
// given
|
||||||
|
let temp_dir = unique_temp_dir("resume-latest-missing-435");
|
||||||
|
let project_dir = temp_dir.join("project");
|
||||||
|
let config_home = temp_dir.join("config-home");
|
||||||
|
let home = temp_dir.join("home");
|
||||||
|
fs::create_dir_all(&project_dir).expect("project dir should exist");
|
||||||
|
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||||
|
fs::create_dir_all(&home).expect("home should exist");
|
||||||
|
let envs = [
|
||||||
|
(
|
||||||
|
"CLAW_CONFIG_HOME",
|
||||||
|
config_home.to_str().expect("utf8 config home"),
|
||||||
|
),
|
||||||
|
("HOME", home.to_str().expect("utf8 home")),
|
||||||
|
("ANTHROPIC_API_KEY", ""),
|
||||||
|
("ANTHROPIC_AUTH_TOKEN", ""),
|
||||||
|
("OPENAI_API_KEY", ""),
|
||||||
|
];
|
||||||
|
|
||||||
|
// when — both text and JSON resume failures should be non-zero and read-only.
|
||||||
|
let text = run_claw_with_env(&project_dir, &["--resume", "latest"], &envs);
|
||||||
|
let json = run_claw_with_env(
|
||||||
|
&project_dir,
|
||||||
|
&["--output-format", "json", "--resume", "latest"],
|
||||||
|
&envs,
|
||||||
|
);
|
||||||
|
|
||||||
|
// then
|
||||||
|
assert_eq!(
|
||||||
|
text.status.code(),
|
||||||
|
Some(1),
|
||||||
|
"text resume failure must be non-zero"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
text.stdout.is_empty(),
|
||||||
|
"text resume failure should not claim success on stdout: {}",
|
||||||
|
String::from_utf8_lossy(&text.stdout)
|
||||||
|
);
|
||||||
|
let text_stderr = String::from_utf8_lossy(&text.stderr);
|
||||||
|
assert!(
|
||||||
|
text_stderr.contains("no managed sessions found"),
|
||||||
|
"text failure should explain missing sessions: {text_stderr}"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
json.status.code(),
|
||||||
|
Some(1),
|
||||||
|
"JSON resume failure must be non-zero"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
json.stderr.is_empty(),
|
||||||
|
"JSON resume failure should keep stderr empty: {}",
|
||||||
|
String::from_utf8_lossy(&json.stderr)
|
||||||
|
);
|
||||||
|
let parsed: Value = serde_json::from_slice(&json.stdout)
|
||||||
|
.expect("JSON resume failure should emit JSON to stdout");
|
||||||
|
assert_eq!(parsed["status"], "error");
|
||||||
|
assert_eq!(parsed["action"], "restore");
|
||||||
|
assert_eq!(parsed["error_kind"], "no_managed_sessions");
|
||||||
|
assert!(
|
||||||
|
!project_dir.join(".claw").exists(),
|
||||||
|
"failed resume must not create .claw/session directories"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resumed_status_command_emits_structured_json_when_requested() {
|
fn resumed_status_command_emits_structured_json_when_requested() {
|
||||||
// given
|
// given
|
||||||
@@ -268,7 +335,7 @@ fn resumed_status_command_emits_structured_json_when_requested() {
|
|||||||
assert_eq!(parsed["kind"], "status");
|
assert_eq!(parsed["kind"], "status");
|
||||||
// model is null in resume mode (not known without --model flag)
|
// model is null in resume mode (not known without --model flag)
|
||||||
assert!(parsed["model"].is_null());
|
assert!(parsed["model"].is_null());
|
||||||
assert_eq!(parsed["permission_mode"], "danger-full-access");
|
assert_eq!(parsed["permission_mode"], "workspace-write");
|
||||||
assert_eq!(parsed["usage"]["messages"], 1);
|
assert_eq!(parsed["usage"]["messages"], 1);
|
||||||
assert!(parsed["usage"]["turns"].is_number());
|
assert!(parsed["usage"]["turns"].is_number());
|
||||||
assert!(parsed["workspace"]["cwd"].as_str().is_some());
|
assert!(parsed["workspace"]["cwd"].as_str().is_some());
|
||||||
@@ -396,6 +463,9 @@ fn resumed_version_command_emits_structured_json() {
|
|||||||
assert!(parsed["version"].as_str().is_some());
|
assert!(parsed["version"].as_str().is_some());
|
||||||
assert!(parsed["git_sha"].as_str().is_some());
|
assert!(parsed["git_sha"].as_str().is_some());
|
||||||
assert!(parsed["target"].as_str().is_some());
|
assert!(parsed["target"].as_str().is_some());
|
||||||
|
assert!(parsed["git_sha_short"].as_str().is_some());
|
||||||
|
assert!(parsed.get("message").is_none());
|
||||||
|
assert!(parsed["human_readable"].as_str().is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -460,8 +530,9 @@ 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");
|
||||||
assert!(parsed["text"].as_str().is_some());
|
// #338: resume help now uses 'message' field for parity with top-level help
|
||||||
let text = parsed["text"].as_str().unwrap();
|
assert!(parsed["message"].as_str().is_some());
|
||||||
|
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -201,30 +201,20 @@ impl GlobalToolRegistry {
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
let builtin_specs = mvp_tool_specs();
|
let actual_names = self.actual_tool_names();
|
||||||
let canonical_names = builtin_specs
|
let canonical_names = self.canonical_allowed_tool_names();
|
||||||
.iter()
|
let canonical_name_set = canonical_names.iter().cloned().collect::<BTreeSet<_>>();
|
||||||
.map(|spec| spec.name.to_string())
|
let mut name_map = BTreeMap::new();
|
||||||
.chain(
|
for actual in &actual_names {
|
||||||
self.plugin_tools
|
let canonical = canonical_allowed_tool_name(actual);
|
||||||
.iter()
|
name_map.insert(allowed_tool_lookup_key(actual), canonical.clone());
|
||||||
.map(|tool| tool.definition().name.clone()),
|
name_map.insert(allowed_tool_lookup_key(&canonical), canonical);
|
||||||
)
|
}
|
||||||
.chain(self.runtime_tools.iter().map(|tool| tool.name.clone()))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let mut name_map = canonical_names
|
|
||||||
.iter()
|
|
||||||
.map(|name| (normalize_tool_name(name), name.clone()))
|
|
||||||
.collect::<BTreeMap<_, _>>();
|
|
||||||
|
|
||||||
for (alias, canonical) in [
|
for (alias, canonical) in self.allowed_tool_aliases() {
|
||||||
("read", "read_file"),
|
if canonical_name_set.contains(&canonical) {
|
||||||
("write", "write_file"),
|
name_map.insert(allowed_tool_lookup_key(&alias), canonical);
|
||||||
("edit", "edit_file"),
|
}
|
||||||
("glob", "glob_search"),
|
|
||||||
("grep", "grep_search"),
|
|
||||||
] {
|
|
||||||
name_map.insert(alias.to_string(), canonical.to_string());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut allowed = BTreeSet::new();
|
let mut allowed = BTreeSet::new();
|
||||||
@@ -233,11 +223,11 @@ impl GlobalToolRegistry {
|
|||||||
.split(|ch: char| ch == ',' || ch.is_whitespace())
|
.split(|ch: char| ch == ',' || ch.is_whitespace())
|
||||||
.filter(|token| !token.is_empty())
|
.filter(|token| !token.is_empty())
|
||||||
{
|
{
|
||||||
let normalized = normalize_tool_name(token);
|
let canonical = name_map.get(&allowed_tool_lookup_key(token)).ok_or_else(|| {
|
||||||
let canonical = name_map.get(&normalized).ok_or_else(|| {
|
|
||||||
format!(
|
format!(
|
||||||
"unsupported tool in --allowedTools: {token} (expected one of: {})",
|
"invalid_tool_name: unsupported tool in --allowedTools: {token}\nAvailable: {}\nAliases: {}\nHint: Use canonical snake_case tool names from Available or aliases from Aliases.",
|
||||||
canonical_names.join(", ")
|
canonical_names.join(", "),
|
||||||
|
format_allowed_tool_aliases(&self.allowed_tool_aliases())
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
allowed.insert(canonical.clone());
|
allowed.insert(canonical.clone());
|
||||||
@@ -258,7 +248,10 @@ impl GlobalToolRegistry {
|
|||||||
pub fn definitions(&self, allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolDefinition> {
|
pub fn definitions(&self, allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolDefinition> {
|
||||||
let builtin = mvp_tool_specs()
|
let builtin = mvp_tool_specs()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
|
.filter(|spec| {
|
||||||
|
allowed_tools
|
||||||
|
.is_none_or(|allowed| allowed.contains(&canonical_allowed_tool_name(spec.name)))
|
||||||
|
})
|
||||||
.map(|spec| ToolDefinition {
|
.map(|spec| ToolDefinition {
|
||||||
name: spec.name.to_string(),
|
name: spec.name.to_string(),
|
||||||
description: Some(spec.description.to_string()),
|
description: Some(spec.description.to_string()),
|
||||||
@@ -267,7 +260,11 @@ impl GlobalToolRegistry {
|
|||||||
let runtime = self
|
let runtime = self
|
||||||
.runtime_tools
|
.runtime_tools
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|tool| allowed_tools.is_none_or(|allowed| allowed.contains(tool.name.as_str())))
|
.filter(|tool| {
|
||||||
|
allowed_tools.is_none_or(|allowed| {
|
||||||
|
allowed.contains(&canonical_allowed_tool_name(&tool.name))
|
||||||
|
})
|
||||||
|
})
|
||||||
.map(|tool| ToolDefinition {
|
.map(|tool| ToolDefinition {
|
||||||
name: tool.name.clone(),
|
name: tool.name.clone(),
|
||||||
description: tool.description.clone(),
|
description: tool.description.clone(),
|
||||||
@@ -277,8 +274,11 @@ impl GlobalToolRegistry {
|
|||||||
.plugin_tools
|
.plugin_tools
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|tool| {
|
.filter(|tool| {
|
||||||
allowed_tools
|
allowed_tools.is_none_or(|allowed| {
|
||||||
.is_none_or(|allowed| allowed.contains(tool.definition().name.as_str()))
|
allowed.contains(&canonical_allowed_tool_name(
|
||||||
|
tool.definition().name.as_str(),
|
||||||
|
))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.map(|tool| ToolDefinition {
|
.map(|tool| ToolDefinition {
|
||||||
name: tool.definition().name.clone(),
|
name: tool.definition().name.clone(),
|
||||||
@@ -294,19 +294,29 @@ impl GlobalToolRegistry {
|
|||||||
) -> Result<Vec<(String, PermissionMode)>, String> {
|
) -> Result<Vec<(String, PermissionMode)>, String> {
|
||||||
let builtin = mvp_tool_specs()
|
let builtin = mvp_tool_specs()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
|
.filter(|spec| {
|
||||||
|
allowed_tools
|
||||||
|
.is_none_or(|allowed| allowed.contains(&canonical_allowed_tool_name(spec.name)))
|
||||||
|
})
|
||||||
.map(|spec| (spec.name.to_string(), spec.required_permission));
|
.map(|spec| (spec.name.to_string(), spec.required_permission));
|
||||||
let runtime = self
|
let runtime = self
|
||||||
.runtime_tools
|
.runtime_tools
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|tool| allowed_tools.is_none_or(|allowed| allowed.contains(tool.name.as_str())))
|
.filter(|tool| {
|
||||||
|
allowed_tools.is_none_or(|allowed| {
|
||||||
|
allowed.contains(&canonical_allowed_tool_name(&tool.name))
|
||||||
|
})
|
||||||
|
})
|
||||||
.map(|tool| (tool.name.clone(), tool.required_permission));
|
.map(|tool| (tool.name.clone(), tool.required_permission));
|
||||||
let plugin = self
|
let plugin = self
|
||||||
.plugin_tools
|
.plugin_tools
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|tool| {
|
.filter(|tool| {
|
||||||
allowed_tools
|
allowed_tools.is_none_or(|allowed| {
|
||||||
.is_none_or(|allowed| allowed.contains(tool.definition().name.as_str()))
|
allowed.contains(&canonical_allowed_tool_name(
|
||||||
|
tool.definition().name.as_str(),
|
||||||
|
))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.map(|tool| {
|
.map(|tool| {
|
||||||
permission_mode_from_plugin(tool.required_permission())
|
permission_mode_from_plugin(tool.required_permission())
|
||||||
@@ -316,6 +326,52 @@ impl GlobalToolRegistry {
|
|||||||
Ok(builtin.chain(runtime).chain(plugin).collect())
|
Ok(builtin.chain(runtime).chain(plugin).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn actual_tool_names(&self) -> Vec<String> {
|
||||||
|
mvp_tool_specs()
|
||||||
|
.iter()
|
||||||
|
.map(|spec| spec.name.to_string())
|
||||||
|
.chain(
|
||||||
|
self.plugin_tools
|
||||||
|
.iter()
|
||||||
|
.map(|tool| tool.definition().name.clone()),
|
||||||
|
)
|
||||||
|
.chain(self.runtime_tools.iter().map(|tool| tool.name.clone()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn canonical_allowed_tool_names(&self) -> Vec<String> {
|
||||||
|
self.actual_tool_names()
|
||||||
|
.into_iter()
|
||||||
|
.map(|name| canonical_allowed_tool_name(&name))
|
||||||
|
.collect::<BTreeSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn allowed_tool_aliases(&self) -> BTreeMap<String, String> {
|
||||||
|
let mut aliases = BTreeMap::from([
|
||||||
|
("read".to_string(), "read_file".to_string()),
|
||||||
|
("Read".to_string(), "read_file".to_string()),
|
||||||
|
("write".to_string(), "write_file".to_string()),
|
||||||
|
("Write".to_string(), "write_file".to_string()),
|
||||||
|
("edit".to_string(), "edit_file".to_string()),
|
||||||
|
("Edit".to_string(), "edit_file".to_string()),
|
||||||
|
("glob".to_string(), "glob_search".to_string()),
|
||||||
|
("Glob".to_string(), "glob_search".to_string()),
|
||||||
|
("grep".to_string(), "grep_search".to_string()),
|
||||||
|
("Grep".to_string(), "grep_search".to_string()),
|
||||||
|
]);
|
||||||
|
for actual in self.actual_tool_names() {
|
||||||
|
let canonical = canonical_allowed_tool_name(&actual);
|
||||||
|
if actual != canonical {
|
||||||
|
aliases.insert(actual, canonical);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
aliases
|
||||||
|
}
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn has_runtime_tool(&self, name: &str) -> bool {
|
pub fn has_runtime_tool(&self, name: &str) -> bool {
|
||||||
self.runtime_tools.iter().any(|tool| tool.name == name)
|
self.runtime_tools.iter().any(|tool| tool.name == name)
|
||||||
@@ -378,8 +434,40 @@ impl GlobalToolRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_tool_name(value: &str) -> String {
|
pub fn canonical_allowed_tool_name(value: &str) -> String {
|
||||||
value.trim().replace('-', "_").to_ascii_lowercase()
|
let trimmed = value.trim().replace('-', "_");
|
||||||
|
let mut output = String::new();
|
||||||
|
let chars = trimmed.chars().collect::<Vec<_>>();
|
||||||
|
for (index, ch) in chars.iter().copied().enumerate() {
|
||||||
|
if ch == '_' || ch.is_whitespace() {
|
||||||
|
output.push('_');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let previous = index.checked_sub(1).and_then(|i| chars.get(i)).copied();
|
||||||
|
let next = chars.get(index + 1).copied();
|
||||||
|
if ch.is_ascii_uppercase()
|
||||||
|
&& index > 0
|
||||||
|
&& !output.ends_with('_')
|
||||||
|
&& (previous.is_some_and(|p| p.is_ascii_lowercase() || p.is_ascii_digit())
|
||||||
|
|| next.is_some_and(|n| n.is_ascii_lowercase()))
|
||||||
|
{
|
||||||
|
output.push('_');
|
||||||
|
}
|
||||||
|
output.push(ch.to_ascii_lowercase());
|
||||||
|
}
|
||||||
|
output.trim_matches('_').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allowed_tool_lookup_key(value: &str) -> String {
|
||||||
|
canonical_allowed_tool_name(value).replace('_', "")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_allowed_tool_aliases(aliases: &BTreeMap<String, String>) -> String {
|
||||||
|
aliases
|
||||||
|
.iter()
|
||||||
|
.map(|(alias, canonical)| format!("{alias}={canonical}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn permission_mode_from_plugin(value: &str) -> Result<PermissionMode, String> {
|
fn permission_mode_from_plugin(value: &str) -> Result<PermissionMode, String> {
|
||||||
@@ -514,7 +602,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
|||||||
"required": ["url", "prompt"],
|
"required": ["url", "prompt"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}),
|
}),
|
||||||
required_permission: PermissionMode::ReadOnly,
|
required_permission: PermissionMode::DangerFullAccess,
|
||||||
},
|
},
|
||||||
ToolSpec {
|
ToolSpec {
|
||||||
name: "WebSearch",
|
name: "WebSearch",
|
||||||
@@ -535,7 +623,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
|||||||
"required": ["query"],
|
"required": ["query"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}),
|
}),
|
||||||
required_permission: PermissionMode::ReadOnly,
|
required_permission: PermissionMode::DangerFullAccess,
|
||||||
},
|
},
|
||||||
ToolSpec {
|
ToolSpec {
|
||||||
name: "TodoWrite",
|
name: "TodoWrite",
|
||||||
@@ -1225,13 +1313,14 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
|||||||
},
|
},
|
||||||
ToolSpec {
|
ToolSpec {
|
||||||
name: "GitShow",
|
name: "GitShow",
|
||||||
description: "Show a commit, tag, or tree object with its diff. Supports showing a specific file at a commit (commit:path) and stat-only mode. Use this instead of running git show via bash to get structured output.",
|
description: "Show a commit, tag, or tree object. Use format to control output: patch (default) shows the full diff, stat shows a diffstat summary, and metadata shows commit info without the diff. Supports showing a specific file at a commit (commit:path) for patch/stat output. Use this instead of running git show via bash to get structured output.",
|
||||||
input_schema: json!({
|
input_schema: json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"commit": { "type": "string" },
|
"commit": { "type": "string" },
|
||||||
"path": { "type": "string" },
|
"path": { "type": "string" },
|
||||||
"stat": { "type": "boolean" }
|
"stat": { "type": "boolean" },
|
||||||
|
"format": { "type": "string", "enum": ["patch", "stat", "metadata"] },
|
||||||
},
|
},
|
||||||
"required": ["commit"],
|
"required": ["commit"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -1320,8 +1409,26 @@ fn execute_tool_with_enforcer(
|
|||||||
maybe_enforce_permission_check_with_mode(enforcer, name, input, required_mode)?;
|
maybe_enforce_permission_check_with_mode(enforcer, name, input, required_mode)?;
|
||||||
run_grep_search(grep_input)
|
run_grep_search(grep_input)
|
||||||
}
|
}
|
||||||
"WebFetch" => from_value::<WebFetchInput>(input).and_then(run_web_fetch),
|
"WebFetch" => {
|
||||||
"WebSearch" => from_value::<WebSearchInput>(input).and_then(run_web_search),
|
let web_input = from_value::<WebFetchInput>(input)?;
|
||||||
|
maybe_enforce_permission_check_with_mode(
|
||||||
|
enforcer,
|
||||||
|
name,
|
||||||
|
input,
|
||||||
|
PermissionMode::DangerFullAccess,
|
||||||
|
)?;
|
||||||
|
run_web_fetch(web_input)
|
||||||
|
}
|
||||||
|
"WebSearch" => {
|
||||||
|
let web_input = from_value::<WebSearchInput>(input)?;
|
||||||
|
maybe_enforce_permission_check_with_mode(
|
||||||
|
enforcer,
|
||||||
|
name,
|
||||||
|
input,
|
||||||
|
PermissionMode::DangerFullAccess,
|
||||||
|
)?;
|
||||||
|
run_web_search(web_input)
|
||||||
|
}
|
||||||
"TodoWrite" => from_value::<TodoWriteInput>(input).and_then(run_todo_write),
|
"TodoWrite" => from_value::<TodoWriteInput>(input).and_then(run_todo_write),
|
||||||
"Skill" => from_value::<SkillInput>(input).and_then(run_skill),
|
"Skill" => from_value::<SkillInput>(input).and_then(run_skill),
|
||||||
"Agent" => from_value::<AgentInput>(input).and_then(run_agent),
|
"Agent" => from_value::<AgentInput>(input).and_then(run_agent),
|
||||||
@@ -2008,14 +2115,37 @@ fn run_git_log(input: GitLogInput) -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::needless_pass_by_value)]
|
|
||||||
/// Execute `git show` for a given commit, optionally with --stat or a file path.
|
/// Execute `git show` for a given commit, optionally with --stat or a file path.
|
||||||
/// Uses the `commit:path` syntax when a path is specified.
|
/// Uses the `commit:path` syntax when a path is specified.
|
||||||
fn run_git_show(input: GitShowInput) -> Result<String, String> {
|
fn run_git_show(input: GitShowInput) -> Result<String, String> {
|
||||||
let mut args: Vec<String> = vec!["show".to_string()];
|
let mut args: Vec<String> = vec!["show".to_string()];
|
||||||
if input.stat.unwrap_or(false) {
|
|
||||||
|
match input.format.as_deref() {
|
||||||
|
Some("metadata") if input.path.is_some() => {
|
||||||
|
return Err(
|
||||||
|
"GitShow format \"metadata\" cannot be combined with path; metadata describes a commit, not a blob. Use format \"patch\" or \"stat\" with path, or omit path."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Some("metadata") => {
|
||||||
|
args.push("--format=medium".to_string());
|
||||||
|
args.push("--no-patch".to_string());
|
||||||
|
}
|
||||||
|
Some("stat") => {
|
||||||
args.push("--stat".to_string());
|
args.push("--stat".to_string());
|
||||||
}
|
}
|
||||||
|
Some("patch") | None => {
|
||||||
|
if input.format.is_none() && input.stat.unwrap_or(false) {
|
||||||
|
args.push("--stat".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(other) => {
|
||||||
|
return Err(format!(
|
||||||
|
"unknown GitShow format: \"{other}\". Supported values: \"patch\" (default), \"stat\", \"metadata\"."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(ref path) = input.path {
|
if let Some(ref path) = input.path {
|
||||||
args.push(format!("{}:{}", input.commit, path));
|
args.push(format!("{}:{}", input.commit, path));
|
||||||
} else {
|
} else {
|
||||||
@@ -2571,6 +2701,20 @@ 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() {
|
||||||
@@ -2588,6 +2732,26 @@ 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())
|
||||||
}
|
}
|
||||||
@@ -2964,6 +3128,9 @@ struct GitShowInput {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
/// If true, show diffstat summary instead of full diff.
|
/// If true, show diffstat summary instead of full diff.
|
||||||
stat: Option<bool>,
|
stat: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
/// Output format: "patch" (default) shows the full diff, "stat" shows a diffstat summary, and "metadata" shows commit info without the diff. When set, takes priority over `stat`.
|
||||||
|
format: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Input for the GitBlame tool: shows per-line author/revision info for a file.
|
/// Input for the GitBlame tool: shows per-line author/revision info for a file.
|
||||||
@@ -4165,7 +4332,7 @@ fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet<String> {
|
|||||||
"PowerShell",
|
"PowerShell",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
tools.into_iter().map(str::to_string).collect()
|
tools.into_iter().map(canonical_allowed_tool_name).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn agent_permission_policy() -> PermissionPolicy {
|
fn agent_permission_policy() -> PermissionPolicy {
|
||||||
@@ -5193,7 +5360,10 @@ impl SubagentToolExecutor {
|
|||||||
|
|
||||||
impl ToolExecutor for SubagentToolExecutor {
|
impl ToolExecutor for SubagentToolExecutor {
|
||||||
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
|
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
|
||||||
if !self.allowed_tools.contains(tool_name) {
|
if !self
|
||||||
|
.allowed_tools
|
||||||
|
.contains(&canonical_allowed_tool_name(tool_name))
|
||||||
|
{
|
||||||
return Err(ToolError::new(format!(
|
return Err(ToolError::new(format!(
|
||||||
"tool `{tool_name}` is not enabled for this sub-agent"
|
"tool `{tool_name}` is not enabled for this sub-agent"
|
||||||
)));
|
)));
|
||||||
@@ -5208,7 +5378,10 @@ impl ToolExecutor for SubagentToolExecutor {
|
|||||||
fn tool_specs_for_allowed_tools(allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolSpec> {
|
fn tool_specs_for_allowed_tools(allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolSpec> {
|
||||||
mvp_tool_specs()
|
mvp_tool_specs()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
|
.filter(|spec| {
|
||||||
|
allowed_tools
|
||||||
|
.is_none_or(|allowed| allowed.contains(&canonical_allowed_tool_name(spec.name)))
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6779,6 +6952,87 @@ mod tests {
|
|||||||
assert!(names.contains(&"WorkerSendPrompt"));
|
assert!(names.contains(&"WorkerSendPrompt"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_show_schema_exposes_format_enum() {
|
||||||
|
let spec = mvp_tool_specs()
|
||||||
|
.into_iter()
|
||||||
|
.find(|spec| spec.name == "GitShow")
|
||||||
|
.expect("GitShow spec");
|
||||||
|
assert_eq!(
|
||||||
|
spec.input_schema["properties"]["format"]["enum"],
|
||||||
|
json!(["patch", "stat", "metadata"])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_show_supports_patch_stat_metadata_and_rejects_metadata_path() {
|
||||||
|
let _guard = env_guard();
|
||||||
|
let root = temp_path("git-show-format");
|
||||||
|
init_git_repo(&root);
|
||||||
|
commit_file(&root, "README.md", "initial\nupdated\n", "update readme");
|
||||||
|
let previous = std::env::current_dir().expect("cwd");
|
||||||
|
std::env::set_current_dir(&root).expect("set cwd");
|
||||||
|
|
||||||
|
let patch = execute_tool("GitShow", &json!({"commit": "HEAD", "format": "patch"}))
|
||||||
|
.expect("patch git show");
|
||||||
|
let patch: serde_json::Value = serde_json::from_str(&patch).expect("patch json");
|
||||||
|
assert!(patch["output"]
|
||||||
|
.as_str()
|
||||||
|
.expect("patch output")
|
||||||
|
.contains("diff --git"));
|
||||||
|
|
||||||
|
let stat = execute_tool("GitShow", &json!({"commit": "HEAD", "format": "stat"}))
|
||||||
|
.expect("stat git show");
|
||||||
|
let stat: serde_json::Value = serde_json::from_str(&stat).expect("stat json");
|
||||||
|
assert!(stat["output"]
|
||||||
|
.as_str()
|
||||||
|
.expect("stat output")
|
||||||
|
.contains("README.md"));
|
||||||
|
|
||||||
|
let legacy_stat = execute_tool("GitShow", &json!({"commit": "HEAD", "stat": true}))
|
||||||
|
.expect("legacy stat git show");
|
||||||
|
let legacy_stat: serde_json::Value =
|
||||||
|
serde_json::from_str(&legacy_stat).expect("legacy stat json");
|
||||||
|
assert!(legacy_stat["output"]
|
||||||
|
.as_str()
|
||||||
|
.expect("legacy stat output")
|
||||||
|
.contains("README.md"));
|
||||||
|
|
||||||
|
let metadata = execute_tool("GitShow", &json!({"commit": "HEAD", "format": "metadata"}))
|
||||||
|
.expect("metadata git show");
|
||||||
|
let metadata: serde_json::Value = serde_json::from_str(&metadata).expect("metadata json");
|
||||||
|
let metadata_output = metadata["output"].as_str().expect("metadata output");
|
||||||
|
assert!(metadata_output.contains("commit "));
|
||||||
|
assert!(metadata_output.contains("update readme"));
|
||||||
|
assert!(!metadata_output.contains("diff --git"));
|
||||||
|
|
||||||
|
let file_patch = execute_tool(
|
||||||
|
"GitShow",
|
||||||
|
&json!({"commit": "HEAD", "path": "README.md", "format": "patch"}),
|
||||||
|
)
|
||||||
|
.expect("file patch git show");
|
||||||
|
let file_patch: serde_json::Value =
|
||||||
|
serde_json::from_str(&file_patch).expect("file patch json");
|
||||||
|
assert_eq!(
|
||||||
|
file_patch["output"].as_str().expect("file patch output"),
|
||||||
|
"initial\nupdated"
|
||||||
|
);
|
||||||
|
|
||||||
|
let metadata_path = execute_tool(
|
||||||
|
"GitShow",
|
||||||
|
&json!({"commit": "HEAD", "path": "README.md", "format": "metadata"}),
|
||||||
|
)
|
||||||
|
.expect_err("metadata with path should be rejected");
|
||||||
|
assert!(metadata_path.contains("cannot be combined with path"));
|
||||||
|
|
||||||
|
let invalid = execute_tool("GitShow", &json!({"commit": "HEAD", "format": "bogus"}))
|
||||||
|
.expect_err("invalid format should be rejected");
|
||||||
|
assert!(invalid.contains("unknown GitShow format"));
|
||||||
|
|
||||||
|
std::env::set_current_dir(&previous).expect("restore cwd");
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_unknown_tool_names() {
|
fn rejects_unknown_tool_names() {
|
||||||
let error = execute_tool("nope", &json!({})).expect_err("tool should be rejected");
|
let error = execute_tool("nope", &json!({})).expect_err("tool should be rejected");
|
||||||
@@ -7477,6 +7731,29 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allowed_tools_normalize_to_canonical_snake_case_and_aliases_432() {
|
||||||
|
let registry = GlobalToolRegistry::builtin();
|
||||||
|
let allowed = registry
|
||||||
|
.normalize_allowed_tools(&["Read,WebFetch,MCP".to_string()])
|
||||||
|
.expect("aliases and legacy names should normalize")
|
||||||
|
.expect("allow-list should be populated");
|
||||||
|
assert!(allowed.contains("read_file"));
|
||||||
|
assert!(allowed.contains("web_fetch"));
|
||||||
|
assert!(allowed.contains("mcp"));
|
||||||
|
assert!(!allowed.contains("Read"));
|
||||||
|
assert!(!allowed.contains("WebFetch"));
|
||||||
|
|
||||||
|
let canonical = registry.canonical_allowed_tool_names();
|
||||||
|
assert!(canonical.contains(&"web_fetch".to_string()));
|
||||||
|
assert!(canonical.contains(&"todo_write".to_string()));
|
||||||
|
assert!(!canonical.contains(&"WebFetch".to_string()));
|
||||||
|
assert_eq!(
|
||||||
|
registry.allowed_tool_aliases().get("WebFetch"),
|
||||||
|
Some(&"web_fetch".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_tools_extend_registry_definitions_permissions_and_search() {
|
fn runtime_tools_extend_registry_definitions_permissions_and_search() {
|
||||||
let registry = GlobalToolRegistry::builtin()
|
let registry = GlobalToolRegistry::builtin()
|
||||||
@@ -8458,7 +8735,7 @@ mod tests {
|
|||||||
.expect("spawn job should be captured");
|
.expect("spawn job should be captured");
|
||||||
assert_eq!(captured_job.prompt, "Check tests and outstanding work.");
|
assert_eq!(captured_job.prompt, "Check tests and outstanding work.");
|
||||||
assert!(captured_job.allowed_tools.contains("read_file"));
|
assert!(captured_job.allowed_tools.contains("read_file"));
|
||||||
assert!(!captured_job.allowed_tools.contains("Agent"));
|
assert!(!captured_job.allowed_tools.contains("agent"));
|
||||||
|
|
||||||
let normalized = execute_tool(
|
let normalized = execute_tool(
|
||||||
"Agent",
|
"Agent",
|
||||||
@@ -9058,7 +9335,7 @@ mod tests {
|
|||||||
let general = allowed_tools_for_subagent("general-purpose");
|
let general = allowed_tools_for_subagent("general-purpose");
|
||||||
assert!(general.contains("bash"));
|
assert!(general.contains("bash"));
|
||||||
assert!(general.contains("write_file"));
|
assert!(general.contains("write_file"));
|
||||||
assert!(!general.contains("Agent"));
|
assert!(!general.contains("agent"));
|
||||||
|
|
||||||
let explore = allowed_tools_for_subagent("Explore");
|
let explore = allowed_tools_for_subagent("Explore");
|
||||||
assert!(explore.contains("read_file"));
|
assert!(explore.contains("read_file"));
|
||||||
@@ -9066,13 +9343,13 @@ mod tests {
|
|||||||
assert!(!explore.contains("bash"));
|
assert!(!explore.contains("bash"));
|
||||||
|
|
||||||
let plan = allowed_tools_for_subagent("Plan");
|
let plan = allowed_tools_for_subagent("Plan");
|
||||||
assert!(plan.contains("TodoWrite"));
|
assert!(plan.contains("todo_write"));
|
||||||
assert!(plan.contains("StructuredOutput"));
|
assert!(plan.contains("structured_output"));
|
||||||
assert!(!plan.contains("Agent"));
|
assert!(!plan.contains("agent"));
|
||||||
|
|
||||||
let verification = allowed_tools_for_subagent("Verification");
|
let verification = allowed_tools_for_subagent("Verification");
|
||||||
assert!(verification.contains("bash"));
|
assert!(verification.contains("bash"));
|
||||||
assert!(verification.contains("PowerShell"));
|
assert!(verification.contains("power_shell"));
|
||||||
assert!(!verification.contains("write_file"));
|
assert!(!verification.contains("write_file"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10156,6 +10433,26 @@ printf 'pwsh:%s' "$1"
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn given_workspace_write_enforcer_when_web_tools_then_denied() {
|
||||||
|
let registry = workspace_write_registry();
|
||||||
|
for (tool, input) in [
|
||||||
|
(
|
||||||
|
"WebFetch",
|
||||||
|
json!({"url":"https://example.com", "prompt":"summarize"}),
|
||||||
|
),
|
||||||
|
("WebSearch", json!({"query":"rust language"})),
|
||||||
|
] {
|
||||||
|
let err = registry
|
||||||
|
.execute(tool, &input)
|
||||||
|
.expect_err("network tools should require explicit full access");
|
||||||
|
assert!(
|
||||||
|
err.contains("requires 'danger-full-access'"),
|
||||||
|
"{tool} should require elevated mode: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn given_workspace_write_enforcer_when_bash_uses_shell_expansion_then_denied() {
|
fn given_workspace_write_enforcer_when_bash_uses_shell_expansion_then_denied() {
|
||||||
let registry = workspace_write_registry();
|
let registry = workspace_write_registry();
|
||||||
|
|||||||
145
scripts/dogfood-probe.py
Normal file
145
scripts/dogfood-probe.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProbeResult:
|
||||||
|
kind: str
|
||||||
|
argv: list[str]
|
||||||
|
returncode: int | None
|
||||||
|
stdout: bytes
|
||||||
|
stderr: bytes
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stdout_text(self) -> str:
|
||||||
|
return self.stdout.decode('utf-8', errors='replace')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stderr_text(self) -> str:
|
||||||
|
return self.stderr.decode('utf-8', errors='replace')
|
||||||
|
|
||||||
|
def to_json_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
'kind': self.kind,
|
||||||
|
'argv': self.argv,
|
||||||
|
'returncode': self.returncode,
|
||||||
|
'stdout': self.stdout_text,
|
||||||
|
'stderr': self.stderr_text,
|
||||||
|
'message': self.message,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_probe(argv: Sequence[str], *, timeout: float = 10.0, require_stdout_json_byte0: bool = False) -> ProbeResult:
|
||||||
|
explicit_argv = [str(arg) for arg in argv]
|
||||||
|
if not explicit_argv:
|
||||||
|
return ProbeResult(
|
||||||
|
kind='probe_error',
|
||||||
|
argv=[],
|
||||||
|
returncode=None,
|
||||||
|
stdout=b'',
|
||||||
|
stderr=b'',
|
||||||
|
message='argv must contain at least the executable path',
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
completed = subprocess.run(
|
||||||
|
explicit_argv,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
return ProbeResult(
|
||||||
|
kind='timeout',
|
||||||
|
argv=explicit_argv,
|
||||||
|
returncode=None,
|
||||||
|
stdout=exc.stdout or b'',
|
||||||
|
stderr=exc.stderr or b'',
|
||||||
|
message=f'probe timed out after {timeout:g}s',
|
||||||
|
)
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
return ProbeResult(
|
||||||
|
kind='probe_error',
|
||||||
|
argv=explicit_argv,
|
||||||
|
returncode=None,
|
||||||
|
stdout=b'',
|
||||||
|
stderr=b'',
|
||||||
|
message=str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
if require_stdout_json_byte0:
|
||||||
|
if not completed.stdout:
|
||||||
|
return ProbeResult(
|
||||||
|
kind='product_error',
|
||||||
|
argv=explicit_argv,
|
||||||
|
returncode=completed.returncode,
|
||||||
|
stdout=completed.stdout,
|
||||||
|
stderr=completed.stderr,
|
||||||
|
message='stdout is empty; expected JSON at byte 0',
|
||||||
|
)
|
||||||
|
if completed.stdout[:1] not in (b'{', b'['):
|
||||||
|
return ProbeResult(
|
||||||
|
kind='product_error',
|
||||||
|
argv=explicit_argv,
|
||||||
|
returncode=completed.returncode,
|
||||||
|
stdout=completed.stdout,
|
||||||
|
stderr=completed.stderr,
|
||||||
|
message='stdout JSON does not start at byte 0',
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
json.loads(completed.stdout.decode('utf-8'))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
return ProbeResult(
|
||||||
|
kind='product_error',
|
||||||
|
argv=explicit_argv,
|
||||||
|
returncode=completed.returncode,
|
||||||
|
stdout=completed.stdout,
|
||||||
|
stderr=completed.stderr,
|
||||||
|
message=f'stdout is not parseable JSON: {exc}',
|
||||||
|
)
|
||||||
|
|
||||||
|
if completed.returncode != 0:
|
||||||
|
return ProbeResult(
|
||||||
|
kind='product_error',
|
||||||
|
argv=explicit_argv,
|
||||||
|
returncode=completed.returncode,
|
||||||
|
stdout=completed.stdout,
|
||||||
|
stderr=completed.stderr,
|
||||||
|
message=f'process exited with code {completed.returncode}',
|
||||||
|
)
|
||||||
|
|
||||||
|
return ProbeResult(
|
||||||
|
kind='ok',
|
||||||
|
argv=explicit_argv,
|
||||||
|
returncode=completed.returncode,
|
||||||
|
stdout=completed.stdout,
|
||||||
|
stderr=completed.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description='Run an argv-safe dogfood probe and emit separated channels as JSON.')
|
||||||
|
parser.add_argument('--timeout', type=float, default=10.0)
|
||||||
|
parser.add_argument('--stdout-json-byte0', action='store_true', help='Require stdout to be parseable JSON starting at byte 0.')
|
||||||
|
parser.add_argument('command', nargs=argparse.REMAINDER, help='Executable and arguments to run. Use -- before the target argv.')
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
command = args.command
|
||||||
|
if command and command[0] == '--':
|
||||||
|
command = command[1:]
|
||||||
|
|
||||||
|
result = run_probe(command, timeout=args.timeout, require_stdout_json_byte0=args.stdout_json_byte0)
|
||||||
|
print(json.dumps(result.to_json_dict(), sort_keys=True))
|
||||||
|
return 0 if result.kind == 'ok' else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
@@ -9,6 +9,9 @@ from pathlib import Path
|
|||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
NEXT_ID = REPO_ROOT / 'scripts' / 'roadmap-next-id.sh'
|
NEXT_ID = REPO_ROOT / 'scripts' / 'roadmap-next-id.sh'
|
||||||
|
DOGFOOD_PROBE = REPO_ROOT / 'scripts' / 'dogfood-probe.py'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def run_next_id(roadmap: Path, script: Path = NEXT_ID) -> subprocess.CompletedProcess[str]:
|
def run_next_id(roadmap: Path, script: Path = NEXT_ID) -> subprocess.CompletedProcess[str]:
|
||||||
@@ -21,6 +24,16 @@ def run_next_id(roadmap: Path, script: Path = NEXT_ID) -> subprocess.CompletedPr
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_dogfood_probe(args: list[str]) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
['python3', str(DOGFOOD_PROBE), *args],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RoadmapHelperTests(unittest.TestCase):
|
class RoadmapHelperTests(unittest.TestCase):
|
||||||
def test_roadmap_next_id_prints_only_next_id_after_duplicate_check(self) -> None:
|
def test_roadmap_next_id_prints_only_next_id_after_duplicate_check(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
@@ -46,6 +59,17 @@ class RoadmapHelperTests(unittest.TestCase):
|
|||||||
self.assertIn('999', result.stderr)
|
self.assertIn('999', result.stderr)
|
||||||
self.assertNotIn('1000', result.stdout)
|
self.assertNotIn('1000', result.stdout)
|
||||||
|
|
||||||
|
def test_roadmap_next_id_fails_when_explicit_roadmap_path_is_missing(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
roadmap = Path(temp_dir) / 'missing-ROADMAP.md'
|
||||||
|
|
||||||
|
result = run_next_id(roadmap)
|
||||||
|
|
||||||
|
self.assertNotEqual(0, result.returncode)
|
||||||
|
self.assertEqual('', result.stdout)
|
||||||
|
self.assertIn('ROADMAP not found', result.stderr)
|
||||||
|
self.assertIn(str(roadmap), result.stderr)
|
||||||
|
|
||||||
def test_roadmap_next_id_fails_closed_when_checker_is_unavailable(self) -> None:
|
def test_roadmap_next_id_fails_closed_when_checker_is_unavailable(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
script_dir = Path(temp_dir) / 'scripts'
|
script_dir = Path(temp_dir) / 'scripts'
|
||||||
@@ -62,6 +86,78 @@ class RoadmapHelperTests(unittest.TestCase):
|
|||||||
self.assertIn('required ROADMAP id checker not found or not readable', result.stderr)
|
self.assertIn('required ROADMAP id checker not found or not readable', result.stderr)
|
||||||
self.assertIn('refusing to print a next id', result.stderr)
|
self.assertIn('refusing to print a next id', result.stderr)
|
||||||
|
|
||||||
|
def test_dogfood_probe_runs_explicit_argv_and_separates_channels(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
fixture = Path(temp_dir) / 'fixture.py'
|
||||||
|
fixture.write_text(
|
||||||
|
'from __future__ import annotations\n'
|
||||||
|
'import json\n'
|
||||||
|
'import sys\n'
|
||||||
|
'print(json.dumps({"argv": sys.argv[1:]}))\n'
|
||||||
|
'print("diagnostic", file=sys.stderr)\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
result = run_dogfood_probe([
|
||||||
|
'--stdout-json-byte0',
|
||||||
|
'--',
|
||||||
|
'python3',
|
||||||
|
str(fixture),
|
||||||
|
'--output-format',
|
||||||
|
'json',
|
||||||
|
'doctor',
|
||||||
|
'--help',
|
||||||
|
])
|
||||||
|
|
||||||
|
self.assertEqual(0, result.returncode)
|
||||||
|
payload = __import__('json').loads(result.stdout)
|
||||||
|
self.assertEqual('ok', payload['kind'])
|
||||||
|
self.assertEqual([
|
||||||
|
'python3',
|
||||||
|
str(fixture),
|
||||||
|
'--output-format',
|
||||||
|
'json',
|
||||||
|
'doctor',
|
||||||
|
'--help',
|
||||||
|
], payload['argv'])
|
||||||
|
self.assertEqual(0, payload['returncode'])
|
||||||
|
self.assertEqual('{"argv": ["--output-format", "json", "doctor", "--help"]}\n', payload['stdout'])
|
||||||
|
self.assertEqual('diagnostic\n', payload['stderr'])
|
||||||
|
|
||||||
|
def test_dogfood_probe_labels_timeout_separately_from_product_error(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
fixture = Path(temp_dir) / 'sleep.py'
|
||||||
|
fixture.write_text('import time\ntime.sleep(2)\n')
|
||||||
|
|
||||||
|
result = run_dogfood_probe(['--timeout', '0.1', '--', 'python3', str(fixture)])
|
||||||
|
|
||||||
|
self.assertEqual(1, result.returncode)
|
||||||
|
payload = __import__('json').loads(result.stdout)
|
||||||
|
self.assertEqual('timeout', payload['kind'])
|
||||||
|
self.assertIsNone(payload['returncode'])
|
||||||
|
self.assertIn('timed out', payload['message'])
|
||||||
|
|
||||||
|
def test_dogfood_probe_labels_probe_construction_failure(self) -> None:
|
||||||
|
result = run_dogfood_probe([])
|
||||||
|
|
||||||
|
self.assertEqual(1, result.returncode)
|
||||||
|
payload = __import__('json').loads(result.stdout)
|
||||||
|
self.assertEqual('probe_error', payload['kind'])
|
||||||
|
self.assertEqual([], payload['argv'])
|
||||||
|
self.assertIsNone(payload['returncode'])
|
||||||
|
self.assertIn('argv must contain', payload['message'])
|
||||||
|
|
||||||
|
def test_dogfood_probe_labels_stdout_json_prefix_failure_as_product_error(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
fixture = Path(temp_dir) / 'prefixed.py'
|
||||||
|
fixture.write_text('print("warning before json")\nprint("{}")\n')
|
||||||
|
|
||||||
|
result = run_dogfood_probe(['--stdout-json-byte0', '--', 'python3', str(fixture)])
|
||||||
|
|
||||||
|
self.assertEqual(1, result.returncode)
|
||||||
|
payload = __import__('json').loads(result.stdout)
|
||||||
|
self.assertEqual('product_error', payload['kind'])
|
||||||
|
self.assertEqual(0, payload['returncode'])
|
||||||
|
self.assertIn('byte 0', payload['message'])
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user