Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
142282a1d9 | ||
|
|
786735655d | ||
|
|
4dc603f622 | ||
|
|
4d2e9f0d08 | ||
|
|
1335ca6671 | ||
|
|
ddc45f2c81 | ||
|
|
eebb384676 | ||
|
|
39d6560137 | ||
|
|
bb3346a828 | ||
|
|
ac5fe0d218 | ||
|
|
0c6f179a84 | ||
|
|
feb992757c | ||
|
|
23c27e0778 | ||
|
|
2c418fc8ff | ||
|
|
571af701bd | ||
|
|
2eb9d94fac | ||
|
|
eefa978f61 | ||
|
|
bea2230633 | ||
|
|
df67edec13 | ||
|
|
42eb6ab5fb | ||
|
|
8a68660161 | ||
|
|
ab018b3098 | ||
|
|
1697b7a5ce | ||
|
|
6b5852dc18 | ||
|
|
aa52040498 | ||
|
|
547330197b | ||
|
|
7f11491078 | ||
|
|
0324c1d410 | ||
|
|
b9ef1d271a | ||
|
|
9520f2bad7 | ||
|
|
e2d18c50f0 | ||
|
|
e20b762426 | ||
|
|
7f8e5f08bb | ||
|
|
99721c762f |
No files matched your search
@@ -4,9 +4,15 @@
|
||||
|
||||
<p align="center"><strong>A terminal coding agent you own.</strong></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/70679?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/70679/daily?language=Go" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
|
||||
<a href="https://trendshift.io/repositories/70679?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/70679/weekly?language=Go" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
|
||||
<a href="https://trendshift.io/repositories/70679?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/70679" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="Go 1.26.9+" src="https://img.shields.io/badge/Go-1.26.9+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
|
||||
<br>
|
||||
@@ -23,6 +29,70 @@ zero exec "fix the failing test in ./pkg"
|
||||
zero exec --output-format stream-json < turns.jsonl
|
||||
```
|
||||
|
||||
## Partners
|
||||
|
||||
<table align="center">
|
||||
<tr>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://twigpine.com">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://twigpine.com/logo-dark.png">
|
||||
<img src="https://twigpine.com/logo.png" alt="Twigpine logo" width="72">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://mimo.mi.com">
|
||||
<img src="https://mimo.xiaomi.com/mimo-v2-pro/assets/logo.svg" alt="Xiaomi MiMo logo" width="136">
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://novita.ai/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/novita-logo-dark.svg">
|
||||
<img src="docs/assets/novita-logo.svg" alt="Novita AI logo" width="136">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://concentrate.ai/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/concentrate-logo-dark.svg">
|
||||
<img src="docs/assets/concentrate-logo.svg" alt="Concentrate logo" width="64">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://exa.ai/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/exa-logo-dark.svg">
|
||||
<img src="docs/assets/exa-logo.svg" alt="Exa logo" width="110">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://twigpine.com"><strong>Twigpine</strong></a></td>
|
||||
<td align="center"><a href="https://mimo.mi.com"><strong>Xiaomi MiMo</strong></a></td>
|
||||
<td align="center"><a href="https://novita.ai/"><strong>Novita AI</strong></a></td>
|
||||
<td align="center"><a href="https://concentrate.ai/"><strong>Concentrate</strong></a></td>
|
||||
<td align="center"><a href="https://exa.ai/"><strong>Exa</strong></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://www.gmicloud.ai/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/gmi-cloud-logo-dark.svg">
|
||||
<img src="docs/assets/gmi-cloud-logo.svg" alt="GMI Cloud logo" width="120">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://www.gmicloud.ai/"><strong>GMI Cloud</strong></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Why Zero
|
||||
|
||||
- **Use the model you want.** Bring OpenAI, Anthropic, Gemini, Groq, OpenRouter,
|
||||
@@ -76,7 +146,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
|
||||
|
||||
### From source
|
||||
|
||||
Source builds require Go 1.26.6+.
|
||||
Source builds require Go 1.26.9+.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
@@ -133,6 +203,7 @@ export GEMINI_API_KEY=...
|
||||
export AIMLAPI_API_KEY=...
|
||||
export LONGCAT_API_KEY=...
|
||||
export FIREWORKS_API_KEY=...
|
||||
export GMI_API_KEY=...
|
||||
export MINIMAX_API_KEY=...
|
||||
export MINIMAXI_API_KEY=...
|
||||
```
|
||||
@@ -155,6 +226,12 @@ To configure Fireworks AI directly, run:
|
||||
zero providers setup fireworks --set-active
|
||||
```
|
||||
|
||||
To configure GMI Cloud directly, run:
|
||||
|
||||
```bash
|
||||
zero providers setup gmicloud --set-active
|
||||
```
|
||||
|
||||
MiniMax presets use the Anthropic-compatible endpoints for the global and China
|
||||
regions:
|
||||
|
||||
@@ -313,7 +390,7 @@ zero context context-budget report
|
||||
zero repo-map deterministic repository map
|
||||
zero repo-info local repository summary
|
||||
zero search | find search local session history
|
||||
zero sessions inspect, resume, fork, and rewind sessions
|
||||
zero sessions inspect, resume, fork, rewind, and prune sessions
|
||||
zero spec manage spec-mode drafts
|
||||
zero specialist manage specialist subagents
|
||||
zero skills manage markdown instruction skills
|
||||
@@ -331,6 +408,13 @@ zero update --check check for newer releases
|
||||
zero upgrade download, verify, and install the latest release
|
||||
```
|
||||
|
||||
Cron keeps the newest 1,000 run outcomes per job in `runs.jsonl`. Each new run
|
||||
atomically replaces the history with the retained tail; existing larger histories
|
||||
are trimmed on their next run. Archive the file before that run if you need older
|
||||
outcomes. This retention does not delete session directories or reset fire counts.
|
||||
Malformed records and legacy lines of 1 MiB or more are skipped when reading or
|
||||
compacting history; new records of that size are rejected.
|
||||
|
||||
## Extending Zero
|
||||
|
||||
### Project and personal instructions
|
||||
|
||||
@@ -4,9 +4,15 @@
|
||||
|
||||
<p align="center"><strong>一个独属于你的终端编码智能体。</strong></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/70679?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/70679/daily?language=Go" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
|
||||
<a href="https://trendshift.io/repositories/70679?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/70679/weekly?language=Go" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
|
||||
<a href="https://trendshift.io/repositories/70679?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/70679" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="Go 1.26.9+" src="https://img.shields.io/badge/Go-1.26.9+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
|
||||
<br>
|
||||
@@ -21,6 +27,70 @@ zero exec "修复 ./pkg 中失败的测试"
|
||||
zero exec --output-format stream-json < turns.jsonl
|
||||
```
|
||||
|
||||
## 合作伙伴
|
||||
|
||||
<table align="center">
|
||||
<tr>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://twigpine.com">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://twigpine.com/logo-dark.png">
|
||||
<img src="https://twigpine.com/logo.png" alt="Twigpine logo" width="72">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://mimo.mi.com">
|
||||
<img src="https://mimo.xiaomi.com/mimo-v2-pro/assets/logo.svg" alt="Xiaomi MiMo logo" width="136">
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://novita.ai/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/novita-logo-dark.svg">
|
||||
<img src="docs/assets/novita-logo.svg" alt="Novita AI logo" width="136">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://concentrate.ai/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/concentrate-logo-dark.svg">
|
||||
<img src="docs/assets/concentrate-logo.svg" alt="Concentrate logo" width="64">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://exa.ai/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/exa-logo-dark.svg">
|
||||
<img src="docs/assets/exa-logo.svg" alt="Exa logo" width="110">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://twigpine.com"><strong>Twigpine</strong></a></td>
|
||||
<td align="center"><a href="https://mimo.mi.com"><strong>Xiaomi MiMo</strong></a></td>
|
||||
<td align="center"><a href="https://novita.ai/"><strong>Novita AI</strong></a></td>
|
||||
<td align="center"><a href="https://concentrate.ai/"><strong>Concentrate</strong></a></td>
|
||||
<td align="center"><a href="https://exa.ai/"><strong>Exa</strong></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://www.gmicloud.ai/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/gmi-cloud-logo-dark.svg">
|
||||
<img src="docs/assets/gmi-cloud-logo.svg" alt="GMI Cloud logo" width="120">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://www.gmicloud.ai/"><strong>GMI Cloud</strong></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 为什么选择 Zero
|
||||
|
||||
- **使用你想要的模型。** 支持 OpenAI、Anthropic、Gemini、Groq、OpenRouter、DeepSeek、Mistral、xAI、Qwen、Kimi、GitHub Models、Ollama、LM Studio、Atomic Chat,或任何 OpenAI/Anthropic 兼容端点。
|
||||
@@ -57,7 +127,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
|
||||
|
||||
### 从源码构建
|
||||
|
||||
源码构建需要 Go 1.26.6+。
|
||||
源码构建需要 Go 1.26.9+。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
@@ -106,6 +176,7 @@ export OPENAI_API_KEY=sk-...
|
||||
export ANTHROPIC_API_KEY=...
|
||||
export GEMINI_API_KEY=...
|
||||
export LONGCAT_API_KEY=...
|
||||
export GMI_API_KEY=...
|
||||
```
|
||||
|
||||
要直接配置美团 LongCat(LongCat-2.0),运行:
|
||||
@@ -114,6 +185,12 @@ export LONGCAT_API_KEY=...
|
||||
zero providers setup longcat --set-active
|
||||
```
|
||||
|
||||
要直接配置 GMI Cloud,运行:
|
||||
|
||||
```bash
|
||||
zero providers setup gmicloud --set-active
|
||||
```
|
||||
|
||||
对于本地模型,运行 Ollama、LM Studio 或 [Atomic Chat](https://atomic.chat) 桌面应用,
|
||||
然后使用 `zero setup` 或 `zero providers detect`。使用 Atomic Chat 时,请先加载模型并启用
|
||||
本地 OpenAI 兼容 API(默认地址为 `http://127.0.0.1:1337/v1`),再选择 `atomic-chat-local`。
|
||||
@@ -218,7 +295,7 @@ zero context 上下文预算报告
|
||||
zero repo-map 确定性仓库映射
|
||||
zero repo-info 本地仓库摘要
|
||||
zero search | find 搜索本地会话历史
|
||||
zero sessions 检查、恢复、分叉和回滚会话
|
||||
zero sessions 检查、恢复、分叉、回滚和清理会话
|
||||
zero spec 管理规范模式草稿
|
||||
zero specialist 管理专业子智能体
|
||||
zero skills 管理 Markdown 指令技能
|
||||
|
||||
@@ -153,7 +153,7 @@ describe a version that is no longer on disk. Prefer `mise upgrade`.
|
||||
go install github.com/Gitlawb/zero/cmd/zero@latest
|
||||
```
|
||||
|
||||
This builds from source, so it needs Go 1.26.6+ and it does not go through the
|
||||
This builds from source, so it needs Go 1.26.9+ and it does not go through the
|
||||
release archives. Two consequences worth knowing before you pick it:
|
||||
|
||||
- On Linux you also need the sandbox helper, which is a separate binary and is
|
||||
@@ -180,7 +180,7 @@ Build a local binary:
|
||||
go build -o zero ./cmd/zero
|
||||
```
|
||||
|
||||
Source builds require Go 1.26.6+.
|
||||
Source builds require Go 1.26.9+.
|
||||
|
||||
### Sandbox Helpers For Source Builds
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Local per-model completion limits
|
||||
|
||||
Add `modelRPM` to the existing Zero config file:
|
||||
|
||||
```json
|
||||
{"modelRPM": {"gpt-4.1": 30, "claude-sonnet-4.5": 15}}
|
||||
```
|
||||
|
||||
Keys resolve through the model registry; custom IDs match exactly after trimming
|
||||
whitespace. Missing keys and zero mean unlimited. Negative limits and conflicting
|
||||
aliases are configuration errors. Project config may add or tighten a user cap,
|
||||
but cannot relax it. Changes take effect on the next Zero process start.
|
||||
|
||||
For `zero exec` and interactive agent turns, admission happens immediately before
|
||||
the wrapped turn session's `Stream`. N admissions fit in any sliding 60-second
|
||||
window; N+1 returns a local rate-limit error before entering that method. The
|
||||
error reports when a slot expires. Exec uses its existing provider-error exit
|
||||
code plus a local-limit hint. There is no sleep or hidden retry. Failed admitted
|
||||
requests still consume a slot.
|
||||
|
||||
Sessions and model switches share a limiter in one process. Separate processes
|
||||
have independent windows; restarting clears them. This is not an account-wide
|
||||
cap. It counts Stream admissions, not all HTTP requests: setup/prewarm,
|
||||
compaction, provider-internal retries, discovery and direct calls outside that
|
||||
boundary are not counted. No TPM, cost budgets, persistence, cross-process
|
||||
coordination or new runtime/Python dependency is introduced.
|
||||
@@ -6,6 +6,7 @@ Zero.
|
||||
## User Docs
|
||||
|
||||
- [Install](INSTALL.md)
|
||||
- [Local per-model completion limits](MODEL_RPM.md)
|
||||
- [Update flow](UPDATE.md)
|
||||
- [OAuth logins and subscription-backed providers](oauth-subscriptions.md)
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" width="80" height="80">
|
||||
<circle cx="6.4" cy="6.4" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="6.4" r="1.3440793933950872" fill="#FFFFFF"/><circle cx="25.6" cy="6.4" r="2.03724340327418" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="6.4" r="2.5983501502741575" fill="#FFFFFF"/><circle cx="44.8" cy="6.4" r="3.087883576356698" fill="#FFFFFF"/><circle cx="54.4" cy="6.4" r="3.530261898078346" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="6.4" r="3.938362368365323" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="6.4" r="4.32" fill="#FFFFFF"/><circle cx="6.4" cy="16" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="16" r="1.2253406720005793" fill="#FFFFFF"/><circle cx="25.6" cy="16" r="1.8572691561702617" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="16" r="2.3688065860361402" fill="#FFFFFF"/><circle cx="44.8" cy="16" r="2.8150936284760535" fill="#FFFFFF"/><circle cx="54.4" cy="16" r="3.2183913448763177" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="16" r="3.5904393853139163" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="16" r="3.9383623683653233" fill="#FFFFFF"/><circle cx="6.4" cy="25.6" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="25.6" r="1.0983685811330859" fill="#FFFFFF"/><circle cx="25.6" cy="25.6" r="1.6648154545579388" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="25.6" r="2.1233464197636582" fill="#FFFFFF"/><circle cx="44.8" cy="25.6" r="2.5233883646560065" fill="#FFFFFF"/><circle cx="54.4" cy="25.6" r="2.8848956178295655" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="25.6" r="3.2183913448763177" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="25.6" r="3.530261898078346" fill="#FFFFFF"/><circle cx="6.4" cy="35.199999999999996" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="35.199999999999996" r="0.9607316398574451" fill="#FFFFFF"/><circle cx="25.6" cy="35.199999999999996" r="1.456196862502631" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="35.199999999999996" r="1.8572691561702614" fill="#FFFFFF"/><circle cx="44.8" cy="35.199999999999996" r="2.2071817085957024" fill="#FFFFFF"/><circle cx="54.4" cy="35.199999999999996" r="2.5233883646560065" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="35.199999999999996" r="2.815093628476053" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="35.199999999999996" r="3.087883576356698" fill="#FFFFFF"/><circle cx="6.4" cy="44.8" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="44.8" r="0.8084233550482692" fill="#FFFFFF"/><circle cx="25.6" cy="44.8" r="1.2253406720005793" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="44.8" r="1.56282951468281" fill="#FFFFFF"/><circle cx="44.8" cy="44.8" r="1.8572691561702617" fill="#FFFFFF"/><circle cx="54.4" cy="44.8" r="2.1233464197636582" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="44.8" r="2.3688065860361402" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="44.8" r="2.5983501502741584" fill="#FFFFFF"/><circle cx="6.4" cy="54.4" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="54.4" r="0.633846499460857" fill="#FFFFFF"/><circle cx="25.6" cy="54.4" r="0.9607316398574451" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="54.4" r="1.225340672000579" fill="#FFFFFF"/><circle cx="44.8" cy="54.4" r="1.456196862502631" fill="#FFFFFF"/><circle cx="54.4" cy="54.4" r="1.6648154545579388" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="54.4" r="1.8572691561702614" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="54.4" r="2.03724340327418" fill="#FFFFFF"/><circle cx="6.4" cy="63.99999999999999" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="63.99999999999999" r="0.4181827351271541" fill="#FFFFFF"/><circle cx="25.6" cy="63.99999999999999" r="0.6338464994608571" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="63.99999999999999" r="0.8084233550482695" fill="#FFFFFF"/><circle cx="44.8" cy="63.99999999999999" r="0.9607316398574454" fill="#FFFFFF"/><circle cx="54.4" cy="63.99999999999999" r="1.0983685811330863" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="63.99999999999999" r="1.2253406720005795" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="63.99999999999999" r="1.3440793933950876" fill="#FFFFFF"/><circle cx="6.4" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="25.6" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="44.8" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="54.4" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" width="80" height="80">
|
||||
<circle cx="6.4" cy="6.4" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="6.4" r="1.3440793933950872" fill="#0D0E10"/><circle cx="25.6" cy="6.4" r="2.03724340327418" fill="#0D0E10"/><circle cx="35.199999999999996" cy="6.4" r="2.5983501502741575" fill="#0D0E10"/><circle cx="44.8" cy="6.4" r="3.087883576356698" fill="#0D0E10"/><circle cx="54.4" cy="6.4" r="3.530261898078346" fill="#0D0E10"/><circle cx="63.99999999999999" cy="6.4" r="3.938362368365323" fill="#0D0E10"/><circle cx="73.60000000000001" cy="6.4" r="4.32" fill="#0D0E10"/><circle cx="6.4" cy="16" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="16" r="1.2253406720005793" fill="#0D0E10"/><circle cx="25.6" cy="16" r="1.8572691561702617" fill="#0D0E10"/><circle cx="35.199999999999996" cy="16" r="2.3688065860361402" fill="#0D0E10"/><circle cx="44.8" cy="16" r="2.8150936284760535" fill="#0D0E10"/><circle cx="54.4" cy="16" r="3.2183913448763177" fill="#0D0E10"/><circle cx="63.99999999999999" cy="16" r="3.5904393853139163" fill="#0D0E10"/><circle cx="73.60000000000001" cy="16" r="3.9383623683653233" fill="#0D0E10"/><circle cx="6.4" cy="25.6" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="25.6" r="1.0983685811330859" fill="#0D0E10"/><circle cx="25.6" cy="25.6" r="1.6648154545579388" fill="#0D0E10"/><circle cx="35.199999999999996" cy="25.6" r="2.1233464197636582" fill="#0D0E10"/><circle cx="44.8" cy="25.6" r="2.5233883646560065" fill="#0D0E10"/><circle cx="54.4" cy="25.6" r="2.8848956178295655" fill="#0D0E10"/><circle cx="63.99999999999999" cy="25.6" r="3.2183913448763177" fill="#0D0E10"/><circle cx="73.60000000000001" cy="25.6" r="3.530261898078346" fill="#0D0E10"/><circle cx="6.4" cy="35.199999999999996" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="35.199999999999996" r="0.9607316398574451" fill="#0D0E10"/><circle cx="25.6" cy="35.199999999999996" r="1.456196862502631" fill="#0D0E10"/><circle cx="35.199999999999996" cy="35.199999999999996" r="1.8572691561702614" fill="#0D0E10"/><circle cx="44.8" cy="35.199999999999996" r="2.2071817085957024" fill="#0D0E10"/><circle cx="54.4" cy="35.199999999999996" r="2.5233883646560065" fill="#0D0E10"/><circle cx="63.99999999999999" cy="35.199999999999996" r="2.815093628476053" fill="#0D0E10"/><circle cx="73.60000000000001" cy="35.199999999999996" r="3.087883576356698" fill="#0D0E10"/><circle cx="6.4" cy="44.8" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="44.8" r="0.8084233550482692" fill="#0D0E10"/><circle cx="25.6" cy="44.8" r="1.2253406720005793" fill="#0D0E10"/><circle cx="35.199999999999996" cy="44.8" r="1.56282951468281" fill="#0D0E10"/><circle cx="44.8" cy="44.8" r="1.8572691561702617" fill="#0D0E10"/><circle cx="54.4" cy="44.8" r="2.1233464197636582" fill="#0D0E10"/><circle cx="63.99999999999999" cy="44.8" r="2.3688065860361402" fill="#0D0E10"/><circle cx="73.60000000000001" cy="44.8" r="2.5983501502741584" fill="#0D0E10"/><circle cx="6.4" cy="54.4" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="54.4" r="0.633846499460857" fill="#0D0E10"/><circle cx="25.6" cy="54.4" r="0.9607316398574451" fill="#0D0E10"/><circle cx="35.199999999999996" cy="54.4" r="1.225340672000579" fill="#0D0E10"/><circle cx="44.8" cy="54.4" r="1.456196862502631" fill="#0D0E10"/><circle cx="54.4" cy="54.4" r="1.6648154545579388" fill="#0D0E10"/><circle cx="63.99999999999999" cy="54.4" r="1.8572691561702614" fill="#0D0E10"/><circle cx="73.60000000000001" cy="54.4" r="2.03724340327418" fill="#0D0E10"/><circle cx="6.4" cy="63.99999999999999" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="63.99999999999999" r="0.4181827351271541" fill="#0D0E10"/><circle cx="25.6" cy="63.99999999999999" r="0.6338464994608571" fill="#0D0E10"/><circle cx="35.199999999999996" cy="63.99999999999999" r="0.8084233550482695" fill="#0D0E10"/><circle cx="44.8" cy="63.99999999999999" r="0.9607316398574454" fill="#0D0E10"/><circle cx="54.4" cy="63.99999999999999" r="1.0983685811330863" fill="#0D0E10"/><circle cx="63.99999999999999" cy="63.99999999999999" r="1.2253406720005795" fill="#0D0E10"/><circle cx="73.60000000000001" cy="63.99999999999999" r="1.3440793933950876" fill="#0D0E10"/><circle cx="6.4" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="25.6" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="35.199999999999996" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="44.8" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="54.4" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="63.99999999999999" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="73.60000000000001" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg width="128" height="40" viewBox="0 0 413 129" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M106.417 9.30741C106.417 9.57392 106.324 9.83227 106.155 10.0388L61.5395 64.5L106.155 118.96C106.324 119.166 106.417 119.425 106.417 119.691V127.843C106.417 128.482 105.897 129 105.255 129H1.16175C0.520132 129 0 128.482 0 127.843V1.15695C0 0.517982 0.520131 0 1.16174 0H105.255C105.897 0 106.417 0.517984 106.417 1.15695V9.30741ZM19.0364 116.382H87.6921L53.363 74.4795L19.0364 116.382ZM12.6699 104.192L40.1426 70.6585H12.6699V104.192ZM12.6699 58.0409H39.8964L12.6699 24.8064V58.0409ZM53.363 54.5193L87.6921 12.6176H19.0364L53.363 54.5193Z" fill="white"/>
|
||||
<path d="M176.198 117.65C166.094 117.65 157.323 115.553 149.886 111.36C142.588 107.167 136.905 101.017 132.835 92.911C128.906 84.8045 126.941 75.0208 126.941 63.5599C126.941 53.3569 128.976 44.2022 133.046 36.0957C137.256 27.9892 143.01 21.6298 150.307 17.0175C157.744 12.4052 166.024 10.099 175.146 10.099C185.109 10.099 193.599 12.3353 200.616 16.8078C207.633 21.1406 212.965 27.3602 216.614 35.4667C220.263 43.4334 222.087 52.8677 222.087 63.7695V66.1766C222.087 66.8156 221.567 67.3336 220.925 67.3336H145.044C145.185 74.881 146.588 81.3802 149.254 86.8311C152.061 92.282 155.78 96.475 160.411 99.4101C165.182 102.205 170.515 103.603 176.409 103.603C183.285 103.603 189.179 102.066 194.091 98.9908C198.779 95.9225 201.741 91.008 202.977 84.2471C203.08 83.6853 203.565 83.267 204.139 83.267H219.737C220.429 83.267 220.969 83.8669 220.871 84.5494C220.038 90.3376 217.988 95.6406 214.72 100.458C211.211 105.63 206.159 109.823 199.564 113.037C193.108 116.112 185.32 117.65 176.198 117.65ZM202.721 54.5449C202.721 45.3203 200.265 37.9825 195.354 32.5316C190.582 27.0807 183.846 24.3552 175.146 24.3552C166.866 24.3552 160.06 27.2205 154.727 32.9509C149.395 38.6813 146.377 45.8794 145.676 54.5449H202.721Z" fill="white"/>
|
||||
<path d="M364.585 117.65C358.13 117.65 352.236 116.392 346.903 113.876C341.571 111.36 337.361 107.796 334.273 103.184C331.186 98.4317 329.642 92.9808 329.642 86.8311C329.642 77.7462 332.73 70.688 338.904 65.6564C345.219 60.485 353.359 57.0607 363.322 55.3835L385.004 52.0291C387.67 51.6098 389.705 51.0507 391.108 50.3519C392.652 49.5133 393.704 48.465 394.266 47.2071C394.967 45.8095 395.318 44.0624 395.318 41.9659C395.318 36.515 393.424 32.2521 389.635 29.1772C385.846 25.9626 380.934 24.3552 374.9 24.3552C367.322 24.3552 361.498 26.2421 357.428 30.0158C353.595 33.4387 351.443 38.1016 350.971 44.0046C350.922 44.622 350.412 45.1106 349.79 45.1106H334.014C333.353 45.1106 332.823 44.5605 332.872 43.9032C333.312 38.0771 335.042 32.6792 338.062 27.7096C341.29 22.3985 345.921 18.1356 351.955 14.921C358.13 11.7063 365.287 10.099 373.426 10.099C381.706 10.099 388.793 11.6364 394.687 14.7113C400.721 17.7862 405.282 22.1888 408.369 27.9193C411.456 33.6497 413 40.4284 413 48.2554V113.348C413 113.987 412.48 114.505 411.838 114.505H396.901C396.259 114.505 395.739 113.987 395.739 113.348V109.473C395.739 108.076 395.739 106.678 395.739 105.28C395.739 103.743 395.739 102.275 395.739 100.878C393.213 106.189 389.214 110.312 383.741 113.247C378.408 116.182 372.023 117.65 364.585 117.65ZM367.111 103.603C372.444 103.603 377.215 102.485 381.425 100.249C385.775 97.8727 389.143 94.6581 391.529 90.6048C394.055 86.4118 395.318 81.6597 395.318 76.3486V61.4634C394.757 62.302 393.564 63.0707 391.74 63.7695C389.915 64.4684 387.389 65.1672 384.162 65.866L369.427 68.5915C362.971 69.8494 357.849 71.9459 354.06 74.881C350.271 77.8161 348.377 81.7995 348.377 86.8311C348.377 91.8627 350.131 95.916 353.639 98.9908C357.288 102.066 361.779 103.603 367.111 103.603Z" fill="white"/>
|
||||
<path d="M285.673 62.9573L325.127 112.948C325.726 113.707 325.183 114.82 324.213 114.82H305.929C305.572 114.82 305.235 114.657 305.015 114.378L275.053 76.414L245.093 114.378C244.872 114.657 244.536 114.82 244.179 114.82H225.893C224.924 114.82 224.381 113.707 224.98 112.948L264.433 62.9573L227.177 15.7512C226.578 14.9921 227.121 13.8793 228.091 13.8793H246.375C246.732 13.8793 247.068 14.0423 247.289 14.3214L275.053 49.5006L302.818 14.3214C303.038 14.0423 303.375 13.8793 303.731 13.8793H322.016C322.985 13.8793 323.528 14.9921 322.929 15.7512L285.673 62.9573Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg width="128" height="40" viewBox="0 0 413 129" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M106.417 9.30741C106.417 9.57392 106.324 9.83227 106.155 10.0388L61.5395 64.5L106.155 118.96C106.324 119.166 106.417 119.425 106.417 119.691V127.843C106.417 128.482 105.897 129 105.255 129H1.16175C0.520132 129 0 128.482 0 127.843V1.15695C0 0.517982 0.520131 0 1.16174 0H105.255C105.897 0 106.417 0.517984 106.417 1.15695V9.30741ZM19.0364 116.382H87.6921L53.363 74.4795L19.0364 116.382ZM12.6699 104.192L40.1426 70.6585H12.6699V104.192ZM12.6699 58.0409H39.8964L12.6699 24.8064V58.0409ZM53.363 54.5193L87.6921 12.6176H19.0364L53.363 54.5193Z" fill="#0143D9"/>
|
||||
<path d="M176.198 117.65C166.094 117.65 157.323 115.553 149.886 111.36C142.588 107.167 136.905 101.017 132.835 92.911C128.906 84.8045 126.941 75.0208 126.941 63.5599C126.941 53.3569 128.976 44.2022 133.046 36.0957C137.256 27.9892 143.01 21.6298 150.307 17.0175C157.744 12.4052 166.024 10.099 175.146 10.099C185.109 10.099 193.599 12.3353 200.616 16.8078C207.633 21.1406 212.965 27.3602 216.614 35.4667C220.263 43.4334 222.087 52.8677 222.087 63.7695V66.1766C222.087 66.8156 221.567 67.3336 220.925 67.3336H145.044C145.185 74.881 146.588 81.3802 149.254 86.8311C152.061 92.282 155.78 96.475 160.411 99.4101C165.182 102.205 170.515 103.603 176.409 103.603C183.285 103.603 189.179 102.066 194.091 98.9908C198.779 95.9225 201.741 91.008 202.977 84.2471C203.08 83.6853 203.565 83.267 204.139 83.267H219.737C220.429 83.267 220.969 83.8669 220.871 84.5494C220.038 90.3376 217.988 95.6406 214.72 100.458C211.211 105.63 206.159 109.823 199.564 113.037C193.108 116.112 185.32 117.65 176.198 117.65ZM202.721 54.5449C202.721 45.3203 200.265 37.9825 195.354 32.5316C190.582 27.0807 183.846 24.3552 175.146 24.3552C166.866 24.3552 160.06 27.2205 154.727 32.9509C149.395 38.6813 146.377 45.8794 145.676 54.5449H202.721Z" fill="black"/>
|
||||
<path d="M364.585 117.65C358.13 117.65 352.236 116.392 346.903 113.876C341.571 111.36 337.361 107.796 334.273 103.184C331.186 98.4317 329.642 92.9808 329.642 86.8311C329.642 77.7462 332.73 70.688 338.904 65.6564C345.219 60.485 353.359 57.0607 363.322 55.3835L385.004 52.0291C387.67 51.6098 389.705 51.0507 391.108 50.3519C392.652 49.5133 393.704 48.465 394.266 47.2071C394.967 45.8095 395.318 44.0624 395.318 41.9659C395.318 36.515 393.424 32.2521 389.635 29.1772C385.846 25.9626 380.934 24.3552 374.9 24.3552C367.322 24.3552 361.498 26.2421 357.428 30.0158C353.595 33.4387 351.443 38.1016 350.971 44.0046C350.922 44.622 350.412 45.1106 349.79 45.1106H334.014C333.353 45.1106 332.823 44.5605 332.872 43.9032C333.312 38.0771 335.042 32.6792 338.062 27.7096C341.29 22.3985 345.921 18.1356 351.955 14.921C358.13 11.7063 365.287 10.099 373.426 10.099C381.706 10.099 388.793 11.6364 394.687 14.7113C400.721 17.7862 405.282 22.1888 408.369 27.9193C411.456 33.6497 413 40.4284 413 48.2554V113.348C413 113.987 412.48 114.505 411.838 114.505H396.901C396.259 114.505 395.739 113.987 395.739 113.348V109.473C395.739 108.076 395.739 106.678 395.739 105.28C395.739 103.743 395.739 102.275 395.739 100.878C393.213 106.189 389.214 110.312 383.741 113.247C378.408 116.182 372.023 117.65 364.585 117.65ZM367.111 103.603C372.444 103.603 377.215 102.485 381.425 100.249C385.775 97.8727 389.143 94.6581 391.529 90.6048C394.055 86.4118 395.318 81.6597 395.318 76.3486V61.4634C394.757 62.302 393.564 63.0707 391.74 63.7695C389.915 64.4684 387.389 65.1672 384.162 65.866L369.427 68.5915C362.971 69.8494 357.849 71.9459 354.06 74.881C350.271 77.8161 348.377 81.7995 348.377 86.8311C348.377 91.8627 350.131 95.916 353.639 98.9908C357.288 102.066 361.779 103.603 367.111 103.603Z" fill="black"/>
|
||||
<path d="M285.673 62.9573L325.127 112.948C325.726 113.707 325.183 114.82 324.213 114.82H305.929C305.572 114.82 305.235 114.657 305.015 114.378L275.053 76.414L245.093 114.378C244.872 114.657 244.536 114.82 244.179 114.82H225.893C224.924 114.82 224.381 113.707 224.98 112.948L264.433 62.9573L227.177 15.7512C226.578 14.9921 227.121 13.8793 228.091 13.8793H246.375C246.732 13.8793 247.068 14.0423 247.289 14.3214L275.053 49.5006L302.818 14.3214C303.038 14.0423 303.375 13.8793 303.731 13.8793H322.016C322.985 13.8793 323.528 14.9921 322.929 15.7512L285.673 62.9573Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 34 718 165" width="718" height="165" fill="#fff">
|
||||
<path d="M123.292 132.486h-33.1c-2.644 0-4.808-2.395-4.808-5.363v-21.436c0-2.95 2.148-5.363 4.809-5.363h33.099c2.645 0 4.808-2.395 4.808-5.363V73.525c0-2.95 2.147-5.363 4.808-5.363h33.099c2.646 0 4.809-2.395 4.809-5.363V41.363c0-2.95-2.148-5.363-4.809-5.363H90.208c-2.645 0-4.808 2.395-4.808 5.363V62.8c0 2.95-2.147 5.363-4.808 5.363h-33.1c-2.645 0-4.808 2.395-4.808 5.363v21.436c0 2.95-2.147 5.363-4.808 5.363H4.808c-2.645 0-4.808 2.395-4.808 5.363v21.436c0 2.95 2.147 5.363 4.808 5.363h33.1c2.645 0 4.808 2.395 4.808 5.363v21.436c0 2.95 2.147 5.363 4.808 5.363h33.099c2.645 0 4.808 2.395 4.808 5.363v21.436c0 2.95 2.148 5.363 4.809 5.363h75.798c2.646 0 4.809-2.395 4.809-5.363v-21.436c0-2.95-2.148-5.363-4.809-5.363H132.94c-2.646 0-4.809-2.395-4.809-5.363v-21.436c0-2.95-2.147-5.363-4.808-5.363z"/>
|
||||
<path d="M251.408 100.341c2.646 0 4.809 2.395 4.809 5.363v21.436c0 2.95-2.148 5.363-4.809 5.363H218.31c-2.646 0-4.809 2.395-4.809 5.363v21.436c0 2.95-2.147 5.363-4.808 5.363h-33.099c-2.645 0-4.808-2.395-4.808-5.363v-53.58c0-2.951 2.147-5.364 4.808-5.364h75.799zM510.68 40.105h-28.795v153.33h28.795zM717.43 40.105h-28.795v153.33h28.795z"/>
|
||||
<path d="M532.41 40.105h-28.83l35.391 153.33H567.8zM653.242 40.105h-28.795v153.33h28.795z"/>
|
||||
<path d="M596.594 193.435h-28.795l35.39-153.33h28.795zM452.028 114.062v27.042s-.139.174-.191.261h-83.069V114.08h83.295-.035z"/>
|
||||
<path d="M452.028 141.347v35.651c-17.479 12.306-39.834 19.769-62.953 19.769-59.673 0-97.858-35.981-97.858-80.362s34.54-80.362 97.858-80.362c22.511 0 45.127 7.689 62.953 20.36v35.685c-13.105-16.42-37.856-28.743-62.953-28.743-44.052 0-70.695 20.846-70.695 53.077 0 32.232 28.691 53.077 70.695 53.077 25.323 0 49.727-11.906 62.744-28.1h.191v-.035z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 34 718 165" width="718" height="165" fill="#0D0E10">
|
||||
<path d="M123.292 132.486h-33.1c-2.644 0-4.808-2.395-4.808-5.363v-21.436c0-2.95 2.148-5.363 4.809-5.363h33.099c2.645 0 4.808-2.395 4.808-5.363V73.525c0-2.95 2.147-5.363 4.808-5.363h33.099c2.646 0 4.809-2.395 4.809-5.363V41.363c0-2.95-2.148-5.363-4.809-5.363H90.208c-2.645 0-4.808 2.395-4.808 5.363V62.8c0 2.95-2.147 5.363-4.808 5.363h-33.1c-2.645 0-4.808 2.395-4.808 5.363v21.436c0 2.95-2.147 5.363-4.808 5.363H4.808c-2.645 0-4.808 2.395-4.808 5.363v21.436c0 2.95 2.147 5.363 4.808 5.363h33.1c2.645 0 4.808 2.395 4.808 5.363v21.436c0 2.95 2.147 5.363 4.808 5.363h33.099c2.645 0 4.808 2.395 4.808 5.363v21.436c0 2.95 2.148 5.363 4.809 5.363h75.798c2.646 0 4.809-2.395 4.809-5.363v-21.436c0-2.95-2.148-5.363-4.809-5.363H132.94c-2.646 0-4.809-2.395-4.809-5.363v-21.436c0-2.95-2.147-5.363-4.808-5.363z"/>
|
||||
<path d="M251.408 100.341c2.646 0 4.809 2.395 4.809 5.363v21.436c0 2.95-2.148 5.363-4.809 5.363H218.31c-2.646 0-4.809 2.395-4.809 5.363v21.436c0 2.95-2.147 5.363-4.808 5.363h-33.099c-2.645 0-4.808-2.395-4.808-5.363v-53.58c0-2.951 2.147-5.364 4.808-5.364h75.799zM510.68 40.105h-28.795v153.33h28.795zM717.43 40.105h-28.795v153.33h28.795z"/>
|
||||
<path d="M532.41 40.105h-28.83l35.391 153.33H567.8zM653.242 40.105h-28.795v153.33h28.795z"/>
|
||||
<path d="M596.594 193.435h-28.795l35.39-153.33h28.795zM452.028 114.062v27.042s-.139.174-.191.261h-83.069V114.08h83.295-.035z"/>
|
||||
<path d="M452.028 141.347v35.651c-17.479 12.306-39.834 19.769-62.953 19.769-59.673 0-97.858-35.981-97.858-80.362s34.54-80.362 97.858-80.362c22.511 0 45.127 7.689 62.953 20.36v35.685c-13.105-16.42-37.856-28.743-62.953-28.743-44.052 0-70.695 20.846-70.695 53.077 0 32.232 28.691 53.077 70.695 53.077 25.323 0 49.727-11.906 62.744-28.1h.191v-.035z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg width="151" height="30" viewBox="0 0 151 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_novita_dark)">
|
||||
<path d="M17.2639 0.640625V12.1297L0.03125 29.363H17.2639V17.8739H17.7376L28.7529 29.363H45.9875L17.2639 0.640625Z" fill="white"/>
|
||||
</g>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M126.329 25.5459H131.778V28.9895H127.454C124.427 28.9895 122.523 27.3247 122.523 24.2256V15.0698H119.438V11.5974H122.523L123.417 7.03393H126.3V11.5974H131.778V15.0698H126.329V25.5459ZM61.9158 6.0293L69.8449 23.7083H70.0753V6.0293H74.1983V28.9895H67.97L60.0409 11.3105H59.8105V28.9895H55.6875V6.0293H61.9158ZM84.78 29.334C79.734 29.334 76.5052 25.804 76.5052 20.3223C76.5052 14.8406 79.6764 11.2529 84.78 11.2529C89.8836 11.2529 92.9971 14.6966 92.9971 20.3223C92.9971 25.948 89.826 29.334 84.78 29.334ZM84.78 25.8904C87.0293 25.8904 88.903 25.115 88.9606 22.9053V17.7393C88.9606 15.4719 87.0581 14.6966 84.78 14.6966C82.5019 14.6966 80.5418 15.4719 80.5418 17.7393V22.9053C80.5994 25.1438 82.5307 25.8904 84.78 25.8904ZM97.4094 11.5686L101.907 25.3443H102.137L106.635 11.5686H110.787L104.587 28.9607H99.4547L93.284 11.5686H97.407H97.4094ZM112.922 11.5974H116.728V28.9895H112.922V11.5974ZM112.69 6.0293H116.957V9.30127H112.69V6.0293ZM141.12 29.3628C136.477 29.3628 133.508 25.8328 133.508 20.3223C133.508 14.8118 136.42 11.2817 141.12 11.2817C143.312 11.2817 145.099 12.1423 146.166 13.7495H146.397L147.147 11.5974H150.03V28.9895H147.147L146.397 26.8938H146.166C145.013 28.4146 143.081 29.3616 141.12 29.3616V29.3628ZM141.898 25.8904C144.176 25.8904 146.165 25.1726 146.223 22.9053V17.7393C146.223 15.4143 144.146 14.7542 141.812 14.7542C139.477 14.7542 137.545 15.4719 137.545 17.7393V22.9053C137.602 25.2014 139.592 25.8904 141.898 25.8904Z" fill="white"/>
|
||||
<defs>
|
||||
<clipPath id="clip0_novita_dark">
|
||||
<rect width="45.9562" height="28.7223" fill="white" transform="translate(0.0292969 0.640625)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg width="151" height="30" viewBox="0 0 151 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_125_11573)">
|
||||
<path d="M17.2639 0.640625V12.1297L0.03125 29.363H17.2639V17.8739H17.7376L28.7529 29.363H45.9875L17.2639 0.640625Z" fill="black"/>
|
||||
</g>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M126.329 25.5459H131.778V28.9895H127.454C124.427 28.9895 122.523 27.3247 122.523 24.2256V15.0698H119.438V11.5974H122.523L123.417 7.03393H126.3V11.5974H131.778V15.0698H126.329V25.5459ZM61.9158 6.0293L69.8449 23.7083H70.0753V6.0293H74.1983V28.9895H67.97L60.0409 11.3105H59.8105V28.9895H55.6875V6.0293H61.9158ZM84.78 29.334C79.734 29.334 76.5052 25.804 76.5052 20.3223C76.5052 14.8406 79.6764 11.2529 84.78 11.2529C89.8836 11.2529 92.9971 14.6966 92.9971 20.3223C92.9971 25.948 89.826 29.334 84.78 29.334ZM84.78 25.8904C87.0293 25.8904 88.903 25.115 88.9606 22.9053V17.7393C88.9606 15.4719 87.0581 14.6966 84.78 14.6966C82.5019 14.6966 80.5418 15.4719 80.5418 17.7393V22.9053C80.5994 25.1438 82.5307 25.8904 84.78 25.8904ZM97.4094 11.5686L101.907 25.3443H102.137L106.635 11.5686H110.787L104.587 28.9607H99.4547L93.284 11.5686H97.407H97.4094ZM112.922 11.5974H116.728V28.9895H112.922V11.5974ZM112.69 6.0293H116.957V9.30127H112.69V6.0293ZM141.12 29.3628C136.477 29.3628 133.508 25.8328 133.508 20.3223C133.508 14.8118 136.42 11.2817 141.12 11.2817C143.312 11.2817 145.099 12.1423 146.166 13.7495H146.397L147.147 11.5974H150.03V28.9895H147.147L146.397 26.8938H146.166C145.013 28.4146 143.081 29.3616 141.12 29.3616V29.3628ZM141.898 25.8904C144.176 25.8904 146.165 25.1726 146.223 22.9053V17.7393C146.223 15.4143 144.146 14.7542 141.812 14.7542C139.477 14.7542 137.545 15.4719 137.545 17.7393V22.9053C137.602 25.2014 139.592 25.8904 141.898 25.8904Z" fill="black"/>
|
||||
<defs>
|
||||
<clipPath id="clip0_125_11573">
|
||||
<rect width="45.9562" height="28.7223" fill="white" transform="translate(0.0292969 0.640625)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -1,6 +1,6 @@
|
||||
module github.com/Gitlawb/zero
|
||||
|
||||
go 1.26.6
|
||||
go 1.26.9
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.1.1
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodelcatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
@@ -106,6 +107,14 @@ type acpSession struct {
|
||||
restrictModels bool
|
||||
cancel context.CancelFunc
|
||||
history []turnRecord
|
||||
|
||||
// readOnly marks a session published by session/load for a kind that is
|
||||
// not resumable (a child, side or spec sub-run). Load still replays its
|
||||
// transcript, which is the render-only use a client needs, but a prompt
|
||||
// would continue the sub-run as a standalone conversation, which every
|
||||
// other resume path refuses (sessions.IsResumableKind). Set once when the
|
||||
// session is first registered and never changed, so it is read without mu.
|
||||
readOnly bool
|
||||
}
|
||||
|
||||
// NewAgent builds the ACP server and registers its method handlers on conn.
|
||||
@@ -204,7 +213,7 @@ func (a *Agent) handleSessionNew(ctx context.Context, params json.RawMessage) (a
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInternalError, "create session: "+err.Error())
|
||||
}
|
||||
sess, _ := a.registerSession(meta.SessionID, root, nil, model, models, restrictModels)
|
||||
sess, _ := a.registerSession(meta.SessionID, root, nil, model, models, restrictModels, false)
|
||||
return NewSessionResult{
|
||||
SessionID: sess.id,
|
||||
ConfigOptions: a.configOptions(sess),
|
||||
@@ -301,6 +310,12 @@ func (a *Agent) activatePersistedSession(ctx context.Context, p LoadSessionParam
|
||||
if operation == persistedSessionResume && historyErr != nil {
|
||||
return nil, RPCError(codeInternalError, "restore session history: "+historyErr.Error())
|
||||
}
|
||||
// Load stays best effort about a history it cannot read, but not about one
|
||||
// prune holds: publishing it would leave the client using a session prune may
|
||||
// be removing, one this process could not hold open.
|
||||
if errors.Is(historyErr, sessions.ErrPruning) {
|
||||
return nil, RPCError(codeInternalError, historyErr.Error())
|
||||
}
|
||||
model, models, restrictModels, err := a.resolveModelChoices(ctx, root)
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInternalError, "config: "+err.Error())
|
||||
@@ -311,7 +326,10 @@ func (a *Agent) activatePersistedSession(ctx context.Context, p LoadSessionParam
|
||||
models = append(models, SessionConfigOptionValue{Value: persistedModel, Name: persistedModel})
|
||||
}
|
||||
}
|
||||
sess, existed := a.registerSession(meta.SessionID, root, history, model, models, restrictModels)
|
||||
// Resume has already refused a non-resumable kind above, so only load can
|
||||
// reach here with one, and it publishes that session for viewing only.
|
||||
readOnly := !sessions.IsResumableKind(meta.SessionKind)
|
||||
sess, existed := a.registerSession(meta.SessionID, root, history, model, models, restrictModels, readOnly)
|
||||
if existed {
|
||||
_, messages, historyWarning, historyErr = a.refreshSessionHistory(sess, requireHistoryLog)
|
||||
if operation == persistedSessionResume && historyErr != nil {
|
||||
@@ -433,6 +451,9 @@ func (a *Agent) handleSessionPrompt(ctx context.Context, params json.RawMessage)
|
||||
if sess == nil {
|
||||
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
|
||||
}
|
||||
if sess.readOnly {
|
||||
return nil, RPCError(codeInvalidParams, "session is not resumable, so it was loaded for viewing only and cannot be prompted: "+p.SessionID)
|
||||
}
|
||||
|
||||
// Serialize turns for this session so two prompts can't interleave history or
|
||||
// fight over the single cancel slot. session/cancel still works concurrently
|
||||
@@ -508,6 +529,30 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
|
||||
}
|
||||
queue(messageEvent("user", userText))
|
||||
|
||||
visionCache := make(map[string]bool)
|
||||
var visionCacheMu sync.Mutex
|
||||
supportsVision := func(modelID string) bool {
|
||||
modelID = strings.TrimSpace(modelID)
|
||||
if modelID == "" {
|
||||
return false
|
||||
}
|
||||
visionCacheMu.Lock()
|
||||
defer visionCacheMu.Unlock()
|
||||
if cached, ok := visionCache[modelID]; ok {
|
||||
return cached
|
||||
}
|
||||
supported := a.modelSupportsVision(ctx, resolved.Provider, modelID)
|
||||
visionCache[modelID] = supported
|
||||
return supported
|
||||
}
|
||||
effectivePrompt := userText
|
||||
if len(images) > 0 && !supportsVision(resolved.Provider.Model) {
|
||||
msg := fmt.Sprintf("Model %s does not support image input; ignoring %d prompt image(s).", resolved.Provider.Model, len(images))
|
||||
note.text("[zero] " + msg + "\n\n")
|
||||
effectivePrompt = fmt.Sprintf("[Note: %s]\n\n%s", msg, userText)
|
||||
images = nil
|
||||
}
|
||||
|
||||
opts := agent.Options{
|
||||
Cwd: sess.cwd,
|
||||
SessionID: sess.id,
|
||||
@@ -519,6 +564,7 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
|
||||
DeferThreshold: workspace.DeferThreshold,
|
||||
MaxTurns: resolved.MaxTurns,
|
||||
Images: images,
|
||||
SupportsVision: supportsVision,
|
||||
OnText: note.text,
|
||||
OnReasoning: note.thought,
|
||||
OnToolCall: func(call agent.ToolCall) {
|
||||
@@ -537,7 +583,7 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
|
||||
},
|
||||
}
|
||||
|
||||
agentPrompt := buildPrompt(sess.snapshotHistory(), userText)
|
||||
agentPrompt := buildPrompt(sess.snapshotHistory(), effectivePrompt)
|
||||
result, runErr := a.deps.RunAgent(ctx, agentPrompt, provider, opts)
|
||||
if result.FinalAnswer != "" {
|
||||
queue(messageEvent("assistant", result.FinalAnswer))
|
||||
@@ -908,9 +954,20 @@ func (a *Agent) loadHistory(sessionID string, requireHistoryLog bool) ([]turnRec
|
||||
// enough: rehydration substitutes the compaction event in place of the events
|
||||
// it replaced, so a loop that skips everything but EventMessage would drop the
|
||||
// summary exactly as before. It is projected below. Reported by @jatmn.
|
||||
//
|
||||
// The session was picked from its metadata a moment ago. Make sure it is
|
||||
// still there, and held, before restoring it: see sessions.HoldToContinue.
|
||||
if err := a.deps.Store.HoldToContinue(sessionID); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
events, eventLogPresent, err := a.deps.Store.ReadRehydratedEventsWithPresence(sessionID)
|
||||
var rehydrateWarning error
|
||||
if err != nil {
|
||||
if errors.Is(err, sessions.ErrPruning) {
|
||||
// Not a rehydration failure: the raw read would continue the session
|
||||
// without holding it while zero sessions prune may be removing it.
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
rehydrateWarning = err
|
||||
events, eventLogPresent, err = a.deps.Store.ReadEventsWithPresence(sessionID)
|
||||
if err != nil {
|
||||
@@ -1154,13 +1211,13 @@ func promptImages(blocks []ContentBlock) []zeroruntime.ImageBlock {
|
||||
// The bool reports that case so persisted activation can re-read and apply disk
|
||||
// history under turnMu. history is set BEFORE first publication so no concurrent
|
||||
// prompt can read a half-initialized session.
|
||||
func (a *Agent) registerSession(id, cwd string, history []turnRecord, model string, models []SessionConfigOptionValue, restrictModels bool) (*acpSession, bool) {
|
||||
func (a *Agent) registerSession(id, cwd string, history []turnRecord, model string, models []SessionConfigOptionValue, restrictModels bool, readOnly bool) (*acpSession, bool) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if existing := a.sessions[id]; existing != nil {
|
||||
return existing, true
|
||||
}
|
||||
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, model: model, models: models, restrictModels: restrictModels, history: history}
|
||||
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, model: model, models: models, restrictModels: restrictModels, history: history, readOnly: readOnly}
|
||||
a.sessions[id] = sess
|
||||
return sess, false
|
||||
}
|
||||
@@ -1305,3 +1362,32 @@ func sameWorkspace(left, right string) bool {
|
||||
}
|
||||
return os.SameFile(leftInfo, rightInfo)
|
||||
}
|
||||
|
||||
func (a *Agent) modelSupportsVision(ctx context.Context, profile config.ProviderProfile, modelID string) bool {
|
||||
trimmed := strings.TrimSpace(modelID)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
if a.deps.DiscoverModels != nil {
|
||||
if discovered, err := a.deps.DiscoverModels(ctx, profile); err == nil {
|
||||
for _, dm := range discovered {
|
||||
if strings.EqualFold(strings.TrimSpace(dm.ID), trimmed) {
|
||||
if len(dm.InputModalities) > 0 {
|
||||
for _, mod := range dm.InputModalities {
|
||||
if strings.EqualFold(strings.TrimSpace(mod), "image") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
reg, _ := modelregistry.DefaultRegistry()
|
||||
if entry, known := reg.Resolve(trimmed); known {
|
||||
return entry.Supports(modelregistry.ModelCapabilityVision)
|
||||
}
|
||||
return modelregistry.SupportsVision(reg, trimmed)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -847,6 +849,48 @@ func TestACPRunTurnClosesWorkspaceAfterSuccessAndFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPWiresSupportsVision(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
var captured agent.Options
|
||||
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
|
||||
captured = opts
|
||||
return agent.Result{FinalAnswer: "done"}, nil
|
||||
}
|
||||
deps.DiscoverModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return []providermodeldiscovery.Model{
|
||||
{ID: "custom-vision", InputModalities: []string{"text", "image"}},
|
||||
{ID: "custom-text", InputModalities: []string{"text"}},
|
||||
}, nil
|
||||
}
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var newRes NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
var promptRes PromptResult
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: newRes.SessionID,
|
||||
Prompt: []ContentBlock{
|
||||
{Type: "text", Text: "hello"},
|
||||
},
|
||||
}, &promptRes); err != nil {
|
||||
t.Fatalf("session/prompt: %v", err)
|
||||
}
|
||||
if captured.SupportsVision == nil {
|
||||
t.Fatal("SupportsVision was not wired into agent.Options")
|
||||
}
|
||||
if !captured.SupportsVision("custom-vision") {
|
||||
t.Fatal("SupportsVision(custom-vision) = false, want true")
|
||||
}
|
||||
if captured.SupportsVision("custom-text") {
|
||||
t.Fatal("SupportsVision(custom-text) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestACPRejectsInvalidCwd confirms session/new fails when the workspace root
|
||||
// resolver rejects the client cwd (e.g. filesystem root).
|
||||
func TestACPRejectsInvalidCwd(t *testing.T) {
|
||||
@@ -1251,6 +1295,85 @@ func TestACPResumeAppliesStandaloneSessionKindPolicy(t *testing.T) {
|
||||
}, &LoadSessionResult{}); err != nil {
|
||||
t.Fatalf("session/load should retain render-only access: %v", err)
|
||||
}
|
||||
// The check above only showed the refused resume never registered the
|
||||
// session. Load does register it, so the prompt that matters is the one
|
||||
// AFTER the load: render-only means the transcript can be read and the
|
||||
// sub-run cannot be continued.
|
||||
err = h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("must stay closed after load")},
|
||||
}, &PromptResult{})
|
||||
if !errors.As(err, &rpcErr) || rpcErr.Code != codeInvalidParams {
|
||||
t.Fatalf("session/prompt after load of a %s session = %v, want invalid params", tc.name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// LOAD PUBLISHES A SUB-RUN FOR VIEWING, NOT FOR CONTINUING. Every other resume
|
||||
// path (the TUI, exec sessions, the exec CLI, and ACP's own session/resume)
|
||||
// refuses a kind sessions.IsResumableKind rejects. session/load has a real
|
||||
// render-only use, a client rebuilding a transcript it can scroll, so it keeps
|
||||
// loading those sessions; what it must not do is publish one that accepts a
|
||||
// prompt, because the prompt would carry on a child, side or spec sub-run as a
|
||||
// standalone conversation. Checked in both directions, so a gate that refused
|
||||
// everything would fail on the resumable kinds.
|
||||
func TestACPLoadPublishesSubRunsReadOnly(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
kind sessions.SessionKind
|
||||
readOnly bool
|
||||
}{
|
||||
{name: "regular", kind: ""},
|
||||
{name: "fork", kind: sessions.SessionKindFork},
|
||||
{name: "child", kind: sessions.SessionKindChild, readOnly: true},
|
||||
{name: "side", kind: sessions.SessionKindSide, readOnly: true},
|
||||
{name: "spec draft", kind: sessions.SessionKindSpecDraft, readOnly: true},
|
||||
{name: "spec impl", kind: sessions.SessionKindSpecImpl, readOnly: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := !sessions.IsResumableKind(tc.kind); got != tc.readOnly {
|
||||
t.Fatalf("SETUP INVALID: IsResumableKind(%q) disagrees with this table (readOnly=%v), so the cases no longer mean what they say", tc.kind, tc.readOnly)
|
||||
}
|
||||
deps := testDeps(t)
|
||||
workspace := t.TempDir()
|
||||
created, err := deps.Store.Create(sessions.CreateInput{
|
||||
SessionID: "load-" + strings.ReplaceAll(tc.name, " ", "-"), Cwd: workspace, SessionKind: tc.kind,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{
|
||||
SessionID: created.SessionID, Cwd: workspace,
|
||||
}, &LoadSessionResult{}); err != nil {
|
||||
t.Fatalf("session/load of a %s session: %v", tc.name, err)
|
||||
}
|
||||
err = h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("continue")},
|
||||
}, &PromptResult{})
|
||||
// A resumable session has to be genuinely promptable, not merely "not
|
||||
// refused as view-only": any other failure would hide a broken
|
||||
// writable path behind a passing test.
|
||||
if !tc.readOnly {
|
||||
if err != nil {
|
||||
t.Fatalf("session/prompt after load of a %s session = %v, want success", tc.name, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
// The decision is the RPC code; the message only distinguishes the
|
||||
// view-only refusal from other invalid-params errors such as an
|
||||
// unknown session id.
|
||||
var rpcErr *rpcError
|
||||
if !errors.As(err, &rpcErr) || rpcErr.Code != codeInvalidParams {
|
||||
t.Fatalf("session/prompt after load of a %s session = %v, want invalid params", tc.name, err)
|
||||
}
|
||||
if !strings.Contains(rpcErr.Message, "loaded for viewing only") {
|
||||
t.Fatalf("session/prompt after load of a %s session was refused for another reason: %v", tc.name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2418,3 +2541,265 @@ func payloadString(payload any, key string) string {
|
||||
value, _ := decoded[key].(string)
|
||||
return value
|
||||
}
|
||||
|
||||
// Load is best effort about a history it cannot read, but a session that zero
|
||||
// sessions prune holds is refused by load and resume alike, and not published:
|
||||
// this process could not hold it open while prune may be removing it.
|
||||
func TestACPLoadAndResumeAreRefusedWhilePruneHoldsTheSession(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
cwd := t.TempDir()
|
||||
meta, err := deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: cwd})
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
// Created by an earlier process, which has since exited.
|
||||
deps.Store.Release(meta.SessionID)
|
||||
|
||||
release, locked, err := sessions.NewStore(sessions.StoreOptions{RootDir: deps.Store.RootDir}).HoldExclusive(meta.SessionID)
|
||||
if err != nil || !locked {
|
||||
t.Fatalf("take the lease the way prune does: locked=%v, %v", locked, err)
|
||||
}
|
||||
defer release()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: meta.SessionID, Cwd: cwd, McpServers: []McpServer{}}, &LoadSessionResult{}); err == nil || !strings.Contains(err.Error(), "locked by zero sessions prune") {
|
||||
t.Errorf("session/load while prune holds the session: err = %v, want it refused", err)
|
||||
}
|
||||
if err := h.client.Call(ctx, MethodSessionResume, ResumeSessionParams{SessionID: meta.SessionID, Cwd: cwd, McpServers: []McpServer{}}, &ResumeSessionResult{}); err == nil || !strings.Contains(err.Error(), "locked by zero sessions prune") {
|
||||
t.Errorf("session/resume while prune holds the session: err = %v, want it refused", err)
|
||||
}
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: meta.SessionID,
|
||||
Prompt: []ContentBlock{TextBlock("carry on")},
|
||||
}, &PromptResult{}); err == nil {
|
||||
t.Fatal("a session refused while prune held it was still promptable")
|
||||
}
|
||||
}
|
||||
|
||||
// Activation reads the session's metadata before it restores the history. A
|
||||
// session prune removes in between is refused as removed, which activation then
|
||||
// refuses to publish for load as well as resume, instead of restoring it as an
|
||||
// empty conversation.
|
||||
func TestACPLoadHistoryRefusesASessionRemovedAfterItWasPicked(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
meta, err := deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: t.TempDir()})
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
deps.Store.Release(meta.SessionID)
|
||||
dir := filepath.Join(deps.Store.RootDir, meta.SessionID)
|
||||
if err := os.Remove(filepath.Join(dir, sessions.MetadataFile)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a := &Agent{deps: deps}
|
||||
if _, _, _, err := a.loadHistory(meta.SessionID, false); !errors.Is(err, sessions.ErrPruning) {
|
||||
t.Errorf("load history of a session prune is removing: err = %v, want it refused", err)
|
||||
}
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := a.loadHistory(meta.SessionID, false); !errors.Is(err, sessions.ErrPruning) {
|
||||
t.Errorf("load history of a session prune removed: err = %v, want it refused", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPPromptUnsupportedModelDropsImagesAndNotifies(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
deps.DiscoverModels = func(ctx context.Context, p config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return []providermodeldiscovery.Model{
|
||||
{ID: "fake-model", InputModalities: []string{"text"}},
|
||||
}, nil
|
||||
}
|
||||
var capturedOpts agent.Options
|
||||
var capturedPrompt string
|
||||
deps.RunAgent = func(ctx context.Context, prompt string, provider zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
|
||||
capturedPrompt = prompt
|
||||
capturedOpts = opts
|
||||
return agent.Result{FinalAnswer: "Answered without images"}, nil
|
||||
}
|
||||
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var initRes InitializeResult
|
||||
if err := h.client.Call(ctx, MethodInitialize, InitializeParams{ProtocolVersion: ProtocolVersion}, &initRes); err != nil {
|
||||
t.Fatalf("initialize: %v", err)
|
||||
}
|
||||
|
||||
var newRes NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
|
||||
rawPng := "\x89PNG\r\n\x1a\nfake-image-bytes"
|
||||
b64Png := base64.StdEncoding.EncodeToString([]byte(rawPng))
|
||||
var promptRes PromptResult
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: newRes.SessionID,
|
||||
Prompt: []ContentBlock{
|
||||
TextBlock("look at this image"),
|
||||
ImageBlock(b64Png, "image/png"),
|
||||
},
|
||||
}, &promptRes); err != nil {
|
||||
t.Fatalf("session/prompt: %v", err)
|
||||
}
|
||||
if promptRes.StopReason != StopEndTurn {
|
||||
t.Fatalf("stopReason = %q, want %q", promptRes.StopReason, StopEndTurn)
|
||||
}
|
||||
|
||||
got := drainTextUntil(t, h.updates, func(text string) bool {
|
||||
return strings.Contains(text, "does not support image input")
|
||||
})
|
||||
if !strings.Contains(got, "Model fake-model does not support image input; ignoring 1 prompt image(s).") {
|
||||
t.Fatalf("streamed text = %q, want drop notice", got)
|
||||
}
|
||||
if len(capturedOpts.Images) != 0 {
|
||||
t.Fatalf("captured %d images, want 0 (images should be withheld)", len(capturedOpts.Images))
|
||||
}
|
||||
if !strings.Contains(capturedPrompt, "[Note: Model fake-model does not support image input; ignoring 1 prompt image(s).]") {
|
||||
t.Fatalf("captured prompt = %q, want prompt note", capturedPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPSessionPrompt_SupportsVisionMemoizedPerRun(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
var discoverCount int
|
||||
var discoverMu sync.Mutex
|
||||
deps.DiscoverModels = func(ctx context.Context, p config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
discoverMu.Lock()
|
||||
discoverCount++
|
||||
discoverMu.Unlock()
|
||||
return []providermodeldiscovery.Model{
|
||||
{ID: "fake-model", InputModalities: []string{"text", "image"}},
|
||||
}, nil
|
||||
}
|
||||
deps.RunAgent = func(ctx context.Context, prompt string, provider zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
|
||||
for i := 0; i < 5; i++ {
|
||||
if !opts.SupportsVision(opts.Model) {
|
||||
t.Error("expected SupportsVision to report true")
|
||||
}
|
||||
}
|
||||
return agent.Result{FinalAnswer: "Done"}, nil
|
||||
}
|
||||
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var initRes InitializeResult
|
||||
if err := h.client.Call(ctx, MethodInitialize, InitializeParams{ProtocolVersion: ProtocolVersion}, &initRes); err != nil {
|
||||
t.Fatalf("initialize: %v", err)
|
||||
}
|
||||
|
||||
var newRes NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
|
||||
discoverMu.Lock()
|
||||
discoverCount = 0
|
||||
discoverMu.Unlock()
|
||||
|
||||
rawPng := "\x89PNG\r\n\x1a\nfake-image-bytes"
|
||||
b64Png := base64.StdEncoding.EncodeToString([]byte(rawPng))
|
||||
var promptRes PromptResult
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: newRes.SessionID,
|
||||
Prompt: []ContentBlock{
|
||||
TextBlock("look at this"),
|
||||
ImageBlock(b64Png, "image/png"),
|
||||
},
|
||||
}, &promptRes); err != nil {
|
||||
t.Fatalf("session/prompt: %v", err)
|
||||
}
|
||||
if promptRes.StopReason != StopEndTurn {
|
||||
t.Fatalf("stopReason = %q, want %q", promptRes.StopReason, StopEndTurn)
|
||||
}
|
||||
|
||||
discoverMu.Lock()
|
||||
count := discoverCount
|
||||
discoverMu.Unlock()
|
||||
if count != 1 {
|
||||
t.Fatalf("DiscoverModels was called %d times, want exactly 1 call (memoized per run)", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPEndToEndImagePromptSupportedModel(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
deps.DiscoverModels = func(ctx context.Context, p config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return []providermodeldiscovery.Model{
|
||||
{ID: "discovered-vision-model", InputModalities: []string{"text", "image"}},
|
||||
}, nil
|
||||
}
|
||||
deps.ResolveConfig = func(_ string, o config.Overrides) (config.ResolvedConfig, error) {
|
||||
model := "discovered-vision-model"
|
||||
if o.Provider.Model != "" {
|
||||
model = o.Provider.Model
|
||||
}
|
||||
return config.ResolvedConfig{
|
||||
Provider: config.ProviderProfile{Name: "fake", Model: model},
|
||||
MaxTurns: 4,
|
||||
}, nil
|
||||
}
|
||||
var capturedOpts agent.Options
|
||||
var capturedPrompt string
|
||||
deps.RunAgent = func(ctx context.Context, prompt string, provider zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
|
||||
capturedPrompt = prompt
|
||||
capturedOpts = opts
|
||||
return agent.Result{FinalAnswer: "I can see the image!"}, nil
|
||||
}
|
||||
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var initRes InitializeResult
|
||||
if err := h.client.Call(ctx, MethodInitialize, InitializeParams{ProtocolVersion: ProtocolVersion}, &initRes); err != nil {
|
||||
t.Fatalf("initialize: %v", err)
|
||||
}
|
||||
|
||||
var newRes NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
|
||||
rawPng := []byte("\x89PNG\r\n\x1a\nreal-png-bytes")
|
||||
b64Png := base64.StdEncoding.EncodeToString(rawPng)
|
||||
var promptRes PromptResult
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: newRes.SessionID,
|
||||
Prompt: []ContentBlock{
|
||||
TextBlock("analyze this image"),
|
||||
ImageBlock(b64Png, "image/png"),
|
||||
},
|
||||
}, &promptRes); err != nil {
|
||||
t.Fatalf("session/prompt: %v", err)
|
||||
}
|
||||
if promptRes.StopReason != StopEndTurn {
|
||||
t.Fatalf("stopReason = %q, want %q", promptRes.StopReason, StopEndTurn)
|
||||
}
|
||||
|
||||
if len(capturedOpts.Images) != 1 {
|
||||
t.Fatalf("captured %d images, want 1", len(capturedOpts.Images))
|
||||
}
|
||||
if capturedOpts.Images[0].MediaType != "image/png" {
|
||||
t.Fatalf("mediaType = %q, want image/png", capturedOpts.Images[0].MediaType)
|
||||
}
|
||||
if !bytes.Equal(capturedOpts.Images[0].Data, rawPng) {
|
||||
t.Fatalf("image bytes mismatch: got %v, want %v", capturedOpts.Images[0].Data, rawPng)
|
||||
}
|
||||
if strings.Contains(capturedPrompt, "does not support image input") {
|
||||
t.Fatalf("prompt unexpectedly contains refusal note: %q", capturedPrompt)
|
||||
}
|
||||
}
|
||||
@@ -139,7 +139,7 @@ func browserToolTitle(command, rawArgs string) string {
|
||||
}
|
||||
|
||||
// browserTitleTextSafe validates text after URL parsing has decoded escaped
|
||||
// UTF-8 in the host. Valid UTF-8 alone is not presentation-safe: control,
|
||||
// UTF-8 in the host. Valid UTF-8 alone is not presentation-safe: whitespace, control,
|
||||
// format/bidi, and line/paragraph separator runes can reorder or split the
|
||||
// permission label shown to a user. The execution URL remains unchanged.
|
||||
func browserTitleTextSafe(text string) bool {
|
||||
@@ -147,7 +147,7 @@ func browserTitleTextSafe(text string) bool {
|
||||
return false
|
||||
}
|
||||
for _, r := range text {
|
||||
if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) {
|
||||
if unicode.IsSpace(r) || unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,6 +224,9 @@ func TestBrowserPermissionTitlesMirrorSafeToolArguments(t *testing.T) {
|
||||
|
||||
func TestBrowserOpenTitlesRejectDecodedUnicodePresentationControls(t *testing.T) {
|
||||
for _, rawURL := range []string{
|
||||
"https://safe.example%C2%A0evil.test/path",
|
||||
"https://safe.example%E2%80%83evil.test/path",
|
||||
"https://safe.example%E3%80%80evil.test/path",
|
||||
"https://safe.example%E2%80%AEevil.test/path",
|
||||
"https://safe.example%E2%81%A6evil.test/path",
|
||||
"https://safe.example%C2%85evil.test/path",
|
||||
@@ -243,6 +246,9 @@ func TestBrowserOpenTitlesRejectDecodedUnicodePresentationControls(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if title := browserToolTitle("open", string(args)); title != "browser open" {
|
||||
t.Errorf("unsafe origin accepted before permission translation: %q", title)
|
||||
}
|
||||
updates := []ToolCallUpdate{
|
||||
toolCallStart(agent.ToolCall{ID: "start", Name: "browser_open", Arguments: string(args)}),
|
||||
permissionToolCall(agent.PermissionRequest{ToolCallID: "permission", ToolName: "browser_open", Args: map[string]any{"url": rawURL}}),
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/streamjson"
|
||||
@@ -25,14 +26,18 @@ const maxTurnsAnswer = "Agent reached maximum number of turns without a final an
|
||||
const maxTurnsFinalAnswerPrompt = "You have reached the tool-turn limit. Do not call tools. Give a concise final answer now: summarize what you completed, what you found, and any remaining blockers."
|
||||
|
||||
// maxStreamStallRetries bounds how many times a turn that timed out (idle/stall)
|
||||
// WITH NO OUTPUT yet is re-issued on a fresh connection before giving up. Only
|
||||
// the no-output case is retried (a partial turn would duplicate), so this is a
|
||||
// safe recovery for a stalled/dead pooled connection.
|
||||
// OR hit a mid-stream transport abort (#973) with no answer text yet is re-issued
|
||||
// on a fresh connection before giving up. Incomplete tool-call previews are
|
||||
// allowed through the gate (they are never dispatched before the error return).
|
||||
// The safety claim is only that visible answer prose is not duplicated on retry —
|
||||
// not that every partial stream shape is excluded. Covers a stalled/dead pooled
|
||||
// connection or a socket abort before tool dispatch.
|
||||
//
|
||||
// Set to 1 (not 2): each attempt can itself idle for the full stream timeout
|
||||
// (~5min) before the stall is even detected, so 2 retries left an interactive
|
||||
// session frozen for ~15min. One retry keeps the common single-hiccup recovery
|
||||
// while bounding the worst case to ~2× the idle timeout.
|
||||
// while bounding the worst case to ~2× the idle timeout. Mid-stream transport
|
||||
// aborts share this bound (do not raise it for #973).
|
||||
const maxStreamStallRetries = 1
|
||||
|
||||
const (
|
||||
@@ -374,7 +379,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
rec.StampFirstToken()
|
||||
}
|
||||
if onText != nil {
|
||||
forwardedVisibleText = true
|
||||
// Zero-length chunks are not committed answer prose; counting
|
||||
// them would block an eligible mid-stream abort retry.
|
||||
if s != "" {
|
||||
forwardedVisibleText = true
|
||||
}
|
||||
onText(s)
|
||||
}
|
||||
}
|
||||
@@ -450,9 +459,14 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, ctx.Err()
|
||||
}
|
||||
// A stream idle/stall timeout is safely re-issued when the turn committed NO
|
||||
// answer text — no forwarded visible prose (forwardedVisibleText) and no
|
||||
// collected final text (collected.Text). This covers two cases:
|
||||
// A stream idle/stall timeout OR mid-stream transport abort (#973 — wsarecv /
|
||||
// connection reset / unexpected EOF / connection closed / broken pipe) is
|
||||
// safely re-issued when the turn committed NO answer text — no forwarded
|
||||
// visible prose (forwardedVisibleText) and no collected final text
|
||||
// (collected.Text). The abort gate matches midStreamAbortNeedles only, not
|
||||
// the full shouldReconnect list: connect-phase timeouts and connection
|
||||
// refused are NOT retried here (a slow healthy server must not cost a
|
||||
// second prefill). This covers three cases:
|
||||
// 1. Nothing streamed at all before the connection died (the original
|
||||
// macOS stale-pooled-connection hang past the response-header timeout).
|
||||
// 2. The model streamed transient reasoning and began a tool call (e.g. a
|
||||
@@ -463,13 +477,27 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// re-render is transient previews, not duplicated answer text. This is
|
||||
// why the gate no longer excludes collected.ToolCalls: an incomplete
|
||||
// tool call from a timed-out stream is discard-and-retry, not output.
|
||||
// 3. Mid-stream socket abort after connect succeeded (Windows WSAECONNABORTED /
|
||||
// wsarecv, connection reset by peer). Same no-tool-executed safety: the
|
||||
// error returns before tool dispatch, so a bounded retry is safe.
|
||||
// A turn that forwarded real prose is NOT retried (it would duplicate visible
|
||||
// answer text) and falls through to the error return below. Capped +
|
||||
// exponential backoff, with a user-visible notice per attempt.
|
||||
for attempt := 1; attempt <= maxStreamStallRetries &&
|
||||
isStreamTimeoutError(collected.Error) && !forwardedVisibleText &&
|
||||
(isStreamTimeoutError(collected.Error) || isMidStreamTransportAbort(collected.Error)) &&
|
||||
!forwardedVisibleText &&
|
||||
collected.Text == ""; attempt++ {
|
||||
if notify := stallRetryNoticeFor(options); notify != nil {
|
||||
var notify reconnectNotifier
|
||||
if isStreamTimeoutError(collected.Error) {
|
||||
notify = stallRetryNoticeFor(options)
|
||||
} else {
|
||||
notify = reconnectNoticeFor(options)
|
||||
// Count this post-connect reissue even when the replacement
|
||||
// connect succeeds immediately. streamWithReconnect only
|
||||
// increments reconnect_count after a failed connect attempt.
|
||||
options.Trace.Counter(trace.CounterReconnectCount, 1)
|
||||
}
|
||||
if notify != nil {
|
||||
notify(attempt, maxStreamStallRetries)
|
||||
}
|
||||
if err := sleepWithContext(ctx, backoffFor(attempt)); err != nil {
|
||||
@@ -482,12 +510,22 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
retryStream, retryErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
|
||||
if retryErr != nil {
|
||||
result.Messages = copyMessages(messages)
|
||||
if ctx.Err() != nil {
|
||||
return result, ctx.Err()
|
||||
}
|
||||
return result, retryErr
|
||||
}
|
||||
stallGenSpan := options.Trace.Span(trace.SpanGeneration)
|
||||
collected = zeroruntime.CollectStreamWithOptions(ctx, retryStream, forwardingOpts)
|
||||
stallGenSpan.End()
|
||||
}
|
||||
// Recheck ctx after a retried CollectStream: helpers.go stores
|
||||
// ctx.Err().Error() in collected.Error, and errors.New of that string
|
||||
// drops the context.Canceled sentinel ACP branches on.
|
||||
if ctx.Err() != nil {
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, ctx.Err()
|
||||
}
|
||||
if collected.Error != "" {
|
||||
// Route a reissued stream's non-stall error through the SAME recovery as
|
||||
// the initial stream (image-rejection wrapping / context-limit compaction)
|
||||
@@ -498,6 +536,10 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, stop
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, ctx.Err()
|
||||
}
|
||||
if collected.Error != "" {
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, errors.New(collected.Error)
|
||||
@@ -646,9 +688,25 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// recorded. They travel as user messages, and a user message between two
|
||||
// tool_results breaks strict provider replay — Anthropic coalesces them
|
||||
// into one user block list and requires the tool_result blocks first, so
|
||||
// interleaving yields [tool_result, text, image, tool_result] and a 400.
|
||||
// Same reason the self-correction feedback below is deferred.
|
||||
var toolImageMessages []zeroruntime.Message
|
||||
// Images ride a following USER message rather than the tool result
|
||||
// above. Every provider drops images on a tool-role message —
|
||||
// Anthropic's tool_result content is a string, Gemini's is a
|
||||
// functionResponse, and OpenAI guards its image parts to the user role
|
||||
// — so attaching them there would silently deliver nothing. A separate
|
||||
// message also keeps the one-tool-result-per-tool-call pairing intact,
|
||||
// which the providers validate. Vision-gate evaluation is deferred until
|
||||
// after any model switch this turn resolves, so images can reach an
|
||||
// escalated vision-capable model.
|
||||
var toolResultsWithImages []ToolResult
|
||||
buildToolImageMessages := func() []zeroruntime.Message {
|
||||
var out []zeroruntime.Message
|
||||
for _, tr := range toolResultsWithImages {
|
||||
if imageMessage, ok := toolResultImageMessage(tr, options); ok {
|
||||
out = append(out, imageMessage)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
// Parallel read-ahead state: results for calls[precomputedStart:precomputedEnd]
|
||||
// executed concurrently, consumed strictly in order below.
|
||||
var precomputed []precomputedToolResult
|
||||
@@ -705,15 +763,8 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
IsError: toolResult.Status == tools.StatusError,
|
||||
ChangedFiles: append([]string(nil), toolResult.ChangedFiles...),
|
||||
})
|
||||
// Images ride a following USER message rather than the tool result
|
||||
// above. Every provider drops images on a tool-role message —
|
||||
// Anthropic's tool_result content is a string, Gemini's is a
|
||||
// functionResponse, and OpenAI guards its image parts to the user role
|
||||
// — so attaching them there would silently deliver nothing. A separate
|
||||
// message also keeps the one-tool-result-per-tool-call pairing intact,
|
||||
// which the providers validate.
|
||||
if imageMessage, ok := toolResultImageMessage(toolResult); ok {
|
||||
toolImageMessages = append(toolImageMessages, imageMessage)
|
||||
if len(toolResult.Images) > 0 {
|
||||
toolResultsWithImages = append(toolResultsWithImages, toolResult)
|
||||
}
|
||||
|
||||
// A tool may demand the run ABORT — a canceled/timed-out ask_user prompt
|
||||
@@ -725,13 +776,13 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
}
|
||||
if abortErr != nil {
|
||||
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
|
||||
messages = append(messages, toolImageMessages...)
|
||||
messages = append(messages, buildToolImageMessages()...)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, abortErr
|
||||
}
|
||||
if stopReason := stopReasonFromToolResult(toolResult); stopReason != "" {
|
||||
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
|
||||
messages = append(messages, toolImageMessages...)
|
||||
messages = append(messages, buildToolImageMessages()...)
|
||||
result.FinalAnswer = toolResult.ModelOutput()
|
||||
result.StopReason = stopReason
|
||||
result.Messages = copyMessages(messages)
|
||||
@@ -755,7 +806,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// messages stay valid for a strict provider replay (Anthropic
|
||||
// rejects a tool_use with no answering tool_result).
|
||||
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
|
||||
messages = append(messages, toolImageMessages...)
|
||||
messages = append(messages, buildToolImageMessages()...)
|
||||
result.FinalAnswer = toolFailureStopAnswer(call.Name, outcome.Count)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, nil
|
||||
@@ -774,10 +825,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
postEditDiagnostics.enqueue(ctx, toolResult.ChangedFiles)
|
||||
}
|
||||
}
|
||||
// Every tool_result for this turn is now recorded, including aborted
|
||||
// placeholders, so the images can follow without splitting them.
|
||||
messages = append(messages, toolImageMessages...)
|
||||
toolImageMessages = nil
|
||||
|
||||
// Run post-edit self-correction once over the union of files this turn
|
||||
// changed, then append any feedback after every tool_result is recorded so
|
||||
@@ -865,6 +912,12 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
}
|
||||
}
|
||||
|
||||
// Every tool_result, self-correct notice, and model-switch update for this
|
||||
// turn is now in place; evaluate and append image messages against the
|
||||
// effective (potentially escalated) model.
|
||||
messages = append(messages, buildToolImageMessages()...)
|
||||
toolResultsWithImages = nil
|
||||
|
||||
// A turn can mix valid tool calls with a dropped (nameless) one. The valid
|
||||
// calls executed above; surface the dropped call too so it is never
|
||||
// silently ignored just because the turn also did real work. This is
|
||||
@@ -3464,8 +3517,10 @@ func copyMessages(messages []Message) []Message {
|
||||
//
|
||||
// The text names the tool so the model can tell which call an image came from
|
||||
// when several ran in one turn — the images arrive detached from their tool
|
||||
// result, so nothing else associates them.
|
||||
func toolResultImageMessage(result ToolResult) (zeroruntime.Message, bool) {
|
||||
// result, so nothing else associates them. If the effective model cannot
|
||||
// accept images, the tool's text result is left unchanged and this follow-up
|
||||
// is a notice with no image parts, matching the CLI/TUI vision gate.
|
||||
func toolResultImageMessage(result ToolResult, options Options) (zeroruntime.Message, bool) {
|
||||
images := make([]zeroruntime.ImageBlock, 0, len(result.Images))
|
||||
for _, image := range result.Images {
|
||||
if len(image.Data) == 0 {
|
||||
@@ -3486,9 +3541,26 @@ func toolResultImageMessage(result ToolResult) (zeroruntime.Message, bool) {
|
||||
if label == "" {
|
||||
label = "tool"
|
||||
}
|
||||
if !modelAcceptsToolImages(options) {
|
||||
return zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: "Image output from " + label + " was not sent because the current model does not support image input. Switch to a vision-capable model before retrying image delivery, including any images skipped by the tool's image budget.",
|
||||
}, true
|
||||
}
|
||||
return zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: "Image output from " + label + ":",
|
||||
Images: images,
|
||||
}, true
|
||||
}
|
||||
|
||||
func modelAcceptsToolImages(options Options) bool {
|
||||
if options.SupportsVision != nil {
|
||||
return options.SupportsVision(options.Model)
|
||||
}
|
||||
registry, err := modelregistry.DefaultRegistry()
|
||||
if err != nil {
|
||||
return modelregistry.VisionCapableByName(options.Model)
|
||||
}
|
||||
return modelregistry.SupportsVision(registry, options.Model)
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// midStreamAbortProvider connects successfully but emits a transport-abort
|
||||
// StreamEventError on the first abortBefore calls, then succeeds with "done".
|
||||
// hangOnCall, if > 0, makes that 1-based call block until ctx is done so a
|
||||
// cancel during the retried CollectStream can be reproduced.
|
||||
// connectFailFrom, if > 0, makes that 1-based call and later return a
|
||||
// reconnectable connect error so streamWithReconnect enters its backoff.
|
||||
type midStreamAbortProvider struct {
|
||||
calls int32
|
||||
abortBefore int32
|
||||
abortError string
|
||||
partialText string
|
||||
emptyTextEvent bool
|
||||
partialToolCall string
|
||||
hangOnCall int32
|
||||
connectFailFrom int32
|
||||
connectErr string
|
||||
started chan struct{}
|
||||
}
|
||||
|
||||
func (p *midStreamAbortProvider) StreamCompletion(ctx context.Context, _ zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
n := atomic.AddInt32(&p.calls, 1)
|
||||
if p.connectFailFrom > 0 && n >= p.connectFailFrom {
|
||||
if p.started != nil {
|
||||
select {
|
||||
case <-p.started:
|
||||
default:
|
||||
close(p.started)
|
||||
}
|
||||
}
|
||||
errMsg := p.connectErr
|
||||
if errMsg == "" {
|
||||
errMsg = "connection reset by peer"
|
||||
}
|
||||
return nil, errors.New(errMsg)
|
||||
}
|
||||
if p.hangOnCall > 0 && n == p.hangOnCall {
|
||||
if p.started != nil {
|
||||
close(p.started)
|
||||
}
|
||||
ch := make(chan zeroruntime.StreamEvent)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
close(ch)
|
||||
}()
|
||||
return ch, nil
|
||||
}
|
||||
ch := make(chan zeroruntime.StreamEvent, 5)
|
||||
if n <= p.abortBefore {
|
||||
if p.emptyTextEvent {
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: ""}
|
||||
}
|
||||
if p.partialText != "" {
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: p.partialText}
|
||||
}
|
||||
if p.partialToolCall != "" {
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "tc_1", ToolName: p.partialToolCall}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "tc_1", ArgumentsFragment: `{"path":"x.html","content":"<!doctype`}
|
||||
}
|
||||
errMsg := p.abortError
|
||||
if errMsg == "" {
|
||||
errMsg = "provider stream error: read: connection reset by peer"
|
||||
}
|
||||
ch <- zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventError,
|
||||
Error: errMsg,
|
||||
}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func TestRunRetriesMidStreamConnectionReset(t *testing.T) {
|
||||
p := &midStreamAbortProvider{abortBefore: 1}
|
||||
result, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
|
||||
if err != nil {
|
||||
t.Fatalf("a mid-stream connection reset should retry to success, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 retry), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRetriesMidStreamWindowsAbort(t *testing.T) {
|
||||
p := &midStreamAbortProvider{
|
||||
abortBefore: 1,
|
||||
abortError: "provider stream error: read: wsarecv: An established connection was aborted by the software in your host machine",
|
||||
}
|
||||
result, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
|
||||
if err != nil {
|
||||
t.Fatalf("a Windows mid-stream abort should retry to success, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 retry), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// wsarecv alone (no "connection was aborted" substring) must still retry, so
|
||||
// deleting the wsarecv needle cannot go green.
|
||||
func TestRunRetriesMidStreamWsarecvNeedle(t *testing.T) {
|
||||
p := &midStreamAbortProvider{
|
||||
abortBefore: 1,
|
||||
abortError: "provider stream error: read: wsarecv: 10053",
|
||||
}
|
||||
result, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
|
||||
if err != nil {
|
||||
t.Fatalf("wsarecv without 'connection was aborted' should retry to success, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 retry), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotRetryMidStreamAbortAfterPartialOutput(t *testing.T) {
|
||||
sawText := 0
|
||||
p := &midStreamAbortProvider{abortBefore: 1, partialText: "partial"}
|
||||
_, err := Run(context.Background(), "go", p, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnText: func(string) { sawText++ },
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("a mid-stream abort after partial output must NOT be retried; want an error")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 1 {
|
||||
t.Fatalf("partial-then-abort must not retry, got %d calls", got)
|
||||
}
|
||||
if sawText == 0 {
|
||||
t.Fatal("OnText must have fired so forwardedVisibleText is the named guard")
|
||||
}
|
||||
}
|
||||
|
||||
// A zero-length OnText chunk is not committed answer prose, so an eligible
|
||||
// transport abort still retries.
|
||||
func TestRunRetriesMidStreamAbortAfterEmptyTextEvent(t *testing.T) {
|
||||
p := &midStreamAbortProvider{abortBefore: 1, emptyTextEvent: true}
|
||||
result, err := Run(context.Background(), "go", p, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnText: func(string) {},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("empty OnText must not block mid-stream abort retry, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 retry), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRetriesMidStreamAbortAfterIncompleteToolCall(t *testing.T) {
|
||||
starts := 0
|
||||
dispatched := 0
|
||||
p := &midStreamAbortProvider{abortBefore: 1, partialToolCall: "write_file"}
|
||||
result, err := Run(context.Background(), "go", p, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnToolCallStart: func(string, string) { starts++ },
|
||||
OnToolCallDelta: func(string, string) {},
|
||||
OnToolCall: func(ToolCall) { dispatched++ },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("a mid-stream abort mid-incomplete-tool-call should retry to success, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 retry), got %d", got)
|
||||
}
|
||||
if starts != 1 {
|
||||
t.Fatalf("the incomplete tool call should have been forwarded once before the abort, got %d starts", starts)
|
||||
}
|
||||
if dispatched != 0 {
|
||||
t.Fatalf("OnToolCall must never fire for an incomplete aborted tool call (no dispatch), got %d", dispatched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunGivesUpAfterMaxMidStreamAbortRetries(t *testing.T) {
|
||||
if maxStreamStallRetries != 1 {
|
||||
t.Fatalf("maxStreamStallRetries = %d, want 1 (#973 must not raise this bound)", maxStreamStallRetries)
|
||||
}
|
||||
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
|
||||
streamReconnectBase = time.Millisecond
|
||||
p := &midStreamAbortProvider{abortBefore: 99}
|
||||
_, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
|
||||
if err == nil {
|
||||
t.Fatal("a persistent mid-stream abort must surface an error after exhausting retries")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 retry); pin the bound at 1, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMidStreamAbortUsesReconnectNotice(t *testing.T) {
|
||||
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
|
||||
streamReconnectBase = time.Millisecond
|
||||
var notices string
|
||||
p := &midStreamAbortProvider{abortBefore: 1}
|
||||
result, err := Run(context.Background(), "go", p, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnReasoning: func(s string) { notices += s },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("retry should succeed, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
lower := strings.ToLower(notices)
|
||||
if !strings.Contains(lower, "connection lost") || !strings.Contains(lower, "reconnecting") {
|
||||
t.Fatalf("transport abort must use reconnect wording, notices = %q", notices)
|
||||
}
|
||||
if strings.Contains(lower, "model stalled") {
|
||||
t.Fatalf("transport abort must not use stall wording, notices = %q", notices)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCancelDuringMidStreamRetryPreservesContextCanceled(t *testing.T) {
|
||||
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
|
||||
streamReconnectBase = time.Millisecond
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
started := make(chan struct{})
|
||||
p := &midStreamAbortProvider{abortBefore: 1, hangOnCall: 2, started: started}
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := Run(ctx, "go", p, Options{Registry: tools.NewRegistry()})
|
||||
errCh <- err
|
||||
}()
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for retried stream")
|
||||
}
|
||||
cancel()
|
||||
err := <-errCh
|
||||
if err == nil {
|
||||
t.Fatal("want context.Canceled after cancel during retried stream")
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancel during retried stream must keep the sentinel, got %q (errors.Is Canceled = false)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCancelDuringReplacementConnectBackoffPreservesContextCanceled(t *testing.T) {
|
||||
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
|
||||
streamReconnectBase = 200 * time.Millisecond
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
started := make(chan struct{})
|
||||
p := &midStreamAbortProvider{abortBefore: 1, connectFailFrom: 2, started: started}
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := Run(ctx, "go", p, Options{Registry: tools.NewRegistry()})
|
||||
errCh <- err
|
||||
}()
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for replacement connect failure")
|
||||
}
|
||||
cancel()
|
||||
err := <-errCh
|
||||
if err == nil {
|
||||
t.Fatal("want context.Canceled after cancel during replacement connect backoff")
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancel during replacement connect backoff must keep the sentinel, got %q (errors.Is Canceled = false)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMidStreamAbortRecordsReconnectCountOnImmediateReplacement(t *testing.T) {
|
||||
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
|
||||
streamReconnectBase = time.Millisecond
|
||||
rec := trace.NewRecorder("midstream-abort", "run-1", "test")
|
||||
p := &midStreamAbortProvider{abortBefore: 1}
|
||||
result, err := Run(context.Background(), "go", p, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
Trace: rec,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("retry should succeed, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 immediate replacement), got %d", got)
|
||||
}
|
||||
gotTrace := rec.Finish()
|
||||
if got := gotTrace.Counter(trace.CounterReconnectCount); got != 1 {
|
||||
t.Fatalf("reconnect_count = %d, want 1 for a successful replacement connection", got)
|
||||
}
|
||||
if got := gotTrace.Counter(trace.CounterModelRequests); got != 2 {
|
||||
t.Fatalf("model_requests = %d, want 2 (initial + replacement)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotRetryResponseHeaderTimeout(t *testing.T) {
|
||||
p := &midStreamAbortProvider{
|
||||
abortBefore: 99,
|
||||
abortError: "net/http: timeout awaiting response headers",
|
||||
}
|
||||
_, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
|
||||
if err == nil {
|
||||
t.Fatal("a response-header timeout must not be retried as a mid-stream abort")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 1 {
|
||||
t.Fatalf("header timeout must not retry, got %d calls", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMidStreamTransportAbort(t *testing.T) {
|
||||
aborts := []string{
|
||||
"provider stream error: read: connection reset by peer",
|
||||
"provider stream error: unexpected EOF",
|
||||
"provider stream error: read: wsarecv: 10053",
|
||||
"provider stream error: read: wsarecv: An established connection was aborted by the software in your host machine",
|
||||
"An existing connection was forcibly closed by the remote host",
|
||||
"provider stream error: read: connection closed",
|
||||
"write: broken pipe",
|
||||
"provider stream error: server closed the connection",
|
||||
"provider stream error: software caused connection abort",
|
||||
}
|
||||
for _, m := range aborts {
|
||||
if !isMidStreamTransportAbort(m) {
|
||||
t.Fatalf("want mid-stream transport abort: %q", m)
|
||||
}
|
||||
}
|
||||
notAborts := []string{
|
||||
"",
|
||||
"context length exceeded",
|
||||
"rate limit error: slow down",
|
||||
"model not found",
|
||||
"net/http: timeout awaiting response headers",
|
||||
"dial tcp 10.0.0.1:443: connect: connection refused",
|
||||
"i/o timeout",
|
||||
"500 Internal Server Error",
|
||||
"502 Bad Gateway",
|
||||
"503 Service Unavailable",
|
||||
"504 Gateway Timeout",
|
||||
"provider request error: request does not satisfy oneOf schema",
|
||||
"provider request error: connection closed is not a supported finish reason",
|
||||
"provider error: connection closed is not a supported finish reason",
|
||||
"provider error: upstream connection reset by peer",
|
||||
"provider error: unexpected EOF while reading upstream response",
|
||||
"provider error: 502 Bad Gateway",
|
||||
"auth error: connection reset by peer",
|
||||
"rate limit error: server closed",
|
||||
"schema error: property oneOf is invalid",
|
||||
}
|
||||
for _, m := range notAborts {
|
||||
if isMidStreamTransportAbort(m) {
|
||||
t.Fatalf("must NOT classify as mid-stream transport abort: %q", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotRetryClassifiedProviderErrorWithSocketPhrase(t *testing.T) {
|
||||
p := &midStreamAbortProvider{
|
||||
abortBefore: 1,
|
||||
abortError: "provider request error: connection closed is not a supported finish reason",
|
||||
}
|
||||
var notices string
|
||||
opts := Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnReasoning: func(s string) { notices += s },
|
||||
}
|
||||
_, err := Run(context.Background(), "go", p, opts)
|
||||
if err == nil {
|
||||
t.Fatal("expected application error, got nil")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 1 {
|
||||
t.Fatalf("classified provider error with 'connection closed' must not retry (got %d calls, want 1)", got)
|
||||
}
|
||||
if notices != "" {
|
||||
t.Fatalf("expected no reconnect notices, got %q", notices)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "connection closed") {
|
||||
t.Fatalf("expected original error preserved, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotRetryProviderErrorWithSocketPhrase(t *testing.T) {
|
||||
p := &midStreamAbortProvider{
|
||||
abortBefore: 1,
|
||||
abortError: "provider error: unexpected EOF while reading upstream response",
|
||||
}
|
||||
var notices string
|
||||
opts := Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnReasoning: func(s string) { notices += s },
|
||||
}
|
||||
_, err := Run(context.Background(), "go", p, opts)
|
||||
if err == nil {
|
||||
t.Fatal("expected application error, got nil")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 1 {
|
||||
t.Fatalf("provider error with 'unexpected EOF' must not retry (got %d calls, want 1)", got)
|
||||
}
|
||||
if notices != "" {
|
||||
t.Fatalf("expected no reconnect notices, got %q", notices)
|
||||
}
|
||||
if got := err.Error(); got != p.abortError {
|
||||
t.Fatalf("error = %q, want original provider error %q", got, p.abortError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotRetryApplicationErrorContainingEOFSubstring(t *testing.T) {
|
||||
p := &midStreamAbortProvider{
|
||||
abortBefore: 1,
|
||||
abortError: "provider request error: request does not satisfy oneOf schema",
|
||||
}
|
||||
var notices string
|
||||
opts := Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnReasoning: func(s string) { notices += s },
|
||||
}
|
||||
_, err := Run(context.Background(), "go", p, opts)
|
||||
if err == nil {
|
||||
t.Fatal("expected application error, got nil")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 1 {
|
||||
t.Fatalf("application error with 'oneOf' must not retry (got %d calls, want 1)", got)
|
||||
}
|
||||
if notices != "" {
|
||||
t.Fatalf("expected no reconnect notices, got %q", notices)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "oneOf") {
|
||||
t.Fatalf("expected original error preserved, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRetriesMidStreamUnexpectedEOF(t *testing.T) {
|
||||
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
|
||||
streamReconnectBase = time.Millisecond
|
||||
p := &midStreamAbortProvider{
|
||||
abortBefore: 1,
|
||||
abortError: "provider stream error: unexpected EOF",
|
||||
}
|
||||
var notices string
|
||||
opts := Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnReasoning: func(s string) { notices += s },
|
||||
}
|
||||
result, err := Run(context.Background(), "go", p, opts)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected EOF should retry to success, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 retry), got %d", got)
|
||||
}
|
||||
lower := strings.ToLower(notices)
|
||||
if !strings.Contains(lower, "connection lost") || !strings.Contains(lower, "reconnecting") {
|
||||
t.Fatalf("expected reconnect notice, got %q", notices)
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,15 @@ func shouldReconnect(ctx context.Context, err error) bool {
|
||||
"i/o timeout",
|
||||
"server closed",
|
||||
"unexpected end",
|
||||
// Windows mid-stream aborts (WSAECONNABORTED / wsarecv) — host or AV
|
||||
// forcibly closes an established socket during CollectStream.
|
||||
// "software caused connection abort" is the Unix/macOS ECONNABORTED
|
||||
// text; it also appears in midStreamAbortNeedles, which must stay a
|
||||
// subset of this list.
|
||||
"wsarecv",
|
||||
"connection was aborted",
|
||||
"software caused connection abort",
|
||||
"forcibly closed",
|
||||
} {
|
||||
if strings.Contains(msg, needle) {
|
||||
return true
|
||||
@@ -152,6 +161,86 @@ func shouldReconnect(ctx context.Context, err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// midStreamAbortNeedles are the transport-abort classes retried AFTER connect
|
||||
// succeeded and the stream body has started. This is a subset of shouldReconnect:
|
||||
// connect-phase signals (timeout, connection refused, "temporarily unavailable")
|
||||
// stay on the connect retry path, because matching them here would re-prefill a
|
||||
// healthy-but-slow server (ollama cloud header timeouts) and tell the user the
|
||||
// connection was lost. Issue #973 names WSAECONNABORTED / connection reset /
|
||||
// unexpected stream EOF.
|
||||
var midStreamAbortNeedles = []string{
|
||||
"unexpected end",
|
||||
"connection reset",
|
||||
"broken pipe",
|
||||
"connection closed",
|
||||
"server closed",
|
||||
"wsarecv",
|
||||
"connection was aborted",
|
||||
"software caused connection abort",
|
||||
"forcibly closed",
|
||||
}
|
||||
|
||||
var classifiedNonTransportPrefixes = []string{
|
||||
"provider request error:",
|
||||
"provider error:",
|
||||
"auth error:",
|
||||
"rate limit error:",
|
||||
}
|
||||
|
||||
// isMidStreamTransportAbort reports whether a collected stream error string is a
|
||||
// retryable mid-stream transport abort. It does NOT delegate to shouldReconnect:
|
||||
// that list is a connect-phase argument ("no response was received"). The stream
|
||||
// body has already started here, so only abort/reset/EOF/close classes retry.
|
||||
//
|
||||
// Used for failures DURING the stream body AFTER a successful connect and BEFORE
|
||||
// tool dispatch — the incomplete turn committed no answer text and executed no
|
||||
// tools, so a bounded re-issue is safe (same safety rules as the stall path).
|
||||
func isMidStreamTransportAbort(message string) bool {
|
||||
lowered := strings.ToLower(strings.TrimSpace(message))
|
||||
if lowered == "" || isContextLimitError(lowered) {
|
||||
return false
|
||||
}
|
||||
for _, prefix := range classifiedNonTransportPrefixes {
|
||||
if strings.Contains(lowered, prefix) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if errhint.HasStatusCode(lowered, "500", "502", "503", "504") {
|
||||
return false
|
||||
}
|
||||
if containsWordBoundary(lowered, "eof") {
|
||||
return true
|
||||
}
|
||||
for _, needle := range midStreamAbortNeedles {
|
||||
if strings.Contains(lowered, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsWordBoundary(text, word string) bool {
|
||||
start := 0
|
||||
for {
|
||||
idx := strings.Index(text[start:], word)
|
||||
if idx < 0 {
|
||||
return false
|
||||
}
|
||||
pos := start + idx
|
||||
endPos := pos + len(word)
|
||||
leftBoundary := pos == 0 || !isAlphaNumByte(text[pos-1])
|
||||
rightBoundary := endPos == len(text) || !isAlphaNumByte(text[endPos])
|
||||
if leftBoundary && rightBoundary {
|
||||
return true
|
||||
}
|
||||
start = pos + 1
|
||||
}
|
||||
}
|
||||
|
||||
func isAlphaNumByte(c byte) bool {
|
||||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'
|
||||
}
|
||||
|
||||
// backoffFor is the deterministic exponential base delay for a 1-based attempt,
|
||||
// capped at streamReconnectMax. Jitter is layered on separately (jitteredBackoff).
|
||||
func backoffFor(attempt int) time.Duration {
|
||||
|
||||
@@ -92,6 +92,12 @@ func TestShouldReconnectClassification(t *testing.T) {
|
||||
disconnects := []string{
|
||||
"unexpected EOF", "connection reset by peer", "broken pipe",
|
||||
"i/o timeout", "server closed the connection", "connection refused",
|
||||
// Windows socket aborts also remain reconnectable at connect time.
|
||||
"wsarecv: An established connection was aborted by the software in your host machine",
|
||||
"read: connection was aborted",
|
||||
// Unix/macOS ECONNABORTED text.
|
||||
"read: software caused connection abort",
|
||||
"An existing connection was forcibly closed by the remote host",
|
||||
}
|
||||
for _, m := range disconnects {
|
||||
if !shouldReconnect(ctx, errors.New(m)) {
|
||||
|
||||
@@ -10,6 +10,24 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestNonVisionImageNoticeQualifiesBudgetRecovery(t *testing.T) {
|
||||
result := ToolResult{
|
||||
Name: "mcp_screenshot",
|
||||
Output: "One image exceeded this result's remaining image budget. Retrying with fewer images can recover this payload.",
|
||||
Images: []zeroruntime.ImageBlock{{MediaType: "image/png", Data: []byte("image")}},
|
||||
}
|
||||
notice, ok := toolResultImageMessage(result, Options{
|
||||
SupportsVision: func(string) bool { return false },
|
||||
})
|
||||
if !ok || len(notice.Images) != 0 {
|
||||
t.Fatal("non-vision delivery must produce a text-only notice")
|
||||
}
|
||||
if !strings.Contains(notice.Content, "Switch to a vision-capable model before retrying") ||
|
||||
!strings.Contains(notice.Content, "budget") {
|
||||
t.Fatalf("notice does not qualify budget recovery: %s", notice.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// imageTool returns a tool result carrying an image, the way a screenshot tool
|
||||
// should be able to.
|
||||
type imageTool struct {
|
||||
@@ -62,6 +80,7 @@ func TestRunDeliversToolResultImagesToTheModel(t *testing.T) {
|
||||
result, err := Run(context.Background(), "screenshot please", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 2,
|
||||
Model: "gpt-4o",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
@@ -86,11 +105,16 @@ func TestRunDeliversToolResultImagesToTheModel(t *testing.T) {
|
||||
|
||||
// The tool-result pairing must be untouched: one tool result per tool call.
|
||||
toolResults := 0
|
||||
var toolText string
|
||||
for _, message := range result.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
toolResults++
|
||||
toolText = message.Content
|
||||
}
|
||||
}
|
||||
if toolText != "Captured a screenshot." {
|
||||
t.Fatalf("tool text = %q, want preserved", toolText)
|
||||
}
|
||||
if toolResults != 1 {
|
||||
t.Errorf("got %d tool-result messages for 1 tool call", toolResults)
|
||||
}
|
||||
@@ -136,7 +160,7 @@ func TestRunKeepsToolResultsContiguousWhenAToolReturnsAnImage(t *testing.T) {
|
||||
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "two calls", provider, Options{Registry: registry, MaxTurns: 2})
|
||||
result, err := Run(context.Background(), "two calls", provider, Options{Registry: registry, MaxTurns: 2, Model: "gpt-4o"})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
@@ -205,3 +229,176 @@ func messageShape(messages []zeroruntime.Message) string {
|
||||
}
|
||||
return "[" + strings.Join(parts, " ") + "]"
|
||||
}
|
||||
|
||||
func TestRunDropsToolResultImagesForANonVisionModel(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(imageTool{media: "image/png", data: []byte("\x89PNG\r\n\x1a\nfake")})
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_1", ToolName: "capture"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{{Type: zeroruntime.StreamEventText, Content: "ok"}, {Type: zeroruntime.StreamEventDone}},
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "screenshot please", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 2,
|
||||
Model: "totally-made-up-model",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
var toolText string
|
||||
var noticed bool
|
||||
for _, message := range result.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
toolText = message.Content
|
||||
}
|
||||
if len(message.Images) > 0 {
|
||||
t.Fatalf("non-vision model received image bytes on %q: %s", message.Role, messageShape(result.Messages))
|
||||
}
|
||||
if strings.Contains(message.Content, "does not support image input") {
|
||||
noticed = true
|
||||
}
|
||||
}
|
||||
if toolText != "Captured a screenshot." {
|
||||
t.Fatalf("tool text = %q, want preserved on the non-vision path", toolText)
|
||||
}
|
||||
if !noticed {
|
||||
t.Fatalf("non-vision model was not told the image was dropped; recorded %s", messageShape(result.Messages))
|
||||
}
|
||||
}
|
||||
|
||||
type switchAndCaptureTool struct {
|
||||
targetModel string
|
||||
}
|
||||
|
||||
func (switchAndCaptureTool) Name() string { return "switch_and_capture" }
|
||||
func (switchAndCaptureTool) Description() string { return "Switches model and captures image" }
|
||||
func (switchAndCaptureTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
|
||||
}
|
||||
func (switchAndCaptureTool) Safety() tools.Safety {
|
||||
return tools.Safety{Permission: tools.PermissionAllow}
|
||||
}
|
||||
func (t switchAndCaptureTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{
|
||||
Status: tools.StatusOK,
|
||||
Output: "[image returned by tool]",
|
||||
Images: []zeroruntime.ImageBlock{{MediaType: "image/png", Data: []byte("\x89PNG\r\n\x1a\nfake")}},
|
||||
Meta: map[string]string{"escalate_to_model": t.targetModel},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunToolImagesRespectsModelSwitch(t *testing.T) {
|
||||
t.Run("Non-vision to vision model escalation forwards images", func(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(switchAndCaptureTool{targetModel: "gpt-4o"})
|
||||
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_1", ToolName: "switch_and_capture"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "I see it on gpt-4o."},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
|
||||
switched := false
|
||||
switcher := func(_ context.Context, modelID string) (Provider, error) {
|
||||
switched = true
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "test", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 2,
|
||||
Model: "non-vision-initial",
|
||||
ModelSwitcher: switcher,
|
||||
SupportsVision: func(modelID string) bool {
|
||||
return modelID == "gpt-4o"
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if !switched {
|
||||
t.Fatal("expected model switch to occur")
|
||||
}
|
||||
|
||||
var carrier *zeroruntime.Message
|
||||
for index := range result.Messages {
|
||||
if len(result.Messages[index].Images) > 0 {
|
||||
carrier = &result.Messages[index]
|
||||
}
|
||||
if strings.Contains(result.Messages[index].Content, "does not support image input") {
|
||||
t.Fatalf("images were unexpectedly dropped after switch to vision model: %v", result.Messages[index])
|
||||
}
|
||||
}
|
||||
if carrier == nil {
|
||||
t.Fatal("expected image carrier message after escalation to vision model")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Vision to non-vision model switch drops images with notice", func(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(switchAndCaptureTool{targetModel: "text-only-dest"})
|
||||
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_1", ToolName: "switch_and_capture"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "ok."},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
|
||||
switcher := func(_ context.Context, modelID string) (Provider, error) {
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "test", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 2,
|
||||
Model: "gpt-4o",
|
||||
ModelSwitcher: switcher,
|
||||
SupportsVision: func(modelID string) bool {
|
||||
return modelID == "gpt-4o"
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
var noticed bool
|
||||
var toolOutputSeen bool
|
||||
for _, m := range result.Messages {
|
||||
if len(m.Images) > 0 {
|
||||
t.Fatalf("image delivered to non-vision destination model: %v", m)
|
||||
}
|
||||
if strings.Contains(m.Content, "does not support image input") {
|
||||
noticed = true
|
||||
}
|
||||
if strings.Contains(m.Content, "[image returned by tool]") {
|
||||
toolOutputSeen = true
|
||||
}
|
||||
}
|
||||
if !noticed {
|
||||
t.Fatal("expected drop notice when switching to non-vision model")
|
||||
}
|
||||
if !toolOutputSeen {
|
||||
t.Fatal("expected tool output preserved in message history")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -341,6 +341,11 @@ type Options struct {
|
||||
// nil for text-only runs (the seeded message then carries no images, exactly
|
||||
// as before).
|
||||
Images []zeroruntime.ImageBlock
|
||||
// SupportsVision, when set, reports whether the effective model accepts
|
||||
// image input. Tool-produced images are dropped at the shared delivery
|
||||
// boundary when this is false. nil uses the curated catalog plus the
|
||||
// name heuristic via modelregistry.SupportsVision.
|
||||
SupportsVision func(modelID string) bool
|
||||
// ContextWindow is the model's maximum input token budget. When > 0 the agent
|
||||
// loop compacts long conversations once the estimated size crosses a fraction
|
||||
// of this window. 0 DISABLES compaction entirely (every existing caller/test
|
||||
|
||||
@@ -4,6 +4,7 @@ package background
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
@@ -46,7 +47,13 @@ func terminateOwnedProcess(cmd *exec.Cmd) (bool, error) {
|
||||
terminateErr = terminateProcessForTest(cmd.Process.Pid)
|
||||
})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("pin process %d for termination: %w", cmd.Process.Pid, err)
|
||||
// WithHandle refuses only a process that has already been waited or
|
||||
// released, so it has finished. Say so in the form exec.Cmd understands:
|
||||
// a Cancel error wrapping os.ErrProcessDone is "nothing to cancel", where
|
||||
// the unexported "already released" error it would otherwise carry made
|
||||
// Wait report a spurious cancel failure. A dead root has no tree left to
|
||||
// find either way.
|
||||
return true, fmt.Errorf("pin process %d for termination: %w: %w", cmd.Process.Pid, os.ErrProcessDone, err)
|
||||
}
|
||||
return alreadyExited, terminateErr
|
||||
}
|
||||
|
||||
@@ -73,6 +73,29 @@ func TerminateCommand(cmd *exec.Cmd) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TerminateCommandGroup stops a started command's process group on POSIX, or its
|
||||
// process tree on Windows, WITHOUT reaping it.
|
||||
//
|
||||
// It is TerminateCommand without the Wait, for a caller whose Wait already runs
|
||||
// somewhere else: a worker pool waiting on the command in its own goroutine, or
|
||||
// the command's own Cancel hook, which exec.Cmd invokes while its Wait is in
|
||||
// progress. TerminateCommand would be a second Wait there. Both used to pass
|
||||
// cmd.Process.Pid to TerminateProcess, which rediscovers the group at signal time
|
||||
// with Getpgid, and once the leader has exited that lookup fails, so only the dead
|
||||
// leader was signalled and its descendants kept running. When cmd was configured
|
||||
// by ConfigureChildProcessGroup the group now comes from that launch-time
|
||||
// configuration instead. On Windows it is the tree kill TerminateProcess performs,
|
||||
// with the process handle pinned while it runs so the PID cannot be recycled
|
||||
// underneath it; once the command has been waited that pin fails with an error
|
||||
// wrapping os.ErrProcessDone.
|
||||
func TerminateCommandGroup(cmd *exec.Cmd) error {
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := terminateOwnedProcess(cmd)
|
||||
return err
|
||||
}
|
||||
|
||||
// classifyWaitError treats a non-zero exit status as success: a process being
|
||||
// terminated is expected to report one (or a signal), so the only interesting
|
||||
// failure is Wait itself not working.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package background
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TerminateCommandGroup LEAVES THE WAIT TO ITS CALLER. Its callers already have a
|
||||
// Wait running or about to run: the daemon pool waits on its workers, and
|
||||
// exec.Cmd calls the Cancel hook from inside Wait. Reaping here, the way
|
||||
// TerminateCommand does, would make their Wait fail with "Wait was already
|
||||
// called" instead of reporting how the command ended.
|
||||
func TestTerminateCommandGroupLeavesTheWaitToTheCaller(t *testing.T) {
|
||||
cmd := exec.Command("sleep", "30")
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd = exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 30")
|
||||
}
|
||||
ConfigureChildProcessGroup(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
if err := TerminateCommandGroup(cmd); err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
t.Fatalf("TerminateCommandGroup: %v", err)
|
||||
}
|
||||
|
||||
waited := make(chan error, 1)
|
||||
go func() { waited <- cmd.Wait() }()
|
||||
select {
|
||||
case err := <-waited:
|
||||
var exitErr *exec.ExitError
|
||||
if err != nil && !errors.As(err, &exitErr) {
|
||||
t.Fatalf("the caller's own Wait failed after TerminateCommandGroup: %v", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
_ = cmd.Process.Kill()
|
||||
t.Fatal("the command was still running 10s after TerminateCommandGroup")
|
||||
}
|
||||
}
|
||||
|
||||
// A command that was never started, or a nil one, is nothing to stop.
|
||||
func TestTerminateCommandGroupWithNoProcessIsANoOp(t *testing.T) {
|
||||
if err := TerminateCommandGroup(nil); err != nil {
|
||||
t.Fatalf("nil command: %v", err)
|
||||
}
|
||||
if err := TerminateCommandGroup(exec.Command("sleep", "1")); err != nil {
|
||||
t.Fatalf("unstarted command: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// After the caller has waited, Windows cannot pin the process any more. The error
|
||||
// must still read as "already finished", which is what exec.Cmd's Cancel contract
|
||||
// and the pool both treat as nothing wrong.
|
||||
func TestTerminateCommandGroupAfterWaitReportsProcessDone(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("POSIX signals the group, which is simply gone after the wait")
|
||||
}
|
||||
cmd := exec.Command("cmd.exe", "/c", "exit", "0")
|
||||
ConfigureChildProcessGroup(cmd)
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
if err := TerminateCommandGroup(cmd); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||
t.Fatalf("after Wait the error is %v, want nil or one wrapping os.ErrProcessDone", err)
|
||||
}
|
||||
}
|
||||
@@ -695,7 +695,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
// Refresh the models.dev pricing/limits cache in the background when stale;
|
||||
// the overlay is read at registry construction from the cache file, so this
|
||||
// benefits the next run and never blocks or fails this one.
|
||||
go func() { _ = modelregistry.RefreshModelsDevCache(context.Background()) }()
|
||||
modelregistry.StartModelsDevRefresh()
|
||||
|
||||
workspaceRoot, err := deps.getwd()
|
||||
if err != nil {
|
||||
@@ -727,7 +727,13 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
resolved.Provider = usable
|
||||
resolved.ActiveProvider = usable.Name
|
||||
} else {
|
||||
resolved = config.ResolvedConfig{}
|
||||
// Fresh-onboarding reset, but the user's notification preference is
|
||||
// NOT config-to-redo: clearing it here would surface as an empty
|
||||
// Options.Notify, and the TUI's unconfigured default (both/unfocused)
|
||||
// would then resurrect alerts a user explicitly turned off while the
|
||||
// setup wizard runs (maintainer review, PR #1001). Carry the stored
|
||||
// block through; other wizard-visible state starts clean as before.
|
||||
resolved = config.ResolvedConfig{Notify: resolved.Notify}
|
||||
forceSetup = true
|
||||
}
|
||||
}
|
||||
@@ -959,7 +965,12 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
// notice when project hooks/plugins were dropped for an untrusted workspace.
|
||||
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner)
|
||||
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
|
||||
// From the engine the session's commands run through, so the notice
|
||||
// describes the sandbox they actually get.
|
||||
startupNotices := []string{sandboxEngine.DegradedNotice()}
|
||||
modelRPM := zeroruntime.NewModelRPMLimiter(resolved.ModelRPM)
|
||||
return deps.runTUI(context.Background(), tui.Options{
|
||||
ModelRPM: modelRPM,
|
||||
Cwd: workspaceRoot,
|
||||
Version: version,
|
||||
Theme: theme,
|
||||
@@ -982,7 +993,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
if provider == nil {
|
||||
return nil
|
||||
}
|
||||
if optimized, ok := providers.OptimizedTurnSessions(profile, provider, providers.Options{}); ok {
|
||||
if optimized, ok := providers.ConfiguredTurnSessions(profile, provider, providers.Options{ModelRPM: modelRPM}); ok {
|
||||
return optimized
|
||||
}
|
||||
return providers.DefaultTurnSessions(profile, provider, providers.Options{})
|
||||
@@ -1046,6 +1057,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
PermissionMode: permissionMode,
|
||||
Notify: resolved.Notify,
|
||||
KeyBindings: resolved.KeyBindings,
|
||||
StartupNotices: startupNotices,
|
||||
STT: resolved.STT,
|
||||
BuildDictationTranscriber: newDictationTranscriberFactory(resolved, userConfigPath, sttServerManager),
|
||||
ShutdownDictationServer: sttServerManager.Shutdown,
|
||||
|
||||
@@ -343,8 +343,10 @@ func TestRunNoArgsFallsBackToUsableProviderWhenNoneMarkedActive(t *testing.T) {
|
||||
// config.json has providers configured but none marked active (e.g. a
|
||||
// blank/stale activeProvider field) — Resolve returns the successfully
|
||||
// normalized providers list alongside ErrNoActiveProvider.
|
||||
return config.ResolvedConfig{Providers: []config.ProviderProfile{usable}},
|
||||
fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "")
|
||||
return config.ResolvedConfig{
|
||||
Providers: []config.ProviderProfile{usable},
|
||||
ModelRPM: map[string]int{"gpt-test": 1},
|
||||
}, fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "")
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
providerProfile = profile
|
||||
@@ -374,6 +376,77 @@ func TestRunNoArgsFallsBackToUsableProviderWhenNoneMarkedActive(t *testing.T) {
|
||||
}
|
||||
if providerProfile.Name != "work" {
|
||||
t.Fatalf("provider used = %q, want fallback to the usable saved provider %q", providerProfile.Name, "work")
|
||||
|
||||
}
|
||||
if launchedOptions.ModelRPM == nil {
|
||||
t.Fatal("ModelRPM = nil, want limiter preserved through provider fallback")
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainer regression (PR #1001): a stored explicit notify opt-out must
|
||||
// survive the provider-recovery startup path. With a stale activeProvider and
|
||||
// another usable saved provider, Resolve returns the partial config alongside
|
||||
// ErrNoActiveProvider and the recovery branch forwards it — dropping the
|
||||
// notify policy there would surface an empty Options.Notify, and the TUI's
|
||||
// unconfigured default (both) would resurrect alerts the user explicitly
|
||||
// turned off. The same recovery branch that clears the resolved config for
|
||||
// the setup wizard carries the stored notify block through.
|
||||
func TestRunNoArgsPreservesNotifyOptOutThroughProviderRecovery(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
setCLIUserConfigRoot(t)
|
||||
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
var launchedOptions tui.Options
|
||||
launched := false
|
||||
|
||||
usable := config.ProviderProfile{
|
||||
Name: "work",
|
||||
ProviderKind: config.ProviderKindOpenAI,
|
||||
BaseURL: config.OpenAIBaseURL,
|
||||
APIKey: "sk-test",
|
||||
Model: "gpt-test",
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
// Stale activeProvider + usable saved provider, and the user's
|
||||
// config.json explicitly opts out of notifications. The fixed
|
||||
// resolver carries the parsed notify block through the error path.
|
||||
return config.ResolvedConfig{
|
||||
Providers: []config.ProviderProfile{usable},
|
||||
Notify: config.NotifyConfig{Mode: "off", FocusMode: "always"},
|
||||
}, fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "ghost")
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return &cliFakeProvider{}, nil
|
||||
},
|
||||
userConfigPath: func() (string, error) {
|
||||
return userConfigPath, nil
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
launched = true
|
||||
launchedOptions = options
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exit code = %d, want 0, stderr=%q", exitCode, stderr.String())
|
||||
}
|
||||
if !launched {
|
||||
t.Fatal("TUI was not launched")
|
||||
}
|
||||
// The explicit opt-out reaches the TUI intact — NOT the both/unfocused
|
||||
// unconfigured default.
|
||||
if launchedOptions.Notify.Mode != "off" || launchedOptions.Notify.FocusMode != "always" {
|
||||
t.Fatalf("Options.Notify = %+v, want the stored off/always opt-out preserved through recovery", launchedOptions.Notify)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,30 @@ type modelSummary = zerocommands.ModelSnapshot
|
||||
type providerCatalogSummary = zerocommands.ProviderCatalogSnapshot
|
||||
|
||||
func runConfig(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
// The first non-flag argument is a subcommand. With no positional argument
|
||||
// (or only flag arguments), the read-only summary path runs — this keeps
|
||||
// `zero config` and `zero config --json` working unchanged.
|
||||
command := "summary"
|
||||
rest := args
|
||||
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
|
||||
command = strings.ToLower(strings.TrimSpace(args[0]))
|
||||
rest = args[1:]
|
||||
}
|
||||
switch command {
|
||||
case "summary":
|
||||
return runConfigSummary(rest, stdout, stderr, deps)
|
||||
case "notify":
|
||||
return runConfigNotify(rest, stdout, stderr, deps)
|
||||
case "help":
|
||||
if err := writeConfigHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unknown config command %q", command))
|
||||
}
|
||||
|
||||
func runConfigSummary(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseCommandCenterArgs(args, false, false)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
@@ -466,8 +490,12 @@ func formatProviderCatalogValue(value string, fallback string) string {
|
||||
func writeConfigHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero config [flags]
|
||||
zero config notify [flags]
|
||||
|
||||
Inspects resolved Go configuration without printing secrets.
|
||||
Inspects resolved Go configuration without printing secrets. The notify
|
||||
subcommand reads or updates the stored global notification preference,
|
||||
which controls both the completion and needs-input alerts —
|
||||
run "zero config notify --help" for details.
|
||||
|
||||
Flags:
|
||||
--json Print JSON summary
|
||||
|
||||
@@ -40,7 +40,11 @@ var completionRoot = completionNode{
|
||||
},
|
||||
{names: []string{"daemon"}, children: leafNodes("start", "stop", "status", "run", "attach", "serve-remote", "link")},
|
||||
{names: []string{"setup"}},
|
||||
{names: []string{"config"}},
|
||||
{names: []string{"config"}, flags: []string{"-h", "--help", "--json"}, children: []completionNode{
|
||||
{names: []string{"notify"}, flags: []string{
|
||||
"-h", "--help", "--mode", "--focus", "--reset", "--json",
|
||||
}},
|
||||
}},
|
||||
{names: []string{"models"}, children: []completionNode{{names: []string{"list", "ls"}}}},
|
||||
{names: []string{"providers"}, children: []completionNode{
|
||||
{names: []string{"current"}}, {names: []string{"list"}}, {names: []string{"catalog"}},
|
||||
|
||||
@@ -165,6 +165,11 @@ func TestCompletionTreeCoversAliasesNestingAndCommonFlags(t *testing.T) {
|
||||
assertCandidates(t, byPath["completions"], "bash", "zsh", "fish", "powershell", "elvish")
|
||||
assertCandidates(t, byPath["plugins"], "list", "add", "info", "remove", "rm")
|
||||
assertCandidates(t, byPath["plugin"], "list", "add", "info", "remove", "rm")
|
||||
// `config` gained the notify subcommand; the completion tree must expose
|
||||
// it (and its flags) so shell completion cannot go stale for new CLI
|
||||
// surfaces (maintainer review, PR #1001).
|
||||
assertCandidates(t, byPath["config"], "notify", "--json", "--help")
|
||||
assertCandidates(t, byPath["config notify"], "--mode", "--focus", "--reset", "--json", "--help")
|
||||
}
|
||||
|
||||
func assertCandidates(t *testing.T, got []string, wants ...string) {
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
// runConfigNotify implements `zero config notify`: with no flags it prints the
|
||||
// stored mode/focusMode; --mode/--focus update them via the same
|
||||
// config.UpdateNotify writer the TUI /notify command uses, so all surfaces stay
|
||||
// in lockstep; --reset blanks both fields so the TUI's effective default
|
||||
// applies again (an unconfigured headless run stays silent).
|
||||
//
|
||||
// The command manages a user preference, so it talks ONLY to the user's own
|
||||
// config file (config.UserNotify / config.UpdateNotify) and never runs the full
|
||||
// config resolution: resolving providers would fail with ErrNoActiveProvider
|
||||
// for a brand-new user, locking them out of setting notifications before they
|
||||
// have even configured a provider (CodeRabbit review, PR #1001). The display
|
||||
// therefore reports the USER'S stored values — a project config that
|
||||
// overrides notify for one repo is not shown here, by the same logic the
|
||||
// maintainer applied to the write path.
|
||||
func runConfigNotify(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseConfigNotifyArgs(args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeConfigNotifyHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
configPath, err := deps.userConfigPath()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
|
||||
if options.mode != "" || options.focus != "" || options.reset {
|
||||
// One serialized read-merge-write transaction: the lock covers
|
||||
// reading the stored block, applying only the explicit fields, and
|
||||
// replacing the file, so two concurrent partial updates (e.g.
|
||||
// --mode off and --focus always from two terminals) cannot lose
|
||||
// each other's change (maintainer review, PR #1001). Omitted
|
||||
// fields preserve the values stored in the user's OWN file — blank
|
||||
// stays blank; --reset is the only path that clears both fields.
|
||||
_, err := config.UpdateNotify(configPath, func(current config.NotifyConfig) config.NotifyConfig {
|
||||
if options.reset {
|
||||
return config.NotifyConfig{}
|
||||
}
|
||||
if options.mode != "" {
|
||||
current.Mode = options.mode
|
||||
}
|
||||
if options.focus != "" {
|
||||
current.FocusMode = options.focus
|
||||
}
|
||||
return current
|
||||
})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
// Report the stored values (blank renders as "(default)").
|
||||
current, err := config.UserNotify(configPath)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitUsage)
|
||||
}
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, map[string]any{
|
||||
"mode": current.Mode,
|
||||
"focusMode": current.FocusMode,
|
||||
}); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
lines := []string{
|
||||
"Notify",
|
||||
"mode: " + displayCLIValue(current.Mode, "(default)"),
|
||||
"focusMode: " + displayCLIValue(current.FocusMode, "(default)"),
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, strings.Join(lines, "\n")); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
type configNotifyOptions struct {
|
||||
mode string
|
||||
focus string
|
||||
reset bool
|
||||
json bool
|
||||
}
|
||||
|
||||
func parseConfigNotifyArgs(args []string) (configNotifyOptions, bool, error) {
|
||||
options := configNotifyOptions{}
|
||||
for index := 0; index < len(args); index++ {
|
||||
arg := args[index]
|
||||
switch {
|
||||
case arg == "-h" || arg == "--help" || arg == "help":
|
||||
return options, true, nil
|
||||
case arg == "--json":
|
||||
options.json = true
|
||||
case arg == "--reset":
|
||||
options.reset = true
|
||||
case arg == "--mode":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.mode = value
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--mode="):
|
||||
value, err := requiredInlineFlagValue(arg, "--mode")
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.mode = value
|
||||
case arg == "--focus":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.focus = value
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--focus="):
|
||||
value, err := requiredInlineFlagValue(arg, "--focus")
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.focus = value
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
|
||||
default:
|
||||
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
|
||||
}
|
||||
}
|
||||
return options, false, nil
|
||||
}
|
||||
|
||||
func writeConfigNotifyHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, "Usage:\n"+
|
||||
" zero config notify [flags]\n"+
|
||||
"\n"+
|
||||
"Print or update the stored global notification preference.\n"+
|
||||
"\n"+
|
||||
"The preference controls BOTH notification kinds: the turn-completion\n"+
|
||||
"(\"Zero: ready\") alert and the needs-input alert. mode off silences both;\n"+
|
||||
"the focus mode (unfocused, always, focused) applies to both.\n"+
|
||||
"\n"+
|
||||
"When run with no flag, prints the stored mode and focusMode; a field you\n"+
|
||||
"never set shows as (default) — the TUI alerts with bell + notification,\n"+
|
||||
"firing only when the terminal is unfocused, while an unconfigured\n"+
|
||||
"headless run stays silent. Omitted flags preserve the values stored in\n"+
|
||||
"YOUR config file; --reset clears both so the TUI's effective default\n"+
|
||||
"applies again. If --reset is combined with --mode/--focus, --reset wins\n"+
|
||||
"and the explicit values are ignored.\n"+
|
||||
"\n"+
|
||||
"Examples:\n"+
|
||||
" zero config notify\n"+
|
||||
" zero config notify --json\n"+
|
||||
" zero config notify --mode both --focus unfocused\n"+
|
||||
" zero config notify --mode off\n"+
|
||||
" zero config notify --reset # clear the stored preference\n"+
|
||||
"\n"+
|
||||
"Flags:\n"+
|
||||
" --mode <off|bell|notify|both> Notification mechanism (both kinds)\n"+
|
||||
" --focus <unfocused|always|focused> When the alert fires\n"+
|
||||
" --reset Clear the stored preference so the TUI effective default applies\n"+
|
||||
" --json Machine-readable output\n"+
|
||||
" -h, --help Show this help\n")
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
// `zero config notify` with no flag and a fresh config: nothing is configured,
|
||||
// the resolver leaves the fields blank (defaults deliberately live in the TUI,
|
||||
// not the resolver — maintainer review, PR #1001), and the command reports
|
||||
// "(default)" for both.
|
||||
func TestRunConfigNotifyPrintsUnconfiguredAsDefault(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
// A valid openai profile so the resolver does not error with
|
||||
// ErrNoActiveProvider. The notify defaults are applied independently of
|
||||
// the provider resolution path.
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}]
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "mode: (default)") {
|
||||
t.Errorf("stdout should show unconfigured mode as (default), got: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "focusMode: (default)") {
|
||||
t.Errorf("stdout should show unconfigured focus as (default), got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// `zero config notify --json` emits a machine-readable payload. A configured
|
||||
// pair round-trips; unconfigured fields are empty strings (never defaults
|
||||
// filled in), so scripts can distinguish "user chose" from "default".
|
||||
func TestRunConfigNotifyPrintsJSON(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}],
|
||||
"notify": {"mode": "bell", "focusMode": "always"}
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["mode"] != "bell" {
|
||||
t.Errorf("mode = %v, want bell", payload["mode"])
|
||||
}
|
||||
if payload["focusMode"] != "always" {
|
||||
t.Errorf("focusMode = %v, want always", payload["focusMode"])
|
||||
}
|
||||
}
|
||||
|
||||
// The unconfigured JSON shape: fields are empty strings, never defaults
|
||||
// filled in, so scripts can tell "user chose" from "default".
|
||||
func TestRunConfigNotifyPrintsJSONUnconfigured(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}]
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["mode"] != "" {
|
||||
t.Errorf("mode = %v, want empty (unconfigured)", payload["mode"])
|
||||
}
|
||||
if payload["focusMode"] != "" {
|
||||
t.Errorf("focusMode = %v, want empty (unconfigured)", payload["focusMode"])
|
||||
}
|
||||
}
|
||||
|
||||
// `zero config notify --mode off` writes the new value to disk and prints
|
||||
// confirmation. The user can read it back by running the command again.
|
||||
func TestRunConfigNotifyWritesModeChange(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}],
|
||||
"notify": {"mode": "both", "focusMode": "always"}
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--mode", "off"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "off" {
|
||||
t.Errorf("Notify.Mode = %q, want off", cfg.Notify.Mode)
|
||||
}
|
||||
// A mode-only update must preserve the configured focusMode, not wipe it.
|
||||
if cfg.Notify.FocusMode != "always" {
|
||||
t.Errorf("Notify.FocusMode = %q, want preserved %q", cfg.Notify.FocusMode, "always")
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "mode: off") {
|
||||
t.Errorf("stdout should confirm the change, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A focus-only update preserves the configured mode.
|
||||
func TestRunConfigNotifyFocusOnlyPreservesMode(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}],
|
||||
"notify": {"mode": "bell", "focusMode": "unfocused"}
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--focus", "always"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "bell" {
|
||||
t.Errorf("Notify.Mode = %q, want preserved %q", cfg.Notify.Mode, "bell")
|
||||
}
|
||||
if cfg.Notify.FocusMode != "always" {
|
||||
t.Errorf("Notify.FocusMode = %q, want always", cfg.Notify.FocusMode)
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainer regression (PR #1001): a partial update must seed from the USER'S
|
||||
// OWN file, never from the resolved view. A project .zero/config.json setting
|
||||
// mode=off resolves into the session, but `--focus always` inside that repo
|
||||
// must NOT copy the project's off into the user's global config.
|
||||
func TestRunConfigNotifyDoesNotCopyProjectNotifyIntoUserConfig(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "user.json")
|
||||
projectPath := filepath.Join(t.TempDir(), "project.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"activeProvider": "openai"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(projectPath, []byte(`{"notify": {"mode": "off"}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
// Resolve EXACTLY the way production does: user config + project config
|
||||
// merged, so resolved.Notify.Mode is the project's "off".
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{
|
||||
UserConfigPath: configPath,
|
||||
ProjectConfigPath: projectPath,
|
||||
Env: map[string]string{"OPENAI_API_KEY": "sk-test"},
|
||||
})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--focus", "always"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
// The user's file must keep an unspecified mode unspecified — the
|
||||
// project's "off" stays in the project file where it belongs.
|
||||
if cfg.Notify.Mode != "" {
|
||||
t.Errorf("user Notify.Mode = %q, want blank (project's off must not leak into the user file)", cfg.Notify.Mode)
|
||||
}
|
||||
if cfg.Notify.FocusMode != "always" {
|
||||
t.Errorf("Notify.FocusMode = %q, want always", cfg.Notify.FocusMode)
|
||||
}
|
||||
// The project file is untouched.
|
||||
project := readFileConfig(t, projectPath)
|
||||
if project.Notify.Mode != "off" {
|
||||
t.Errorf("project Notify.Mode = %q, want untouched off", project.Notify.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainer regression (PR #1001): with a clean config, `--mode off` must not
|
||||
// also pin focusMode as an explicit choice — blank means "use the built-in
|
||||
// default", and a partial update keeps it that way.
|
||||
func TestRunConfigNotifyDoesNotPinDefaultsAsExplicitChoices(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"activeProvider": "openai"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--mode", "off"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "off" {
|
||||
t.Errorf("Notify.Mode = %q, want off", cfg.Notify.Mode)
|
||||
}
|
||||
if cfg.Notify.FocusMode != "" {
|
||||
t.Errorf("Notify.FocusMode = %q, want blank (unspecified stays unspecified; the default must not be pinned)", cfg.Notify.FocusMode)
|
||||
}
|
||||
}
|
||||
|
||||
// `--mode` and `--focus` together update both fields in one call.
|
||||
func TestRunConfigNotifyWritesModeAndFocus(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}]
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--mode", "both", "--focus", "unfocused"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "both" || cfg.Notify.FocusMode != "unfocused" {
|
||||
t.Errorf("Notify = %+v, want mode=both focusMode=unfocused", cfg.Notify)
|
||||
}
|
||||
}
|
||||
|
||||
// `--mode loud` is a usage error. The config must not be mutated.
|
||||
func TestRunConfigNotifyRejectsInvalidMode(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}],
|
||||
"notify": {"mode": "off"}
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--mode", "loud"}, &stdout, &stderr, deps)
|
||||
if exitCode == exitSuccess {
|
||||
t.Fatalf("expected failure for invalid mode, got success; stdout=%s", stdout.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "off" {
|
||||
t.Errorf("Notify.Mode = %q, want off (unchanged after failed write)", cfg.Notify.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
// `--reset` blanks both fields so the TUI's effective default applies again
|
||||
// (an unconfigured headless run stays silent). Useful for "go back to the
|
||||
// recommended setup" after a custom value.
|
||||
func TestRunConfigNotifyResetClearsStoredValues(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}],
|
||||
"notify": {"mode": "off", "focusMode": "always"}
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--reset"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "" || cfg.Notify.FocusMode != "" {
|
||||
t.Errorf("Notify after reset = %+v, want empty (defaults apply)", cfg.Notify)
|
||||
}
|
||||
}
|
||||
|
||||
// CodeRabbit regression (PR #1001): the command manages a user preference and
|
||||
// must not require provider resolution. A brand-new user with NO provider
|
||||
// configured (the resolver would return ErrNoActiveProvider) can still read,
|
||||
// set, and reset their notification preference.
|
||||
func TestRunConfigNotifyWorksWithoutAnyProviderConfigured(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// resolveConfig fails the way the real resolver does for a fresh user —
|
||||
// the command must never call it, so a panic-free stub is enough to prove
|
||||
// the point; use the failing resolver to catch any regression to the
|
||||
// resolve-first shape.
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{}})
|
||||
}
|
||||
|
||||
// Read works and shows defaults.
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("read: exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "mode: (default)") {
|
||||
t.Errorf("read should show (default), got: %s", stdout.String())
|
||||
}
|
||||
|
||||
// Write works.
|
||||
stdout.Reset()
|
||||
exitCode = runWithDeps([]string{"config", "notify", "--mode", "bell", "--focus", "always"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("write: exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "bell" || cfg.Notify.FocusMode != "always" {
|
||||
t.Fatalf("write: Notify = %+v, want bell/always", cfg.Notify)
|
||||
}
|
||||
|
||||
// JSON read reflects the stored pair.
|
||||
stdout.Reset()
|
||||
exitCode = runWithDeps([]string{"config", "notify", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("json: exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json output invalid: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["mode"] != "bell" || payload["focusMode"] != "always" {
|
||||
t.Errorf("json = mode %v focus %v, want bell/always", payload["mode"], payload["focusMode"])
|
||||
}
|
||||
|
||||
// Reset works.
|
||||
stdout.Reset()
|
||||
exitCode = runWithDeps([]string{"config", "notify", "--reset"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("reset: exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg = readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "" || cfg.Notify.FocusMode != "" {
|
||||
t.Errorf("reset: Notify = %+v, want empty", cfg.Notify)
|
||||
}
|
||||
}
|
||||
|
||||
// `zero config` (no subcommand) still works after the dispatch change.
|
||||
func TestRunConfigSummaryStillWorks(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config"}, &stdout, &stderr, commandCenterDeps(t))
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Config") {
|
||||
t.Errorf("stdout should show the config summary, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,10 @@ package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -39,3 +43,115 @@ func TestFireJobClaimPreventsDoubleFire(t *testing.T) {
|
||||
t.Fatalf("NextRunAt must advance past now, got %s", d.NextRunAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFireJobOverlappingAdjacentSlots(t *testing.T) {
|
||||
for _, olderFirst := range []bool{false, true} {
|
||||
name := "newer completes first"
|
||||
if olderFirst {
|
||||
name = "older completes first"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
stores := []*cron.Store{
|
||||
cron.NewStore(cron.StoreOptions{RootDir: root}),
|
||||
cron.NewStore(cron.StoreOptions{RootDir: root}),
|
||||
}
|
||||
now := time.Date(2026, 6, 9, 9, 0, 0, 0, time.UTC)
|
||||
job, err := stores[0].Add(cron.Job{Expr: "* * * * *", Prompt: "fire me", NextRunAt: now, FireCount: 7})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var release, done [2]chan struct{}
|
||||
for i := range stores {
|
||||
release[i], done[i] = make(chan struct{}), make(chan struct{})
|
||||
// Always release blocked runners, including on assertion failures.
|
||||
defer func() {
|
||||
select {
|
||||
case <-release[i]:
|
||||
default:
|
||||
close(release[i])
|
||||
}
|
||||
<-done[i]
|
||||
}()
|
||||
started := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done[i])
|
||||
fireJob(stores[i], func() time.Time { return now.Add(time.Duration(i) * time.Minute) }, job, io.Discard, io.Discard,
|
||||
func(_ []string, _, _ io.Writer) int {
|
||||
close(started)
|
||||
<-release[i]
|
||||
return i // Include a failed execution in the accounting.
|
||||
})
|
||||
}()
|
||||
select {
|
||||
case <-started:
|
||||
case <-done[i]:
|
||||
t.Fatal("runner did not claim its slot")
|
||||
}
|
||||
}
|
||||
order := []int{1, 0}
|
||||
if olderFirst {
|
||||
order = []int{0, 1}
|
||||
}
|
||||
for n, i := range order {
|
||||
close(release[i])
|
||||
<-done[i]
|
||||
current, err := stores[0].Get(job.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if current.FireCount != 8+n || !current.NextRunAt.Equal(now.Add(2*time.Minute)) {
|
||||
t.Fatalf("after completion %d: FireCount=%d NextRunAt=%s; want count %d and next %s", n+1, current.FireCount, current.NextRunAt, 8+n, now.Add(2*time.Minute))
|
||||
}
|
||||
claimed, err := claimFire(stores[1], now.Add(time.Minute), job.ID)
|
||||
if err != nil || claimed {
|
||||
t.Fatalf("already claimed slot became available: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
}
|
||||
runs, err := stores[0].Runs(job.ID, 0)
|
||||
if err != nil || len(runs) != 2 {
|
||||
t.Fatalf("runs=%v err=%v", runs, err)
|
||||
}
|
||||
for n, i := range order {
|
||||
if !runs[n].At.Equal(now.Add(time.Duration(i)*time.Minute)) || runs[n].ExitCode != i {
|
||||
t.Fatalf("unexpected run record: %+v", runs[n])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFireJobCompletionReadErrorDoesNotOverwriteState(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store := cron.NewStore(cron.StoreOptions{RootDir: root})
|
||||
now := time.Date(2026, 6, 9, 9, 0, 0, 0, time.UTC)
|
||||
job, err := store.Add(cron.Job{Expr: "* * * * *", Prompt: "fire me", NextRunAt: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metadata := filepath.Join(root, job.ID, "metadata.json")
|
||||
var stdout, stderr bytes.Buffer
|
||||
fireJob(store, func() time.Time { return now }, job, &stdout, &stderr,
|
||||
func(_ []string, _, _ io.Writer) int {
|
||||
// A corrupt read must not cause completion to replace unknown newer state
|
||||
// with the stale pre-execution job. This works without permission tricks.
|
||||
if err := os.WriteFile(metadata, []byte("unreadable job"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return 0
|
||||
})
|
||||
data, err := os.ReadFile(metadata)
|
||||
if err != nil || string(data) != "unreadable job" {
|
||||
t.Fatalf("completion overwrote unreadable state: %q, err=%v", data, err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "failed to persist job state") {
|
||||
t.Fatalf("missing persistence warning: %s", stderr.String())
|
||||
}
|
||||
if want := "fired " + job.ID + " -> exit 0 (next: unknown)\n"; stdout.String() != want {
|
||||
t.Fatalf("completion summary = %q; want %q", stdout.String(), want)
|
||||
}
|
||||
runs, err := store.Runs(job.ID, 0)
|
||||
if err != nil || len(runs) != 1 || runs[0].ExitCode != 0 {
|
||||
t.Fatalf("execution should still be recorded: runs=%v err=%v", runs, err)
|
||||
}
|
||||
}
|
||||
@@ -129,17 +129,15 @@ func reconcileOverdue(store *cron.Store, now func() time.Time, ids []string, std
|
||||
}
|
||||
}
|
||||
|
||||
// fireJob runs one job via the exec runner, records the outcome, advances the
|
||||
// schedule, and persists. The foreground loop is single-goroutine, so the
|
||||
// previous fire has already returned before the next tick — no overlap.
|
||||
// fireJob claims one job, runs it via the exec runner, and records the outcome.
|
||||
// Separate scheduler processes can have adjacent slots executing concurrently.
|
||||
func fireJob(store *cron.Store, now func() time.Time, job cron.Job, stdout io.Writer, stderr io.Writer, exec execRunner) {
|
||||
fired := now()
|
||||
// Atomically claim this fire before running it so concurrent schedulers (or
|
||||
// --once overlapping the forever loop) can't both execute the same due job: the
|
||||
// winner advances NextRunAt under the per-job lock, and a loser sees the
|
||||
// advanced time and skips. The post-exec advance recomputes the same
|
||||
// NextRunAt (sched.Next(fired) is deterministic), so the claim serializes the
|
||||
// fire decision without changing any persisted schedule state (M9).
|
||||
// advanced time and skips. Only the claim writes schedule state; completion
|
||||
// must not overwrite a later slot's claim with this run's stale job copy.
|
||||
claimed, claimErr := claimFire(store, fired, job.ID)
|
||||
if claimErr != nil {
|
||||
if errors.Is(claimErr, cron.ErrJobNotFound) {
|
||||
@@ -178,44 +176,25 @@ func fireJob(store *cron.Store, now func() time.Time, job cron.Job, stdout io.Wr
|
||||
rec.Error = cronTruncate(detail, 500)
|
||||
}
|
||||
|
||||
job.FireCount++
|
||||
// Advance the schedule. If the expression can no longer produce a future run
|
||||
// (became invalid, or is an impossible spec whose Next is zero), pause the job
|
||||
// so it cannot re-fire on every tick.
|
||||
// Explain schedules that claimFire paused because they cannot advance.
|
||||
if sched, perr := cron.Parse(job.Expr); perr != nil {
|
||||
job.Status = cron.StatusPaused
|
||||
if rec.Error == "" {
|
||||
rec.Error = "invalid schedule; job paused: " + perr.Error()
|
||||
}
|
||||
} else if nxt := sched.Next(fired.Truncate(time.Minute)); nxt.IsZero() {
|
||||
job.Status = cron.StatusPaused
|
||||
if rec.Error == "" {
|
||||
rec.Error = "schedule no longer fires; job paused"
|
||||
}
|
||||
} else {
|
||||
// Minute-aligned input keeps this advance identical to claimFire's and lets
|
||||
// the DST fall-back collapse guard engage (AUDIT-M4).
|
||||
job.NextRunAt = nxt
|
||||
}
|
||||
// Re-read and persist the advanced state ATOMICALLY under the per-job lock. The
|
||||
// job may have been paused or removed while it executed, and this in-memory copy
|
||||
// is stale from tick start. Mutate closes the read-modify-write window a single
|
||||
// scheduler alone could only narrow (Store.Mutate took a cross-process lock): a
|
||||
// concurrent scheduler or an external pause/remove landing here can no longer be
|
||||
// clobbered.
|
||||
// Count this completion against current state under the cross-process lock.
|
||||
// The claim already advanced or paused the schedule. Leave that state alone:
|
||||
// another scheduler may have claimed a later slot or a user may have edited it.
|
||||
persisted, err := store.Mutate(job.ID, func(current cron.Job, readErr error) (cron.Job, error) {
|
||||
if readErr != nil {
|
||||
// A transient read failure (IO/permission) is NOT removal — persist the
|
||||
// computed next state anyway so the schedule advances and the job does not
|
||||
// re-fire next tick.
|
||||
fmt.Fprintf(stderr, "warning: could not re-read job %s before persist: %v\n", job.ID, readErr)
|
||||
return job, nil
|
||||
}
|
||||
current.FireCount = job.FireCount
|
||||
current.NextRunAt = job.NextRunAt
|
||||
if current.Status != cron.StatusPaused {
|
||||
current.Status = job.Status
|
||||
// Never replace unknown newer state with the pre-execution snapshot.
|
||||
return current, readErr
|
||||
}
|
||||
current.FireCount++
|
||||
return current, nil
|
||||
})
|
||||
if errors.Is(err, cron.ErrJobNotFound) {
|
||||
@@ -229,19 +208,21 @@ func fireJob(store *cron.Store, now func() time.Time, job cron.Job, stdout io.Wr
|
||||
if aerr := store.AppendRun(job.ID, rec); aerr != nil {
|
||||
fmt.Fprintf(stderr, "warning: failed to record run for %s: %v\n", job.ID, aerr)
|
||||
}
|
||||
next := "unknown"
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "warning: failed to persist job state for %s: %v\n", job.ID, err)
|
||||
persisted = job
|
||||
} else {
|
||||
next = formatCronTime(persisted.NextRunAt)
|
||||
}
|
||||
fmt.Fprintf(stdout, "fired %s -> exit %d (next: %s)\n", job.ID, code, formatCronTime(persisted.NextRunAt))
|
||||
fmt.Fprintf(stdout, "fired %s -> exit %d (next: %s)\n", job.ID, code, next)
|
||||
}
|
||||
|
||||
// claimFire atomically claims a due job's fire under the per-job lock and reports
|
||||
// whether THIS caller should run it. It returns false only when the job was paused
|
||||
// externally or another scheduler already advanced NextRunAt past `fired` (so a
|
||||
// valid recurring job never double-fires). A valid due job has its NextRunAt
|
||||
// advanced here to claim the slot; an invalid or exhausted schedule is left for the
|
||||
// caller to fire-once and pause (preserving existing single-scheduler behavior).
|
||||
// advanced here to claim the slot; an invalid or exhausted schedule is paused
|
||||
// here and the caller still fires once (preserving single-scheduler behavior).
|
||||
func claimFire(store *cron.Store, fired time.Time, id string) (bool, error) {
|
||||
claimed := true
|
||||
_, err := store.Mutate(id, func(current cron.Job, readErr error) (cron.Job, error) {
|
||||
@@ -268,8 +249,8 @@ func claimFire(store *cron.Store, fired time.Time, id string) (bool, error) {
|
||||
}
|
||||
// Unparseable or unadvanceable schedule (an impossible spec whose Next is
|
||||
// zero): pause the job inside this same locked claim so a concurrent scheduler
|
||||
// sees a non-active job and does NOT also fire it. The winner still fires once
|
||||
// and fireJob's post-exec keeps it paused. Without this, both callers leave
|
||||
// sees a non-active job and does NOT also fire it. The winner still fires once.
|
||||
// Without this, both callers leave
|
||||
// NextRunAt unchanged, both see the job due, and both fire the same slot
|
||||
// (AUDIT-M5).
|
||||
current.Status = cron.StatusPaused
|
||||
|
||||
@@ -53,7 +53,7 @@ func TestCronRunOnceFiresDueJobs(t *testing.T) {
|
||||
if d.FireCount != 1 || !d.NextRunAt.After(now) {
|
||||
t.Fatalf("due job not advanced: %+v", d)
|
||||
}
|
||||
runs, _ := store.Runs(due.ID)
|
||||
runs, _ := store.Runs(due.ID, 0)
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("expected 1 run record, got %d", len(runs))
|
||||
}
|
||||
@@ -146,7 +146,7 @@ func TestCronRunPausesUnadvanceableJob(t *testing.T) {
|
||||
if d.Status != cron.StatusPaused {
|
||||
t.Fatalf("unadvanceable job must be paused, got status=%q", d.Status)
|
||||
}
|
||||
runs, _ := store.Runs(job.ID)
|
||||
runs, _ := store.Runs(job.ID, 0)
|
||||
if len(runs) != 1 || runs[0].Error == "" {
|
||||
t.Fatalf("expected one run record with an error, got %+v", runs)
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
// Refresh the models.dev pricing/limits cache in the background when stale;
|
||||
// the overlay is read at registry construction from the cache file, so this
|
||||
// benefits the next run and never blocks or fails this one.
|
||||
go func() { _ = modelregistry.RefreshModelsDevCache(context.Background()) }()
|
||||
modelregistry.StartModelsDevRefresh()
|
||||
|
||||
// A mode seeds model/effort/max-turns/tool filters as a preset. Expand it up
|
||||
// front — before tool-filter validation and the --list-tools branch — so a
|
||||
@@ -347,6 +347,17 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
} else if notice != "" {
|
||||
_, _ = fmt.Fprintln(stderr, "[zero] "+notice)
|
||||
}
|
||||
// Before any tool runs, and before the MCP servers and plugins below start
|
||||
// through the same sandbox, so a run that goes ahead with reduced isolation
|
||||
// says so where its output starts, not only when someone runs `zero doctor`.
|
||||
// A notice that cannot be written stops the run, as the image and effort
|
||||
// notices further down do: going ahead would mean reduced isolation and no
|
||||
// warning delivered.
|
||||
if notice := sandboxEngine.DegradedNotice(); notice != "" {
|
||||
if _, err := fmt.Fprintln(stderr, "[zero] "+notice); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
executionRunner.SetPreparer(sandboxEngine)
|
||||
if permissionMode != agent.PermissionModePlan {
|
||||
mcpRuntime, mcpSkip, err = registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options), trustRoot, executionRunner)
|
||||
@@ -452,20 +463,20 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
// escalation so post-switch turns are attributed to the escalated model.
|
||||
currentModel := resolved.Provider.Model
|
||||
// Optimized OpenAI turn sessions (ZERO_OPENAI_TURN_SESSION, default off). nil
|
||||
// when gated off or the profile is ineligible: agent.Run then wraps the
|
||||
// provider in its default adapter. This is the run's STARTING session provider
|
||||
// and is used whether or not escalation is enabled.
|
||||
turnSessions, _ := providers.OptimizedTurnSessions(resolved.Provider, provider, providers.Options{})
|
||||
// when gated off or ineligible, unless a configured RPM limiter needs to
|
||||
// wrap the default adapter. The limiter is shared across model switches.
|
||||
sessionOptions := providers.Options{ModelRPM: zeroruntime.NewModelRPMLimiter(resolved.ModelRPM)}
|
||||
turnSessions, _ := providers.ConfiguredTurnSessions(resolved.Provider, provider, sessionOptions)
|
||||
// Both switchers come from one shared builder so exec and the interactive TUI
|
||||
// cannot drift on the nil contracts the agent loop depends on. The session
|
||||
// switcher is nil unless this run STARTED optimized, which is what keeps a
|
||||
// default-adapter run on the default adapter.
|
||||
// switcher preserves the starting transport and shares the RPM limiter.
|
||||
var modelSwitcher func(context.Context, string) (agent.Provider, error)
|
||||
var modelSessionSwitcher func(context.Context, string) (zeroruntime.TurnSessionProvider, error)
|
||||
if options.allowEscalation {
|
||||
modelSwitcher, modelSessionSwitcher = providers.EscalationSwitchers(
|
||||
resolved.Provider, provider, deps.newProvider,
|
||||
func(modelID string) { currentModel = modelID },
|
||||
sessionOptions,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -660,6 +671,9 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
ContextWindowFor: func(modelID string) int {
|
||||
return modelregistry.AgentContextWindow(modelContextWindow(modelRegistry, modelID))
|
||||
},
|
||||
SupportsVision: func(modelID string) bool {
|
||||
return modelregistry.SupportsVision(modelRegistry, modelID)
|
||||
},
|
||||
ReasoningEffort: forwardEffort,
|
||||
Trace: traceRecorder,
|
||||
Cwd: workspaceRoot,
|
||||
|
||||
@@ -70,8 +70,8 @@ func TestRunExecListsFilteredToolsWithoutPromptOrProvider(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
if stderr.String() != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String())
|
||||
}
|
||||
if providerBuilt {
|
||||
t.Fatal("provider should not be constructed for --list-tools")
|
||||
@@ -108,8 +108,8 @@ func TestRunExecListsToolsAsStreamJSONWhenRequested(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
if stderr.String() != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String())
|
||||
}
|
||||
if providerBuilt {
|
||||
t.Fatal("provider should not be constructed for --list-tools")
|
||||
@@ -176,8 +176,8 @@ func TestRunExecListsMCPToolsWithoutProviderConstruction(t *testing.T) {
|
||||
if !closed {
|
||||
t.Fatal("MCP runtime was not closed after --list-tools")
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
if stderr.String() != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "mcp_docs_lookup") || !strings.Contains(stdout.String(), "Lookup documentation") {
|
||||
t.Fatalf("expected MCP tool in list output, got %q", stdout.String())
|
||||
@@ -323,8 +323,8 @@ func TestRunExecStreamJSONOutputsRunEndAndRecordsSession(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
if stderr.String() != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String())
|
||||
}
|
||||
|
||||
events := decodeJSONLines(t, stdout.String())
|
||||
@@ -388,8 +388,8 @@ func TestRunExecStreamJSONEmitsAndRecordsPermissionEvents(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
if stderr.String() != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String())
|
||||
}
|
||||
|
||||
events := decodeJSONLines(t, stdout.String())
|
||||
@@ -460,7 +460,7 @@ func TestRunExecStreamJSONEmitsAndRecordsPermissionEvents(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("approved exec exit = %d, stderr = %q", exitCode, stderr.String())
|
||||
}
|
||||
if stderr.String() != "" {
|
||||
if stderr.String() != hostSandboxNotice(t) {
|
||||
t.Fatalf("approved exec wrote stderr = %q", stderr.String())
|
||||
}
|
||||
approvedEvents := decodeJSONLines(t, stdout.String())
|
||||
|
||||
@@ -118,12 +118,15 @@ func runExecSpecDraft(run execSpecDraftRun) int {
|
||||
hookDispatcher, hookSkip := newHookDispatcher(run.workspaceRoot, run.trustRoot, execution.NewRunner(run.sandboxEngine))
|
||||
emitTrustNotice(run.stderr, hookSkip, run.mcpSkip)
|
||||
result, err := agent.Run(runCtx, run.prompt, run.provider, agent.Options{
|
||||
MaxTurns: run.resolved.MaxTurns,
|
||||
ContextWindow: resolveAgentContextWindow(runCtx, run.modelRegistry, run.resolved.Provider),
|
||||
SessionID: draftSession.SessionID,
|
||||
SessionTitle: run.sessionTitle,
|
||||
ProviderName: run.resolved.Provider.Name,
|
||||
Model: run.resolved.Provider.Model,
|
||||
MaxTurns: run.resolved.MaxTurns,
|
||||
ContextWindow: resolveAgentContextWindow(runCtx, run.modelRegistry, run.resolved.Provider),
|
||||
SessionID: draftSession.SessionID,
|
||||
SessionTitle: run.sessionTitle,
|
||||
ProviderName: run.resolved.Provider.Name,
|
||||
Model: run.resolved.Provider.Model,
|
||||
SupportsVision: func(modelID string) bool {
|
||||
return modelregistry.SupportsVision(run.modelRegistry, modelID)
|
||||
},
|
||||
ReasoningEffort: run.reasoningEffort,
|
||||
Profile: run.profilePolicy,
|
||||
Cwd: run.workspaceRoot,
|
||||
|
||||
@@ -568,8 +568,8 @@ func TestRunExecAcceptsLegacyModelProfileFlags(t *testing.T) {
|
||||
if !strings.Contains(stdout, "hello") {
|
||||
t.Fatalf("expected prompt output, got %q", stdout)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr)
|
||||
if stderr != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -696,8 +696,8 @@ func TestRunExecAssemblesInlineAndFilePromptRelativeToCwd(t *testing.T) {
|
||||
if !strings.Contains(stdout, "inline prompt\n\nfile prompt") {
|
||||
t.Fatalf("expected inline and file prompt joined by blank line, got %q", stdout)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr)
|
||||
if stderr != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -715,8 +715,8 @@ func TestRunExecAcceptsFileOnlyPrompt(t *testing.T) {
|
||||
if !strings.Contains(stdout, "file only prompt") {
|
||||
t.Fatalf("expected file prompt output, got %q", stdout)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr)
|
||||
if stderr != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -798,8 +798,8 @@ func TestRunExecJSONOutputsNDJSONEvents(t *testing.T) {
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr)
|
||||
if stderr != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr)
|
||||
}
|
||||
|
||||
events := decodeJSONLines(t, stdout)
|
||||
@@ -833,8 +833,8 @@ func TestRunExecResolvesCanonicalModelAlias(t *testing.T) {
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr for active model, got %q", stderr)
|
||||
if stderr != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice for active model, got %q", stderr)
|
||||
}
|
||||
events := decodeJSONLines(t, stdout)
|
||||
if got := events[0]["model"]; got != "gpt-4.1" {
|
||||
@@ -926,8 +926,8 @@ func TestRunExecJSONUnsafeOutputsWarningEvent(t *testing.T) {
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr)
|
||||
if stderr != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr)
|
||||
}
|
||||
|
||||
events := decodeJSONLines(t, stdout)
|
||||
@@ -970,10 +970,10 @@ func TestRunExecUsesProjectConfigAndOpenAICompatibleProvider(t *testing.T) {
|
||||
"name": "local",
|
||||
"provider_kind": "openai-compatible",
|
||||
"base_url": "` + server.URL + `",
|
||||
"api_key": "sk-local",
|
||||
"model": "local-model"
|
||||
}]
|
||||
}`
|
||||
"api_key": "sk-local",
|
||||
"model": "local-model"
|
||||
}]
|
||||
}`
|
||||
if err := os.WriteFile(filepath.Join(configDir, "config.json"), []byte(writeConfig), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -988,8 +988,8 @@ func TestRunExecUsesProjectConfigAndOpenAICompatibleProvider(t *testing.T) {
|
||||
if strings.TrimSpace(stdout.String()) != "provider ok" {
|
||||
t.Fatalf("stdout = %q, want provider response", stdout.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
if stderr.String() != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String())
|
||||
}
|
||||
if gotAuth != "Bearer sk-local" {
|
||||
t.Fatalf("Authorization = %q, want project config token", gotAuth)
|
||||
@@ -1204,8 +1204,8 @@ func TestRunExecAutoHighEmitsUnsafeWarning(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr)
|
||||
if stderr != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr)
|
||||
}
|
||||
|
||||
events := decodeJSONLines(t, stdout)
|
||||
|
||||
@@ -69,7 +69,7 @@ func TestRunExecListToolsHonorsDisabledLocalControlConfig(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
if stderr.String() != hostSandboxNotice(t) {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/tui"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type rpmExecProvider struct{ calls int }
|
||||
|
||||
func (p *rpmExecProvider) StreamCompletion(context.Context, zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
p.calls++
|
||||
ch := make(chan zeroruntime.StreamEvent, 4)
|
||||
if p.calls == 1 {
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "fixture", ToolName: "read_file"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "fixture", ArgumentsFragment: `{"path":"missing-fixture.txt"}`}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "fixture"}
|
||||
} else {
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "unexpected second completion"}
|
||||
}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
func rpmTestDeps(t *testing.T, p *rpmExecProvider) appDeps {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for _, k := range []string{"HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME"} {
|
||||
t.Setenv(k, root)
|
||||
}
|
||||
return appDeps{getwd: func() (string, error) { return root, nil }, resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
c := execResolvedConfig()
|
||||
c.ModelRPM = map[string]int{c.Provider.Model: 1}
|
||||
return c, nil
|
||||
}, newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { return p, nil }, registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
}}
|
||||
}
|
||||
func TestRunExecRPMRefusesSecondCompletion(t *testing.T) {
|
||||
p := &rpmExecProvider{}
|
||||
deps := rpmTestDeps(t, p)
|
||||
var out, err bytes.Buffer
|
||||
code := runWithDeps([]string{"exec", "fixture"}, &out, &err, deps)
|
||||
if code != exitProvider || p.calls != 1 || !strings.Contains(err.String(), "Local modelRPM cap reached") {
|
||||
t.Fatalf("exit=%d calls=%d stdout=%s stderr=%s", code, p.calls, out.String(), err.String())
|
||||
}
|
||||
}
|
||||
func TestInteractiveRPMFactorySharesWindow(t *testing.T) {
|
||||
p := &rpmExecProvider{}
|
||||
deps := rpmTestDeps(t, p)
|
||||
launched := false
|
||||
deps.runTUI = func(ctx context.Context, o tui.Options) int {
|
||||
launched = true
|
||||
if o.ModelRPM == nil {
|
||||
t.Fatal("TUI escalation lacks limiter")
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
ss := o.NewTurnSessionProvider(o.ProviderProfile, p)
|
||||
s, e := ss.OpenTurnSession(ctx)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
_, e = s.Stream(ctx, zeroruntime.CompletionRequest{})
|
||||
var hit *zeroruntime.RPMLimitError
|
||||
if (i == 0 && e != nil) || (i == 1 && !errors.As(e, &hit)) {
|
||||
t.Fatalf("run=%d err=%v", i, e)
|
||||
}
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
var out, err bytes.Buffer
|
||||
code := runWithDeps(nil, &out, &err, deps)
|
||||
if !launched || code != exitSuccess || p.calls != 1 {
|
||||
t.Fatalf("launched=%v exit=%d calls=%d stderr=%s", launched, code, p.calls, err.String())
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
|
||||
{id: "groq", name: "Groq", transport: "openai-compatible", defaultModel: "llama-3.3-70b-versatile"},
|
||||
{id: "longcat", name: "LongCat", transport: "openai-compatible", defaultModel: "LongCat-2.0"},
|
||||
{id: "fireworks", name: "Fireworks AI", transport: "openai-compatible", defaultModel: "accounts/fireworks/models/kimi-k2p7-code"},
|
||||
{id: "gmicloud", name: "GMI Cloud", transport: "openai-compatible", defaultModel: "Qwen/Qwen3.8-Max"},
|
||||
{id: "ollama-cloud", name: "Ollama Cloud", transport: "openai-compatible", defaultModel: "qwen3-coder:480b"},
|
||||
{id: "ollama", name: "Ollama Local", transport: "openai-compatible", defaultModel: "llama3.1"},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/tui"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// hostSandboxNotice is the degraded-sandbox line exec prints on THIS machine for
|
||||
// a run under the default sandbox policy, or "" where the sandbox is not
|
||||
// degraded. Exec tests that expect a quiet stderr compare against it rather than
|
||||
// against "": unless a test pins a backend, the run gets the host's own, and a
|
||||
// Linux runner without the sandbox helper is degraded where macOS and Windows
|
||||
// are not.
|
||||
func hostSandboxNotice(t *testing.T) string {
|
||||
t.Helper()
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: t.TempDir(),
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Backend: sandbox.SelectBackend(sandbox.BackendOptions{}),
|
||||
})
|
||||
notice := engine.DegradedNotice()
|
||||
if notice == "" {
|
||||
return ""
|
||||
}
|
||||
return "[zero] " + notice + "\n"
|
||||
}
|
||||
|
||||
// clearSandboxNestingMarkers keeps a test that pins the notice from inheriting
|
||||
// the nesting markers when the suite itself runs inside a Zero sandbox.
|
||||
func clearSandboxNestingMarkers(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv(sandbox.EnvSandboxed, "")
|
||||
t.Setenv(sandbox.EnvSandboxBackend, "")
|
||||
}
|
||||
|
||||
// unavailableTestSandbox is the backend a Linux box without the helper gets,
|
||||
// pinned so a test sees the degraded case on every platform.
|
||||
func unavailableTestSandbox(sandbox.BackendOptions) sandbox.Backend {
|
||||
return sandbox.Backend{Name: sandbox.BackendUnavailable, Platform: "linux", Fallback: true, Message: "Linux sandbox helper is not available"}
|
||||
}
|
||||
|
||||
const unavailableTestSandboxNotice = "[zero] Sandbox enforcement is degraded: Linux sandbox helper is not available. See `zero sandbox policy --effective`.\n"
|
||||
|
||||
func runExecWithSandbox(t *testing.T, args []string, backend func(sandbox.BackendOptions) sandbox.Backend, sandboxConfig config.SandboxConfig) (int, string, string) {
|
||||
t.Helper()
|
||||
isolateCLIUserState(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{
|
||||
ActiveProvider: "echo",
|
||||
Provider: config.ProviderProfile{
|
||||
Name: "echo",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "http://127.0.0.1/v1",
|
||||
Model: "echo-model",
|
||||
},
|
||||
MaxTurns: 3,
|
||||
Sandbox: sandboxConfig,
|
||||
}, nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return echoExecProvider{}, nil
|
||||
},
|
||||
selectSandboxBackend: backend,
|
||||
})
|
||||
return exitCode, stdout.String(), stderr.String()
|
||||
}
|
||||
|
||||
// A RUN THAT GOES AHEAD WITH REDUCED ISOLATION SAYS SO. Before this only `zero
|
||||
// doctor` and `zero sandbox policy` did, so `zero exec` ran every tool with the
|
||||
// native sandbox effectively off and printed nothing about it. #1041.
|
||||
func TestExecSaysOnStderrWhenTheSandboxIsDegraded(t *testing.T) {
|
||||
clearSandboxNestingMarkers(t)
|
||||
exitCode, _, stderr := runExecWithSandbox(t, []string{"exec", "hello"}, unavailableTestSandbox, config.SandboxConfig{})
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exit code = %d, stderr %q", exitCode, stderr)
|
||||
}
|
||||
if stderr != unavailableTestSandboxNotice {
|
||||
t.Fatalf("stderr = %q, want exactly the degraded notice %q", stderr, unavailableTestSandboxNotice)
|
||||
}
|
||||
}
|
||||
|
||||
// The notice goes to stderr in the JSON modes as well, so stdout stays one JSON
|
||||
// object per line for whatever reads it.
|
||||
func TestExecKeepsTheDegradedNoticeOffStdoutInJSONMode(t *testing.T) {
|
||||
clearSandboxNestingMarkers(t)
|
||||
exitCode, stdout, stderr := runExecWithSandbox(t, []string{"exec", "--output-format", "json", "hello"}, unavailableTestSandbox, config.SandboxConfig{})
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exit code = %d, stderr %q", exitCode, stderr)
|
||||
}
|
||||
if stderr != unavailableTestSandboxNotice {
|
||||
t.Fatalf("stderr = %q, want exactly the degraded notice", stderr)
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSpace(stdout), "\n") {
|
||||
var event map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
t.Fatalf("stdout carries a line that is not JSON: %q (%v)", line, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A sandbox the user turned off is their choice, not a degradation.
|
||||
func TestExecSaysNothingWhenTheSandboxIsTurnedOff(t *testing.T) {
|
||||
clearSandboxNestingMarkers(t)
|
||||
off := false
|
||||
exitCode, _, stderr := runExecWithSandbox(t, []string{"exec", "hello"}, unavailableTestSandbox, config.SandboxConfig{Enabled: &off})
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exit code = %d, stderr %q", exitCode, stderr)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("stderr = %q, want nothing for a disabled sandbox", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// countingExecProvider is echoExecProvider, counting the completions it streams.
|
||||
type countingExecProvider struct {
|
||||
echoExecProvider
|
||||
streamed *int
|
||||
}
|
||||
|
||||
func (provider countingExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
*provider.streamed++
|
||||
return provider.echoExecProvider.StreamCompletion(ctx, request)
|
||||
}
|
||||
|
||||
// A degraded notice that cannot be written stops the run before the model is
|
||||
// asked for anything, as the image and effort notices do, instead of going ahead
|
||||
// with reduced isolation and no warning delivered.
|
||||
func TestExecStopsWhenTheDegradedNoticeCannotBeWritten(t *testing.T) {
|
||||
clearSandboxNestingMarkers(t)
|
||||
isolateCLIUserState(t)
|
||||
var stdout bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
streamed := 0
|
||||
exitCode := runWithDeps([]string{"exec", "hello"}, &stdout, failingWriter{}, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{
|
||||
ActiveProvider: "echo",
|
||||
Provider: config.ProviderProfile{
|
||||
Name: "echo",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "http://127.0.0.1/v1",
|
||||
Model: "echo-model",
|
||||
},
|
||||
MaxTurns: 3,
|
||||
}, nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return countingExecProvider{streamed: &streamed}, nil
|
||||
},
|
||||
selectSandboxBackend: unavailableTestSandbox,
|
||||
})
|
||||
if exitCode != exitCrash {
|
||||
t.Fatalf("exit code = %d, want %d when the degraded notice cannot be written", exitCode, exitCrash)
|
||||
}
|
||||
if streamed != 0 {
|
||||
t.Fatalf("the model was asked %d times: the run went ahead without its warning", streamed)
|
||||
}
|
||||
}
|
||||
|
||||
// launchTUIWithSandbox runs the root command the way a bare `zero` does, with
|
||||
// the sandbox backend pinned to the unavailable one, and returns the options the
|
||||
// TUI would have started with.
|
||||
func launchTUIWithSandbox(t *testing.T, sandboxConfig config.SandboxConfig) tui.Options {
|
||||
t.Helper()
|
||||
isolateCLIUserState(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
var launched tui.Options
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{MaxTurns: 12, Sandbox: sandboxConfig}, nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
t.Fatal("newProvider should not be called without a resolved provider")
|
||||
return nil, nil
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
selectSandboxBackend: unavailableTestSandbox,
|
||||
runTUI: func(_ context.Context, options tui.Options) int {
|
||||
launched = options
|
||||
return 0
|
||||
},
|
||||
})
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exit code = %d, stderr %q", exitCode, stderr.String())
|
||||
}
|
||||
return launched
|
||||
}
|
||||
|
||||
// The TUI shows the same notice when the session opens, taken from the engine
|
||||
// its commands run through.
|
||||
func TestTUILaunchCarriesTheDegradedNotice(t *testing.T) {
|
||||
clearSandboxNestingMarkers(t)
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
sandbox config.SandboxConfig
|
||||
want bool
|
||||
}{
|
||||
{name: "degraded", want: true},
|
||||
{name: "turned off", sandbox: config.SandboxConfig{Enabled: func() *bool { off := false; return &off }()}},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
launched := launchTUIWithSandbox(t, testCase.sandbox)
|
||||
var notices []string
|
||||
for _, notice := range launched.StartupNotices {
|
||||
if strings.TrimSpace(notice) != "" {
|
||||
notices = append(notices, notice)
|
||||
}
|
||||
}
|
||||
want := strings.TrimSuffix(strings.TrimPrefix(unavailableTestSandboxNotice, "[zero] "), "\n")
|
||||
if testCase.want && (len(notices) != 1 || notices[0] != want) {
|
||||
t.Fatalf("StartupNotices = %q, want exactly %q", notices, want)
|
||||
}
|
||||
if !testCase.want && len(notices) != 0 {
|
||||
t.Fatalf("StartupNotices = %q, want none", notices)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/gitsafe"
|
||||
)
|
||||
|
||||
type scratchFileBaseline map[string]bool
|
||||
@@ -78,7 +80,13 @@ func gitUntrackedFiles(workspaceRoot string) ([]string, bool) {
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
output, err := exec.CommandContext(ctx, "git", "-C", workspaceRoot, "status", "--porcelain", "-z", "--untracked-files=all").Output()
|
||||
// gitsafe keeps the workspace's own git config from running a program, and
|
||||
// --ignore-submodules=dirty keeps git out of submodule work trees.
|
||||
command, err := gitsafe.Command(ctx, workspaceRoot, "status", "--porcelain", "-z", "--untracked-files=all", "--ignore-submodules=dirty")
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
output, err := command.Output()
|
||||
if err != nil {
|
||||
// Not a git repo (or git failed for some other reason) — nothing
|
||||
// reliable to report against, so stay silent rather than guess.
|
||||
|
||||
@@ -18,6 +18,8 @@ type sessionCommandOptions struct {
|
||||
excludeTarget bool
|
||||
preserveLast int
|
||||
maxPromptChars int
|
||||
olderThan string
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
func runSessions(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
@@ -72,6 +74,11 @@ func runSessions(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
return writeExecUsageError(stderr, "sessions compact-plan requires a session id")
|
||||
}
|
||||
return runSessionsCompactPlan(store, remaining[0], options, stdout, stderr)
|
||||
case "prune":
|
||||
if len(remaining) != 0 {
|
||||
return writeExecUsageError(stderr, "sessions prune does not accept positional arguments")
|
||||
}
|
||||
return runSessionsPrune(store, options, stdout, stderr, deps)
|
||||
default:
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unknown sessions command %q", command))
|
||||
}
|
||||
@@ -91,6 +98,8 @@ func parseSessionsArgs(args []string) (string, []string, sessionCommandOptions,
|
||||
options.json = true
|
||||
case "--exclude-target":
|
||||
options.excludeTarget = true
|
||||
case "--dry-run":
|
||||
options.dryRun = true
|
||||
case "--kind":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
@@ -168,6 +177,21 @@ func parseSessionsArgs(args []string) (string, []string, sessionCommandOptions,
|
||||
}
|
||||
options.preserveLast = preserveLast
|
||||
continue
|
||||
case arg == "--older-than":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return command, remaining, options, false, err
|
||||
}
|
||||
options.olderThan = value
|
||||
index = next
|
||||
continue
|
||||
case strings.HasPrefix(arg, "--older-than="):
|
||||
value, err := parseNonEmptySessionsFlag("--older-than", strings.TrimPrefix(arg, "--older-than="))
|
||||
if err != nil {
|
||||
return command, remaining, options, false, err
|
||||
}
|
||||
options.olderThan = value
|
||||
continue
|
||||
case arg == "--max-prompt-chars":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
@@ -225,7 +249,7 @@ func parseSessionKindFlag(value string) (sessions.SessionKind, error) {
|
||||
|
||||
func isSessionsCommand(command string) bool {
|
||||
switch command {
|
||||
case "list", "children", "lineage", "tree", "rewind-plan", "rewind", "compact-plan":
|
||||
case "list", "children", "lineage", "tree", "rewind-plan", "rewind", "compact-plan", "prune":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -244,6 +268,9 @@ func validateSessionCommandFlags(command string, options sessionCommandOptions)
|
||||
if hasCompactionFlag && command != "compact-plan" {
|
||||
return execUsageError{"--preserve-last and --max-prompt-chars are only valid for sessions compact-plan"}
|
||||
}
|
||||
if (options.olderThan != "" || options.dryRun) && command != "prune" {
|
||||
return execUsageError{"--older-than and --dry-run are only valid for sessions prune"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -532,6 +559,7 @@ Commands:
|
||||
rewind-plan <id> Preview events kept and dropped by a rewind
|
||||
rewind <id> Restore workspace files and truncate the log to a checkpoint
|
||||
compact-plan <id> Preview events compacted and preserved by compaction
|
||||
prune Remove sessions last updated before a cutoff
|
||||
|
||||
Flags:
|
||||
--json Print JSON output
|
||||
@@ -541,7 +569,13 @@ Flags:
|
||||
--exclude-target Drop the target event (rewind-plan, rewind)
|
||||
--preserve-last <n> Keep recent events in compact-plan
|
||||
--max-prompt-chars <n> Limit compact-plan summary prompt
|
||||
--older-than <age> Prune cutoff, as days (30d) or a duration (720h); at least 1d.
|
||||
Without it, prune uses sessions.retentionDays from your user config.
|
||||
--dry-run List what prune would remove, and why it keeps the rest
|
||||
-h, --help Show this help
|
||||
|
||||
prune only runs when you run it. It never removes a session another Zero process
|
||||
has open, or an ancestor of a session it keeps, and --dry-run shows why.
|
||||
`)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
// maxPruneDays keeps a day count from overflowing time.Duration.
|
||||
const maxPruneDays = 36500
|
||||
|
||||
// runSessionsPrune removes sessions last updated before a cutoff, or with
|
||||
// --dry-run lists what would go and why. It runs only when asked (#971).
|
||||
func runSessionsPrune(store *sessions.Store, options sessionCommandOptions, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
olderThan, err := pruneCutoff(options, deps)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
report, err := store.Prune(sessions.PruneOptions{OlderThan: olderThan, DryRun: options.dryRun})
|
||||
if err != nil {
|
||||
return writeSessionCommandError(stderr, err)
|
||||
}
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, redaction.RedactValue(report, redaction.Options{})); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
} else if _, err := fmt.Fprint(stdout, formatPruneReport(report)); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if len(report.Failed) > 0 {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// pruneCutoff is --older-than, or else sessions.retentionDays from the user's
|
||||
// own config, and never below sessions.MinimumPruneAge. Project config has no
|
||||
// say in it: see config.SessionsConfig.
|
||||
func pruneCutoff(options sessionCommandOptions, deps appDeps) (time.Duration, error) {
|
||||
var age time.Duration
|
||||
if options.olderThan != "" {
|
||||
parsed, err := parsePruneAge(options.olderThan)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
age = parsed
|
||||
} else {
|
||||
days := 0
|
||||
if deps.userConfigPath != nil {
|
||||
path, err := deps.userConfigPath()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("sessions prune could not find your config: %w", err)
|
||||
}
|
||||
settings, err := config.ReadSessionsConfig(path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("sessions prune could not read sessions.retentionDays: %w", err)
|
||||
}
|
||||
days = settings.RetentionDays
|
||||
}
|
||||
if days == 0 {
|
||||
return 0, execUsageError{"sessions prune needs a cutoff: pass --older-than (for example --older-than 30d) or set sessions.retentionDays in your user config"}
|
||||
}
|
||||
if days > maxPruneDays {
|
||||
return 0, execUsageError{fmt.Sprintf("sessions.retentionDays %d is more than %d", days, maxPruneDays)}
|
||||
}
|
||||
age = time.Duration(days) * 24 * time.Hour
|
||||
}
|
||||
if age < sessions.MinimumPruneAge {
|
||||
return 0, execUsageError{fmt.Sprintf("sessions prune does not remove anything updated in the last %d hours; use --older-than 1d or more", int(sessions.MinimumPruneAge.Hours()))}
|
||||
}
|
||||
return age, nil
|
||||
}
|
||||
|
||||
// parsePruneAge accepts a whole number of days ("30d") or a Go duration
|
||||
// ("720h").
|
||||
func parsePruneAge(value string) (time.Duration, error) {
|
||||
invalid := execUsageError{fmt.Sprintf("invalid --older-than %q: expected days like 30d or a duration like 720h", value)}
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if days, ok := strings.CutSuffix(trimmed, "d"); ok {
|
||||
count, err := strconv.Atoi(days)
|
||||
if err != nil || count <= 0 || count > maxPruneDays {
|
||||
return 0, invalid
|
||||
}
|
||||
return time.Duration(count) * 24 * time.Hour, nil
|
||||
}
|
||||
age, err := time.ParseDuration(trimmed)
|
||||
if err != nil || age <= 0 {
|
||||
return 0, invalid
|
||||
}
|
||||
return age, nil
|
||||
}
|
||||
|
||||
func formatPruneReport(report sessions.PruneReport) string {
|
||||
var out strings.Builder
|
||||
verb := "Removed"
|
||||
if report.DryRun {
|
||||
verb = "Would remove"
|
||||
}
|
||||
if len(report.Removed) == 0 {
|
||||
fmt.Fprintf(&out, "No sessions to remove: nothing last updated before %s can go.\n", report.Cutoff)
|
||||
} else {
|
||||
var total int64
|
||||
for _, entry := range report.Removed {
|
||||
total += entry.Bytes
|
||||
}
|
||||
fmt.Fprintf(&out, "%s %d %s last updated before %s (%s):\n", verb, len(report.Removed), plural(len(report.Removed), "session", "sessions"), report.Cutoff, formatPruneBytes(total))
|
||||
for _, entry := range report.Removed {
|
||||
fmt.Fprintf(&out, " %s\n", formatPruneEntry(entry, redact(entry.Title)))
|
||||
}
|
||||
}
|
||||
if len(report.Kept) > 0 {
|
||||
fmt.Fprintf(&out, "Kept %d old enough to remove:\n", len(report.Kept))
|
||||
for _, entry := range report.Kept {
|
||||
fmt.Fprintf(&out, " %s\n", formatPruneEntry(entry, entry.Reason))
|
||||
}
|
||||
}
|
||||
if len(report.Failed) > 0 {
|
||||
failed := "Could not remove"
|
||||
if report.DryRun {
|
||||
failed = "Could not check"
|
||||
}
|
||||
fmt.Fprintf(&out, "%s %d:\n", failed, len(report.Failed))
|
||||
for _, entry := range report.Failed {
|
||||
fmt.Fprintf(&out, " %s\n", formatPruneEntry(entry, redact(entry.Reason)))
|
||||
}
|
||||
}
|
||||
if report.DryRun {
|
||||
out.WriteString("Dry run: nothing was removed.\n")
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func formatPruneEntry(entry sessions.PruneEntry, note string) string {
|
||||
updated := entry.UpdatedAt
|
||||
if len(updated) >= len("2006-01-02") {
|
||||
updated = updated[:len("2006-01-02")]
|
||||
}
|
||||
line := redact(entry.SessionID) + " " + updated
|
||||
if note = strings.TrimSpace(note); note != "" {
|
||||
line += " " + note
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func formatPruneBytes(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
value, suffix := float64(bytes)/unit, "KB"
|
||||
for _, next := range []string{"MB", "GB", "TB"} {
|
||||
if value < unit {
|
||||
break
|
||||
}
|
||||
value, suffix = value/unit, next
|
||||
}
|
||||
return fmt.Sprintf("%.1f %s", value, suffix)
|
||||
}
|
||||
|
||||
func plural(count int, one, many string) string {
|
||||
if count == 1 {
|
||||
return one
|
||||
}
|
||||
return many
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
// pruneCLIFixture is a session root holding one session last written in 2020
|
||||
// and one written now, neither held open, plus deps whose user config path is
|
||||
// the returned file (which does not exist until a test writes it).
|
||||
func pruneCLIFixture(t *testing.T) (string, string, appDeps) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for _, session := range []struct {
|
||||
id string
|
||||
at func() time.Time
|
||||
}{
|
||||
{"old-session", func() time.Time { return time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) }},
|
||||
{"recent-session", time.Now},
|
||||
} {
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: root, Now: session.at})
|
||||
if _, err := store.Create(sessions.CreateInput{SessionID: session.id, Title: "title of " + session.id}); err != nil {
|
||||
t.Fatalf("create %s: %v", session.id, err)
|
||||
}
|
||||
store.Release(session.id)
|
||||
}
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
deps := appDeps{
|
||||
newSessionStore: func() *sessions.Store { return sessions.NewStore(sessions.StoreOptions{RootDir: root}) },
|
||||
userConfigPath: func() (string, error) { return configPath, nil },
|
||||
}
|
||||
return root, configPath, deps
|
||||
}
|
||||
|
||||
func runPruneCLI(t *testing.T, deps appDeps, args ...string) (int, string, string) {
|
||||
t.Helper()
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := runWithDeps(append([]string{"sessions"}, args...), &stdout, &stderr, deps)
|
||||
return code, stdout.String(), stderr.String()
|
||||
}
|
||||
|
||||
func pruneSessionExists(t *testing.T, root, id string) bool {
|
||||
t.Helper()
|
||||
_, err := os.Stat(filepath.Join(root, id))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func TestSessionsPruneDryRunListsWithoutRemoving(t *testing.T) {
|
||||
root, _, deps := pruneCLIFixture(t)
|
||||
code, stdout, stderr := runPruneCLI(t, deps, "prune", "--older-than", "30d", "--dry-run")
|
||||
if code != exitSuccess {
|
||||
t.Fatalf("exit %d, stderr %q", code, stderr)
|
||||
}
|
||||
for _, want := range []string{"Would remove 1 session", "old-session", "Dry run: nothing was removed."} {
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Errorf("output does not contain %q:\n%s", want, stdout)
|
||||
}
|
||||
}
|
||||
if strings.Contains(stdout, "recent-session") {
|
||||
t.Errorf("the recent session was listed:\n%s", stdout)
|
||||
}
|
||||
if !pruneSessionExists(t, root, "old-session") {
|
||||
t.Fatal("a dry run removed the session")
|
||||
}
|
||||
}
|
||||
|
||||
// A dry run removes nothing, so a session it could not check is not reported
|
||||
// as one it could not remove.
|
||||
func TestSessionsPruneDryRunReportsFailuresAsUnchecked(t *testing.T) {
|
||||
failed := []sessions.PruneEntry{{SessionID: "old-session", UpdatedAt: "2020-01-01T00:00:00Z", Reason: "the session disappeared while pruning"}}
|
||||
dryRun := formatPruneReport(sessions.PruneReport{Cutoff: "2026-08-27T00:00:00Z", DryRun: true, Failed: failed})
|
||||
if !strings.Contains(dryRun, "Could not check 1:") || strings.Contains(dryRun, "Could not remove") {
|
||||
t.Errorf("dry run report:\n%s", dryRun)
|
||||
}
|
||||
run := formatPruneReport(sessions.PruneReport{Cutoff: "2026-08-27T00:00:00Z", Failed: failed})
|
||||
if !strings.Contains(run, "Could not remove 1:") {
|
||||
t.Errorf("report:\n%s", run)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionsPruneRemovesOnlyOldSessions(t *testing.T) {
|
||||
root, _, deps := pruneCLIFixture(t)
|
||||
code, stdout, stderr := runPruneCLI(t, deps, "prune", "--older-than=30d")
|
||||
if code != exitSuccess {
|
||||
t.Fatalf("exit %d, stderr %q", code, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout, "Removed 1 session") || !strings.Contains(stdout, "old-session") {
|
||||
t.Fatalf("output does not report the removal:\n%s", stdout)
|
||||
}
|
||||
if pruneSessionExists(t, root, "old-session") {
|
||||
t.Fatal("the old session is still there")
|
||||
}
|
||||
if !pruneSessionExists(t, root, "recent-session") {
|
||||
t.Fatal("the recent session was removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionsPruneReportsJSON(t *testing.T) {
|
||||
_, _, deps := pruneCLIFixture(t)
|
||||
code, stdout, stderr := runPruneCLI(t, deps, "prune", "--older-than", "720h", "--json")
|
||||
if code != exitSuccess {
|
||||
t.Fatalf("exit %d, stderr %q", code, stderr)
|
||||
}
|
||||
var report sessions.PruneReport
|
||||
if err := json.Unmarshal([]byte(stdout), &report); err != nil {
|
||||
t.Fatalf("output is not a prune report: %v\n%s", err, stdout)
|
||||
}
|
||||
if len(report.Removed) != 1 || report.Removed[0].SessionID != "old-session" || report.DryRun {
|
||||
t.Fatalf("report = %+v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionsPruneRefusesABadOrShortCutoff(t *testing.T) {
|
||||
root, _, deps := pruneCLIFixture(t)
|
||||
for _, tc := range []struct {
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
{"12h", "last 24 hours"},
|
||||
{"0d", "invalid --older-than"},
|
||||
{"soon", "invalid --older-than"},
|
||||
{"-5d", "invalid --older-than"},
|
||||
} {
|
||||
code, _, stderr := runPruneCLI(t, deps, "prune", "--older-than="+tc.value)
|
||||
if code != exitUsage || !strings.Contains(stderr, tc.want) {
|
||||
t.Errorf("--older-than %s: exit %d, stderr %q, want a usage error naming %q", tc.value, code, stderr, tc.want)
|
||||
}
|
||||
}
|
||||
if !pruneSessionExists(t, root, "old-session") {
|
||||
t.Fatal("a refused prune removed a session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionsPruneNeedsACutoff(t *testing.T) {
|
||||
root, _, deps := pruneCLIFixture(t)
|
||||
code, _, stderr := runPruneCLI(t, deps, "prune")
|
||||
if code != exitUsage || !strings.Contains(stderr, "needs a cutoff") {
|
||||
t.Fatalf("exit %d, stderr %q, want a usage error asking for a cutoff", code, stderr)
|
||||
}
|
||||
if !pruneSessionExists(t, root, "old-session") {
|
||||
t.Fatal("prune without a cutoff removed a session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionsPruneUsesTheUserRetentionSetting(t *testing.T) {
|
||||
root, configPath, deps := pruneCLIFixture(t)
|
||||
if err := os.WriteFile(configPath, []byte(`{"sessions":{"retentionDays":30}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, stdout, stderr := runPruneCLI(t, deps, "prune")
|
||||
if code != exitSuccess {
|
||||
t.Fatalf("exit %d, stderr %q", code, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout, "Removed 1 session") || pruneSessionExists(t, root, "old-session") {
|
||||
t.Fatalf("the user retention setting was not applied:\n%s", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// A REPOSITORY CANNOT CHOOSE WHAT PRUNE DELETES. Sessions are the user's, not
|
||||
// the project's, so sessions.retentionDays in a workspace's .zero/config.json
|
||||
// is not a cutoff, even when prune runs from inside that workspace.
|
||||
func TestSessionsPruneIgnoresRetentionInProjectConfig(t *testing.T) {
|
||||
root, _, deps := pruneCLIFixture(t)
|
||||
workspace := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workspace, ".zero"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(workspace, ".zero", "config.json"), []byte(`{"sessions":{"retentionDays":1}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deps.getwd = func() (string, error) { return workspace, nil }
|
||||
t.Chdir(workspace)
|
||||
|
||||
code, _, stderr := runPruneCLI(t, deps, "prune")
|
||||
if code != exitUsage || !strings.Contains(stderr, "needs a cutoff") {
|
||||
t.Fatalf("exit %d, stderr %q: the project's retention setting was used", code, stderr)
|
||||
}
|
||||
if !pruneSessionExists(t, root, "old-session") {
|
||||
t.Fatal("a project config setting removed a session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionsPruneFlagsBelongToPrune(t *testing.T) {
|
||||
_, _, deps := pruneCLIFixture(t)
|
||||
for _, args := range [][]string{{"list", "--dry-run"}, {"list", "--older-than", "30d"}} {
|
||||
code, _, stderr := runPruneCLI(t, deps, args...)
|
||||
if code != exitUsage || !strings.Contains(stderr, "only valid for sessions prune") {
|
||||
t.Errorf("%v: exit %d, stderr %q", args, code, stderr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An empty cutoff is a usage error, never a fall back to sessions.retentionDays.
|
||||
func TestSessionsPruneRejectsAnEmptyCutoff(t *testing.T) {
|
||||
root, configPath, deps := pruneCLIFixture(t)
|
||||
if err := os.WriteFile(configPath, []byte(`{"sessions":{"retentionDays":1}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, args := range [][]string{
|
||||
{"prune", "--older-than", ""},
|
||||
{"prune", "--older-than", " "},
|
||||
{"prune", "--older-than="},
|
||||
{"list", "--older-than", ""},
|
||||
} {
|
||||
code, _, stderr := runPruneCLI(t, deps, args...)
|
||||
if code != exitUsage || !strings.Contains(stderr, "--older-than requires a value") {
|
||||
t.Errorf("%q: exit %d, stderr %q, want a usage error", args, code, stderr)
|
||||
}
|
||||
}
|
||||
if !pruneSessionExists(t, root, "old-session") {
|
||||
t.Fatal("an empty cutoff fell back to the retention setting and removed a session")
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,10 @@ func TestCLILaunchHelpersLeaveUserStateAlone(t *testing.T) {
|
||||
{"execAdvertisesEscalateModel", func(t *testing.T) {
|
||||
_ = execAdvertisesEscalateModel(t, []string{"--allow-escalation", "exec", "say hi"})
|
||||
}},
|
||||
{"runExecWithSandbox", func(t *testing.T) {
|
||||
_, _, _ = runExecWithSandbox(t, []string{"exec", "hello"}, unavailableTestSandbox, config.SandboxConfig{})
|
||||
}},
|
||||
{"launchTUIWithSandbox", func(t *testing.T) { _ = launchTUIWithSandbox(t, config.SandboxConfig{}) }},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
seedRoot, configPath, seeded, before := seedEmulatedUserConfig(t)
|
||||
|
||||
@@ -244,6 +244,86 @@ func petJSONObject(encodedPet []byte) []byte {
|
||||
return append(result, '}')
|
||||
}
|
||||
|
||||
// setNotifyJSONObject replaces only the notify member's value, keeping the
|
||||
// original bytes of every unrelated member so a notification save cannot
|
||||
// reorder, reformat, or drop the user's other settings — including values whose
|
||||
// EXPLICIT presence matters (tools.deferThreshold: 0, mcp servers'
|
||||
// disabled: false) and keys the typed FileConfig does not model at all. The
|
||||
// typed serializer cannot round-trip explicit zeros (omitempty drops them), so
|
||||
// notification writes go through this byte-preserving editor instead
|
||||
// (maintainer review, PR #1001). An empty notify block removes the member
|
||||
// entirely so a reset leaves no `"notify": {}` husk behind.
|
||||
//
|
||||
// A hand-edited file may contain DUPLICATE notify members. Go's decoder
|
||||
// MERGES the fields of duplicate object members into the same struct, so an
|
||||
// earlier duplicate's field survives a replacement that omits it — e.g.
|
||||
// {"notify":{"mode":"off"},"notify":{"focusMode":"focused"}} + a
|
||||
// replacement {"focusMode":"always"} would leave mode "off" effective.
|
||||
// The nonempty path therefore removes EVERY duplicate and inserts the
|
||||
// replacement fresh (full-replace semantics: nothing inherited), and the
|
||||
// reset path removes every member the same way. UpdateNotify's partial
|
||||
// merge is unaffected: it seeds omitted fields from the stored value BEFORE
|
||||
// calling this editor, so the value it passes is already complete.
|
||||
// Mirrors setPetPreferenceJSON's duplicate handling.
|
||||
func setNotifyJSONObject(data []byte, notify NotifyConfig) ([]byte, error) {
|
||||
encoded, err := json.Marshal(notify)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode notify preference: %w", err)
|
||||
}
|
||||
|
||||
rootStart := skipJSONSpace(data, 0)
|
||||
root, err := parseJSONObject(data, rootStart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notifyIndex := lastJSONMember(root.members, "notify")
|
||||
// Count duplicates: the ordinary file has exactly one notify member, and
|
||||
// for it a positional replace keeps the key where the user put it and
|
||||
// preserves their formatting (byte-preserving contract). The
|
||||
// remove-all-and-reinsert path below is ONLY for files that actually
|
||||
// contain duplicates, where Go's field-merging decode would otherwise let
|
||||
// an earlier block's fields leak into the replacement.
|
||||
duplicates := 0
|
||||
for _, member := range root.members {
|
||||
if member.key == "notify" {
|
||||
duplicates++
|
||||
}
|
||||
}
|
||||
if string(encoded) == "{}" {
|
||||
for notifyIndex >= 0 {
|
||||
data = removeJSONMember(data, root, notifyIndex)
|
||||
rootStart = skipJSONSpace(data, 0)
|
||||
root, err = parseJSONObject(data, rootStart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notifyIndex = lastJSONMember(root.members, "notify")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
if duplicates <= 1 {
|
||||
if notifyIndex < 0 {
|
||||
return insertJSONMember(data, root, "notify", encoded), nil
|
||||
}
|
||||
// Single member: replace its value in place — no key movement, no
|
||||
// reformatting of the surrounding bytes.
|
||||
return replaceJSONRange(data, root.members[notifyIndex].valueStart, root.members[notifyIndex].valueEnd, encoded), nil
|
||||
}
|
||||
// Multiple duplicates: Go merges their fields at decode time, so the
|
||||
// replacement must not inherit anything — remove every duplicate and
|
||||
// insert the complete value fresh.
|
||||
for notifyIndex >= 0 {
|
||||
data = removeJSONMember(data, root, notifyIndex)
|
||||
rootStart = skipJSONSpace(data, 0)
|
||||
root, err = parseJSONObject(data, rootStart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notifyIndex = lastJSONMember(root.members, "notify")
|
||||
}
|
||||
return insertJSONMember(data, root, "notify", encoded), nil
|
||||
}
|
||||
|
||||
func replaceJSONRange(data []byte, start, end int, replacement []byte) []byte {
|
||||
result := make([]byte, 0, len(data)-(end-start)+len(replacement))
|
||||
result = append(result, data[:start]...)
|
||||
|
||||
@@ -3,7 +3,6 @@ package config
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
@@ -72,10 +71,12 @@ var lockConfigFileFn = lockConfigFile
|
||||
|
||||
func lockConfigFile(path string) (func() error, error) {
|
||||
lockPath := path + ".lock"
|
||||
if dir := filepath.Dir(lockPath); dir != "." && dir != "" {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create config directory %s: %w", dir, err)
|
||||
}
|
||||
// Every mutator locks before it writes, so this is where the config
|
||||
// directory is normally created. ensureConfigDir also syncs each new entry
|
||||
// into its parent before the lock file exists; writeConfigData would find
|
||||
// nothing missing by then and could not run that barrier itself.
|
||||
if err := ensureConfigDir(filepath.Dir(lockPath)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deadline := time.Now().Add(configLockTimeout)
|
||||
for {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func normalizeModelRPM(limits map[string]int) (map[string]int, error) {
|
||||
if len(limits) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
registry, err := modelregistry.DefaultRegistry()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]int, len(limits))
|
||||
for key, n := range limits {
|
||||
model := strings.TrimSpace(key)
|
||||
if model == "" || n < 0 {
|
||||
return nil, fmt.Errorf("invalid modelRPM entry %q: model must be non-empty and limit >= 0", key)
|
||||
}
|
||||
if id, ok := registry.ResolveID(model); ok {
|
||||
model = id
|
||||
}
|
||||
if old, exists := out[model]; exists && old != n {
|
||||
return nil, fmt.Errorf("conflicting modelRPM entries resolve to %q", model)
|
||||
}
|
||||
out[model] = n
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Project config can tighten a user cap but cannot relax it.
|
||||
func mergeModelRPM(dst *map[string]int, src map[string]int, tighten bool) {
|
||||
if len(src) == 0 {
|
||||
return
|
||||
}
|
||||
if *dst == nil {
|
||||
*dst = make(map[string]int)
|
||||
}
|
||||
for model, n := range src {
|
||||
old := (*dst)[model]
|
||||
if !tighten || old == 0 || (n > 0 && n < old) {
|
||||
(*dst)[model] = n
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestModelRPMRoundTrip(t *testing.T) {
|
||||
var c FileConfig
|
||||
if e := json.Unmarshal([]byte(`{"modelRPM":{" OPENAI:GPT-4.1 ":2,"custom":0},"future":true}`), &c); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
want := map[string]int{"gpt-4.1": 2, "custom": 0}
|
||||
if !reflect.DeepEqual(c.ModelRPM, want) {
|
||||
t.Fatal(c.ModelRPM)
|
||||
}
|
||||
b, e := json.Marshal(c)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
var d FileConfig
|
||||
if e = json.Unmarshal(b, &d); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if !reflect.DeepEqual(d.ModelRPM, want) || string(d.Extra["future"]) != "true" {
|
||||
t.Fatalf("round trip=%s", b)
|
||||
}
|
||||
}
|
||||
func TestModelRPMInvalid(t *testing.T) {
|
||||
for _, s := range []string{`{"modelRPM":{"":1}}`, `{"modelRPM":{"a":-1}}`, `{"modelRPM":{"a":1.5}}`, `{"modelRPM":{"a":"2"}}`, `{"modelRPM":{"gpt-4.1":1,"openai:gpt-4.1":2}}`} {
|
||||
var c FileConfig
|
||||
if e := json.Unmarshal([]byte(s), &c); e == nil {
|
||||
t.Fatalf("accepted %s", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestModelRPMProjectCanOnlyTighten(t *testing.T) {
|
||||
user := writeConfig(t, `{"modelRPM":{"gpt-4.1":2},"activeProvider":"test","providers":[{"name":"test","provider":"openai","model":"gpt-4.1"}]}`)
|
||||
for _, n := range []string{"0", "1", "5"} {
|
||||
project := writeConfig(t, `{"modelRPM":{"openai:gpt-4.1":`+n+`}}`)
|
||||
c, e := Resolve(ResolveOptions{UserConfigPath: user, ProjectConfigPath: project, Env: map[string]string{}})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
want := 2
|
||||
if n == "1" {
|
||||
want = 1
|
||||
}
|
||||
if c.ModelRPM["gpt-4.1"] != want {
|
||||
t.Fatal(c.ModelRPM)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,9 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
// trusted global-config merge, which must keep honouring the setting.
|
||||
commandConfig.Sandbox.Enabled = nil
|
||||
commandConfig.CrossSessionInbound = ""
|
||||
// External provider commands cannot relax the user cap.
|
||||
mergeModelRPM(&cfg.ModelRPM, commandConfig.ModelRPM, true)
|
||||
commandConfig.ModelRPM = nil
|
||||
mergeConfig(&cfg, commandConfig)
|
||||
}
|
||||
|
||||
@@ -157,11 +160,31 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
// On ErrNoActiveProvider, providers may still hold the successfully
|
||||
// normalized (but active-less) profile list — keep it so a caller can fall
|
||||
// back to an already-configured usable provider instead of treating this
|
||||
// like a config with nothing set up at all.
|
||||
return ResolvedConfig{Providers: providers}, err
|
||||
// like a config with nothing set up at all. The non-provider fields parsed
|
||||
// before the failure (notify, sandbox, tools, preferences, ...) are kept
|
||||
// too: the TUI's provider-recovery path forwards this partial config, and
|
||||
// dropping them would let the TUI's unconfigured notify default overwrite
|
||||
// a stored explicit opt-out on startup (maintainer review, PR #1001).
|
||||
partial := ResolvedConfig{
|
||||
ModelRPM: cfg.ModelRPM,
|
||||
Providers: providers,
|
||||
MaxTurns: cfg.MaxTurns,
|
||||
MCP: cfg.MCP,
|
||||
Sandbox: cfg.Sandbox,
|
||||
Notify: cfg.Notify,
|
||||
Tools: cfg.Tools,
|
||||
Swarm: cfg.Swarm,
|
||||
Preferences: cfg.Preferences,
|
||||
KeyBindings: cfg.KeyBindings,
|
||||
LocalControl: cfg.LocalControl,
|
||||
STT: cfg.STT,
|
||||
CrossSessionInbound: cfg.CrossSessionInbound,
|
||||
}
|
||||
return partial, err
|
||||
}
|
||||
|
||||
return ResolvedConfig{
|
||||
ModelRPM: cfg.ModelRPM,
|
||||
ActiveProvider: active.Name,
|
||||
Providers: providers,
|
||||
Provider: active,
|
||||
@@ -230,6 +253,7 @@ func loadConfigFile(path string) (FileConfig, error) {
|
||||
}
|
||||
|
||||
func mergeConfig(dst *FileConfig, src FileConfig) {
|
||||
mergeModelRPM(&dst.ModelRPM, src.ModelRPM, false)
|
||||
if activeProvider := strings.TrimSpace(src.ActiveProvider); activeProvider != "" {
|
||||
dst.ActiveProvider = activeProvider
|
||||
}
|
||||
@@ -291,6 +315,7 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
|
||||
}
|
||||
|
||||
func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
mergeModelRPM(&dst.ModelRPM, src.ModelRPM, true)
|
||||
if activeProvider := strings.TrimSpace(src.ActiveProvider); activeProvider != "" {
|
||||
dst.ActiveProvider = activeProvider
|
||||
}
|
||||
|
||||
@@ -1109,6 +1109,34 @@ func TestResolveProviderCommandOverridesEnvProviderFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveProviderCommandCannotRelaxUserModelRPM(t *testing.T) {
|
||||
userPath := writeConfig(t, `{
|
||||
"modelRPM": {
|
||||
"gpt-command": 5
|
||||
}
|
||||
}`)
|
||||
|
||||
command := writeCommand(t, commandScript{
|
||||
Stdout: `{"activeProvider":"cmd","providers":[{"name":"cmd","provider":"openai","apiKey":"sk-command","model":"gpt-command"}],"modelRPM":{"gpt-command":10}}`,
|
||||
})
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
UserConfigPath: userPath,
|
||||
ProviderCommand: command,
|
||||
Env: map[string]string{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
|
||||
if got := resolved.ModelRPM["gpt-command"]; got != 5 {
|
||||
t.Fatalf(
|
||||
"ModelRPM[gpt-command] = %d, want user cap 5 preserved against provider-command relaxation",
|
||||
got,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAllowsNoConfiguredProviders(t *testing.T) {
|
||||
resolved, err := Resolve(ResolveOptions{Env: map[string]string{}})
|
||||
if err != nil {
|
||||
@@ -1151,8 +1179,13 @@ func TestResolveRejectsActiveProviderWithoutConfiguredProfiles(t *testing.T) {
|
||||
if HasProviderProfile(resolved.Provider) {
|
||||
t.Fatalf("Provider = %#v, want zero value", resolved.Provider)
|
||||
}
|
||||
if resolved.MaxTurns != 0 {
|
||||
t.Fatalf("MaxTurns = %d, want zero on failed resolve", resolved.MaxTurns)
|
||||
// The error path now carries the parsed non-provider fields through
|
||||
// (maintainer review, PR #1001): the TUI's provider-recovery startup
|
||||
// forwards this partial config, and a zero MaxTurns there would silently
|
||||
// discard the user's turn budget. Matches the no-providers SUCCESS path,
|
||||
// which has always defaulted MaxTurns.
|
||||
if resolved.MaxTurns != defaultMaxTurns {
|
||||
t.Fatalf("MaxTurns = %d, want default %d carried through the partial resolve", resolved.MaxTurns, defaultMaxTurns)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1161,10 +1194,13 @@ func TestResolveKeepsNormalizedProvidersWhenNoneMarkedActive(t *testing.T) {
|
||||
// activeProvider is blank/stale — a caller like the interactive TUI still
|
||||
// needs the normalized list to fall back to an already-usable provider
|
||||
// instead of forcing a full re-onboarding wizard.
|
||||
path := writeConfig(t, `{"providers":[
|
||||
{"name":"work","provider_kind":"openai","apiKey":"sk-test","model":"gpt-test"},
|
||||
{"name":"other","provider_kind":"openai","apiKey":"sk-other","model":"gpt-test"}
|
||||
]}`)
|
||||
path := writeConfig(t, `{
|
||||
"modelRPM":{"gpt-test":1},
|
||||
"providers":[
|
||||
{"name":"work","provider_kind":"openai","apiKey":"sk-test","model":"gpt-test"},
|
||||
{"name":"other","provider_kind":"openai","apiKey":"sk-other","model":"gpt-test"}
|
||||
]
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
|
||||
if !errors.Is(err, ErrNoActiveProvider) {
|
||||
@@ -1173,6 +1209,9 @@ func TestResolveKeepsNormalizedProvidersWhenNoneMarkedActive(t *testing.T) {
|
||||
if len(resolved.Providers) != 2 {
|
||||
t.Fatalf("Providers = %#v, want the 2 normalized profiles preserved despite the error", resolved.Providers)
|
||||
}
|
||||
if got := resolved.ModelRPM["gpt-test"]; got != 1 {
|
||||
t.Fatalf("ModelRPM[gpt-test] = %d, want 1 preserved despite ErrNoActiveProvider", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTrimsProviderProfileAliasesBeforeFallback(t *testing.T) {
|
||||
@@ -1864,14 +1903,43 @@ func TestResolveNotifyInvalidFocusMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNotifyDefaultEmpty(t *testing.T) {
|
||||
path := writeConfig(t, `{}`)
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
||||
// An unconfigured notify block must resolve EMPTY. Defaults live in the TUI
|
||||
// (effectiveTUINotifyMode) and in `zero config notify`'s display, not in the
|
||||
// resolver: a filled-in default here leaks into headless `zero exec` (BEL +
|
||||
// OSC-9 bytes on stderr under -o json) and into the CLI/TUI preserve paths,
|
||||
// which must treat "user never chose" differently from "user chose both"
|
||||
// (maintainer review, PR #1001).
|
||||
func TestResolveNotifyUnconfiguredStaysEmpty(t *testing.T) {
|
||||
// No config file at all.
|
||||
resolved, err := Resolve(ResolveOptions{Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if resolved.Notify.Mode != "" || resolved.Notify.FocusMode != "" {
|
||||
t.Fatalf("unset notify should be empty, got %+v", resolved.Notify)
|
||||
t.Fatalf("no config file: notify = %+v, want empty (resolver must not default)", resolved.Notify)
|
||||
}
|
||||
|
||||
for name, body := range map[string]string{
|
||||
"empty config": `{}`,
|
||||
"empty block": `{"notify":{}}`,
|
||||
"mode only": `{"notify":{"mode":"off"}}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: writeConfig(t, body), Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if name == "mode only" {
|
||||
if resolved.Notify.Mode != "off" {
|
||||
t.Errorf("notify.mode = %q, want preserved off", resolved.Notify.Mode)
|
||||
}
|
||||
} else if resolved.Notify.Mode != "" {
|
||||
t.Errorf("notify.mode = %q, want empty (resolver must not default)", resolved.Notify.Mode)
|
||||
}
|
||||
if resolved.Notify.FocusMode != "" {
|
||||
t.Errorf("notify.focusMode = %q, want empty (resolver must not default)", resolved.Notify.FocusMode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2254,6 +2322,19 @@ func TestResolveRejectsInvalidCrossSessionInbound(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveReportsExplicitMaxTurns(t *testing.T) {
|
||||
// A nil ResolveOptions.Env means "read the real process environment" (see
|
||||
// envValue), so a case that leaves it nil inherits whatever the developer
|
||||
// happens to export. ANTHROPIC_BASE_URL alone is enough: applyProviderEnv
|
||||
// synthesizes a second, keyless "anthropic" profile from a base URL on its
|
||||
// own, the single-provider shortcut in normalizeProviders stops applying,
|
||||
// and Resolve fails with `active provider "" not found` before this test
|
||||
// reaches its assertion. Every variable the resolver reads does it, so the
|
||||
// cases below state an EMPTY environment rather than no environment, and
|
||||
// these three are set to a hostile value so the test proves it.
|
||||
t.Setenv("ANTHROPIC_BASE_URL", "https://proxy.example/v1")
|
||||
t.Setenv("OPENAI_API_KEY", "from-the-developer-shell")
|
||||
t.Setenv(MaxTurnsEnv, "7")
|
||||
|
||||
const providerJSON = `{
|
||||
"activeProvider": "p",
|
||||
"providers": [{"name": "p", "provider_kind": "openai-compatible", "base_url": "https://x.example/v1", "apiKey": "k", "model": "m"}]
|
||||
@@ -2266,10 +2347,15 @@ func TestResolveReportsExplicitMaxTurns(t *testing.T) {
|
||||
set bool
|
||||
}{
|
||||
{"built-in default only", ResolveOptions{
|
||||
Env: map[string]string{},
|
||||
Overrides: Overrides{Provider: ProviderProfile{Name: "p", ProviderKind: "openai-compatible", BaseURL: "https://x.example/v1", APIKey: "k", Model: "m"}},
|
||||
}, false},
|
||||
{"user config", ResolveOptions{UserConfigPath: writeConfig(t, providerWithTurns)}, true},
|
||||
{"user config", ResolveOptions{
|
||||
Env: map[string]string{},
|
||||
UserConfigPath: writeConfig(t, providerWithTurns),
|
||||
}, true},
|
||||
{"project config", ResolveOptions{
|
||||
Env: map[string]string{},
|
||||
UserConfigPath: writeConfig(t, providerJSON),
|
||||
ProjectConfigPath: writeConfig(t, `{"maxTurns": 40}`),
|
||||
}, true},
|
||||
@@ -2278,6 +2364,7 @@ func TestResolveReportsExplicitMaxTurns(t *testing.T) {
|
||||
Overrides: Overrides{Provider: ProviderProfile{Name: "p", ProviderKind: "openai-compatible", BaseURL: "https://x.example/v1", APIKey: "k", Model: "m"}},
|
||||
}, true},
|
||||
{"cli override", ResolveOptions{
|
||||
Env: map[string]string{},
|
||||
Overrides: Overrides{
|
||||
MaxTurns: 42,
|
||||
Provider: ProviderProfile{Name: "p", ProviderKind: "openai-compatible", BaseURL: "https://x.example/v1", APIKey: "k", Model: "m"},
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
)
|
||||
|
||||
// ReadSessionsConfig reads the sessions settings from the config file at path,
|
||||
// which callers pass as the user config. A missing file is an empty config. See
|
||||
// SessionsConfig for why nothing else supplies these settings.
|
||||
func ReadSessionsConfig(path string) (SessionsConfig, error) {
|
||||
cfg, err := loadConfigFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return SessionsConfig{}, nil
|
||||
}
|
||||
return SessionsConfig{}, err
|
||||
}
|
||||
return cfg.Sessions, nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSessionsConfigRoundTripsAsAKnownKey(t *testing.T) {
|
||||
var cfg FileConfig
|
||||
if err := json.Unmarshal([]byte(`{"sessions":{"retentionDays":14}}`), &cfg); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if cfg.Sessions.RetentionDays != 14 {
|
||||
t.Fatalf("retentionDays = %d, want 14", cfg.Sessions.RetentionDays)
|
||||
}
|
||||
if _, stray := cfg.Extra["sessions"]; stray {
|
||||
t.Fatal("sessions was kept as an unknown key as well as parsed")
|
||||
}
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"sessions":{"retentionDays":14}`) {
|
||||
t.Fatalf("marshalled config lost the setting: %s", data)
|
||||
}
|
||||
empty, err := json.Marshal(FileConfig{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(empty), "sessions") {
|
||||
t.Fatalf("an unset sessions block was written out: %s", empty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionsConfigRejectsANegativeRetention(t *testing.T) {
|
||||
var cfg FileConfig
|
||||
err := json.Unmarshal([]byte(`{"sessions":{"retentionDays":-1}}`), &cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "retentionDays") {
|
||||
t.Fatalf("a negative retention was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A write that changes something else must not drop the setting.
|
||||
func TestSessionsConfigSurvivesAnUnrelatedWrite(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"sessions":{"retentionDays":21}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SetTheme(path, "dark"); err != nil {
|
||||
t.Fatalf("SetTheme: %v", err)
|
||||
}
|
||||
settings, err := ReadSessionsConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadSessionsConfig: %v", err)
|
||||
}
|
||||
if settings.RetentionDays != 21 {
|
||||
t.Fatalf("after an unrelated write retentionDays = %d, want 21", settings.RetentionDays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSessionsConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if settings, err := ReadSessionsConfig(filepath.Join(dir, "missing.json")); err != nil || settings != (SessionsConfig{}) {
|
||||
t.Fatalf("a missing config = %+v, %v; want empty and no error", settings, err)
|
||||
}
|
||||
broken := filepath.Join(dir, "broken.json")
|
||||
if err := os.WriteFile(broken, []byte(`{"sessions":`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadSessionsConfig(broken); err == nil {
|
||||
t.Fatal("a config that does not parse was read as empty")
|
||||
}
|
||||
}
|
||||
@@ -177,6 +177,19 @@ const (
|
||||
STTProviderDeepgram STTProviderKind = "deepgram"
|
||||
)
|
||||
|
||||
// SessionsConfig holds settings for Zero's saved sessions.
|
||||
//
|
||||
// READ FROM THE USER'S OWN CONFIG ONLY. It decides what `zero sessions prune`
|
||||
// deletes, and sessions belong to the user rather than to a repository, so a
|
||||
// project's .zero/config.json has no say: the resolver does not merge it, and
|
||||
// the prune command reads it straight from the user config file.
|
||||
type SessionsConfig struct {
|
||||
// RetentionDays is the cutoff `zero sessions prune` uses when it is given no
|
||||
// --older-than. Unset (0) means prune needs an explicit cutoff; nothing is
|
||||
// ever removed without one being asked for.
|
||||
RetentionDays int `json:"retentionDays,omitempty"`
|
||||
}
|
||||
|
||||
// STTConfig configures speech-to-text dictation. All fields are optional; empty
|
||||
// values take the documented defaults. Booleans that need a real tri-state
|
||||
// (distinguishing "unset" from "false") use *bool, matching PreferencesConfig.Recaps.
|
||||
@@ -351,6 +364,7 @@ func (cfg *ToolsConfig) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
type FileConfig struct {
|
||||
ModelRPM map[string]int `json:"modelRPM,omitempty"`
|
||||
ActiveProvider string `json:"activeProvider,omitempty"`
|
||||
Providers []ProviderProfile `json:"providers,omitempty"`
|
||||
MaxTurns int `json:"maxTurns,omitempty"`
|
||||
@@ -364,6 +378,7 @@ type FileConfig struct {
|
||||
LocalControl LocalControlConfig `json:"localControl,omitempty"`
|
||||
STT STTConfig `json:"stt,omitempty"`
|
||||
CrossSessionInbound string `json:"crossSessionInbound,omitempty"`
|
||||
Sessions SessionsConfig `json:"sessions,omitempty"`
|
||||
// maxTurnsSet records that some merge source supplied a positive maxTurns —
|
||||
// i.e. the value is configured, not the built-in default. Unexported like
|
||||
// Tools.deferThresholdSet: merge bookkeeping, not a config key.
|
||||
@@ -375,6 +390,7 @@ type FileConfig struct {
|
||||
|
||||
func (cfg FileConfig) MarshalJSON() ([]byte, error) {
|
||||
type rawConfig struct {
|
||||
ModelRPM map[string]int `json:"modelRPM,omitempty"`
|
||||
ActiveProvider string `json:"activeProvider,omitempty"`
|
||||
Providers []ProviderProfile `json:"providers,omitempty"`
|
||||
MaxTurns int `json:"maxTurns,omitempty"`
|
||||
@@ -388,8 +404,10 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
|
||||
LocalControl *LocalControlConfig `json:"localControl,omitempty"`
|
||||
STT *STTConfig `json:"stt,omitempty"`
|
||||
CrossSessionInbound string `json:"crossSessionInbound,omitempty"`
|
||||
Sessions *SessionsConfig `json:"sessions,omitempty"`
|
||||
}
|
||||
raw := rawConfig{
|
||||
ModelRPM: cfg.ModelRPM,
|
||||
ActiveProvider: cfg.ActiveProvider,
|
||||
Providers: cfg.Providers,
|
||||
MaxTurns: cfg.MaxTurns,
|
||||
@@ -408,6 +426,9 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
|
||||
if !cfg.STT.Empty() {
|
||||
raw.STT = &cfg.STT
|
||||
}
|
||||
if cfg.Sessions != (SessionsConfig{}) {
|
||||
raw.Sessions = &cfg.Sessions
|
||||
}
|
||||
known, err := json.Marshal(raw)
|
||||
if err != nil || len(cfg.Extra) == 0 {
|
||||
return known, err
|
||||
@@ -465,6 +486,7 @@ type Overrides struct {
|
||||
}
|
||||
|
||||
type ResolvedConfig struct {
|
||||
ModelRPM map[string]int
|
||||
ActiveProvider string
|
||||
Providers []ProviderProfile
|
||||
Provider ProviderProfile
|
||||
@@ -533,6 +555,7 @@ type MCPOAuthConfig struct {
|
||||
|
||||
func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
type rawConfig struct {
|
||||
ModelRPM map[string]int `json:"modelRPM"`
|
||||
ActiveProvider string `json:"activeProvider"`
|
||||
Providers []ProviderProfile `json:"providers"`
|
||||
MaxTurns int `json:"maxTurns"`
|
||||
@@ -546,6 +569,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
LocalControl LocalControlConfig `json:"localControl"`
|
||||
STT STTConfig `json:"stt"`
|
||||
CrossSessionInbound string `json:"crossSessionInbound"`
|
||||
Sessions SessionsConfig `json:"sessions"`
|
||||
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
|
||||
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
|
||||
}
|
||||
@@ -567,6 +591,11 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
extra = nil
|
||||
}
|
||||
cfg.ActiveProvider = raw.ActiveProvider
|
||||
limits, err := normalizeModelRPM(raw.ModelRPM)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.ModelRPM = limits
|
||||
cfg.Providers = raw.Providers
|
||||
// A negative maxTurns is unambiguously invalid; without this it would be
|
||||
// silently dropped by the `MaxTurns > 0` merge gates and fall back to the
|
||||
@@ -587,6 +616,10 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
cfg.LocalControl = raw.LocalControl
|
||||
cfg.STT = raw.STT
|
||||
cfg.CrossSessionInbound = raw.CrossSessionInbound
|
||||
if raw.Sessions.RetentionDays < 0 {
|
||||
return fmt.Errorf("invalid sessions.retentionDays %d: must be >= 0", raw.Sessions.RetentionDays)
|
||||
}
|
||||
cfg.Sessions = raw.Sessions
|
||||
cfg.Extra = extra
|
||||
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
|
||||
cfg.MCP.Servers = map[string]MCPServerConfig{}
|
||||
|
||||
@@ -6,9 +6,11 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/notify"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
)
|
||||
|
||||
@@ -756,6 +758,128 @@ func SetTheme(path string, theme string) (result FileConfig, err error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// UserNotify returns the notify block stored in the user's own config file at
|
||||
// path, trimmed. A missing file or missing/empty block returns the zero value:
|
||||
// callers that need to preserve "whatever the user already chose" on a partial
|
||||
// update must seed from THIS value, not from the resolved view — the resolver
|
||||
// merges project config and the TUI applies its own defaults, so seeding from
|
||||
// resolved copies choices the user never made into their global file (a
|
||||
// project's mode: off, or a pinned default) and across every other project.
|
||||
func UserNotify(path string) (NotifyConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return NotifyConfig{}, nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return NotifyConfig{}, nil
|
||||
}
|
||||
return NotifyConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
var cfg FileConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return NotifyConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
cfg.Notify.Mode = strings.TrimSpace(cfg.Notify.Mode)
|
||||
cfg.Notify.FocusMode = strings.TrimSpace(cfg.Notify.FocusMode)
|
||||
return cfg.Notify, nil
|
||||
}
|
||||
|
||||
// validateNotify trims both fields and checks them against the accepted vocab
|
||||
// (mode in {off,bell,notify,both}; focusMode in {unfocused,always,focused}) so
|
||||
// a bad caller cannot write a value the resolver would later reject at
|
||||
// startup. An empty Mode or FocusMode is stored as-is — blank means "use the
|
||||
// built-in defaults" (the TUI's effectiveTUINotifyMode maps an empty mode to
|
||||
// both; the notify package treats an empty focusMode as unfocused), not "off".
|
||||
func validateNotify(value NotifyConfig) (NotifyConfig, error) {
|
||||
value.Mode = strings.TrimSpace(value.Mode)
|
||||
value.FocusMode = strings.TrimSpace(value.FocusMode)
|
||||
if mode := value.Mode; mode != "" {
|
||||
switch notify.Mode(mode) {
|
||||
case notify.ModeOff, notify.ModeBell, notify.ModeNotify, notify.ModeBoth:
|
||||
default:
|
||||
return NotifyConfig{}, fmt.Errorf("invalid notify.mode %q: expected off, bell, notify, or both", mode)
|
||||
}
|
||||
}
|
||||
if focus := value.FocusMode; focus != "" {
|
||||
switch notify.FocusMode(focus) {
|
||||
case notify.FocusUnfocused, notify.FocusAlways, notify.FocusFocused:
|
||||
default:
|
||||
return NotifyConfig{}, fmt.Errorf("invalid notify.focusMode %q: expected unfocused, always, or focused", focus)
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// UpdateNotify runs one SERIALIZED read-modify-write transaction on the user's
|
||||
// notify block. merge receives the currently stored block and returns the
|
||||
// block to store; it must preserve any field the caller is not explicitly
|
||||
// changing. The lock covers reading the stored block, applying merge, and
|
||||
// replacing the file, so two concurrent partial updates (for example
|
||||
// `--mode off` and `--focus always` from two terminals) cannot interleave
|
||||
// their reads and writes and silently undo each other's explicit change
|
||||
// (maintainer review, PR #1001 reproduced a 50/50 lost-update without it).
|
||||
//
|
||||
// The write replaces ONLY the notify member's bytes (setNotifyJSONObject),
|
||||
// preserving every unrelated value and its explicit presence — including
|
||||
// explicit zeros/falses like tools.deferThreshold: 0 or an MCP server's
|
||||
// disabled: false, which the typed serializer's omitempty cannot round-trip —
|
||||
// through the existing temp-file-and-rename atomic publish. Locking only the
|
||||
// final write would leave the stale-field merge outside the transaction, so
|
||||
// the whole sequence runs under one lock — the SAME lockConfigFileFn authority
|
||||
// every other config mutator uses (provider/theme/credential/MCP writes), not
|
||||
// a private lock: two authorities over the same file would not contend
|
||||
// (maintainer review, PR #1001). The release error is joined into the
|
||||
// result like every other writer: a failed unlock would leave the advisory
|
||||
// lock held and later updates blocked while the caller is told success.
|
||||
func UpdateNotify(path string, merge func(current NotifyConfig) NotifyConfig) (result NotifyConfig, err error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return NotifyConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
if merge == nil {
|
||||
return NotifyConfig{}, fmt.Errorf("merge function is required")
|
||||
}
|
||||
release, err := lockConfigFileFn(path)
|
||||
if err != nil {
|
||||
return NotifyConfig{}, err
|
||||
}
|
||||
// Joined, not chosen between: a release failure annotates the result
|
||||
// instead of masking the mutation error that actually explains what went
|
||||
// wrong. Reporting success after a failed release would claim a state the
|
||||
// next update cannot reproduce.
|
||||
defer func() { err = errors.Join(err, release()) }()
|
||||
|
||||
current, err := UserNotify(path)
|
||||
if err != nil {
|
||||
return NotifyConfig{}, err
|
||||
}
|
||||
next, err := validateNotify(merge(current))
|
||||
if err != nil {
|
||||
return NotifyConfig{}, err
|
||||
}
|
||||
|
||||
// Read the raw file and edit only the notify member. A missing file starts
|
||||
// from an empty object so a first-time write does not need a pre-seeded
|
||||
// config.
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return NotifyConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
data = []byte("{}")
|
||||
}
|
||||
updated, err := setNotifyJSONObject(data, next)
|
||||
if err != nil {
|
||||
return NotifyConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
if err := writeConfigData(path, updated); err != nil {
|
||||
return NotifyConfig{}, err
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// SetPet persists only the terminal-pet preference while preserving every
|
||||
// unrelated user setting through the config writer's atomic replace path.
|
||||
func SetPet(path string, pet string) (result FileConfig, err error) {
|
||||
@@ -973,12 +1097,116 @@ func writeConfigFile(path string, cfg FileConfig) error {
|
||||
return writeConfigData(path, data)
|
||||
}
|
||||
|
||||
// Sync seams allow tests to inject persistence failures at each barrier.
|
||||
var syncConfigFileFn = (*os.File).Sync
|
||||
var syncConfigDirFn = syncConfigDir
|
||||
|
||||
// Windows cannot sync directory handles, so rename durability is best effort
|
||||
// there, matching the sessions store. Elsewhere a directory the user can write
|
||||
// but not read cannot be opened for syncing either; that is also best effort,
|
||||
// because the write it follows has already succeeded. Every other failure to
|
||||
// open, sync, or close the directory is reported: the barrier was requested and
|
||||
// not reached, so the caller must not acknowledge the save.
|
||||
func syncConfigDir(dir string) error {
|
||||
if runtime.GOOS == "windows" {
|
||||
return nil
|
||||
}
|
||||
d, err := os.Open(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrPermission) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return errors.Join(d.Sync(), d.Close())
|
||||
}
|
||||
|
||||
// ensureConfigDir is the ONE path that creates the config directory, shared by
|
||||
// lockConfigFile and writeConfigData. Every exported mutator locks before it
|
||||
// writes, and the lock file is a sibling of the config, so in practice the lock
|
||||
// path is what first creates the directory; a barrier that lived only in
|
||||
// writeConfigData would find nothing missing by then and never run.
|
||||
//
|
||||
// Each missing component is created with os.Mkdir rather than os.MkdirAll so
|
||||
// the call knows exactly which entries it created, then each new entry is
|
||||
// synced into its parent (deepest first) so a crash cannot lose the directory,
|
||||
// and with it the config, after a successful write. A component that appeared
|
||||
// between the lookup and the Mkdir belongs to another writer: it is not rolled
|
||||
// back, but its parent is still synced, because this write depends on the
|
||||
// entry being durable whoever created it.
|
||||
//
|
||||
// If a barrier fails, the directories this call created are removed again.
|
||||
// Leaving them would let a retry find nothing missing, skip the barrier, and
|
||||
// report a durability it never established. Only what this call created is
|
||||
// removed, with os.Remove so a directory another process has since populated
|
||||
// stays; a failed rollback is joined into the error, never swallowed.
|
||||
func ensureConfigDir(dir string) error {
|
||||
if dir == "." || dir == "" {
|
||||
return nil
|
||||
}
|
||||
missing := missingConfigDirs(dir)
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
var created []string
|
||||
for i := len(missing) - 1; i >= 0; i-- {
|
||||
d := missing[i]
|
||||
err := os.Mkdir(d, 0o700)
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return errors.Join(fmt.Errorf("create config directory %s: %w", d, err), removeConfigDirs(created))
|
||||
}
|
||||
created = append(created, d)
|
||||
}
|
||||
for _, d := range missing {
|
||||
parent := filepath.Dir(d)
|
||||
if err := syncConfigDirFn(parent); err != nil {
|
||||
return errors.Join(fmt.Errorf("sync config directory %s: %w", parent, err), removeConfigDirs(created))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeConfigDirs rolls back directories ensureConfigDir created in this
|
||||
// call, deepest first. os.Remove refuses a non-empty directory, so an entry
|
||||
// another process already populated survives and its error is reported.
|
||||
func removeConfigDirs(created []string) error {
|
||||
var errs []error
|
||||
for i := len(created) - 1; i >= 0; i-- {
|
||||
if err := os.Remove(created[i]); err != nil {
|
||||
errs = append(errs, fmt.Errorf("roll back config directory %s: %w", created[i], err))
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// missingConfigDirs lists dir and each ancestor that does not exist yet,
|
||||
// deepest first, so ensureConfigDir can create them shallowest first and sync
|
||||
// each new entry into its parent deepest first.
|
||||
func missingConfigDirs(dir string) []string {
|
||||
var missing []string
|
||||
for d := dir; ; {
|
||||
if _, err := os.Lstat(d); !errors.Is(err, os.ErrNotExist) {
|
||||
return missing
|
||||
}
|
||||
missing = append(missing, d)
|
||||
parent := filepath.Dir(d)
|
||||
if parent == d {
|
||||
return missing
|
||||
}
|
||||
d = parent
|
||||
}
|
||||
}
|
||||
|
||||
func writeConfigData(path string, data []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
if dir != "." && dir != "" {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("create config directory %s: %w", dir, err)
|
||||
}
|
||||
// Usually a no-op: the lock path has already created and synced the
|
||||
// directory. It still matters for the rare unlocked caller and for a
|
||||
// directory removed between the lock and the write.
|
||||
if err := ensureConfigDir(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(data) == 0 || data[len(data)-1] != '\n' {
|
||||
data = append(data, '\n')
|
||||
@@ -999,11 +1227,19 @@ func writeConfigData(path string, data []byte) error {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("write config %s: %w", path, err)
|
||||
}
|
||||
// Persist the complete contents before making the replacement visible.
|
||||
if err := syncConfigFileFn(tmp); err != nil {
|
||||
return fmt.Errorf("sync config %s: %w", path, errors.Join(err, tmp.Close()))
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("write config %s: %w", path, err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
return fmt.Errorf("write config %s: %w", path, err)
|
||||
}
|
||||
// The replacement is already visible if this fails; do not roll it back.
|
||||
if err := syncConfigDirFn(dir); err != nil {
|
||||
return fmt.Errorf("sync config directory %s: %w", dir, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package config
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -10,9 +12,299 @@ import (
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWriteConfigDataSync(t *testing.T) {
|
||||
failure := errors.New("injected sync failure")
|
||||
for _, stage := range []string{"success", "file", "directory"} {
|
||||
t.Run(stage, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.json")
|
||||
oldData, newData := "{\"old\":true}\n", "{\"new\":true}\n"
|
||||
if err := os.WriteFile(path, []byte(oldData), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkData := func(path, want string) {
|
||||
t.Helper()
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil || string(got) != want {
|
||||
t.Fatalf("read %s = %q, %v; want %q", path, got, err, want)
|
||||
}
|
||||
}
|
||||
fileSync, dirSync := syncConfigFileFn, syncConfigDirFn
|
||||
t.Cleanup(func() { syncConfigFileFn, syncConfigDirFn = fileSync, dirSync })
|
||||
var tmp *os.File
|
||||
var calls []string
|
||||
syncConfigFileFn = func(f *os.File) error {
|
||||
tmp = f
|
||||
calls = append(calls, "file")
|
||||
checkData(f.Name(), newData)
|
||||
checkData(path, oldData)
|
||||
if stage == "file" {
|
||||
return failure
|
||||
}
|
||||
return fileSync(f)
|
||||
}
|
||||
syncConfigDirFn = func(got string) error {
|
||||
calls = append(calls, "directory")
|
||||
if got != dir || tmp == nil {
|
||||
t.Fatalf("directory sync = %q, temp = %v", got, tmp)
|
||||
}
|
||||
if _, err := tmp.Seek(0, io.SeekCurrent); !errors.Is(err, os.ErrClosed) {
|
||||
t.Fatalf("temp must be closed before directory sync: %v", err)
|
||||
}
|
||||
checkData(path, newData)
|
||||
if stage == "directory" {
|
||||
return failure
|
||||
}
|
||||
return dirSync(got)
|
||||
}
|
||||
err := writeConfigData(path, []byte(strings.TrimSuffix(newData, "\n")))
|
||||
if stage == "success" {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else if !errors.Is(err, failure) {
|
||||
t.Fatalf("error = %v, want injected sync failure", err)
|
||||
}
|
||||
wantCalls := []string{"file", "directory"}
|
||||
wantData := newData
|
||||
if stage == "file" {
|
||||
wantCalls, wantData = []string{"file"}, oldData
|
||||
}
|
||||
if !reflect.DeepEqual(calls, wantCalls) {
|
||||
t.Fatalf("sync calls = %v, want %v", calls, wantCalls)
|
||||
}
|
||||
checkData(path, wantData)
|
||||
if _, err := tmp.Seek(0, io.SeekCurrent); !errors.Is(err, os.ErrClosed) {
|
||||
t.Fatalf("temp must be closed on return: %v", err)
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil || len(entries) != 1 || entries[0].Name() != "config.json" {
|
||||
t.Fatalf("temporary file not cleaned up: %v, %v", entries, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteConfigDataSyncsCreatedDirectories(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outer := filepath.Join(root, "a")
|
||||
dir := filepath.Join(outer, "b")
|
||||
path := filepath.Join(dir, "config.json")
|
||||
failure := errors.New("injected sync failure")
|
||||
dirSync := syncConfigDirFn
|
||||
t.Cleanup(func() { syncConfigDirFn = dirSync })
|
||||
|
||||
for _, fail := range []bool{false, true} {
|
||||
var calls []string
|
||||
syncConfigDirFn = func(got string) error {
|
||||
calls = append(calls, got)
|
||||
if fail && got == root {
|
||||
return failure
|
||||
}
|
||||
return dirSync(got)
|
||||
}
|
||||
if err := os.RemoveAll(outer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := writeConfigData(path, []byte(`{}`))
|
||||
if fail {
|
||||
if !errors.Is(err, failure) {
|
||||
t.Fatalf("error = %v, want injected sync failure", err)
|
||||
}
|
||||
if want := []string{outer, root}; !reflect.DeepEqual(calls, want) {
|
||||
t.Fatalf("sync calls = %v, want %v", calls, want)
|
||||
}
|
||||
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("config must not be written after a failed directory sync: %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Each new directory's entry is synced into its parent, then the
|
||||
// config's own directory after the rename.
|
||||
if want := []string{outer, root, dir}; !reflect.DeepEqual(calls, want) {
|
||||
t.Fatalf("sync calls = %v, want %v", calls, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockingMutatorSyncsCreatedDirectories(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outer := filepath.Join(root, "a")
|
||||
dir := filepath.Join(outer, "b")
|
||||
path := filepath.Join(dir, "config.json")
|
||||
failure := errors.New("injected sync failure")
|
||||
dirSync := syncConfigDirFn
|
||||
t.Cleanup(func() { syncConfigDirFn = dirSync })
|
||||
|
||||
exists := func(p string) bool {
|
||||
t.Helper()
|
||||
_, err := os.Lstat(p)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Every exported mutator acquires the config lock before it writes, and
|
||||
// the lock file is a sibling of the config, so the lock path is what first
|
||||
// creates the config directory. The barrier for those new entries has to
|
||||
// run there, before the lock file exists: by the time writeConfigData
|
||||
// looks, nothing is missing any more.
|
||||
for _, fail := range []bool{true, false} {
|
||||
var calls []string
|
||||
syncConfigDirFn = func(got string) error {
|
||||
calls = append(calls, got)
|
||||
if got == outer || got == root {
|
||||
if exists(path+".lock") || exists(path) {
|
||||
t.Fatalf("parent sync of %s ran after the lock file or config was created", got)
|
||||
}
|
||||
}
|
||||
if fail && got == root {
|
||||
return failure
|
||||
}
|
||||
return dirSync(got)
|
||||
}
|
||||
_, err := UpdateNotify(path, func(NotifyConfig) NotifyConfig {
|
||||
return NotifyConfig{Mode: "off"}
|
||||
})
|
||||
if fail {
|
||||
if !errors.Is(err, failure) {
|
||||
t.Fatalf("error = %v, want injected sync failure", err)
|
||||
}
|
||||
if want := []string{outer, root}; !reflect.DeepEqual(calls, want) {
|
||||
t.Fatalf("sync calls = %v, want %v", calls, want)
|
||||
}
|
||||
// The directories this attempt created are gone again, so the
|
||||
// retry below re-creates them and re-runs the barrier instead of
|
||||
// finding them present and reporting a durability it never had.
|
||||
if exists(outer) {
|
||||
t.Fatalf("%s must be rolled back after a failed parent sync", outer)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []string{outer, root, dir}; !reflect.DeepEqual(calls, want) {
|
||||
t.Fatalf("sync calls = %v, want %v", calls, want)
|
||||
}
|
||||
got, err := UserNotify(path)
|
||||
if err != nil || got.Mode != "off" {
|
||||
t.Fatalf("UserNotify = %+v, %v; want mode off", got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteConfigDataRollsBackOnlyCreatedDirectories(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
existing := filepath.Join(root, "existing")
|
||||
if err := os.Mkdir(existing, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keep := filepath.Join(existing, "keep")
|
||||
if err := os.WriteFile(keep, []byte("keep\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outer := filepath.Join(existing, "a")
|
||||
dir := filepath.Join(outer, "b")
|
||||
path := filepath.Join(dir, "config.json")
|
||||
failure := errors.New("injected sync failure")
|
||||
dirSync := syncConfigDirFn
|
||||
t.Cleanup(func() { syncConfigDirFn = dirSync })
|
||||
stat := func(p string) error {
|
||||
_, err := os.Lstat(p)
|
||||
return err
|
||||
}
|
||||
|
||||
// The barrier for "a" fails: both directories this call created go, the
|
||||
// pre-existing directory and its file stay.
|
||||
syncConfigDirFn = func(got string) error {
|
||||
if got == existing {
|
||||
return failure
|
||||
}
|
||||
return dirSync(got)
|
||||
}
|
||||
if err := writeConfigData(path, []byte(`{}`)); !errors.Is(err, failure) {
|
||||
t.Fatalf("error = %v, want injected sync failure", err)
|
||||
}
|
||||
for _, p := range []string{dir, outer} {
|
||||
if err := stat(p); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("%s must be removed after a failed parent sync: %v", p, err)
|
||||
}
|
||||
}
|
||||
for _, p := range []string{existing, keep} {
|
||||
if err := stat(p); err != nil {
|
||||
t.Fatalf("pre-existing %s must survive the rollback: %v", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A directory another process populated in the meantime is not ours to
|
||||
// remove: it stays, the failed rollback is reported with the barrier
|
||||
// failure, and the write still does not acknowledge the save.
|
||||
intruder := filepath.Join(dir, "other.lock")
|
||||
syncConfigDirFn = func(got string) error {
|
||||
if got == existing {
|
||||
if err := os.WriteFile(intruder, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return failure
|
||||
}
|
||||
return dirSync(got)
|
||||
}
|
||||
err := writeConfigData(path, []byte(`{}`))
|
||||
if !errors.Is(err, failure) {
|
||||
t.Fatalf("error = %v, want injected sync failure", err)
|
||||
}
|
||||
if err := stat(intruder); err != nil {
|
||||
t.Fatalf("a populated directory must not be removed: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "roll back config directory") {
|
||||
t.Fatalf("error must report the failed rollback: %v", err)
|
||||
}
|
||||
if err := stat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("config must not be written after a failed directory sync: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncConfigDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := syncConfigDir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := syncConfigDir(filepath.Join(dir, "missing"))
|
||||
if runtime.GOOS == "windows" {
|
||||
// Directory handles cannot be synced on Windows, so every call is a
|
||||
// no-op there, including one for a directory that does not exist.
|
||||
if err != nil {
|
||||
t.Fatalf("syncConfigDir must be a no-op on windows: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
// A directory that is gone means the persistence barrier was never
|
||||
// reached, so the caller must not acknowledge the save.
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("missing directory: error = %v, want %v", err, os.ErrNotExist)
|
||||
}
|
||||
// A directory the user can write but not read cannot be opened for
|
||||
// syncing; that is best effort, as in the sessions store, because the
|
||||
// write it follows has already succeeded.
|
||||
unreadable := filepath.Join(dir, "unreadable")
|
||||
if err := os.Mkdir(unreadable, 0o300); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(unreadable, 0o700) })
|
||||
if err := syncConfigDir(unreadable); err != nil {
|
||||
t.Fatalf("write-only directory must be best effort: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetActiveProviderSwitchesConfiguredProvider(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
@@ -426,6 +718,286 @@ func TestSetThemePreservesUnknownTopLevelFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// setNotify replaces the stored notify block with value through UpdateNotify
|
||||
// and returns the re-read config, so the tests below can assert on a
|
||||
// FileConfig-shaped result. Every product surface uses UpdateNotify's merge
|
||||
// form directly; this replace form exists only for these tests.
|
||||
func setNotify(path string, value NotifyConfig) (FileConfig, error) {
|
||||
next, err := UpdateNotify(path, func(NotifyConfig) NotifyConfig { return value })
|
||||
if err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
cfg, err := loadConfigFile(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
cfg.Notify = next
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func TestSetNotifyPersistsValidValues(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "openai",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "openai", ProviderKind: ProviderKindOpenAI, Model: "gpt-4.1"},
|
||||
},
|
||||
}, 0o600)
|
||||
|
||||
cfg, err := setNotify(path, NotifyConfig{Mode: " both ", FocusMode: " unfocused "})
|
||||
if err != nil {
|
||||
t.Fatalf("setNotify() error = %v", err)
|
||||
}
|
||||
if cfg.Notify.Mode != "both" || cfg.Notify.FocusMode != "unfocused" {
|
||||
t.Fatalf("Notify = %+v, want mode=both focusMode=unfocused (trimmed)", cfg.Notify)
|
||||
}
|
||||
persisted := readConfigFixture(t, path)
|
||||
if persisted.Notify.Mode != "both" || persisted.Notify.FocusMode != "unfocused" {
|
||||
t.Fatalf("persisted Notify = %+v, want mode=both focusMode=unfocused", persisted.Notify)
|
||||
}
|
||||
if persisted.ActiveProvider != "openai" || len(persisted.Providers) != 1 {
|
||||
t.Fatalf("provider config was not preserved by setNotify: %#v", persisted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetNotifyRejectsInvalidMode(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{ActiveProvider: "openai"}, 0o600)
|
||||
if _, err := setNotify(path, NotifyConfig{Mode: "loud", FocusMode: "unfocused"}); err == nil {
|
||||
t.Fatal("expected error for invalid notify.mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetNotifyRejectsInvalidFocusMode(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{ActiveProvider: "openai"}, 0o600)
|
||||
if _, err := setNotify(path, NotifyConfig{Mode: "off", FocusMode: "sideways"}); err == nil {
|
||||
t.Fatal("expected error for invalid notify.focusMode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetNotifyRejectsEmptyConfigPath(t *testing.T) {
|
||||
if _, err := setNotify("", NotifyConfig{Mode: "off"}); err == nil {
|
||||
t.Fatal("expected error for empty config path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetNotifyBlankValuesPreservedAsDefaults(t *testing.T) {
|
||||
// An empty mode/focusMode stored on disk is a valid "use the built-in
|
||||
// defaults" signal — setNotify must not reject blanks, and they must round
|
||||
// trip unchanged.
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{ActiveProvider: "openai"}, 0o600)
|
||||
if _, err := setNotify(path, NotifyConfig{}); err != nil {
|
||||
t.Fatalf("setNotify({}) should accept blank values, got error: %v", err)
|
||||
}
|
||||
persisted := readConfigFixture(t, path)
|
||||
if persisted.Notify.Mode != "" || persisted.Notify.FocusMode != "" {
|
||||
t.Fatalf("blank notify values should round-trip, got %+v", persisted.Notify)
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainer regression (PR #1001): a notification save must preserve unrelated
|
||||
// values whose EXPLICIT presence matters. The typed serializer's omitempty
|
||||
// cannot round-trip tools.deferThreshold: 0 (explicit "never defer" vs unset
|
||||
// "use default 3") or an MCP server's disabled: false (explicitly enabled), so
|
||||
// UpdateNotify edits only the notify member's bytes instead. The
|
||||
// assertions run against the RAW file, because reading through FileConfig
|
||||
// would hide exactly the presence loss being tested.
|
||||
func TestSetNotifyPreservesExplicitUnrelatedValues(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
for name, body := range map[string]string{
|
||||
"explicit zero deferThreshold": `{
|
||||
"tools": {"deferThreshold": 0},
|
||||
"mcp": {"servers": {"firecrawl": {"command": "npx", "disabled": false}}}
|
||||
}`,
|
||||
"unknown keys stay untouched": `{
|
||||
"activeProvider": "openai",
|
||||
"customTopLevel": {"nested": [1, 2, 3]},
|
||||
"notify": {"mode": "both"}
|
||||
}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
path := filepath.Join(dir, name+".json")
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := setNotify(path, NotifyConfig{Mode: "off", FocusMode: "always"}); err != nil {
|
||||
t.Fatalf("setNotify: %v", err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if strings.Contains(name, "explicit zero") {
|
||||
tools, ok := obj["tools"]
|
||||
if !ok || !strings.Contains(string(tools), `"deferThreshold": 0`) {
|
||||
t.Errorf("tools.deferThreshold: 0 lost through a notify write; tools = %s", string(tools))
|
||||
}
|
||||
mcp, ok := obj["mcp"]
|
||||
if !ok || !strings.Contains(string(mcp), `"disabled": false`) {
|
||||
t.Errorf("mcp disabled: false lost through a notify write; mcp = %s", string(mcp))
|
||||
}
|
||||
}
|
||||
if strings.Contains(name, "unknown keys") {
|
||||
if _, ok := obj["customTopLevel"]; !ok {
|
||||
t.Error("unknown top-level key lost through a notify write")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainer regression (PR #1001, CodeRabbit follow-up): a hand-edited config
|
||||
// may contain DUPLICATE notify members (JSON decoders tolerate them and the
|
||||
// last occurrence wins). A reset must remove EVERY notify member — removing
|
||||
// only the last leaves the earlier block as the new effective preference, so
|
||||
// `zero config notify --reset` would report success while the old value still
|
||||
// applies.
|
||||
func TestSetNotifyResetRemovesEveryDuplicateNotifyMember(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
duplicated := `{"notify":{"mode":"off"},"activeProvider":"openai","notify":{"mode":"bell"}}`
|
||||
if err := os.WriteFile(path, []byte(duplicated), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := setNotify(path, NotifyConfig{}); err != nil {
|
||||
t.Fatalf("setNotify({}) reset: %v", err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if _, still := obj["notify"]; still {
|
||||
t.Fatalf("reset left a notify member behind: %s", string(raw))
|
||||
}
|
||||
if obj["activeProvider"] == nil || string(obj["activeProvider"]) != `"openai"` {
|
||||
t.Errorf("unrelated member lost through the reset: %s", string(raw))
|
||||
}
|
||||
|
||||
// A reset on a file with NO notify member is a no-op, and a partial update
|
||||
// against duplicates replaces the LAST member (the effective one under
|
||||
// last-occurrence-wins decoding).
|
||||
if err := os.WriteFile(path, []byte(duplicated), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := setNotify(path, NotifyConfig{Mode: "off", FocusMode: "always"}); err != nil {
|
||||
t.Fatalf("setNotify replace: %v", err)
|
||||
}
|
||||
raw, err = os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, err := UserNotify(path)
|
||||
if err != nil {
|
||||
t.Fatalf("UserNotify: %v", err)
|
||||
}
|
||||
if stored.Mode != "off" || stored.FocusMode != "always" {
|
||||
t.Fatalf("after replace, effective notify = %+v, want off/always (last member replaced)", stored)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"activeProvider"`) {
|
||||
t.Errorf("unrelated member lost through the replace: %s", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainer regression (PR #1001): concurrent partial updates must not lose
|
||||
// each other's explicit change. Two `zero config notify` calls (--mode off and
|
||||
// --focus always) racing from both/unfocused previously interleaved their
|
||||
// read-merge-write and one write silently undid the other (reproduced 50/50
|
||||
// without the lock). UpdateNotify serializes the whole transaction.
|
||||
func TestUpdateNotifyConcurrentPartialUpdatesLoseNoField(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
if err := os.WriteFile(path, []byte(`{"notify":{"mode":"both","focusMode":"unfocused"}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 2)
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := UpdateNotify(path, func(current NotifyConfig) NotifyConfig {
|
||||
current.Mode = "off"
|
||||
return current
|
||||
}); err != nil {
|
||||
errs <- fmt.Errorf("mode update: %w", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := UpdateNotify(path, func(current NotifyConfig) NotifyConfig {
|
||||
current.FocusMode = "always"
|
||||
return current
|
||||
}); err != nil {
|
||||
errs <- fmt.Errorf("focus update: %w", err)
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatalf("concurrent update failed: %v", err)
|
||||
}
|
||||
|
||||
stored, err := UserNotify(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read stored: %v", err)
|
||||
}
|
||||
// BOTH explicit changes must survive: mode from writer 1, focus from
|
||||
// writer 2. The old race let the second writer's stale focus
|
||||
// ("unfocused") or stale mode ("both") win.
|
||||
if stored.Mode != "off" {
|
||||
t.Errorf("mode = %q, want off (concurrent focus update must not clobber it)", stored.Mode)
|
||||
}
|
||||
if stored.FocusMode != "always" {
|
||||
t.Errorf("focusMode = %q, want always (concurrent mode update must not clobber it)", stored.FocusMode)
|
||||
}
|
||||
}
|
||||
|
||||
// UserNotify reads the notify block from the user's own file. Partial updates
|
||||
// seed from this value so they preserve what the USER chose (blank included)
|
||||
// instead of copying a project config's setting or a pinned default into the
|
||||
// global file (maintainer review, PR #1001).
|
||||
func TestUserNotify(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Missing file: zero value, no error.
|
||||
got, err := UserNotify(filepath.Join(dir, "missing.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing file should not error: %v", err)
|
||||
}
|
||||
if got.Mode != "" || got.FocusMode != "" {
|
||||
t.Fatalf("missing file = %+v, want zero value", got)
|
||||
}
|
||||
|
||||
// Present block: trimmed values returned.
|
||||
path := filepath.Join(dir, "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{Notify: NotifyConfig{Mode: " bell ", FocusMode: " always "}}, 0o600)
|
||||
got, err = UserNotify(path)
|
||||
if err != nil {
|
||||
t.Fatalf("UserNotify: %v", err)
|
||||
}
|
||||
if got.Mode != "bell" || got.FocusMode != "always" {
|
||||
t.Fatalf("UserNotify = %+v, want bell/always (trimmed)", got)
|
||||
}
|
||||
|
||||
// Blank path: zero value, no error.
|
||||
if got, err = UserNotify(""); err != nil || got.Mode != "" || got.FocusMode != "" {
|
||||
t.Fatalf("blank path = %+v err=%v, want zero value", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecapsPreferenceRoundTrips(t *testing.T) {
|
||||
// Default (unset) is ON.
|
||||
if !(PreferencesConfig{}).RecapsEnabled() {
|
||||
@@ -1078,3 +1650,109 @@ func TestEditProviderRejectsCollisionAndUnknown(t *testing.T) {
|
||||
t.Fatalf("config was rewritten by a rejected edit")
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainer regression (PR #1001): notify writes must contend with EXISTING
|
||||
// config mutators, not only each other. UpdateNotify originally took a private
|
||||
// lock (acquireConfigLock); on Windows its LockFileEx range did not overlap
|
||||
// lockutil's, so `zero config notify --mode off` could race a theme (or
|
||||
// provider/credential/MCP) write and silently discard it — the later rename
|
||||
// wins and the other acknowledged change is gone. UpdateNotify now uses the
|
||||
// same lockConfigFileFn authority as every other mutator; this race holds it
|
||||
// against SetTheme and requires BOTH acknowledged edits to survive.
|
||||
func TestUpdateNotifyRacesExistingConfigMutatorWithoutLoss(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
if err := os.WriteFile(path, []byte(`{"notify":{"mode":"both","focusMode":"unfocused"}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 2)
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := UpdateNotify(path, func(NotifyConfig) NotifyConfig {
|
||||
return NotifyConfig{Mode: "off", FocusMode: "always"}
|
||||
}); err != nil {
|
||||
errs <- fmt.Errorf("notify write: %w", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := SetTheme(path, "dracula"); err != nil {
|
||||
errs <- fmt.Errorf("theme write: %w", err)
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := readConfigFixture(t, path)
|
||||
if cfg.Notify.Mode != "off" || cfg.Notify.FocusMode != "always" {
|
||||
t.Errorf("notify lost the race: %+v, want off/always — the two writers did not contend on one lock", cfg.Notify)
|
||||
}
|
||||
if cfg.Preferences.Theme != "dracula" {
|
||||
t.Errorf("theme lost the race: %q, want dracula — the two writers did not contend on one lock", cfg.Preferences.Theme)
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainer regression (PR #1001, jatmn review): Go's decoder MERGES the
|
||||
// fields of duplicate JSON members into one struct, so a full replacement that
|
||||
// omits a field could inherit it from an earlier duplicate —
|
||||
// {"notify":{"mode":"off"},"notify":{"focusMode":"focused"}} +
|
||||
// setNotify({FocusMode:"always"}) must yield blank/always, not off/always.
|
||||
// The editor removes every duplicate and inserts the replacement fresh.
|
||||
func TestSetNotifyReplacementInheritsNoFieldFromEarlierDuplicate(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
duplicated := `{"notify":{"mode":"off"},"notify":{"focusMode":"focused"},"activeProvider":"openai"}`
|
||||
if err := os.WriteFile(path, []byte(duplicated), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := setNotify(path, NotifyConfig{FocusMode: "always"}); err != nil {
|
||||
t.Fatalf("setNotify: %v", err)
|
||||
}
|
||||
stored, err := UserNotify(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.Mode != "" || stored.FocusMode != "always" {
|
||||
t.Fatalf("replacement inherited a field from an earlier duplicate: %+v, want blank/always", stored)
|
||||
}
|
||||
|
||||
// Unrelated bytes still survive the rewrite.
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"activeProvider":"openai"`) {
|
||||
t.Fatalf("unrelated member lost through the replace: %s", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainer regression (PR #1001, Vasanthdev2004 review): the duplicate-key
|
||||
// fix must not reach the ORDINARY single-member file. Removing and re-inserting
|
||||
// the notify member there moved the key to the end of the object and collapsed
|
||||
// its formatting, so every /notify toggle reordered the user's config — JSON
|
||||
// member order carries no meaning, but a byte-preserving write should not
|
||||
// reformat anything it does not have to. A single notify member is replaced
|
||||
// in place: key position and surrounding formatting unchanged.
|
||||
func TestSetNotifySingleMemberReplacedInPlace(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
original := "{\n \"activeProvider\": \"openai\",\n \"notify\": {\"mode\": \"both\", \"focusMode\": \"unfocused\"},\n \"preferences\": {\"theme\": \"dracula\"}\n}\n"
|
||||
if err := os.WriteFile(path, []byte(original), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := setNotify(path, NotifyConfig{Mode: "off", FocusMode: "always"}); err != nil {
|
||||
t.Fatalf("setNotify: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "{\n \"activeProvider\": \"openai\",\n \"notify\": {\"mode\":\"off\",\"focusMode\":\"always\"},\n \"preferences\": {\"theme\": \"dracula\"}\n}\n"
|
||||
if string(raw) != want {
|
||||
t.Fatalf("single-member replace moved or reformatted unrelated bytes:\n got: %q\nwant: %q", string(raw), want)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,61 @@
|
||||
package cron
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/fsutil"
|
||||
)
|
||||
|
||||
func TestAppendRunRetainsNewestThousand(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job, err := store.Add(Job{Expr: "* * * * *", Prompt: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Seed an oversized legacy log, then cross the boundary again after compaction.
|
||||
var history bytes.Buffer
|
||||
for i := 0; i < 1207; i++ {
|
||||
if err := json.NewEncoder(&history).Encode(RunRecord{ExitCode: i}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
|
||||
if err := os.WriteFile(path, history.Bytes(), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, next := range []int{1207, 1208} {
|
||||
if err := store.AppendRun(job.ID, RunRecord{ExitCode: next}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := bytes.Split(bytes.TrimSpace(data), []byte("\n"))
|
||||
if len(lines) != 1000 {
|
||||
t.Fatalf("retained %d records, want 1000", len(lines))
|
||||
}
|
||||
for i, line := range lines {
|
||||
var rec RunRecord
|
||||
if err := json.Unmarshal(line, &rec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.ExitCode != next-999+i {
|
||||
t.Fatalf("record %d = %d, want %d", i, rec.ExitCode, next-999+i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AppendRun on a job removed mid-run must NOT resurrect its directory (which the
|
||||
// old unconditional MkdirAll did, leaving an orphaned runs.jsonl with no
|
||||
// metadata.json).
|
||||
@@ -24,7 +75,7 @@ func TestAppendRunDoesNotResurrectRemovedJob(t *testing.T) {
|
||||
if _, err := os.Stat(store.jobDir(job.ID)); !os.IsNotExist(err) {
|
||||
t.Fatalf("AppendRun resurrected the removed job directory (stat err = %v)", err)
|
||||
}
|
||||
runs, err := store.Runs(job.ID)
|
||||
runs, err := store.Runs(job.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Runs: %v", err)
|
||||
}
|
||||
@@ -32,3 +83,256 @@ func TestAppendRunDoesNotResurrectRemovedJob(t *testing.T) {
|
||||
t.Fatalf("expected no runs for a removed job, got %d", len(runs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunsTail(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job, err := store.Add(Job{Prompt: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var history bytes.Buffer
|
||||
// A forward scan would fail on this old oversized line. A recent-window
|
||||
// reader must stop before reaching it, rather than merely cap its output.
|
||||
history.WriteString(strings.Repeat("x", 1024*1024+1) + "\n")
|
||||
for i := 0; i < 1103; i++ {
|
||||
if err := json.NewEncoder(&history).Encode(RunRecord{ExitCode: i, SessionTitle: strings.Repeat("界", 1500)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
history.WriteString("bad json\n\n{\"exitCode\":1103}") // no final newline
|
||||
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
|
||||
if err := os.WriteFile(path, history.Bytes(), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, limit := range []int{1, 7, 999, 1000, 1001, 0, -1} {
|
||||
runs, err := store.Runs(job.ID, limit)
|
||||
want := limit
|
||||
if want <= 0 || want > 1000 {
|
||||
want = 1000
|
||||
}
|
||||
if err != nil || len(runs) != want {
|
||||
t.Fatalf("limit %d: got %d records, err %v", limit, len(runs), err)
|
||||
}
|
||||
for i, run := range runs {
|
||||
if run.ExitCode != 1104-want+i {
|
||||
t.Fatalf("limit %d, record %d = %d", limit, i, run.ExitCode)
|
||||
}
|
||||
if run.ExitCode != 1103 && run.SessionTitle != strings.Repeat("界", 1500) {
|
||||
t.Fatal("record spanning read blocks was corrupted")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendRunFailurePreservesHistory(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job, err := store.Add(Job{Prompt: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
|
||||
for _, data := range []string{"{\"exitCode\":7}\n", strings.Repeat("x", 1024*1024)} {
|
||||
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := RunRecord{ExitCode: 8, Error: strings.Repeat("x", 1024*1024)}
|
||||
if err := store.AppendRun(job.ID, rec); err == nil {
|
||||
t.Fatal("expected oversized new record error")
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil || string(got) != data {
|
||||
t.Fatalf("failed append changed history: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendRunWithOversizedHistory(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job, err := store.Add(Job{Prompt: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
|
||||
// An oversized unterminated legacy record must not block future outcomes.
|
||||
if err := os.WriteFile(path, []byte(strings.Repeat("x", maxRunBytes)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.AppendRun(job.ID, RunRecord{ExitCode: 42}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runs, err := store.Runs(job.ID, 1)
|
||||
if err != nil || len(runs) != 1 || runs[0].ExitCode != 42 {
|
||||
t.Fatalf("new outcome lost: %+v, %v", runs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunsSkipOversizedLines(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job, err := store.Add(Job{Prompt: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
|
||||
for _, size := range []int{maxRunBytes - 1, maxRunBytes, maxRunBytes + 4096} {
|
||||
// Valid JSON plus trailing whitespace catches accidental decoding of a
|
||||
// prefix after dropping only the oversized suffix. The exact boundary
|
||||
// also distinguishes an oversized line from the largest allowed line.
|
||||
line := `{"exitCode":99}`
|
||||
line += strings.Repeat(" ", size-len(line))
|
||||
data := line + "\n{\"exitCode\":7}\n" + line + "\n{\"exitCode\":42}\n" + line
|
||||
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runs, err := store.Runs(job.ID, 10)
|
||||
want := []int{7, 42}
|
||||
if size < maxRunBytes {
|
||||
want = []int{99, 7, 99, 42, 99}
|
||||
}
|
||||
if err != nil || len(runs) != len(want) {
|
||||
t.Fatalf("size %d: got %+v, err %v; want %v", size, runs, err, want)
|
||||
}
|
||||
for i, rec := range runs {
|
||||
if rec.ExitCode != want[i] {
|
||||
t.Fatalf("size %d: record %d = %d, want %d", size, i, rec.ExitCode, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An outside reader holding runs.jsonl open makes the Windows replace fail;
|
||||
// the outcome must still be recorded rather than dropped.
|
||||
func TestAppendRunWhileHistoryHeldOpen(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job, err := store.Add(Job{Prompt: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
|
||||
if err := os.WriteFile(path, []byte("{\"exitCode\":7}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
held, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer held.Close()
|
||||
if err := store.AppendRun(job.ID, RunRecord{ExitCode: 42}); err != nil {
|
||||
t.Fatalf("AppendRun with history held open: %v", err)
|
||||
}
|
||||
runs, err := store.Runs(job.ID, 10)
|
||||
if err != nil || len(runs) != 2 || runs[0].ExitCode != 7 || runs[1].ExitCode != 42 {
|
||||
t.Fatalf("outcome lost while history held open: %+v, %v", runs, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Only a sharing or lock violation falls back to appending. Any other replace
|
||||
// failure, including a persistent access denial, and a committed replacement
|
||||
// whose backup cleanup failed must leave the log untouched, so history can
|
||||
// neither grow past retention nor record an outcome twice.
|
||||
func TestAppendRunFallbackOnlyForSharingViolation(t *testing.T) {
|
||||
const seed = "{\"exitCode\":7}\n"
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
append bool
|
||||
}{
|
||||
{"sharing violation", syscall.Errno(32), runtime.GOOS == "windows"},
|
||||
{"access denied", os.ErrPermission, false},
|
||||
{"other", errors.New("boom"), false},
|
||||
{"committed", &fsutil.CommittedReplacementCleanupError{Cause: syscall.Errno(32)}, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job, err := store.Add(Job{Prompt: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
|
||||
if err := os.WriteFile(path, []byte(seed), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.replace = func(string, string) error { return tc.err }
|
||||
err = store.AppendRun(job.ID, RunRecord{ExitCode: 42})
|
||||
data, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if tc.append {
|
||||
if err != nil || !strings.HasPrefix(string(data), seed) || !strings.Contains(string(data), "\"exitCode\":42") {
|
||||
t.Fatalf("sharing violation dropped the outcome: err %v, log %q", err, data)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, tc.err) {
|
||||
t.Fatalf("AppendRun error = %v, want %v", err, tc.err)
|
||||
}
|
||||
if string(data) != seed {
|
||||
t.Fatalf("non-transient replace failure changed the log: %q", data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The fallback append must start a fresh line even when the log ends in an
|
||||
// unterminated record, so neither record is corrupted.
|
||||
func TestAppendRunLineAfterUnterminatedTail(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job, err := store.Add(Job{Prompt: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
|
||||
if err := os.WriteFile(path, []byte("{\"exitCode\":7}"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := appendRunLine(path, []byte("{\"exitCode\":42}")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runs, err := store.Runs(job.ID, 10)
|
||||
if err != nil || len(runs) != 2 || runs[0].ExitCode != 7 || runs[1].ExitCode != 42 {
|
||||
t.Fatalf("fallback append corrupted history: %+v, %v", runs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHistoryConcurrentStores(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job, err := store.Add(Job{Prompt: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var history bytes.Buffer
|
||||
for i := 0; i < 1000; i++ {
|
||||
if err := json.NewEncoder(&history).Encode(RunRecord{ExitCode: -1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(store.jobDir(job.ID), "runs.jsonl"), history.Bytes(), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 12; i++ {
|
||||
wg.Go(func() {
|
||||
other := NewStore(StoreOptions{RootDir: store.root})
|
||||
if err := other.AppendRun(job.ID, RunRecord{ExitCode: i}); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
runs, err := other.Runs(job.ID, 1000)
|
||||
if err != nil || len(runs) != 1000 {
|
||||
t.Errorf("inconsistent history: %d records, err %v", len(runs), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
runs, err := store.Runs(job.ID, 12)
|
||||
if err != nil || len(runs) != 12 {
|
||||
t.Fatalf("final tail: %d records, err %v", len(runs), err)
|
||||
}
|
||||
seen := make(map[int]bool)
|
||||
for _, run := range runs {
|
||||
if run.ExitCode < 0 || run.ExitCode >= 12 || seen[run.ExitCode] {
|
||||
t.Fatalf("lost or duplicate concurrent append: %+v", runs)
|
||||
}
|
||||
seen[run.ExitCode] = true
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -16,6 +17,10 @@ import (
|
||||
const (
|
||||
StatusActive = "active"
|
||||
StatusPaused = "paused"
|
||||
|
||||
// MaxRunHistory is the number of recent outcomes retained per job.
|
||||
MaxRunHistory = 1000
|
||||
maxRunBytes = 1024 * 1024
|
||||
)
|
||||
|
||||
// ErrJobNotFound is returned (wrapped) by Get when a job's metadata file is
|
||||
@@ -56,6 +61,9 @@ type StoreOptions struct {
|
||||
type Store struct {
|
||||
root string
|
||||
now func() time.Time
|
||||
// replace overrides the run-history replacement primitive in tests; nil
|
||||
// uses the platform default.
|
||||
replace func(src, dst string) error
|
||||
}
|
||||
|
||||
func NewStore(opts StoreOptions) *Store {
|
||||
@@ -228,8 +236,7 @@ func (s *Store) Update(job Job) error {
|
||||
// Remove). It re-reads the CURRENT on-disk job and passes it to mutate along with
|
||||
// any read error; mutate returns the job to persist. A removed job aborts with
|
||||
// ErrJobNotFound (no recreate). A transient read error (not removal) is surfaced
|
||||
// via readErr so the caller can still persist a best-effort state — the fire path
|
||||
// advances the schedule regardless, to avoid a re-fire.
|
||||
// via readErr; the callback can return it to abort without overwriting state.
|
||||
func (s *Store) Mutate(id string, mutate func(current Job, readErr error) (Job, error)) (Job, error) {
|
||||
if !validID(id) {
|
||||
return Job{}, fmt.Errorf("invalid cron job id %q", id)
|
||||
@@ -309,10 +316,8 @@ func (s *Store) AppendRun(id string, rec RunRecord) error {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
// Bail if the job was removed (e.g. mid-run) — otherwise the MkdirAll below
|
||||
// would resurrect a deleted job's directory with an orphaned runs.jsonl and no
|
||||
// metadata.json (which Runs would then still return). Under the per-job lock
|
||||
// this stat-then-write is race-free against a concurrent Remove.
|
||||
// Do not resurrect a job removed mid-run. The lock serializes this check
|
||||
// and publication against Remove and other history writers/readers.
|
||||
if _, err := os.Stat(filepath.Join(s.jobDir(id), "metadata.json")); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
@@ -320,30 +325,98 @@ func (s *Store) AppendRun(id string, rec RunRecord) error {
|
||||
return err
|
||||
}
|
||||
dir := s.jobDir(id)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(filepath.Join(dir, "runs.jsonl"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
line, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(line) >= maxRunBytes {
|
||||
return fmt.Errorf("cron run record exceeds %d bytes", maxRunBytes-1)
|
||||
}
|
||||
runs, err := s.readRuns(id, MaxRunHistory-1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Publish a complete snapshot even below the retention boundary so readers
|
||||
// never see a partial append. Legacy oversized logs compact on their next run.
|
||||
f, err := os.CreateTemp(dir, "runs-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
w := bufio.NewWriter(f)
|
||||
for _, run := range runs {
|
||||
if err := json.NewEncoder(w).Encode(run); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := w.Write(append(line, '\n')); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := f.Write(append(line, '\n')); err != nil {
|
||||
if err := w.Flush(); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
// Surface a buffered-write failure that only materializes on Close.
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(dir, "runs.jsonl")
|
||||
// ReplaceWithRetry keeps a DACL applied to runs.jsonl itself; a rename would
|
||||
// publish the temporary file's inherited directory DACL instead.
|
||||
err = fsutil.ReplaceWithRetry(f.Name(), path, s.replace)
|
||||
var committed *fsutil.CommittedReplacementCleanupError
|
||||
if err == nil || errors.As(err, &committed) || !fsutil.IsSharingOrLockViolation(err) {
|
||||
return err
|
||||
}
|
||||
// Windows refuses to replace a log another process holds open. Append the
|
||||
// record instead so the outcome survives; compaction resumes on a later run.
|
||||
// Other failures are returned so a log that can never be replaced cannot
|
||||
// grow past the retention limit. The leading newline ends any unterminated
|
||||
// tail, and readRuns skips the blank line it may leave. Zero's own readers
|
||||
// hold the job lock.
|
||||
if appendErr := appendRunLine(path, line); appendErr != nil {
|
||||
return errors.Join(err, appendErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendRunLine(path string, line []byte) error {
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
record := append(append([]byte{'\n'}, line...), '\n')
|
||||
if _, err := f.Write(record); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
func (s *Store) Runs(id string) ([]RunRecord, error) {
|
||||
// Runs returns the newest limit valid records in append order (oldest first).
|
||||
// Non-positive limits and limits above MaxRunHistory use MaxRunHistory. It reads
|
||||
// backwards from the tail, including for legacy logs not yet compacted.
|
||||
func (s *Store) Runs(id string, limit int) ([]RunRecord, error) {
|
||||
if !validID(id) {
|
||||
return nil, fmt.Errorf("invalid cron job id %q", id)
|
||||
}
|
||||
if limit <= 0 || limit > MaxRunHistory {
|
||||
limit = MaxRunHistory
|
||||
}
|
||||
unlock, err := s.lockJob(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer unlock()
|
||||
return s.readRuns(id, limit)
|
||||
}
|
||||
|
||||
// readRuns requires the job lock. Memory is bounded by limit records plus one
|
||||
// line. Malformed and oversized legacy lines are skipped so they cannot prevent
|
||||
// recording new outcomes during compaction.
|
||||
func (s *Store) readRuns(id string, limit int) ([]RunRecord, error) {
|
||||
f, err := os.Open(filepath.Join(s.jobDir(id), "runs.jsonl"))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
@@ -352,14 +425,48 @@ func (s *Store) Runs(id string) ([]RunRecord, error) {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var runs []RunRecord
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
var line []byte
|
||||
oversized := false
|
||||
decode := func() {
|
||||
slices.Reverse(line)
|
||||
var rec RunRecord
|
||||
if json.Unmarshal(scanner.Bytes(), &rec) == nil {
|
||||
if json.Unmarshal(line, &rec) == nil {
|
||||
runs = append(runs, rec)
|
||||
}
|
||||
line = line[:0]
|
||||
}
|
||||
return runs, scanner.Err()
|
||||
var block [4096]byte
|
||||
for end := info.Size(); end > 0 && len(runs) < limit; {
|
||||
start := max(int64(0), end-int64(len(block)))
|
||||
n, err := f.ReadAt(block[:end-start], start)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := n - 1; i >= 0 && len(runs) < limit; i-- {
|
||||
if block[i] == '\n' {
|
||||
if !oversized {
|
||||
decode()
|
||||
}
|
||||
oversized = false
|
||||
} else if !oversized {
|
||||
if len(line) >= maxRunBytes-1 {
|
||||
line = line[:0]
|
||||
oversized = true
|
||||
continue
|
||||
}
|
||||
line = append(line, block[i])
|
||||
}
|
||||
}
|
||||
end = start
|
||||
}
|
||||
if len(runs) < limit && len(line) > 0 {
|
||||
decode()
|
||||
}
|
||||
slices.Reverse(runs)
|
||||
return runs, nil
|
||||
}
|
||||
@@ -238,7 +238,7 @@ func TestStoreAppendRun(t *testing.T) {
|
||||
t.Fatalf("AppendRun: %v", err)
|
||||
}
|
||||
}
|
||||
runs, err := s.Runs(job.ID)
|
||||
runs, err := s.Runs(job.ID, 0)
|
||||
if err != nil || len(runs) != 3 || runs[2].ExitCode != 2 {
|
||||
t.Fatalf("Runs=%v err=%v", runs, err)
|
||||
}
|
||||
@@ -280,7 +280,7 @@ func TestStoreRejectsUnsafeID(t *testing.T) {
|
||||
if _, err := s.Get(id); err == nil {
|
||||
t.Fatalf("Get(%q) must be rejected", id)
|
||||
}
|
||||
if _, err := s.Runs(id); err == nil {
|
||||
if _, err := s.Runs(id, 0); err == nil {
|
||||
t.Fatalf("Runs(%q) must be rejected", id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
//go:build windows
|
||||
|
||||
package cron
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// Compacting runs.jsonl publishes a temporary file carrying the job directory's
|
||||
// inherited DACL. A rename would replace a restrictive DACL applied to the log
|
||||
// itself, so AppendRun must keep the log's own descriptor.
|
||||
func TestAppendRunPreservesHistoryDACL(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job, err := store.Add(Job{Prompt: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
|
||||
if err := os.WriteFile(path, []byte("{\"exitCode\":7}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A protected DACL granting only the owner: distinct from whatever the
|
||||
// temporary file inherits from the directory.
|
||||
restricted, err := windows.SecurityDescriptorFromString("D:P(A;;FA;;;OW)")
|
||||
if err != nil {
|
||||
t.Skipf("cannot build a test security descriptor: %v", err)
|
||||
}
|
||||
dacl, _, err := restricted.DACL()
|
||||
if err != nil {
|
||||
t.Skipf("cannot read the test DACL: %v", err)
|
||||
}
|
||||
if err := windows.SetNamedSecurityInfo(
|
||||
path,
|
||||
windows.SE_FILE_OBJECT,
|
||||
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
|
||||
nil, nil, dacl, nil,
|
||||
); err != nil {
|
||||
t.Skipf("cannot apply a restrictive DACL on this filesystem: %v", err)
|
||||
}
|
||||
want := describeDACL(t, path)
|
||||
if inherited := describeDACL(t, store.jobDir(job.ID)); strings.Contains(inherited, want) {
|
||||
t.Skip("the job directory already carries the same DACL; this filesystem cannot show the difference")
|
||||
}
|
||||
|
||||
if err := store.AppendRun(job.ID, RunRecord{ExitCode: 42}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := describeDACL(t, path); got != want {
|
||||
t.Fatalf("DACL after AppendRun = %q, want the log's own %q", got, want)
|
||||
}
|
||||
runs, err := store.Runs(job.ID, 10)
|
||||
if err != nil || len(runs) != 2 || runs[1].ExitCode != 42 {
|
||||
t.Fatalf("outcome not published by replacement: %+v, %v", runs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func describeDACL(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
t.Skipf("cannot read the security descriptor of %s: %v", path, err)
|
||||
}
|
||||
text := sd.String()
|
||||
if index := strings.Index(text, "D:"); index >= 0 {
|
||||
return text[index:]
|
||||
}
|
||||
return text
|
||||
}
|
||||
@@ -72,9 +72,10 @@ func (w *execWorker) Kill() error {
|
||||
if w.cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
// background.TerminateProcess is the cross-platform terminate (kills the
|
||||
// process group on POSIX, taskkill /T on Windows).
|
||||
return background.TerminateProcess(w.cmd.Process.Pid)
|
||||
// The group the worker was launched into, not one rediscovered from its PID:
|
||||
// the pool may call this after the worker has exited and been waited, and its
|
||||
// children can still be running. See background.TerminateCommandGroup.
|
||||
return background.TerminateCommandGroup(w.cmd)
|
||||
}
|
||||
|
||||
// readerLines adapts a bufio.Reader to the Lines interface. Unlike a capped
|
||||
@@ -154,13 +155,11 @@ func NewExecLauncher(cfg ExecLauncherConfig) (Launcher, error) {
|
||||
background.ConfigureChildProcessGroup(cmd)
|
||||
// CommandContext's default cancel sends os.Process.Kill to the LEADER only,
|
||||
// orphaning the process group we just configured (a stuck worker's children
|
||||
// would survive ctx cancellation). Terminate the whole group instead — the
|
||||
// same cross-platform group terminate Kill() uses (D11).
|
||||
// would survive ctx cancellation). Terminate the whole group instead, the
|
||||
// same launch-time group terminate Kill() uses (D11). Cancel runs while Wait
|
||||
// is in progress, so it must not reap.
|
||||
cmd.Cancel = func() error {
|
||||
if cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
return background.TerminateProcess(cmd.Process.Pid)
|
||||
return background.TerminateCommandGroup(cmd)
|
||||
}
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A WORKER'S CHILDREN ARE STOPPED THROUGH THE GROUP IT WAS LAUNCHED INTO.
|
||||
//
|
||||
// Kill and the Cancel hook used to hand the worker's PID to
|
||||
// background.TerminateProcess, which looks the group up again with Getpgid when it
|
||||
// signals. Once the worker has exited and been waited, that lookup fails, only the
|
||||
// dead PID is signalled, and whatever the worker left running survives; on Darwin
|
||||
// the lookup already fails while the exited worker is still a zombie. The worker
|
||||
// here is a shell that backgrounds a sleep, prints its PID and exits, so after the
|
||||
// wait the sleep is the only process left in the worker's group. Reported in #861.
|
||||
func TestExecWorkerStopsWhatItLeftRunningAfterItExits(t *testing.T) {
|
||||
for _, stop := range []struct {
|
||||
name string
|
||||
run func(*execWorker) error
|
||||
}{
|
||||
{"Kill", func(worker *execWorker) error { return worker.Kill() }},
|
||||
{"Cancel hook", func(worker *execWorker) error { return worker.cmd.Cancel() }},
|
||||
} {
|
||||
t.Run(stop.name, func(t *testing.T) {
|
||||
launcher, err := NewExecLauncher(ExecLauncherConfig{
|
||||
Executable: "/bin/sh",
|
||||
BaseArgs: []string{"-c", "sleep 300 >/dev/null 2>&1 & echo $!"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewExecLauncher: %v", err)
|
||||
}
|
||||
handle, err := launcher(context.Background(), WorkerSpec{})
|
||||
if err != nil {
|
||||
t.Fatalf("launch: %v", err)
|
||||
}
|
||||
worker, ok := handle.(*execWorker)
|
||||
if !ok {
|
||||
t.Fatalf("SETUP INVALID: the launcher returned %T, not the exec worker under test", handle)
|
||||
}
|
||||
line, ok, err := worker.Stdout().Next()
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("read the child's pid: ok=%v err=%v", ok, err)
|
||||
}
|
||||
childPID, err := strconv.Atoi(strings.TrimSpace(line))
|
||||
if err != nil {
|
||||
t.Fatalf("parse the child's pid %q: %v", line, err)
|
||||
}
|
||||
t.Cleanup(func() { _ = syscall.Kill(childPID, syscall.SIGKILL) })
|
||||
|
||||
if _, err := worker.Wait(); err != nil {
|
||||
t.Fatalf("wait for the worker: %v", err)
|
||||
}
|
||||
if childStopped(childPID) {
|
||||
t.Fatalf("SETUP INVALID: the worker's child %d is not running, so there is nothing left to stop", childPID)
|
||||
}
|
||||
|
||||
// The pool ignores what Kill returns; the outcome is what matters.
|
||||
_ = stop.run(worker)
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for !childStopped(childPID) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("the worker's child %d survived %s after the worker was reaped", childPID, stop.name)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// childStopped reports whether pid is gone or only waiting to be reaped.
|
||||
func childStopped(pid int) bool {
|
||||
if errors.Is(syscall.Kill(pid, syscall.Signal(0)), syscall.ESRCH) {
|
||||
return true
|
||||
}
|
||||
state, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output()
|
||||
if err != nil {
|
||||
return errors.Is(syscall.Kill(pid, syscall.Signal(0)), syscall.ESRCH)
|
||||
}
|
||||
return strings.HasPrefix(strings.TrimSpace(string(state)), "Z")
|
||||
}
|
||||
@@ -17,6 +17,28 @@ const (
|
||||
ExitPermanent = 76
|
||||
)
|
||||
|
||||
// Exit codes `zero exec` (internal/cli/exec.go) uses to report a run that
|
||||
// finished, or was stopped on purpose, rather than crashed. They are duplicated
|
||||
// here because the daemon cannot import the CLI package; keep them in sync.
|
||||
const (
|
||||
exitUsage = 2 // bad flags or arguments: a retry fails the same way
|
||||
exitProvider = 3 // provider failure the run already surfaced
|
||||
exitIncomplete = 4 // the agent ran (edits, commands) and stopped unfinished
|
||||
exitInterrupted = 130 // SIGINT: someone stopped it on purpose
|
||||
)
|
||||
|
||||
// isFinalExit reports whether a worker exit code means the run is over and must
|
||||
// not be retried. Retrying would repeat the agent's side effects and re-bill the
|
||||
// provider, and append a second run to the same session stream. Only crash-type
|
||||
// exits (1, signals) and launch/read errors are worth another attempt.
|
||||
func isFinalExit(code int) bool {
|
||||
switch code {
|
||||
case exitUsage, exitProvider, exitIncomplete, exitInterrupted:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// defaultPoolSize, defaultMaxAttempts and defaultKillTimeout are used when a
|
||||
// PoolOptions field is left zero.
|
||||
const (
|
||||
@@ -176,7 +198,8 @@ type Sink interface {
|
||||
// Run leases a worker slot and dispatches spec to a worker, streaming its
|
||||
// stream-json lines to sink. It is at-least-once with bounded retries: a worker
|
||||
// that crashes (non-zero, non-permanent) is retried on a fresh worker after a
|
||||
// backoff; ExitPermanent or exhausting MaxAttempts returns ErrPermanent. Run
|
||||
// backoff; ExitPermanent, a final `zero exec` exit (usage, provider, incomplete,
|
||||
// interrupted) or exhausting MaxAttempts returns ErrPermanent. Run
|
||||
// queues when all slots are busy. The returned int is the final worker exit code.
|
||||
func (p *Pool) Run(ctx context.Context, spec WorkerSpec, sink Sink) (int, error) {
|
||||
if err := p.acquire(ctx); err != nil {
|
||||
@@ -209,6 +232,9 @@ func (p *Pool) Run(ctx context.Context, spec WorkerSpec, sink Sink) (int, error)
|
||||
case code == ExitPermanent:
|
||||
p.logf("worker %d exited permanently (code=%d) — not retrying", stat.id, code)
|
||||
return code, ErrPermanent
|
||||
case isFinalExit(code):
|
||||
p.logf("worker %d finished with exit code %d — not retrying", stat.id, code)
|
||||
return code, fmt.Errorf("%w: worker %d exited code=%d", ErrPermanent, stat.id, code)
|
||||
case code == ExitTempfail:
|
||||
lastErr = fmt.Errorf("worker %d tempfail (code=%d)", stat.id, code)
|
||||
p.logf("worker %d tempfail — retry after %s", stat.id, p.opts.TempfailDelay)
|
||||
|
||||
@@ -3,6 +3,7 @@ package daemon
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -148,6 +149,35 @@ func TestPoolRunPermanentStopsImmediately(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A `zero exec` run that ended with usage (2), provider (3), incomplete (4) or
|
||||
// interrupted (130) is over: retrying would repeat its side effects and merge a
|
||||
// second run into the same session stream.
|
||||
func TestPoolRunFinalExitCodesDoNotRetry(t *testing.T) {
|
||||
for _, code := range []int{2, 3, 4, 130} {
|
||||
t.Run(fmt.Sprintf("exit_%d", code), func(t *testing.T) {
|
||||
launcher, calls := seqLauncher(
|
||||
&fakeWorker{pid: 1, exitCode: code, out: []string{"first run"}},
|
||||
&fakeWorker{pid: 2, exitCode: 0, out: []string{"second run"}},
|
||||
)
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher, MaxAttempts: 5, Backoff: func(int) time.Duration { return 0 }})
|
||||
sink := &collectSink{}
|
||||
got, err := pool.Run(context.Background(), WorkerSpec{Session: "s"}, sink)
|
||||
if !errors.Is(err, ErrPermanent) {
|
||||
t.Fatalf("Run err = %v, want ErrPermanent", err)
|
||||
}
|
||||
if got != code {
|
||||
t.Fatalf("Run code = %d, want the worker's exit code %d", got, code)
|
||||
}
|
||||
if *calls != 1 {
|
||||
t.Fatalf("launcher calls = %d, want 1 (no retry)", *calls)
|
||||
}
|
||||
if len(sink.lines) != 1 || sink.lines[0] != "first run" {
|
||||
t.Fatalf("sink lines = %v, want only the first run's output", sink.lines)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolRunCapExhausted(t *testing.T) {
|
||||
launcher, calls := seqLauncher(
|
||||
&fakeWorker{pid: 1, exitCode: 1},
|
||||
|
||||
@@ -32,6 +32,9 @@ const (
|
||||
// ErrSessionExists is returned when starting a session ID already in use.
|
||||
var ErrSessionExists = errors.New("daemon: session already exists")
|
||||
|
||||
// ErrSessionOverloaded means the manager's unfinished-session limit is reached.
|
||||
var ErrSessionOverloaded = errors.New("daemon: pending session limit reached")
|
||||
|
||||
// ErrSessionNotFound is returned by Get/Attach for an unknown session ID.
|
||||
var ErrSessionNotFound = errors.New("daemon: session not found")
|
||||
|
||||
@@ -219,6 +222,9 @@ func (s *Session) isFinished() bool {
|
||||
// daemon does not accumulate finished sessions without limit.
|
||||
const defaultMaxSessions = 256
|
||||
|
||||
// defaultMaxPending bounds queued and running sessions independently of history.
|
||||
const defaultMaxPending = 256
|
||||
|
||||
// SessionManager owns the live session registry and routes each session to a
|
||||
// pool worker. Mirrors session-manager.js + roster.js. Finished sessions are
|
||||
// retained (so a late `attach` sees history) up to MaxSessions, past which the
|
||||
@@ -227,8 +233,10 @@ type SessionManager struct {
|
||||
pool *Pool
|
||||
maxBuffer int
|
||||
maxSessions int
|
||||
maxPending int
|
||||
|
||||
mu sync.Mutex
|
||||
pending int // queued + running; protected by mu
|
||||
sessions map[string]*Session
|
||||
order []string // creation order, for FIFO eviction of finished sessions
|
||||
}
|
||||
@@ -240,6 +248,9 @@ type SessionManagerOptions struct {
|
||||
// MaxSessions caps the retained session registry; 0 => default. The oldest
|
||||
// FINISHED sessions are evicted once the cap is exceeded.
|
||||
MaxSessions int
|
||||
// MaxPending caps unfinished sessions (queued plus running), independently
|
||||
// of retained history. Non-positive values use the default of 256.
|
||||
MaxPending int
|
||||
}
|
||||
|
||||
// NewSessionManager builds a manager over pool.
|
||||
@@ -251,10 +262,15 @@ func NewSessionManager(opts SessionManagerOptions) (*SessionManager, error) {
|
||||
if maxSessions <= 0 {
|
||||
maxSessions = defaultMaxSessions
|
||||
}
|
||||
maxPending := opts.MaxPending
|
||||
if maxPending <= 0 {
|
||||
maxPending = defaultMaxPending
|
||||
}
|
||||
return &SessionManager{
|
||||
pool: opts.Pool,
|
||||
maxBuffer: opts.MaxBuffer,
|
||||
maxSessions: maxSessions,
|
||||
maxPending: maxPending,
|
||||
sessions: map[string]*Session{},
|
||||
}, nil
|
||||
}
|
||||
@@ -262,13 +278,23 @@ func NewSessionManager(opts SessionManagerOptions) (*SessionManager, error) {
|
||||
// Start creates a session for spec and dispatches it to the pool. It returns the
|
||||
// Session immediately (non-blocking); the run proceeds in the background and the
|
||||
// session transitions queued -> running -> done/failed. A duplicate ID is
|
||||
// rejected with ErrSessionExists.
|
||||
// rejected with ErrSessionExists; a full admission limit returns
|
||||
// ErrSessionOverloaded without creating a session or background goroutine.
|
||||
func (m *SessionManager) Start(ctx context.Context, spec WorkerSpec) (*Session, error) {
|
||||
m.mu.Lock()
|
||||
if _, exists := m.sessions[spec.Session]; exists {
|
||||
m.mu.Unlock()
|
||||
return nil, ErrSessionExists
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
m.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
if m.pending >= m.maxPending {
|
||||
m.mu.Unlock()
|
||||
return nil, ErrSessionOverloaded
|
||||
}
|
||||
m.pending++
|
||||
sess := newSession(spec.Session, spec.Cwd, m.maxBuffer)
|
||||
m.sessions[spec.Session] = sess
|
||||
m.order = append(m.order, spec.Session)
|
||||
@@ -277,7 +303,11 @@ func (m *SessionManager) Start(ctx context.Context, spec WorkerSpec) (*Session,
|
||||
|
||||
go func() {
|
||||
code, err := m.pool.Run(ctx, spec, sess)
|
||||
m.mu.Lock()
|
||||
sess.finish(code, err)
|
||||
m.pending--
|
||||
m.pruneLocked()
|
||||
m.mu.Unlock()
|
||||
}()
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package daemon
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -208,6 +210,130 @@ func TestSessionManagerKeepsRunningOverCap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManagerAdmissionBound(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, waitCh: block})
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
|
||||
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool, MaxSessions: 2})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer func() {
|
||||
cancel()
|
||||
close(block)
|
||||
testutil.WaitFor(t, "all admitted sessions finished", func() bool {
|
||||
mgr.mu.Lock()
|
||||
defer mgr.mu.Unlock()
|
||||
return mgr.pending == 0
|
||||
})
|
||||
}()
|
||||
var wg sync.WaitGroup
|
||||
var accepted atomic.Int32
|
||||
for i := 0; i < 300; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := mgr.Start(ctx, WorkerSpec{Session: fmt.Sprintf("s%d", i)}); err == nil {
|
||||
accepted.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if got := accepted.Load(); got != 256 {
|
||||
t.Fatalf("accepted %d unfinished sessions, want default bound 256", got)
|
||||
}
|
||||
if got := len(mgr.Statuses()); got != 256 {
|
||||
t.Fatalf("registered %d sessions, want 256", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManagerCancellationReleasesAdmission(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
var release sync.Once
|
||||
defer release.Do(func() { close(block) })
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, waitCh: block}, &fakeWorker{pid: 2, exitCode: ExitPermanent}, &fakeWorker{pid: 3})
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
|
||||
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool, MaxPending: 2, MaxSessions: 10})
|
||||
first, err := mgr.Start(context.Background(), WorkerSpec{Session: "running"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testutil.WaitFor(t, "first running", func() bool { return first.State() == SessionRunning })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
queued, err := mgr.Start(ctx, WorkerSpec{Session: "queued"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s, err := mgr.Start(context.Background(), WorkerSpec{Session: "retry"}); s != nil || !errors.Is(err, ErrSessionOverloaded) {
|
||||
t.Fatalf("overloaded Start = (%v, %v)", s, err)
|
||||
}
|
||||
if _, ok := mgr.Get("retry"); ok {
|
||||
t.Fatal("rejected session was registered")
|
||||
}
|
||||
if _, err := mgr.Start(context.Background(), WorkerSpec{Session: "running"}); !errors.Is(err, ErrSessionExists) {
|
||||
t.Fatalf("duplicate at capacity: %v", err)
|
||||
}
|
||||
cancel()
|
||||
testutil.WaitFor(t, "queued cancellation", func() bool { return queued.State() == SessionFailed })
|
||||
if !errors.Is(queued.Err(), context.Canceled) {
|
||||
t.Fatalf("queued error = %v", queued.Err())
|
||||
}
|
||||
if s, err := mgr.Start(ctx, WorkerSpec{Session: "canceled"}); s != nil || !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("canceled Start = (%v, %v)", s, err)
|
||||
}
|
||||
retry, err := mgr.Start(context.Background(), WorkerSpec{Session: "retry"})
|
||||
if err != nil {
|
||||
t.Fatalf("capacity not released after cancellation: %v", err)
|
||||
}
|
||||
release.Do(func() { close(block) })
|
||||
testutil.WaitFor(t, "first completed", func() bool { return first.State() == SessionDone })
|
||||
testutil.WaitFor(t, "retry failed", func() bool { return retry.State() == SessionFailed })
|
||||
if !errors.Is(retry.Err(), ErrPermanent) {
|
||||
t.Fatalf("retry error = %v", retry.Err())
|
||||
}
|
||||
last, err := mgr.Start(context.Background(), WorkerSpec{Session: "last"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testutil.WaitFor(t, "last completed", func() bool { return last.State() == SessionDone })
|
||||
mgr.mu.Lock()
|
||||
defer mgr.mu.Unlock()
|
||||
if mgr.pending != 0 || len(mgr.sessions) != 4 {
|
||||
t.Fatalf("pending=%d retained=%d, want 0 and 4", mgr.pending, len(mgr.sessions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManagerPrunesOnCompletion(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
var release sync.Once
|
||||
defer release.Do(func() { close(block) })
|
||||
launcher, _ := seqLauncher(
|
||||
&fakeWorker{pid: 1, waitCh: block},
|
||||
&fakeWorker{pid: 2},
|
||||
&fakeWorker{pid: 3, exitCode: ExitPermanent},
|
||||
)
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
|
||||
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool, MaxSessions: 2})
|
||||
sessions := make([]*Session, 3)
|
||||
for i := range sessions {
|
||||
var err error
|
||||
sessions[i], err = mgr.Start(context.Background(), WorkerSpec{Session: fmt.Sprintf("s%d", i)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
release.Do(func() { close(block) })
|
||||
for _, s := range sessions {
|
||||
select {
|
||||
case <-s.Done():
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("session did not finish")
|
||||
}
|
||||
}
|
||||
if got := len(mgr.Statuses()); got != 2 {
|
||||
t.Fatalf("retained %d completed sessions without another Start, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStatuses(t *testing.T) {
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, out: []string{"l"}, exitCode: 0})
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
|
||||
|
||||
@@ -24,6 +24,7 @@ const (
|
||||
Connectivity
|
||||
ModelNotFound
|
||||
ContextOverflow
|
||||
LocalRPM
|
||||
)
|
||||
|
||||
// providerMarkers are the prefixes the provider layer attaches to every
|
||||
@@ -59,6 +60,8 @@ func Classify(err error) Category {
|
||||
return Unknown
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(m, "rate limit error: local rpm limit for"):
|
||||
return LocalRPM
|
||||
case containsAny(m, "auth error:", "unauthorized", "api key", "api_key", "invalid_api_key",
|
||||
"authentication", "permission denied", "forbidden") || containsStatusCode(m, "401", "403"):
|
||||
return Auth
|
||||
@@ -89,6 +92,8 @@ func TUIHint(err error) string {
|
||||
return "API key rejected — run /provider to re-check your credentials"
|
||||
case RateLimit:
|
||||
return "Rate limited — wait a moment, or switch model with /model"
|
||||
case LocalRPM:
|
||||
return "Local modelRPM cap reached — wait for the window to expire, or switch model with /model"
|
||||
case Connectivity:
|
||||
return "Can't reach the provider — run /doctor --connectivity"
|
||||
case ModelNotFound:
|
||||
@@ -109,6 +114,8 @@ func CLIHint(err error) string {
|
||||
return "API key rejected — run `zero setup`, `zero auth openrouter` for OpenRouter, or set the provider's API key"
|
||||
case RateLimit:
|
||||
return "Rate limited — wait a moment, or switch model with --model"
|
||||
case LocalRPM:
|
||||
return "Local modelRPM cap reached — wait for the window to expire, or switch model with --model"
|
||||
case Connectivity:
|
||||
return "Can't reach the provider — run `zero doctor`"
|
||||
case ModelNotFound:
|
||||
|
||||
@@ -17,6 +17,7 @@ func TestClassify(t *testing.T) {
|
||||
{"raw 401", "provider request error: 401 Unauthorized", Auth},
|
||||
{"invalid api key", "provider request error: invalid_api_key: incorrect key provided", Auth},
|
||||
{"rate limit prefix", "rate limit error: 429 too many requests", RateLimit},
|
||||
{"local RPM", `rate limit error: local RPM limit for "gpt-4.1" (2 requests/60s); retry in 12s`, LocalRPM},
|
||||
{"overloaded", "provider error: model is overloaded, please retry", RateLimit},
|
||||
{"resource exhausted gemini", "provider stream error: rpc error: code = ResourceExhausted desc = quota exceeded", RateLimit},
|
||||
{"context length openai", "provider request error: this model's maximum context length is 128000 tokens", ContextOverflow},
|
||||
|
||||
@@ -24,11 +24,15 @@ const (
|
||||
var (
|
||||
ErrProcessNotFound = errors.New("execution process not found")
|
||||
ErrProcessStdinDisabled = errors.New("execution process does not accept stdin")
|
||||
ErrProcessCapacity = errors.New("execution process capacity reached; stop a running process before starting another")
|
||||
)
|
||||
|
||||
type ProcessManagerOptions struct {
|
||||
CompletedRetention time.Duration
|
||||
MaxProcesses int
|
||||
// MaxProcesses bounds retained processes and in-flight launches. Completed
|
||||
// history is pruned before rejecting a start; live processes are never evicted.
|
||||
// Zero uses the default limit; a negative value disables the limit.
|
||||
MaxProcesses int
|
||||
}
|
||||
|
||||
// ProcessManager owns retained interactive-process identity, transport,
|
||||
@@ -37,6 +41,7 @@ type ProcessManager struct {
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
processes map[int]*managedProcess
|
||||
starting int
|
||||
completedRetention time.Duration
|
||||
maxProcesses int
|
||||
startTransport processTransportStarter
|
||||
@@ -120,12 +125,21 @@ func (manager *ProcessManager) Start(ctx context.Context, input ProcessStart, wa
|
||||
if input.Prepared.Command == nil {
|
||||
return ProcessResult{}, errors.New("prepared execution has no command")
|
||||
}
|
||||
if err := manager.reserve(); err != nil {
|
||||
if input.Prepared.Cleanup != nil {
|
||||
input.Prepared.Cleanup()
|
||||
}
|
||||
return ProcessResult{}, err
|
||||
}
|
||||
command := input.Prepared.Command
|
||||
buffer := newProcessOutputBuffer()
|
||||
request := input.Request
|
||||
observer := NewChangeObserver(request.WorkspaceRoots[0])
|
||||
stdin, tty, transportCleanup, err := manager.startTransport(command, buffer, input.TTY)
|
||||
if err != nil {
|
||||
manager.mu.Lock()
|
||||
manager.starting--
|
||||
manager.mu.Unlock()
|
||||
if input.Prepared.Cleanup != nil {
|
||||
input.Prepared.Cleanup()
|
||||
}
|
||||
@@ -331,9 +345,13 @@ func (manager *ProcessManager) StopAll() []int {
|
||||
return ids
|
||||
}
|
||||
|
||||
// Remove forgets completed history only. A live process must remain tracked
|
||||
// until completion, even if a termination attempt fails.
|
||||
func (manager *ProcessManager) Remove(id int) {
|
||||
manager.mu.Lock()
|
||||
delete(manager.processes, id)
|
||||
if process, ok := manager.processes[id]; ok && process.doneClosed() {
|
||||
delete(manager.processes, id)
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -358,22 +376,25 @@ func (manager *ProcessManager) get(id int) (*managedProcess, bool) {
|
||||
return process, ok
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) reserve() error {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
if manager.maxProcesses > 0 && len(manager.processes)+manager.starting >= manager.maxProcesses {
|
||||
completed := manager.processToPruneLocked()
|
||||
if completed == nil {
|
||||
return ErrProcessCapacity
|
||||
}
|
||||
delete(manager.processes, completed.id)
|
||||
}
|
||||
manager.starting++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) store(process *managedProcess) {
|
||||
manager.mu.Lock()
|
||||
var evicted *managedProcess
|
||||
if manager.maxProcesses > 0 && len(manager.processes) >= manager.maxProcesses {
|
||||
evicted = manager.processToPruneLocked()
|
||||
if evicted != nil && evicted.doneClosed() {
|
||||
delete(manager.processes, evicted.id)
|
||||
evicted = nil
|
||||
}
|
||||
}
|
||||
manager.starting--
|
||||
manager.processes[process.id] = process
|
||||
manager.mu.Unlock()
|
||||
if evicted != nil {
|
||||
_, _ = evicted.output.Write([]byte("[zero] session evicted: too many background terminals\n"))
|
||||
evicted.terminate()
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) processToPruneLocked() *managedProcess {
|
||||
@@ -387,9 +408,6 @@ func (manager *ProcessManager) processToPruneLocked() *managedProcess {
|
||||
return process
|
||||
}
|
||||
}
|
||||
if len(processes) > 8 {
|
||||
return processes[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package execution
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -23,6 +24,145 @@ func processManagerRequest(root string, command *exec.Cmd) Request {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessManagerReservesCapacityBeforeTransport(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manager := NewProcessManager(ProcessManagerOptions{MaxProcesses: 1})
|
||||
entered := make(chan struct{}, 20)
|
||||
release := make(chan struct{})
|
||||
launchErr := errors.New("transport failed")
|
||||
manager.startTransport = func(*exec.Cmd, io.Writer, bool) (io.WriteCloser, bool, func(), error) {
|
||||
entered <- struct{}{}
|
||||
<-release
|
||||
return nil, false, nil, launchErr
|
||||
}
|
||||
start := func() error {
|
||||
command := exec.Command(os.Args[0])
|
||||
cleaned := false
|
||||
_, err := manager.Start(context.Background(), ProcessStart{
|
||||
Prepared: PreparedCommand{Command: command, Cleanup: func() { cleaned = true }},
|
||||
Request: processManagerRequest(root, command),
|
||||
}, 0)
|
||||
if !cleaned {
|
||||
t.Error("failed admission or launch did not clean prepared resources")
|
||||
}
|
||||
return err
|
||||
}
|
||||
first := make(chan error, 1)
|
||||
go func() { first <- start() }()
|
||||
<-entered
|
||||
results := make(chan error, 16)
|
||||
for range 16 {
|
||||
go func() { results <- start() }()
|
||||
}
|
||||
// Release blocked transports even when testing the unfixed implementation.
|
||||
select {
|
||||
case <-entered:
|
||||
close(release)
|
||||
<-first
|
||||
for range 16 {
|
||||
<-results
|
||||
}
|
||||
t.Fatal("additional transport launched while the only slot was reserved")
|
||||
case err := <-results:
|
||||
if err == nil || errors.Is(err, launchErr) {
|
||||
t.Errorf("full manager returned %v, want capacity error", err)
|
||||
}
|
||||
}
|
||||
for range 15 {
|
||||
if err := <-results; err == nil || errors.Is(err, launchErr) {
|
||||
t.Errorf("full manager returned %v, want capacity error", err)
|
||||
}
|
||||
}
|
||||
close(release)
|
||||
if err := <-first; !errors.Is(err, launchErr) {
|
||||
t.Fatalf("first launch = %v", err)
|
||||
}
|
||||
if err := start(); !errors.Is(err, launchErr) {
|
||||
t.Fatalf("failed launch did not release reservation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessManagerCapacityDoesNotEvictLiveProcesses(t *testing.T) {
|
||||
for _, limit := range []int{1, 9} {
|
||||
t.Run(fmt.Sprint(limit), func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manager := NewProcessManager(ProcessManagerOptions{MaxProcesses: limit})
|
||||
kills := 0
|
||||
for id := range limit {
|
||||
manager.processes[id] = &managedProcess{
|
||||
id: id, command: &exec.Cmd{Process: &os.Process{Pid: 123}},
|
||||
done: make(chan struct{}), output: newProcessOutputBuffer(),
|
||||
kill: func(int) error { kills++; return errors.New("kill failed") },
|
||||
}
|
||||
}
|
||||
manager.Stop(0) // A failed kill must not free capacity.
|
||||
manager.Remove(0) // Nor may removing a live identity bypass the limit.
|
||||
launches := 0
|
||||
launchErr := errors.New("unexpected launch")
|
||||
manager.startTransport = func(*exec.Cmd, io.Writer, bool) (io.WriteCloser, bool, func(), error) {
|
||||
launches++
|
||||
return nil, false, nil, launchErr
|
||||
}
|
||||
command := exec.Command(os.Args[0])
|
||||
input := ProcessStart{Prepared: PreparedCommand{Command: command}, Request: processManagerRequest(root, command)}
|
||||
if _, err := manager.Start(context.Background(), input, 0); err == nil || errors.Is(err, launchErr) {
|
||||
t.Fatalf("full manager returned %v, want capacity error before launch", err)
|
||||
}
|
||||
if launches != 0 || kills != 1 || manager.Len() != limit {
|
||||
t.Fatalf("launches=%d kills=%d retained=%d", launches, kills, manager.Len())
|
||||
}
|
||||
manager.processes[0].markDone(nil, 0, AdapterReport{}, nil, nil)
|
||||
if _, err := manager.Start(context.Background(), input, 0); !errors.Is(err, launchErr) {
|
||||
t.Fatalf("completed history did not free capacity: %v", err)
|
||||
}
|
||||
if launches != 1 || manager.Len() != limit-1 {
|
||||
t.Fatalf("launches=%d retained=%d after completion", launches, manager.Len())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessManagerCapacityHelper(t *testing.T) {
|
||||
if os.Getenv("ZERO_PROCESS_CAPACITY_HELPER") != "1" {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Minute)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func TestProcessManagerCapacityWithRunningProcess(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manager := NewProcessManager(ProcessManagerOptions{MaxProcesses: 1})
|
||||
t.Cleanup(func() { manager.StopAll() })
|
||||
start := func() (ProcessResult, *exec.Cmd, error) {
|
||||
command := exec.Command(os.Args[0], "-test.run=^TestProcessManagerCapacityHelper$")
|
||||
command.Env = append(os.Environ(), "ZERO_PROCESS_CAPACITY_HELPER=1")
|
||||
result, err := manager.Start(context.Background(), ProcessStart{
|
||||
Prepared: PreparedCommand{Command: command}, Request: processManagerRequest(root, command),
|
||||
}, 0)
|
||||
return result, command, err
|
||||
}
|
||||
first, _, err := start()
|
||||
if err != nil || first.Exited {
|
||||
t.Fatalf("first start = %+v, %v", first, err)
|
||||
}
|
||||
if _, command, err := start(); err == nil || command.Process != nil {
|
||||
t.Fatalf("second start = %v, process=%v; want rejection before OS launch", err, command.Process)
|
||||
}
|
||||
if got := len(manager.List()); got != 1 {
|
||||
t.Fatalf("live processes = %d, want 1", got)
|
||||
}
|
||||
stopped, err := manager.Continue(context.Background(), ProcessContinue{
|
||||
ProcessID: first.ProcessID, Interrupt: true, Wait: 10 * time.Second,
|
||||
})
|
||||
if err != nil || !stopped.Exited {
|
||||
t.Fatalf("stop = %+v, %v", stopped, err)
|
||||
}
|
||||
if next, _, err := start(); err != nil || next.Exited {
|
||||
t.Fatalf("start after completion = %+v, %v", next, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessManagerRetainsAndContinuesWithStableIdentity(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("test command uses a POSIX shell")
|
||||
|
||||
@@ -75,6 +75,14 @@ func RenameWithRetry(src, dst string, rename func(src, dst string) error) error
|
||||
return err
|
||||
}
|
||||
|
||||
// IsSharingOrLockViolation reports whether err is a Windows sharing or lock
|
||||
// violation: another open handle on the file denies the requested access. It
|
||||
// is always false on other platforms, where the same errno values mean
|
||||
// unrelated errors.
|
||||
func IsSharingOrLockViolation(err error) bool {
|
||||
return runtime.GOOS == "windows" && isWindowsSharingOrLockViolation(err)
|
||||
}
|
||||
|
||||
func isWindowsSharingOrLockViolation(err error) bool {
|
||||
var errno syscall.Errno
|
||||
if errors.As(err, &errno) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package fsutil
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"testing"
|
||||
@@ -45,3 +46,22 @@ func TestRenameWithRetryNonRetryableError(t *testing.T) {
|
||||
t.Errorf("expected only 1 attempt for non-retryable error, got %d", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
// Errno 32 and 33 are EPIPE and EDOM on Unix, so only Windows may classify them.
|
||||
func TestIsSharingOrLockViolation(t *testing.T) {
|
||||
onWindows := runtime.GOOS == "windows"
|
||||
for _, tc := range []struct {
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{syscall.Errno(32), onWindows},
|
||||
{fmt.Errorf("wrapped: %w", syscall.Errno(33)), onWindows},
|
||||
{syscall.Errno(5), false}, // ERROR_ACCESS_DENIED
|
||||
{errors.New("other"), false},
|
||||
{nil, false},
|
||||
} {
|
||||
if got := IsSharingOrLockViolation(tc.err); got != tc.want {
|
||||
t.Errorf("IsSharingOrLockViolation(%v) = %v, want %v", tc.err, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package gitsafe
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The packages whose git calls Zero makes on its own. The CLI runs git for the
|
||||
// scratch-file warning and the TUI for its files sweep and pull request
|
||||
// diffstat; each call has to come through Command.
|
||||
var automaticGitPackages = []string{
|
||||
filepath.Join("..", "cli"),
|
||||
filepath.Join("..", "tui"),
|
||||
}
|
||||
|
||||
// parsePackage parses the non-test Go files in dir.
|
||||
func parsePackage(t *testing.T, dir string) (*token.FileSet, []*ast.File) {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", dir, err)
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
var files []*ast.File
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
|
||||
continue
|
||||
}
|
||||
file, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", name, err)
|
||||
}
|
||||
files = append(files, file)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
t.Fatalf("no Go files in %s", dir)
|
||||
}
|
||||
return fset, files
|
||||
}
|
||||
|
||||
// importName is the name file uses for the package at path, or "" when the
|
||||
// file does not import it.
|
||||
func importName(file *ast.File, path string) string {
|
||||
for _, spec := range file.Imports {
|
||||
if value, err := strconv.Unquote(spec.Path.Value); err != nil || value != path {
|
||||
continue
|
||||
}
|
||||
if spec.Name != nil {
|
||||
return spec.Name.Name
|
||||
}
|
||||
return filepath.Base(path)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// directGitExecs lists the exec.Command and exec.CommandContext calls in files
|
||||
// whose program is the literal "git".
|
||||
func directGitExecs(fset *token.FileSet, files []*ast.File) []string {
|
||||
var found []string
|
||||
for _, file := range files {
|
||||
execName := importName(file, "os/exec")
|
||||
if execName == "" {
|
||||
continue
|
||||
}
|
||||
ast.Inspect(file, func(node ast.Node) bool {
|
||||
call, ok := node.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
selector, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if pkg, ok := selector.X.(*ast.Ident); !ok || pkg.Name != execName {
|
||||
return true
|
||||
}
|
||||
program := 0
|
||||
switch selector.Sel.Name {
|
||||
case "Command":
|
||||
case "CommandContext":
|
||||
program = 1
|
||||
default:
|
||||
return true
|
||||
}
|
||||
if len(call.Args) <= program {
|
||||
return true
|
||||
}
|
||||
if literal, ok := call.Args[program].(*ast.BasicLit); ok && literal.Kind == token.STRING {
|
||||
if value, err := strconv.Unquote(literal.Value); err == nil && value == "git" {
|
||||
found = append(found, fset.Position(call.Pos()).String())
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
func TestAutomaticGitCallsDoNotExecGitDirectly(t *testing.T) {
|
||||
for _, dir := range automaticGitPackages {
|
||||
fset, files := parsePackage(t, dir)
|
||||
for _, position := range directGitExecs(fset, files) {
|
||||
t.Errorf("%s runs git directly; go through gitsafe.Command so the repository's config cannot make it run a program", position)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// gitArguments returns the git arguments of call when it is gitsafe.Command or a
|
||||
// runner called with the program "git" (the TUI's runner(ctx, cwd, "git", ...)),
|
||||
// as far as they are string literals.
|
||||
func gitArguments(call *ast.CallExpr, gitsafeName string) ([]string, bool) {
|
||||
start := -1
|
||||
if selector, ok := call.Fun.(*ast.SelectorExpr); ok && gitsafeName != "" && selector.Sel.Name == "Command" {
|
||||
if pkg, ok := selector.X.(*ast.Ident); ok && pkg.Name == gitsafeName {
|
||||
start = 2
|
||||
}
|
||||
}
|
||||
if start < 0 {
|
||||
for index, argument := range call.Args {
|
||||
if literal, ok := argument.(*ast.BasicLit); ok && literal.Kind == token.STRING && literal.Value == `"git"` {
|
||||
start = index + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if start < 0 || start > len(call.Args) {
|
||||
return nil, false
|
||||
}
|
||||
var arguments []string
|
||||
for _, argument := range call.Args[start:] {
|
||||
if literal, ok := argument.(*ast.BasicLit); ok && literal.Kind == token.STRING {
|
||||
if value, err := strconv.Unquote(literal.Value); err == nil {
|
||||
arguments = append(arguments, value)
|
||||
continue
|
||||
}
|
||||
}
|
||||
arguments = append(arguments, "")
|
||||
}
|
||||
return arguments, true
|
||||
}
|
||||
|
||||
// A submodule has its own config, which Command does not check, so the
|
||||
// automatic status and diff calls stay out of submodule work trees.
|
||||
func TestAutomaticStatusAndDiffSkipSubmoduleWorkTrees(t *testing.T) {
|
||||
checked := 0
|
||||
for _, dir := range automaticGitPackages {
|
||||
fset, files := parsePackage(t, dir)
|
||||
for _, file := range files {
|
||||
gitsafeName := importName(file, "github.com/Gitlawb/zero/internal/gitsafe")
|
||||
ast.Inspect(file, func(node ast.Node) bool {
|
||||
call, ok := node.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
arguments, ok := gitArguments(call, gitsafeName)
|
||||
if !ok || len(arguments) == 0 || (arguments[0] != "status" && arguments[0] != "diff") {
|
||||
return true
|
||||
}
|
||||
checked++
|
||||
for _, argument := range arguments {
|
||||
if argument == "--ignore-submodules=dirty" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
t.Errorf("%s runs git %s without --ignore-submodules=dirty", fset.Position(call.Pos()), arguments[0])
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
// The scratch-file status, the files sweep's status and diff, and the pull
|
||||
// request diffstat.
|
||||
if checked != 4 {
|
||||
t.Fatalf("checked %d automatic status or diff calls, want 4; update this test when they change", checked)
|
||||
}
|
||||
}
|
||||
|
||||
// The TUI runs every git command through one runner, whose program name is a
|
||||
// parameter, so the check above cannot see it. The runner has to hand git to
|
||||
// Command itself.
|
||||
func TestTUICommandRunnerSendsGitThroughCommand(t *testing.T) {
|
||||
_, files := parsePackage(t, filepath.Join("..", "tui"))
|
||||
var runner *ast.FuncDecl
|
||||
var runnerFile *ast.File
|
||||
for _, file := range files {
|
||||
for _, declaration := range file.Decls {
|
||||
if function, ok := declaration.(*ast.FuncDecl); ok && function.Recv == nil && function.Name.Name == "defaultPRCommandRunner" {
|
||||
runner, runnerFile = function, file
|
||||
}
|
||||
}
|
||||
}
|
||||
if runner == nil {
|
||||
t.Fatal("defaultPRCommandRunner not found in internal/tui")
|
||||
}
|
||||
gitsafeName := importName(runnerFile, "github.com/Gitlawb/zero/internal/gitsafe")
|
||||
calls := false
|
||||
if gitsafeName != "" {
|
||||
ast.Inspect(runner.Body, func(node ast.Node) bool {
|
||||
if call, ok := node.(*ast.CallExpr); ok {
|
||||
if selector, ok := call.Fun.(*ast.SelectorExpr); ok && selector.Sel.Name == "Command" {
|
||||
if pkg, ok := selector.X.(*ast.Ident); ok && pkg.Name == gitsafeName {
|
||||
calls = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return !calls
|
||||
})
|
||||
}
|
||||
if !calls {
|
||||
t.Fatal("defaultPRCommandRunner does not call gitsafe.Command, so the TUI's git calls skip it")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// Package gitsafe builds the read-only git commands Zero runs on its own, as
|
||||
// opposed to a git command the user or the model asked for: the status and diff
|
||||
// calls behind the TUI's changed-files sweep, its pull request diffstat and the
|
||||
// scratch-file warning, which run without anyone asking for git.
|
||||
//
|
||||
// A REPOSITORY CAN MAKE THOSE COMMANDS RUN A PROGRAM. git status and git diff read
|
||||
// the repository's own .git/config, and several keys there name programs git then
|
||||
// runs: core.fsmonitor on every status, a clean filter or diff driver assigned
|
||||
// through .gitattributes whenever a file's content has to be read, and hooks such
|
||||
// as post-index-change when the index is rewritten. A repository unpacked from an
|
||||
// archive or handed over as a directory carries that config, and Zero would run it
|
||||
// on the first turn, although it refuses the same workspace's project hooks and
|
||||
// plugins until the user trusts it.
|
||||
//
|
||||
// The single-key cases are switched off on the command line. Filter and diff
|
||||
// driver commands are named per driver, so they cannot be switched off without
|
||||
// knowing the names; a repository whose own config defines any is skipped instead.
|
||||
// Commands defined in the user's own global config, such as git-lfs, are the
|
||||
// user's choice and are not affected.
|
||||
package gitsafe
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrRepositoryDefinesCommands is returned for a repository whose own config
|
||||
// defines a filter or diff driver command. The caller goes without the git
|
||||
// information rather than run it.
|
||||
var ErrRepositoryDefinesCommands = errors.New("the repository's own git config defines a filter or diff command, so Zero does not run git there on its own")
|
||||
|
||||
// commandKeys matches the config keys whose value is a program git can run while
|
||||
// computing status or a diff, and which are named per driver. Git prints section
|
||||
// and variable names in lower case, so the patterns do not need to fold case.
|
||||
const commandKeys = `filter\..+\.(clean|smudge|process)|diff\..+\.(command|textconv)|diff\.external`
|
||||
|
||||
// worktreeConfigKey makes git also read per-worktree config (config.worktree),
|
||||
// which a --local read leaves out. Without it git ignores that file.
|
||||
const worktreeConfigKey = "extensions.worktreeconfig"
|
||||
|
||||
const (
|
||||
localConfigPattern = `^(` + commandKeys + `|extensions\.worktreeconfig)$`
|
||||
worktreeConfigPattern = `^(` + commandKeys + `)$`
|
||||
)
|
||||
|
||||
// Command returns git with args, run in dir, with the programs a repository can
|
||||
// configure turned off. It fails with ErrRepositoryDefinesCommands when the
|
||||
// repository's own config defines a filter or diff driver command, and with git's
|
||||
// own error when dir is not inside a repository.
|
||||
//
|
||||
// Callers that inspect a work tree that may contain submodules should also pass
|
||||
// --ignore-submodules=dirty: a submodule has its own config, which this does not
|
||||
// read, and git only runs commands there when it looks inside the submodule.
|
||||
func Command(ctx context.Context, dir string, args ...string) (*exec.Cmd, error) {
|
||||
env := environment()
|
||||
defines, err := repositoryDefinesCommands(ctx, dir, env)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if defines {
|
||||
return nil, ErrRepositoryDefinesCommands
|
||||
}
|
||||
command := exec.CommandContext(ctx, "git", append(disabledPrograms(), args...)...)
|
||||
command.Dir = dir
|
||||
// No opportunistic index refresh: a read-only call never rewrites the index,
|
||||
// so it never triggers the hooks that run when the index is written. Git
|
||||
// before 2.15 does not know the variable and ignores it.
|
||||
command.Env = append(env, "GIT_OPTIONAL_LOCKS=0")
|
||||
return command, nil
|
||||
}
|
||||
|
||||
// disabledPrograms turns off the single-key ways a repository runs a program
|
||||
// during status or diff. Git ignores a key it does not know, so an older git
|
||||
// without hooksPath (2.9) or fsmonitor (2.16) accepts these unchanged.
|
||||
func disabledPrograms() []string {
|
||||
return []string{"-c", "core.fsmonitor=false", "-c", "core.hooksPath=" + os.DevNull}
|
||||
}
|
||||
|
||||
// repositoryLocationVariables make git use a given repository, work tree or
|
||||
// index instead of discovering them from the working directory. Git exports
|
||||
// them to hooks, so a Zero started from a hook inherits the hook's repository.
|
||||
var repositoryLocationVariables = []string{
|
||||
"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR",
|
||||
"GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_NAMESPACE", "GIT_PREFIX",
|
||||
}
|
||||
|
||||
// environment is Zero's environment without the repository-location variables,
|
||||
// so git finds the repository from dir. The config check and the command share
|
||||
// it: checking one repository's config and then running git in another would
|
||||
// let the second one's commands through.
|
||||
func environment() []string {
|
||||
parent := os.Environ()
|
||||
env := make([]string, 0, len(parent)+1)
|
||||
for _, entry := range parent {
|
||||
name, _, _ := strings.Cut(entry, "=")
|
||||
if !isRepositoryLocationVariable(name) {
|
||||
env = append(env, entry)
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func isRepositoryLocationVariable(name string) bool {
|
||||
for _, variable := range repositoryLocationVariables {
|
||||
// Windows environment names are case-insensitive.
|
||||
if name == variable || (runtime.GOOS == "windows" && strings.EqualFold(name, variable)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// repositoryDefinesCommands reports whether the repository's own config, with
|
||||
// includes followed, defines a filter or diff driver command. The local config is
|
||||
// read once, together with the key that turns on per-worktree config, and that
|
||||
// file is read only when it is on, so most repositories cost one git process.
|
||||
// Reading config runs nothing.
|
||||
func repositoryDefinesCommands(ctx context.Context, dir string, env []string) (bool, error) {
|
||||
entries, err := readConfig(ctx, dir, env, "--local", localConfigPattern)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
worktreeConfig := false
|
||||
for _, entry := range entries {
|
||||
if entry.key != worktreeConfigKey {
|
||||
return true, nil
|
||||
}
|
||||
worktreeConfig = !configFalse(entry.value)
|
||||
}
|
||||
if !worktreeConfig {
|
||||
return false, nil
|
||||
}
|
||||
// The extension needs git 2.20, which also has --worktree, so a failure here
|
||||
// is real and the caller goes without git.
|
||||
entries, err = readConfig(ctx, dir, env, "--worktree", worktreeConfigPattern)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return len(entries) > 0, nil
|
||||
}
|
||||
|
||||
// configFalse reports whether a config value is one of git's spellings of false.
|
||||
// Anything else, including a key with no value, counts as on, so an unexpected
|
||||
// spelling costs one more read rather than skipping the file.
|
||||
func configFalse(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "false", "no", "off", "0":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type configEntry struct {
|
||||
key string
|
||||
value string
|
||||
}
|
||||
|
||||
// readConfig returns the entries in one config scope whose key matches pattern.
|
||||
// It is a variable so tests can answer for git.
|
||||
var readConfig = func(ctx context.Context, dir string, env []string, scope string, pattern string) ([]configEntry, error) {
|
||||
command := exec.CommandContext(ctx, "git", "config", "-z", scope, "--includes", "--get-regexp", pattern)
|
||||
command.Dir = dir
|
||||
command.Env = env
|
||||
output, err := command.Output()
|
||||
if err != nil {
|
||||
var exit *exec.ExitError
|
||||
if errors.As(err, &exit) && exit.ExitCode() == 1 {
|
||||
return nil, nil // no key matched
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return parseConfigEntries(output), nil
|
||||
}
|
||||
|
||||
// parseConfigEntries splits git config -z output: each entry ends with a NUL,
|
||||
// and a newline separates the key from its value when there is one.
|
||||
func parseConfigEntries(output []byte) []configEntry {
|
||||
var entries []configEntry
|
||||
for _, record := range bytes.Split(output, []byte{0}) {
|
||||
if len(record) == 0 {
|
||||
continue
|
||||
}
|
||||
key, value, _ := bytes.Cut(record, []byte{'\n'})
|
||||
entries = append(entries, configEntry{key: string(key), value: string(value)})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package gitsafe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type configCall struct {
|
||||
scope string
|
||||
env []string
|
||||
}
|
||||
|
||||
// answerConfig stands in for git's config read and records each call.
|
||||
func answerConfig(t *testing.T, answer func(scope string) ([]configEntry, error)) *[]configCall {
|
||||
t.Helper()
|
||||
var calls []configCall
|
||||
previous := readConfig
|
||||
readConfig = func(_ context.Context, _ string, env []string, scope string, _ string) ([]configEntry, error) {
|
||||
calls = append(calls, configCall{scope: scope, env: env})
|
||||
return answer(scope)
|
||||
}
|
||||
t.Cleanup(func() { readConfig = previous })
|
||||
return &calls
|
||||
}
|
||||
|
||||
func noEntries(string) ([]configEntry, error) { return nil, nil }
|
||||
|
||||
func scopes(calls *[]configCall) []string {
|
||||
var read []string
|
||||
for _, call := range *calls {
|
||||
read = append(read, call.scope)
|
||||
}
|
||||
return read
|
||||
}
|
||||
|
||||
// effectiveEnv returns the value a child process gets for name: os/exec keeps
|
||||
// the last entry for a name, compared without case on Windows.
|
||||
func effectiveEnv(env []string, name string) (string, bool) {
|
||||
value, found := "", false
|
||||
for _, entry := range env {
|
||||
key, rest, ok := strings.Cut(entry, "=")
|
||||
if ok && (key == name || (runtime.GOOS == "windows" && strings.EqualFold(key, name))) {
|
||||
value, found = rest, true
|
||||
}
|
||||
}
|
||||
return value, found
|
||||
}
|
||||
|
||||
func TestCommandTurnsOffRepositoryPrograms(t *testing.T) {
|
||||
answerConfig(t, noEntries)
|
||||
t.Setenv("GIT_OPTIONAL_LOCKS", "1")
|
||||
dir := t.TempDir()
|
||||
|
||||
command, err := Command(context.Background(), dir, "status", "--porcelain")
|
||||
if err != nil {
|
||||
t.Fatalf("Command: %v", err)
|
||||
}
|
||||
want := []string{"git", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=" + os.DevNull, "status", "--porcelain"}
|
||||
if !reflect.DeepEqual(command.Args, want) {
|
||||
t.Fatalf("args = %q, want %q", command.Args, want)
|
||||
}
|
||||
if command.Dir != dir {
|
||||
t.Fatalf("dir = %q, want %q", command.Dir, dir)
|
||||
}
|
||||
// An inherited GIT_OPTIONAL_LOCKS must not turn the index refresh back on.
|
||||
if value, _ := effectiveEnv(command.Env, "GIT_OPTIONAL_LOCKS"); value != "0" {
|
||||
t.Fatalf("effective GIT_OPTIONAL_LOCKS = %q, want 0", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandDropsRepositoryLocationVariables(t *testing.T) {
|
||||
// Turn per-worktree config on so both reads happen and both are checked.
|
||||
calls := answerConfig(t, func(scope string) ([]configEntry, error) {
|
||||
if scope == "--local" {
|
||||
return []configEntry{{key: worktreeConfigKey, value: "true"}}, nil
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
elsewhere := filepath.Join(t.TempDir(), "elsewhere")
|
||||
for _, name := range repositoryLocationVariables {
|
||||
t.Setenv(name, elsewhere)
|
||||
}
|
||||
t.Setenv("ZERO_GITSAFE_TEST_KEPT", "kept")
|
||||
|
||||
command, err := Command(context.Background(), t.TempDir(), "status")
|
||||
if err != nil {
|
||||
t.Fatalf("Command: %v", err)
|
||||
}
|
||||
for _, name := range repositoryLocationVariables {
|
||||
if value, found := effectiveEnv(command.Env, name); found {
|
||||
t.Fatalf("%s=%q reached git", name, value)
|
||||
}
|
||||
}
|
||||
if value, _ := effectiveEnv(command.Env, "ZERO_GITSAFE_TEST_KEPT"); value != "kept" {
|
||||
t.Fatalf("an unrelated variable was dropped: %q", value)
|
||||
}
|
||||
// The config check has to look at the repository the command runs in, so it
|
||||
// gets the same environment, minus only the lock variable added afterwards.
|
||||
if got := scopes(calls); !reflect.DeepEqual(got, []string{"--local", "--worktree"}) {
|
||||
t.Fatalf("config reads = %q, want --local then --worktree", got)
|
||||
}
|
||||
for _, call := range *calls {
|
||||
if !reflect.DeepEqual(call.env, command.Env[:len(command.Env)-1]) {
|
||||
t.Fatalf("the %s config read ran with a different environment than the command", call.scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocationVariableNamesFoldCaseOnlyOnWindows(t *testing.T) {
|
||||
if !isRepositoryLocationVariable("GIT_DIR") {
|
||||
t.Fatal("GIT_DIR not recognised")
|
||||
}
|
||||
if got, want := isRepositoryLocationVariable("Git_Dir"), runtime.GOOS == "windows"; got != want {
|
||||
t.Fatalf("Git_Dir recognised = %v, want %v on %s", got, want, runtime.GOOS)
|
||||
}
|
||||
for _, name := range []string{"GIT_DIRECTORY", "MY_GIT_DIR", "GIT_CONFIG_GLOBAL", ""} {
|
||||
if isRepositoryLocationVariable(name) {
|
||||
t.Fatalf("%q treated as a repository-location variable", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandRefusesRepositoryThatDefinesCommands(t *testing.T) {
|
||||
enabled := configEntry{key: worktreeConfigKey, value: "true"}
|
||||
command := configEntry{key: "filter.x.clean", value: "x"}
|
||||
cases := map[string]map[string][]configEntry{
|
||||
"local": {"--local": {command}},
|
||||
"local after the extension": {"--local": {enabled, command}},
|
||||
"per-worktree": {"--local": {enabled}, "--worktree": {{key: "diff.x.textconv", value: "x"}}},
|
||||
}
|
||||
for name, answers := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
answerConfig(t, func(scope string) ([]configEntry, error) { return answers[scope], nil })
|
||||
got, err := Command(context.Background(), t.TempDir(), "status")
|
||||
if !errors.Is(err, ErrRepositoryDefinesCommands) {
|
||||
t.Fatalf("err = %v, want ErrRepositoryDefinesCommands", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatal("a command came back for a repository that defines commands")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandReadsPerWorktreeConfigOnlyWhenItIsOn(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
local []configEntry
|
||||
want []string
|
||||
}{
|
||||
"no extension": {nil, []string{"--local"}},
|
||||
"extension off": {[]configEntry{{key: worktreeConfigKey, value: "false"}}, []string{"--local"}},
|
||||
"extension off as no": {[]configEntry{{key: worktreeConfigKey, value: "No"}}, []string{"--local"}},
|
||||
"extension on": {[]configEntry{{key: worktreeConfigKey, value: "true"}}, []string{"--local", "--worktree"}},
|
||||
"extension with no value": {[]configEntry{{key: worktreeConfigKey}}, []string{"--local", "--worktree"}},
|
||||
"turned off again": {[]configEntry{{key: worktreeConfigKey, value: "true"}, {key: worktreeConfigKey, value: "off"}}, []string{"--local"}},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
calls := answerConfig(t, func(scope string) ([]configEntry, error) {
|
||||
if scope == "--local" {
|
||||
return tc.local, nil
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
if _, err := Command(context.Background(), t.TempDir(), "status"); err != nil {
|
||||
t.Fatalf("Command: %v", err)
|
||||
}
|
||||
if got := scopes(calls); !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("config reads = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandFailsWhenConfigCannotBeRead(t *testing.T) {
|
||||
unreadable := errors.New("not a git repository")
|
||||
for _, failing := range []string{"--local", "--worktree"} {
|
||||
t.Run(failing, func(t *testing.T) {
|
||||
answerConfig(t, func(scope string) ([]configEntry, error) {
|
||||
if scope == failing {
|
||||
return nil, unreadable
|
||||
}
|
||||
return []configEntry{{key: worktreeConfigKey, value: "true"}}, nil
|
||||
})
|
||||
if _, err := Command(context.Background(), t.TempDir(), "status"); !errors.Is(err, unreadable) {
|
||||
t.Fatalf("err = %v, want the config read error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Go's regexp stands in for git's extended regex here; the patterns use nothing
|
||||
// the two treat differently.
|
||||
func TestConfigPatterns(t *testing.T) {
|
||||
local := regexp.MustCompile(localConfigPattern)
|
||||
worktree := regexp.MustCompile(worktreeConfigPattern)
|
||||
for _, key := range []string{
|
||||
"filter.lfs.clean", "filter.lfs.smudge", "filter.lfs.process", "filter.a.b.clean",
|
||||
"diff.word.textconv", "diff.tool.command", "diff.external",
|
||||
} {
|
||||
if !local.MatchString(key) || !worktree.MatchString(key) {
|
||||
t.Errorf("%s is not matched in both scopes", key)
|
||||
}
|
||||
}
|
||||
if !local.MatchString(worktreeConfigKey) || worktree.MatchString(worktreeConfigKey) {
|
||||
t.Errorf("%s belongs to the local read only", worktreeConfigKey)
|
||||
}
|
||||
for _, key := range []string{
|
||||
"filter.lfs.required", "diff.word.cachetextconv", "diff.word.binary",
|
||||
"core.fsmonitor", "diff.external.x", "diff.renames", "extensions.worktreeconfigx",
|
||||
} {
|
||||
if local.MatchString(key) || worktree.MatchString(key) {
|
||||
t.Errorf("%s is matched", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigEntries(t *testing.T) {
|
||||
got := parseConfigEntries([]byte("filter.x.clean\nrun this\nand that\x00extensions.worktreeconfig\x00"))
|
||||
want := []configEntry{
|
||||
{key: "filter.x.clean", value: "run this\nand that"},
|
||||
{key: "extensions.worktreeconfig"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("entries = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Real git: a fresh repository with nothing configured, the same repository with
|
||||
// per-worktree config turned on, and a directory that is not a repository. This
|
||||
// pins the output format and exit codes readConfig relies on; the command keys
|
||||
// themselves are covered above.
|
||||
func TestCommandAgainstRealGit(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not available")
|
||||
}
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
|
||||
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
|
||||
repo := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
// Both share a parent; stop git from finding an enclosing repository there.
|
||||
t.Setenv("GIT_CEILING_DIRECTORIES", filepath.Dir(repo))
|
||||
// Run as if from a hook in another repository. The setup below has to reach
|
||||
// repo, as Command does, and never the repository these name.
|
||||
decoy := t.TempDir()
|
||||
t.Setenv("GIT_DIR", filepath.Join(decoy, ".git"))
|
||||
t.Setenv("GIT_WORK_TREE", decoy)
|
||||
decoyUntouched := func(step string) {
|
||||
t.Helper()
|
||||
if _, err := os.Stat(filepath.Join(decoy, ".git")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("%s reached the repository GIT_DIR names instead of repo: %v", step, err)
|
||||
}
|
||||
}
|
||||
git := func(args ...string) {
|
||||
t.Helper()
|
||||
command := exec.Command("git", append([]string{"-C", repo}, args...)...)
|
||||
command.Env = environment()
|
||||
if output, err := command.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v: %s", args, err, output)
|
||||
}
|
||||
}
|
||||
git("init", "-q")
|
||||
decoyUntouched("git init")
|
||||
gitRead := readConfig
|
||||
var read []string
|
||||
readConfig = func(ctx context.Context, dir string, env []string, scope string, pattern string) ([]configEntry, error) {
|
||||
read = append(read, scope)
|
||||
return gitRead(ctx, dir, env, scope, pattern)
|
||||
}
|
||||
t.Cleanup(func() { readConfig = gitRead })
|
||||
|
||||
command, err := Command(context.Background(), repo, "status", "--porcelain")
|
||||
if err != nil {
|
||||
t.Fatalf("Command in a plain repository: %v", err)
|
||||
}
|
||||
if output, err := command.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git status: %v: %s", err, output)
|
||||
}
|
||||
if !reflect.DeepEqual(read, []string{"--local"}) {
|
||||
t.Fatalf("config reads in a plain repository = %q, want --local only", read)
|
||||
}
|
||||
|
||||
if !gitAtLeast(t, 2, 20) {
|
||||
t.Log("skipping the per-worktree case: it needs git 2.20 for extensions.worktreeConfig")
|
||||
} else {
|
||||
git("config", "extensions.worktreeConfig", "true")
|
||||
decoyUntouched("git config")
|
||||
read = nil
|
||||
if _, err := Command(context.Background(), repo, "status"); err != nil {
|
||||
t.Fatalf("Command with per-worktree config on: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(read, []string{"--local", "--worktree"}) {
|
||||
t.Fatalf("config reads with per-worktree config on = %q, want --local then --worktree", read)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := Command(context.Background(), outside, "status"); err == nil {
|
||||
t.Fatal("Command outside a repository succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// gitAtLeast reports whether the git on PATH is at least major.minor.
|
||||
func gitAtLeast(t *testing.T, major, minor int) bool {
|
||||
t.Helper()
|
||||
output, err := exec.Command("git", "version").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("git version: %v", err)
|
||||
}
|
||||
var gotMajor, gotMinor int
|
||||
if _, err := fmt.Sscanf(strings.TrimPrefix(strings.TrimSpace(string(output)), "git version "), "%d.%d", &gotMajor, &gotMinor); err != nil {
|
||||
t.Fatalf("parse %q: %v", output, err)
|
||||
}
|
||||
return gotMajor > major || (gotMajor == major && gotMinor >= minor)
|
||||
}
|
||||
@@ -3,11 +3,13 @@ package mcp
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
@@ -16,6 +18,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/imageinput"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
type RemoteTool struct {
|
||||
@@ -27,10 +31,16 @@ type RemoteTool struct {
|
||||
type Content struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
// MimeType names what a non-text block holds. Decoded but not yet forwarded:
|
||||
// it is what lets a dropped block be described to the model instead of
|
||||
// vanishing (#823). Servers that omit it still decode fine.
|
||||
// MimeType names what a non-text block holds. Additive and omitempty, so
|
||||
// servers that never send it decode exactly as before. Image blocks with
|
||||
// valid data are forwarded on Result.Images; MimeType is what lets a
|
||||
// remaining dropped block (audio, resource, failed decode) be described
|
||||
// instead of vanishing (#823).
|
||||
MimeType string `json:"mimeType,omitempty"`
|
||||
// Data is the MCP image block's base64 payload. Additive and omitempty so a
|
||||
// result that never sent it still unmarshals. Decoded only for type
|
||||
// "image"; other types leave it unread.
|
||||
Data string `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type CallToolResult struct {
|
||||
@@ -381,10 +391,6 @@ func (client *Client) request(ctx context.Context, method string, params any, ta
|
||||
}
|
||||
}
|
||||
|
||||
func (client *Client) ensureWriter() {
|
||||
_ = client.startWriter()
|
||||
}
|
||||
|
||||
func (client *Client) startWriter() error {
|
||||
client.writeMu.Lock()
|
||||
defer client.writeMu.Unlock()
|
||||
@@ -746,24 +752,95 @@ func TextContent(content []Content) string {
|
||||
return strings.TrimSpace(strings.Join(parts, "\n"))
|
||||
}
|
||||
|
||||
// DroppedContentSummary describes the blocks TextContent discards, e.g.
|
||||
// "1 image/png block" or "2 resource blocks, 1 audio/wav block". It returns ""
|
||||
// when a result is entirely text, so a caller adds nothing to the ordinary case.
|
||||
//
|
||||
// This exists because dropping silently is the worst available behaviour. A
|
||||
// screenshot server returns a valid image, TextContent keeps nothing, and the
|
||||
// call is reported as "(empty MCP tool result)" — so the model concludes the
|
||||
// tool produced nothing and usually retries, burning another call on the same
|
||||
// empty answer. Naming what came back costs nothing and ends that loop even
|
||||
// though the payload still cannot be forwarded.
|
||||
//
|
||||
// Counts are grouped by mime type and ordered by first appearance, so the same
|
||||
// result always produces the same sentence.
|
||||
func DroppedContentSummary(content []Content) string {
|
||||
// itemDisp is the per-item forwarding/drop disposition produced by the
|
||||
// single-pass conversion. DroppedContentSummary is built from this so a
|
||||
// valid image is never base64-decoded a second time just to name what was
|
||||
// kept versus dropped.
|
||||
type itemDisp uint8
|
||||
|
||||
const (
|
||||
dispText itemDisp = iota
|
||||
dispForwarded
|
||||
dispDropped
|
||||
dispBudgetExceeded
|
||||
dispUninspected
|
||||
)
|
||||
|
||||
// decodeImageBase64 is the MCP image payload decoder. Tests replace it to
|
||||
// count decode attempts; production uses standard base64.
|
||||
var decodeImageBase64 = base64.StdEncoding.DecodeString
|
||||
|
||||
// Bound provider content-block overhead independently of decoded image size.
|
||||
const maxForwardedImages = 16
|
||||
|
||||
// Bound decode work even when invalid or oversized-for-the-residue candidates
|
||||
// do not consume the forwarding budgets.
|
||||
const maxInspectedImages = 32
|
||||
|
||||
type imageLimitReason int
|
||||
|
||||
const (
|
||||
imageLimitNone imageLimitReason = iota
|
||||
imageLimitBudget
|
||||
imageLimitForwardCount
|
||||
imageLimitInspectionCount
|
||||
)
|
||||
|
||||
func forwardImages(content []Content) ([]zeroruntime.ImageBlock, []itemDisp, imageLimitReason) {
|
||||
disp := make([]itemDisp, len(content))
|
||||
var images []zeroruntime.ImageBlock
|
||||
remaining := imageinput.MaxImageBytes
|
||||
inspected := 0
|
||||
var limitReason imageLimitReason
|
||||
for i, item := range content {
|
||||
if item.Type == "text" {
|
||||
disp[i] = dispText
|
||||
continue
|
||||
}
|
||||
if item.Type == "image" {
|
||||
if remaining == 0 {
|
||||
disp[i] = dispUninspected
|
||||
if limitReason == imageLimitNone {
|
||||
limitReason = imageLimitBudget
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(images) >= maxForwardedImages {
|
||||
disp[i] = dispUninspected
|
||||
if limitReason == imageLimitNone {
|
||||
limitReason = imageLimitForwardCount
|
||||
}
|
||||
continue
|
||||
}
|
||||
if inspected >= maxInspectedImages {
|
||||
disp[i] = dispUninspected
|
||||
if limitReason == imageLimitNone {
|
||||
limitReason = imageLimitInspectionCount
|
||||
}
|
||||
continue
|
||||
}
|
||||
inspected++
|
||||
if image, ok := imageBlockFromContent(item); ok {
|
||||
if len(image.Data) <= remaining {
|
||||
images = append(images, image)
|
||||
remaining -= len(image.Data)
|
||||
disp[i] = dispForwarded
|
||||
continue
|
||||
}
|
||||
disp[i] = dispBudgetExceeded
|
||||
continue
|
||||
}
|
||||
}
|
||||
disp[i] = dispDropped
|
||||
}
|
||||
return images, disp, limitReason
|
||||
}
|
||||
|
||||
func droppedContentNote(content []Content, disp []itemDisp, kinds ...itemDisp) string {
|
||||
labels := make([]string, 0, len(content))
|
||||
counts := make(map[string]int, len(content))
|
||||
for _, item := range content {
|
||||
if item.Type == "text" {
|
||||
for i, item := range content {
|
||||
if i >= len(disp) || !dispKind(disp[i], kinds) {
|
||||
continue
|
||||
}
|
||||
// Prefer the mime type: "image/png" tells the reader more than "image".
|
||||
@@ -794,3 +871,56 @@ func DroppedContentSummary(content []Content) string {
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func dispKind(got itemDisp, kinds []itemDisp) bool {
|
||||
for _, kind := range kinds {
|
||||
if got == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func dispCount(disp []itemDisp, kinds ...itemDisp) int {
|
||||
count := 0
|
||||
for _, d := range disp {
|
||||
if dispKind(d, kinds) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func imageBlockFromContent(item Content) (zeroruntime.ImageBlock, bool) {
|
||||
if item.Type != "image" {
|
||||
return zeroruntime.ImageBlock{}, false
|
||||
}
|
||||
raw := strings.TrimSpace(item.Data)
|
||||
if raw == "" {
|
||||
return zeroruntime.ImageBlock{}, false
|
||||
}
|
||||
// EncodedLen(MaxImageBytes) is the encoded size of an image that decodes
|
||||
// to exactly the inclusive cap, including "==" padding. DecodedLen is an
|
||||
// upper bound and reports cap+2 for that input, so using it here would
|
||||
// reject a valid at-limit PNG. The post-decode len(data) check is the
|
||||
// exact backstop.
|
||||
if len(raw) > base64.StdEncoding.EncodedLen(imageinput.MaxImageBytes) {
|
||||
return zeroruntime.ImageBlock{}, false
|
||||
}
|
||||
data, err := decodeImageBase64(raw)
|
||||
if err != nil {
|
||||
return zeroruntime.ImageBlock{}, false
|
||||
}
|
||||
if len(data) == 0 || len(data) > imageinput.MaxImageBytes {
|
||||
return zeroruntime.ImageBlock{}, false
|
||||
}
|
||||
sniffLen := len(data)
|
||||
if sniffLen > 512 {
|
||||
sniffLen = 512
|
||||
}
|
||||
mediaType := zeroruntime.NormalizeImageMediaType(http.DetectContentType(data[:sniffLen]))
|
||||
if mediaType == "" {
|
||||
return zeroruntime.ImageBlock{}, false
|
||||
}
|
||||
return zeroruntime.ImageBlock{MediaType: mediaType, Data: data}, true
|
||||
}
|
||||
@@ -1420,7 +1420,7 @@ func TestWriterLoopExitsOnRepeatedClose(t *testing.T) {
|
||||
reader: newMessageReader(inReader),
|
||||
writer: newMessageWriter(outWriter),
|
||||
}
|
||||
client.ensureWriter()
|
||||
_ = client.startWriter()
|
||||
if err := client.writeMessage(context.Background(), rpcMessage{Method: "notifications/ping"}); err != nil {
|
||||
t.Fatalf("writeMessage: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package mcp
|
||||
|
||||
import "github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
|
||||
// DroppedContentSummary describes the blocks that were not forwarded, e.g.
|
||||
// "1 audio/wav block" or "2 resource blocks, 1 image/png block". It returns ""
|
||||
// when every block is text or an image that ImageBlocks successfully
|
||||
// forwarded, so a caller adds nothing to the ordinary case.
|
||||
func DroppedContentSummary(content []Content) string {
|
||||
_, disp, _ := forwardImages(content)
|
||||
return droppedContentNote(content, disp, dispDropped, dispBudgetExceeded, dispUninspected)
|
||||
}
|
||||
|
||||
// ImageBlocks converts MCP image content into the same ImageBlock channel
|
||||
// capture tools already use.
|
||||
func ImageBlocks(content []Content) []zeroruntime.ImageBlock {
|
||||
images, _, _ := forwardImages(content)
|
||||
return images
|
||||
}
|
||||
@@ -657,11 +657,10 @@ func decodeSSERPCMessage(reader io.Reader) (rpcMessage, error) {
|
||||
return rpcMessage{}, fmt.Errorf("missing MCP SSE response data")
|
||||
}
|
||||
|
||||
// maxSSEEventBytes bounds a single SSE line/event. The previous 1 MiB cap made a
|
||||
// large but legitimate MCP message (e.g. a big tool result) hit bufio.ErrTooLong,
|
||||
// which failed the request permanently with no recovery. Raise it to a generous
|
||||
// bound that still protects against an unbounded remote server.
|
||||
const maxSSEEventBytes = 8 * 1024 * 1024
|
||||
// maxSSEEventBytes bounds a single SSE line/event. It accommodates multi-image
|
||||
// responses (such as 8 MiB + 4 MiB) in base64 (~16.8 MiB) plus JSON-RPC envelope,
|
||||
// metadata, and SSE framing overhead.
|
||||
const maxSSEEventBytes = 32 * 1024 * 1024
|
||||
|
||||
func scanSSEEvents(reader io.Reader, handle func(sseEvent) bool) error {
|
||||
scanner := bufio.NewScanner(reader)
|
||||
@@ -871,7 +870,11 @@ func (source *storeTokenSource) Refresh(ctx context.Context) (string, error) {
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no stored OAuth token for MCP server %s", source.server.Name)
|
||||
}
|
||||
refreshed, err := refreshAccessToken(ctx, source.httpClient, source.config(), token, source.now)
|
||||
cfg, client, err := refreshSettings(ctx, source.httpClient, source.server.URL, source.config(), token)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
refreshed, err := refreshAccessToken(ctx, client, cfg, token, source.now)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -440,3 +441,80 @@ func TestDeliverEventMessageSkipsMethodPresence(t *testing.T) {
|
||||
t.Fatal("true response must complete pending")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSSEEventsLargeImagePayload(t *testing.T) {
|
||||
// Construct a 10 MiB image payload (~13.98 MiB in base64).
|
||||
tenMiB := 10 * 1024 * 1024
|
||||
imgB64 := paddedPNGBase64(tenMiB)
|
||||
resultJSON, err := json.Marshal(map[string]any{
|
||||
"content": []map[string]any{
|
||||
{"type": "text", "text": "analysis screenshot"},
|
||||
{"type": "image", "mimeType": "image/png", "data": imgB64},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rpcJSON, err := json.Marshal(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": json.RawMessage(resultJSON),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("Single data line with 10 MiB image", func(t *testing.T) {
|
||||
stream := "event: message\ndata: " + string(rpcJSON) + "\n\n"
|
||||
msg, err := decodeSSERPCMessage(strings.NewReader(stream))
|
||||
if err != nil {
|
||||
t.Fatalf("decodeSSERPCMessage failed on single-line 10 MiB image: %v", err)
|
||||
}
|
||||
if !rpcIDMatches(msg.ID, 1) {
|
||||
t.Fatalf("expected id 1, got %#v", msg.ID)
|
||||
}
|
||||
var decoded CallToolResult
|
||||
if err := json.Unmarshal(msg.Result, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal result: %v", err)
|
||||
}
|
||||
if len(decoded.Content) != 2 || len(decoded.Content[1].Data) != len(imgB64) {
|
||||
t.Fatalf("image payload did not survive the SSE round trip: got %d bytes, want %d",
|
||||
len(decoded.Content[1].Data), len(imgB64))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Multi data lines with 10 MiB image", func(t *testing.T) {
|
||||
stream := "event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\ndata: \"result\":" + string(resultJSON) + "}\n\n"
|
||||
msg, err := decodeSSERPCMessage(strings.NewReader(stream))
|
||||
if err != nil {
|
||||
t.Fatalf("decodeSSERPCMessage failed on multi-line 10 MiB image: %v", err)
|
||||
}
|
||||
if !rpcIDMatches(msg.ID, 1) {
|
||||
t.Fatalf("expected id 1, got %#v", msg.ID)
|
||||
}
|
||||
var decoded CallToolResult
|
||||
if err := json.Unmarshal(msg.Result, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal result: %v", err)
|
||||
}
|
||||
if len(decoded.Content) != 2 || len(decoded.Content[1].Data) != len(imgB64) {
|
||||
t.Fatalf("image payload did not survive the SSE round trip: got %d bytes, want %d",
|
||||
len(decoded.Content[1].Data), len(imgB64))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Oversized event exceeding 32 MiB rejected cleanly", func(t *testing.T) {
|
||||
oversizedRPC, marshalErr := json.Marshal(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": map[string]any{"padding": strings.Repeat("A", 33*1024*1024)},
|
||||
})
|
||||
if marshalErr != nil {
|
||||
t.Fatal(marshalErr)
|
||||
}
|
||||
stream := "event: message\ndata: " + string(oversizedRPC) + "\n\n"
|
||||
_, err := decodeSSERPCMessage(strings.NewReader(stream))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 33 MiB event, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2,20 +2,35 @@ package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/imageinput"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// A server that returns only an image currently reports "(empty MCP tool
|
||||
// result)": TextContent keeps text blocks and drops the rest, so a successful
|
||||
// call looks like it produced nothing. The model then usually retries, which is
|
||||
// the worst outcome, and the user is never told an image existed (#823).
|
||||
// tinyPNGBase64 is a 1x1 PNG. http.DetectContentType sniffs it as image/png.
|
||||
const tinyPNGBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
|
||||
// paddedPNGBase64 is a decodable image/png of decoded length n. The 8-byte
|
||||
// PNG magic is enough for http.DetectContentType; the rest is padding so
|
||||
// tests can hit size caps without committing a multi-mebibyte fixture.
|
||||
func paddedPNGBase64(n int) string {
|
||||
raw := make([]byte, n)
|
||||
copy(raw, "\x89PNG\r\n\x1a\n")
|
||||
return base64.StdEncoding.EncodeToString(raw)
|
||||
}
|
||||
|
||||
// A server that returns an image WITHOUT payload still cannot be forwarded, so
|
||||
// the delivered result must name the block rather than report "(empty MCP tool
|
||||
// result)". The model then usually retries, which is the worst outcome (#823).
|
||||
//
|
||||
// Carrying the payload is a separate change. Naming what was dropped is what
|
||||
// stops the retry loop, and it has to be true of the DELIVERED result, so this
|
||||
// drives registryTool.Run rather than the helper alone.
|
||||
// Image blocks that DO carry data are forwarded on Result.Images (see the
|
||||
// payload tests below). This case is the remaining drop path, and it has to be
|
||||
// true of the DELIVERED result, so this drives registryTool.Run rather than
|
||||
// the helper alone.
|
||||
func TestAnImageOnlyResultSaysWhatItReturned(t *testing.T) {
|
||||
tool := registryTool{
|
||||
client: &nonTextClient{content: []Content{
|
||||
@@ -134,6 +149,24 @@ func TestDroppedContentSummaryNamesTheBlocks(t *testing.T) {
|
||||
},
|
||||
want: "2 resource blocks, 1 audio/wav block",
|
||||
},
|
||||
{
|
||||
name: "successfully forwarded image is not named as dropped",
|
||||
content: []Content{{Type: "image", MimeType: "image/png", Data: tinyPNGBase64}},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "malformed image data is still named",
|
||||
content: []Content{{Type: "image", MimeType: "image/png", Data: "%%%not-base64%%%"}},
|
||||
want: "1 image/png block",
|
||||
},
|
||||
{
|
||||
name: "forwarded image plus audio names only the audio",
|
||||
content: []Content{
|
||||
{Type: "image", MimeType: "image/png", Data: tinyPNGBase64},
|
||||
{Type: "audio", MimeType: "audio/wav"},
|
||||
},
|
||||
want: "1 audio/wav block",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
@@ -156,3 +189,544 @@ func (client *nonTextClient) CallTool(context.Context, string, map[string]any) (
|
||||
}
|
||||
|
||||
func (client *nonTextClient) Close() error { return nil }
|
||||
|
||||
func TestAnImageWithPayloadIsForwarded(t *testing.T) {
|
||||
tool := registryTool{
|
||||
client: &nonTextClient{content: []Content{
|
||||
{Type: "image", MimeType: "image/png", Data: tinyPNGBase64},
|
||||
}},
|
||||
server: Server{Name: "shots"},
|
||||
remote: RemoteTool{Name: "screenshot"},
|
||||
}
|
||||
|
||||
result := tool.Run(context.Background(), map[string]any{})
|
||||
|
||||
if result.Output != "[image returned by tool]" {
|
||||
t.Fatalf("image-only Output = %q, want [image returned by tool] so the tool_result is not an empty body", result.Output)
|
||||
}
|
||||
if strings.Contains(result.Output, "cannot forward") {
|
||||
t.Fatalf("a forwarded image is still described as unforwardable:\n%s", result.Output)
|
||||
}
|
||||
if len(result.Images) != 1 {
|
||||
t.Fatalf("Images len = %d, want 1", len(result.Images))
|
||||
}
|
||||
if result.Images[0].MediaType != "image/png" {
|
||||
t.Errorf("MediaType = %q, want image/png", result.Images[0].MediaType)
|
||||
}
|
||||
if len(result.Images[0].Data) == 0 {
|
||||
t.Fatal("forwarded image has empty Data")
|
||||
}
|
||||
if DroppedContentSummary([]Content{{Type: "image", MimeType: "image/png", Data: tinyPNGBase64}}) != "" {
|
||||
t.Fatal("drop summary named a successfully forwarded image")
|
||||
}
|
||||
if result.Status != tools.StatusOK {
|
||||
t.Errorf("status = %v, want OK", result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudioIsStillDroppedAndNamed(t *testing.T) {
|
||||
tool := registryTool{
|
||||
client: &nonTextClient{content: []Content{
|
||||
{Type: "audio", MimeType: "audio/wav"},
|
||||
}},
|
||||
server: Server{Name: "shots"},
|
||||
remote: RemoteTool{Name: "clip"},
|
||||
}
|
||||
|
||||
result := tool.Run(context.Background(), map[string]any{})
|
||||
|
||||
if len(result.Images) != 0 {
|
||||
t.Fatalf("audio was forwarded as an image: %#v", result.Images)
|
||||
}
|
||||
if !strings.Contains(result.Output, "audio/wav") {
|
||||
t.Errorf("the output does not name the dropped audio:\n%s", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "cannot forward yet") {
|
||||
t.Errorf("the output does not say the audio cannot be forwarded:\n%s", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextAndImageKeepsTextAndForwardsImage(t *testing.T) {
|
||||
tool := registryTool{
|
||||
client: &nonTextClient{content: []Content{
|
||||
{Type: "text", Text: "captured the page"},
|
||||
{Type: "image", MimeType: "image/png", Data: tinyPNGBase64},
|
||||
}},
|
||||
server: Server{Name: "shots"},
|
||||
remote: RemoteTool{Name: "screenshot"},
|
||||
}
|
||||
|
||||
result := tool.Run(context.Background(), map[string]any{})
|
||||
|
||||
if !strings.Contains(result.Output, "captured the page") {
|
||||
t.Errorf("the text block was lost:\n%s", result.Output)
|
||||
}
|
||||
if strings.Contains(result.Output, "cannot forward") {
|
||||
t.Errorf("a forwarded image is still described as unforwardable:\n%s", result.Output)
|
||||
}
|
||||
if strings.Contains(result.Output, "[image forwarded]") {
|
||||
t.Errorf("text+image result substituted a placeholder over the text:\n%s", result.Output)
|
||||
}
|
||||
if len(result.Images) != 1 {
|
||||
t.Fatalf("Images len = %d, want 1", len(result.Images))
|
||||
}
|
||||
if result.Images[0].MediaType != "image/png" {
|
||||
t.Errorf("MediaType = %q, want image/png", result.Images[0].MediaType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedImageDataDoesNotPanic(t *testing.T) {
|
||||
tool := registryTool{
|
||||
client: &nonTextClient{content: []Content{
|
||||
{Type: "image", MimeType: "image/png", Data: "%%%not-base64%%%"},
|
||||
}},
|
||||
server: Server{Name: "shots"},
|
||||
remote: RemoteTool{Name: "screenshot"},
|
||||
}
|
||||
|
||||
result := tool.Run(context.Background(), map[string]any{})
|
||||
|
||||
if len(result.Images) != 0 {
|
||||
t.Fatalf("malformed image was forwarded: %#v", result.Images)
|
||||
}
|
||||
if !strings.Contains(result.Output, "image/png") {
|
||||
t.Errorf("malformed image was not named in the drop summary:\n%s", result.Output)
|
||||
}
|
||||
if strings.Contains(result.Output, "(empty MCP tool result)") {
|
||||
t.Errorf("malformed image still reported as empty:\n%s", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageContentJSONDecodesDataAndStaysCompatibleWithoutIt(t *testing.T) {
|
||||
var withData CallToolResult
|
||||
raw := []byte(`{"content":[{"type":"image","mimeType":"image/png","data":"` + tinyPNGBase64 + `"}]}`)
|
||||
if err := json.Unmarshal(raw, &withData); err != nil {
|
||||
t.Fatalf("unmarshal image content: %v", err)
|
||||
}
|
||||
if len(withData.Content) != 1 {
|
||||
t.Fatalf("content len = %d, want 1", len(withData.Content))
|
||||
}
|
||||
if withData.Content[0].Type != "image" || withData.Content[0].MimeType != "image/png" {
|
||||
t.Fatalf("decoded fields = %+v", withData.Content[0])
|
||||
}
|
||||
if withData.Content[0].Data != tinyPNGBase64 {
|
||||
t.Fatalf("data = %q, want tiny PNG base64", withData.Content[0].Data)
|
||||
}
|
||||
|
||||
var withoutData CallToolResult
|
||||
if err := json.Unmarshal([]byte(`{"content":[{"type":"image","mimeType":"image/png"}]}`), &withoutData); err != nil {
|
||||
t.Fatalf("unmarshal image content without data: %v", err)
|
||||
}
|
||||
if withoutData.Content[0].Data != "" {
|
||||
t.Fatalf("absent data decoded as %q, want empty", withoutData.Content[0].Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnExactlyMaxImageBytesPaddedPNGIsForwarded(t *testing.T) {
|
||||
payload := paddedPNGBase64(imageinput.MaxImageBytes)
|
||||
if got := base64.StdEncoding.DecodedLen(len(payload)); got <= imageinput.MaxImageBytes {
|
||||
t.Fatalf("fixture DecodedLen = %d, want > %d so the old bound would reject it", got, imageinput.MaxImageBytes)
|
||||
}
|
||||
tool := registryTool{
|
||||
client: &nonTextClient{content: []Content{
|
||||
{Type: "image", MimeType: "image/png", Data: payload},
|
||||
}},
|
||||
server: Server{Name: "shots"},
|
||||
remote: RemoteTool{Name: "screenshot"},
|
||||
}
|
||||
|
||||
result := tool.Run(context.Background(), map[string]any{})
|
||||
if len(result.Images) != 1 {
|
||||
t.Fatalf("at-limit padded PNG was dropped: images=%d output=%q", len(result.Images), result.Output)
|
||||
}
|
||||
if got := len(result.Images[0].Data); got != imageinput.MaxImageBytes {
|
||||
t.Fatalf("forwarded size = %d, want %d", got, imageinput.MaxImageBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnOversizedImageIsDroppedAndNamed(t *testing.T) {
|
||||
tool := registryTool{
|
||||
client: &nonTextClient{content: []Content{
|
||||
{Type: "image", MimeType: "image/png", Data: paddedPNGBase64(imageinput.MaxImageBytes + 1)},
|
||||
}},
|
||||
server: Server{Name: "shots"},
|
||||
remote: RemoteTool{Name: "screenshot"},
|
||||
}
|
||||
|
||||
result := tool.Run(context.Background(), map[string]any{})
|
||||
|
||||
if len(result.Images) != 0 {
|
||||
t.Fatalf("oversized image was forwarded: %#v", result.Images)
|
||||
}
|
||||
if !strings.Contains(result.Output, "image/png") {
|
||||
t.Errorf("oversized image was not named in the drop summary:\n%s", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "cannot forward yet") {
|
||||
t.Errorf("individually oversized image should stay unforwardable, not a budget skip:\n%s", result.Output)
|
||||
}
|
||||
if strings.Contains(result.Output, "image budget") {
|
||||
t.Errorf("individually oversized image was described as a budget skip:\n%s", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateImageBudgetForwardsTheFirstAndNamesTheRest(t *testing.T) {
|
||||
// Each payload is under the per-image cap; together they exceed the
|
||||
// aggregate MaxImageBytes budget for one result. Identical bytes are
|
||||
// deliberate: DroppedContentSummary must name the second even though
|
||||
// imageBlockFromContent would accept it in isolation.
|
||||
payload := paddedPNGBase64(imageinput.MaxImageBytes/2 + 1)
|
||||
tool := registryTool{
|
||||
client: &nonTextClient{content: []Content{
|
||||
{Type: "image", MimeType: "image/png", Data: payload},
|
||||
{Type: "image", MimeType: "image/png", Data: payload},
|
||||
}},
|
||||
server: Server{Name: "shots"},
|
||||
remote: RemoteTool{Name: "screenshot"},
|
||||
}
|
||||
|
||||
result := tool.Run(context.Background(), map[string]any{})
|
||||
|
||||
if len(result.Images) != 1 {
|
||||
t.Fatalf("Images len = %d, want 1 (first fits the aggregate budget)", len(result.Images))
|
||||
}
|
||||
if got := len(result.Images[0].Data); got != imageinput.MaxImageBytes/2+1 {
|
||||
t.Errorf("forwarded image size = %d, want %d", got, imageinput.MaxImageBytes/2+1)
|
||||
}
|
||||
if !strings.Contains(result.Output, "[image returned by tool]") {
|
||||
t.Errorf("the forwarded first image has no placeholder:\n%s", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "image/png") {
|
||||
t.Errorf("the dropped second image was not named:\n%s", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "which exceeded this result's remaining image budget") {
|
||||
t.Errorf("the dropped second image is not described as a budget exceeded:\n%s", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "Retrying with fewer images can recover this payload.") {
|
||||
t.Errorf("the output does not tell the model a retry can recover the payload:\n%s", result.Output)
|
||||
}
|
||||
if strings.Contains(result.Output, "cannot forward yet") {
|
||||
t.Errorf("a budget-exceeded image is described as unforwardable:\n%s", result.Output)
|
||||
}
|
||||
if strings.Contains(result.Output, "Retrying cannot recover this payload.") {
|
||||
t.Errorf("a budget-exceeded image is described as unrecoverable:\n%s", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImagePayloadsAreDecodedOnceAndNotPastTheBudget(t *testing.T) {
|
||||
orig := decodeImageBase64
|
||||
t.Cleanup(func() { decodeImageBase64 = orig })
|
||||
var n int
|
||||
decodeImageBase64 = func(s string) ([]byte, error) {
|
||||
n++
|
||||
return orig(s)
|
||||
}
|
||||
|
||||
// Two images fill the 10 MiB aggregate exactly, so remaining hits 0 and
|
||||
// the later candidates must not be decoded at all.
|
||||
half := imageinput.MaxImageBytes / 2
|
||||
payload := paddedPNGBase64(half)
|
||||
content := []Content{
|
||||
{Type: "image", MimeType: "image/png", Data: payload},
|
||||
{Type: "image", MimeType: "image/png", Data: payload},
|
||||
{Type: "image", MimeType: "image/png", Data: payload},
|
||||
{Type: "image", MimeType: "image/png", Data: "not-even-valid-base64!"},
|
||||
{Type: "image", MimeType: "image/png", Data: ""},
|
||||
{Type: "audio", MimeType: "audio/wav"},
|
||||
}
|
||||
|
||||
n = 0
|
||||
images := ImageBlocks(content)
|
||||
if n != 2 {
|
||||
t.Fatalf("ImageBlocks decoded %d payloads, want 2 (budget fills after two %d-byte images)", n, half)
|
||||
}
|
||||
if len(images) != 2 {
|
||||
t.Fatalf("ImageBlocks len = %d, want 2", len(images))
|
||||
}
|
||||
n = 0
|
||||
if got := DroppedContentSummary(content); got != "3 image/png blocks, 1 audio/wav block" {
|
||||
t.Fatalf("DroppedContentSummary() = %q, want the three skipped images and the audio", got)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Fatalf("DroppedContentSummary decoded %d payloads, want 2 (same single pass as ImageBlocks)", n)
|
||||
}
|
||||
|
||||
n = 0
|
||||
result := registryTool{
|
||||
client: &nonTextClient{content: content},
|
||||
server: Server{Name: "shots"},
|
||||
remote: RemoteTool{Name: "screenshot"},
|
||||
}.Run(context.Background(), map[string]any{})
|
||||
if n != 2 {
|
||||
t.Fatalf("Run decoded %d payloads, want 2 (one pass; drop note must not decode again)", n)
|
||||
}
|
||||
if len(result.Images) != 2 {
|
||||
t.Fatalf("Images len = %d, want 2", len(result.Images))
|
||||
}
|
||||
if !strings.Contains(result.Output, "[image returned by tool]") {
|
||||
t.Errorf("forwarded images have no placeholder:\n%s", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "image/png") {
|
||||
t.Errorf("skipped images were not named:\n%s", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "which were not inspected because the aggregate image budget was reached") {
|
||||
t.Errorf("uninspected images are not described correctly:\n%s", result.Output)
|
||||
}
|
||||
if strings.Contains(result.Output, "Retrying with fewer images can recover this payload.") {
|
||||
t.Errorf("uninspected images must not make unsupported recovery claims:\n%s", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "audio/wav") {
|
||||
t.Errorf("audio was not named:\n%s", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "cannot forward yet") {
|
||||
t.Errorf("audio is not described as unforwardable:\n%s", result.Output)
|
||||
}
|
||||
if strings.Contains(result.Output, "(empty MCP tool result)") {
|
||||
t.Errorf("forwarded images still reported as empty:\n%s", result.Output)
|
||||
}
|
||||
|
||||
n = 0
|
||||
one := []Content{{Type: "image", MimeType: "image/png", Data: payload}}
|
||||
oneResult := registryTool{
|
||||
client: &nonTextClient{content: one},
|
||||
server: Server{Name: "shots"},
|
||||
remote: RemoteTool{Name: "screenshot"},
|
||||
}.Run(context.Background(), map[string]any{})
|
||||
if n != 1 {
|
||||
t.Fatalf("one image decoded %d times, want 1", n)
|
||||
}
|
||||
if len(oneResult.Images) != 1 {
|
||||
t.Fatalf("one-image Images len = %d, want 1", len(oneResult.Images))
|
||||
}
|
||||
if oneResult.Output != "[image returned by tool]" {
|
||||
t.Fatalf("one-image Output = %q, want [image returned by tool]", oneResult.Output)
|
||||
}
|
||||
if strings.Contains(oneResult.Output, "cannot forward") {
|
||||
t.Fatalf("a forwarded image is still described as unforwardable:\n%s", oneResult.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageBudgetNonZeroResidueAllowsSmallerLaterImage(t *testing.T) {
|
||||
// First image: 8 MiB (fits, 2 MiB left)
|
||||
// Second image: 3 MiB (exceeds remaining 2 MiB, budgetExceeded)
|
||||
// Third image: 1 MiB (fits in remaining 2 MiB, forwarded, 1 MiB left)
|
||||
img8 := paddedPNGBase64(8 * 1024 * 1024)
|
||||
img3 := paddedPNGBase64(3 * 1024 * 1024)
|
||||
img1 := paddedPNGBase64(1 * 1024 * 1024)
|
||||
|
||||
content := []Content{
|
||||
{Type: "image", MimeType: "image/png", Data: img8},
|
||||
{Type: "image", MimeType: "image/png", Data: img3},
|
||||
{Type: "image", MimeType: "image/png", Data: img1},
|
||||
}
|
||||
|
||||
images, disp, _ := forwardImages(content)
|
||||
if len(images) != 2 {
|
||||
t.Fatalf("forwardImages len = %d, want 2 (8 MiB + 1 MiB)", len(images))
|
||||
}
|
||||
if disp[0] != dispForwarded || disp[1] != dispBudgetExceeded || disp[2] != dispForwarded {
|
||||
t.Fatalf("dispositions = %v, want [forwarded, budgetExceeded, forwarded]", disp)
|
||||
}
|
||||
|
||||
result := registryTool{
|
||||
client: &nonTextClient{content: content},
|
||||
server: Server{Name: "shots"},
|
||||
remote: RemoteTool{Name: "screenshot"},
|
||||
}.Run(context.Background(), map[string]any{})
|
||||
|
||||
if len(result.Images) != 2 {
|
||||
t.Fatalf("result Images len = %d, want 2", len(result.Images))
|
||||
}
|
||||
if !strings.Contains(result.Output, "exceeded this result's remaining image budget") {
|
||||
t.Fatalf("expected remaining budget notice in output:\n%s", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "Retrying with fewer images can recover this payload.") {
|
||||
t.Fatalf("expected retry recovery guidance for validated exceeded image:\n%s", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageCountBudgetPreservesTextAndSkipsFurtherDecoding(t *testing.T) {
|
||||
content := []Content{{Type: "image", Data: "invalid"}}
|
||||
for range 17 {
|
||||
content = append(content, Content{Type: "image", MimeType: "image/png", Data: tinyPNGBase64})
|
||||
}
|
||||
content = append(content, Content{Type: "text", Text: "trailing text"})
|
||||
previous := decodeImageBase64
|
||||
decodes := 0
|
||||
decodeImageBase64 = func(s string) ([]byte, error) {
|
||||
decodes++
|
||||
return previous(s)
|
||||
}
|
||||
t.Cleanup(func() { decodeImageBase64 = previous })
|
||||
result := registryTool{
|
||||
client: &nonTextClient{content: content},
|
||||
server: Server{Name: "shots"}, remote: RemoteTool{Name: "screenshot"},
|
||||
}.Run(context.Background(), map[string]any{})
|
||||
if len(result.Images) != 16 || decodes != 17 {
|
||||
t.Fatalf("forwarded %d images with %d decodes, want 16 images and 17 attempts including invalid input", len(result.Images), decodes)
|
||||
}
|
||||
if !strings.Contains(result.Output, "trailing text") || !strings.Contains(result.Output, "1 image/png block, which was not inspected") {
|
||||
t.Fatalf("missing text or count-budget notice: %s", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageInspectionBudgetBoundsRejectedCandidates(t *testing.T) {
|
||||
// Use the decoder seam to exercise accounting without allocating a large
|
||||
// payload for every rejected candidate.
|
||||
previous := decodeImageBase64
|
||||
decodes := 0
|
||||
large, _ := previous(paddedPNGBase64(8 * 1024 * 1024))
|
||||
rejected, _ := previous(paddedPNGBase64(3 * 1024 * 1024))
|
||||
decodeImageBase64 = func(string) ([]byte, error) {
|
||||
decodes++
|
||||
if decodes == 1 {
|
||||
return large, nil
|
||||
}
|
||||
return rejected, nil
|
||||
}
|
||||
t.Cleanup(func() { decodeImageBase64 = previous })
|
||||
content := make([]Content, 40)
|
||||
for i := range content {
|
||||
content[i] = Content{Type: "image", MimeType: "image/png", Data: tinyPNGBase64}
|
||||
}
|
||||
content = append(content, Content{Type: "text", Text: "trailing text"})
|
||||
images, disp, _ := forwardImages(content)
|
||||
if decodes != 32 || len(images) != 1 {
|
||||
t.Fatalf("got %d decodes and %d forwarded images, want 32 and 1", decodes, len(images))
|
||||
}
|
||||
for i := 1; i < 40; i++ {
|
||||
want := dispBudgetExceeded
|
||||
if i >= 32 {
|
||||
want = dispUninspected
|
||||
}
|
||||
if disp[i] != want {
|
||||
t.Errorf("disposition[%d] = %v, want %v", i, disp[i], want)
|
||||
}
|
||||
}
|
||||
if disp[40] != dispText {
|
||||
t.Fatal("inspection limit discarded trailing text")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkForwardImagesFourHalfBudget(b *testing.B) {
|
||||
payload := paddedPNGBase64(imageinput.MaxImageBytes / 2)
|
||||
content := []Content{
|
||||
{Type: "image", MimeType: "image/png", Data: payload},
|
||||
{Type: "image", MimeType: "image/png", Data: payload},
|
||||
{Type: "image", MimeType: "image/png", Data: payload},
|
||||
{Type: "image", MimeType: "image/png", Data: payload},
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _, _ = forwardImages(content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPResultUninspectedImageLimitingReasonNotes(t *testing.T) {
|
||||
t.Run("forwarded count limit reached", func(t *testing.T) {
|
||||
content := make([]Content, 20)
|
||||
for i := range content {
|
||||
content[i] = Content{Type: "image", MimeType: "image/png", Data: tinyPNGBase64}
|
||||
}
|
||||
result := registryTool{
|
||||
client: &nonTextClient{content: content},
|
||||
server: Server{Name: "shots"},
|
||||
remote: RemoteTool{Name: "screenshot"},
|
||||
}.Run(context.Background(), map[string]any{})
|
||||
if len(result.Images) != 16 {
|
||||
t.Fatalf("expected 16 forwarded images, got %d", len(result.Images))
|
||||
}
|
||||
if !strings.Contains(result.Output, "which were not inspected because the maximum forwarded image count was reached.") {
|
||||
t.Fatalf("expected maximum forwarded image count message, got:\n%s", result.Output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("inspection limit reached", func(t *testing.T) {
|
||||
previous := decodeImageBase64
|
||||
large := make([]byte, 1024)
|
||||
rejected := make([]byte, imageinput.MaxImageBytes+1)
|
||||
decodes := 0
|
||||
decodeImageBase64 = func(s string) ([]byte, error) {
|
||||
decodes++
|
||||
if decodes == 1 {
|
||||
return large, nil
|
||||
}
|
||||
return rejected, nil
|
||||
}
|
||||
t.Cleanup(func() { decodeImageBase64 = previous })
|
||||
content := make([]Content, 35)
|
||||
for i := range content {
|
||||
content[i] = Content{Type: "image", MimeType: "image/png", Data: tinyPNGBase64}
|
||||
}
|
||||
result := registryTool{
|
||||
client: &nonTextClient{content: content},
|
||||
server: Server{Name: "shots"},
|
||||
remote: RemoteTool{Name: "screenshot"},
|
||||
}.Run(context.Background(), map[string]any{})
|
||||
if !strings.Contains(result.Output, "which were not inspected because the maximum image inspection limit was reached.") {
|
||||
t.Fatalf("expected maximum image inspection limit message, got:\n%s", result.Output)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The pre-decode encoded-length cap is the decode-DoS guard: it keeps a
|
||||
// hostile server from making Zero decode a payload of any size (up to the
|
||||
// inspection cap times per result) before the post-decode backstop drops it.
|
||||
// Removing the cap changes nothing that is forwarded, so no existing test
|
||||
// notices; this pins the guard on the decode hook instead. A payload one
|
||||
// byte over the encoded cap must be rejected without the decoder ever being
|
||||
// called.
|
||||
func TestOverCapEncodedPayloadIsRejectedBeforeDecoding(t *testing.T) {
|
||||
previous := decodeImageBase64
|
||||
decodes := 0
|
||||
decodeImageBase64 = func(s string) ([]byte, error) {
|
||||
decodes++
|
||||
return previous(s)
|
||||
}
|
||||
t.Cleanup(func() { decodeImageBase64 = previous })
|
||||
|
||||
// An at-limit PNG would decode to exactly MaxImageBytes and forward; a
|
||||
// single extra base64 character pushes it one byte over the encoded cap,
|
||||
// so only the pre-decode guard can be responsible for rejecting it.
|
||||
payload := paddedPNGBase64(imageinput.MaxImageBytes) + "A"
|
||||
content := []Content{{Type: "image", MimeType: "image/png", Data: payload}}
|
||||
|
||||
if images := ImageBlocks(content); len(images) != 0 {
|
||||
t.Fatalf("ImageBlocks forwarded %d images from an over-cap payload, want 0", len(images))
|
||||
}
|
||||
if decodes != 0 {
|
||||
t.Fatalf("decoder ran %d times, want 0: the pre-decode cap must reject before any decode work", decodes)
|
||||
}
|
||||
if got := DroppedContentSummary(content); got != "1 image/png block" {
|
||||
t.Fatalf("DroppedContentSummary() = %q, want the over-cap payload named as dropped", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The media type is sniffed from the decoded bytes, never taken from the
|
||||
// block's declared mimeType: a hostile server must not be able to relabel
|
||||
// HTML or a script as image/png and have it forwarded to the provider as an
|
||||
// image. Every other fixture pairs real PNG bytes with image/png, so
|
||||
// sniffing and trusting the declared type agree there and no test can tell
|
||||
// them apart; this fixture makes them disagree.
|
||||
func TestDeclaredMimeTypeDisagreeingWithSniffedBytesIsDropped(t *testing.T) {
|
||||
html := []byte("<!doctype html><html><body><script>alert(1)</script></body></html>")
|
||||
tool := registryTool{
|
||||
client: &nonTextClient{content: []Content{
|
||||
{Type: "image", MimeType: "image/png", Data: base64.StdEncoding.EncodeToString(html)},
|
||||
}},
|
||||
server: Server{Name: "shots"},
|
||||
remote: RemoteTool{Name: "screenshot"},
|
||||
}
|
||||
|
||||
result := tool.Run(context.Background(), map[string]any{})
|
||||
|
||||
if len(result.Images) != 0 {
|
||||
t.Fatalf("forwarded %d images from an HTML payload declared as image/png, want 0", len(result.Images))
|
||||
}
|
||||
if strings.Contains(result.Output, "[image returned by tool]") {
|
||||
t.Errorf("a non-image payload declared as image/png is still announced as a returned image:\n%s", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "image/png") {
|
||||
t.Errorf("the dropped block is not named in the drop summary:\n%s", result.Output)
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,11 @@ func tokenToStored(t oauth.Token) StoredToken {
|
||||
TokenType: t.TokenType,
|
||||
Scopes: t.Scopes,
|
||||
ExpiresAt: t.ExpiresAt,
|
||||
|
||||
TokenEndpoint: t.TokenEndpoint,
|
||||
ProtectedTokenEndpoint: t.ProtectedTokenEndpoint,
|
||||
ClientID: t.ClientID,
|
||||
ClientSecret: t.ClientSecret,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,7 +346,53 @@ func refreshAccessToken(ctx context.Context, client *http.Client, cfg OAuthConfi
|
||||
if err != nil {
|
||||
return StoredToken{}, err
|
||||
}
|
||||
return tokenToStored(token), nil
|
||||
refreshed := tokenToStored(token)
|
||||
// The token response never carries what Login learned about the client and
|
||||
// endpoint, so carry it over or the next refresh would lose it.
|
||||
refreshed.TokenEndpoint = current.TokenEndpoint
|
||||
refreshed.ProtectedTokenEndpoint = current.ProtectedTokenEndpoint
|
||||
refreshed.ClientID = current.ClientID
|
||||
refreshed.ClientSecret = current.ClientSecret
|
||||
return refreshed, nil
|
||||
}
|
||||
|
||||
// refreshSettings fills the gaps in the configured OAuth settings from what the
|
||||
// login stored: the discovered token endpoint and the dynamically registered
|
||||
// client. Explicit config always wins. A stored endpoint is re-validated before
|
||||
// credentials are posted to it, and one that came from server-advertised
|
||||
// discovery is also held to the public-network policy, with the returned client
|
||||
// pinned to it, exactly as Login did.
|
||||
func refreshSettings(ctx context.Context, client *http.Client, resourceURL string, cfg OAuthConfig, stored StoredToken) (OAuthConfig, *http.Client, error) {
|
||||
if strings.TrimSpace(cfg.ClientID) == "" && strings.TrimSpace(stored.ClientID) != "" {
|
||||
cfg.ClientID = stored.ClientID
|
||||
if strings.TrimSpace(cfg.ClientSecret) == "" {
|
||||
cfg.ClientSecret = stored.ClientSecret
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(cfg.TokenEndpoint) != "" || strings.TrimSpace(stored.TokenEndpoint) == "" {
|
||||
return cfg, client, nil
|
||||
}
|
||||
endpoint := strings.TrimSpace(stored.TokenEndpoint)
|
||||
if err := oauth.ValidateEndpointURL(endpoint); err != nil {
|
||||
return cfg, client, fmt.Errorf("mcp oauth: stored token endpoint: %w", err)
|
||||
}
|
||||
if stored.ProtectedTokenEndpoint {
|
||||
metadata := authServerMetadata{
|
||||
TokenEndpoint: endpoint,
|
||||
protectedResourceURL: strings.TrimSpace(resourceURL),
|
||||
protectTokenEndpoint: true,
|
||||
}
|
||||
if err := validateProtectedDiscoveredEndpoints(ctx, metadata); err != nil {
|
||||
return cfg, client, err
|
||||
}
|
||||
protected, err := newAdvertisedOAuthDiscoveryClient(client, metadata.protectedResourceURL)
|
||||
if err != nil {
|
||||
return cfg, client, err
|
||||
}
|
||||
client = protected
|
||||
}
|
||||
cfg.TokenEndpoint = endpoint
|
||||
return cfg, client, nil
|
||||
}
|
||||
|
||||
// registerClient performs dynamic client registration against the registration
|
||||
@@ -446,6 +497,7 @@ func Login(ctx context.Context, options LoginOptions) (StoredToken, error) {
|
||||
tokenClient = protectedEndpointClient
|
||||
}
|
||||
|
||||
registeredClientID, registeredClientSecret := "", ""
|
||||
if strings.TrimSpace(cfg.ClientID) == "" {
|
||||
if registration := strings.TrimSpace(metadata.RegistrationEndpoint); registration != "" {
|
||||
registrationClient := httpClient
|
||||
@@ -457,8 +509,10 @@ func Login(ctx context.Context, options LoginOptions) (StoredToken, error) {
|
||||
return StoredToken{}, regErr
|
||||
}
|
||||
cfg.ClientID = clientID
|
||||
registeredClientID = clientID
|
||||
if clientSecret != "" {
|
||||
cfg.ClientSecret = clientSecret
|
||||
registeredClientSecret = clientSecret
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -494,31 +548,28 @@ func Login(ctx context.Context, options LoginOptions) (StoredToken, error) {
|
||||
err error
|
||||
}
|
||||
resultChan := make(chan callbackResult, 1)
|
||||
server := &http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/callback" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
code, parseErr := flow.parseCallback(r.URL.Query())
|
||||
if parseErr != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = io.WriteString(w, "Authorization failed. You may close this window.")
|
||||
} else {
|
||||
_, _ = io.WriteString(w, "Authorization complete. You may close this window.")
|
||||
}
|
||||
select {
|
||||
case resultChan <- callbackResult{code: code, err: parseErr}:
|
||||
default:
|
||||
}
|
||||
}),
|
||||
}
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
defer func() {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
_ = server.Shutdown(shutdownCtx)
|
||||
}()
|
||||
// The shared callback server bounds header reads, force-closes a client
|
||||
// still connected when shutdown runs out of time, and waits for Serve to
|
||||
// return. The bare http.Server this used to build did none of that, so a
|
||||
// client stalled mid-header outlived Login (#1028).
|
||||
server := oauth.StartCallbackServer(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/callback" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
code, parseErr := flow.parseCallback(r.URL.Query())
|
||||
if parseErr != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = io.WriteString(w, "Authorization failed. You may close this window.")
|
||||
} else {
|
||||
_, _ = io.WriteString(w, "Authorization complete. You may close this window.")
|
||||
}
|
||||
select {
|
||||
case resultChan <- callbackResult{code: code, err: parseErr}:
|
||||
default:
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
open := options.OpenBrowser
|
||||
if open == nil {
|
||||
@@ -537,6 +588,14 @@ func Login(ctx context.Context, options LoginOptions) (StoredToken, error) {
|
||||
if err != nil {
|
||||
return StoredToken{}, err
|
||||
}
|
||||
// Persist only what config does not already carry, so refresh can reuse
|
||||
// it without copying configured secrets into the token store.
|
||||
if strings.TrimSpace(options.Config.TokenEndpoint) == "" {
|
||||
token.TokenEndpoint = metadata.TokenEndpoint
|
||||
token.ProtectedTokenEndpoint = metadata.protectTokenEndpoint
|
||||
}
|
||||
token.ClientID = registeredClientID
|
||||
token.ClientSecret = registeredClientSecret
|
||||
return token, nil
|
||||
case <-loginCtx.Done():
|
||||
return StoredToken{}, fmt.Errorf("timed out waiting for OAuth authorization callback: %w", loginCtx.Err())
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
)
|
||||
|
||||
const testServerIdentity = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
// discoveryOAuthServer serves a standard `auth: oauth` setup: protected-resource
|
||||
// metadata, authorization-server metadata with a registration endpoint, dynamic
|
||||
// client registration, and a token endpoint that records refresh requests.
|
||||
type discoveryOAuthServer struct {
|
||||
*httptest.Server
|
||||
refreshForms chan url.Values
|
||||
}
|
||||
|
||||
func newDiscoveryOAuthServer(t *testing.T) *discoveryOAuthServer {
|
||||
t.Helper()
|
||||
fixture := &discoveryOAuthServer{refreshForms: make(chan url.Values, 4)}
|
||||
fixture.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
base := "http://" + r.Host
|
||||
switch r.URL.Path {
|
||||
case "/mcp":
|
||||
w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="`+base+`/resource-metadata"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
case "/resource-metadata":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"resource": base + "/mcp",
|
||||
"authorization_servers": []string{base},
|
||||
})
|
||||
case "/.well-known/oauth-authorization-server":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"issuer": base,
|
||||
"authorization_endpoint": base + "/authorize",
|
||||
"token_endpoint": base + "/token",
|
||||
"registration_endpoint": base + "/register",
|
||||
})
|
||||
case "/register":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"client_id": "registered-client",
|
||||
"client_secret": "registered-secret",
|
||||
})
|
||||
case "/token":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Errorf("parse token form: %v", err)
|
||||
}
|
||||
if r.Form.Get("grant_type") == "refresh_token" {
|
||||
fixture.refreshForms <- r.Form
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "access-refreshed",
|
||||
"refresh_token": "refresh-rotated",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "access-initial",
|
||||
"refresh_token": "refresh-initial",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(fixture.Close)
|
||||
return fixture
|
||||
}
|
||||
|
||||
func driveLoopbackCallback(authURL string) error {
|
||||
parsed, err := url.Parse(authURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
callbackURL := parsed.Query().Get("redirect_uri") + "?code=auth-code&state=" + url.QueryEscape(parsed.Query().Get("state"))
|
||||
go func() {
|
||||
for attempt := 0; attempt < 20; attempt++ {
|
||||
response, requestErr := http.Get(callbackURL)
|
||||
if requestErr == nil {
|
||||
response.Body.Close()
|
||||
return
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestLoginRecordsDiscoveredEndpointAndRegisteredClient(t *testing.T) {
|
||||
fixture := newDiscoveryOAuthServer(t)
|
||||
|
||||
token, err := Login(context.Background(), LoginOptions{
|
||||
ServerName: "notion-style",
|
||||
ServerURL: fixture.URL + "/mcp",
|
||||
Config: OAuthConfig{},
|
||||
HTTPClient: fixture.Client(),
|
||||
OpenBrowser: driveLoopbackCallback,
|
||||
Timeout: 5 * time.Second,
|
||||
Now: time.Now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Login() error = %v", err)
|
||||
}
|
||||
if token.TokenEndpoint != fixture.URL+"/token" || !token.ProtectedTokenEndpoint {
|
||||
t.Fatalf("stored token endpoint = %q protected=%v, want the discovered protected endpoint", token.TokenEndpoint, token.ProtectedTokenEndpoint)
|
||||
}
|
||||
if token.ClientID != "registered-client" || token.ClientSecret != "registered-secret" {
|
||||
t.Fatalf("stored client = %q/%q, want the dynamically registered client", token.ClientID, token.ClientSecret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginDoesNotCopyConfiguredClientOrEndpointIntoStore(t *testing.T) {
|
||||
fixture := newDiscoveryOAuthServer(t)
|
||||
|
||||
token, err := Login(context.Background(), LoginOptions{
|
||||
ServerName: "configured",
|
||||
ServerURL: fixture.URL + "/mcp",
|
||||
Config: OAuthConfig{
|
||||
ClientID: "configured-client",
|
||||
ClientSecret: "configured-secret",
|
||||
TokenEndpoint: fixture.URL + "/token",
|
||||
},
|
||||
HTTPClient: fixture.Client(),
|
||||
OpenBrowser: driveLoopbackCallback,
|
||||
Timeout: 5 * time.Second,
|
||||
Now: time.Now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Login() error = %v", err)
|
||||
}
|
||||
if token.TokenEndpoint != "" || token.ProtectedTokenEndpoint || token.ClientID != "" || token.ClientSecret != "" {
|
||||
t.Fatalf("configured values were copied into the token store: %#v", token)
|
||||
}
|
||||
}
|
||||
|
||||
// The standard `auth: oauth` setup configures no token endpoint or client, so
|
||||
// refresh must work from what the discovery-based login stored.
|
||||
func TestStoreTokenSourceRefreshUsesStoredDiscoveryAndClient(t *testing.T) {
|
||||
fixture := newDiscoveryOAuthServer(t)
|
||||
server := Server{Name: "notion-style", URL: fixture.URL + "/mcp", Identity: testServerIdentity, Auth: ServerAuthOAuth}
|
||||
|
||||
loggedIn, err := Login(context.Background(), LoginOptions{
|
||||
ServerName: server.Name,
|
||||
ServerURL: server.URL,
|
||||
HTTPClient: fixture.Client(),
|
||||
OpenBrowser: driveLoopbackCallback,
|
||||
Timeout: 5 * time.Second,
|
||||
Now: time.Now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Login() error = %v", err)
|
||||
}
|
||||
store, err := NewTokenStore(TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "oauth-tokens.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTokenStore() error = %v", err)
|
||||
}
|
||||
if err := store.SaveForServer(server, loggedIn); err != nil {
|
||||
t.Fatalf("SaveForServer() error = %v", err)
|
||||
}
|
||||
|
||||
source := &storeTokenSource{server: server, store: store, httpClient: http.DefaultClient, now: time.Now}
|
||||
access, err := source.Refresh(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Refresh() error = %v", err)
|
||||
}
|
||||
if access != "access-refreshed" {
|
||||
t.Fatalf("Refresh() access token = %q", access)
|
||||
}
|
||||
form := <-fixture.refreshForms
|
||||
if form.Get("client_id") != "registered-client" || form.Get("client_secret") != "registered-secret" || form.Get("refresh_token") != "refresh-initial" {
|
||||
t.Fatalf("refresh form = %v, want the registered client and stored refresh token", form)
|
||||
}
|
||||
|
||||
// The refresh must not drop what Login stored, or the next one would fail.
|
||||
saved, ok, err := store.LoadForServer(server)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadForServer() = %v, %v", ok, err)
|
||||
}
|
||||
if saved.RefreshToken != "refresh-rotated" || saved.TokenEndpoint != fixture.URL+"/token" || !saved.ProtectedTokenEndpoint ||
|
||||
saved.ClientID != "registered-client" || saved.ClientSecret != "registered-secret" {
|
||||
t.Fatalf("refreshed token lost the stored endpoint or client: %#v", saved)
|
||||
}
|
||||
if _, err := source.Refresh(context.Background()); err != nil {
|
||||
t.Fatalf("second Refresh() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshSettingsConfigOverridesStored(t *testing.T) {
|
||||
cfg, client, err := refreshSettings(context.Background(), http.DefaultClient, "https://mcp.example.com/mcp",
|
||||
OAuthConfig{ClientID: "configured-client", ClientSecret: "configured-secret", TokenEndpoint: "https://configured.example.com/token"},
|
||||
StoredToken{TokenEndpoint: "https://stored.example.com/token", ProtectedTokenEndpoint: true, ClientID: "stored-client", ClientSecret: "stored-secret"})
|
||||
if err != nil {
|
||||
t.Fatalf("refreshSettings() error = %v", err)
|
||||
}
|
||||
if cfg.TokenEndpoint != "https://configured.example.com/token" || cfg.ClientID != "configured-client" || cfg.ClientSecret != "configured-secret" {
|
||||
t.Fatalf("config did not win over stored values: %#v", cfg)
|
||||
}
|
||||
if client != http.DefaultClient {
|
||||
t.Fatal("an explicitly configured endpoint must not switch to the protected-discovery client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshSettingsKeepsConfiguredSecretWithStoredClientID(t *testing.T) {
|
||||
cfg, _, err := refreshSettings(context.Background(), http.DefaultClient, "https://mcp.example.com/mcp",
|
||||
OAuthConfig{ClientSecret: "configured-secret"},
|
||||
StoredToken{ClientID: "stored-client", ClientSecret: "stored-secret"})
|
||||
if err != nil {
|
||||
t.Fatalf("refreshSettings() error = %v", err)
|
||||
}
|
||||
if cfg.ClientID != "stored-client" || cfg.ClientSecret != "configured-secret" {
|
||||
t.Fatalf("client = %q/%q, want stored id with the configured secret", cfg.ClientID, cfg.ClientSecret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshSettingsRejectsUnsafeStoredEndpoint(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
stored StoredToken
|
||||
want error
|
||||
}{
|
||||
{
|
||||
name: "cleartext endpoint",
|
||||
stored: StoredToken{TokenEndpoint: "http://auth.example.com/token", ClientID: "c"},
|
||||
want: oauth.ErrInsecureTokenEndpoint,
|
||||
},
|
||||
{
|
||||
name: "advertised loopback endpoint",
|
||||
stored: StoredToken{TokenEndpoint: "https://127.0.0.1/token", ProtectedTokenEndpoint: true, ClientID: "c"},
|
||||
want: errUnsafeOAuthDiscoveryTarget,
|
||||
},
|
||||
{
|
||||
name: "advertised private endpoint",
|
||||
stored: StoredToken{TokenEndpoint: "https://10.0.0.5/token", ProtectedTokenEndpoint: true, ClientID: "c"},
|
||||
want: errUnsafeOAuthDiscoveryTarget,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, _, err := refreshSettings(context.Background(), http.DefaultClient, "https://mcp.example.com/mcp", OAuthConfig{}, tc.stored)
|
||||
if !errors.Is(err, tc.want) {
|
||||
t.Fatalf("refreshSettings() error = %v, want %v", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreTokenSourceRefreshDoesNotPostToUnsafeStoredEndpoint(t *testing.T) {
|
||||
var hits atomic.Int64
|
||||
loopback := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { hits.Add(1) }))
|
||||
defer loopback.Close()
|
||||
|
||||
// A public MCP resource must not be able to point refresh at loopback.
|
||||
server := Server{Name: "public", URL: "https://mcp.example.com/mcp", Identity: testServerIdentity, Auth: ServerAuthOAuth}
|
||||
store, err := NewTokenStore(TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "oauth-tokens.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTokenStore() error = %v", err)
|
||||
}
|
||||
if err := store.SaveForServer(server, StoredToken{
|
||||
AccessToken: "a", RefreshToken: "r", ClientID: "c",
|
||||
TokenEndpoint: loopback.URL + "/token", ProtectedTokenEndpoint: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveForServer() error = %v", err)
|
||||
}
|
||||
source := &storeTokenSource{server: server, store: store, httpClient: http.DefaultClient, now: time.Now}
|
||||
if _, err := source.Refresh(context.Background()); !errors.Is(err, errUnsafeOAuthDiscoveryTarget) {
|
||||
t.Fatalf("Refresh() error = %v, want the discovery policy refusal", err)
|
||||
}
|
||||
if hits.Load() != 0 {
|
||||
t.Fatalf("loopback endpoint received %d request(s)", hits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoredTokenPersistsDiscoveryFields(t *testing.T) {
|
||||
store, err := NewTokenStore(TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "oauth-tokens.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTokenStore() error = %v", err)
|
||||
}
|
||||
want := StoredToken{
|
||||
AccessToken: "a", RefreshToken: "r",
|
||||
TokenEndpoint: "https://auth.example.com/token", ProtectedTokenEndpoint: true,
|
||||
ClientID: "client", ClientSecret: "secret",
|
||||
}
|
||||
if err := store.Save("demo", want); err != nil {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
got, ok, err := store.Load("demo")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Load() = %v, %v", ok, err)
|
||||
}
|
||||
if got.TokenEndpoint != want.TokenEndpoint || !got.ProtectedTokenEndpoint || got.ClientID != want.ClientID || got.ClientSecret != want.ClientSecret {
|
||||
t.Fatalf("round trip lost discovery fields: %#v", got)
|
||||
}
|
||||
statuses, err := store.Status()
|
||||
if err != nil {
|
||||
t.Fatalf("Status() error = %v", err)
|
||||
}
|
||||
encoded, _ := json.Marshal(statuses)
|
||||
for _, secret := range []string{"secret", "https://auth.example.com/token"} {
|
||||
if strings.Contains(string(encoded), secret) {
|
||||
t.Fatalf("status output leaked %q: %s", secret, encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,15 @@ type StoredToken struct {
|
||||
TokenType string `json:"token_type,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||
// TokenEndpoint, ClientID and ClientSecret hold what Login discovered or
|
||||
// dynamically registered, so Refresh works for the standard `auth: oauth`
|
||||
// setup whose config carries none of them. ClientSecret is sensitive.
|
||||
// ProtectedTokenEndpoint is set when the endpoint came from server-advertised
|
||||
// discovery and must pass the public-network policy again before use.
|
||||
TokenEndpoint string `json:"token_endpoint,omitempty"`
|
||||
ProtectedTokenEndpoint bool `json:"protected_token_endpoint,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
ClientSecret string `json:"client_secret,omitempty"`
|
||||
}
|
||||
|
||||
// TokenStatus is a redaction-safe summary of a stored token. It deliberately
|
||||
@@ -393,6 +402,11 @@ func storedToOAuth(s StoredToken) oauth.Token {
|
||||
TokenType: s.TokenType,
|
||||
Scopes: s.Scopes,
|
||||
ExpiresAt: s.ExpiresAt,
|
||||
|
||||
TokenEndpoint: s.TokenEndpoint,
|
||||
ProtectedTokenEndpoint: s.ProtectedTokenEndpoint,
|
||||
ClientID: s.ClientID,
|
||||
ClientSecret: s.ClientSecret,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -668,3 +670,86 @@ func TestFullFlowUsesProtectedResourceDiscovery(t *testing.T) {
|
||||
t.Fatalf("flow hits = probe:%d resource:%d authorization:%d token:%d", resourceProbeHits.Load(), resourceMetadataHits.Load(), authorizationMetadataHits.Load(), tokenHits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// A CLIENT STALLED MID-HEADER MUST NOT OUTLIVE LOGIN (#1028).
|
||||
//
|
||||
// Login built a bare http.Server and ended it with Shutdown under a one-second
|
||||
// deadline, and Shutdown only closes idle connections. A client that has sent
|
||||
// part of a request header is not idle, so it survived the deadline and kept
|
||||
// its goroutine, and the Serve goroutine, alive after Login returned.
|
||||
//
|
||||
// No sleep is needed to get the stalled connection accepted before Login ends:
|
||||
// it is dialled before the real callback's connection, Serve accepts in order,
|
||||
// and Login cannot finish until the real callback has been served.
|
||||
func TestLoginDoesNotLeaveAStalledCallbackClientOpen(t *testing.T) {
|
||||
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "access-final", "token_type": "Bearer", "expires_in": 3600,
|
||||
})
|
||||
}))
|
||||
defer tokenServer.Close()
|
||||
|
||||
var stalled net.Conn
|
||||
opener := func(authURL string) error {
|
||||
parsed, err := url.Parse(authURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
redirect, err := url.Parse(parsed.Query().Get("redirect_uri"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stalled, err = net.Dial("tcp", redirect.Host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(stalled, "GET /callback HTTP/1.1\r\nHost: %s\r\nX-Stall:", redirect.Host); err != nil {
|
||||
return err
|
||||
}
|
||||
cbURL := redirect.String() + "?code=auth-code&state=" + url.QueryEscape(parsed.Query().Get("state"))
|
||||
go func() {
|
||||
if resp, err := http.Get(cbURL); err == nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := Login(context.Background(), LoginOptions{
|
||||
ServerName: "demo",
|
||||
ServerURL: "https://issuer.invalid",
|
||||
Config: OAuthConfig{
|
||||
ClientID: "client-123",
|
||||
AuthorizationEndpoint: "https://issuer.invalid/authorize",
|
||||
TokenEndpoint: tokenServer.URL,
|
||||
},
|
||||
HTTPClient: http.DefaultClient,
|
||||
OpenBrowser: opener,
|
||||
Timeout: 5 * time.Second,
|
||||
Now: time.Now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Login() error = %v", err)
|
||||
}
|
||||
if stalled == nil {
|
||||
t.Fatal("SETUP INVALID: the stalled client never connected, so this proves nothing")
|
||||
}
|
||||
defer stalled.Close()
|
||||
requireCallbackClientClosed(t, stalled)
|
||||
}
|
||||
|
||||
// requireCallbackClientClosed fails unless the server end of conn is already
|
||||
// closed: a read has to fail with something other than a timeout.
|
||||
func requireCallbackClientClosed(t *testing.T, conn net.Conn) {
|
||||
t.Helper()
|
||||
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
var one [1]byte
|
||||
_, err := conn.Read(one[:])
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||
t.Fatal("the stalled callback client was still connected after Login returned")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("the stalled callback client received data instead of being closed")
|
||||
}
|
||||
}
|
||||
@@ -331,23 +331,56 @@ func (tool registryTool) Run(ctx context.Context, args map[string]any) tools.Res
|
||||
status = tools.StatusError
|
||||
}
|
||||
output := TextContent(result.Content)
|
||||
// Say what was thrown away. Without this an image-only result reads as
|
||||
// "(empty MCP tool result)", the model concludes the call produced nothing
|
||||
// and retries, and the user never learns an image came back (#823). The note
|
||||
// is appended only when something was actually dropped, so a text-only
|
||||
// result is byte-for-byte what it was before.
|
||||
images, disp, limitReason := forwardImages(result.Content)
|
||||
// Image blocks with valid data ride Result.Images, the same channel capture
|
||||
// tools already use. Everything else non-text is still named rather than
|
||||
// silently dropped, because Zero still has nowhere to put audio, embedded
|
||||
// resources, or a block whose payload could not be decoded (#823).
|
||||
//
|
||||
// It says retrying cannot RECOVER the payload rather than that a retry
|
||||
// returns the same thing. Each retry is a fresh call, so the server may well
|
||||
// answer differently; what cannot change is that Zero still has nowhere to
|
||||
// put a non-text block. Claiming the response would be identical would be a
|
||||
// promise this code is in no position to make.
|
||||
if dropped := DroppedContentSummary(result.Content); dropped != "" {
|
||||
note := "[zero] this server also returned " + dropped + ", which Zero cannot forward yet. Retrying cannot recover this payload."
|
||||
if output == "" {
|
||||
note = "[zero] this server returned " + dropped + ", which Zero cannot forward yet. Retrying cannot recover this payload."
|
||||
// Conversion happens once: the drop note is built from the same pass's
|
||||
// per-item disposition, so a valid image is not decoded again just to
|
||||
// decide whether it was forwarded.
|
||||
//
|
||||
// Image-only success has no text block, so a one-line placeholder keeps
|
||||
// the tool_result self-describing. ModelOutput and finalizeToolOutcome
|
||||
// copy Output through verbatim; an empty string would hand the model an
|
||||
// empty body next to the image.
|
||||
//
|
||||
// Notes are appended only when something was actually dropped, so a
|
||||
// text-only result is byte-for-byte what it was before, and a successfully
|
||||
// forwarded image is not described as unforwardable.
|
||||
//
|
||||
// Unforwardable blocks (audio, resource, failed decode) say retrying
|
||||
// cannot RECOVER the payload: Zero still has nowhere to put them.
|
||||
// Budget-skipped images are different — Zero can forward them, and a
|
||||
// retry with fewer images can recover the payload — so they get their
|
||||
// own sentence.
|
||||
if output == "" && len(images) > 0 {
|
||||
output = "[image returned by tool]"
|
||||
}
|
||||
if exceeded := droppedContentNote(result.Content, disp, dispBudgetExceeded); exceeded != "" {
|
||||
output = appendServerNote(output, exceeded,
|
||||
", which exceeded this result's remaining image budget. Retrying with fewer images can recover this payload.")
|
||||
}
|
||||
if uninspected := droppedContentNote(result.Content, disp, dispUninspected); uninspected != "" {
|
||||
verb := ", which were not inspected"
|
||||
if dispCount(disp, dispUninspected) == 1 {
|
||||
verb = ", which was not inspected"
|
||||
}
|
||||
output = strings.TrimSpace(output + "\n\n" + note)
|
||||
var cause string
|
||||
switch limitReason {
|
||||
case imageLimitForwardCount:
|
||||
cause = " because the maximum forwarded image count was reached."
|
||||
case imageLimitInspectionCount:
|
||||
cause = " because the maximum image inspection limit was reached."
|
||||
default:
|
||||
cause = " because the aggregate image budget was reached."
|
||||
}
|
||||
output = appendServerNote(output, uninspected, verb+cause)
|
||||
}
|
||||
if dropped := droppedContentNote(result.Content, disp, dispDropped); dropped != "" {
|
||||
output = appendServerNote(output, dropped,
|
||||
", which Zero cannot forward yet. Retrying cannot recover this payload.")
|
||||
}
|
||||
if output == "" {
|
||||
output = "(empty MCP tool result)"
|
||||
@@ -355,6 +388,7 @@ func (tool registryTool) Run(ctx context.Context, args map[string]any) tools.Res
|
||||
return tools.Result{
|
||||
Status: status,
|
||||
Output: output,
|
||||
Images: images,
|
||||
Meta: tool.meta(),
|
||||
}
|
||||
}
|
||||
@@ -399,3 +433,13 @@ func isPersistentlyApproved(store *PermissionStore, server Server, toolName stri
|
||||
})
|
||||
return err == nil && approved
|
||||
}
|
||||
|
||||
// appendServerNote joins a [zero] note to output, choosing "returned" or
|
||||
// "also returned" based on whether anything precedes it.
|
||||
func appendServerNote(output, subject, tail string) string {
|
||||
verb := "this server also returned "
|
||||
if output == "" {
|
||||
verb = "this server returned "
|
||||
}
|
||||
return strings.TrimSpace(output + "\n\n[zero] " + verb + subject + tail)
|
||||
}
|
||||
@@ -197,6 +197,24 @@ func cachedModelsDevProviders() map[string]map[string]modelsDevModel {
|
||||
return modelsDevCached
|
||||
}
|
||||
|
||||
// startModelsDevRefresh starts a background RefreshModelsDevCache. Tests
|
||||
// replace it to see whether a refresh was started.
|
||||
var startModelsDevRefresh = func() {
|
||||
go func() { _ = RefreshModelsDevCache(context.Background()) }()
|
||||
}
|
||||
|
||||
// StartModelsDevRefresh refreshes the models.dev cache in the background when
|
||||
// the fetch is on. ZERO_DISABLE_MODELS_FETCH is read here, before anything is
|
||||
// started: a refresh goroutine reads the environment when it runs, so one
|
||||
// started by a caller that had turned the fetch off (a test, through t.Setenv)
|
||||
// could run after that setting was restored and fetch into the real cache.
|
||||
func StartModelsDevRefresh() {
|
||||
if strings.TrimSpace(os.Getenv("ZERO_DISABLE_MODELS_FETCH")) != "" {
|
||||
return
|
||||
}
|
||||
startModelsDevRefresh()
|
||||
}
|
||||
|
||||
// RefreshModelsDevCache fetches models.dev/api.json into the on-disk cache
|
||||
// when the cache is missing or older than modelsDevRefreshAfter. It is safe to
|
||||
// call fire-and-forget from startup (use a goroutine); it never affects the
|
||||
|
||||
@@ -256,3 +256,25 @@ func TestDefaultModelEntriesAppliesFreshCache(t *testing.T) {
|
||||
}
|
||||
t.Fatal("claude-sonnet-4.5 not found")
|
||||
}
|
||||
|
||||
// With the fetch off, no refresh is started at all, so none is left running to
|
||||
// read the environment after a test has restored it and fetch into the real
|
||||
// cache.
|
||||
func TestStartModelsDevRefreshStartsNothingWhenTheFetchIsOff(t *testing.T) {
|
||||
started := 0
|
||||
previous := startModelsDevRefresh
|
||||
startModelsDevRefresh = func() { started++ }
|
||||
t.Cleanup(func() { startModelsDevRefresh = previous })
|
||||
|
||||
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "1")
|
||||
StartModelsDevRefresh()
|
||||
if started != 0 {
|
||||
t.Fatalf("started %d refreshes with the fetch off, want none", started)
|
||||
}
|
||||
|
||||
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "")
|
||||
StartModelsDevRefresh()
|
||||
if started != 1 {
|
||||
t.Fatalf("started %d refreshes with the fetch on, want 1", started)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
package modelregistry
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Match explicit multimodal local families, including common Ollama spellings.
|
||||
// Gemma 3 requires a vision-capable size: 270M and 1B are text-only.
|
||||
var localVisionFamily = regexp.MustCompile(`^(gemma-?3[-:](4b|12b|27b)|llama-?4|mistral-small-?3\.1|phi-?4-multimodal)($|[-:_.])`)
|
||||
|
||||
// SupportsVision reports whether the model identified by modelID accepts image
|
||||
// input. A model in the curated catalog is authoritative (its declared
|
||||
@@ -36,6 +43,8 @@ func VisionCapableByName(modelID string) bool {
|
||||
id = id[slash+1:] // drop a "provider/" prefix
|
||||
}
|
||||
switch {
|
||||
case localVisionFamily.MatchString(id):
|
||||
return true
|
||||
case strings.Contains(id, "gemini"):
|
||||
return true // every Gemini model is multimodal
|
||||
case strings.Contains(id, "claude-3"), strings.Contains(id, "claude-4"),
|
||||
|
||||
@@ -10,10 +10,15 @@ func TestVisionCapableByName(t *testing.T) {
|
||||
"claude-sonnet-4.5", "claude-3-haiku",
|
||||
"MiniMax-M3",
|
||||
"llava:13b", "qwen2.5-vl-7b", "llama3.2-vision", "pixtral-12b", "moondream",
|
||||
"google/gemma-3-27b-it", "gemma3:27b", "gemma3:4b", "gemma-3-12b-it",
|
||||
"llama-4-scout", "llama4:17b", "mistral-small-3.1", "mistral-small3.1:24b",
|
||||
"phi-4-multimodal", "phi4-multimodal-instruct",
|
||||
}
|
||||
textOnly := []string{
|
||||
"gpt-oss:120b", "kimi-for-coding", "deepseek-coder", "qwen2.5-coder",
|
||||
"codestral", "llama3.1-8b", "grok-text-only", "mistral-large",
|
||||
"gemma3:1b", "gemma-3-1b-it", "gemma3:270m", "gemma-3-270m-it",
|
||||
"gemma2:27b", "mistral-small3:24b", "phi4:14b",
|
||||
// Negated "vision" names must NOT match the bare-"vision" fallback.
|
||||
"my-custom-vision-less-model", "no-vision-model", "grok-vision-less",
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ type Sink interface {
|
||||
// attached Sinks. Safe for concurrent use.
|
||||
type Notifier struct {
|
||||
w io.Writer
|
||||
cfg Config // immutable after New; reads outside the lock are safe
|
||||
cfg Config // swap at runtime via Configure; reads outside the lock are safe
|
||||
|
||||
mu sync.Mutex
|
||||
focused bool
|
||||
@@ -71,6 +71,16 @@ func New(w io.Writer, cfg Config) *Notifier {
|
||||
return &Notifier{w: w, cfg: cfg}
|
||||
}
|
||||
|
||||
// Configure swaps the mode/focus policy at runtime. Sinks, focus state, and the
|
||||
// writer are preserved, so an in-session preference change (e.g. the TUI's
|
||||
// /notify command) applies from the next Notify call. Safe to call concurrently
|
||||
// with Notify.
|
||||
func (n *Notifier) Configure(cfg Config) {
|
||||
n.mu.Lock()
|
||||
n.cfg = cfg
|
||||
n.mu.Unlock()
|
||||
}
|
||||
|
||||
// AddSink registers an additional destination that receives every eligible
|
||||
// event (subject to the same mode/focus policy as the terminal). Sinks fire
|
||||
// even when the Notifier has no terminal writer, so a headless CI run can still
|
||||
@@ -102,18 +112,21 @@ func (n *Notifier) SetFocused(focused bool) {
|
||||
// Sinks are invoked outside the lock so a slow/blocking sink cannot stall a
|
||||
// concurrent Notify or SetFocused.
|
||||
func (n *Notifier) Notify(event Event, message string) {
|
||||
if n.cfg.Mode == ModeOff || n.cfg.Mode == "" {
|
||||
n.mu.Lock()
|
||||
// cfg is mutable at runtime (Configure), so every read happens under the
|
||||
// lock; shouldEmit/sequence work on the local copy.
|
||||
cfg := n.cfg
|
||||
if cfg.Mode == ModeOff || cfg.Mode == "" {
|
||||
n.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
n.mu.Lock()
|
||||
eligible := shouldEmit(n.cfg, event, n.focused)
|
||||
eligible := shouldEmit(cfg, event, n.focused)
|
||||
var sinks []Sink
|
||||
if eligible && len(n.sinks) > 0 {
|
||||
sinks = append(sinks, n.sinks...)
|
||||
}
|
||||
if eligible && n.w != nil {
|
||||
if seq := sequence(n.cfg.Mode, message); seq != "" {
|
||||
if seq := sequence(cfg.Mode, message); seq != "" {
|
||||
_, _ = io.WriteString(n.w, seq)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,44 @@ func TestSequence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Configure swaps the policy at runtime: after turning a silent notifier on,
|
||||
// the next Notify emits; after turning a noisy one off, it stays silent. Sinks
|
||||
// survive the swap (the TUI relies on this when /notify reconfigures mid-run).
|
||||
func TestConfigureAppliesImmediatelyAndKeepsSinks(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
n := New(&buf, Config{Mode: ModeOff})
|
||||
n.SetFocused(true)
|
||||
|
||||
n.Notify(Completion, "x")
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("off should be silent, got %q", buf.String())
|
||||
}
|
||||
|
||||
sink := &recordingSink{}
|
||||
n.AddSink(sink)
|
||||
n.Configure(Config{Mode: ModeBell, FocusMode: FocusAlways})
|
||||
n.Notify(Completion, "x")
|
||||
if buf.String() != "\x07" {
|
||||
t.Fatalf("after Configure(bell) should bell, got %q", buf.String())
|
||||
}
|
||||
sink.mu.Lock()
|
||||
got := len(sink.events)
|
||||
sink.mu.Unlock()
|
||||
if got != 1 {
|
||||
t.Fatalf("sink should still receive events after Configure, got %d", got)
|
||||
}
|
||||
|
||||
n.Configure(Config{Mode: ModeOff})
|
||||
buf.Reset()
|
||||
n.Notify(Completion, "x")
|
||||
sink.mu.Lock()
|
||||
got = len(sink.events)
|
||||
sink.mu.Unlock()
|
||||
if buf.Len() != 0 || got != 1 {
|
||||
t.Fatalf("after Configure(off) should be fully silent, buf=%q events=%d", buf.String(), got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeMessage(t *testing.T) {
|
||||
if got := sanitizeMessage("ok\x1b]0;evil\x07more\nx"); got != "ok]0;evilmorex" {
|
||||
t.Fatalf("sanitize=%q", got)
|
||||
@@ -109,6 +147,21 @@ func TestNotifyRaceSafe(t *testing.T) {
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// Configure mutates cfg under the lock while Notify reads it; this pair must
|
||||
// be race-clean (run under -race). Regression for the unsynchronized cfg read
|
||||
// Notify used to perform before acquiring the lock.
|
||||
func TestConfigureConcurrentWithNotify(t *testing.T) {
|
||||
n := New(&bytes.Buffer{}, Config{Mode: ModeBell, FocusMode: FocusAlways})
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(3)
|
||||
go func() { defer wg.Done(); n.Configure(Config{Mode: ModeBoth, FocusMode: FocusAlways}) }()
|
||||
go func() { defer wg.Done(); n.Configure(Config{Mode: ModeOff}) }()
|
||||
go func() { defer wg.Done(); n.Notify(AwaitingInput, "x") }()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestDefaultMessage(t *testing.T) {
|
||||
if DefaultMessage(Completion) != "Zero: ready" {
|
||||
t.Fatal("completion message")
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// callbackReadHeaderTimeout bounds how long an accepted connection may take to
|
||||
// send its request header. A single-use loopback callback server has no reason
|
||||
// to wait longer, and without a bound a client that stalls mid-header holds the
|
||||
// connection, and the server goroutine, open indefinitely. A var so the
|
||||
// package tests can shorten it.
|
||||
var callbackReadHeaderTimeout = 5 * time.Second
|
||||
|
||||
// callbackShutdownBudget is how long Close waits for in-flight requests before
|
||||
// it force-closes whatever is still open.
|
||||
const callbackShutdownBudget = time.Second
|
||||
|
||||
// CallbackServer is the lifecycle every loopback OAuth callback server needs,
|
||||
// in one place.
|
||||
//
|
||||
// It exists because the same server was written out three times (the shared
|
||||
// LoopbackListener, MCP's Login and OpenRouter's login) and a fix to one of
|
||||
// them did not reach the others. http.Server.Shutdown only closes idle
|
||||
// connections, so a client that has sent part of a request header is active,
|
||||
// survives the shutdown deadline, and keeps its goroutine and the Serve
|
||||
// goroutine alive past the end of the flow (#1028). #1051 closed that for
|
||||
// LoopbackListener; the other two kept the original construction.
|
||||
type CallbackServer struct {
|
||||
server *http.Server
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// StartCallbackServer serves handler on listener until Close.
|
||||
func StartCallbackServer(listener net.Listener, handler http.Handler) *CallbackServer {
|
||||
return startCallbackServer(listener, handler, nil)
|
||||
}
|
||||
|
||||
// startCallbackServer is StartCallbackServer with a ConnState hook, which the
|
||||
// package's own tests use to see a connection reach a given state.
|
||||
func startCallbackServer(listener net.Listener, handler http.Handler, connState func(net.Conn, http.ConnState)) *CallbackServer {
|
||||
s := &CallbackServer{
|
||||
server: &http.Server{
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: callbackReadHeaderTimeout,
|
||||
ConnState: connState,
|
||||
},
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go func() {
|
||||
_ = s.server.Serve(listener)
|
||||
close(s.done)
|
||||
}()
|
||||
return s
|
||||
}
|
||||
|
||||
// Close shuts the server down within callbackShutdownBudget. A connection still
|
||||
// open when the budget runs out (a stalled request header, say) is force-closed
|
||||
// rather than leaked, and Close returns only after the Serve goroutine has
|
||||
// finished, so nothing this server started outlives the call. Idempotent.
|
||||
func (s *CallbackServer) Close() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), callbackShutdownBudget)
|
||||
defer cancel()
|
||||
if err := s.server.Shutdown(ctx); err != nil {
|
||||
_ = s.server.Close()
|
||||
}
|
||||
<-s.done
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// startStalledCallbackClient starts a CallbackServer and connects a client that
|
||||
// sends part of a request header and then stops, which is the shape #1028 is
|
||||
// about. It returns once the server has ACCEPTED the connection, using the
|
||||
// ConnState hook rather than a sleep. StateNew, not StateActive: Go reports
|
||||
// StateActive only after a request read returns, and a stalled header read
|
||||
// returns only when the header timeout fires, so waiting for StateActive would
|
||||
// make the setup depend on the very bound the test is about.
|
||||
func startStalledCallbackClient(t *testing.T) (*CallbackServer, net.Conn) {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accepted := make(chan struct{}, 1)
|
||||
server := startCallbackServer(listener, http.NotFoundHandler(), func(_ net.Conn, state http.ConnState) {
|
||||
if state == http.StateNew {
|
||||
select {
|
||||
case accepted <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
})
|
||||
conn, err := net.Dial("tcp", listener.Addr().String())
|
||||
if err != nil {
|
||||
server.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := fmt.Fprintf(conn, "GET /callback HTTP/1.1\r\nHost: %s\r\nX-Stall:", listener.Addr()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-accepted:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("SETUP INVALID: the server never accepted the stalled client")
|
||||
}
|
||||
return server, conn
|
||||
}
|
||||
|
||||
// connClosedWithin reports whether the server end of conn closes before the
|
||||
// deadline: a read must fail with something other than a timeout.
|
||||
func connClosedWithin(conn net.Conn, deadline time.Duration) bool {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(deadline))
|
||||
var one [1]byte
|
||||
_, err := conn.Read(one[:])
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||
return false
|
||||
}
|
||||
return err != nil
|
||||
}
|
||||
|
||||
// THE HEADER READ IS BOUNDED EVEN BEFORE CLOSE. The force-close in Close ends a
|
||||
// stalled client when the flow ends; the header timeout ends it while the flow
|
||||
// is still waiting for its callback, which can be minutes. Close is not called
|
||||
// until after the assertion, so this can only pass on ReadHeaderTimeout.
|
||||
func TestCallbackServerBoundsAStalledHeaderWithoutClose(t *testing.T) {
|
||||
previous := callbackReadHeaderTimeout
|
||||
callbackReadHeaderTimeout = 200 * time.Millisecond
|
||||
defer func() { callbackReadHeaderTimeout = previous }()
|
||||
|
||||
server, conn := startStalledCallbackClient(t)
|
||||
defer server.Close()
|
||||
defer conn.Close()
|
||||
|
||||
if !connClosedWithin(conn, 3*time.Second) {
|
||||
t.Fatal("a client stalled mid-header was still connected with Close never called; the header read is unbounded")
|
||||
}
|
||||
}
|
||||
|
||||
// CLOSE RETURNS ONLY AFTER SERVE HAS. Its doc comment promises that nothing the
|
||||
// server started outlives the call. With no connections open, Shutdown closes
|
||||
// the listener and returns at once, so this is the case where Close could beat
|
||||
// the Serve goroutine back; the goroutine closing done is the last thing it
|
||||
// does, so done has to be closed by the time Close returns.
|
||||
func TestCallbackServerCloseWaitsForServe(t *testing.T) {
|
||||
for attempt := 0; attempt < 50; attempt++ {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := StartCallbackServer(listener, http.NotFoundHandler())
|
||||
server.Close()
|
||||
select {
|
||||
case <-server.done:
|
||||
default:
|
||||
t.Fatalf("attempt %d: Close returned while the Serve goroutine was still running", attempt)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoopbackListener is a single-use loopback HTTP server that captures an OAuth
|
||||
@@ -19,8 +18,7 @@ type LoopbackListener struct {
|
||||
listener net.Listener
|
||||
state string
|
||||
result chan callbackResult
|
||||
server *http.Server
|
||||
done chan struct{}
|
||||
server *CallbackServer
|
||||
}
|
||||
|
||||
type callbackResult struct {
|
||||
@@ -58,20 +56,8 @@ func newLoopbackListener(state string, port int, connState func(net.Conn, http.C
|
||||
listener: ln,
|
||||
state: state,
|
||||
result: make(chan callbackResult, 1),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
l.server = &http.Server{
|
||||
Handler: http.HandlerFunc(l.handle),
|
||||
// This is a single-use, loopback-only callback server: a client that
|
||||
// never finishes sending its request header must not be able to hold
|
||||
// the accepted connection (and Close, below) open indefinitely.
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ConnState: connState,
|
||||
}
|
||||
go func() {
|
||||
_ = l.server.Serve(ln)
|
||||
close(l.done)
|
||||
}()
|
||||
l.server = startCallbackServer(ln, http.HandlerFunc(l.handle), connState)
|
||||
return l, nil
|
||||
}
|
||||
|
||||
@@ -122,14 +108,10 @@ func (l *LoopbackListener) Wait(ctx context.Context) (string, error) {
|
||||
// Close shuts the listener down (bounded), idempotent. If a client leaves a
|
||||
// connection open past the shutdown deadline (e.g. a stalled request header),
|
||||
// Close forcibly closes it rather than leaking it past the end of the flow,
|
||||
// and waits for the Serve goroutine to finish before returning.
|
||||
// and waits for the Serve goroutine to finish before returning. See
|
||||
// CallbackServer.Close.
|
||||
func (l *LoopbackListener) Close() {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if err := l.server.Shutdown(shutdownCtx); err != nil {
|
||||
_ = l.server.Close()
|
||||
}
|
||||
<-l.done
|
||||
l.server.Close()
|
||||
}
|
||||
|
||||
// parseCallback validates the redirect query and returns the authorization code,
|
||||
|
||||