Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78dfccae1c | ||
|
|
f63dd5eb80 |
No files matched your search
@@ -4,12 +4,6 @@
|
||||
|
||||
<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.9+" src="https://img.shields.io/badge/Go-1.26.9+-00ADD8?logo=go&logoColor=white">
|
||||
@@ -29,70 +23,6 @@ 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,
|
||||
@@ -203,7 +133,6 @@ 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=...
|
||||
```
|
||||
@@ -226,12 +155,6 @@ 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:
|
||||
|
||||
@@ -390,7 +313,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, rewind, and prune sessions
|
||||
zero sessions inspect, resume, fork, and rewind sessions
|
||||
zero spec manage spec-mode drafts
|
||||
zero specialist manage specialist subagents
|
||||
zero skills manage markdown instruction skills
|
||||
|
||||
@@ -4,12 +4,6 @@
|
||||
|
||||
<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.9+" src="https://img.shields.io/badge/Go-1.26.9+-00ADD8?logo=go&logoColor=white">
|
||||
@@ -27,70 +21,6 @@ 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 兼容端点。
|
||||
@@ -176,7 +106,6 @@ 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),运行:
|
||||
@@ -185,12 +114,6 @@ export GMI_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`。
|
||||
@@ -295,7 +218,7 @@ zero context 上下文预算报告
|
||||
zero repo-map 确定性仓库映射
|
||||
zero repo-info 本地仓库摘要
|
||||
zero search | find 搜索本地会话历史
|
||||
zero sessions 检查、恢复、分叉、回滚和清理会话
|
||||
zero sessions 检查、恢复、分叉和回滚会话
|
||||
zero spec 管理规范模式草稿
|
||||
zero specialist 管理专业子智能体
|
||||
zero skills 管理 Markdown 指令技能
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# 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,7 +6,6 @@ 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)
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 4.7 KiB |
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 4.7 KiB |
@@ -1,6 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
@@ -1,6 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
@@ -1,7 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
@@ -1,7 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
@@ -1,11 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1,11 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -310,12 +310,6 @@ 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())
|
||||
@@ -954,20 +948,9 @@ 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 {
|
||||
|
||||
@@ -2542,71 +2542,6 @@ func payloadString(payload any, key string) 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) {
|
||||
|
||||
@@ -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: whitespace, control,
|
||||
// UTF-8 in the host. Valid UTF-8 alone is not presentation-safe: 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.IsSpace(r) || unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) {
|
||||
if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,9 +224,6 @@ 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",
|
||||
@@ -246,9 +243,6 @@ 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}}),
|
||||
|
||||
@@ -968,9 +968,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
// 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,
|
||||
@@ -993,7 +991,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
if provider == nil {
|
||||
return nil
|
||||
}
|
||||
if optimized, ok := providers.ConfiguredTurnSessions(profile, provider, providers.Options{ModelRPM: modelRPM}); ok {
|
||||
if optimized, ok := providers.OptimizedTurnSessions(profile, provider, providers.Options{}); ok {
|
||||
return optimized
|
||||
}
|
||||
return providers.DefaultTurnSessions(profile, provider, providers.Options{})
|
||||
|
||||
@@ -343,10 +343,8 @@ 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},
|
||||
ModelRPM: map[string]int{"gpt-test": 1},
|
||||
}, fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "")
|
||||
return config.ResolvedConfig{Providers: []config.ProviderProfile{usable}},
|
||||
fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "")
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
providerProfile = profile
|
||||
@@ -376,10 +374,6 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,12 +10,12 @@ import (
|
||||
|
||||
// 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
|
||||
// config.SetNotify 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 file (config.UserNotify / config.SetNotify) 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
|
||||
|
||||
@@ -463,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 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)
|
||||
// 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{})
|
||||
// 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 preserves the starting transport and shares the RPM limiter.
|
||||
// switcher is nil unless this run STARTED optimized, which is what keeps a
|
||||
// default-adapter run on the default adapter.
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
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,7 +20,6 @@ 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"},
|
||||
}
|
||||
|
||||
@@ -18,8 +18,6 @@ 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 {
|
||||
@@ -74,11 +72,6 @@ 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))
|
||||
}
|
||||
@@ -98,8 +91,6 @@ 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 {
|
||||
@@ -177,21 +168,6 @@ 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 {
|
||||
@@ -249,7 +225,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", "prune":
|
||||
case "list", "children", "lineage", "tree", "rewind-plan", "rewind", "compact-plan":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -268,9 +244,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -559,7 +532,6 @@ 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
|
||||
@@ -569,13 +541,7 @@ 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
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
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,9 +104,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -166,7 +163,6 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
// 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,
|
||||
@@ -184,7 +180,6 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
}
|
||||
|
||||
return ResolvedConfig{
|
||||
ModelRPM: cfg.ModelRPM,
|
||||
ActiveProvider: active.Name,
|
||||
Providers: providers,
|
||||
Provider: active,
|
||||
@@ -253,7 +248,6 @@ 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
|
||||
}
|
||||
@@ -315,7 +309,6 @@ 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,34 +1109,6 @@ 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 {
|
||||
@@ -1194,13 +1166,10 @@ 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, `{
|
||||
"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"}
|
||||
]
|
||||
}`)
|
||||
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"}
|
||||
]}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
|
||||
if !errors.Is(err, ErrNoActiveProvider) {
|
||||
@@ -1209,9 +1178,6 @@ 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) {
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
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,19 +177,6 @@ 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.
|
||||
@@ -364,7 +351,6 @@ 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"`
|
||||
@@ -378,7 +364,6 @@ 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.
|
||||
@@ -390,7 +375,6 @@ 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"`
|
||||
@@ -404,10 +388,8 @@ 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,
|
||||
@@ -426,9 +408,6 @@ 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
|
||||
@@ -486,7 +465,6 @@ type Overrides struct {
|
||||
}
|
||||
|
||||
type ResolvedConfig struct {
|
||||
ModelRPM map[string]int
|
||||
ActiveProvider string
|
||||
Providers []ProviderProfile
|
||||
Provider ProviderProfile
|
||||
@@ -555,7 +533,6 @@ 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"`
|
||||
@@ -569,7 +546,6 @@ 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"`
|
||||
}
|
||||
@@ -591,11 +567,6 @@ 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
|
||||
@@ -616,10 +587,6 @@ 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{}
|
||||
|
||||
@@ -879,6 +879,24 @@ func UpdateNotify(path string, merge func(current NotifyConfig) NotifyConfig) (r
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// SetNotify replaces the stored notification preference with value. It is a
|
||||
// thin wrapper over the transactional UpdateNotify so existing callers keep
|
||||
// their replace semantics under the same lock and byte-preserving write.
|
||||
func SetNotify(path string, value NotifyConfig) (FileConfig, error) {
|
||||
next, err := UpdateNotify(path, func(NotifyConfig) NotifyConfig { return value })
|
||||
if err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
// Re-read the full config so callers that inspect unrelated fields (and
|
||||
// existing tests) keep their FileConfig-shaped result.
|
||||
cfg, err := loadConfigFile(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
cfg.Notify = next
|
||||
return cfg, 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) {
|
||||
|
||||
@@ -428,23 +428,6 @@ 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{
|
||||
@@ -454,9 +437,9 @@ func TestSetNotifyPersistsValidValues(t *testing.T) {
|
||||
},
|
||||
}, 0o600)
|
||||
|
||||
cfg, err := setNotify(path, NotifyConfig{Mode: " both ", FocusMode: " unfocused "})
|
||||
cfg, err := SetNotify(path, NotifyConfig{Mode: " both ", FocusMode: " unfocused "})
|
||||
if err != nil {
|
||||
t.Fatalf("setNotify() error = %v", err)
|
||||
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)
|
||||
@@ -466,14 +449,14 @@ func TestSetNotifyPersistsValidValues(t *testing.T) {
|
||||
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)
|
||||
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 {
|
||||
if _, err := SetNotify(path, NotifyConfig{Mode: "loud", FocusMode: "unfocused"}); err == nil {
|
||||
t.Fatal("expected error for invalid notify.mode")
|
||||
}
|
||||
}
|
||||
@@ -481,25 +464,25 @@ func TestSetNotifyRejectsInvalidMode(t *testing.T) {
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
// 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)
|
||||
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 != "" {
|
||||
@@ -511,7 +494,7 @@ func TestSetNotifyBlankValuesPreservedAsDefaults(t *testing.T) {
|
||||
// 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
|
||||
// SetNotify/UpdateNotify edit 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) {
|
||||
@@ -534,8 +517,8 @@ func TestSetNotifyPreservesExplicitUnrelatedValues(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := setNotify(path, NotifyConfig{Mode: "off", FocusMode: "always"}); err != nil {
|
||||
t.Fatalf("setNotify: %v", err)
|
||||
if _, err := SetNotify(path, NotifyConfig{Mode: "off", FocusMode: "always"}); err != nil {
|
||||
t.Fatalf("SetNotify: %v", err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
@@ -578,8 +561,8 @@ func TestSetNotifyResetRemovesEveryDuplicateNotifyMember(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := setNotify(path, NotifyConfig{}); err != nil {
|
||||
t.Fatalf("setNotify({}) reset: %v", err)
|
||||
if _, err := SetNotify(path, NotifyConfig{}); err != nil {
|
||||
t.Fatalf("SetNotify({}) reset: %v", err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
@@ -603,8 +586,8 @@ func TestSetNotifyResetRemovesEveryDuplicateNotifyMember(t *testing.T) {
|
||||
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)
|
||||
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 {
|
||||
@@ -1411,7 +1394,7 @@ func TestUpdateNotifyRacesExistingConfigMutatorWithoutLoss(t *testing.T) {
|
||||
// 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.
|
||||
// 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")
|
||||
@@ -1420,8 +1403,8 @@ func TestSetNotifyReplacementInheritsNoFieldFromEarlierDuplicate(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := setNotify(path, NotifyConfig{FocusMode: "always"}); err != nil {
|
||||
t.Fatalf("setNotify: %v", err)
|
||||
if _, err := SetNotify(path, NotifyConfig{FocusMode: "always"}); err != nil {
|
||||
t.Fatalf("SetNotify: %v", err)
|
||||
}
|
||||
stored, err := UserNotify(path)
|
||||
if err != nil {
|
||||
@@ -1454,8 +1437,8 @@ func TestSetNotifySingleMemberReplacedInPlace(t *testing.T) {
|
||||
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)
|
||||
if _, err := SetNotify(path, NotifyConfig{Mode: "off", FocusMode: "always"}); err != nil {
|
||||
t.Fatalf("SetNotify: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
|
||||
@@ -24,7 +24,6 @@ const (
|
||||
Connectivity
|
||||
ModelNotFound
|
||||
ContextOverflow
|
||||
LocalRPM
|
||||
)
|
||||
|
||||
// providerMarkers are the prefixes the provider layer attaches to every
|
||||
@@ -60,8 +59,6 @@ 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
|
||||
@@ -92,8 +89,6 @@ 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:
|
||||
@@ -114,8 +109,6 @@ 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,7 +17,6 @@ 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,15 +24,11 @@ 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 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
|
||||
MaxProcesses int
|
||||
}
|
||||
|
||||
// ProcessManager owns retained interactive-process identity, transport,
|
||||
@@ -41,7 +37,6 @@ type ProcessManager struct {
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
processes map[int]*managedProcess
|
||||
starting int
|
||||
completedRetention time.Duration
|
||||
maxProcesses int
|
||||
startTransport processTransportStarter
|
||||
@@ -125,21 +120,12 @@ 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()
|
||||
}
|
||||
@@ -345,13 +331,9 @@ 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()
|
||||
if process, ok := manager.processes[id]; ok && process.doneClosed() {
|
||||
delete(manager.processes, id)
|
||||
}
|
||||
delete(manager.processes, id)
|
||||
manager.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -376,25 +358,22 @@ 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()
|
||||
manager.starting--
|
||||
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.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 {
|
||||
@@ -408,6 +387,9 @@ func (manager *ProcessManager) processToPruneLocked() *managedProcess {
|
||||
return process
|
||||
}
|
||||
}
|
||||
if len(processes) > 8 {
|
||||
return processes[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package execution
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -24,145 +23,6 @@ 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")
|
||||
|
||||
@@ -143,10 +143,6 @@ var descriptors = []Descriptor{
|
||||
openAICompat("dashscope", "DashScope", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "qwen-plus", []string{"DASHSCOPE_API_KEY", "QWEN_API_KEY"}, "qwen"),
|
||||
openAICompat("moonshot", "Moonshot AI", "https://api.moonshot.ai/v1", "kimi-k2-0905-preview", []string{"MOONSHOT_API_KEY"}, "kimi"),
|
||||
openAICompat("atlascloud", "Atlas Cloud", "https://api.atlascloud.ai/v1", "qwen/qwen3.5-flash", []string{"ATLASCLOUD_API_KEY"}, "atlas cloud", "atlas"),
|
||||
// GMI Cloud — OpenAI-compatible inference. The id matches the models.dev
|
||||
// provider id so the remote catalog resolves without a mapping; GMI's own
|
||||
// docs name the key GMI_API_KEY, models.dev names it GMICLOUD_API_KEY.
|
||||
openAICompat("gmicloud", "GMI Cloud", "https://api.gmi-serving.com/v1", "Qwen/Qwen3.8-Max", []string{"GMI_API_KEY", "GMICLOUD_API_KEY"}, "gmi cloud", "gmi"),
|
||||
openAICompat("longcat", "LongCat", "https://api.longcat.chat/openai", "LongCat-2.0", []string{"LONGCAT_API_KEY"}, "meituan longcat", "meituan", "longcat-2.0"),
|
||||
openAICompat("nvidia-nim", "NVIDIA NIM", "https://integrate.api.nvidia.com/v1", "nvidia/llama-3.1-nemotron-70b-instruct", []string{"NVIDIA_API_KEY"}, "nvidia nim"),
|
||||
anthropicCompat("minimax", "MiniMax", "https://api.minimax.io/anthropic", "MiniMax-M3", []string{"MINIMAX_API_KEY"}, "mini-max", "mini_max"),
|
||||
|
||||
@@ -27,7 +27,6 @@ var expectedCatalogIDs = []string{
|
||||
"dashscope",
|
||||
"moonshot",
|
||||
"atlascloud",
|
||||
"gmicloud",
|
||||
"longcat",
|
||||
"nvidia-nim",
|
||||
"minimax",
|
||||
@@ -202,31 +201,6 @@ func TestAtlasCloudDescriptor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGMICloudDescriptor(t *testing.T) {
|
||||
descriptor, err := Require("gmicloud")
|
||||
if err != nil {
|
||||
t.Fatalf("Require(gmicloud) error = %v", err)
|
||||
}
|
||||
if descriptor.Name != "GMI Cloud" {
|
||||
t.Fatalf("Name = %q, want GMI Cloud", descriptor.Name)
|
||||
}
|
||||
if descriptor.DefaultBaseURL != "https://api.gmi-serving.com/v1" {
|
||||
t.Fatalf("DefaultBaseURL = %q, want GMI Cloud OpenAI-compatible endpoint", descriptor.DefaultBaseURL)
|
||||
}
|
||||
if descriptor.DefaultModel != "Qwen/Qwen3.8-Max" {
|
||||
t.Fatalf("DefaultModel = %q, want Qwen/Qwen3.8-Max", descriptor.DefaultModel)
|
||||
}
|
||||
if descriptor.Transport != TransportOpenAICompatible {
|
||||
t.Fatalf("Transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
|
||||
}
|
||||
if !descriptor.RequiresAuth {
|
||||
t.Fatal("RequiresAuth = false, want true")
|
||||
}
|
||||
if !reflect.DeepEqual(descriptor.AuthEnvVars, []string{"GMI_API_KEY", "GMICLOUD_API_KEY"}) {
|
||||
t.Fatalf("AuthEnvVars = %#v, want GMI_API_KEY then GMICLOUD_API_KEY", descriptor.AuthEnvVars)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogDescriptorsExposeRequiredDefaults(t *testing.T) {
|
||||
for _, descriptor := range All() {
|
||||
if descriptor.ID == "" {
|
||||
@@ -347,9 +321,6 @@ func TestLookupNormalizesIDsAndAliases(t *testing.T) {
|
||||
"mini_max": "minimax",
|
||||
"Moonshot": "moonshot",
|
||||
"Atlas Cloud": "atlascloud",
|
||||
"GMI Cloud": "gmicloud",
|
||||
"gmi-cloud": "gmicloud",
|
||||
"gmi": "gmicloud",
|
||||
"nvidia nim": "nvidia-nim",
|
||||
"xiaomi mimo": "xiaomi-mimo",
|
||||
"custom_openai_compatible": "custom-openai-compatible",
|
||||
@@ -400,7 +371,7 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
|
||||
TransportBedrock: {"bedrock"},
|
||||
TransportVertex: {"vertex"},
|
||||
TransportAnthropicCompat: {"minimax", "minimaxi-cn", "opencode-go-anthropic-compatible", "custom-anthropic-compatible"},
|
||||
TransportOpenAICompat: {"gitlawb-opengateway", "aimlapi", "ollama-cloud", "ollama", "lmstudio", "atomic-chat-local", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "fireworks", "dashscope", "moonshot", "atlascloud", "gmicloud", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
|
||||
TransportOpenAICompat: {"gitlawb-opengateway", "aimlapi", "ollama-cloud", "ollama", "lmstudio", "atomic-chat-local", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "fireworks", "dashscope", "moonshot", "atlascloud", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
|
||||
}
|
||||
|
||||
for transport, wantIDs := range cases {
|
||||
|
||||
@@ -149,12 +149,6 @@ var curatedModels = map[string][]Model{
|
||||
{ID: "qwen/qwen3.5-flash", Description: "catalog default"},
|
||||
{ID: "deepseek-ai/deepseek-v4-pro", Description: "reasoning model"},
|
||||
},
|
||||
"gmicloud": {
|
||||
{ID: "Qwen/Qwen3.8-Max", Description: "catalog default"},
|
||||
{ID: "Qwen/Qwen3.8-Flash", Description: "fast model"},
|
||||
{ID: "deepseek-ai/DeepSeek-V4-Pro", Description: "reasoning model"},
|
||||
{ID: "deepseek-ai/DeepSeek-V4-Flash", Description: "fast coding model"},
|
||||
},
|
||||
"nvidia-nim": {
|
||||
{ID: "nvidia/llama-3.1-nemotron-70b-instruct", Description: "catalog default"},
|
||||
{ID: "meta/llama-3.1-70b-instruct", Description: "general model"},
|
||||
|
||||
@@ -48,11 +48,6 @@ func TestModelsAreProviderScoped(t *testing.T) {
|
||||
want: []string{"qwen/qwen3.5-flash", "deepseek-ai/deepseek-v4-pro"},
|
||||
notWant: []string{"gpt-4.1", "claude-sonnet-4.5", "openai/gpt-4.1"},
|
||||
},
|
||||
{
|
||||
provider: "gmicloud",
|
||||
want: []string{"Qwen/Qwen3.8-Max", "Qwen/Qwen3.8-Flash", "deepseek-ai/DeepSeek-V4-Pro", "deepseek-ai/DeepSeek-V4-Flash"},
|
||||
notWant: []string{"gpt-4.1", "claude-sonnet-4.5", "deepseek-ai/deepseek-v4-pro"},
|
||||
},
|
||||
{
|
||||
provider: "mistral",
|
||||
want: []string{"mistral-large-latest", "codestral-latest"},
|
||||
|
||||
@@ -22,15 +22,14 @@ import (
|
||||
// An error is reported to the loop, which records a note and continues on the
|
||||
// current model.
|
||||
//
|
||||
// The session switcher is installed for an optimized start or configured RPM
|
||||
// limits. Default-adapter starts retain that transport after escalation.
|
||||
// The same limiter wraps switched sessions so switching cannot reset a cap.
|
||||
// The session switcher comes back nil unless the run STARTED optimized. A run
|
||||
// that began on the default adapter stays on it, so escalation cannot quietly
|
||||
// change the transport underneath a session.
|
||||
func EscalationSwitchers(
|
||||
profile config.ProviderProfile,
|
||||
provider zeroruntime.Provider,
|
||||
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error),
|
||||
onSwitch func(modelID string),
|
||||
sessionOptions ...Options,
|
||||
) (
|
||||
func(context.Context, string) (zeroruntime.Provider, error),
|
||||
func(context.Context, string) (zeroruntime.TurnSessionProvider, error),
|
||||
@@ -38,10 +37,6 @@ func EscalationSwitchers(
|
||||
if newProvider == nil {
|
||||
return nil, nil
|
||||
}
|
||||
options := Options{}
|
||||
if len(sessionOptions) > 0 {
|
||||
options = sessionOptions[0]
|
||||
}
|
||||
// The escalated profile is the run's profile with the model replaced, so the
|
||||
// credential, base URL and headers travel with it. Callers pass a newProvider
|
||||
// that already applies the stored key, which is why there is no per-site key
|
||||
@@ -67,8 +62,8 @@ func EscalationSwitchers(
|
||||
return switchedProvider, nil
|
||||
}
|
||||
|
||||
turnSessions, _ := OptimizedTurnSessions(profile, provider, options)
|
||||
if turnSessions == nil && options.ModelRPM == nil {
|
||||
turnSessions, _ := OptimizedTurnSessions(profile, provider, Options{})
|
||||
if turnSessions == nil {
|
||||
return modelSwitcher, nil
|
||||
}
|
||||
|
||||
@@ -83,14 +78,12 @@ func EscalationSwitchers(
|
||||
if onSwitch != nil {
|
||||
onSwitch(modelID)
|
||||
}
|
||||
if turnSessions != nil {
|
||||
if optimized, ok := OptimizedTurnSessions(switchedProfile, switchedProvider, options); ok {
|
||||
return limitTurnSessions(switchedProfile, optimized, options), nil
|
||||
}
|
||||
if optimized, ok := OptimizedTurnSessions(switchedProfile, switchedProvider, Options{}); ok {
|
||||
return optimized, nil
|
||||
}
|
||||
// Ineligible target: the default adapter, but carrying the switched
|
||||
// model's own capability projection rather than the original's.
|
||||
return limitTurnSessions(switchedProfile, DefaultTurnSessions(switchedProfile, switchedProvider, options), options), nil
|
||||
return DefaultTurnSessions(switchedProfile, switchedProvider, Options{}), nil
|
||||
}
|
||||
return modelSwitcher, sessionSwitcher
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
|
||||
// Options configures provider construction.
|
||||
type Options struct {
|
||||
ModelRPM *zeroruntime.ModelRPMLimiter
|
||||
UserAgent string
|
||||
HTTPClient *http.Client
|
||||
ModelRegistry *modelregistry.Registry
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type rpmRoundTripper struct{ calls atomic.Int64 }
|
||||
|
||||
func (r *rpmRoundTripper) RoundTrip(q *http.Request) (*http.Response, error) {
|
||||
r.calls.Add(1)
|
||||
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"text/event-stream"}}, Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), Request: q}, nil
|
||||
}
|
||||
func TestConfiguredRPMBlocksNPlusOneBeforeHTTP(t *testing.T) {
|
||||
for _, opt := range []string{"0", "1"} {
|
||||
t.Run("optimized="+opt, func(t *testing.T) {
|
||||
t.Setenv(openaiTurnSessionEnv, opt)
|
||||
r := &rpmRoundTripper{}
|
||||
o := Options{HTTPClient: &http.Client{Transport: r}, ModelRPM: zeroruntime.NewModelRPMLimiter(map[string]int{"gpt-4.1": 2})}
|
||||
profile := config.ProviderProfile{Name: "test", ProviderKind: config.ProviderKindOpenAI, Model: "openai:gpt-4.1", APIKey: "fixture-only"}
|
||||
p, e := New(profile, o)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if i == 2 {
|
||||
profile.Model = "gpt-4.1"
|
||||
}
|
||||
ss, ok := ConfiguredTurnSessions(profile, p, o)
|
||||
if !ok {
|
||||
t.Fatal("missing wrapper")
|
||||
}
|
||||
s, e := ss.OpenTurnSession(context.Background())
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
ch, e := s.Stream(context.Background(), zeroruntime.CompletionRequest{Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "fixture"}}})
|
||||
if i == 2 {
|
||||
var hit *zeroruntime.RPMLimitError
|
||||
if !errors.As(e, &hit) || ch != nil {
|
||||
t.Fatalf("N+1 was not denied: %v", e)
|
||||
}
|
||||
} else {
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
for range ch {
|
||||
}
|
||||
}
|
||||
if e := s.Close(); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
if r.calls.Load() != 2 {
|
||||
t.Fatalf("transport=%d want 2", r.calls.Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestEscalationSharesRPMWhenStartingUnoptimized(t *testing.T) {
|
||||
t.Setenv(openaiTurnSessionEnv, "0")
|
||||
o := Options{ModelRPM: zeroruntime.NewModelRPMLimiter(map[string]int{"gpt-4.1": 1})}
|
||||
profile := config.ProviderProfile{Name: "test", ProviderKind: config.ProviderKindOpenAI, Model: "gpt-4.1"}
|
||||
p := escalationStubProvider{}
|
||||
ss, _ := ConfiguredTurnSessions(profile, p, o)
|
||||
s, e := ss.OpenTurnSession(context.Background())
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, e = s.Stream(context.Background(), zeroruntime.CompletionRequest{}); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
_, switcher := EscalationSwitchers(profile, p, func(config.ProviderProfile) (zeroruntime.Provider, error) { return p, nil }, nil, o)
|
||||
if switcher == nil {
|
||||
t.Fatal("missing shared session switcher")
|
||||
}
|
||||
ss, e = switcher(context.Background(), "openai:gpt-4.1")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
s, e = ss.OpenTurnSession(context.Background())
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
_, e = s.Stream(context.Background(), zeroruntime.CompletionRequest{})
|
||||
var hit *zeroruntime.RPMLimitError
|
||||
if !errors.As(e, &hit) {
|
||||
t.Fatalf("switch reset cap: %v", e)
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
@@ -90,22 +89,6 @@ func ContentStallTimeout(idleTimeout time.Duration) time.Duration {
|
||||
// entirely (streams may then hang until the HTTP/transport layer gives up).
|
||||
const streamIdleTimeoutEnv = "ZERO_STREAM_IDLE_TIMEOUT"
|
||||
|
||||
// bareSecondsDuration converts a positive second count into a time.Duration.
|
||||
// time.Duration is int64 nanoseconds, so a count that passes strconv.Atoi can
|
||||
// still overflow when multiplied by time.Second and wrap to 0, which would
|
||||
// disable a timeout that should have stayed at its default. ok is false when
|
||||
// the product does not fit.
|
||||
func bareSecondsDuration(secs int) (time.Duration, bool) {
|
||||
if secs <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
const maxSecs = int64(math.MaxInt64) / int64(time.Second)
|
||||
if int64(secs) > maxSecs {
|
||||
return 0, false
|
||||
}
|
||||
return time.Duration(secs) * time.Second, true
|
||||
}
|
||||
|
||||
// ResolveStreamIdleTimeout selects the effective stream idle timeout. Precedence:
|
||||
// an explicit positive option (e.g. set by a test) wins; otherwise the
|
||||
// ZERO_STREAM_IDLE_TIMEOUT env override if set and valid; otherwise
|
||||
@@ -122,59 +105,15 @@ func ResolveStreamIdleTimeout(option time.Duration) time.Duration {
|
||||
if d, err := time.ParseDuration(raw); err == nil && d > 0 {
|
||||
return d
|
||||
}
|
||||
if secs, err := strconv.Atoi(raw); err == nil {
|
||||
if d, ok := bareSecondsDuration(secs); ok {
|
||||
return d
|
||||
}
|
||||
if secs, err := strconv.Atoi(raw); err == nil && secs > 0 {
|
||||
return time.Duration(secs) * time.Second
|
||||
}
|
||||
// Unparseable / non-positive / overflow: fall through to the default
|
||||
// rather than silently disabling the watchdog.
|
||||
// Unparseable / non-positive: fall through to the default rather than
|
||||
// silently disabling the watchdog on a typo.
|
||||
}
|
||||
return DefaultStreamIdleTimeout
|
||||
}
|
||||
|
||||
// DefaultResponseHeaderTimeout is how long the shared HTTP transport waits for
|
||||
// a response header after the request is written. 120s, not 60s: a slow cloud
|
||||
// proxy (e.g. ollama `*:cloud`) can withhold its 200 response header until the
|
||||
// upstream model emits a first token, so a 60s cap risked aborting a
|
||||
// legitimately-slow-but-alive request. 120s still bounds a truly dead reused
|
||||
// connection (which never responds) while tolerating slow header delivery; slow
|
||||
// first tokens after the header are covered by the idle + content-stall
|
||||
// watchdogs.
|
||||
const DefaultResponseHeaderTimeout = 120 * time.Second
|
||||
|
||||
// responseHeaderTimeoutEnv is the global override for the response header
|
||||
// timeout. It accepts the same forms as ZERO_STREAM_IDLE_TIMEOUT: a Go duration
|
||||
// ("5m", "300s", "90s") or a bare number of seconds ("300"). A value of "0",
|
||||
// "off", "none", or "disabled" removes the limit entirely (a connection that
|
||||
// never answers may then wait until the request context ends). Useful when a
|
||||
// local model server needs longer than DefaultResponseHeaderTimeout to produce
|
||||
// the first byte, for example a cold model load on a throttled Ollama.
|
||||
const responseHeaderTimeoutEnv = "ZERO_RESPONSE_HEADER_TIMEOUT"
|
||||
|
||||
// ResolveResponseHeaderTimeout selects the effective response header timeout:
|
||||
// the ZERO_RESPONSE_HEADER_TIMEOUT env override if set and valid, otherwise
|
||||
// DefaultResponseHeaderTimeout. A returned value <= 0 means no limit.
|
||||
func ResolveResponseHeaderTimeout() time.Duration {
|
||||
if raw := strings.TrimSpace(os.Getenv(responseHeaderTimeoutEnv)); raw != "" {
|
||||
switch strings.ToLower(raw) {
|
||||
case "0", "off", "none", "disabled":
|
||||
return 0
|
||||
}
|
||||
if d, err := time.ParseDuration(raw); err == nil && d > 0 {
|
||||
return d
|
||||
}
|
||||
if secs, err := strconv.Atoi(raw); err == nil {
|
||||
if d, ok := bareSecondsDuration(secs); ok {
|
||||
return d
|
||||
}
|
||||
}
|
||||
// Unparseable / non-positive / overflow: fall through to the default
|
||||
// rather than silently removing the limit.
|
||||
}
|
||||
return DefaultResponseHeaderTimeout
|
||||
}
|
||||
|
||||
// NormalizeBaseURL trims trailing slashes and validates an HTTP API base URL.
|
||||
func NormalizeBaseURL(baseURL string, defaultBaseURL string, label string) (string, error) {
|
||||
baseURL = strings.TrimSpace(baseURL)
|
||||
@@ -188,18 +127,7 @@ func NormalizeBaseURL(baseURL string, defaultBaseURL string, label string) (stri
|
||||
return baseURL, nil
|
||||
}
|
||||
|
||||
// sharedHTTPClients holds one stall-hardened client per resolved response-header
|
||||
// timeout. The timeout is read when HTTPClient is called, not at package init:
|
||||
// a test (or a process that sets ZERO_RESPONSE_HEADER_TIMEOUT before building
|
||||
// a provider) must see that value on the transport it actually dials. The
|
||||
// transport field is never rewritten after creation, so concurrent requests
|
||||
// keep the conn pool of the timeout they resolved.
|
||||
var sharedHTTPClients = struct {
|
||||
mu sync.Mutex
|
||||
byTimeout map[time.Duration]*http.Client
|
||||
}{}
|
||||
|
||||
// stallHardenedClient builds the client used when a provider supplies none.
|
||||
// sharedHTTPClient is the process-wide client used when a provider supplies none.
|
||||
// It tunes the default transport to defeat the stale-pooled-connection hang: Go
|
||||
// keeps idle keep-alive connections in a pool, and a later request can reuse one
|
||||
// the server/NAT has silently dropped. Because the model call is a POST (non-
|
||||
@@ -231,11 +159,15 @@ var sharedHTTPClients = struct {
|
||||
// to the minutes-long stalls this avoids — and this doesn't touch
|
||||
// Linux/Windows, where the underlying OS doesn't keep dead/degraded
|
||||
// pooled connections around as long.
|
||||
func stallHardenedClient(headerTimeout time.Duration) *http.Client {
|
||||
var sharedHTTPClient = func() *http.Client {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
// DefaultResponseHeaderTimeout (120s) unless ZERO_RESPONSE_HEADER_TIMEOUT
|
||||
// overrides it; see the constant for why the default is 120s and not 60s.
|
||||
transport.ResponseHeaderTimeout = headerTimeout
|
||||
// 120s, not 60s: a slow cloud proxy (e.g. ollama `*:cloud`) can withhold its
|
||||
// 200 response header until the upstream model emits a first token, so a 60s cap
|
||||
// risked aborting a legitimately-slow-but-alive request. 120s still bounds a
|
||||
// truly dead reused connection (which never responds) while tolerating slow
|
||||
// header delivery; slow first tokens after the header are covered by the idle +
|
||||
// content-stall watchdogs.
|
||||
transport.ResponseHeaderTimeout = 120 * time.Second
|
||||
transport.IdleConnTimeout = 30 * time.Second
|
||||
// Periodically close idle connections to prevent stale HTTP/2
|
||||
// connections from causing PROTOCOL_ERROR on the next request.
|
||||
@@ -251,22 +183,7 @@ func stallHardenedClient(headerTimeout time.Duration) *http.Client {
|
||||
}
|
||||
transport.DisableKeepAlives = runtime.GOOS == "darwin"
|
||||
return &http.Client{Transport: transport}
|
||||
}
|
||||
|
||||
func sharedStallClient() *http.Client {
|
||||
timeout := ResolveResponseHeaderTimeout()
|
||||
sharedHTTPClients.mu.Lock()
|
||||
defer sharedHTTPClients.mu.Unlock()
|
||||
if sharedHTTPClients.byTimeout == nil {
|
||||
sharedHTTPClients.byTimeout = make(map[time.Duration]*http.Client)
|
||||
}
|
||||
if client := sharedHTTPClients.byTimeout[timeout]; client != nil {
|
||||
return client
|
||||
}
|
||||
client := stallHardenedClient(timeout)
|
||||
sharedHTTPClients.byTimeout[timeout] = client
|
||||
return client
|
||||
}
|
||||
}()
|
||||
|
||||
const defaultIdleConnCloseInterval = 30 * time.Second
|
||||
|
||||
@@ -304,7 +221,7 @@ func HTTPClient(client *http.Client) *http.Client {
|
||||
if client != nil {
|
||||
return client
|
||||
}
|
||||
return sharedStallClient()
|
||||
return sharedHTTPClient
|
||||
}
|
||||
|
||||
// SendEvent writes a provider event without blocking cancellation cleanup.
|
||||
|
||||
@@ -340,10 +340,6 @@ func TestStreamTimeoutMessage(t *testing.T) {
|
||||
// response-header wait + shorter idle-conn reuse) that defeats the macOS stale-
|
||||
// pooled-connection hang; an explicit client is returned untouched.
|
||||
func TestHTTPClientReturnsStallHardenedSharedClient(t *testing.T) {
|
||||
// Package init must not bake the timeout. An ambient
|
||||
// ZERO_RESPONSE_HEADER_TIMEOUT would otherwise make this assertion
|
||||
// depend on whatever the process inherited.
|
||||
t.Setenv("ZERO_RESPONSE_HEADER_TIMEOUT", "")
|
||||
got := HTTPClient(nil)
|
||||
if got == nil {
|
||||
t.Fatal("HTTPClient(nil) returned nil")
|
||||
@@ -378,66 +374,6 @@ func TestHTTPClientReturnsStallHardenedSharedClient(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The shared transport must enforce the timeout ResolveResponseHeaderTimeout
|
||||
// returns. Checking the resolver alone stays green if the client still carries
|
||||
// the value captured at init.
|
||||
func TestHTTPClientTransportUsesResolvedHeaderTimeout(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
delay, err := time.ParseDuration(r.URL.Query().Get("delay"))
|
||||
if err != nil {
|
||||
http.Error(w, "bad delay", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
time.Sleep(delay)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
t.Setenv("ZERO_RESPONSE_HEADER_TIMEOUT", "200ms")
|
||||
short := HTTPClient(nil)
|
||||
tr, ok := short.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("transport type = %T, want *http.Transport", short.Transport)
|
||||
}
|
||||
if tr.ResponseHeaderTimeout != ResolveResponseHeaderTimeout() || tr.ResponseHeaderTimeout != 200*time.Millisecond {
|
||||
t.Fatalf("ResponseHeaderTimeout = %v, resolver = %v, want 200ms", tr.ResponseHeaderTimeout, ResolveResponseHeaderTimeout())
|
||||
}
|
||||
started := time.Now()
|
||||
resp, err := short.Get(srv.URL + "?delay=1s")
|
||||
if resp != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("slow header succeeded; the transport did not apply the 200ms timeout")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "timeout awaiting response headers") {
|
||||
t.Fatalf("error = %v, want the transport's response-header timeout", err)
|
||||
}
|
||||
if elapsed := time.Since(started); elapsed >= 800*time.Millisecond {
|
||||
t.Fatalf("header wait took %v; the transport waited on the handler instead of the resolved timeout", elapsed)
|
||||
}
|
||||
|
||||
t.Setenv("ZERO_RESPONSE_HEADER_TIMEOUT", "2s")
|
||||
long := HTTPClient(nil)
|
||||
if long == short {
|
||||
t.Fatal("a new resolved timeout reused the transport that still has the previous timeout")
|
||||
}
|
||||
if got := long.Transport.(*http.Transport).ResponseHeaderTimeout; got != 2*time.Second {
|
||||
t.Fatalf("ResponseHeaderTimeout = %v, want 2s", got)
|
||||
}
|
||||
resp, err = long.Get(srv.URL + "?delay=50ms")
|
||||
if err != nil {
|
||||
t.Fatalf("header within the resolved timeout failed: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204", resp.StatusCode)
|
||||
}
|
||||
if again := HTTPClient(nil); again != long {
|
||||
t.Fatal("the same resolved timeout must keep one shared client")
|
||||
}
|
||||
}
|
||||
|
||||
// startIdleConnCloser periodically closes idle pooled connections so stale HTTP/2
|
||||
// connections are not reused across long idle periods.
|
||||
func TestStartIdleConnCloserClosesIdleConnections(t *testing.T) {
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
package providerio
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestResolveResponseHeaderTimeout(t *testing.T) {
|
||||
const env = "ZERO_RESPONSE_HEADER_TIMEOUT"
|
||||
|
||||
t.Run("default when env is unset or empty", func(t *testing.T) {
|
||||
t.Setenv(env, "")
|
||||
if got := ResolveResponseHeaderTimeout(); got != DefaultResponseHeaderTimeout {
|
||||
t.Fatalf("got %v, want default %v", got, DefaultResponseHeaderTimeout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default keeps the value that was previously hardcoded", func(t *testing.T) {
|
||||
if DefaultResponseHeaderTimeout != 120*time.Second {
|
||||
t.Fatalf("default is %v; this override must not change the 120s default", DefaultResponseHeaderTimeout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("env Go duration", func(t *testing.T) {
|
||||
t.Setenv(env, "240s")
|
||||
if got := ResolveResponseHeaderTimeout(); got != 240*time.Second {
|
||||
t.Fatalf("got %v, want 240s", got)
|
||||
}
|
||||
t.Setenv(env, "5m")
|
||||
if got := ResolveResponseHeaderTimeout(); got != 5*time.Minute {
|
||||
t.Fatalf("got %v, want 5m", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("env bare seconds", func(t *testing.T) {
|
||||
t.Setenv(env, "300")
|
||||
if got := ResolveResponseHeaderTimeout(); got != 300*time.Second {
|
||||
t.Fatalf("got %v, want 300s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("env value is trimmed", func(t *testing.T) {
|
||||
t.Setenv(env, " 90s ")
|
||||
if got := ResolveResponseHeaderTimeout(); got != 90*time.Second {
|
||||
t.Fatalf("got %v, want 90s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("env removes the limit", func(t *testing.T) {
|
||||
for _, value := range []string{"0", "off", "none", "disabled", "OFF", "Disabled"} {
|
||||
t.Setenv(env, value)
|
||||
if got := ResolveResponseHeaderTimeout(); got != 0 {
|
||||
t.Fatalf("%q: got %v, want 0 (no limit)", value, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bare seconds that overflow time.Duration keep the default", func(t *testing.T) {
|
||||
t.Setenv(env, "36028797018963968")
|
||||
if got := ResolveResponseHeaderTimeout(); got != DefaultResponseHeaderTimeout {
|
||||
t.Fatalf("got %v, want default %v (overflow must not become a zero timeout)", got, DefaultResponseHeaderTimeout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid env falls back to default, not unlimited", func(t *testing.T) {
|
||||
for _, value := range []string{"banana", "-5s", "-1", "1.5x"} {
|
||||
t.Setenv(env, value)
|
||||
if got := ResolveResponseHeaderTimeout(); got != DefaultResponseHeaderTimeout {
|
||||
t.Fatalf("%q: got %v, want default %v (a typo must not remove the limit)", value, got, DefaultResponseHeaderTimeout)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -49,13 +49,6 @@ func TestResolveStreamIdleTimeout(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bare seconds that overflow time.Duration keep the default", func(t *testing.T) {
|
||||
t.Setenv(env, "36028797018963968")
|
||||
if got := ResolveStreamIdleTimeout(0); got != DefaultStreamIdleTimeout {
|
||||
t.Fatalf("got %v, want default %v (overflow must not disable the watchdog)", got, DefaultStreamIdleTimeout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid env falls back to default, not disabled", func(t *testing.T) {
|
||||
t.Setenv(env, "banana")
|
||||
if got := ResolveStreamIdleTimeout(0); got != DefaultStreamIdleTimeout {
|
||||
|
||||
@@ -101,25 +101,3 @@ func DefaultTurnSessions(profile config.ProviderProfile, provider zeroruntime.Pr
|
||||
}
|
||||
return zeroruntime.NewProviderTurnSessionProvider(provider, caps)
|
||||
}
|
||||
|
||||
// ConfiguredTurnSessions preserves transport selection and wraps the starting
|
||||
// session when RPM is configured. Without either feature, the default is nil.
|
||||
func ConfiguredTurnSessions(profile config.ProviderProfile, p zeroruntime.Provider, o Options) (zeroruntime.TurnSessionProvider, bool) {
|
||||
s, ok := OptimizedTurnSessions(profile, p, o)
|
||||
if o.ModelRPM == nil {
|
||||
return s, ok
|
||||
}
|
||||
if !ok {
|
||||
s = DefaultTurnSessions(profile, p, o)
|
||||
}
|
||||
return limitTurnSessions(profile, s, o), true
|
||||
}
|
||||
func limitTurnSessions(profile config.ProviderProfile, s zeroruntime.TurnSessionProvider, o Options) zeroruntime.TurnSessionProvider {
|
||||
model := strings.TrimSpace(profile.Model)
|
||||
if registry, err := defaultRegistry(o.ModelRegistry); err == nil {
|
||||
if id, ok := registry.ResolveID(model); ok {
|
||||
model = id
|
||||
}
|
||||
}
|
||||
return o.ModelRPM.Wrap(model, s)
|
||||
}
|
||||
@@ -423,18 +423,11 @@ func effectiveProgram(args []*syntax.Word) (string, []*syntax.Word) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// dashCPayload returns the literal text of the word following the shell's
|
||||
// command flag in an AST arg list (the command a shell launcher will run), or ""
|
||||
// when there is none. shellCommandFlag recognizes both a bare `-c`/`--command`
|
||||
// and a POSIX short-option cluster such as `-ec`, `-lc`, or `-xc`.
|
||||
// dashCPayload returns the literal text of the word following `-c` in an AST arg
|
||||
// list (the command a shell launcher will run), or "" when there is none.
|
||||
func dashCPayload(args []*syntax.Word) string {
|
||||
for index := 0; index < len(args); index++ {
|
||||
// `--` ends option processing: the remaining words are positional
|
||||
// operands, so a later `-ec`/`-c` is not the shell's command flag.
|
||||
if wordText(args[index]) == "--" {
|
||||
break
|
||||
}
|
||||
if shellCommandFlag(wordText(args[index])) && index+1 < len(args) {
|
||||
if wordText(args[index]) == "-c" && index+1 < len(args) {
|
||||
return wordText(args[index+1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,19 +31,6 @@ func TestAnalyzeCommand(t *testing.T) {
|
||||
{name: "sudo wraps rm -rf", script: "sudo rm -rf /tmp/x", destructive: true},
|
||||
{name: "env wraps curl", script: "env curl https://x.test", network: true},
|
||||
{name: "bash -c wraps editor", script: `bash -c 'vim file'`, interactive: true},
|
||||
// POSIX getopt clusters the command flag with other short options; the
|
||||
// payload must still be parsed and classified (ZERO-ESC-02).
|
||||
{name: "bash -ec wraps destructive payload", script: `bash -ec 'fdisk /dev/sda'`, destructive: true},
|
||||
{name: "sh -lc wraps destructive payload", script: `sh -lc 'shred -u secret.txt'`, destructive: true},
|
||||
{name: "zsh -xc wraps destructive payload", script: `zsh -xc 'parted /dev/sda mklabel gpt'`, destructive: true},
|
||||
{name: "bash -ec wraps network payload", script: `bash -ec 'curl https://x.test'`, network: true},
|
||||
{name: "bash --command wraps editor", script: `bash --command 'vim file'`, interactive: true},
|
||||
// `--` ends option processing, so a `-ec`/`-c` after it is a positional
|
||||
// operand, not the shell command flag: the quoted text must NOT be parsed
|
||||
// as a payload command and must not trigger its classification.
|
||||
{name: "bash -- -ec editor is not a payload", script: `bash -- -ec 'vim file'`, interactive: false},
|
||||
{name: "bash -- -ec fdisk is not a payload", script: `bash -- -ec 'fdisk /dev/sda'`, destructive: false},
|
||||
{name: "bash -- -c curl is not a payload", script: `bash -- -c 'curl https://x.test'`, network: false},
|
||||
{name: "sudo wraps bare repl", script: "sudo python3", interactive: true},
|
||||
// A valueless wrapper flag must not swallow the real payload command.
|
||||
{name: "sudo -n keeps rm payload", script: "sudo -n rm -rf /tmp/x", destructive: true},
|
||||
|
||||
@@ -1093,15 +1093,6 @@ func regexpQuoteMeta(value string) string {
|
||||
return replacer.Replace(value)
|
||||
}
|
||||
|
||||
// ScrubSensitiveEnv removes credential-bearing variables from a child
|
||||
// environment. It is exported for callers that exec a host tool OUTSIDE the
|
||||
// platform sandbox — notably format-on-write, which runs a project's formatter
|
||||
// in-process — so that a formatter doing dynamic configuration evaluation
|
||||
// cannot read API keys and tokens out of the inherited environment.
|
||||
func ScrubSensitiveEnv(env []string) []string {
|
||||
return scrubSensitiveEnv(env)
|
||||
}
|
||||
|
||||
func scrubSensitiveEnv(env []string, additionalKeys ...string) []string {
|
||||
// Secrets not covered by the provider catalog: cloud/VCS credentials and
|
||||
// providers Zero talks to through generic OpenAI-compatible endpoints.
|
||||
|
||||
@@ -400,32 +400,6 @@ func isNumericToken(field string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// shellCommandFlag reports whether arg is a shell option token that carries the
|
||||
// command string to run — the `-c`/`--command` flag — so callers can recurse into
|
||||
// the payload. POSIX getopt lets a shell cluster its short options, so the flag
|
||||
// is frequently grouped with other letters (`bash -ec`, `sh -lc`, `zsh -xc`);
|
||||
// a bare `-c` is only the one-letter case. A long option other than the exact
|
||||
// `--command` is not a match. `-o` and `-O` consume the remainder of their
|
||||
// cluster (or the next token) as an option value, so a `c` that follows them is
|
||||
// that value, not the command flag.
|
||||
func shellCommandFlag(arg string) bool {
|
||||
if arg == "--command" {
|
||||
return true
|
||||
}
|
||||
if len(arg) < 2 || arg[0] != '-' || arg[1] == '-' {
|
||||
return false
|
||||
}
|
||||
for _, flag := range arg[1:] {
|
||||
switch flag {
|
||||
case 'c':
|
||||
return true
|
||||
case 'o', 'O':
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// shellDashCPayload returns the command string passed to `sh -c`/`bash -c`
|
||||
// (and other POSIX shells) so the caller can recurse into it, or "" when the
|
||||
// segment is not a `<shell> -c <payload>` invocation. The payload is returned
|
||||
@@ -442,14 +416,7 @@ func shellDashCPayload(program string, fields []string) string {
|
||||
}
|
||||
args := fields[start+1:]
|
||||
for i, arg := range args {
|
||||
// `--` ends option processing: every following token is a positional
|
||||
// operand (a script name or argument), not a shell flag. Treating
|
||||
// `bash -- -ec 'cmd'` as `-c` would recurse into an operand the shell
|
||||
// never runs as a command string.
|
||||
if arg == "--" {
|
||||
break
|
||||
}
|
||||
if shellCommandFlag(arg) {
|
||||
if arg == "-c" || arg == "--command" {
|
||||
if i+1 < len(args) {
|
||||
return strings.Join(args[i+1:], " ")
|
||||
}
|
||||
|
||||
@@ -67,10 +67,6 @@ func TestDetectInteractiveCommandAllowsNonInteractive(t *testing.T) {
|
||||
"tail -n 50 app.log",
|
||||
"ssh host 'uptime'",
|
||||
"grep -r foo .",
|
||||
// After the `--` separator the following tokens are positional operands,
|
||||
// not shell flags, so `-ec` must not be read as the `-c` command flag.
|
||||
"bash -- -ec 'vim file.txt'",
|
||||
"sh -- -c 'less /var/log/syslog'",
|
||||
}
|
||||
for _, command := range cases {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
@@ -185,12 +181,6 @@ func TestDetectInteractiveThroughWrappersAndShellC(t *testing.T) {
|
||||
{name: "env with assignment option", command: "env -i EDITOR=x vim file.txt", wantCmd: "vim"},
|
||||
{name: "sh -c payload", command: "sh -c 'vim file.txt'", wantCmd: "vim"},
|
||||
{name: "bash -c payload", command: `bash -c "less /var/log/syslog"`, wantCmd: "less"},
|
||||
// POSIX getopt clusters the command flag with other short options; the
|
||||
// payload must still be located and recursed into (ZERO-ESC-02).
|
||||
{name: "sh -ec grouped flags", command: "sh -ec 'vim file.txt'", wantCmd: "vim"},
|
||||
{name: "bash -lc grouped flags", command: `bash -lc "less /var/log/syslog"`, wantCmd: "less"},
|
||||
{name: "zsh -xc grouped flags", command: "zsh -xc 'nano notes.txt'", wantCmd: "nano"},
|
||||
{name: "bash --command long flag", command: `bash --command "less /var/log/syslog"`, wantCmd: "less"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -205,65 +195,7 @@ func TestDetectInteractiveThroughWrappersAndShellC(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Audit ZERO-ESC-02: the shared shell-command-flag helper must honor POSIX
|
||||
// getopt short-option clustering, so a command hidden behind `bash -ec`/
|
||||
// `sh -lc`/`zsh -xc` (and the legacy `--command`) is still recognized, while
|
||||
// unrelated options and option values are not mistaken for the flag.
|
||||
func TestShellCommandFlag(t *testing.T) {
|
||||
cases := []struct {
|
||||
arg string
|
||||
want bool
|
||||
}{
|
||||
{arg: "-c", want: true},
|
||||
{arg: "-ec", want: true},
|
||||
{arg: "-lc", want: true},
|
||||
{arg: "-xc", want: true},
|
||||
{arg: "-ce", want: true},
|
||||
{arg: "--command", want: true},
|
||||
{arg: "-xec", want: true},
|
||||
{arg: "-e", want: false},
|
||||
{arg: "-o", want: false},
|
||||
{arg: "-O", want: false},
|
||||
// `-o`/`-O` consume the rest of the cluster as their value, so a later
|
||||
// `c` is that value (e.g. `-o c`), not the command flag.
|
||||
{arg: "-oc", want: false},
|
||||
{arg: "-Oc", want: false},
|
||||
{arg: "--norc", want: false},
|
||||
{arg: "--command=payload", want: false},
|
||||
{arg: "-", want: false},
|
||||
{arg: "", want: false},
|
||||
{arg: "c", want: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := shellCommandFlag(tc.arg); got != tc.want {
|
||||
t.Errorf("shellCommandFlag(%q) = %v, want %v", tc.arg, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Audit: the `--` separator ends option processing, so a `-c`/`-ec` token
|
||||
// appearing after it is a positional operand, not the shell command flag. A
|
||||
// payload hidden behind `bash -- -ec '...'` must not be recursed into.
|
||||
func TestShellDashCPayloadStopsAtDashDash(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
fields []string
|
||||
want string
|
||||
}{
|
||||
{name: "dashdash before clustered flag", fields: []string{"bash", "--", "-ec", "vim file.txt"}, want: ""},
|
||||
{name: "dashdash before bare flag", fields: []string{"sh", "--", "-c", "less file"}, want: ""},
|
||||
{name: "real flag before dashdash", fields: []string{"bash", "-c", "vim file.txt", "--"}, want: "vim file.txt --"},
|
||||
{name: "no dashdash grouped flag", fields: []string{"bash", "-ec", "vim file.txt"}, want: "vim file.txt"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := shellDashCPayload(tc.fields[0], tc.fields); got != tc.want {
|
||||
t.Fatalf("shellDashCPayload(%v) = %q, want %q", tc.fields, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Audit finding (MED): the interactive-program detector must not be bypassed by
|
||||
// quote/escape characters embedded INSIDE the program token (e.g. `vi\m`,
|
||||
// `v"i"m`, `'v'im`), not just surrounding it.
|
||||
func TestDetectInteractiveStripsEmbeddedQuotingFromToken(t *testing.T) {
|
||||
|
||||
@@ -2,7 +2,6 @@ package sessions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
@@ -152,20 +151,10 @@ func PrepareExec(options PrepareExecOptions) (PreparedExec, error) {
|
||||
}
|
||||
|
||||
func readExecContextEvents(store *Store, sessionID string) ([]Event, error) {
|
||||
// The session was picked from its metadata a moment ago. Make sure it is
|
||||
// still there, and held, before reading it to continue: see HoldToContinue.
|
||||
if err := store.HoldToContinue(sessionID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contextEvents, err := store.ReadRehydratedEvents(sessionID)
|
||||
if err == nil {
|
||||
return contextEvents, nil
|
||||
}
|
||||
if errors.Is(err, ErrPruning) {
|
||||
// Not a rehydration failure: the raw read would continue the session
|
||||
// without holding it while prune may be removing it.
|
||||
return nil, err
|
||||
}
|
||||
rawEvents, rawErr := store.ReadEvents(sessionID)
|
||||
if rawErr != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
package sessions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// leaseFileName is the file whose lock says a process has the session open.
|
||||
const leaseFileName = "lease.lock"
|
||||
|
||||
func (store *Store) leasePath(sessionID string) string {
|
||||
return filepath.Join(store.sessionPath(sessionID), leaseFileName)
|
||||
}
|
||||
|
||||
// Hold marks sessionID as open in this process until the process exits.
|
||||
//
|
||||
// A SESSION HOLDS NO LOCK BETWEEN WRITES. session.lock is taken around each
|
||||
// write and released straight after, so nothing told `zero sessions prune` in
|
||||
// another terminal that a TUI, an exec run or an editor still had the session
|
||||
// open, and an idle open session looked exactly like an abandoned one. Hold takes
|
||||
// a shared lock on the session's lease file and keeps it for the life of the
|
||||
// process; prune takes the same lock exclusively and leaves alone any session it
|
||||
// cannot get. Every write holds its session this way (lockSession).
|
||||
//
|
||||
// Hold never waits and never fails its caller. A lease that cannot be taken,
|
||||
// because the session directory is gone or prune holds it at this moment, only
|
||||
// means prune cannot see this process, and that is enough for a write: it lands
|
||||
// under session.lock, where prune checks the session again, so either prune sees
|
||||
// the write and keeps the session or the write finds the session gone. A read
|
||||
// changes nothing prune checks, so the reads that continue a session, and the
|
||||
// creations that read a parent, use holdOrRefuse instead.
|
||||
func (store *Store) Hold(sessionID string) {
|
||||
store.hold(sessionID)
|
||||
}
|
||||
|
||||
// ErrPruning is matched, through errors.Is, by the error a process gets when it
|
||||
// tries to continue a session, or to create a session under it, while zero
|
||||
// sessions prune holds that session or once it has removed it.
|
||||
var ErrPruning = errors.New("locked by zero sessions prune")
|
||||
|
||||
// pruneRefusal is an error matching ErrPruning whose message says which of the
|
||||
// two it was.
|
||||
type pruneRefusal struct{ message string }
|
||||
|
||||
func (refusal pruneRefusal) Error() string { return refusal.message }
|
||||
|
||||
func (refusal pruneRefusal) Is(target error) bool { return target == ErrPruning }
|
||||
|
||||
func pruneBusy(sessionID string) error {
|
||||
return pruneRefusal{"zero session " + sessionID + " is locked by zero sessions prune; try again"}
|
||||
}
|
||||
|
||||
func pruneRemoved(sessionID string) error {
|
||||
return pruneRefusal{"zero session " + sessionID + " was removed while it was being opened"}
|
||||
}
|
||||
|
||||
// holdOrRefuse holds a session that is about to be read in order to continue it
|
||||
// (the rehydrated read behind the TUI's resume, exec --resume and --fork, and
|
||||
// ACP's session/load and session/resume) or to create a session under it (Fork,
|
||||
// CreateChild, Create with a parent). When prune holds it at this moment the
|
||||
// caller is refused rather than left to read it without the lease: prune could
|
||||
// then remove a session this process goes on to use, or one a new session is
|
||||
// created under, whose Lineage and Tree fail on a missing ancestor.
|
||||
func (store *Store) holdOrRefuse(sessionID string) error {
|
||||
if store.hold(sessionID) {
|
||||
return pruneBusy(sessionID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// holdParent is holdOrRefuse for the session a new one is created under: Fork,
|
||||
// CreateChild, and Create with a ParentSessionID.
|
||||
//
|
||||
// HOLDING A LEASE FILE DOES NOT PROVE THE SESSION IS STILL THERE. Prune removes
|
||||
// the metadata first and unlinks lease.lock after it, so a process that takes
|
||||
// the lease just then creates a fresh lease.lock in a directory prune is
|
||||
// emptying, and locks it with nothing to contend with. So a parent whose
|
||||
// directory still exists without its metadata is refused once the lease is
|
||||
// held. A parent whose directory is gone altogether is left alone, as before: a
|
||||
// session may name a parent this store never had.
|
||||
func (store *Store) holdParent(parentSessionID string) error {
|
||||
if err := store.holdOrRefuse(parentSessionID); err != nil {
|
||||
return err
|
||||
}
|
||||
if store.beingRemoved(parentSessionID) {
|
||||
store.dropStrayLease(parentSessionID)
|
||||
return pruneRemoved(parentSessionID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HoldToContinue holds a session the caller has already picked, having read its
|
||||
// metadata, and is about to continue: the TUI's resume, exec --resume and
|
||||
// --fork, and ACP's session/load and session/resume. It refuses, with an error
|
||||
// matching ErrPruning, a session prune holds and one that is gone by the time it
|
||||
// is held, whether prune is part way through removing it or has finished.
|
||||
// Callers must not fall back to reading the session another way on that error.
|
||||
// ReadRehydratedEvents on its own still reads a session that does not exist as
|
||||
// an empty one, for callers that picked nothing.
|
||||
func (store *Store) HoldToContinue(sessionID string) error {
|
||||
if err := store.holdOrRefuse(sessionID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(store.metadataPath(sessionID)); errors.Is(err, fs.ErrNotExist) {
|
||||
store.dropStrayLease(sessionID)
|
||||
return pruneRemoved(sessionID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// beingRemoved reports a session directory that exists without its metadata:
|
||||
// one prune is part way through removing.
|
||||
func (store *Store) beingRemoved(sessionID string) bool {
|
||||
if _, err := os.Stat(store.metadataPath(sessionID)); !errors.Is(err, fs.ErrNotExist) {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(store.sessionPath(sessionID))
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
// dropStrayLease gives back a lease taken on a session that turned out to be
|
||||
// gone, and removes the lease file it may have created afresh in a directory
|
||||
// prune is emptying, so that prune can still remove the directory.
|
||||
func (store *Store) dropStrayLease(sessionID string) {
|
||||
store.Release(sessionID)
|
||||
if store.beingRemoved(sessionID) {
|
||||
_ = os.Remove(store.leasePath(sessionID))
|
||||
}
|
||||
}
|
||||
|
||||
// hold is Hold, reporting busy when the lease could not be taken because prune
|
||||
// holds it exclusively right now.
|
||||
func (store *Store) hold(sessionID string) (busy bool) {
|
||||
if !ValidSessionID(sessionID) {
|
||||
return false
|
||||
}
|
||||
store.leasesMu.Lock()
|
||||
defer store.leasesMu.Unlock()
|
||||
if _, held := store.leases[sessionID]; held {
|
||||
return false
|
||||
}
|
||||
// Creates the lease file, never the directory: a session that is gone stays
|
||||
// gone.
|
||||
file, err := openLeaseFile(store.leasePath(sessionID))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
locked, err := tryLockLease(file, false)
|
||||
if err != nil || !locked {
|
||||
_ = file.Close()
|
||||
return err == nil
|
||||
}
|
||||
if store.leases == nil {
|
||||
store.leases = map[string]*os.File{}
|
||||
}
|
||||
store.leases[sessionID] = file
|
||||
return false
|
||||
}
|
||||
|
||||
// Release gives up this Store's lease on sessionID. Nothing in Zero calls it
|
||||
// yet: a process holds every session it has touched until it exits, which
|
||||
// releases every lease with it, and tests use Release to stand in for that
|
||||
// exit. A long-lived process that switches sessions, like the TUI on /new, could
|
||||
// call it for the session it leaves.
|
||||
func (store *Store) Release(sessionID string) {
|
||||
store.leasesMu.Lock()
|
||||
defer store.leasesMu.Unlock()
|
||||
file, held := store.leases[sessionID]
|
||||
if !held {
|
||||
return
|
||||
}
|
||||
delete(store.leases, sessionID)
|
||||
unlockLease(file)
|
||||
_ = file.Close()
|
||||
}
|
||||
|
||||
// HoldExclusive takes sessionID's lease exclusively without waiting, which is how
|
||||
// prune keeps every other process out of a session while it checks or removes
|
||||
// it. It reports false, holding nothing, when any process holds the lease. While
|
||||
// it is held, Hold takes nothing and holdOrRefuse refuses. release lets it go,
|
||||
// and does nothing after the first call.
|
||||
func (store *Store) HoldExclusive(sessionID string) (release func(), locked bool, err error) {
|
||||
nothing := func() {}
|
||||
if !ValidSessionID(sessionID) {
|
||||
return nothing, false, fmt.Errorf("invalid zero session id %q", sessionID)
|
||||
}
|
||||
file, err := openLeaseFile(store.leasePath(sessionID))
|
||||
if err != nil {
|
||||
return nothing, false, err
|
||||
}
|
||||
locked, err = tryLockLease(file, true)
|
||||
if err != nil || !locked {
|
||||
_ = file.Close()
|
||||
return nothing, false, err
|
||||
}
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
unlockLease(file)
|
||||
_ = file.Close()
|
||||
})
|
||||
}, true, nil
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package sessions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// tryLockLease takes the lease lock on file without waiting: shared for a
|
||||
// process that has the session open, exclusive for prune. It reports false when
|
||||
// another holder's lock conflicts. flock locks belong to the open file
|
||||
// description, so a second descriptor in the same process conflicts like another
|
||||
// process would.
|
||||
func tryLockLease(file *os.File, exclusive bool) (bool, error) {
|
||||
how := unix.LOCK_SH
|
||||
if exclusive {
|
||||
how = unix.LOCK_EX
|
||||
}
|
||||
err := unix.Flock(int(file.Fd()), how|unix.LOCK_NB)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if errors.Is(err, unix.EWOULDBLOCK) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
// openLeaseFile opens (creating it if needed) the lease file of a session whose
|
||||
// directory exists.
|
||||
func openLeaseFile(path string) (*os.File, error) {
|
||||
return os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
|
||||
}
|
||||
|
||||
func unlockLease(file *os.File) {
|
||||
_ = unix.Flock(int(file.Fd()), unix.LOCK_UN)
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package sessions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// tryLockLease takes the lease lock on file without waiting: shared for a
|
||||
// process that has the session open, exclusive for prune. It reports false when
|
||||
// another holder's lock conflicts. LockFileEx locks belong to the handle, so a
|
||||
// second handle in the same process conflicts like another process would.
|
||||
func tryLockLease(file *os.File, exclusive bool) (bool, error) {
|
||||
flags := uint32(windows.LOCKFILE_FAIL_IMMEDIATELY)
|
||||
if exclusive {
|
||||
flags |= windows.LOCKFILE_EXCLUSIVE_LOCK
|
||||
}
|
||||
err := windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, 1, 0, new(windows.Overlapped))
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if errors.Is(err, windows.ERROR_LOCK_VIOLATION) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
// openLeaseFile opens (creating it if needed) the lease file of a session whose
|
||||
// directory exists.
|
||||
//
|
||||
// WITH FILE_SHARE_DELETE, unlike session.lock. A lease is held for the life of
|
||||
// the process, and a file held open without share-delete cannot be removed: the
|
||||
// session directory could then never be deleted while any process had it open,
|
||||
// by prune or by anything else, including a test's temp-directory cleanup. With
|
||||
// it, a delete removes the name at once (POSIX semantics) while the holder's
|
||||
// handle stays valid.
|
||||
func openLeaseFile(path string) (*os.File, error) {
|
||||
pathUTF16, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return nil, &os.PathError{Op: "open", Path: path, Err: err}
|
||||
}
|
||||
handle, err := windows.CreateFile(pathUTF16, windows.GENERIC_READ|windows.GENERIC_WRITE,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
nil, windows.OPEN_ALWAYS, windows.FILE_ATTRIBUTE_NORMAL, 0)
|
||||
if err != nil {
|
||||
return nil, &os.PathError{Op: "open", Path: path, Err: err}
|
||||
}
|
||||
return os.NewFile(uintptr(handle), path), nil
|
||||
}
|
||||
|
||||
func unlockLease(file *os.File) {
|
||||
_ = windows.UnlockFileEx(windows.Handle(file.Fd()), 0, 1, 0, new(windows.Overlapped))
|
||||
}
|
||||
@@ -10,9 +10,6 @@ func (store *Store) CreateChild(parentSessionID string, input ChildInput) (Metad
|
||||
if !ValidSessionID(parentSessionID) {
|
||||
return Metadata{}, fmt.Errorf("invalid zero session id %q", parentSessionID)
|
||||
}
|
||||
if err := store.holdParent(parentSessionID); err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
parent, err := store.Get(parentSessionID)
|
||||
if err != nil {
|
||||
return Metadata{}, err
|
||||
|
||||
@@ -1,369 +0,0 @@
|
||||
package sessions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MinimumPruneAge is the shortest cutoff Prune accepts.
|
||||
//
|
||||
// A floor under the lease, not a substitute for it. An older Zero, or anything
|
||||
// else that writes a session without taking the lease, is invisible to prune,
|
||||
// and a day is long enough that a session such a writer is actively using has
|
||||
// been written since.
|
||||
const MinimumPruneAge = 24 * time.Hour
|
||||
|
||||
// PruneOptions chooses what Prune removes.
|
||||
type PruneOptions struct {
|
||||
// OlderThan makes a session a candidate when it was last updated at least
|
||||
// this long ago. It must be at least MinimumPruneAge.
|
||||
OlderThan time.Duration
|
||||
// DryRun decides everything a real run would and removes nothing. It takes
|
||||
// the same locks to decide, so a Zero opening a session at the moment it is
|
||||
// being checked is told to try again, as it would be during a real run.
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// PruneEntry is one session Prune removed (or in a dry run would remove), kept
|
||||
// although it was old enough, or failed to remove.
|
||||
type PruneEntry struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Kind SessionKind `json:"kind,omitempty"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// PruneReport says what Prune did, or in a dry run what it would do. Sessions
|
||||
// newer than the cutoff are not listed.
|
||||
type PruneReport struct {
|
||||
Cutoff string `json:"cutoff"`
|
||||
DryRun bool `json:"dryRun"`
|
||||
// Removed are the sessions removed, or in a dry run the ones a real run
|
||||
// would remove.
|
||||
Removed []PruneEntry `json:"removed"`
|
||||
// Kept are sessions Prune left for a reason other than being recent.
|
||||
Kept []PruneEntry `json:"kept"`
|
||||
// Failed are sessions whose removal went wrong, or in a dry run could not be
|
||||
// checked; Reason says how. A failure after the metadata was removed leaves
|
||||
// a directory List no longer shows.
|
||||
Failed []PruneEntry `json:"failed"`
|
||||
}
|
||||
|
||||
// The reasons Prune keeps a session that is old enough to remove.
|
||||
const (
|
||||
PruneKeptOpen = "open in another Zero process"
|
||||
PruneKeptParent = "parent of a session that is kept"
|
||||
PruneKeptUpdated = "updated while pruning"
|
||||
PruneKeptUndated = "its last update time could not be read"
|
||||
)
|
||||
|
||||
// pruneRemoveSeam runs after Prune holds a session's lease and write lock and
|
||||
// before it re-reads the metadata. Nil in production.
|
||||
var pruneRemoveSeam func(sessionID string)
|
||||
|
||||
// pruneRemoveDirSeam runs just before Prune removes a session's directory, and
|
||||
// says whether Prune still holds the lease. Nil in production.
|
||||
var pruneRemoveDirSeam func(sessionID string, leaseHeld bool)
|
||||
|
||||
// prunePlannedSeam runs once Prune has made its plan and before it removes
|
||||
// anything. Nil in production.
|
||||
var prunePlannedSeam func()
|
||||
|
||||
// Prune removes sessions last updated before the cutoff that OlderThan sets.
|
||||
//
|
||||
// Only on request: nothing in Zero calls it by itself (#971). It never removes:
|
||||
// - a session another process has open, which holds its lease (see Hold);
|
||||
// - a session with a descendant that is kept, including one created after the
|
||||
// plan was made, because Lineage and Tree fail on a missing ancestor;
|
||||
// - a session written between the plan and its removal;
|
||||
// - a session whose last update time cannot be read.
|
||||
//
|
||||
// Descendants are removed before their ancestors, so a descendant that turns
|
||||
// out to be open part way through still keeps every ancestor above it. Within a
|
||||
// session the metadata goes first, under both the lease and the write lock:
|
||||
// from then on List and Get do not show it, so a removal that fails part way
|
||||
// leaves nothing that looks like a session.
|
||||
func (store *Store) Prune(options PruneOptions) (PruneReport, error) {
|
||||
if options.OlderThan < MinimumPruneAge {
|
||||
return PruneReport{}, fmt.Errorf("prune cutoff %s is shorter than the minimum of %s", options.OlderThan, MinimumPruneAge)
|
||||
}
|
||||
cutoff := store.now().UTC().Add(-options.OlderThan)
|
||||
report := PruneReport{
|
||||
Cutoff: cutoff.Format(time.RFC3339),
|
||||
DryRun: options.DryRun,
|
||||
Removed: []PruneEntry{},
|
||||
Kept: []PruneEntry{},
|
||||
Failed: []PruneEntry{},
|
||||
}
|
||||
all, err := store.List()
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
byID := make(map[string]Metadata, len(all))
|
||||
for _, session := range all {
|
||||
byID[session.SessionID] = session
|
||||
}
|
||||
|
||||
candidates := map[string]bool{}
|
||||
for _, session := range all {
|
||||
updated, err := time.Parse(time.RFC3339, session.UpdatedAt)
|
||||
if err != nil {
|
||||
report.Kept = append(report.Kept, store.pruneEntry(session, PruneKeptUndated))
|
||||
continue
|
||||
}
|
||||
if !updated.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
// Open elsewhere at planning time. Checked again under the lease when the
|
||||
// session is removed; this pass is what lets its ancestors be kept too.
|
||||
release, locked, err := store.HoldExclusive(session.SessionID)
|
||||
if err != nil {
|
||||
report.Kept = append(report.Kept, store.pruneEntry(session, "its lease could not be checked: "+err.Error()))
|
||||
continue
|
||||
}
|
||||
if !locked {
|
||||
report.Kept = append(report.Kept, store.pruneEntry(session, PruneKeptOpen))
|
||||
continue
|
||||
}
|
||||
release()
|
||||
candidates[session.SessionID] = true
|
||||
}
|
||||
|
||||
// Every ancestor of a session that stays, stays.
|
||||
for _, session := range all {
|
||||
if candidates[session.SessionID] {
|
||||
continue
|
||||
}
|
||||
for _, ancestor := range pruneAncestors(byID, session.SessionID) {
|
||||
if candidates[ancestor] {
|
||||
delete(candidates, ancestor)
|
||||
report.Kept = append(report.Kept, store.pruneEntry(byID[ancestor], PruneKeptParent))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
order := make([]Metadata, 0, len(candidates))
|
||||
for id := range candidates {
|
||||
order = append(order, byID[id])
|
||||
}
|
||||
depth := func(id string) int { return len(pruneAncestors(byID, id)) }
|
||||
sort.Slice(order, func(left, right int) bool {
|
||||
leftDepth, rightDepth := depth(order[left].SessionID), depth(order[right].SessionID)
|
||||
if leftDepth != rightDepth {
|
||||
return leftDepth > rightDepth
|
||||
}
|
||||
if order[left].UpdatedAt != order[right].UpdatedAt {
|
||||
return order[left].UpdatedAt < order[right].UpdatedAt
|
||||
}
|
||||
return order[left].SessionID < order[right].SessionID
|
||||
})
|
||||
if prunePlannedSeam != nil {
|
||||
prunePlannedSeam()
|
||||
}
|
||||
|
||||
keep := map[string]bool{}
|
||||
for _, session := range order {
|
||||
if keep[session.SessionID] {
|
||||
report.Kept = append(report.Kept, store.pruneEntry(session, PruneKeptParent))
|
||||
continue
|
||||
}
|
||||
entry := store.pruneEntry(session, "")
|
||||
removed, keptReason, err := store.pruneSession(session.SessionID, cutoff, byID, options.DryRun)
|
||||
switch {
|
||||
case err != nil:
|
||||
entry.Reason = err.Error()
|
||||
report.Failed = append(report.Failed, entry)
|
||||
case keptReason != "":
|
||||
entry.Reason = keptReason
|
||||
report.Kept = append(report.Kept, entry)
|
||||
case removed:
|
||||
report.Removed = append(report.Removed, entry)
|
||||
}
|
||||
if !removed {
|
||||
for _, ancestor := range pruneAncestors(byID, session.SessionID) {
|
||||
keep[ancestor] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// pruneAncestors lists a session's ancestors, nearest first, stopping at a
|
||||
// missing one or a cycle.
|
||||
func pruneAncestors(byID map[string]Metadata, sessionID string) []string {
|
||||
var ancestors []string
|
||||
seen := map[string]bool{sessionID: true}
|
||||
current, ok := byID[sessionID]
|
||||
for ok && current.ParentSessionID != "" && !seen[current.ParentSessionID] {
|
||||
parentID := current.ParentSessionID
|
||||
seen[parentID] = true
|
||||
ancestors = append(ancestors, parentID)
|
||||
current, ok = byID[parentID]
|
||||
}
|
||||
return ancestors
|
||||
}
|
||||
|
||||
func (store *Store) pruneEntry(session Metadata, reason string) PruneEntry {
|
||||
return PruneEntry{
|
||||
SessionID: session.SessionID,
|
||||
Title: session.Title,
|
||||
Kind: session.SessionKind,
|
||||
UpdatedAt: session.UpdatedAt,
|
||||
Bytes: store.sessionBytes(session.SessionID),
|
||||
Reason: reason,
|
||||
}
|
||||
}
|
||||
|
||||
// sessionBytes is what a session occupies on disk, without following links.
|
||||
func (store *Store) sessionBytes(sessionID string) int64 {
|
||||
var total int64
|
||||
_ = filepath.WalkDir(store.sessionPath(sessionID), func(_ string, entry fs.DirEntry, err error) error {
|
||||
if err != nil || entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if info, err := entry.Info(); err == nil && info.Mode().IsRegular() {
|
||||
total += info.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return total
|
||||
}
|
||||
|
||||
// childCreatedAfterPlan reports whether a session the plan did not see names
|
||||
// sessionID as its parent. Only sessions missing from planned are read, so the
|
||||
// cost is one directory listing plus whatever was created since the plan.
|
||||
func (store *Store) childCreatedAfterPlan(sessionID string, planned map[string]Metadata) (bool, error) {
|
||||
entries, err := os.ReadDir(store.RootDir)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
id := entry.Name()
|
||||
if !entry.IsDir() || id == sessionID {
|
||||
continue
|
||||
}
|
||||
if _, seen := planned[id]; seen {
|
||||
continue
|
||||
}
|
||||
child, err := store.readMetadata(id)
|
||||
if err != nil {
|
||||
continue // not a session, or one without its metadata
|
||||
}
|
||||
if child.ParentSessionID == sessionID {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// pruneSession removes one session that planning chose. It reports removed
|
||||
// once the metadata is gone, even when leftovers could not be deleted (err says
|
||||
// what), and a keptReason when the session turned out to be open, was written
|
||||
// since the plan, or has a child the plan did not know about. planned is every
|
||||
// session the plan saw. A dry run makes every one of those checks under the
|
||||
// same locks and stops before removing anything; removed then says a real run
|
||||
// would have gone on to remove the session.
|
||||
func (store *Store) pruneSession(sessionID string, cutoff time.Time, planned map[string]Metadata, dryRun bool) (removed bool, keptReason string, err error) {
|
||||
releaseLease, locked, err := store.HoldExclusive(sessionID)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("check the session's lease: %w", err)
|
||||
}
|
||||
if !locked {
|
||||
return false, PruneKeptOpen, nil
|
||||
}
|
||||
leaseHeld := true
|
||||
letLeaseGo := func() {
|
||||
leaseHeld = false
|
||||
releaseLease()
|
||||
}
|
||||
defer letLeaseGo()
|
||||
release, err := store.lockSessionWithoutLease(sessionID)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
released := false
|
||||
defer func() {
|
||||
if !released {
|
||||
release()
|
||||
}
|
||||
}()
|
||||
if pruneRemoveSeam != nil {
|
||||
pruneRemoveSeam(sessionID)
|
||||
}
|
||||
|
||||
session, err := store.readMetadata(sessionID)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, "", fmt.Errorf("the session disappeared while pruning")
|
||||
}
|
||||
return false, "", err
|
||||
}
|
||||
updated, err := time.Parse(time.RFC3339, session.UpdatedAt)
|
||||
if err != nil {
|
||||
return false, PruneKeptUndated, nil
|
||||
}
|
||||
if !updated.Before(cutoff) {
|
||||
return false, PruneKeptUpdated, nil
|
||||
}
|
||||
// A session forked or given a child after the plan was made, by a process that
|
||||
// has exited since, is not in the plan, and nothing else would stop its parent
|
||||
// going. Every way of creating a session under a parent holds that parent
|
||||
// first (holdParent), so none can start while this lease is held exclusively,
|
||||
// and one that finished has its metadata on disk.
|
||||
child, err := store.childCreatedAfterPlan(sessionID, planned)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("look for sessions created under it: %w", err)
|
||||
}
|
||||
if child {
|
||||
return false, PruneKeptParent, nil
|
||||
}
|
||||
if dryRun {
|
||||
return true, "", nil
|
||||
}
|
||||
|
||||
// THE METADATA FIRST. From here the session no longer exists to List or Get.
|
||||
if err := os.Remove(store.metadataPath(sessionID)); err != nil {
|
||||
return false, "", fmt.Errorf("remove the session metadata: %w", err)
|
||||
}
|
||||
dir := store.sessionPath(sessionID)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return true, "", fmt.Errorf("list what is left of the session: %w", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
// session.lock is held right here; it goes after the release below. The
|
||||
// lease file is held too, but it was opened for deletion (openLeaseFile).
|
||||
if entry.Name() == filepath.Base(store.lockPath(sessionID)) {
|
||||
continue
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(dir, entry.Name())); err != nil {
|
||||
return true, "", fmt.Errorf("remove %s: %w", entry.Name(), err)
|
||||
}
|
||||
}
|
||||
release()
|
||||
released = true
|
||||
// A writer that takes session.lock in the gap only keeps an empty directory
|
||||
// alive, and its own write then fails: the metadata is already gone.
|
||||
if err := os.Remove(store.lockPath(sessionID)); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return true, "", fmt.Errorf("remove the session lock: %w", err)
|
||||
}
|
||||
// Let go of the lease before the directory. lease.lock went with the rest, but
|
||||
// where a delete only takes effect once the last handle closes (Windows without
|
||||
// POSIX delete semantics: older builds, or FAT and exFAT volumes), this handle
|
||||
// would keep the directory from being empty.
|
||||
letLeaseGo()
|
||||
if pruneRemoveDirSeam != nil {
|
||||
pruneRemoveDirSeam(sessionID, leaseHeld)
|
||||
}
|
||||
if err := os.Remove(dir); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return true, "", fmt.Errorf("remove the session directory: %w", err)
|
||||
}
|
||||
return true, "", nil
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
package sessions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A fork made after prune's plan, by a process that has exited since, is not in
|
||||
// the plan and holds nothing. It still keeps its parent: once prune holds that
|
||||
// parent exclusively it looks for children the plan did not know about.
|
||||
func TestPruneKeepsAParentForkedAfterThePlan(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
createFinishedSession(t, root, "parent", "2026-06-01T00:00:00Z", "")
|
||||
|
||||
forked := false
|
||||
prunePlannedSeam = func() {
|
||||
other := NewStore(StoreOptions{RootDir: root, Now: fixedClock("2026-09-25T00:00:00Z")})
|
||||
if _, err := other.Fork("parent", ForkInput{SessionID: "late"}); err != nil {
|
||||
t.Errorf("fork after the plan: %v", err)
|
||||
return
|
||||
}
|
||||
forked = true
|
||||
// The forking process exits, and its leases go with it.
|
||||
other.Release("late")
|
||||
other.Release("parent")
|
||||
}
|
||||
defer func() { prunePlannedSeam = nil }()
|
||||
|
||||
report, err := pruneStore(root).Prune(PruneOptions{OlderThan: thirtyDays})
|
||||
if err != nil {
|
||||
t.Fatalf("Prune: %v", err)
|
||||
}
|
||||
if !forked {
|
||||
t.Fatal("SETUP INVALID: the fork after the plan never happened")
|
||||
}
|
||||
if reason := keptReason(report, "parent"); reason != PruneKeptParent {
|
||||
t.Errorf("parent kept for %q, want %q (removed %v)", reason, PruneKeptParent, pruneIDs(report.Removed))
|
||||
}
|
||||
if lineage, err := pruneStore(root).Lineage("late"); err != nil || len(lineage) != 2 {
|
||||
t.Fatalf("the late fork's lineage is broken: %d entries, %v", len(lineage), err)
|
||||
}
|
||||
}
|
||||
|
||||
// A dry run decides what a real run would, including what only shows once a
|
||||
// session is held for removal: a fork made after the plan, a write since the
|
||||
// plan, and a process that opened the session since the plan. Each case runs
|
||||
// both ways from the same start, and both reports must be the one wanted.
|
||||
func TestPruneDryRunDecidesLikeARealRunAtRemoval(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
// create lays out the sessions. afterPlan changes them once the plan is
|
||||
// made, and returns what to undo when Prune has finished.
|
||||
create func(t *testing.T, root string)
|
||||
afterPlan func(t *testing.T, root string) (undo func())
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "a fork made after the plan",
|
||||
create: func(t *testing.T, root string) {
|
||||
createFinishedSession(t, root, "parent", "2026-06-01T00:00:00Z", "")
|
||||
},
|
||||
afterPlan: func(t *testing.T, root string) func() {
|
||||
other := NewStore(StoreOptions{RootDir: root, Now: fixedClock("2026-09-25T00:00:00Z")})
|
||||
if _, err := other.Fork("parent", ForkInput{SessionID: "late"}); err != nil {
|
||||
t.Errorf("fork after the plan: %v", err)
|
||||
}
|
||||
other.Release("late")
|
||||
other.Release("parent")
|
||||
return func() {}
|
||||
},
|
||||
want: "removed [] kept [parent: " + PruneKeptParent + "] failed []",
|
||||
},
|
||||
{
|
||||
name: "a write since the plan",
|
||||
create: func(t *testing.T, root string) {
|
||||
createFinishedSession(t, root, "parent", "2026-06-01T00:00:00Z", "")
|
||||
createFinishedSession(t, root, "child", "2026-06-15T00:00:00Z", "parent")
|
||||
},
|
||||
afterPlan: func(t *testing.T, root string) func() {
|
||||
rewriteUpdatedAt(t, root, "child", "2026-09-25T23:00:00Z")
|
||||
return func() {}
|
||||
},
|
||||
want: "removed [] kept [child: " + PruneKeptUpdated + ", parent: " + PruneKeptParent + "] failed []",
|
||||
},
|
||||
{
|
||||
name: "a session opened since the plan",
|
||||
create: func(t *testing.T, root string) {
|
||||
createFinishedSession(t, root, "opened", "2026-06-01T00:00:00Z", "")
|
||||
createFinishedSession(t, root, "idle", "2026-06-02T00:00:00Z", "")
|
||||
},
|
||||
afterPlan: func(t *testing.T, root string) func() {
|
||||
other := NewStore(StoreOptions{RootDir: root})
|
||||
if busy := other.hold("opened"); busy {
|
||||
t.Error("SETUP INVALID: the session was busy when opened after the plan")
|
||||
}
|
||||
return func() { other.Release("opened") }
|
||||
},
|
||||
want: "removed [idle] kept [opened: " + PruneKeptOpen + "] failed []",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer func() { prunePlannedSeam = nil }()
|
||||
for _, dryRun := range []bool{false, true} {
|
||||
root := t.TempDir()
|
||||
tc.create(t, root)
|
||||
before := sessionDirNames(t, root)
|
||||
var undo func()
|
||||
prunePlannedSeam = func() { undo = tc.afterPlan(t, root) }
|
||||
report, err := pruneStore(root).Prune(PruneOptions{OlderThan: thirtyDays, DryRun: dryRun})
|
||||
prunePlannedSeam = nil
|
||||
if undo == nil {
|
||||
t.Fatal("SETUP INVALID: nothing changed after the plan")
|
||||
}
|
||||
undo()
|
||||
if err != nil {
|
||||
t.Fatalf("dry run %v: Prune: %v", dryRun, err)
|
||||
}
|
||||
if got := pruneSummary(report); got != tc.want {
|
||||
t.Errorf("dry run %v: the report is %s, want %s", dryRun, got, tc.want)
|
||||
}
|
||||
if !dryRun {
|
||||
continue
|
||||
}
|
||||
for _, id := range before {
|
||||
if _, err := os.Stat(pruneStore(root).metadataPath(id)); err != nil {
|
||||
t.Errorf("the dry run removed the metadata of %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// pruneSummary puts what a report decided on one line, in report order.
|
||||
func pruneSummary(report PruneReport) string {
|
||||
kept := []string{}
|
||||
for _, entry := range report.Kept {
|
||||
kept = append(kept, entry.SessionID+": "+entry.Reason)
|
||||
}
|
||||
return fmt.Sprintf("removed %v kept [%s] failed %v", pruneIDs(report.Removed), strings.Join(kept, ", "), pruneIDs(report.Failed))
|
||||
}
|
||||
|
||||
// Prune removes the metadata first and unlinks lease.lock after it. A process
|
||||
// that picked the session a moment earlier and only now takes its lease creates
|
||||
// a fresh lease.lock in the directory prune is emptying, and locks it with
|
||||
// nothing to contend with. Continuing the session, or creating one under it, is
|
||||
// refused all the same, and prune still removes the directory.
|
||||
func TestContinuingASessionPruneIsRemovingIsRefused(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
createFinishedSession(t, root, "old", "2026-06-01T00:00:00Z", "")
|
||||
other := NewStore(StoreOptions{RootDir: root})
|
||||
|
||||
reached := false
|
||||
pruneRemoveDirSeam = func(id string, _ bool) {
|
||||
if id != "old" {
|
||||
return
|
||||
}
|
||||
reached = true
|
||||
if _, err := os.Stat(other.metadataPath("old")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("SETUP INVALID: the metadata is still there at the seam: %v", err)
|
||||
}
|
||||
if err := other.HoldToContinue("old"); !errors.Is(err, ErrPruning) || !strings.Contains(err.Error(), "was removed") {
|
||||
t.Errorf("continue a session prune is removing: err = %v, want it refused as removed", err)
|
||||
}
|
||||
if events, err := readExecContextEvents(other, "old"); !errors.Is(err, ErrPruning) || events != nil {
|
||||
t.Errorf("exec context read of a session prune is removing: %d events, err = %v, want it refused", len(events), err)
|
||||
}
|
||||
if _, err := other.Create(CreateInput{SessionID: "child", ParentSessionID: "old"}); !errors.Is(err, ErrPruning) {
|
||||
t.Errorf("create a session under one prune is removing: err = %v, want it refused", err)
|
||||
}
|
||||
}
|
||||
defer func() { pruneRemoveDirSeam = nil }()
|
||||
|
||||
report, err := pruneStore(root).Prune(PruneOptions{OlderThan: thirtyDays})
|
||||
if err != nil {
|
||||
t.Fatalf("Prune: %v", err)
|
||||
}
|
||||
if !reached {
|
||||
t.Fatal("SETUP INVALID: prune never reached the directory removal")
|
||||
}
|
||||
if strings.Join(pruneIDs(report.Removed), ",") != "old" || len(report.Failed) != 0 {
|
||||
t.Errorf("removed %v, failed %v: the refused continuation must not keep prune from removing the directory", pruneIDs(report.Removed), pruneIDs(report.Failed))
|
||||
}
|
||||
if sessionDirExists(t, root, "old") || sessionDirExists(t, root, "child") {
|
||||
t.Errorf("left behind: old=%v child=%v", sessionDirExists(t, root, "old"), sessionDirExists(t, root, "child"))
|
||||
}
|
||||
|
||||
// Once prune has finished, the session the caller picked is simply gone, and
|
||||
// continuing it is refused the same way.
|
||||
if err := other.HoldToContinue("old"); !errors.Is(err, ErrPruning) {
|
||||
t.Errorf("continue a session prune removed: err = %v, want it refused", err)
|
||||
}
|
||||
// A caller that picked nothing still reads a missing session as empty.
|
||||
events, present, err := other.ReadRehydratedEventsWithPresence("never-existed")
|
||||
if err != nil || present || len(events) != 0 {
|
||||
t.Errorf("rehydrated read of a session that never existed = %d events, present=%v, err=%v; want empty", len(events), present, err)
|
||||
}
|
||||
}
|
||||
@@ -1,520 +0,0 @@
|
||||
package sessions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// pruneNow is "now" for every prune in these tests.
|
||||
const pruneNow = "2026-09-26T00:00:00Z"
|
||||
|
||||
const thirtyDays = 30 * 24 * time.Hour
|
||||
|
||||
func pruneStore(root string) *Store {
|
||||
return NewStore(StoreOptions{RootDir: root, Now: fixedClock(pruneNow)})
|
||||
}
|
||||
|
||||
// createFinishedSession leaves a session the way a run that has ended leaves
|
||||
// it: created and written at `at` by a Store that no longer holds it open.
|
||||
func createFinishedSession(t *testing.T, root, id, at, parent string) {
|
||||
t.Helper()
|
||||
store := NewStore(StoreOptions{RootDir: root, Now: fixedClock(at)})
|
||||
var err error
|
||||
if parent == "" {
|
||||
_, err = store.Create(CreateInput{SessionID: id, Title: "title " + id})
|
||||
} else {
|
||||
_, err = store.Fork(parent, ForkInput{SessionID: id, Title: "title " + id})
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("create %s: %v", id, err)
|
||||
}
|
||||
if _, err := store.AppendEvent(id, AppendEventInput{Type: EventMessage, Payload: map[string]string{"content": "hello from " + id}}); err != nil {
|
||||
t.Fatalf("append to %s: %v", id, err)
|
||||
}
|
||||
store.Release(id)
|
||||
if parent != "" {
|
||||
store.Release(parent)
|
||||
}
|
||||
}
|
||||
|
||||
func sessionDirExists(t *testing.T, root, id string) bool {
|
||||
t.Helper()
|
||||
_, err := os.Stat(filepath.Join(root, id))
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false
|
||||
}
|
||||
t.Fatalf("stat %s: %v", id, err)
|
||||
return false
|
||||
}
|
||||
|
||||
func pruneIDs(entries []PruneEntry) []string {
|
||||
ids := []string{}
|
||||
for _, entry := range entries {
|
||||
ids = append(ids, entry.SessionID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func keptReason(report PruneReport, id string) string {
|
||||
for _, entry := range report.Kept {
|
||||
if entry.SessionID == id {
|
||||
return entry.Reason
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// rewriteUpdatedAt changes a session's recorded last update behind the store's
|
||||
// back, the way a writer that takes no lease would.
|
||||
func rewriteUpdatedAt(t *testing.T, root, id, updatedAt string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(root, id, "metadata.json")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s metadata: %v", id, err)
|
||||
}
|
||||
var fields map[string]any
|
||||
if err := json.Unmarshal(data, &fields); err != nil {
|
||||
t.Fatalf("decode %s metadata: %v", id, err)
|
||||
}
|
||||
fields["updatedAt"] = updatedAt
|
||||
data, err = json.Marshal(fields)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("write %s metadata: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneRemovesOnlySessionsOlderThanTheCutoff(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
createFinishedSession(t, root, "old-a", "2026-07-01T00:00:00Z", "")
|
||||
createFinishedSession(t, root, "old-b", "2026-07-15T00:00:00Z", "")
|
||||
createFinishedSession(t, root, "recent", "2026-09-25T12:00:00Z", "")
|
||||
|
||||
// A checkpoint blob, which has to go with its session.
|
||||
workspace := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(workspace, "a.txt"), []byte("before"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := NewStore(StoreOptions{RootDir: root, Now: fixedClock("2026-07-01T00:00:00Z")})
|
||||
if _, err := old.CaptureToolCheckpoint("old-a", workspace, "edit_file", []string{"a.txt"}); err != nil {
|
||||
t.Fatalf("capture a checkpoint: %v", err)
|
||||
}
|
||||
old.Release("old-a")
|
||||
if blobs, err := os.ReadDir(filepath.Join(root, "old-a", CheckpointsDir, "blobs")); err != nil || len(blobs) == 0 {
|
||||
t.Fatalf("SETUP INVALID: no checkpoint blob to remove (%v)", err)
|
||||
}
|
||||
|
||||
report, err := pruneStore(root).Prune(PruneOptions{OlderThan: thirtyDays})
|
||||
if err != nil {
|
||||
t.Fatalf("Prune: %v", err)
|
||||
}
|
||||
if got := strings.Join(pruneIDs(report.Removed), ","); got != "old-a,old-b" {
|
||||
t.Fatalf("removed %q, want old-a,old-b", got)
|
||||
}
|
||||
if len(report.Kept) != 0 || len(report.Failed) != 0 {
|
||||
t.Fatalf("kept %v and failed %v, want neither", report.Kept, report.Failed)
|
||||
}
|
||||
for _, entry := range report.Removed {
|
||||
if entry.Bytes <= 0 {
|
||||
t.Errorf("%s was reported at %d bytes", entry.SessionID, entry.Bytes)
|
||||
}
|
||||
if sessionDirExists(t, root, entry.SessionID) {
|
||||
t.Errorf("%s was reported removed and its directory is still there", entry.SessionID)
|
||||
}
|
||||
}
|
||||
if got, err := pruneStore(root).Get("recent"); err != nil || got == nil {
|
||||
t.Fatalf("the recent session is gone or unreadable: %v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneRefusesACutoffShorterThanADay(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
createFinishedSession(t, root, "old", "2026-07-01T00:00:00Z", "")
|
||||
_, err := pruneStore(root).Prune(PruneOptions{OlderThan: 23 * time.Hour})
|
||||
if err == nil || !strings.Contains(err.Error(), "minimum") {
|
||||
t.Fatalf("a 23h cutoff was not refused for being under the minimum: %v", err)
|
||||
}
|
||||
if !sessionDirExists(t, root, "old") {
|
||||
t.Fatal("a refused prune removed a session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneDryRunDecidesTheSameAndRemovesNothing(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
createFinishedSession(t, root, "old-a", "2026-07-01T00:00:00Z", "")
|
||||
createFinishedSession(t, root, "old-b", "2026-07-15T00:00:00Z", "")
|
||||
|
||||
report, err := pruneStore(root).Prune(PruneOptions{OlderThan: thirtyDays, DryRun: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Prune: %v", err)
|
||||
}
|
||||
if !report.DryRun || strings.Join(pruneIDs(report.Removed), ",") != "old-a,old-b" {
|
||||
t.Fatalf("dry run report = %+v, want both sessions listed as would-be removals", report)
|
||||
}
|
||||
for _, id := range []string{"old-a", "old-b"} {
|
||||
if !sessionDirExists(t, root, id) {
|
||||
t.Errorf("a dry run removed %s", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A SESSION ANOTHER PROCESS HAS OPEN STAYS, HOWEVER OLD. The other Store stands
|
||||
// in for the other process: lease locks conflict between handles, not only
|
||||
// between processes. Each way of having a session open is covered: holding it
|
||||
// outright, writing to it, and loading it to resume it.
|
||||
func TestPruneLeavesASessionAnotherProcessHasOpen(t *testing.T) {
|
||||
for _, open := range []struct {
|
||||
name string
|
||||
do func(t *testing.T, other *Store, id string)
|
||||
}{
|
||||
{"held", func(t *testing.T, other *Store, id string) { other.Hold(id) }},
|
||||
{"written", func(t *testing.T, other *Store, id string) {
|
||||
if _, err := other.AppendEvent(id, AppendEventInput{Type: EventMessage, Payload: map[string]string{"content": "still here"}}); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
}},
|
||||
{"resumed", func(t *testing.T, other *Store, id string) {
|
||||
if _, err := other.ReadRehydratedEvents(id); err != nil {
|
||||
t.Fatalf("resume: %v", err)
|
||||
}
|
||||
}},
|
||||
} {
|
||||
t.Run(open.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
createFinishedSession(t, root, "old", "2026-07-01T00:00:00Z", "")
|
||||
// The other process's clock is as old as the session, so a write
|
||||
// leaves it just as old and only the lease can keep it.
|
||||
other := NewStore(StoreOptions{RootDir: root, Now: fixedClock("2026-07-01T00:00:00Z")})
|
||||
open.do(t, other, "old")
|
||||
|
||||
// A dry run has to say so too, not list it as removable.
|
||||
preview, err := pruneStore(root).Prune(PruneOptions{OlderThan: thirtyDays, DryRun: true})
|
||||
if err != nil {
|
||||
t.Fatalf("dry run: %v", err)
|
||||
}
|
||||
if reason := keptReason(preview, "old"); reason != PruneKeptOpen || len(preview.Removed) != 0 {
|
||||
t.Fatalf("dry run kept reason %q and would remove %v, want it kept as open", reason, pruneIDs(preview.Removed))
|
||||
}
|
||||
|
||||
report, err := pruneStore(root).Prune(PruneOptions{OlderThan: thirtyDays})
|
||||
if err != nil {
|
||||
t.Fatalf("Prune: %v", err)
|
||||
}
|
||||
if reason := keptReason(report, "old"); reason != PruneKeptOpen {
|
||||
t.Fatalf("kept reason %q, want %q (report %+v)", reason, PruneKeptOpen, report)
|
||||
}
|
||||
if !sessionDirExists(t, root, "old") {
|
||||
t.Fatal("a session another process had open was removed")
|
||||
}
|
||||
|
||||
// The lease was the only thing keeping it.
|
||||
other.Release("old")
|
||||
report, err = pruneStore(root).Prune(PruneOptions{OlderThan: thirtyDays})
|
||||
if err != nil {
|
||||
t.Fatalf("Prune after release: %v", err)
|
||||
}
|
||||
if strings.Join(pruneIDs(report.Removed), ",") != "old" {
|
||||
t.Fatalf("after the other process let go, removed %v", pruneIDs(report.Removed))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Lineage and Tree fail on a missing ancestor, so an ancestor of anything kept
|
||||
// stays, however old.
|
||||
func TestPruneKeepsEveryAncestorOfAKeptSession(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
createFinishedSession(t, root, "grandparent", "2026-06-01T00:00:00Z", "")
|
||||
createFinishedSession(t, root, "parent", "2026-06-15T00:00:00Z", "grandparent")
|
||||
createFinishedSession(t, root, "child", "2026-09-25T00:00:00Z", "parent")
|
||||
createFinishedSession(t, root, "unrelated", "2026-06-01T00:00:00Z", "")
|
||||
|
||||
report, err := pruneStore(root).Prune(PruneOptions{OlderThan: thirtyDays})
|
||||
if err != nil {
|
||||
t.Fatalf("Prune: %v", err)
|
||||
}
|
||||
if got := strings.Join(pruneIDs(report.Removed), ","); got != "unrelated" {
|
||||
t.Fatalf("removed %q, want only the unrelated session", got)
|
||||
}
|
||||
for _, id := range []string{"parent", "grandparent"} {
|
||||
if reason := keptReason(report, id); reason != PruneKeptParent {
|
||||
t.Errorf("%s kept for %q, want %q", id, reason, PruneKeptParent)
|
||||
}
|
||||
}
|
||||
if lineage, err := pruneStore(root).Lineage("child"); err != nil || len(lineage) != 3 {
|
||||
t.Fatalf("the kept child's lineage is broken: %d entries, %v", len(lineage), err)
|
||||
}
|
||||
}
|
||||
|
||||
// Descendants go first, and one that turns out to have been written since the
|
||||
// plan keeps its ancestors, which are old enough themselves.
|
||||
func TestPruneKeepsTheAncestorsOfASessionWrittenWhilePruning(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
createFinishedSession(t, root, "parent", "2026-06-01T00:00:00Z", "")
|
||||
createFinishedSession(t, root, "child", "2026-06-15T00:00:00Z", "parent")
|
||||
|
||||
var seen []string
|
||||
pruneRemoveSeam = func(id string) {
|
||||
seen = append(seen, id)
|
||||
if id == "child" {
|
||||
rewriteUpdatedAt(t, root, "child", "2026-09-25T23:00:00Z")
|
||||
}
|
||||
}
|
||||
defer func() { pruneRemoveSeam = nil }()
|
||||
|
||||
report, err := pruneStore(root).Prune(PruneOptions{OlderThan: thirtyDays})
|
||||
if err != nil {
|
||||
t.Fatalf("Prune: %v", err)
|
||||
}
|
||||
if strings.Join(seen, ",") != "child" {
|
||||
t.Errorf("removal reached %v, want the child first and the parent never", seen)
|
||||
}
|
||||
if reason := keptReason(report, "child"); reason != PruneKeptUpdated {
|
||||
t.Errorf("child kept for %q, want %q", reason, PruneKeptUpdated)
|
||||
}
|
||||
if reason := keptReason(report, "parent"); reason != PruneKeptParent {
|
||||
t.Errorf("parent kept for %q, want %q", reason, PruneKeptParent)
|
||||
}
|
||||
if len(report.Removed) != 0 || !sessionDirExists(t, root, "parent") || !sessionDirExists(t, root, "child") {
|
||||
t.Fatalf("something was removed: %+v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneKeepsASessionWhoseLastUpdateCannotBeRead(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
createFinishedSession(t, root, "undated", "2026-06-01T00:00:00Z", "")
|
||||
rewriteUpdatedAt(t, root, "undated", "sometime last spring")
|
||||
|
||||
preview, err := pruneStore(root).Prune(PruneOptions{OlderThan: thirtyDays, DryRun: true})
|
||||
if err != nil {
|
||||
t.Fatalf("dry run: %v", err)
|
||||
}
|
||||
if reason := keptReason(preview, "undated"); reason != PruneKeptUndated || len(preview.Removed) != 0 {
|
||||
t.Fatalf("dry run kept reason %q and would remove %v", reason, pruneIDs(preview.Removed))
|
||||
}
|
||||
|
||||
report, err := pruneStore(root).Prune(PruneOptions{OlderThan: thirtyDays})
|
||||
if err != nil {
|
||||
t.Fatalf("Prune: %v", err)
|
||||
}
|
||||
if reason := keptReason(report, "undated"); reason != PruneKeptUndated {
|
||||
t.Fatalf("kept for %q, want %q", reason, PruneKeptUndated)
|
||||
}
|
||||
if !sessionDirExists(t, root, "undated") {
|
||||
t.Fatal("a session with an unreadable update time was removed")
|
||||
}
|
||||
}
|
||||
|
||||
// A session another process has just created, and not yet written to, is as
|
||||
// open as one it has been writing for hours: a TUI creates its session before
|
||||
// the first prompt.
|
||||
func TestPruneLeavesASessionAnotherProcessJustCreated(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
other := NewStore(StoreOptions{RootDir: root, Now: fixedClock("2026-07-01T00:00:00Z")})
|
||||
if _, err := other.Create(CreateInput{SessionID: "fresh", Title: "not written yet"}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
defer other.Release("fresh")
|
||||
|
||||
report, err := pruneStore(root).Prune(PruneOptions{OlderThan: thirtyDays})
|
||||
if err != nil {
|
||||
t.Fatalf("Prune: %v", err)
|
||||
}
|
||||
if reason := keptReason(report, "fresh"); reason != PruneKeptOpen {
|
||||
t.Fatalf("kept reason %q, want %q (report %+v)", reason, PruneKeptOpen, report)
|
||||
}
|
||||
if !sessionDirExists(t, root, "fresh") {
|
||||
t.Fatal("a session another process had just created was removed")
|
||||
}
|
||||
}
|
||||
|
||||
// sessionDirNames lists the session directories under root.
|
||||
func sessionDirNames(t *testing.T, root string) []string {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := []string{}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// Every way of creating a session under a parent holds that parent, and is
|
||||
// refused while prune holds it, rather than creating a session under one that is
|
||||
// about to go: Fork, CreateChild, and the direct Create that exec
|
||||
// --calling-session-id and spec implementations use.
|
||||
func TestForkAndChildHoldTheParentTheyAreCreatedFrom(t *testing.T) {
|
||||
for _, create := range []struct {
|
||||
name string
|
||||
do func(store *Store) error
|
||||
}{
|
||||
{"fork", func(store *Store) error {
|
||||
_, err := store.Fork("parent", ForkInput{SessionID: "new"})
|
||||
return err
|
||||
}},
|
||||
{"child", func(store *Store) error {
|
||||
_, err := store.CreateChild("parent", ChildInput{SessionID: "new"})
|
||||
return err
|
||||
}},
|
||||
{"create with a parent", func(store *Store) error {
|
||||
_, err := store.Create(CreateInput{SessionID: "new", ParentSessionID: "parent"})
|
||||
return err
|
||||
}},
|
||||
{"exec calling session", func(store *Store) error {
|
||||
_, err := PrepareExec(PrepareExecOptions{Store: store, SessionID: "new", CallingSessionID: "parent"})
|
||||
return err
|
||||
}},
|
||||
{"spec implementation", func(store *Store) error {
|
||||
_, _, err := store.EnsureSpecImplementation(EnsureSpecImplementationInput{SpecID: "spec", SpecSourceSessionID: "parent", Prompt: "build it"})
|
||||
return err
|
||||
}},
|
||||
} {
|
||||
t.Run(create.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
createFinishedSession(t, root, "parent", "2026-06-01T00:00:00Z", "")
|
||||
other := NewStore(StoreOptions{RootDir: root, Now: fixedClock("2026-06-01T00:00:00Z")})
|
||||
before := strings.Join(sessionDirNames(t, root), ",")
|
||||
|
||||
release, locked, err := pruneStore(root).HoldExclusive("parent")
|
||||
if err != nil || !locked {
|
||||
t.Fatalf("take the parent's lease the way prune does: locked=%v, %v", locked, err)
|
||||
}
|
||||
err = create.do(other)
|
||||
release()
|
||||
if err == nil || !strings.Contains(err.Error(), "locked by zero sessions prune") {
|
||||
t.Errorf("%s from a parent prune holds: err = %v, want it refused", create.name, err)
|
||||
}
|
||||
if after := strings.Join(sessionDirNames(t, root), ","); after != before {
|
||||
t.Errorf("the refused %s created a session anyway: %s, was %s", create.name, after, before)
|
||||
}
|
||||
|
||||
if err := create.do(other); err != nil {
|
||||
t.Fatalf("%s once prune let go: %v", create.name, err)
|
||||
}
|
||||
report, err := pruneStore(root).Prune(PruneOptions{OlderThan: thirtyDays, DryRun: true})
|
||||
if err != nil {
|
||||
t.Fatalf("dry run: %v", err)
|
||||
}
|
||||
if reason := keptReason(report, "parent"); reason != PruneKeptOpen {
|
||||
t.Errorf("parent kept for %q, want %q: the %s's process holds it", reason, PruneKeptOpen, create.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Where a delete only takes effect once the last handle closes, the lease Prune
|
||||
// holds would keep the session directory from being empty, so it lets go first.
|
||||
func TestPruneLetsGoOfTheLeaseBeforeRemovingTheDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
createFinishedSession(t, root, "old", "2026-06-01T00:00:00Z", "")
|
||||
|
||||
var reached []string
|
||||
pruneRemoveDirSeam = func(id string, leaseHeld bool) {
|
||||
reached = append(reached, id)
|
||||
if leaseHeld {
|
||||
t.Errorf("%s: the directory is removed while prune still holds the lease", id)
|
||||
}
|
||||
}
|
||||
defer func() { pruneRemoveDirSeam = nil }()
|
||||
|
||||
report, err := pruneStore(root).Prune(PruneOptions{OlderThan: thirtyDays})
|
||||
if err != nil {
|
||||
t.Fatalf("Prune: %v", err)
|
||||
}
|
||||
if strings.Join(reached, ",") != "old" || strings.Join(pruneIDs(report.Removed), ",") != "old" || sessionDirExists(t, root, "old") {
|
||||
t.Fatalf("reached %v and removed %v, want old reached and removed", reached, pruneIDs(report.Removed))
|
||||
}
|
||||
}
|
||||
|
||||
// Loading a session to continue it is refused while prune holds it, instead of
|
||||
// read without the lease, and the exec path does not fall back to the raw log on
|
||||
// that refusal.
|
||||
func TestResumeIsRefusedWhilePruneHoldsTheSession(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
createFinishedSession(t, root, "old", "2026-06-01T00:00:00Z", "")
|
||||
other := NewStore(StoreOptions{RootDir: root})
|
||||
|
||||
release, locked, err := pruneStore(root).HoldExclusive("old")
|
||||
if err != nil || !locked {
|
||||
t.Fatalf("take the lease the way prune does: locked=%v, %v", locked, err)
|
||||
}
|
||||
_, _, readErr := other.ReadRehydratedEventsWithPresence("old")
|
||||
events, execErr := readExecContextEvents(other, "old")
|
||||
release()
|
||||
if !errors.Is(readErr, ErrPruning) {
|
||||
t.Errorf("rehydrated read while prune holds the session: err = %v, want ErrPruning", readErr)
|
||||
}
|
||||
if !errors.Is(execErr, ErrPruning) || events != nil {
|
||||
t.Errorf("exec context read while prune holds the session: %d events, err = %v, want it refused, not read from the raw log", len(events), execErr)
|
||||
}
|
||||
|
||||
// Once prune lets go, the read succeeds and holds the session.
|
||||
if _, err := other.ReadRehydratedEvents("old"); err != nil {
|
||||
t.Fatalf("read once prune let go: %v", err)
|
||||
}
|
||||
report, err := pruneStore(root).Prune(PruneOptions{OlderThan: thirtyDays, DryRun: true})
|
||||
if err != nil {
|
||||
t.Fatalf("dry run: %v", err)
|
||||
}
|
||||
if reason := keptReason(report, "old"); reason != PruneKeptOpen {
|
||||
t.Errorf("old kept for %q, want %q", reason, PruneKeptOpen)
|
||||
}
|
||||
}
|
||||
|
||||
// A lease that cannot be taken at all is not a free one: prune keeps the
|
||||
// session. That is why a process whose own hold fails the same way carries on
|
||||
// with the session instead of being refused. Prune cannot remove it either, and
|
||||
// refusing would stop resume and fork wherever locking does not work, for people
|
||||
// who never prune.
|
||||
func TestPruneKeepsASessionWhoseLeaseCannotBeChecked(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
createFinishedSession(t, root, "old", "2026-06-01T00:00:00Z", "")
|
||||
lease := filepath.Join(root, "old", leaseFileName)
|
||||
if err := os.Remove(lease); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("remove the lease file: %v", err)
|
||||
}
|
||||
// A directory where the lease file goes cannot be opened as one.
|
||||
if err := os.Mkdir(lease, 0o700); err != nil {
|
||||
t.Fatalf("SETUP INVALID: %v", err)
|
||||
}
|
||||
if _, locked, err := pruneStore(root).HoldExclusive("old"); err == nil || locked {
|
||||
t.Fatalf("SETUP INVALID: the lease could still be taken: locked=%v, %v", locked, err)
|
||||
}
|
||||
|
||||
for _, dryRun := range []bool{true, false} {
|
||||
report, err := pruneStore(root).Prune(PruneOptions{OlderThan: thirtyDays, DryRun: dryRun})
|
||||
if err != nil {
|
||||
t.Fatalf("Prune (dry run %v): %v", dryRun, err)
|
||||
}
|
||||
if reason := keptReason(report, "old"); !strings.HasPrefix(reason, "its lease could not be checked") || len(report.Removed) != 0 || len(report.Failed) != 0 {
|
||||
t.Fatalf("dry run %v: kept for %q, removed %v, failed %v, want it kept because its lease could not be checked", dryRun, reason, pruneIDs(report.Removed), pruneIDs(report.Failed))
|
||||
}
|
||||
}
|
||||
if !sessionDirExists(t, root, "old") {
|
||||
t.Fatal("a session whose lease could not be checked was removed")
|
||||
}
|
||||
|
||||
other := NewStore(StoreOptions{RootDir: root})
|
||||
if _, err := other.ReadRehydratedEvents("old"); err != nil {
|
||||
t.Fatalf("resume a session whose lease cannot be taken: %v", err)
|
||||
}
|
||||
if _, err := other.Fork("old", ForkInput{SessionID: "fork"}); err != nil {
|
||||
t.Fatalf("fork a session whose lease cannot be taken: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -233,16 +233,7 @@ func (store *Store) ReadRehydratedEvents(sessionID string) ([]Event, error) {
|
||||
// ReadRehydratedEventsWithPresence carries the underlying event-log presence
|
||||
// through compaction projection without changing ReadRehydratedEvents' existing
|
||||
// empty-on-missing contract.
|
||||
//
|
||||
// It is how a session is loaded to be continued (the TUI's resume, `exec
|
||||
// --resume` and --fork, ACP's session/load and session/resume), so it holds the
|
||||
// session open, and it fails with ErrPruning while prune holds it. Callers that
|
||||
// fall back to ReadEvents when rehydration fails must not fall back on that
|
||||
// error. See holdOrRefuse.
|
||||
func (store *Store) ReadRehydratedEventsWithPresence(sessionID string) ([]Event, bool, error) {
|
||||
if err := store.holdOrRefuse(sessionID); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
events, present, err := store.ReadEventsWithPresence(sessionID)
|
||||
if err != nil {
|
||||
return nil, present, err
|
||||
|
||||
@@ -233,10 +233,6 @@ type Store struct {
|
||||
// is accepted deliberately rather than risk an unsafe eviction.
|
||||
sessionLocks map[string]*sync.Mutex
|
||||
idCounter atomic.Uint64
|
||||
|
||||
// leases are the sessions this Store holds open. See Hold.
|
||||
leasesMu sync.Mutex
|
||||
leases map[string]*os.File
|
||||
}
|
||||
|
||||
var sessionIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$`)
|
||||
@@ -283,14 +279,6 @@ func (store *Store) Create(input CreateInput) (Metadata, error) {
|
||||
if input.Depth < 0 {
|
||||
return Metadata{}, fmt.Errorf("invalid zero session depth %d", input.Depth)
|
||||
}
|
||||
// A session created under a parent holds that parent, the way Fork and
|
||||
// CreateChild do, and is refused while prune holds or is removing it: exec
|
||||
// --calling-session-id and spec implementations create their children here.
|
||||
if parent := strings.TrimSpace(input.ParentSessionID); parent != "" {
|
||||
if err := store.holdParent(parent); err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
}
|
||||
|
||||
timestamp := store.timestamp()
|
||||
session := Metadata{
|
||||
@@ -344,7 +332,6 @@ func (store *Store) Create(input CreateInput) (Metadata, error) {
|
||||
if err := file.Close(); err != nil {
|
||||
return Metadata{}, fmt.Errorf("close zero session events file: %w", err)
|
||||
}
|
||||
store.Hold(sessionID)
|
||||
return session, nil
|
||||
}
|
||||
|
||||
@@ -442,9 +429,6 @@ func (store *Store) Fork(parentSessionID string, input ForkInput) (Metadata, err
|
||||
if !ValidSessionID(parentSessionID) {
|
||||
return Metadata{}, fmt.Errorf("invalid zero session id %q", parentSessionID)
|
||||
}
|
||||
if err := store.holdParent(parentSessionID); err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
parent, err := store.Get(parentSessionID)
|
||||
if err != nil {
|
||||
return Metadata{}, err
|
||||
@@ -913,14 +897,6 @@ func (store *Store) sessionLock(sessionID string) *sync.Mutex {
|
||||
// reverse order. The OS lock is best-effort: if it cannot be acquired (e.g. an
|
||||
// unsupported platform) the in-memory mutex still applies.
|
||||
func (store *Store) lockSession(sessionID string) (func(), error) {
|
||||
// A process that writes a session has it open. See Hold.
|
||||
store.Hold(sessionID)
|
||||
return store.lockSessionWithoutLease(sessionID)
|
||||
}
|
||||
|
||||
// lockSessionWithoutLease is lockSession for prune, which must not mark as open
|
||||
// the session it is about to remove.
|
||||
func (store *Store) lockSessionWithoutLease(sessionID string) (func(), error) {
|
||||
mu := store.sessionLock(sessionID)
|
||||
mu.Lock()
|
||||
release, err := store.acquireFileLock(sessionID)
|
||||
|
||||
@@ -229,9 +229,8 @@ func TestRunResumePassesTheParentModelThroughToTheChild(t *testing.T) {
|
||||
Prompt: "keep going",
|
||||
Resume: "child_task",
|
||||
}, TaskRunOptions{
|
||||
ParentSessionID: parent.SessionID,
|
||||
ParentModel: "claude-opus-4.1",
|
||||
ParentReasoningEffort: "high",
|
||||
ParentSessionID: parent.SessionID,
|
||||
ParentModel: "claude-opus-4.1",
|
||||
}); err != nil {
|
||||
t.Fatalf("Run(resume): %v", err)
|
||||
}
|
||||
@@ -247,9 +246,6 @@ func TestRunResumePassesTheParentModelThroughToTheChild(t *testing.T) {
|
||||
if model != "claude-opus-4.1" {
|
||||
t.Fatalf("the resumed child was launched with --model %q, want the parent's %q", model, "claude-opus-4.1")
|
||||
}
|
||||
if effort, ok := argValue(captured, "--reasoning-effort"); !ok || effort != "high" {
|
||||
t.Fatalf("the resumed child was launched with --reasoning-effort %q (present=%t), want the parent's", effort, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// THE FRESH CALL SITE NEEDS THE SAME GUARD, FOR THE SAME REASON.
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -63,16 +64,7 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any
|
||||
if err != nil {
|
||||
return errorResult("Error reading " + requestedPath + ": " + err.Error())
|
||||
}
|
||||
// Anchor both the read and the write on the granted write root: bytes and
|
||||
// identity come from the same descriptor-bound object, and the commit
|
||||
// below writes back through that same root, so a parent swapped for an
|
||||
// escaping symlink between validation and use cannot redirect either.
|
||||
root, rootedRelative, err := openScopedWriteRoot(tool.workspaceRoot, tool.scope, absolutePath)
|
||||
if err != nil {
|
||||
return errorResult("Error reading " + relativePath + ": " + err.Error())
|
||||
}
|
||||
defer root.Close()
|
||||
contentBytes, priorInfo, err := readRootedFile(root, rootedRelative)
|
||||
contentBytes, err := os.ReadFile(absolutePath)
|
||||
if err != nil {
|
||||
return errorResult("Error reading " + relativePath + ": " + err.Error())
|
||||
}
|
||||
@@ -88,6 +80,10 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any
|
||||
}
|
||||
}
|
||||
content := string(contentBytes)
|
||||
priorInfo, err := os.Stat(absolutePath)
|
||||
if err != nil {
|
||||
return errorResult("Error reading " + relativePath + ": " + err.Error())
|
||||
}
|
||||
occurrences := strings.Count(content, oldString)
|
||||
|
||||
// CRLF fallback: read_file normalizes \r\n → \n before presenting content to
|
||||
@@ -161,7 +157,7 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any
|
||||
if err := recheckScopedWriteTarget(tool.workspaceRoot, tool.scope, requestedPath); err != nil {
|
||||
return errorResult("Error writing " + relativePath + ": " + err.Error())
|
||||
}
|
||||
if err := commitRootedFileContents(root, absolutePath, rootedRelative, priorInfo, &content, updated); err != nil {
|
||||
if err := commitFileContents(absolutePath, priorInfo, &content, updated); err != nil {
|
||||
return errorResult("Error writing " + relativePath + ": " + err.Error())
|
||||
}
|
||||
modelKnownContent := updated
|
||||
@@ -175,7 +171,7 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any
|
||||
// compare against the current on-disk state, not the pre-edit version.
|
||||
newInfo := formatting.Info
|
||||
if newInfo == nil {
|
||||
newInfo, _ = root.Stat(rootedRelative)
|
||||
newInfo, _ = os.Stat(absolutePath)
|
||||
}
|
||||
if !finalContentKnown {
|
||||
options.FileTracker.Forget(absolutePath)
|
||||
|
||||
@@ -19,29 +19,22 @@ var fileWriteBeforeCommit func(path string)
|
||||
// the file descriptor directly.
|
||||
var fileWriteStat = func(file *os.File) (os.FileInfo, error) { return file.Stat() }
|
||||
|
||||
// commitRootedFileContents binds an overwrite to the file identity and bytes
|
||||
// that the caller observed, opening every component through an already-open
|
||||
// *os.Root descriptor rather than re-resolving the absolute path. Because the
|
||||
// ancestor directories are traversed relative to the root handle, a parent
|
||||
// swapped for a symlink that escapes the workspace between validation and this
|
||||
// call is refused by the kernel instead of redirecting the write.
|
||||
//
|
||||
// A create uses exclusive creation THROUGH THE ROOT. An overwrite opens the
|
||||
// commitFileContents binds an overwrite to the file identity and bytes that
|
||||
// the caller observed. A create uses exclusive creation. An overwrite opens the
|
||||
// observed object without truncation, verifies identity/content through that
|
||||
// handle, then truncates and writes the same handle. relativePath is the target
|
||||
// expressed relative to root; absolutePath is retained only for the test hook
|
||||
// and error reporting.
|
||||
// handle, then truncates and writes the same handle. A path replacement before
|
||||
// or during commit therefore fails instead of publishing stale rich evidence.
|
||||
//
|
||||
// expectedInfo nil means the caller observed a missing path. expectedContent
|
||||
// may be nil for an existing but unreadable file; that path may still be
|
||||
// overwritten, but callers must omit rich before/after evidence.
|
||||
func commitRootedFileContents(root *os.Root, absolutePath, relativePath string, expectedInfo os.FileInfo, expectedContent *string, content string) error {
|
||||
func commitFileContents(path string, expectedInfo os.FileInfo, expectedContent *string, content string) error {
|
||||
if fileWriteBeforeCommit != nil {
|
||||
fileWriteBeforeCommit(absolutePath)
|
||||
fileWriteBeforeCommit(path)
|
||||
}
|
||||
|
||||
if expectedInfo == nil {
|
||||
file, err := root.OpenFile(relativePath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
||||
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -50,14 +43,14 @@ func commitRootedFileContents(root *os.Root, absolutePath, relativePath string,
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
return writeAndVerifyRootedFileIdentity(root, relativePath, file, openedInfo, content, false)
|
||||
return writeAndVerifyFileIdentity(path, file, openedInfo, content, false)
|
||||
}
|
||||
|
||||
flags := os.O_WRONLY
|
||||
if expectedContent != nil {
|
||||
flags = os.O_RDWR
|
||||
}
|
||||
file, err := root.OpenFile(relativePath, flags, 0)
|
||||
file, err := os.OpenFile(path, flags, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -70,7 +63,7 @@ func commitRootedFileContents(root *os.Root, absolutePath, relativePath string,
|
||||
_ = file.Close()
|
||||
return errFileChangedDuringWrite
|
||||
}
|
||||
pathInfo, err := root.Stat(relativePath)
|
||||
pathInfo, err := os.Stat(path)
|
||||
if err != nil || !os.SameFile(openedInfo, pathInfo) {
|
||||
_ = file.Close()
|
||||
return errFileChangedDuringWrite
|
||||
@@ -86,10 +79,10 @@ func commitRootedFileContents(root *os.Root, absolutePath, relativePath string,
|
||||
return errFileChangedDuringWrite
|
||||
}
|
||||
}
|
||||
return writeAndVerifyRootedFileIdentity(root, relativePath, file, openedInfo, content, true)
|
||||
return writeAndVerifyFileIdentity(path, file, openedInfo, content, true)
|
||||
}
|
||||
|
||||
func writeAndVerifyRootedFileIdentity(root *os.Root, relativePath string, file *os.File, openedInfo os.FileInfo, content string, truncate bool) error {
|
||||
func writeAndVerifyFileIdentity(path string, file *os.File, openedInfo os.FileInfo, content string, truncate bool) error {
|
||||
if truncate {
|
||||
if err := file.Truncate(0); err != nil {
|
||||
_ = file.Close()
|
||||
@@ -107,7 +100,7 @@ func writeAndVerifyRootedFileIdentity(root *os.Root, relativePath string, file *
|
||||
if err := file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
pathInfo, err := root.Stat(relativePath)
|
||||
pathInfo, err := os.Stat(path)
|
||||
if err != nil || !os.SameFile(openedInfo, pathInfo) {
|
||||
return fmt.Errorf("%w: path identity changed", errFileChangedDuringWrite)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,8 @@ package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -17,74 +15,6 @@ func installFileWriteRace(t *testing.T, mutate func(string)) {
|
||||
t.Cleanup(func() { fileWriteBeforeCommit = prior })
|
||||
}
|
||||
|
||||
func installWriteRootBeforeOpen(t *testing.T, hook func(string)) {
|
||||
t.Helper()
|
||||
prior := writeRootBeforeOpen
|
||||
writeRootBeforeOpen = hook
|
||||
t.Cleanup(func() { writeRootBeforeOpen = prior })
|
||||
}
|
||||
|
||||
// A granted root swapped for a symlink between the pre-open identity stat and
|
||||
// os.OpenRoot must be refused: os.OpenRoot re-resolves the path, so without the
|
||||
// identity comparison the returned descriptor would be bound to the external
|
||||
// directory and every later mutation would escape the validated boundary. The
|
||||
// hook reproduces the exact check-to-use window deterministically.
|
||||
func TestOpenScopedWriteRootRejectsSubstitutedRoot(t *testing.T) {
|
||||
t.Run("accepts stable root", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
resolvedRoot, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handle, relative, err := openScopedWriteRoot(root, nil, filepath.Join(resolvedRoot, "created.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("stable root rejected: %v", err)
|
||||
}
|
||||
defer handle.Close()
|
||||
if relative != "created.txt" {
|
||||
t.Fatalf("relative = %q, want created.txt", relative)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects substituted root", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
resolvedRoot, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
originalRoot := resolvedRoot + "-original"
|
||||
t.Cleanup(func() { _ = os.RemoveAll(originalRoot) })
|
||||
installWriteRootBeforeOpen(t, func(path string) {
|
||||
if path != resolvedRoot {
|
||||
return
|
||||
}
|
||||
if err := os.Rename(resolvedRoot, originalRoot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, resolvedRoot); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
handle, _, err := openScopedWriteRoot(root, nil, filepath.Join(resolvedRoot, "created.txt"))
|
||||
if err == nil {
|
||||
_ = handle.Close()
|
||||
t.Fatal("openScopedWriteRoot accepted a root substituted between stat and open")
|
||||
}
|
||||
if !errors.Is(err, errWriteRootSubstituted) {
|
||||
t.Fatalf("error = %v, want errWriteRootSubstituted", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func installWritePreimageBeforeObserve(t *testing.T, hook func(string)) {
|
||||
t.Helper()
|
||||
prior := writePreimageBeforeObserve
|
||||
writePreimageBeforeObserve = hook
|
||||
t.Cleanup(func() { writePreimageBeforeObserve = prior })
|
||||
}
|
||||
|
||||
func installFileWriteStat(t *testing.T, stat func(*os.File) (os.FileInfo, error)) {
|
||||
t.Helper()
|
||||
prior := fileWriteStat
|
||||
@@ -157,39 +87,6 @@ func TestEditFileRefusesPreimageRace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A parent directory swapped for a symlink that escapes the workspace between
|
||||
// validation and the commit must be refused. The create is opened relative to
|
||||
// the granted root handle, so the kernel never follows the swapped link. This
|
||||
// is the regression that fails on the pre-migration code: there the unanchored
|
||||
// os.OpenFile(path, O_CREATE|O_EXCL) followed the symlink and created the file
|
||||
// outside the workspace.
|
||||
func TestWriteFileRefusesParentSymlinkSwapBeforeCreate(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
sub := filepath.Join(root, "sub")
|
||||
if err := os.Mkdir(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
installFileWriteRace(t, func(string) {
|
||||
if err := os.Rename(sub, filepath.Join(root, "sub-original")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, sub); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
result := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"path": "sub/created.txt", "content": "zero\n",
|
||||
})
|
||||
if result.Status == StatusOK {
|
||||
t.Fatalf("create through a swapped parent symlink must fail, got OK: %s", result.Output)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "created.txt")); err == nil {
|
||||
t.Fatal("create escaped the workspace through the swapped parent symlink")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverwriteDoesNotStatOpenedFileAfterFinalPreimageComparison(t *testing.T) {
|
||||
for name, run := range map[string]func(string) Result{
|
||||
"write overwrite": func(root string) Result {
|
||||
@@ -232,121 +129,3 @@ func TestOverwriteDoesNotStatOpenedFileAfterFinalPreimageComparison(t *testing.T
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A pathname re-resolution after the write root is open must not supply the
|
||||
// preimage. The root handle still names the directory that was opened; a
|
||||
// same-name directory, or a symlink planted at the old path, is a different
|
||||
// object. Observing that object through the absolute path makes the later
|
||||
// identity check disagree with the rooted open and refuse a write the rooted
|
||||
// preimage would have committed. The commit must land in the rooted object and
|
||||
// leave the pathname substitute untouched.
|
||||
func TestWriteFilePreimageIgnoresDissentingPathname(t *testing.T) {
|
||||
t.Run("same-name directory", func(t *testing.T) {
|
||||
assertRootedPreimageNotPathnameSubstitute(t, false)
|
||||
})
|
||||
t.Run("symlink permutation", func(t *testing.T) {
|
||||
assertRootedPreimageNotPathnameSubstitute(t, true)
|
||||
})
|
||||
}
|
||||
|
||||
func assertRootedPreimageNotPathnameSubstitute(t *testing.T, symlink bool) {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows kernel locks open directory handles held by *os.Root, preventing directory swap")
|
||||
}
|
||||
root := t.TempDir()
|
||||
resolved, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(resolved, "existing.txt"), []byte("original\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
moved := resolved + "-moved"
|
||||
decoyHold := resolved + "-decoy"
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll(moved)
|
||||
_ = os.RemoveAll(decoyHold)
|
||||
})
|
||||
|
||||
installWritePreimageBeforeObserve(t, func(absolutePath string) {
|
||||
if filepath.Base(absolutePath) != "existing.txt" {
|
||||
return
|
||||
}
|
||||
dir := filepath.Dir(absolutePath)
|
||||
if err := os.Rename(dir, moved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if symlink {
|
||||
if err := os.Mkdir(decoyHold, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(decoyHold, "existing.txt"), []byte("decoy\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(decoyHold, dir); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := os.Mkdir(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "existing.txt"), []byte("decoy\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
result := NewScopedWriteFileTool(resolved, nil).Run(context.Background(), map[string]any{
|
||||
"path": "existing.txt", "content": "zero\n", "overwrite": true,
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("rooted overwrite status = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
movedBytes, err := os.ReadFile(filepath.Join(moved, "existing.txt"))
|
||||
if err != nil || string(movedBytes) != "zero\n" {
|
||||
t.Fatalf("rooted object = %q, err=%v; a dissenting pathname must not redirect the commit", movedBytes, err)
|
||||
}
|
||||
decoyPath := filepath.Join(resolved, "existing.txt")
|
||||
if symlink {
|
||||
decoyPath = filepath.Join(decoyHold, "existing.txt")
|
||||
}
|
||||
decoyBytes, err := os.ReadFile(decoyPath)
|
||||
if err != nil || string(decoyBytes) != "decoy\n" {
|
||||
t.Fatalf("pathname substitute = %q, err=%v", decoyBytes, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Replacing the destination with a different file that carries the same bytes,
|
||||
// after the preimage and before the commit, must not be treated as the observed
|
||||
// object. On Windows, a FileInfo from os.Stat fills volume and file index only
|
||||
// when os.SameFile re-opens the path, so the byte comparison is the only check
|
||||
// left and it cannot see this substitution. The rooted preimage captures
|
||||
// identity at observation time, and the write must refuse.
|
||||
func TestWriteFileRefusesSameNameSubstitutionWithIdenticalBytes(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "existing.txt")
|
||||
const observed = "original\n"
|
||||
if err := os.WriteFile(target, []byte(observed), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
installFileWriteRace(t, func(path string) {
|
||||
preserved := path + ".preserved"
|
||||
if err := os.Rename(path, preserved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(observed), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
result := NewScopedWriteFileTool(root, nil).Run(context.Background(), map[string]any{
|
||||
"path": "existing.txt", "content": "REPLACED BY THE TOOL\n", "overwrite": true,
|
||||
})
|
||||
if result.Status != StatusError || !strings.Contains(result.Output, errFileChangedDuringWrite.Error()) {
|
||||
t.Fatalf("same-name substitution = %s: %s", result.Status, result.Output)
|
||||
}
|
||||
got, err := os.ReadFile(target)
|
||||
if err != nil || string(got) != observed {
|
||||
t.Fatalf("substituted object = %q, err=%v; the write must not land in an object the preimage did not observe", got, err)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package tools
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -37,17 +38,15 @@ var formatOnWriteTimeout = 10 * time.Second
|
||||
// formatOnWriteResult is the content after formatting, plus whether the
|
||||
// formatting that was supposed to happen actually did.
|
||||
//
|
||||
// A TIMEOUT IS NOT THE SAME KIND OF MISS AS THE OTHERS. The toggle being off,
|
||||
// an extension with no formatter, or a binary that is not installed are
|
||||
// standing facts about the environment. Those stay silent, because nothing is
|
||||
// wrong and saying so on every write would be noise. A deadline firing is
|
||||
// different: formatting was configured, available and expected, and the file
|
||||
// was written unformatted anyway, on a machine that was merely slow. Left
|
||||
// silent, the caller believes it wrote canonical style and finds out from a CI
|
||||
// format check it cannot see, which is the thing this feature exists to
|
||||
// prevent. A formatter binary that resolves inside a write root is also
|
||||
// reported: skipping it is a security decision, and a silent skip looks like
|
||||
// format-on-write simply stopped working.
|
||||
// A TIMEOUT IS NOT THE SAME KIND OF MISS AS THE OTHERS. Every other way this
|
||||
// falls back is a standing fact about the environment: the toggle is off, the
|
||||
// extension has no formatter, the binary is not installed. Those are silent on
|
||||
// purpose, because nothing is wrong and saying so on every write would be
|
||||
// noise. A deadline firing is different: formatting was configured, available
|
||||
// and expected, and the file was written unformatted anyway, on a machine that
|
||||
// was merely slow. Left silent, the caller believes it wrote canonical style
|
||||
// and finds out from a CI format check it cannot see, which is the thing this
|
||||
// feature exists to prevent.
|
||||
type formatOnWriteResult struct {
|
||||
Content string
|
||||
ContentKnown bool
|
||||
@@ -56,9 +55,6 @@ type formatOnWriteResult struct {
|
||||
// tell a slow gofmt from a slow prettier.
|
||||
Formatter string
|
||||
TimedOut bool
|
||||
// Skipped is set when a formatter was found but not run. Empty when
|
||||
// formatting was not applicable, ran, or failed for another recorded reason.
|
||||
Skipped string
|
||||
// RestoreFailed means the file on disk is not known to hold Content.
|
||||
//
|
||||
// These formatters edit in place, so one that is killed or fails partway
|
||||
@@ -72,60 +68,39 @@ type formatOnWriteResult struct {
|
||||
}
|
||||
|
||||
// notice is the line appended to the tool summary when formatting was expected
|
||||
// and did not happen (timeout, restore failure, or a refused workspace binary),
|
||||
// and empty in every other case.
|
||||
// and did not happen, and empty in every other case.
|
||||
func (result formatOnWriteResult) notice(relativePath string) string {
|
||||
if result.RestoreFailed {
|
||||
return "\n\nWARNING: " + relativePath + " may not hold what was written. " +
|
||||
result.Formatter + " was interrupted while rewriting it in place and the " +
|
||||
"content could not be written back. Re-read the file before trusting it."
|
||||
}
|
||||
if result.TimedOut {
|
||||
return "\n\nNote: " + relativePath + " was written but not formatted: " +
|
||||
result.Formatter + " did not finish within " + formatOnWriteTimeout.String() +
|
||||
". The file holds exactly what was written, so a project format check may still flag it."
|
||||
if !result.TimedOut {
|
||||
return ""
|
||||
}
|
||||
if result.Skipped != "" {
|
||||
return "\n\nNote: " + relativePath + " was written but not formatted: " + result.Skipped + "."
|
||||
}
|
||||
return ""
|
||||
return "\n\nNote: " + relativePath + " was written but not formatted: " +
|
||||
result.Formatter + " did not finish within " + formatOnWriteTimeout.String() +
|
||||
". The file holds exactly what was written, so a project format check may still flag it."
|
||||
}
|
||||
|
||||
// formatSkippedInsideWriteRoot is the formatOnWriteResult.Skipped value when
|
||||
// the resolved formatter binary lives under a granted write root.
|
||||
const formatSkippedInsideWriteRoot = "skipped, binary resolves inside the workspace"
|
||||
|
||||
// formatterCommands maps a file extension to the formatter argv; the file path
|
||||
// is appended as the final argument. Only in-place, community-standard
|
||||
// formatters — a missing binary silently skips formatting.
|
||||
//
|
||||
// PRETTIER IS INVOKED WITH --no-config ON PURPOSE. Prettier resolves config
|
||||
// relative to the target file. Its JavaScript configs (.prettierrc.js,
|
||||
// .prettierrc.cjs, prettier.config.js) are MODULES: loading one runs arbitrary
|
||||
// code from the workspace, and a malicious dependency pulled by a plugin runs
|
||||
// too. A declarative config can still name a plugin by path, so it is not a
|
||||
// safe subset. That code would execute with Zero's full privileges, outside the
|
||||
// Landlock/seccomp confinement applied to shell commands.
|
||||
// Prettier therefore runs on its built-in defaults, and project
|
||||
// configuration is not honored: .prettierrc, .prettierrc.json, .prettierrc.yaml,
|
||||
// prettier.config.js, the prettier key in package.json, and .editorconfig are all
|
||||
// ignored by --no-config. The remaining formatters keep honoring their own
|
||||
// non-executable project config.
|
||||
// is appended as the final argument. Only in-place, config-respecting,
|
||||
// community-standard formatters — a missing binary silently skips formatting.
|
||||
var formatterCommands = map[string][]string{
|
||||
".go": {"gofmt", "-w"},
|
||||
".rs": {"rustfmt"},
|
||||
".py": {"ruff", "format", "--quiet"},
|
||||
".ts": {"prettier", "--log-level", "silent", "--no-config", "--write"},
|
||||
".tsx": {"prettier", "--log-level", "silent", "--no-config", "--write"},
|
||||
".js": {"prettier", "--log-level", "silent", "--no-config", "--write"},
|
||||
".jsx": {"prettier", "--log-level", "silent", "--no-config", "--write"},
|
||||
".json": {"prettier", "--log-level", "silent", "--no-config", "--write"},
|
||||
".css": {"prettier", "--log-level", "silent", "--no-config", "--write"},
|
||||
".scss": {"prettier", "--log-level", "silent", "--no-config", "--write"},
|
||||
".html": {"prettier", "--log-level", "silent", "--no-config", "--write"},
|
||||
".md": {"prettier", "--log-level", "silent", "--no-config", "--write"},
|
||||
".yaml": {"prettier", "--log-level", "silent", "--no-config", "--write"},
|
||||
".yml": {"prettier", "--log-level", "silent", "--no-config", "--write"},
|
||||
".ts": {"prettier", "--log-level", "silent", "--write"},
|
||||
".tsx": {"prettier", "--log-level", "silent", "--write"},
|
||||
".js": {"prettier", "--log-level", "silent", "--write"},
|
||||
".jsx": {"prettier", "--log-level", "silent", "--write"},
|
||||
".json": {"prettier", "--log-level", "silent", "--write"},
|
||||
".css": {"prettier", "--log-level", "silent", "--write"},
|
||||
".scss": {"prettier", "--log-level", "silent", "--write"},
|
||||
".html": {"prettier", "--log-level", "silent", "--write"},
|
||||
".md": {"prettier", "--log-level", "silent", "--write"},
|
||||
".yaml": {"prettier", "--log-level", "silent", "--write"},
|
||||
".yml": {"prettier", "--log-level", "silent", "--write"},
|
||||
".zig": {"zig", "fmt"},
|
||||
".dart": {"dart", "format"},
|
||||
".tf": {"terraform", "fmt"},
|
||||
@@ -152,11 +127,6 @@ var runFormatOnWriteCommand = func(ctx context.Context, binaryPath string, argum
|
||||
formatter := exec.CommandContext(ctx, binaryPath, arguments...)
|
||||
formatter.Dir = directory
|
||||
formatter.Stdin = strings.NewReader("")
|
||||
// The formatter may evaluate project files (plugins, configs) as code. Do
|
||||
// not hand it Zero's credential-bearing environment: scrub the same keys the
|
||||
// sandbox strips from shell commands while preserving PATH, HOME, and the
|
||||
// platform variables a formatter needs to start.
|
||||
formatter.Env = sandbox.ScrubSensitiveEnv(os.Environ())
|
||||
return formatter.Run()
|
||||
}
|
||||
|
||||
@@ -190,17 +160,7 @@ func maybeFormatWrittenFileScoped(ctx context.Context, workspaceRoot string, sco
|
||||
if err != nil {
|
||||
return unformatted
|
||||
}
|
||||
// A binary that resolves inside a write root is repository-controlled: npm
|
||||
// run puts node_modules/.bin on PATH, so a cloned project can shadow
|
||||
// prettier (or gofmt) with its own executable and have it run with Zero's
|
||||
// privileges. Skipping formatting is the safe fallback; running an
|
||||
// untrusted formatter is never worth canonical bytes. The skip is recorded
|
||||
// on the result: a silent return looks like format-on-write stopped working.
|
||||
if roots, rootsErr := scopedRoots(workspaceRoot, scope); rootsErr == nil && formatterBinaryInWriteRoots(binaryPath, roots) {
|
||||
unformatted.Skipped = formatSkippedInsideWriteRoot
|
||||
return unformatted
|
||||
}
|
||||
root, relativePath, err := openScopedWriteRoot(workspaceRoot, scope, absolutePath)
|
||||
root, relativePath, err := openFormattedFileRoot(workspaceRoot, scope, absolutePath)
|
||||
if err != nil {
|
||||
unformatted.ContentKnown = false
|
||||
return unformatted
|
||||
@@ -247,37 +207,43 @@ func restoreFormattedFile(root *os.Root, relativePath string, content string, mo
|
||||
return file.Close()
|
||||
}
|
||||
|
||||
// formatterBinaryInWriteRoots reports whether the resolved formatter binary
|
||||
// lives under one of the configured write roots (the workspace or an /add-dir
|
||||
// root). Both sides are symlink-resolved before comparison so an aliased or
|
||||
// linked path cannot hide a workspace-planted binary. A binary that cannot be
|
||||
// resolved is treated as untrusted, because trust cannot be established.
|
||||
func formatterBinaryInWriteRoots(binaryPath string, roots []string) bool {
|
||||
resolvedBinary, err := filepath.Abs(binaryPath)
|
||||
// openFormattedFileRoot opens the write root before the formatter runs and
|
||||
// computes the target relative to that descriptor-bound root. Atomic in-root
|
||||
// replacement remains valid; a formatter that swaps the target to an escaping
|
||||
// symlink is rejected when readFormattedFile opens it through the root.
|
||||
func openFormattedFileRoot(workspaceRoot string, scope PathScope, absolutePath string) (*os.Root, string, error) {
|
||||
roots, err := scopedRoots(workspaceRoot, scope)
|
||||
if err != nil {
|
||||
return false
|
||||
return nil, "", err
|
||||
}
|
||||
if evaluatedBinary, err := filepath.EvalSymlinks(resolvedBinary); err == nil {
|
||||
resolvedBinary = evaluatedBinary
|
||||
}
|
||||
for _, root := range roots {
|
||||
resolvedRoot, err := filepath.Abs(root)
|
||||
var firstErr error
|
||||
for _, configuredRoot := range roots {
|
||||
resolvedRoot, err := filepath.Abs(configuredRoot)
|
||||
if err == nil {
|
||||
resolvedRoot, err = filepath.EvalSymlinks(resolvedRoot)
|
||||
}
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if evaluated, err := filepath.EvalSymlinks(resolvedRoot); err == nil {
|
||||
resolvedRoot = evaluated
|
||||
}
|
||||
relative, err := filepath.Rel(resolvedRoot, resolvedBinary)
|
||||
if err != nil {
|
||||
candidate := sandbox.NormalizePrefixForRoot(absolutePath, resolvedRoot)
|
||||
relativePath, err := filepath.Rel(resolvedRoot, candidate)
|
||||
if err != nil || relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) || filepath.IsAbs(relativePath) {
|
||||
continue
|
||||
}
|
||||
if relative == "." {
|
||||
return true
|
||||
}
|
||||
if relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative) {
|
||||
return true
|
||||
root, err := os.OpenRoot(resolvedRoot)
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
return root, relativePath, nil
|
||||
}
|
||||
return false
|
||||
if firstErr != nil {
|
||||
return nil, "", firstErr
|
||||
}
|
||||
return nil, "", fmt.Errorf("%s must stay inside the configured write roots", absolutePath)
|
||||
}
|
||||
@@ -531,125 +531,3 @@ func TestEditFileAcceptsInRootAtomicFormatterReplacement(t *testing.T) {
|
||||
t.Fatalf("atomic formatter tracker = %#v, tracked=%t", version, tracked)
|
||||
}
|
||||
}
|
||||
|
||||
// Prettier's JavaScript configs are modules, so loading one is arbitrary code
|
||||
// execution from the workspace. Every prettier command must therefore disable
|
||||
// config resolution; this pins that, because a future entry copied without the
|
||||
// flag would silently reopen the hole.
|
||||
func TestFormatOnWritePrettierDisablesProjectConfig(t *testing.T) {
|
||||
for extension, command := range formatterCommands {
|
||||
if command[0] != "prettier" {
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for _, argument := range command[1:] {
|
||||
if argument == "--no-config" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("prettier command for %s is %#v; it must pass --no-config so a project .prettierrc.js is never evaluated", extension, command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A formatter that resolves inside the workspace is repository-controlled. npm
|
||||
// run puts node_modules/.bin on PATH, so a cloned project can shadow the real
|
||||
// binary; it must be refused rather than executed with Zero's privileges.
|
||||
func TestFormatOnWriteRejectsFormatterResolvedInsideWorkspace(t *testing.T) {
|
||||
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
|
||||
root := t.TempDir()
|
||||
marker := filepath.Join(root, "formatter-ran")
|
||||
binaryName := "plantedfmt" + formatterScriptExtension()
|
||||
if err := os.WriteFile(filepath.Join(root, binaryName), []byte(plantingFormatterScript(marker)), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", root+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
registerFormatterCommand(t, ".plantedfmt", binaryName)
|
||||
|
||||
target := filepath.Join(root, "subject.plantedfmt")
|
||||
const written = "written but not formatted\n"
|
||||
if err := os.WriteFile(target, []byte(written), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
formatting := maybeFormatWrittenFile(context.Background(), target, written)
|
||||
if formatting.Content != written || !formatting.ContentKnown {
|
||||
t.Fatalf("workspace-planted formatter result = %q, known=%t", formatting.Content, formatting.ContentKnown)
|
||||
}
|
||||
if formatting.Skipped != formatSkippedInsideWriteRoot {
|
||||
t.Fatalf("workspace-planted formatter skip = %q, want %q", formatting.Skipped, formatSkippedInsideWriteRoot)
|
||||
}
|
||||
notice := formatting.notice("subject.plantedfmt")
|
||||
if !strings.Contains(notice, formatSkippedInsideWriteRoot) {
|
||||
t.Fatalf("workspace-planted formatter notice = %q, want the skip reason", notice)
|
||||
}
|
||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||
t.Fatal("a formatter binary resolved inside the workspace was executed")
|
||||
}
|
||||
}
|
||||
|
||||
// The formatter can evaluate project code, so it must not inherit credentials.
|
||||
// PATH and ordinary variables survive; the provider keys the sandbox scrubs do
|
||||
// not.
|
||||
func TestFormatOnWriteScrubsCredentialsFromFormatterEnvironment(t *testing.T) {
|
||||
marker := filepath.Join(t.TempDir(), "envdump")
|
||||
installFakeFormatter(t, ".envfmt", "envfmt", envDumpFormatterScript(marker))
|
||||
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
|
||||
t.Setenv("OPENAI_API_KEY", "sk-super-secret-value")
|
||||
t.Setenv("ZERO_FORMAT_TEST_CONTROL", "present")
|
||||
|
||||
target := filepath.Join(t.TempDir(), "subject.envfmt")
|
||||
const written = "x\n"
|
||||
if err := os.WriteFile(target, []byte(written), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
formatting := maybeFormatWrittenFile(context.Background(), target, written)
|
||||
if formatting.RestoreFailed {
|
||||
t.Fatal("formatter failed unexpectedly")
|
||||
}
|
||||
|
||||
dumped, err := os.ReadFile(marker)
|
||||
if err != nil {
|
||||
t.Fatalf("formatter did not run: %v", err)
|
||||
}
|
||||
if strings.Contains(string(dumped), "sk-super-secret-value") {
|
||||
t.Fatal("formatter inherited a credential from the environment")
|
||||
}
|
||||
if !strings.Contains(string(dumped), "ZERO_FORMAT_TEST_CONTROL=present") {
|
||||
t.Fatal("formatter env dump did not contain the control variable, so the formatter may not have run")
|
||||
}
|
||||
}
|
||||
|
||||
// plantingFormatterScript leaves a marker when executed, so a test can prove
|
||||
// the refusal happened before the binary ran.
|
||||
func plantingFormatterScript(marker string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "@echo off\r\necho ran> \"" + marker + "\"\r\nexit /b 0\r\n"
|
||||
}
|
||||
return "#!/bin/sh\necho ran > \"" + marker + "\"\nexit 0\n"
|
||||
}
|
||||
|
||||
// envDumpFormatterScript writes its environment to marker.
|
||||
func envDumpFormatterScript(marker string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "@echo off\r\nset > \"" + marker + "\"\r\nexit /b 0\r\n"
|
||||
}
|
||||
return "#!/bin/sh\nenv > \"" + marker + "\"\nexit 0\n"
|
||||
}
|
||||
|
||||
// registerFormatterCommand registers an already-on-PATH binary for extension
|
||||
// for the test's duration.
|
||||
func registerFormatterCommand(t *testing.T, extension, binaryName string) {
|
||||
t.Helper()
|
||||
previous, existed := formatterCommands[extension]
|
||||
formatterCommands[extension] = []string{binaryName}
|
||||
t.Cleanup(func() {
|
||||
if existed {
|
||||
formatterCommands[extension] = previous
|
||||
return
|
||||
}
|
||||
delete(formatterCommands, extension)
|
||||
})
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// looksLikePosixAbsolutePath reports whether p has the shape of an absolute
|
||||
// POSIX path: a single leading "/". Windows treats both "/" and "\" as
|
||||
// separators, so two leading separators in any combination (//server/share,
|
||||
// /\server/share, /\?\C:...) are UNC or device-style paths, and a drive-letter
|
||||
// path is Windows-absolute, so none of them is a POSIX path the model
|
||||
// hallucinated. Shape-only: it never consults GOOS or the filesystem.
|
||||
func looksLikePosixAbsolutePath(p string) bool {
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
return false
|
||||
}
|
||||
return len(p) < 2 || (p[1] != '/' && p[1] != '\\')
|
||||
}
|
||||
|
||||
// annotateMissingPosixPathError appends a Windows-only hint to a read-path
|
||||
// resolution error. On Windows a leading-slash POSIX path is not absolute
|
||||
// (filepath.IsAbs is false without a volume name), so it is joined under the
|
||||
// workspace root and a miss surfaces as a raw OS-level error; the hint names
|
||||
// the host OS and the workspace root so the model stops retrying the same
|
||||
// shape. The error is wrapped with %w so errors.Is/errors.As keep working.
|
||||
//
|
||||
// It returns err unchanged unless the host is Windows, requestedPath looks
|
||||
// like an absolute POSIX path, and err reports a missing path. It never
|
||||
// changes which path is resolved or opened.
|
||||
func annotateMissingPosixPathError(goos string, workspaceRoot string, requestedPath string, err error) error {
|
||||
if err == nil || goos != "windows" || !looksLikePosixAbsolutePath(requestedPath) || !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
// Manual quotes rather than %q: %q escapes the backslashes in a Windows
|
||||
// workspace root, which makes the suggestion harder to use verbatim.
|
||||
hint := "[zero] path hint: this host is Windows, so the POSIX path was resolved under the workspace root \"" +
|
||||
workspaceRoot + "\" and does not exist there.\n" +
|
||||
"Suggestion: use a workspace-relative path such as \"internal/tools/workspace.go\", or a Windows absolute path under that workspace root."
|
||||
return fmt.Errorf("%w\n%s", err, hint)
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLooksLikePosixAbsolutePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{"/home/user/repo/notes.txt", true},
|
||||
{"/tmp/notes.txt", true},
|
||||
{"/", true},
|
||||
{"/home/../etc/passwd", true},
|
||||
{"//server/share/notes.txt", false},
|
||||
{"///x", false},
|
||||
{`/\server\share\notes.txt`, false},
|
||||
{`/\?\C:\notes.txt`, false},
|
||||
{`C:/Users/me/notes.txt`, false},
|
||||
{`C:\Users\me\notes.txt`, false},
|
||||
{`\home\user\notes.txt`, false},
|
||||
{"home/user/notes.txt", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if got := looksLikePosixAbsolutePath(test.path); got != test.want {
|
||||
t.Errorf("looksLikePosixAbsolutePath(%q) = %v, want %v", test.path, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnotateMissingPosixPathError(t *testing.T) {
|
||||
const root = `C:\Users\me\repo`
|
||||
missing := &os.PathError{Op: "lstat", Path: `C:\Users\me\repo\home`, Err: fs.ErrNotExist}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
goos string
|
||||
requested string
|
||||
err error
|
||||
wantSameErr bool
|
||||
}{
|
||||
{
|
||||
name: "windows posix miss gets the hint",
|
||||
goos: "windows",
|
||||
requested: "/home/user/repo/notes.txt",
|
||||
err: missing,
|
||||
wantSameErr: false,
|
||||
},
|
||||
{
|
||||
name: "windows root-like miss gets the hint",
|
||||
goos: "windows",
|
||||
requested: "/tmp/notes.txt",
|
||||
err: missing,
|
||||
wantSameErr: false,
|
||||
},
|
||||
{
|
||||
name: "linux leaves the error unchanged",
|
||||
goos: "linux",
|
||||
requested: "/home/user/repo/notes.txt",
|
||||
err: missing,
|
||||
wantSameErr: true,
|
||||
},
|
||||
{
|
||||
name: "darwin leaves the error unchanged",
|
||||
goos: "darwin",
|
||||
requested: "/home/user/repo/notes.txt",
|
||||
err: missing,
|
||||
wantSameErr: true,
|
||||
},
|
||||
{
|
||||
name: "windows compact path is not a posix path",
|
||||
goos: "windows",
|
||||
requested: `C:\Users\me\repo\notes.txt`,
|
||||
err: missing,
|
||||
wantSameErr: true,
|
||||
},
|
||||
{
|
||||
name: "windows unc path is not a posix path",
|
||||
goos: "windows",
|
||||
requested: `//server/share/notes.txt`,
|
||||
err: missing,
|
||||
wantSameErr: true,
|
||||
},
|
||||
{
|
||||
name: "windows mixed-separator unc path is not a posix path",
|
||||
goos: "windows",
|
||||
requested: `/\server\share/notes.txt`,
|
||||
err: missing,
|
||||
wantSameErr: true,
|
||||
},
|
||||
{
|
||||
name: "windows device path is not a posix path",
|
||||
goos: "windows",
|
||||
requested: `/\?\C:\notes.txt`,
|
||||
err: missing,
|
||||
wantSameErr: true,
|
||||
},
|
||||
{
|
||||
name: "windows relative path is not a posix path",
|
||||
goos: "windows",
|
||||
requested: "notes.txt",
|
||||
err: missing,
|
||||
wantSameErr: true,
|
||||
},
|
||||
{
|
||||
name: "windows non-missing error is not hinted",
|
||||
goos: "windows",
|
||||
requested: "/home/user/repo/notes.txt",
|
||||
err: &os.PathError{Op: "lstat", Path: "/home", Err: fs.ErrPermission},
|
||||
wantSameErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := annotateMissingPosixPathError(test.goos, root, test.requested, test.err)
|
||||
if test.wantSameErr {
|
||||
if got != test.err {
|
||||
t.Fatalf("expected the original error to pass through, got %v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == test.err {
|
||||
t.Fatal("expected a wrapped error, got the original")
|
||||
}
|
||||
message := got.Error()
|
||||
for _, want := range []string{
|
||||
test.err.Error(),
|
||||
"[zero] path hint:",
|
||||
"Windows",
|
||||
root,
|
||||
"workspace-relative",
|
||||
} {
|
||||
if !strings.Contains(message, want) {
|
||||
t.Fatalf("expected %q in %q", want, message)
|
||||
}
|
||||
}
|
||||
if !errors.Is(got, fs.ErrNotExist) {
|
||||
t.Fatalf("expected wrapped error to keep fs.ErrNotExist, got %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if got := annotateMissingPosixPathError("windows", root, "/home/x", nil); got != nil {
|
||||
t.Fatalf("expected nil error to pass through, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// posixWiringRequest builds a POSIX-shaped request that resolves only against
|
||||
// the test-owned tree under root on the host running the test. On Windows a
|
||||
// leading-slash request is not absolute and is joined under root, which is the
|
||||
// production behavior under test. On Unix it is already absolute, so root is
|
||||
// prepended explicitly to keep the test hermetic; a fixed "/home/..." pathname
|
||||
// would read host state the test does not own.
|
||||
func posixWiringRequest(root string, relative string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "/" + relative
|
||||
}
|
||||
return filepath.ToSlash(root) + "/" + relative
|
||||
}
|
||||
|
||||
func TestResolveScopedReadPathForGOOSAnnotatesWindowsPosixMiss(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
const existingRelative = "home/zero-hint-real/real.txt"
|
||||
writeTestFile(t, filepath.Join(root, filepath.FromSlash(existingRelative)), "real")
|
||||
|
||||
// The resolved relative path proves the resolution stayed inside the
|
||||
// test-owned tree rather than matching the hint text only.
|
||||
_, relative, err := resolveScopedReadPathForGOOS("windows", root, nil, posixWiringRequest(root, existingRelative))
|
||||
if err != nil {
|
||||
t.Fatalf("expected the POSIX-shaped workspace path to resolve, got %v", err)
|
||||
}
|
||||
if relative != existingRelative {
|
||||
t.Fatalf("resolved relative path = %q, want %q", relative, existingRelative)
|
||||
}
|
||||
|
||||
const missingRelative = "home/zero-hint-missing/missing.txt"
|
||||
_, _, err = resolveScopedReadPathForGOOS("windows", root, nil, posixWiringRequest(root, missingRelative))
|
||||
if err == nil {
|
||||
t.Fatal("expected a resolution error for a missing path")
|
||||
}
|
||||
message := err.Error()
|
||||
for _, want := range []string{"[zero] path hint:", "Windows", root} {
|
||||
if !strings.Contains(message, want) {
|
||||
t.Fatalf("expected %q in %q", want, message)
|
||||
}
|
||||
}
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("expected missing-path error to keep fs.ErrNotExist, got %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := resolveScopedReadPathForGOOS("linux", root, nil, posixWiringRequest(root, missingRelative)); err == nil || strings.Contains(err.Error(), "[zero] path hint:") {
|
||||
t.Fatalf("linux must not get the windows hint, got %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := resolveScopedReadPathForGOOS("windows", root, nil, "missing.txt"); err == nil || strings.Contains(err.Error(), "[zero] path hint:") {
|
||||
t.Fatalf("a relative path must not get the posix hint, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadFileWindowsPosixPathHint is the end-to-end check on the platform the
|
||||
// hint exists for: read_file must surface the guidance when a POSIX path is
|
||||
// joined under the workspace root and misses.
|
||||
func TestReadFileWindowsPosixPathHint(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skipf("windows-only path resolution; host is %s", runtime.GOOS)
|
||||
}
|
||||
root := t.TempDir()
|
||||
|
||||
result := NewReadFileTool(root).Run(context.Background(), map[string]any{
|
||||
"path": "/home/zero-hint-missing/missing.txt",
|
||||
})
|
||||
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("expected an error result, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "[zero] path hint:") || !strings.Contains(result.Output, "Windows") {
|
||||
t.Fatalf("expected the windows path hint, got %q", result.Output)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -255,13 +254,6 @@ func scopedReadRoots(workspaceRoot string, scope PathScope) ([]string, error) {
|
||||
}
|
||||
|
||||
func resolveScopedReadPath(workspaceRoot string, scope PathScope, requestedPath string) (string, string, error) {
|
||||
return resolveScopedReadPathForGOOS(runtimeGOOS(), workspaceRoot, scope, requestedPath)
|
||||
}
|
||||
|
||||
// resolveScopedReadPathForGOOS is resolveScopedReadPath with the host OS
|
||||
// injected, so the Windows-only missing-POSIX-path hint can be checked for
|
||||
// every platform from any one of them (the same seam as detectShellRuntime).
|
||||
func resolveScopedReadPathForGOOS(goos string, workspaceRoot string, scope PathScope, requestedPath string) (string, string, error) {
|
||||
// Spill files (truncated tool output saved under the per-uid temp dir) are
|
||||
// readable regardless of scope: the truncation notice tells the model to
|
||||
// read_file/grep them, which must actually work. resolveSpillReadPath
|
||||
@@ -271,15 +263,7 @@ func resolveScopedReadPathForGOOS(goos string, workspaceRoot string, scope PathS
|
||||
return spillPath, spillPath, nil
|
||||
}
|
||||
if requestedPath == "" || !filepath.IsAbs(requestedPath) || scope == nil {
|
||||
absolute, relative, err := resolveWorkspacePath(workspaceRoot, requestedPath)
|
||||
if err != nil {
|
||||
// The hint only belongs on this branch: the scoped loop below runs
|
||||
// only when filepath.IsAbs(requestedPath) holds, which a
|
||||
// POSIX-looking path never does on Windows. Confinement errors are
|
||||
// not missing-path errors, so the annotator leaves them unchanged.
|
||||
err = annotateMissingPosixPathError(goos, workspaceRoot, requestedPath, err)
|
||||
}
|
||||
return absolute, relative, err
|
||||
return resolveWorkspacePath(workspaceRoot, requestedPath)
|
||||
}
|
||||
roots, err := scopedReadRoots(workspaceRoot, scope)
|
||||
if err != nil {
|
||||
@@ -415,92 +399,3 @@ func recheckScopedWriteTarget(workspaceRoot string, scope PathScope, requestedPa
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// errWriteRootSubstituted reports that the granted root directory changed
|
||||
// identity between the pre-open stat and the descriptor open, so the handle may
|
||||
// point outside the validated boundary. The operation fails closed.
|
||||
var errWriteRootSubstituted = errors.New("write root changed identity between validation and open")
|
||||
|
||||
// writeRootBeforeOpen is a deterministic test hook. Production leaves it nil;
|
||||
// tests use it to substitute the root path after its identity is captured but
|
||||
// before os.OpenRoot resolves it, exercising the same check-to-use window a
|
||||
// concurrent attacker would race.
|
||||
var writeRootBeforeOpen func(path string)
|
||||
|
||||
// openScopedWriteRoot opens the granted write root that contains absolutePath
|
||||
// and returns a descriptor-bound handle plus the path relative to it. The
|
||||
// caller closes the handle.
|
||||
//
|
||||
// absolutePath is expected to be symlink-resolved, as resolveScopedPath and
|
||||
// resolveScopedTargetPath return it. Each configured root is resolved before
|
||||
// comparison so a workspace that legitimately sits under a platform alias
|
||||
// (macOS /var -> /private/var, Windows 8.3 short names) still matches. Every
|
||||
// mutation is then performed relative to the returned handle, so no component
|
||||
// above the target can be swapped for an escaping link between the pathname
|
||||
// check and the open.
|
||||
func openScopedWriteRoot(workspaceRoot string, scope PathScope, absolutePath string) (*os.Root, string, error) {
|
||||
roots, err := scopedRoots(workspaceRoot, scope)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
var firstErr error
|
||||
for _, configuredRoot := range roots {
|
||||
resolvedRoot, err := filepath.Abs(configuredRoot)
|
||||
if err == nil {
|
||||
resolvedRoot, err = filepath.EvalSymlinks(resolvedRoot)
|
||||
}
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Capture the root directory identity BEFORE opening the handle.
|
||||
// os.OpenRoot resolves every component again, so replacing the root path
|
||||
// between this stat and the open (a symlink or directory swap) would bind
|
||||
// the returned descriptor to a different, possibly external directory.
|
||||
// Comparing identities after the open closes that check-to-use window.
|
||||
expectedStat, err := os.Stat(resolvedRoot)
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
candidate := sandbox.NormalizePrefixForRoot(absolutePath, resolvedRoot)
|
||||
relativePath, err := filepath.Rel(resolvedRoot, candidate)
|
||||
if err != nil || relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) || filepath.IsAbs(relativePath) {
|
||||
continue
|
||||
}
|
||||
if writeRootBeforeOpen != nil {
|
||||
writeRootBeforeOpen(resolvedRoot)
|
||||
}
|
||||
root, err := os.OpenRoot(resolvedRoot)
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
actualStat, err := root.Stat(".")
|
||||
if err != nil {
|
||||
_ = root.Close()
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !os.SameFile(expectedStat, actualStat) {
|
||||
_ = root.Close()
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("%w: %s", errWriteRootSubstituted, resolvedRoot)
|
||||
}
|
||||
continue
|
||||
}
|
||||
return root, relativePath, nil
|
||||
}
|
||||
if firstErr != nil {
|
||||
return nil, "", firstErr
|
||||
}
|
||||
return nil, "", fmt.Errorf("%s must stay inside the configured write roots", absolutePath)
|
||||
}
|
||||
@@ -8,20 +8,11 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// writePreimageBeforeObserve is a deterministic test hook. Production leaves
|
||||
// it nil. Tests substitute the pathname after the write root is open and
|
||||
// before existence or bytes are read, so a dissenting absolute-path resolution
|
||||
// cannot be mistaken for the rooted object.
|
||||
var writePreimageBeforeObserve func(absolutePath string)
|
||||
|
||||
type writeFileTool struct {
|
||||
baseTool
|
||||
workspaceRoot string
|
||||
scope PathScope
|
||||
// readRooted reads an existing file through the granted write root. Tests
|
||||
// replace it to simulate an unreadable preimage; production uses
|
||||
// readRootedFile so bytes and identity come from the same descriptor.
|
||||
readRooted func(*os.Root, string) ([]byte, os.FileInfo, error)
|
||||
readFile func(string) ([]byte, error)
|
||||
}
|
||||
|
||||
func NewScopedWriteFileTool(workspaceRoot string, scope PathScope) Tool {
|
||||
@@ -44,7 +35,7 @@ func NewScopedWriteFileTool(workspaceRoot string, scope PathScope) Tool {
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
scope: scope,
|
||||
readRooted: readRootedFile,
|
||||
readFile: os.ReadFile,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,25 +61,10 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an
|
||||
if err != nil {
|
||||
return errorResult("Error writing file " + requestedPath + ": " + err.Error())
|
||||
}
|
||||
// Anchor observation and mutation on the granted write root. The pathname
|
||||
// checks above say what was REQUESTED; this handle enforces what actually
|
||||
// happens, so a parent directory swapped for an escaping symlink after
|
||||
// validation cannot redirect the create or overwrite. Existence and prior
|
||||
// bytes must come from the same handle: os.Stat on the absolute path stores
|
||||
// a pathname and, on Windows, fills volume and file index only when
|
||||
// os.SameFile re-opens that path, so a same-name substitution compares equal.
|
||||
root, rootedRelative, err := openScopedWriteRoot(tool.workspaceRoot, tool.scope, absolutePath)
|
||||
if err != nil {
|
||||
return errorResult("Error writing file " + relativePath + ": " + err.Error())
|
||||
}
|
||||
defer root.Close()
|
||||
if writePreimageBeforeObserve != nil {
|
||||
writePreimageBeforeObserve(absolutePath)
|
||||
}
|
||||
|
||||
existed := false
|
||||
var priorInfo os.FileInfo
|
||||
if info, err := root.Stat(rootedRelative); err == nil {
|
||||
if info, err := os.Stat(absolutePath); err == nil {
|
||||
existed = true
|
||||
priorInfo = info
|
||||
if !overwrite {
|
||||
@@ -100,23 +76,17 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an
|
||||
|
||||
// On overwrite, refuse to clobber a tracked file that changed on disk outside
|
||||
// Zero since it was last read — the new content was likely composed against a
|
||||
// stale view. The rooted read below is also the preimage: one descriptor-bound
|
||||
// snapshot supplies both the conflict check and the bytes passed to commit.
|
||||
readRooted := tool.readRooted
|
||||
if readRooted == nil {
|
||||
readRooted = readRootedFile
|
||||
}
|
||||
priorContent := ""
|
||||
priorContentKnown := !existed
|
||||
// stale view. Only read current bytes when there is a baseline to compare,
|
||||
// so a first-touch create/overwrite stays a single write with no extra read.
|
||||
if existed {
|
||||
if options.FileTracker != nil && !options.FileTracker.SeenWhole(absolutePath) {
|
||||
return errorResult(fileUnseenMessage(relativePath))
|
||||
}
|
||||
current, info, rerr := readRooted(root, rootedRelative)
|
||||
if _, tracked := options.FileTracker.Version(absolutePath); tracked {
|
||||
// Fail CLOSED: if the tracked file can't be re-read to verify it, refuse
|
||||
// the overwrite rather than clobbering a file whose current state is
|
||||
// unknown (it may have been replaced or removed out from under us).
|
||||
current, rerr := tool.readFile(absolutePath)
|
||||
if rerr != nil {
|
||||
return errorResult(fileConflictMessage(relativePath))
|
||||
}
|
||||
@@ -124,20 +94,21 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an
|
||||
return errorResult(fileConflictMessage(relativePath))
|
||||
}
|
||||
}
|
||||
if rerr == nil {
|
||||
// Prefer the identity from the opened object over root.Stat. Both
|
||||
// capture it at observation time; the opened object is the one whose
|
||||
// bytes were just read.
|
||||
priorInfo = info
|
||||
priorContent = string(current)
|
||||
}
|
||||
|
||||
// Capture the prior content (before we replace it) so an overwrite can show a
|
||||
// real diff; a fresh create stays "" and previews as all-additions.
|
||||
priorContent := ""
|
||||
priorContentKnown := !existed
|
||||
if existed {
|
||||
if prev, rerr := tool.readFile(absolutePath); rerr == nil {
|
||||
priorContent = string(prev)
|
||||
priorContentKnown = true
|
||||
}
|
||||
}
|
||||
|
||||
if dir := filepath.Dir(rootedRelative); dir != "." {
|
||||
if err := root.MkdirAll(dir, 0o755); err != nil {
|
||||
return errorResult("Error writing file " + relativePath + ": " + err.Error())
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(absolutePath), 0o755); err != nil {
|
||||
return errorResult("Error writing file " + relativePath + ": " + err.Error())
|
||||
}
|
||||
if err := recheckScopedWriteTarget(tool.workspaceRoot, tool.scope, requestedPath); err != nil {
|
||||
return errorResult("Error writing file " + relativePath + ": " + err.Error())
|
||||
@@ -146,7 +117,7 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an
|
||||
if priorContentKnown {
|
||||
expectedContent = &priorContent
|
||||
}
|
||||
if err := commitRootedFileContents(root, absolutePath, rootedRelative, priorInfo, expectedContent, content); err != nil {
|
||||
if err := commitFileContents(absolutePath, priorInfo, expectedContent, content); err != nil {
|
||||
return errorResult("Error writing file " + relativePath + ": " + err.Error())
|
||||
}
|
||||
modelKnownContent := content
|
||||
@@ -164,7 +135,7 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an
|
||||
// session compares against what is now on disk.
|
||||
newInfo := formatting.Info
|
||||
if newInfo == nil {
|
||||
newInfo, _ = root.Stat(rootedRelative)
|
||||
newInfo, _ = os.Stat(absolutePath)
|
||||
}
|
||||
if finalContentKnown {
|
||||
options.FileTracker.Record(absolutePath, []byte(content), newInfo)
|
||||
|
||||
@@ -455,9 +455,7 @@ func TestWriteFileToolOmitsDiffWhenOverwritePreimageCannotBeRead(t *testing.T) {
|
||||
path := filepath.Join(root, "private.txt")
|
||||
writeTestFile(t, path, "before\n")
|
||||
tool := NewScopedWriteFileTool(root, nil).(writeFileTool)
|
||||
tool.readRooted = func(*os.Root, string) ([]byte, os.FileInfo, error) {
|
||||
return nil, nil, os.ErrPermission
|
||||
}
|
||||
tool.readFile = func(string) ([]byte, error) { return nil, os.ErrPermission }
|
||||
registry := NewRegistry()
|
||||
registry.Register(tool)
|
||||
result := registry.RunWithOptions(context.Background(), tool.Name(), map[string]any{
|
||||
|
||||
@@ -77,12 +77,9 @@ type dictationController struct {
|
||||
regionStart int
|
||||
regionEnd int
|
||||
regionPrefix string
|
||||
// regionRendered is the exact text last inserted into [regionStart,
|
||||
// regionEnd). A later partial or cancel may remove that range only while it
|
||||
// still matches, so stale bounds never consume user replacement text.
|
||||
regionRendered string
|
||||
// regionAnchor snapshots the text BEFORE the live region, so the next
|
||||
// partial can detect edits before the tracked range.
|
||||
// partial can detect external edits (typing, paste) and shift [start,end)
|
||||
// to stay aligned. Updated alongside regionStart on each render.
|
||||
regionAnchor string
|
||||
|
||||
// waveBars is the recording waveform's recent bar heights (a scrolling ring):
|
||||
@@ -330,7 +327,6 @@ func (d *dictationController) reset() {
|
||||
d.streamStop = nil
|
||||
d.regionActive = false
|
||||
d.regionPrefix = ""
|
||||
d.regionRendered = ""
|
||||
d.waveBars = nil
|
||||
d.waveTick = 0
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
@@ -91,30 +92,52 @@ func (m model) handleDictationPartial(msg sttPartialMsg) model {
|
||||
|
||||
func (m *model) applyStreamingText(text string) {
|
||||
state := m.currentComposerState()
|
||||
stateRunes := []rune(state.text)
|
||||
if !m.dictation.regionActive {
|
||||
m.dictation.regionActive = true
|
||||
m.dictation.regionStart = state.cursor
|
||||
m.dictation.regionEnd = state.cursor
|
||||
m.dictation.regionPrefix = ""
|
||||
// Anchor the prefix text before the live region. A later partial removes
|
||||
// the tracked range only while both this prefix and the rendered region
|
||||
// still match.
|
||||
m.dictation.regionAnchor = string(stateRunes[:state.cursor])
|
||||
// Anchor the prefix text BEFORE the live region. If the user types or
|
||||
// pastes outside the region, the next partial can detect the change in
|
||||
// this prefix and shift [start,end) to stay aligned.
|
||||
m.dictation.regionAnchor = state.text
|
||||
if needsLeadingSpace(state) {
|
||||
// Fold the separator into the region so a cancel removes it too.
|
||||
m.dictation.regionPrefix = " "
|
||||
}
|
||||
} else if !m.dictation.liveRegionMatches(stateRunes) {
|
||||
// The composer changed before or inside the tracked range. Its old
|
||||
// bounds no longer prove which bytes belong to dictation, so preserve
|
||||
// everything and insert the next partial as a fresh live region.
|
||||
m.dictation.regionStart = state.cursor
|
||||
m.dictation.regionEnd = state.cursor
|
||||
m.dictation.regionPrefix = ""
|
||||
m.dictation.regionAnchor = string(stateRunes[:state.cursor])
|
||||
if needsLeadingSpace(state) {
|
||||
m.dictation.regionPrefix = " "
|
||||
} else {
|
||||
// Compare the prefix before the live region. If it changed (the user
|
||||
// typed/pasted there) shift [start,end) by the length delta so the
|
||||
// next partial's slice targets the right span. If the user edited
|
||||
// INSIDE the region, drop the live region and re-anchor at the
|
||||
// current cursor — we can't tell the partial from the user's text
|
||||
// after that point.
|
||||
prefix := string([]rune(state.text)[:m.dictation.regionStart])
|
||||
anchor := m.dictation.regionAnchor
|
||||
switch {
|
||||
case prefix == anchor:
|
||||
// External edit at or after the region — no shift needed.
|
||||
case len(prefix) > len(anchor) && strings.HasPrefix(prefix, anchor):
|
||||
// External edit inserted text just before the region (i.e. at
|
||||
// position regionStart, pushing the region right).
|
||||
delta := len([]rune(prefix)) - len([]rune(anchor))
|
||||
m.dictation.regionStart += delta
|
||||
m.dictation.regionEnd += delta
|
||||
m.dictation.regionAnchor = prefix
|
||||
case len(anchor) > len(prefix) && strings.HasPrefix(anchor, prefix):
|
||||
// External delete just before the region.
|
||||
delta := len([]rune(anchor)) - len([]rune(prefix))
|
||||
m.dictation.regionStart -= delta
|
||||
m.dictation.regionEnd -= delta
|
||||
m.dictation.regionAnchor = prefix
|
||||
default:
|
||||
// User edited inside or across the region — can't safely
|
||||
// overwrite. Drop the live region and re-anchor at the cursor
|
||||
// so the next partial inserts as a fresh span.
|
||||
m.dictation.regionStart = state.cursor
|
||||
m.dictation.regionEnd = state.cursor
|
||||
m.dictation.regionPrefix = ""
|
||||
m.dictation.regionAnchor = string([]rune(state.text)[:state.cursor])
|
||||
}
|
||||
}
|
||||
// Replace [regionStart, regionEnd) with prefix + the new cumulative text.
|
||||
@@ -130,22 +153,14 @@ func (m *model) applyStreamingText(text string) {
|
||||
}
|
||||
updated := insertComposerText(cleared, rendered)
|
||||
m.dictation.regionEnd = m.dictation.regionStart + len([]rune(rendered))
|
||||
m.dictation.regionRendered = rendered
|
||||
m.setComposerState(updated)
|
||||
}
|
||||
|
||||
func (d dictationController) liveRegionMatches(stateRunes []rune) bool {
|
||||
return d.regionStart >= 0 && d.regionEnd >= d.regionStart && d.regionEnd <= len(stateRunes) &&
|
||||
string(stateRunes[:d.regionStart]) == d.regionAnchor &&
|
||||
string(stateRunes[d.regionStart:d.regionEnd]) == d.regionRendered
|
||||
}
|
||||
|
||||
// commitDictationRegion keeps the streamed text in the composer and stops
|
||||
// tracking it as a live region (used on successful completion — the final
|
||||
// transcript equals the last partial already rendered).
|
||||
func (m model) commitDictationRegion() model {
|
||||
m.dictation.regionActive = false
|
||||
m.dictation.regionRendered = ""
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -154,11 +169,8 @@ func (m model) commitDictationRegion() model {
|
||||
func (m model) discardDictationRegion() model {
|
||||
if m.dictation.regionActive {
|
||||
state := m.currentComposerState()
|
||||
if m.dictation.liveRegionMatches([]rune(state.text)) {
|
||||
m.setComposerState(deleteComposerRange(state, m.dictation.regionStart, m.dictation.regionEnd))
|
||||
}
|
||||
m.setComposerState(deleteComposerRange(state, m.dictation.regionStart, m.dictation.regionEnd))
|
||||
m.dictation.regionActive = false
|
||||
m.dictation.regionRendered = ""
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package tui
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -176,145 +175,6 @@ func TestDictationStreamingPartialReplacesRegion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDictationStreamingPartialWithCaretInsideExistingText(t *testing.T) {
|
||||
m := model{}
|
||||
m.setComposerState(composerState{text: "hello world", cursor: 5})
|
||||
m.dictation.phase = dictRecording
|
||||
m.dictation.streaming = true
|
||||
|
||||
m = m.handleDictationPartial(sttPartialMsg{text: "there"})
|
||||
if m.composer.text != "hello there world" {
|
||||
t.Fatalf("after first partial: %q", m.composer.text)
|
||||
}
|
||||
m = m.handleDictationPartial(sttPartialMsg{text: "there friend"})
|
||||
m = m.handleDictationPartial(sttPartialMsg{text: "there friend again"})
|
||||
if m.composer.text != "hello there friend again world" {
|
||||
t.Fatalf("after third partial: %q", m.composer.text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDictationStreamingPartialReanchorsRegionAfterComposerShrink(t *testing.T) {
|
||||
m := model{}
|
||||
m.setComposerState(composerState{text: "hello world", cursor: 11})
|
||||
m.dictation.phase = dictRecording
|
||||
m.dictation.streaming = true
|
||||
m = m.handleDictationPartial(sttPartialMsg{text: "there"})
|
||||
|
||||
// Simulate the user replacing the composer while a later partial is in
|
||||
// flight. The old live-region bounds now exceed the new composer length.
|
||||
m.setComposerState(composerState{text: "hi ", cursor: 3})
|
||||
m = m.handleDictationPartial(sttPartialMsg{text: "again"})
|
||||
if m.composer.text != "hi again" {
|
||||
t.Fatalf("partial after composer shrink: %q", m.composer.text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDictationStreamingPartialDoesNotClampStaleRegionIntoUserText(t *testing.T) {
|
||||
m := model{}
|
||||
m.setComposerState(composerState{text: "aOLDz", cursor: 1})
|
||||
m.dictation.phase = dictRecording
|
||||
m.dictation.streaming = true
|
||||
m = m.handleDictationPartial(sttPartialMsg{text: "first"})
|
||||
|
||||
// Replace the composer while another cumulative partial is in flight. The
|
||||
// old region starts after the same prefix but no longer contains dictation
|
||||
// text, so its clamped bounds must not consume the user's "b".
|
||||
m.setComposerState(composerState{text: "ab", cursor: 2})
|
||||
m = m.handleDictationPartial(sttPartialMsg{text: "next"})
|
||||
if m.composer.text != "ab next" {
|
||||
t.Fatalf("stale live region replaced user text: %q", m.composer.text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDictationStreamingCancelDoesNotClampStaleRegionIntoUserText(t *testing.T) {
|
||||
m := model{}
|
||||
m.setComposerState(composerState{text: "aOLDz", cursor: 1})
|
||||
m.dictation.phase = dictRecording
|
||||
m.dictation.streaming = true
|
||||
m = m.handleDictationPartial(sttPartialMsg{text: "first"})
|
||||
|
||||
m.setComposerState(composerState{text: "ab", cursor: 2})
|
||||
m = m.discardDictationRegion()
|
||||
if m.composer.text != "ab" {
|
||||
t.Fatalf("discarded stale live region from user text: %q", m.composer.text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDictationStreamingSameLengthRegionEditPreserved(t *testing.T) {
|
||||
for _, partial := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("later partial=%v", partial), func(t *testing.T) {
|
||||
m := model{}
|
||||
m.setComposerState(composerState{text: "hello world", cursor: 11})
|
||||
m.applyStreamingText("there")
|
||||
// Keep the prefix and rune bounds unchanged, but replace precisely
|
||||
// the rendered region with user text.
|
||||
m.setComposerState(composerState{text: "hello world MINE!", cursor: 17})
|
||||
if partial {
|
||||
m.applyStreamingText("next")
|
||||
if !strings.Contains(m.composer.text, " MINE!") {
|
||||
t.Fatalf("partial deleted same-length user edit: %q", m.composer.text)
|
||||
}
|
||||
}
|
||||
m = m.discardDictationRegion()
|
||||
if m.composer.text != "hello world MINE!" {
|
||||
t.Fatalf("cancel deleted same-length user edit: %q", m.composer.text)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDictationStreamingBackspaceAcrossRegionPreservesUserTextOnCancel(t *testing.T) {
|
||||
m := model{}
|
||||
m.setComposerState(composerState{text: "hello world", cursor: 11})
|
||||
m.dictation.phase = dictRecording
|
||||
m.dictation.streaming = true
|
||||
m = m.handleDictationPartial(sttPartialMsg{text: "there"})
|
||||
|
||||
// The user backspaces through the live transcript and into their original
|
||||
// text while newer cumulative partials are still in flight.
|
||||
m.setComposerState(composerState{text: "hello wor", cursor: 9})
|
||||
m = m.handleDictationPartial(sttPartialMsg{text: "there friend"})
|
||||
m = m.handleDictationPartial(sttPartialMsg{text: "there friend again"})
|
||||
if m.composer.text != "hello wor there friend again" {
|
||||
t.Fatalf("partials replaced user text after backspace: %q", m.composer.text)
|
||||
}
|
||||
|
||||
m, _ = m.cancelDictation()
|
||||
if m.composer.text != "hello wor" {
|
||||
t.Fatalf("cancel did not restore the user's edited text: %q", m.composer.text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDictationStreamingInvalidRegionBoundsReanchor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
start int
|
||||
end int
|
||||
anchor string
|
||||
want string
|
||||
}{
|
||||
{name: "start past end", start: 40, end: 60, anchor: "a much longer previous composer", want: "ab partial text"},
|
||||
{name: "negative start", start: -3, end: 1, anchor: "", want: "ab partial text"},
|
||||
{name: "end past end", start: 1, end: 40, anchor: "a", want: "ab partial text"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
m := model{}
|
||||
m.setComposerState(composerState{text: "ab", cursor: 2})
|
||||
m.dictation.regionActive = true
|
||||
m.dictation.regionStart = tt.start
|
||||
m.dictation.regionEnd = tt.end
|
||||
m.dictation.regionAnchor = tt.anchor
|
||||
|
||||
m.applyStreamingText("partial text")
|
||||
if m.composer.text != tt.want {
|
||||
t.Fatalf("composer = %q, want %q", m.composer.text, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDictationCanceledStreamRaceDoesNotAutoSubmit(t *testing.T) {
|
||||
// Esc can race an already-buffered realtime event: cancelDictation discards
|
||||
// the live region and resets state synchronously, but the streaming
|
||||
|
||||
@@ -89,7 +89,6 @@ type model struct {
|
||||
// allowEscalation mirrors Options.AllowEscalation: it gates the per-run model
|
||||
// switchers, and the caller gates the escalate_model tool on the same flag.
|
||||
allowEscalation bool
|
||||
modelRPM *zeroruntime.ModelRPMLimiter
|
||||
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
|
||||
newTurnSessionProvider func(config.ProviderProfile, zeroruntime.Provider) zeroruntime.TurnSessionProvider
|
||||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
@@ -1056,7 +1055,6 @@ func newModel(ctx context.Context, options Options) model {
|
||||
sandboxSetupCommand: options.SandboxSetupCommand,
|
||||
agentOptions: options.AgentOptions,
|
||||
allowEscalation: options.AllowEscalation,
|
||||
modelRPM: options.ModelRPM,
|
||||
sessionCompactor: options.SessionCompactor,
|
||||
runtimeMessageSink: options.RuntimeMessageSink,
|
||||
permissionMode: permissionMode,
|
||||
@@ -5683,7 +5681,6 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
// currentModel: every usage event after a real escalation is billed
|
||||
// to the escalated model, not the one the run started on.
|
||||
func(modelID string) { usageModelID = modelID },
|
||||
providers.Options{ModelRPM: m.modelRPM},
|
||||
)
|
||||
}
|
||||
options.SupportsVision = func(modelID string) bool {
|
||||
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
|
||||
// Options configures the reusable Zero terminal UI shell.
|
||||
type Options struct {
|
||||
ModelRPM *zeroruntime.ModelRPMLimiter
|
||||
Cwd string
|
||||
Version string // CLI build version, shown on the home screen; empty hides it
|
||||
UserConfigPath string
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
// A resume that finds zero sessions prune holding the session is refused, not
|
||||
// read from the raw log without the lease.
|
||||
func TestResumeEventsIsRefusedWhilePruneHoldsTheSession(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
creator := sessions.NewStore(sessions.StoreOptions{RootDir: root})
|
||||
meta, err := creator.Create(sessions.CreateInput{Title: "resume me"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if _, err := creator.AppendEvent(meta.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: map[string]string{"content": "hello"}}); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
creator.Release(meta.SessionID)
|
||||
|
||||
release, locked, err := sessions.NewStore(sessions.StoreOptions{RootDir: root}).HoldExclusive(meta.SessionID)
|
||||
if err != nil || !locked {
|
||||
t.Fatalf("take the lease the way prune does: locked=%v, %v", locked, err)
|
||||
}
|
||||
defer release()
|
||||
|
||||
m := model{sessionStore: sessions.NewStore(sessions.StoreOptions{RootDir: root})}
|
||||
events, err := m.resumeEvents(meta.SessionID)
|
||||
if !errors.Is(err, sessions.ErrPruning) || events != nil {
|
||||
t.Fatalf("resume while prune holds the session: %d events, err = %v, want it refused", len(events), err)
|
||||
}
|
||||
}
|
||||
|
||||
// A session picked for /resume that prune removes before it is read is refused
|
||||
// too, not resumed as an empty conversation: part way through the removal, with
|
||||
// the directory still there and its metadata gone, and once it is gone.
|
||||
func TestResumeEventsIsRefusedOnceThePickedSessionIsRemoved(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
creator := sessions.NewStore(sessions.StoreOptions{RootDir: root})
|
||||
meta, err := creator.Create(sessions.CreateInput{Title: "resume me"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if _, err := creator.AppendEvent(meta.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: map[string]string{"content": "hello"}}); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
creator.Release(meta.SessionID)
|
||||
dir := filepath.Join(root, meta.SessionID)
|
||||
|
||||
if err := os.Remove(filepath.Join(dir, sessions.MetadataFile)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := model{sessionStore: sessions.NewStore(sessions.StoreOptions{RootDir: root})}
|
||||
if events, err := m.resumeEvents(meta.SessionID); !errors.Is(err, sessions.ErrPruning) || events != nil {
|
||||
t.Errorf("resume while prune is removing the session: %d events, err = %v, want it refused", len(events), err)
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m = model{sessionStore: sessions.NewStore(sessions.StoreOptions{RootDir: root})}
|
||||
if events, err := m.resumeEvents(meta.SessionID); !errors.Is(err, sessions.ErrPruning) || events != nil {
|
||||
t.Errorf("resume after prune removed the session: %d events, err = %v, want it refused", len(events), err)
|
||||
}
|
||||
}
|
||||
@@ -311,20 +311,10 @@ func (m model) resolveResumeSession(args string) (*sessions.Metadata, error) {
|
||||
// the CLI's `zero exec --resume` (readExecContextEvents) and the in-TUI /compact
|
||||
// reload. Falls back to the raw log if rehydration fails.
|
||||
func (m model) resumeEvents(sessionID string) ([]sessions.Event, error) {
|
||||
// Picked from its metadata a moment ago. Make sure it is still there, and
|
||||
// held, before resuming it: see sessions.HoldToContinue.
|
||||
if err := m.sessionStore.HoldToContinue(sessionID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events, err := m.sessionStore.ReadRehydratedEvents(sessionID)
|
||||
if err == nil {
|
||||
return events, nil
|
||||
}
|
||||
if errors.Is(err, sessions.ErrPruning) {
|
||||
// Not a rehydration failure: the raw read would resume the session without
|
||||
// holding it while zero sessions prune may be removing it.
|
||||
return nil, err
|
||||
}
|
||||
raw, rawErr := m.sessionStore.ReadEvents(sessionID)
|
||||
if rawErr != nil {
|
||||
// Surface the raw-read failure (the actual fallback error), not the earlier
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
package zeroruntime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ModelRPMLimiter shares a sliding 60-second window across turn sessions in
|
||||
// one runtime process. Separate processes do not share state.
|
||||
type ModelRPMLimiter struct {
|
||||
mu sync.Mutex
|
||||
limits map[string]int
|
||||
windows map[string][]time.Time
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewModelRPMLimiter copies validated canonical-model limits. Zero/unset is off.
|
||||
func NewModelRPMLimiter(limits map[string]int) *ModelRPMLimiter {
|
||||
active := make(map[string]int)
|
||||
for m, n := range limits {
|
||||
if n > 0 {
|
||||
active[m] = n
|
||||
}
|
||||
}
|
||||
if len(active) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &ModelRPMLimiter{limits: active, windows: make(map[string][]time.Time), now: time.Now}
|
||||
}
|
||||
|
||||
// RPMLimitError denotes rejection before the wrapped Stream method is called.
|
||||
type RPMLimitError struct {
|
||||
Model string
|
||||
Limit int
|
||||
RetryAfter time.Duration
|
||||
}
|
||||
|
||||
func (e *RPMLimitError) Error() string {
|
||||
retry := e.RetryAfter.Round(time.Second)
|
||||
if retry < e.RetryAfter {
|
||||
retry += time.Second
|
||||
}
|
||||
return fmt.Sprintf("rate limit error: local RPM limit for %q (%d requests/60s); retry in %s", e.Model, e.Limit, retry)
|
||||
}
|
||||
|
||||
// Wrap gates Stream admissions, not setup/prewarm, compaction or transport retries.
|
||||
func (l *ModelRPMLimiter) Wrap(model string, p TurnSessionProvider) TurnSessionProvider {
|
||||
if l == nil || l.limits[model] == 0 || p == nil {
|
||||
return p
|
||||
}
|
||||
return rpmProvider{TurnSessionProvider: p, limiter: l, model: model}
|
||||
}
|
||||
func (l *ModelRPMLimiter) admit(ctx context.Context, model string) error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
now := l.now()
|
||||
w := l.windows[model]
|
||||
first := 0
|
||||
for first < len(w) && !w[first].After(now.Add(-time.Minute)) {
|
||||
first++
|
||||
}
|
||||
w = w[:copy(w, w[first:])]
|
||||
l.windows[model] = w
|
||||
if len(w) >= l.limits[model] {
|
||||
return &RPMLimitError{Model: model, Limit: l.limits[model], RetryAfter: w[0].Add(time.Minute).Sub(now)}
|
||||
}
|
||||
l.windows[model] = append(w, now)
|
||||
return nil
|
||||
}
|
||||
|
||||
type rpmProvider struct {
|
||||
TurnSessionProvider
|
||||
limiter *ModelRPMLimiter
|
||||
model string
|
||||
}
|
||||
|
||||
func (p rpmProvider) OpenTurnSession(ctx context.Context) (TurnSession, error) {
|
||||
s, err := p.TurnSessionProvider.OpenTurnSession(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rpmSession{TurnSession: s, limiter: p.limiter, model: p.model}, nil
|
||||
}
|
||||
|
||||
type rpmSession struct {
|
||||
TurnSession
|
||||
limiter *ModelRPMLimiter
|
||||
model string
|
||||
}
|
||||
|
||||
func (s rpmSession) Stream(ctx context.Context, r CompletionRequest) (<-chan StreamEvent, error) {
|
||||
if err := s.limiter.admit(ctx, s.model); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.TurnSession.Stream(ctx, r)
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package zeroruntime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type rpmTransport struct{ calls atomic.Int64 }
|
||||
|
||||
func (p *rpmTransport) StreamCompletion(context.Context, CompletionRequest) (<-chan StreamEvent, error) {
|
||||
p.calls.Add(1)
|
||||
ch := make(chan StreamEvent)
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
func rpmSessionFor(t *testing.T, l *ModelRPMLimiter, m string, p Provider) TurnSession {
|
||||
t.Helper()
|
||||
s, e := l.Wrap(m, NewProviderTurnSessionProvider(p, ProviderCapabilities{Model: m})).OpenTurnSession(context.Background())
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
return s
|
||||
}
|
||||
func TestRPMRejectsNPlusOneBeforeTransportAcrossSessions(t *testing.T) {
|
||||
l := NewModelRPMLimiter(map[string]int{"m": 3})
|
||||
clock := time.Unix(0, 0)
|
||||
l.now = func() time.Time { return clock }
|
||||
p := &rpmTransport{}
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, e := rpmSessionFor(t, l, "m", p).Stream(context.Background(), CompletionRequest{}); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
s := rpmSessionFor(t, l, "m", p)
|
||||
ch, e := s.Stream(context.Background(), CompletionRequest{})
|
||||
var hit *RPMLimitError
|
||||
if !errors.As(e, &hit) || ch != nil || hit.RetryAfter != time.Minute {
|
||||
t.Fatalf("N+1: stream=%v error=%v", ch, e)
|
||||
}
|
||||
if p.calls.Load() != 3 {
|
||||
t.Fatalf("transport calls=%d, want 3", p.calls.Load())
|
||||
}
|
||||
clock = clock.Add(time.Minute)
|
||||
if _, e = s.Stream(context.Background(), CompletionRequest{}); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
func TestRPMSlidingWindow(t *testing.T) {
|
||||
l := NewModelRPMLimiter(map[string]int{"m": 2})
|
||||
clock := time.Unix(59, 0)
|
||||
l.now = func() time.Time { return clock }
|
||||
s := rpmSessionFor(t, l, "m", &rpmTransport{})
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, e := s.Stream(context.Background(), CompletionRequest{}); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
clock = time.Unix(60, 0)
|
||||
_, e := s.Stream(context.Background(), CompletionRequest{})
|
||||
var hit *RPMLimitError
|
||||
if !errors.As(e, &hit) || hit.RetryAfter != 59*time.Second {
|
||||
t.Fatalf("minute boundary reset window: %v", e)
|
||||
}
|
||||
}
|
||||
func TestRPMConcurrentAdmissions(t *testing.T) {
|
||||
l := NewModelRPMLimiter(map[string]int{"m": 7})
|
||||
l.now = func() time.Time { return time.Unix(0, 0) }
|
||||
p := &rpmTransport{}
|
||||
var grants, unexpected atomic.Int64
|
||||
var wg sync.WaitGroup
|
||||
start := make(chan struct{})
|
||||
for i := 0; i < 100; i++ {
|
||||
s := rpmSessionFor(t, l, "m", p)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, e := s.Stream(context.Background(), CompletionRequest{})
|
||||
var hit *RPMLimitError
|
||||
if e == nil {
|
||||
grants.Add(1)
|
||||
} else if !errors.As(e, &hit) {
|
||||
unexpected.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
if grants.Load() != 7 || p.calls.Load() != 7 || unexpected.Load() != 0 {
|
||||
t.Fatalf("grants=%d transport=%d unexpected=%d", grants.Load(), p.calls.Load(), unexpected.Load())
|
||||
}
|
||||
}
|
||||
func TestRPMCancellationFailedAttemptsAndIndependentModels(t *testing.T) {
|
||||
l := NewModelRPMLimiter(map[string]int{"a": 1, "b": 1})
|
||||
p := &recordingProvider{err: errors.New("fixture transport failure")}
|
||||
s := rpmSessionFor(t, l, "a", p)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, e := s.Stream(ctx, CompletionRequest{}); !errors.Is(e, context.Canceled) {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, e := s.Stream(context.Background(), CompletionRequest{}); !errors.Is(e, p.err) {
|
||||
t.Fatal(e)
|
||||
}
|
||||
_, e := s.Stream(context.Background(), CompletionRequest{})
|
||||
var hit *RPMLimitError
|
||||
if !errors.As(e, &hit) || len(p.requests) != 1 {
|
||||
t.Fatalf("failed attempt refunded: %v", e)
|
||||
}
|
||||
_, _ = rpmSessionFor(t, l, "b", p).Stream(context.Background(), CompletionRequest{})
|
||||
for i := 0; i < 3; i++ {
|
||||
_, _ = rpmSessionFor(t, l, "unlimited", p).Stream(context.Background(), CompletionRequest{})
|
||||
}
|
||||
if len(p.requests) != 5 {
|
||||
t.Fatal("other model incorrectly blocked")
|
||||
}
|
||||
}
|
||||
func TestRPMDisabled(t *testing.T) {
|
||||
l := NewModelRPMLimiter(map[string]int{"m": 0})
|
||||
p := &providerTurnSessionProvider{provider: &rpmTransport{}}
|
||||
if l != nil || l.Wrap("m", p) != p {
|
||||
t.Fatal("disabled limit altered provider")
|
||||
}
|
||||
}
|
||||