Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b549f5928f | ||
|
|
1335ca6671 | ||
|
|
ddc45f2c81 | ||
|
|
eebb384676 | ||
|
|
39d6560137 | ||
|
|
bb3346a828 | ||
|
|
ac5fe0d218 | ||
|
|
0c6f179a84 | ||
|
|
feb992757c | ||
|
|
23c27e0778 | ||
|
|
2c418fc8ff | ||
|
|
571af701bd | ||
|
|
2eb9d94fac | ||
|
|
eefa978f61 | ||
|
|
bea2230633 | ||
|
|
df67edec13 | ||
|
|
42eb6ab5fb | ||
|
|
8a68660161 | ||
|
|
ab018b3098 | ||
|
|
1697b7a5ce | ||
|
|
6b5852dc18 | ||
|
|
aa52040498 | ||
|
|
547330197b | ||
|
|
7f11491078 | ||
|
|
0324c1d410 | ||
|
|
b9ef1d271a | ||
|
|
9520f2bad7 | ||
|
|
e2d18c50f0 | ||
|
|
e20b762426 | ||
|
|
7f8e5f08bb | ||
|
|
99721c762f | ||
|
|
dcf229fecd | ||
|
|
3fae0de2ea | ||
|
|
4ba112a0eb | ||
|
|
b76c764b77 | ||
|
|
d73ec81e2d | ||
|
|
2479d946c1 | ||
|
|
d76f7f6931 | ||
|
|
37d69c96ce | ||
|
|
176f5a242b | ||
|
|
9e609c5242 | ||
|
|
2f531178f6 | ||
|
|
3b43c43eca | ||
|
|
faec8ee201 | ||
|
|
c1937dfac7 | ||
|
|
90014210d2 |
No files matched your search
@@ -24,6 +24,7 @@ jobs:
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
|
||||
|
||||
@@ -34,12 +34,15 @@ jobs:
|
||||
- name: Checkout
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.ref == '' }}
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout requested ref
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.ref != '' }}
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff
|
||||
@@ -129,6 +132,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# No registry-url here on purpose: setup-node's registry-url writes an
|
||||
# .npmrc line referencing $NODE_AUTH_TOKEN, and npm hard-fails on the
|
||||
|
||||
@@ -5,6 +5,8 @@ on:
|
||||
paths:
|
||||
- action.yml
|
||||
- docs/GITHUB_ACTION.md
|
||||
- scripts/action-summary.mjs
|
||||
- scripts/action-summary.test.mjs
|
||||
- .github/workflows/zero-action-smoke.yml
|
||||
push:
|
||||
branches:
|
||||
@@ -12,6 +14,8 @@ on:
|
||||
paths:
|
||||
- action.yml
|
||||
- docs/GITHUB_ACTION.md
|
||||
- scripts/action-summary.mjs
|
||||
- scripts/action-summary.test.mjs
|
||||
- .github/workflows/zero-action-smoke.yml
|
||||
|
||||
permissions:
|
||||
@@ -27,6 +31,14 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Run action-summary test suite
|
||||
run: node --test scripts/action-summary.test.mjs
|
||||
|
||||
- name: Parse action.yml and assert documented inputs
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -1 +1 @@
|
||||
{".":"0.8.0"}
|
||||
{".":"0.9.0"}
|
||||
@@ -6,6 +6,61 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
|
||||
tagged. Until then, source builds report the version `dev`.
|
||||
|
||||
## [0.9.0](https://github.com/Gitlawb/zero/compare/v0.8.0...v0.9.0) (2026-09-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **acp:** add standard session list and resume ([#914](https://github.com/Gitlawb/zero/issues/914)) ([c1937df](https://github.com/Gitlawb/zero/commit/c1937dfac72e6ad0e5ade6e48e2d9c17d9c3e5d6))
|
||||
* **acp:** emit structured file changes in tool updates ([#999](https://github.com/Gitlawb/zero/issues/999)) ([faec8ee](https://github.com/Gitlawb/zero/commit/faec8ee2017fdd6d0c2c347add892cb73eb413d7))
|
||||
* **acp:** surface safe browser tool metadata ([#1000](https://github.com/Gitlawb/zero/issues/1000)) ([a6ec043](https://github.com/Gitlawb/zero/commit/a6ec0435e36b9bea9793b2fd97f2ecd30e3a0f9b))
|
||||
* **exec:** default headless runs to the turn-budget ceiling ([#1049](https://github.com/Gitlawb/zero/issues/1049)) ([9e609c5](https://github.com/Gitlawb/zero/commit/9e609c52420fd322f047fc238c04ac81dd623fdd))
|
||||
* **tui:** interactive terminal attach for tty exec sessions ([#1055](https://github.com/Gitlawb/zero/issues/1055)) ([d73ec81](https://github.com/Gitlawb/zero/commit/d73ec81e2d3facb5377bc9ad0fe2aa314628a0c0))
|
||||
* **tui:** offer mid-run model escalation behind --allow-escalation ([#1019](https://github.com/Gitlawb/zero/issues/1019)) ([91d921f](https://github.com/Gitlawb/zero/commit/91d921f4c5b8289a9e437f786218f1b48f2add5a))
|
||||
* **tui:** richer /resume session browser ([#1047](https://github.com/Gitlawb/zero/issues/1047)) ([2f53117](https://github.com/Gitlawb/zero/commit/2f531178f614e587b629e4f81e0470adda647fbd))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **acp:** make configured MCP tools callable ([#1003](https://github.com/Gitlawb/zero/issues/1003)) ([7f5e5af](https://github.com/Gitlawb/zero/commit/7f5e5af7bbdbe5bb4d91706146072d461c0234a4))
|
||||
* **agent:** stop the tool-schema ratchet meaning a different thing per host ([#1017](https://github.com/Gitlawb/zero/issues/1017)) ([f909266](https://github.com/Gitlawb/zero/commit/f9092668a832ea73a69551f0ea2df4f6eaf356df))
|
||||
* **background:** treat POSIX zombie leader as already-exited ([#981](https://github.com/Gitlawb/zero/issues/981)) ([1aacf38](https://github.com/Gitlawb/zero/commit/1aacf38b727d31b5ca09949316d6afa8292aec46))
|
||||
* **config:** lock config read-modify-write across processes ([#960](https://github.com/Gitlawb/zero/issues/960)) ([b04fe96](https://github.com/Gitlawb/zero/commit/b04fe96e9ef3f2deda4fb2a972270300629079d2))
|
||||
* **config:** preserve unknown top-level fields ([#984](https://github.com/Gitlawb/zero/issues/984)) ([488def6](https://github.com/Gitlawb/zero/commit/488def68dcc6d36701fdf171e94a74a436b090fc))
|
||||
* **daemon:** publish status files atomically ([#949](https://github.com/Gitlawb/zero/issues/949)) ([9001421](https://github.com/Gitlawb/zero/commit/90014210d2ada2e3d61f4eca5087d3f35a3c4b8b))
|
||||
* **installtxn:** put back an install a killed commit left behind ([#997](https://github.com/Gitlawb/zero/issues/997)) ([2837f5e](https://github.com/Gitlawb/zero/commit/2837f5efb38dcd3c69771b0ba31565f98a206120))
|
||||
* let the configured forward proxy through the loopback guard ([#1025](https://github.com/Gitlawb/zero/issues/1025)) ([1e4db7c](https://github.com/Gitlawb/zero/commit/1e4db7c6643ca7e2c3d8e4b8b30371cfbe43561e))
|
||||
* **lockutil:** use kernel-held advisory locks ([#950](https://github.com/Gitlawb/zero/issues/950)) ([6fe0d1e](https://github.com/Gitlawb/zero/commit/6fe0d1edcdbadea6d566800fbcca739c1cd54ea1))
|
||||
* **mcp:** discover OAuth through protected resource metadata ([#991](https://github.com/Gitlawb/zero/issues/991)) ([1b5db17](https://github.com/Gitlawb/zero/commit/1b5db1765672820caac1684b168c9898b5ba3593))
|
||||
* **mcp:** reject a server-initiated request as a streamable-HTTP response ([#1054](https://github.com/Gitlawb/zero/issues/1054)) ([2479d94](https://github.com/Gitlawb/zero/commit/2479d946c15dbf74ee3c04a637e97181d1786f08))
|
||||
* **oauth:** force-close loopback callback connections past the shutdown deadline ([#1051](https://github.com/Gitlawb/zero/issues/1051)) ([176f5a2](https://github.com/Gitlawb/zero/commit/176f5a242b754a86ea4c7121a8dcc1c0d72cf2e4))
|
||||
* **oauth:** Split an oversized keyring token blob across entries ([#1007](https://github.com/Gitlawb/zero/issues/1007)) ([275e248](https://github.com/Gitlawb/zero/commit/275e2486695338ce38193a6e3319f8b6a9a5c33c))
|
||||
* **providerio:** close idle connections periodically to prevent HTTP/2 PROTOCOL_ERROR ([#1058](https://github.com/Gitlawb/zero/issues/1058)) ([b76c764](https://github.com/Gitlawb/zero/commit/b76c764b77926891aa67163e6605a2d8540aacaa))
|
||||
* **providers:** register Atomic Chat as local OpenAI-compatible runtime ([#550](https://github.com/Gitlawb/zero/issues/550)) ([79fa142](https://github.com/Gitlawb/zero/commit/79fa1422ef1a3691b93342788453ebc284d57f9c))
|
||||
* **redaction:** split camelCase keys in normalizeKey ([#994](https://github.com/Gitlawb/zero/issues/994)) ([08fbea7](https://github.com/Gitlawb/zero/commit/08fbea7a3221d2061d88038924f90a4cba9af1ac))
|
||||
* **sandbox:** keep a derived ACL target inside the write root it came from ([#1040](https://github.com/Gitlawb/zero/issues/1040)) ([3b43c43](https://github.com/Gitlawb/zero/commit/3b43c43eca968c8619adadc81fcc1a17e220216a))
|
||||
* **sandbox:** keep Windows restricted-token SIDs narrow and fail closed on DenyRead ([#1006](https://github.com/Gitlawb/zero/issues/1006)) ([0207f2b](https://github.com/Gitlawb/zero/commit/0207f2b2697149a73ecc89a5ab5db0bd665f1911))
|
||||
* **sandbox:** Make WSL backend tests deterministic. ([#948](https://github.com/Gitlawb/zero/issues/948)) ([e59bf78](https://github.com/Gitlawb/zero/commit/e59bf78cabc2703678778b904c27c8bc8ef82bc8))
|
||||
* **sandbox:** protect worktree git pointers ([#805](https://github.com/Gitlawb/zero/issues/805)) ([f30f550](https://github.com/Gitlawb/zero/commit/f30f550e6037c53f8521ba2d59c984ae6c574917))
|
||||
* **sandbox:** refcount temporary roots so one holder's cleanup cannot revoke another's ([#911](https://github.com/Gitlawb/zero/issues/911)) ([7acac7a](https://github.com/Gitlawb/zero/commit/7acac7acee65565e25089f2a7b5813c086ce65eb))
|
||||
* **sessions:** carry an interrupted turn's work into the resume prompt ([#1016](https://github.com/Gitlawb/zero/issues/1016)) ([6ae8396](https://github.com/Gitlawb/zero/commit/6ae8396f0282a5f18a431f0c0ef1a272ecc2ca2b))
|
||||
* **sessions:** render resume context fields in key order ([#1043](https://github.com/Gitlawb/zero/issues/1043)) ([da244bc](https://github.com/Gitlawb/zero/commit/da244bcbfcfa05e414567ac0acc3cf8ba745ad7d))
|
||||
* **specialist:** keep the pinned model when a specialist is resumed ([#1009](https://github.com/Gitlawb/zero/issues/1009)) ([aadb4a2](https://github.com/Gitlawb/zero/commit/aadb4a27e9cdb41e774e8e1a8f23cf9c2fb50db2))
|
||||
* **swarm:** stop original member before handoff ([#957](https://github.com/Gitlawb/zero/issues/957)) ([0021475](https://github.com/Gitlawb/zero/commit/0021475d4c58c91911098f414795512cf60a0b56))
|
||||
* **tools:** an edit keeps the reads it did not disturb ([#908](https://github.com/Gitlawb/zero/issues/908)) ([1649008](https://github.com/Gitlawb/zero/commit/1649008506da0e675431dc056251a01891b15cc8))
|
||||
* **tools:** make apply_patch tolerant and re-expose edit_file ([#956](https://github.com/Gitlawb/zero/issues/956)) ([2749447](https://github.com/Gitlawb/zero/commit/2749447cdce4ae5dd333538b36fa919b80ecc66d))
|
||||
* **tools:** reject fuzzy edits with drifted content ([#983](https://github.com/Gitlawb/zero/issues/983)) ([8b3d1b1](https://github.com/Gitlawb/zero/commit/8b3d1b1597b04bfa6d4bc7f912abc13ca80a6298))
|
||||
* **tools:** say when format-on-write ran out of time ([#1044](https://github.com/Gitlawb/zero/issues/1044)) ([6937a30](https://github.com/Gitlawb/zero/commit/6937a309cf00825572210a7610a1f3ea8b74c2f9))
|
||||
* **tui:** move voice capture to Ctrl+Space ([#968](https://github.com/Gitlawb/zero/issues/968)) ([eeea330](https://github.com/Gitlawb/zero/commit/eeea3308fed38b37b203522624babaf2f30df91f))
|
||||
* **update:** keep Windows failed-restore state in trusted recovery records ([#979](https://github.com/Gitlawb/zero/issues/979)) ([a3852ba](https://github.com/Gitlawb/zero/commit/a3852bac595e06c5d3ca9da22212b281b8796802))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **agent:** cheap compaction — dedicated summarizer model, input cap… ([#532](https://github.com/Gitlawb/zero/issues/532)) ([6645f97](https://github.com/Gitlawb/zero/commit/6645f9744b37dda4d52ecc4631578d62cefc89de))
|
||||
* **providers:** parallelize isolated provider unit tests and shrink retry backoffs ([#955](https://github.com/Gitlawb/zero/issues/955)) ([d6b25fc](https://github.com/Gitlawb/zero/commit/d6b25fc3039a6b7d72a5703a87a097449792b102))
|
||||
* reduce startup and turn overhead ([#932](https://github.com/Gitlawb/zero/issues/932)) ([1fde941](https://github.com/Gitlawb/zero/commit/1fde941803c4295c11a4a14559f3c5217d1a9e69))
|
||||
|
||||
## [0.8.0](https://github.com/Gitlawb/zero/compare/v0.7.0...v0.8.0) (2026-08-21)
|
||||
|
||||
|
||||
|
||||
@@ -4,9 +4,15 @@
|
||||
|
||||
<p align="center"><strong>A terminal coding agent you own.</strong></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/70679?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/70679/daily?language=Go" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
|
||||
<a href="https://trendshift.io/repositories/70679?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/70679/weekly?language=Go" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
|
||||
<a href="https://trendshift.io/repositories/70679?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/70679" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="Go 1.26.9+" src="https://img.shields.io/badge/Go-1.26.9+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
|
||||
<br>
|
||||
@@ -23,6 +29,70 @@ zero exec "fix the failing test in ./pkg"
|
||||
zero exec --output-format stream-json < turns.jsonl
|
||||
```
|
||||
|
||||
## Partners
|
||||
|
||||
<table align="center">
|
||||
<tr>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://twigpine.com">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://twigpine.com/logo-dark.png">
|
||||
<img src="https://twigpine.com/logo.png" alt="Twigpine logo" width="72">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://mimo.mi.com">
|
||||
<img src="https://mimo.xiaomi.com/mimo-v2-pro/assets/logo.svg" alt="Xiaomi MiMo logo" width="136">
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://novita.ai/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/novita-logo-dark.svg">
|
||||
<img src="docs/assets/novita-logo.svg" alt="Novita AI logo" width="136">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://concentrate.ai/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/concentrate-logo-dark.svg">
|
||||
<img src="docs/assets/concentrate-logo.svg" alt="Concentrate logo" width="64">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://exa.ai/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/exa-logo-dark.svg">
|
||||
<img src="docs/assets/exa-logo.svg" alt="Exa logo" width="110">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://twigpine.com"><strong>Twigpine</strong></a></td>
|
||||
<td align="center"><a href="https://mimo.mi.com"><strong>Xiaomi MiMo</strong></a></td>
|
||||
<td align="center"><a href="https://novita.ai/"><strong>Novita AI</strong></a></td>
|
||||
<td align="center"><a href="https://concentrate.ai/"><strong>Concentrate</strong></a></td>
|
||||
<td align="center"><a href="https://exa.ai/"><strong>Exa</strong></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://www.gmicloud.ai/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/gmi-cloud-logo-dark.svg">
|
||||
<img src="docs/assets/gmi-cloud-logo.svg" alt="GMI Cloud logo" width="120">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://www.gmicloud.ai/"><strong>GMI Cloud</strong></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Why Zero
|
||||
|
||||
- **Use the model you want.** Bring OpenAI, Anthropic, Gemini, Groq, OpenRouter,
|
||||
@@ -76,7 +146,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
|
||||
|
||||
### From source
|
||||
|
||||
Source builds require Go 1.26.6+.
|
||||
Source builds require Go 1.26.9+.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
@@ -133,6 +203,7 @@ export GEMINI_API_KEY=...
|
||||
export AIMLAPI_API_KEY=...
|
||||
export LONGCAT_API_KEY=...
|
||||
export FIREWORKS_API_KEY=...
|
||||
export GMI_API_KEY=...
|
||||
export MINIMAX_API_KEY=...
|
||||
export MINIMAXI_API_KEY=...
|
||||
```
|
||||
@@ -155,6 +226,12 @@ To configure Fireworks AI directly, run:
|
||||
zero providers setup fireworks --set-active
|
||||
```
|
||||
|
||||
To configure GMI Cloud directly, run:
|
||||
|
||||
```bash
|
||||
zero providers setup gmicloud --set-active
|
||||
```
|
||||
|
||||
MiniMax presets use the Anthropic-compatible endpoints for the global and China
|
||||
regions:
|
||||
|
||||
@@ -313,7 +390,7 @@ zero context context-budget report
|
||||
zero repo-map deterministic repository map
|
||||
zero repo-info local repository summary
|
||||
zero search | find search local session history
|
||||
zero sessions inspect, resume, fork, and rewind sessions
|
||||
zero sessions inspect, resume, fork, rewind, and prune sessions
|
||||
zero spec manage spec-mode drafts
|
||||
zero specialist manage specialist subagents
|
||||
zero skills manage markdown instruction skills
|
||||
@@ -331,6 +408,13 @@ zero update --check check for newer releases
|
||||
zero upgrade download, verify, and install the latest release
|
||||
```
|
||||
|
||||
Cron keeps the newest 1,000 run outcomes per job in `runs.jsonl`. Each new run
|
||||
atomically replaces the history with the retained tail; existing larger histories
|
||||
are trimmed on their next run. Archive the file before that run if you need older
|
||||
outcomes. This retention does not delete session directories or reset fire counts.
|
||||
Malformed records and legacy lines of 1 MiB or more are skipped when reading or
|
||||
compacting history; new records of that size are rejected.
|
||||
|
||||
## Extending Zero
|
||||
|
||||
### Project and personal instructions
|
||||
|
||||
@@ -4,9 +4,15 @@
|
||||
|
||||
<p align="center"><strong>一个独属于你的终端编码智能体。</strong></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/70679?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/70679/daily?language=Go" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
|
||||
<a href="https://trendshift.io/repositories/70679?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/70679/weekly?language=Go" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
|
||||
<a href="https://trendshift.io/repositories/70679?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-70679" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/70679" alt="Twigpine/zero | Trendshift" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="Go 1.26.9+" src="https://img.shields.io/badge/Go-1.26.9+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
|
||||
<br>
|
||||
@@ -21,6 +27,70 @@ zero exec "修复 ./pkg 中失败的测试"
|
||||
zero exec --output-format stream-json < turns.jsonl
|
||||
```
|
||||
|
||||
## 合作伙伴
|
||||
|
||||
<table align="center">
|
||||
<tr>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://twigpine.com">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://twigpine.com/logo-dark.png">
|
||||
<img src="https://twigpine.com/logo.png" alt="Twigpine logo" width="72">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://mimo.mi.com">
|
||||
<img src="https://mimo.xiaomi.com/mimo-v2-pro/assets/logo.svg" alt="Xiaomi MiMo logo" width="136">
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://novita.ai/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/novita-logo-dark.svg">
|
||||
<img src="docs/assets/novita-logo.svg" alt="Novita AI logo" width="136">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://concentrate.ai/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/concentrate-logo-dark.svg">
|
||||
<img src="docs/assets/concentrate-logo.svg" alt="Concentrate logo" width="64">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://exa.ai/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/exa-logo-dark.svg">
|
||||
<img src="docs/assets/exa-logo.svg" alt="Exa logo" width="110">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://twigpine.com"><strong>Twigpine</strong></a></td>
|
||||
<td align="center"><a href="https://mimo.mi.com"><strong>Xiaomi MiMo</strong></a></td>
|
||||
<td align="center"><a href="https://novita.ai/"><strong>Novita AI</strong></a></td>
|
||||
<td align="center"><a href="https://concentrate.ai/"><strong>Concentrate</strong></a></td>
|
||||
<td align="center"><a href="https://exa.ai/"><strong>Exa</strong></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://www.gmicloud.ai/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/gmi-cloud-logo-dark.svg">
|
||||
<img src="docs/assets/gmi-cloud-logo.svg" alt="GMI Cloud logo" width="120">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://www.gmicloud.ai/"><strong>GMI Cloud</strong></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 为什么选择 Zero
|
||||
|
||||
- **使用你想要的模型。** 支持 OpenAI、Anthropic、Gemini、Groq、OpenRouter、DeepSeek、Mistral、xAI、Qwen、Kimi、GitHub Models、Ollama、LM Studio、Atomic Chat,或任何 OpenAI/Anthropic 兼容端点。
|
||||
@@ -57,7 +127,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
|
||||
|
||||
### 从源码构建
|
||||
|
||||
源码构建需要 Go 1.26.6+。
|
||||
源码构建需要 Go 1.26.9+。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
@@ -106,6 +176,7 @@ export OPENAI_API_KEY=sk-...
|
||||
export ANTHROPIC_API_KEY=...
|
||||
export GEMINI_API_KEY=...
|
||||
export LONGCAT_API_KEY=...
|
||||
export GMI_API_KEY=...
|
||||
```
|
||||
|
||||
要直接配置美团 LongCat(LongCat-2.0),运行:
|
||||
@@ -114,6 +185,12 @@ export LONGCAT_API_KEY=...
|
||||
zero providers setup longcat --set-active
|
||||
```
|
||||
|
||||
要直接配置 GMI Cloud,运行:
|
||||
|
||||
```bash
|
||||
zero providers setup gmicloud --set-active
|
||||
```
|
||||
|
||||
对于本地模型,运行 Ollama、LM Studio 或 [Atomic Chat](https://atomic.chat) 桌面应用,
|
||||
然后使用 `zero setup` 或 `zero providers detect`。使用 Atomic Chat 时,请先加载模型并启用
|
||||
本地 OpenAI 兼容 API(默认地址为 `http://127.0.0.1:1337/v1`),再选择 `atomic-chat-local`。
|
||||
@@ -218,7 +295,7 @@ zero context 上下文预算报告
|
||||
zero repo-map 确定性仓库映射
|
||||
zero repo-info 本地仓库摘要
|
||||
zero search | find 搜索本地会话历史
|
||||
zero sessions 检查、恢复、分叉和回滚会话
|
||||
zero sessions 检查、恢复、分叉、回滚和清理会话
|
||||
zero spec 管理规范模式草稿
|
||||
zero specialist 管理专业子智能体
|
||||
zero skills 管理 Markdown 指令技能
|
||||
|
||||
@@ -302,8 +302,9 @@ Conceptually, one requested tool goes through these phases:
|
||||
the model can inspect it and decide what to do next.
|
||||
6. **Render and persist** — the result is surfaced to the TUI, stream-JSON
|
||||
writer, or ACP client. TUI and exec record rich tool/session events for
|
||||
resume/history; ACP currently persists conversational messages and streams
|
||||
tool/permission updates to the client.
|
||||
resume/history; ACP persists conversational messages and completed tool-call
|
||||
starts/results for load replay. Live streaming details and permission
|
||||
exchanges remain client notifications rather than durable history.
|
||||
|
||||
Tool success and failure are both informative context. A successful `read_file`
|
||||
result gives the model file contents; a failed `edit_file` result gives the model
|
||||
|
||||
@@ -153,7 +153,7 @@ describe a version that is no longer on disk. Prefer `mise upgrade`.
|
||||
go install github.com/Gitlawb/zero/cmd/zero@latest
|
||||
```
|
||||
|
||||
This builds from source, so it needs Go 1.26.6+ and it does not go through the
|
||||
This builds from source, so it needs Go 1.26.9+ and it does not go through the
|
||||
release archives. Two consequences worth knowing before you pick it:
|
||||
|
||||
- On Linux you also need the sandbox helper, which is a separate binary and is
|
||||
@@ -180,7 +180,7 @@ Build a local binary:
|
||||
go build -o zero ./cmd/zero
|
||||
```
|
||||
|
||||
Source builds require Go 1.26.6+.
|
||||
Source builds require Go 1.26.9+.
|
||||
|
||||
### Sandbox Helpers For Source Builds
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Local per-model completion limits
|
||||
|
||||
Add `modelRPM` to the existing Zero config file:
|
||||
|
||||
```json
|
||||
{"modelRPM": {"gpt-4.1": 30, "claude-sonnet-4.5": 15}}
|
||||
```
|
||||
|
||||
Keys resolve through the model registry; custom IDs match exactly after trimming
|
||||
whitespace. Missing keys and zero mean unlimited. Negative limits and conflicting
|
||||
aliases are configuration errors. Project config may add or tighten a user cap,
|
||||
but cannot relax it. Changes take effect on the next Zero process start.
|
||||
|
||||
For `zero exec` and interactive agent turns, admission happens immediately before
|
||||
the wrapped turn session's `Stream`. N admissions fit in any sliding 60-second
|
||||
window; N+1 returns a local rate-limit error before entering that method. The
|
||||
error reports when a slot expires. Exec uses its existing provider-error exit
|
||||
code plus a local-limit hint. There is no sleep or hidden retry. Failed admitted
|
||||
requests still consume a slot.
|
||||
|
||||
Sessions and model switches share a limiter in one process. Separate processes
|
||||
have independent windows; restarting clears them. This is not an account-wide
|
||||
cap. It counts Stream admissions, not all HTTP requests: setup/prewarm,
|
||||
compaction, provider-internal retries, discovery and direct calls outside that
|
||||
boundary are not counted. No TPM, cost budgets, persistence, cross-process
|
||||
coordination or new runtime/Python dependency is introduced.
|
||||
@@ -6,6 +6,7 @@ Zero.
|
||||
## User Docs
|
||||
|
||||
- [Install](INSTALL.md)
|
||||
- [Local per-model completion limits](MODEL_RPM.md)
|
||||
- [Update flow](UPDATE.md)
|
||||
- [OAuth logins and subscription-backed providers](oauth-subscriptions.md)
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" width="80" height="80">
|
||||
<circle cx="6.4" cy="6.4" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="6.4" r="1.3440793933950872" fill="#FFFFFF"/><circle cx="25.6" cy="6.4" r="2.03724340327418" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="6.4" r="2.5983501502741575" fill="#FFFFFF"/><circle cx="44.8" cy="6.4" r="3.087883576356698" fill="#FFFFFF"/><circle cx="54.4" cy="6.4" r="3.530261898078346" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="6.4" r="3.938362368365323" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="6.4" r="4.32" fill="#FFFFFF"/><circle cx="6.4" cy="16" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="16" r="1.2253406720005793" fill="#FFFFFF"/><circle cx="25.6" cy="16" r="1.8572691561702617" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="16" r="2.3688065860361402" fill="#FFFFFF"/><circle cx="44.8" cy="16" r="2.8150936284760535" fill="#FFFFFF"/><circle cx="54.4" cy="16" r="3.2183913448763177" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="16" r="3.5904393853139163" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="16" r="3.9383623683653233" fill="#FFFFFF"/><circle cx="6.4" cy="25.6" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="25.6" r="1.0983685811330859" fill="#FFFFFF"/><circle cx="25.6" cy="25.6" r="1.6648154545579388" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="25.6" r="2.1233464197636582" fill="#FFFFFF"/><circle cx="44.8" cy="25.6" r="2.5233883646560065" fill="#FFFFFF"/><circle cx="54.4" cy="25.6" r="2.8848956178295655" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="25.6" r="3.2183913448763177" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="25.6" r="3.530261898078346" fill="#FFFFFF"/><circle cx="6.4" cy="35.199999999999996" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="35.199999999999996" r="0.9607316398574451" fill="#FFFFFF"/><circle cx="25.6" cy="35.199999999999996" r="1.456196862502631" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="35.199999999999996" r="1.8572691561702614" fill="#FFFFFF"/><circle cx="44.8" cy="35.199999999999996" r="2.2071817085957024" fill="#FFFFFF"/><circle cx="54.4" cy="35.199999999999996" r="2.5233883646560065" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="35.199999999999996" r="2.815093628476053" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="35.199999999999996" r="3.087883576356698" fill="#FFFFFF"/><circle cx="6.4" cy="44.8" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="44.8" r="0.8084233550482692" fill="#FFFFFF"/><circle cx="25.6" cy="44.8" r="1.2253406720005793" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="44.8" r="1.56282951468281" fill="#FFFFFF"/><circle cx="44.8" cy="44.8" r="1.8572691561702617" fill="#FFFFFF"/><circle cx="54.4" cy="44.8" r="2.1233464197636582" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="44.8" r="2.3688065860361402" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="44.8" r="2.5983501502741584" fill="#FFFFFF"/><circle cx="6.4" cy="54.4" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="54.4" r="0.633846499460857" fill="#FFFFFF"/><circle cx="25.6" cy="54.4" r="0.9607316398574451" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="54.4" r="1.225340672000579" fill="#FFFFFF"/><circle cx="44.8" cy="54.4" r="1.456196862502631" fill="#FFFFFF"/><circle cx="54.4" cy="54.4" r="1.6648154545579388" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="54.4" r="1.8572691561702614" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="54.4" r="2.03724340327418" fill="#FFFFFF"/><circle cx="6.4" cy="63.99999999999999" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="63.99999999999999" r="0.4181827351271541" fill="#FFFFFF"/><circle cx="25.6" cy="63.99999999999999" r="0.6338464994608571" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="63.99999999999999" r="0.8084233550482695" fill="#FFFFFF"/><circle cx="44.8" cy="63.99999999999999" r="0.9607316398574454" fill="#FFFFFF"/><circle cx="54.4" cy="63.99999999999999" r="1.0983685811330863" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="63.99999999999999" r="1.2253406720005795" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="63.99999999999999" r="1.3440793933950876" fill="#FFFFFF"/><circle cx="6.4" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="16" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="25.6" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="35.199999999999996" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="44.8" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="54.4" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="63.99999999999999" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/><circle cx="73.60000000000001" cy="73.60000000000001" r="0.3456" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" width="80" height="80">
|
||||
<circle cx="6.4" cy="6.4" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="6.4" r="1.3440793933950872" fill="#0D0E10"/><circle cx="25.6" cy="6.4" r="2.03724340327418" fill="#0D0E10"/><circle cx="35.199999999999996" cy="6.4" r="2.5983501502741575" fill="#0D0E10"/><circle cx="44.8" cy="6.4" r="3.087883576356698" fill="#0D0E10"/><circle cx="54.4" cy="6.4" r="3.530261898078346" fill="#0D0E10"/><circle cx="63.99999999999999" cy="6.4" r="3.938362368365323" fill="#0D0E10"/><circle cx="73.60000000000001" cy="6.4" r="4.32" fill="#0D0E10"/><circle cx="6.4" cy="16" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="16" r="1.2253406720005793" fill="#0D0E10"/><circle cx="25.6" cy="16" r="1.8572691561702617" fill="#0D0E10"/><circle cx="35.199999999999996" cy="16" r="2.3688065860361402" fill="#0D0E10"/><circle cx="44.8" cy="16" r="2.8150936284760535" fill="#0D0E10"/><circle cx="54.4" cy="16" r="3.2183913448763177" fill="#0D0E10"/><circle cx="63.99999999999999" cy="16" r="3.5904393853139163" fill="#0D0E10"/><circle cx="73.60000000000001" cy="16" r="3.9383623683653233" fill="#0D0E10"/><circle cx="6.4" cy="25.6" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="25.6" r="1.0983685811330859" fill="#0D0E10"/><circle cx="25.6" cy="25.6" r="1.6648154545579388" fill="#0D0E10"/><circle cx="35.199999999999996" cy="25.6" r="2.1233464197636582" fill="#0D0E10"/><circle cx="44.8" cy="25.6" r="2.5233883646560065" fill="#0D0E10"/><circle cx="54.4" cy="25.6" r="2.8848956178295655" fill="#0D0E10"/><circle cx="63.99999999999999" cy="25.6" r="3.2183913448763177" fill="#0D0E10"/><circle cx="73.60000000000001" cy="25.6" r="3.530261898078346" fill="#0D0E10"/><circle cx="6.4" cy="35.199999999999996" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="35.199999999999996" r="0.9607316398574451" fill="#0D0E10"/><circle cx="25.6" cy="35.199999999999996" r="1.456196862502631" fill="#0D0E10"/><circle cx="35.199999999999996" cy="35.199999999999996" r="1.8572691561702614" fill="#0D0E10"/><circle cx="44.8" cy="35.199999999999996" r="2.2071817085957024" fill="#0D0E10"/><circle cx="54.4" cy="35.199999999999996" r="2.5233883646560065" fill="#0D0E10"/><circle cx="63.99999999999999" cy="35.199999999999996" r="2.815093628476053" fill="#0D0E10"/><circle cx="73.60000000000001" cy="35.199999999999996" r="3.087883576356698" fill="#0D0E10"/><circle cx="6.4" cy="44.8" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="44.8" r="0.8084233550482692" fill="#0D0E10"/><circle cx="25.6" cy="44.8" r="1.2253406720005793" fill="#0D0E10"/><circle cx="35.199999999999996" cy="44.8" r="1.56282951468281" fill="#0D0E10"/><circle cx="44.8" cy="44.8" r="1.8572691561702617" fill="#0D0E10"/><circle cx="54.4" cy="44.8" r="2.1233464197636582" fill="#0D0E10"/><circle cx="63.99999999999999" cy="44.8" r="2.3688065860361402" fill="#0D0E10"/><circle cx="73.60000000000001" cy="44.8" r="2.5983501502741584" fill="#0D0E10"/><circle cx="6.4" cy="54.4" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="54.4" r="0.633846499460857" fill="#0D0E10"/><circle cx="25.6" cy="54.4" r="0.9607316398574451" fill="#0D0E10"/><circle cx="35.199999999999996" cy="54.4" r="1.225340672000579" fill="#0D0E10"/><circle cx="44.8" cy="54.4" r="1.456196862502631" fill="#0D0E10"/><circle cx="54.4" cy="54.4" r="1.6648154545579388" fill="#0D0E10"/><circle cx="63.99999999999999" cy="54.4" r="1.8572691561702614" fill="#0D0E10"/><circle cx="73.60000000000001" cy="54.4" r="2.03724340327418" fill="#0D0E10"/><circle cx="6.4" cy="63.99999999999999" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="63.99999999999999" r="0.4181827351271541" fill="#0D0E10"/><circle cx="25.6" cy="63.99999999999999" r="0.6338464994608571" fill="#0D0E10"/><circle cx="35.199999999999996" cy="63.99999999999999" r="0.8084233550482695" fill="#0D0E10"/><circle cx="44.8" cy="63.99999999999999" r="0.9607316398574454" fill="#0D0E10"/><circle cx="54.4" cy="63.99999999999999" r="1.0983685811330863" fill="#0D0E10"/><circle cx="63.99999999999999" cy="63.99999999999999" r="1.2253406720005795" fill="#0D0E10"/><circle cx="73.60000000000001" cy="63.99999999999999" r="1.3440793933950876" fill="#0D0E10"/><circle cx="6.4" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="16" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="25.6" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="35.199999999999996" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="44.8" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="54.4" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="63.99999999999999" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/><circle cx="73.60000000000001" cy="73.60000000000001" r="0.3456" fill="#0D0E10"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg width="128" height="40" viewBox="0 0 413 129" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M106.417 9.30741C106.417 9.57392 106.324 9.83227 106.155 10.0388L61.5395 64.5L106.155 118.96C106.324 119.166 106.417 119.425 106.417 119.691V127.843C106.417 128.482 105.897 129 105.255 129H1.16175C0.520132 129 0 128.482 0 127.843V1.15695C0 0.517982 0.520131 0 1.16174 0H105.255C105.897 0 106.417 0.517984 106.417 1.15695V9.30741ZM19.0364 116.382H87.6921L53.363 74.4795L19.0364 116.382ZM12.6699 104.192L40.1426 70.6585H12.6699V104.192ZM12.6699 58.0409H39.8964L12.6699 24.8064V58.0409ZM53.363 54.5193L87.6921 12.6176H19.0364L53.363 54.5193Z" fill="white"/>
|
||||
<path d="M176.198 117.65C166.094 117.65 157.323 115.553 149.886 111.36C142.588 107.167 136.905 101.017 132.835 92.911C128.906 84.8045 126.941 75.0208 126.941 63.5599C126.941 53.3569 128.976 44.2022 133.046 36.0957C137.256 27.9892 143.01 21.6298 150.307 17.0175C157.744 12.4052 166.024 10.099 175.146 10.099C185.109 10.099 193.599 12.3353 200.616 16.8078C207.633 21.1406 212.965 27.3602 216.614 35.4667C220.263 43.4334 222.087 52.8677 222.087 63.7695V66.1766C222.087 66.8156 221.567 67.3336 220.925 67.3336H145.044C145.185 74.881 146.588 81.3802 149.254 86.8311C152.061 92.282 155.78 96.475 160.411 99.4101C165.182 102.205 170.515 103.603 176.409 103.603C183.285 103.603 189.179 102.066 194.091 98.9908C198.779 95.9225 201.741 91.008 202.977 84.2471C203.08 83.6853 203.565 83.267 204.139 83.267H219.737C220.429 83.267 220.969 83.8669 220.871 84.5494C220.038 90.3376 217.988 95.6406 214.72 100.458C211.211 105.63 206.159 109.823 199.564 113.037C193.108 116.112 185.32 117.65 176.198 117.65ZM202.721 54.5449C202.721 45.3203 200.265 37.9825 195.354 32.5316C190.582 27.0807 183.846 24.3552 175.146 24.3552C166.866 24.3552 160.06 27.2205 154.727 32.9509C149.395 38.6813 146.377 45.8794 145.676 54.5449H202.721Z" fill="white"/>
|
||||
<path d="M364.585 117.65C358.13 117.65 352.236 116.392 346.903 113.876C341.571 111.36 337.361 107.796 334.273 103.184C331.186 98.4317 329.642 92.9808 329.642 86.8311C329.642 77.7462 332.73 70.688 338.904 65.6564C345.219 60.485 353.359 57.0607 363.322 55.3835L385.004 52.0291C387.67 51.6098 389.705 51.0507 391.108 50.3519C392.652 49.5133 393.704 48.465 394.266 47.2071C394.967 45.8095 395.318 44.0624 395.318 41.9659C395.318 36.515 393.424 32.2521 389.635 29.1772C385.846 25.9626 380.934 24.3552 374.9 24.3552C367.322 24.3552 361.498 26.2421 357.428 30.0158C353.595 33.4387 351.443 38.1016 350.971 44.0046C350.922 44.622 350.412 45.1106 349.79 45.1106H334.014C333.353 45.1106 332.823 44.5605 332.872 43.9032C333.312 38.0771 335.042 32.6792 338.062 27.7096C341.29 22.3985 345.921 18.1356 351.955 14.921C358.13 11.7063 365.287 10.099 373.426 10.099C381.706 10.099 388.793 11.6364 394.687 14.7113C400.721 17.7862 405.282 22.1888 408.369 27.9193C411.456 33.6497 413 40.4284 413 48.2554V113.348C413 113.987 412.48 114.505 411.838 114.505H396.901C396.259 114.505 395.739 113.987 395.739 113.348V109.473C395.739 108.076 395.739 106.678 395.739 105.28C395.739 103.743 395.739 102.275 395.739 100.878C393.213 106.189 389.214 110.312 383.741 113.247C378.408 116.182 372.023 117.65 364.585 117.65ZM367.111 103.603C372.444 103.603 377.215 102.485 381.425 100.249C385.775 97.8727 389.143 94.6581 391.529 90.6048C394.055 86.4118 395.318 81.6597 395.318 76.3486V61.4634C394.757 62.302 393.564 63.0707 391.74 63.7695C389.915 64.4684 387.389 65.1672 384.162 65.866L369.427 68.5915C362.971 69.8494 357.849 71.9459 354.06 74.881C350.271 77.8161 348.377 81.7995 348.377 86.8311C348.377 91.8627 350.131 95.916 353.639 98.9908C357.288 102.066 361.779 103.603 367.111 103.603Z" fill="white"/>
|
||||
<path d="M285.673 62.9573L325.127 112.948C325.726 113.707 325.183 114.82 324.213 114.82H305.929C305.572 114.82 305.235 114.657 305.015 114.378L275.053 76.414L245.093 114.378C244.872 114.657 244.536 114.82 244.179 114.82H225.893C224.924 114.82 224.381 113.707 224.98 112.948L264.433 62.9573L227.177 15.7512C226.578 14.9921 227.121 13.8793 228.091 13.8793H246.375C246.732 13.8793 247.068 14.0423 247.289 14.3214L275.053 49.5006L302.818 14.3214C303.038 14.0423 303.375 13.8793 303.731 13.8793H322.016C322.985 13.8793 323.528 14.9921 322.929 15.7512L285.673 62.9573Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg width="128" height="40" viewBox="0 0 413 129" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M106.417 9.30741C106.417 9.57392 106.324 9.83227 106.155 10.0388L61.5395 64.5L106.155 118.96C106.324 119.166 106.417 119.425 106.417 119.691V127.843C106.417 128.482 105.897 129 105.255 129H1.16175C0.520132 129 0 128.482 0 127.843V1.15695C0 0.517982 0.520131 0 1.16174 0H105.255C105.897 0 106.417 0.517984 106.417 1.15695V9.30741ZM19.0364 116.382H87.6921L53.363 74.4795L19.0364 116.382ZM12.6699 104.192L40.1426 70.6585H12.6699V104.192ZM12.6699 58.0409H39.8964L12.6699 24.8064V58.0409ZM53.363 54.5193L87.6921 12.6176H19.0364L53.363 54.5193Z" fill="#0143D9"/>
|
||||
<path d="M176.198 117.65C166.094 117.65 157.323 115.553 149.886 111.36C142.588 107.167 136.905 101.017 132.835 92.911C128.906 84.8045 126.941 75.0208 126.941 63.5599C126.941 53.3569 128.976 44.2022 133.046 36.0957C137.256 27.9892 143.01 21.6298 150.307 17.0175C157.744 12.4052 166.024 10.099 175.146 10.099C185.109 10.099 193.599 12.3353 200.616 16.8078C207.633 21.1406 212.965 27.3602 216.614 35.4667C220.263 43.4334 222.087 52.8677 222.087 63.7695V66.1766C222.087 66.8156 221.567 67.3336 220.925 67.3336H145.044C145.185 74.881 146.588 81.3802 149.254 86.8311C152.061 92.282 155.78 96.475 160.411 99.4101C165.182 102.205 170.515 103.603 176.409 103.603C183.285 103.603 189.179 102.066 194.091 98.9908C198.779 95.9225 201.741 91.008 202.977 84.2471C203.08 83.6853 203.565 83.267 204.139 83.267H219.737C220.429 83.267 220.969 83.8669 220.871 84.5494C220.038 90.3376 217.988 95.6406 214.72 100.458C211.211 105.63 206.159 109.823 199.564 113.037C193.108 116.112 185.32 117.65 176.198 117.65ZM202.721 54.5449C202.721 45.3203 200.265 37.9825 195.354 32.5316C190.582 27.0807 183.846 24.3552 175.146 24.3552C166.866 24.3552 160.06 27.2205 154.727 32.9509C149.395 38.6813 146.377 45.8794 145.676 54.5449H202.721Z" fill="black"/>
|
||||
<path d="M364.585 117.65C358.13 117.65 352.236 116.392 346.903 113.876C341.571 111.36 337.361 107.796 334.273 103.184C331.186 98.4317 329.642 92.9808 329.642 86.8311C329.642 77.7462 332.73 70.688 338.904 65.6564C345.219 60.485 353.359 57.0607 363.322 55.3835L385.004 52.0291C387.67 51.6098 389.705 51.0507 391.108 50.3519C392.652 49.5133 393.704 48.465 394.266 47.2071C394.967 45.8095 395.318 44.0624 395.318 41.9659C395.318 36.515 393.424 32.2521 389.635 29.1772C385.846 25.9626 380.934 24.3552 374.9 24.3552C367.322 24.3552 361.498 26.2421 357.428 30.0158C353.595 33.4387 351.443 38.1016 350.971 44.0046C350.922 44.622 350.412 45.1106 349.79 45.1106H334.014C333.353 45.1106 332.823 44.5605 332.872 43.9032C333.312 38.0771 335.042 32.6792 338.062 27.7096C341.29 22.3985 345.921 18.1356 351.955 14.921C358.13 11.7063 365.287 10.099 373.426 10.099C381.706 10.099 388.793 11.6364 394.687 14.7113C400.721 17.7862 405.282 22.1888 408.369 27.9193C411.456 33.6497 413 40.4284 413 48.2554V113.348C413 113.987 412.48 114.505 411.838 114.505H396.901C396.259 114.505 395.739 113.987 395.739 113.348V109.473C395.739 108.076 395.739 106.678 395.739 105.28C395.739 103.743 395.739 102.275 395.739 100.878C393.213 106.189 389.214 110.312 383.741 113.247C378.408 116.182 372.023 117.65 364.585 117.65ZM367.111 103.603C372.444 103.603 377.215 102.485 381.425 100.249C385.775 97.8727 389.143 94.6581 391.529 90.6048C394.055 86.4118 395.318 81.6597 395.318 76.3486V61.4634C394.757 62.302 393.564 63.0707 391.74 63.7695C389.915 64.4684 387.389 65.1672 384.162 65.866L369.427 68.5915C362.971 69.8494 357.849 71.9459 354.06 74.881C350.271 77.8161 348.377 81.7995 348.377 86.8311C348.377 91.8627 350.131 95.916 353.639 98.9908C357.288 102.066 361.779 103.603 367.111 103.603Z" fill="black"/>
|
||||
<path d="M285.673 62.9573L325.127 112.948C325.726 113.707 325.183 114.82 324.213 114.82H305.929C305.572 114.82 305.235 114.657 305.015 114.378L275.053 76.414L245.093 114.378C244.872 114.657 244.536 114.82 244.179 114.82H225.893C224.924 114.82 224.381 113.707 224.98 112.948L264.433 62.9573L227.177 15.7512C226.578 14.9921 227.121 13.8793 228.091 13.8793H246.375C246.732 13.8793 247.068 14.0423 247.289 14.3214L275.053 49.5006L302.818 14.3214C303.038 14.0423 303.375 13.8793 303.731 13.8793H322.016C322.985 13.8793 323.528 14.9921 322.929 15.7512L285.673 62.9573Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 34 718 165" width="718" height="165" fill="#fff">
|
||||
<path d="M123.292 132.486h-33.1c-2.644 0-4.808-2.395-4.808-5.363v-21.436c0-2.95 2.148-5.363 4.809-5.363h33.099c2.645 0 4.808-2.395 4.808-5.363V73.525c0-2.95 2.147-5.363 4.808-5.363h33.099c2.646 0 4.809-2.395 4.809-5.363V41.363c0-2.95-2.148-5.363-4.809-5.363H90.208c-2.645 0-4.808 2.395-4.808 5.363V62.8c0 2.95-2.147 5.363-4.808 5.363h-33.1c-2.645 0-4.808 2.395-4.808 5.363v21.436c0 2.95-2.147 5.363-4.808 5.363H4.808c-2.645 0-4.808 2.395-4.808 5.363v21.436c0 2.95 2.147 5.363 4.808 5.363h33.1c2.645 0 4.808 2.395 4.808 5.363v21.436c0 2.95 2.147 5.363 4.808 5.363h33.099c2.645 0 4.808 2.395 4.808 5.363v21.436c0 2.95 2.148 5.363 4.809 5.363h75.798c2.646 0 4.809-2.395 4.809-5.363v-21.436c0-2.95-2.148-5.363-4.809-5.363H132.94c-2.646 0-4.809-2.395-4.809-5.363v-21.436c0-2.95-2.147-5.363-4.808-5.363z"/>
|
||||
<path d="M251.408 100.341c2.646 0 4.809 2.395 4.809 5.363v21.436c0 2.95-2.148 5.363-4.809 5.363H218.31c-2.646 0-4.809 2.395-4.809 5.363v21.436c0 2.95-2.147 5.363-4.808 5.363h-33.099c-2.645 0-4.808-2.395-4.808-5.363v-53.58c0-2.951 2.147-5.364 4.808-5.364h75.799zM510.68 40.105h-28.795v153.33h28.795zM717.43 40.105h-28.795v153.33h28.795z"/>
|
||||
<path d="M532.41 40.105h-28.83l35.391 153.33H567.8zM653.242 40.105h-28.795v153.33h28.795z"/>
|
||||
<path d="M596.594 193.435h-28.795l35.39-153.33h28.795zM452.028 114.062v27.042s-.139.174-.191.261h-83.069V114.08h83.295-.035z"/>
|
||||
<path d="M452.028 141.347v35.651c-17.479 12.306-39.834 19.769-62.953 19.769-59.673 0-97.858-35.981-97.858-80.362s34.54-80.362 97.858-80.362c22.511 0 45.127 7.689 62.953 20.36v35.685c-13.105-16.42-37.856-28.743-62.953-28.743-44.052 0-70.695 20.846-70.695 53.077 0 32.232 28.691 53.077 70.695 53.077 25.323 0 49.727-11.906 62.744-28.1h.191v-.035z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 34 718 165" width="718" height="165" fill="#0D0E10">
|
||||
<path d="M123.292 132.486h-33.1c-2.644 0-4.808-2.395-4.808-5.363v-21.436c0-2.95 2.148-5.363 4.809-5.363h33.099c2.645 0 4.808-2.395 4.808-5.363V73.525c0-2.95 2.147-5.363 4.808-5.363h33.099c2.646 0 4.809-2.395 4.809-5.363V41.363c0-2.95-2.148-5.363-4.809-5.363H90.208c-2.645 0-4.808 2.395-4.808 5.363V62.8c0 2.95-2.147 5.363-4.808 5.363h-33.1c-2.645 0-4.808 2.395-4.808 5.363v21.436c0 2.95-2.147 5.363-4.808 5.363H4.808c-2.645 0-4.808 2.395-4.808 5.363v21.436c0 2.95 2.147 5.363 4.808 5.363h33.1c2.645 0 4.808 2.395 4.808 5.363v21.436c0 2.95 2.147 5.363 4.808 5.363h33.099c2.645 0 4.808 2.395 4.808 5.363v21.436c0 2.95 2.148 5.363 4.809 5.363h75.798c2.646 0 4.809-2.395 4.809-5.363v-21.436c0-2.95-2.148-5.363-4.809-5.363H132.94c-2.646 0-4.809-2.395-4.809-5.363v-21.436c0-2.95-2.147-5.363-4.808-5.363z"/>
|
||||
<path d="M251.408 100.341c2.646 0 4.809 2.395 4.809 5.363v21.436c0 2.95-2.148 5.363-4.809 5.363H218.31c-2.646 0-4.809 2.395-4.809 5.363v21.436c0 2.95-2.147 5.363-4.808 5.363h-33.099c-2.645 0-4.808-2.395-4.808-5.363v-53.58c0-2.951 2.147-5.364 4.808-5.364h75.799zM510.68 40.105h-28.795v153.33h28.795zM717.43 40.105h-28.795v153.33h28.795z"/>
|
||||
<path d="M532.41 40.105h-28.83l35.391 153.33H567.8zM653.242 40.105h-28.795v153.33h28.795z"/>
|
||||
<path d="M596.594 193.435h-28.795l35.39-153.33h28.795zM452.028 114.062v27.042s-.139.174-.191.261h-83.069V114.08h83.295-.035z"/>
|
||||
<path d="M452.028 141.347v35.651c-17.479 12.306-39.834 19.769-62.953 19.769-59.673 0-97.858-35.981-97.858-80.362s34.54-80.362 97.858-80.362c22.511 0 45.127 7.689 62.953 20.36v35.685c-13.105-16.42-37.856-28.743-62.953-28.743-44.052 0-70.695 20.846-70.695 53.077 0 32.232 28.691 53.077 70.695 53.077 25.323 0 49.727-11.906 62.744-28.1h.191v-.035z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg width="151" height="30" viewBox="0 0 151 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_novita_dark)">
|
||||
<path d="M17.2639 0.640625V12.1297L0.03125 29.363H17.2639V17.8739H17.7376L28.7529 29.363H45.9875L17.2639 0.640625Z" fill="white"/>
|
||||
</g>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M126.329 25.5459H131.778V28.9895H127.454C124.427 28.9895 122.523 27.3247 122.523 24.2256V15.0698H119.438V11.5974H122.523L123.417 7.03393H126.3V11.5974H131.778V15.0698H126.329V25.5459ZM61.9158 6.0293L69.8449 23.7083H70.0753V6.0293H74.1983V28.9895H67.97L60.0409 11.3105H59.8105V28.9895H55.6875V6.0293H61.9158ZM84.78 29.334C79.734 29.334 76.5052 25.804 76.5052 20.3223C76.5052 14.8406 79.6764 11.2529 84.78 11.2529C89.8836 11.2529 92.9971 14.6966 92.9971 20.3223C92.9971 25.948 89.826 29.334 84.78 29.334ZM84.78 25.8904C87.0293 25.8904 88.903 25.115 88.9606 22.9053V17.7393C88.9606 15.4719 87.0581 14.6966 84.78 14.6966C82.5019 14.6966 80.5418 15.4719 80.5418 17.7393V22.9053C80.5994 25.1438 82.5307 25.8904 84.78 25.8904ZM97.4094 11.5686L101.907 25.3443H102.137L106.635 11.5686H110.787L104.587 28.9607H99.4547L93.284 11.5686H97.407H97.4094ZM112.922 11.5974H116.728V28.9895H112.922V11.5974ZM112.69 6.0293H116.957V9.30127H112.69V6.0293ZM141.12 29.3628C136.477 29.3628 133.508 25.8328 133.508 20.3223C133.508 14.8118 136.42 11.2817 141.12 11.2817C143.312 11.2817 145.099 12.1423 146.166 13.7495H146.397L147.147 11.5974H150.03V28.9895H147.147L146.397 26.8938H146.166C145.013 28.4146 143.081 29.3616 141.12 29.3616V29.3628ZM141.898 25.8904C144.176 25.8904 146.165 25.1726 146.223 22.9053V17.7393C146.223 15.4143 144.146 14.7542 141.812 14.7542C139.477 14.7542 137.545 15.4719 137.545 17.7393V22.9053C137.602 25.2014 139.592 25.8904 141.898 25.8904Z" fill="white"/>
|
||||
<defs>
|
||||
<clipPath id="clip0_novita_dark">
|
||||
<rect width="45.9562" height="28.7223" fill="white" transform="translate(0.0292969 0.640625)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg width="151" height="30" viewBox="0 0 151 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_125_11573)">
|
||||
<path d="M17.2639 0.640625V12.1297L0.03125 29.363H17.2639V17.8739H17.7376L28.7529 29.363H45.9875L17.2639 0.640625Z" fill="black"/>
|
||||
</g>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M126.329 25.5459H131.778V28.9895H127.454C124.427 28.9895 122.523 27.3247 122.523 24.2256V15.0698H119.438V11.5974H122.523L123.417 7.03393H126.3V11.5974H131.778V15.0698H126.329V25.5459ZM61.9158 6.0293L69.8449 23.7083H70.0753V6.0293H74.1983V28.9895H67.97L60.0409 11.3105H59.8105V28.9895H55.6875V6.0293H61.9158ZM84.78 29.334C79.734 29.334 76.5052 25.804 76.5052 20.3223C76.5052 14.8406 79.6764 11.2529 84.78 11.2529C89.8836 11.2529 92.9971 14.6966 92.9971 20.3223C92.9971 25.948 89.826 29.334 84.78 29.334ZM84.78 25.8904C87.0293 25.8904 88.903 25.115 88.9606 22.9053V17.7393C88.9606 15.4719 87.0581 14.6966 84.78 14.6966C82.5019 14.6966 80.5418 15.4719 80.5418 17.7393V22.9053C80.5994 25.1438 82.5307 25.8904 84.78 25.8904ZM97.4094 11.5686L101.907 25.3443H102.137L106.635 11.5686H110.787L104.587 28.9607H99.4547L93.284 11.5686H97.407H97.4094ZM112.922 11.5974H116.728V28.9895H112.922V11.5974ZM112.69 6.0293H116.957V9.30127H112.69V6.0293ZM141.12 29.3628C136.477 29.3628 133.508 25.8328 133.508 20.3223C133.508 14.8118 136.42 11.2817 141.12 11.2817C143.312 11.2817 145.099 12.1423 146.166 13.7495H146.397L147.147 11.5974H150.03V28.9895H147.147L146.397 26.8938H146.166C145.013 28.4146 143.081 29.3616 141.12 29.3616V29.3628ZM141.898 25.8904C144.176 25.8904 146.165 25.1726 146.223 22.9053V17.7393C146.223 15.4143 144.146 14.7542 141.812 14.7542C139.477 14.7542 137.545 15.4719 137.545 17.7393V22.9053C137.602 25.2014 139.592 25.8904 141.898 25.8904Z" fill="black"/>
|
||||
<defs>
|
||||
<clipPath id="clip0_125_11573">
|
||||
<rect width="45.9562" height="28.7223" fill="white" transform="translate(0.0292969 0.640625)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -1,6 +1,6 @@
|
||||
module github.com/Gitlawb/zero
|
||||
|
||||
go 1.26.6
|
||||
go 1.26.9
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.1.1
|
||||
|
||||
@@ -2,15 +2,20 @@ package acp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodelcatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
@@ -40,8 +45,12 @@ type Deps struct {
|
||||
// ResolveWorkspaceRoot validates + normalizes a client-supplied cwd (must be an
|
||||
// existing directory; never the bare root). It is the file-tool confinement root.
|
||||
ResolveWorkspaceRoot func(cwd string) (string, error)
|
||||
Store *sessions.Store
|
||||
AgentInfo Implementation
|
||||
// PersistEvents is an optional test seam for injecting event-store failures.
|
||||
// It receives a COMPLETED TURN as one batch, never one event at a time: the
|
||||
// batch is the unit the shared store keeps contiguous (see persistTurn).
|
||||
PersistEvents func(sessionID string, inputs []sessions.AppendEventInput) error
|
||||
Store *sessions.Store
|
||||
AgentInfo Implementation
|
||||
}
|
||||
|
||||
// Workspace is the per-turn execution environment passed to the agent. ACP does
|
||||
@@ -98,6 +107,14 @@ type acpSession struct {
|
||||
restrictModels bool
|
||||
cancel context.CancelFunc
|
||||
history []turnRecord
|
||||
|
||||
// readOnly marks a session published by session/load for a kind that is
|
||||
// not resumable (a child, side or spec sub-run). Load still replays its
|
||||
// transcript, which is the render-only use a client needs, but a prompt
|
||||
// would continue the sub-run as a standalone conversation, which every
|
||||
// other resume path refuses (sessions.IsResumableKind). Set once when the
|
||||
// session is first registered and never changed, so it is read without mu.
|
||||
readOnly bool
|
||||
}
|
||||
|
||||
// NewAgent builds the ACP server and registers its method handlers on conn.
|
||||
@@ -106,6 +123,8 @@ func NewAgent(conn *Conn, deps Deps) *Agent {
|
||||
conn.Handle(MethodInitialize, a.handleInitialize)
|
||||
conn.Handle(MethodSessionNew, a.handleSessionNew)
|
||||
conn.Handle(MethodSessionLoad, a.handleSessionLoad)
|
||||
conn.Handle(MethodSessionList, a.handleSessionList)
|
||||
conn.Handle(MethodSessionResume, a.handleSessionResume)
|
||||
conn.Handle(MethodSessionPrompt, a.handleSessionPrompt)
|
||||
conn.Handle(MethodSessionSetMode, a.handleSetMode)
|
||||
conn.Handle(MethodSessionSetConfigOption, a.handleSetConfigOption)
|
||||
@@ -134,11 +153,11 @@ func (a *Agent) handleInitialize(_ context.Context, params json.RawMessage) (any
|
||||
return InitializeResult{
|
||||
ProtocolVersion: negotiated,
|
||||
AgentCapabilities: AgentCapabilities{
|
||||
// Only advertise what ZERO actually implements: session/load (loadSession)
|
||||
// and image prompts. session/resume + the session-capability sub-object
|
||||
// are intentionally omitted since there is no resume handler yet.
|
||||
LoadSession: true,
|
||||
PromptCapabilities: PromptCapabilities{Image: true},
|
||||
// ACP v1 optional methods are advertised as empty capability objects;
|
||||
// clients must gate session/list and session/resume on their presence.
|
||||
LoadSession: true,
|
||||
PromptCapabilities: PromptCapabilities{Image: true},
|
||||
SessionCapabilities: &SessionCapabilities{List: &struct{}{}, Resume: &struct{}{}},
|
||||
},
|
||||
AgentInfo: &info,
|
||||
// ZERO owns credentials (BYOK) and does not delegate auth to the editor.
|
||||
@@ -148,12 +167,41 @@ func (a *Agent) handleInitialize(_ context.Context, params json.RawMessage) (any
|
||||
|
||||
// ---- session lifecycle ----
|
||||
|
||||
// requestedWorkspace is the single rule for a cwd that arrived from the client,
|
||||
// applied before the value can reach ResolveWorkspaceRoot.
|
||||
//
|
||||
// THE REQUEST'S OWN CWD IS THE ONLY THING ALLOWED TO PICK A ROOT. Every params
|
||||
// type spells cwd as a plain string, so an absent field and an empty one decode
|
||||
// to exactly the same "", and the resolver treats "" as "use the directory this
|
||||
// process was started in" and joins a relative cwd onto it. Those two defaults
|
||||
// meet in the middle: `{"sessionId":"known"}` used to activate a persisted
|
||||
// session, and `{}` used to create one, both rooted at wherever the editor
|
||||
// happened to spawn `zero acp` — which then becomes the sandbox and file-tool
|
||||
// confinement root. The client never named that directory, so it is a root the
|
||||
// server invented, and ACP requires an absolute cwd on every method carrying
|
||||
// one. Rejecting here is what keeps a missing field from being answered with a
|
||||
// guess.
|
||||
func requestedWorkspace(cwd string) (string, error) {
|
||||
trimmed := strings.TrimSpace(cwd)
|
||||
if trimmed == "" {
|
||||
return "", RPCError(codeInvalidParams, "cwd is required and must be an absolute path")
|
||||
}
|
||||
if !filepath.IsAbs(trimmed) {
|
||||
return "", RPCError(codeInvalidParams, "cwd must be an absolute path: "+trimmed)
|
||||
}
|
||||
return trimmed, nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleSessionNew(ctx context.Context, params json.RawMessage) (any, error) {
|
||||
var p NewSessionParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, RPCError(codeInvalidParams, "invalid session/new params")
|
||||
}
|
||||
root, err := a.deps.ResolveWorkspaceRoot(p.Cwd)
|
||||
cwd, err := requestedWorkspace(p.Cwd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root, err := a.deps.ResolveWorkspaceRoot(cwd)
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInvalidParams, err.Error())
|
||||
}
|
||||
@@ -165,7 +213,7 @@ func (a *Agent) handleSessionNew(ctx context.Context, params json.RawMessage) (a
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInternalError, "create session: "+err.Error())
|
||||
}
|
||||
sess := a.registerSession(meta.SessionID, root, nil, model, models, restrictModels)
|
||||
sess, _ := a.registerSession(meta.SessionID, root, nil, model, models, restrictModels, false)
|
||||
return NewSessionResult{
|
||||
SessionID: sess.id,
|
||||
ConfigOptions: a.configOptions(sess),
|
||||
@@ -178,22 +226,96 @@ func (a *Agent) handleSessionLoad(ctx context.Context, params json.RawMessage) (
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, RPCError(codeInvalidParams, "invalid session/load params")
|
||||
}
|
||||
return a.activatePersistedSession(ctx, p, persistedSessionLoad)
|
||||
}
|
||||
|
||||
func (a *Agent) handleSessionResume(ctx context.Context, params json.RawMessage) (any, error) {
|
||||
var p ResumeSessionParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, RPCError(codeInvalidParams, "invalid session/resume params")
|
||||
}
|
||||
return a.activatePersistedSession(ctx, p, persistedSessionResume)
|
||||
}
|
||||
|
||||
type persistedSessionOperation uint8
|
||||
|
||||
const (
|
||||
persistedSessionLoad persistedSessionOperation = iota
|
||||
persistedSessionResume
|
||||
)
|
||||
|
||||
// activatePersistedSession restores the agent's internal conversation context
|
||||
// for both lifecycle methods. session/load additionally replays user-visible
|
||||
// history as ordered session/update notifications; session/resume deliberately
|
||||
// does not, which makes it safe for an already-rendered desktop reconnect.
|
||||
func (a *Agent) activatePersistedSession(ctx context.Context, p LoadSessionParams, operation persistedSessionOperation) (any, error) {
|
||||
// BOTH METHODS, BEFORE ANYTHING IS LOOKED UP. session/resume shares this
|
||||
// params type with session/load, so the wire cannot tell an omitted cwd from
|
||||
// an empty one; the blank case used to be answered with the persisted cwd,
|
||||
// which made {"sessionId":"known"} a complete, successful activation request.
|
||||
// Validating here rather than in one handler is what keeps the two entry
|
||||
// points from drifting apart.
|
||||
cwd, err := requestedWorkspace(p.Cwd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta, err := a.deps.Store.Get(p.SessionID)
|
||||
if err != nil || meta == nil {
|
||||
return nil, RPCError(codeInvalidParams, "session not found: "+p.SessionID)
|
||||
}
|
||||
cwdInput := p.Cwd
|
||||
if strings.TrimSpace(cwdInput) == "" {
|
||||
cwdInput = meta.Cwd
|
||||
if operation == persistedSessionResume && !sessions.IsResumableKind(meta.SessionKind) {
|
||||
return nil, RPCError(codeInvalidParams, "session is not resumable: "+p.SessionID)
|
||||
}
|
||||
root, err := a.deps.ResolveWorkspaceRoot(cwdInput)
|
||||
if strings.TrimSpace(meta.Cwd) == "" {
|
||||
return nil, RPCError(codeInvalidParams, "session has no persisted workspace: "+p.SessionID)
|
||||
}
|
||||
// SAME RULE ON THE WAY IN. Omitting a relative entry from the listing is not
|
||||
// enough: a stored "." still resolves against this process's directory, so a
|
||||
// client that hands back that same directory as its cwd would be sold the
|
||||
// invented root as a match. A workspace that cannot be identified is not one
|
||||
// this session can be restored into.
|
||||
if !filepath.IsAbs(meta.Cwd) {
|
||||
return nil, RPCError(codeInvalidParams, "session workspace is not an absolute path, so it cannot be identified: "+p.SessionID)
|
||||
}
|
||||
persistedRoot, err := a.deps.ResolveWorkspaceRoot(meta.Cwd)
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInvalidParams, "persisted session workspace is unavailable: "+err.Error())
|
||||
}
|
||||
root, err := a.deps.ResolveWorkspaceRoot(cwd)
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInvalidParams, err.Error())
|
||||
}
|
||||
// ACP session cwd is immutable. Loading history under a different root
|
||||
// would give a conversation from one workspace access to another
|
||||
// workspace's configuration, files, and tools.
|
||||
if !sameWorkspace(root, persistedRoot) {
|
||||
return nil, RPCError(codeInvalidParams, "session cwd does not match its persisted workspace")
|
||||
}
|
||||
// Load history BEFORE publishing the session so no concurrent prompt observes
|
||||
// a half-initialized session (registerSession sets history under the lock and
|
||||
// reuses an already-live session rather than orphaning its in-flight turn).
|
||||
history, historyErr := a.loadHistory(meta.SessionID)
|
||||
requireHistoryLog := operation == persistedSessionResume && meta.EventCount > 0
|
||||
history, messages, historyWarning, historyErr := a.loadHistory(meta.SessionID, requireHistoryLog)
|
||||
// RESUME PROMISES RESTORED CONTEXT, SO A FAILED RESTORATION IS A FAILED
|
||||
// RESUME. historyErr used to do nothing but suppress replay and raise a
|
||||
// warning: the session was registered and reported ready regardless, so a
|
||||
// corrupt record or an unreadable events file left the caller holding a live,
|
||||
// promptable session ID whose next prompt ran as a brand-new conversation
|
||||
// under the old identity. Nothing in the response said so.
|
||||
//
|
||||
// Publication is now gated on restoration for resume. LOAD keeps the
|
||||
// best-effort policy deliberately rather than by inheriting this helper: it
|
||||
// replays what it has and warns about the rest, which is the right trade for
|
||||
// a client rebuilding a transcript it can still scroll. Reported by @jatmn.
|
||||
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())
|
||||
@@ -204,12 +326,39 @@ func (a *Agent) handleSessionLoad(ctx context.Context, params json.RawMessage) (
|
||||
models = append(models, SessionConfigOptionValue{Value: persistedModel, Name: persistedModel})
|
||||
}
|
||||
}
|
||||
sess := a.registerSession(meta.SessionID, root, history, model, models, restrictModels)
|
||||
// Resume has already refused a non-resumable kind above, so only load can
|
||||
// reach here with one, and it publishes that session for viewing only.
|
||||
readOnly := !sessions.IsResumableKind(meta.SessionKind)
|
||||
sess, existed := a.registerSession(meta.SessionID, root, history, model, models, restrictModels, readOnly)
|
||||
if existed {
|
||||
_, messages, historyWarning, historyErr = a.refreshSessionHistory(sess, requireHistoryLog)
|
||||
if operation == persistedSessionResume && historyErr != nil {
|
||||
return nil, RPCError(codeInternalError, "restore session history: "+historyErr.Error())
|
||||
}
|
||||
}
|
||||
note := ¬ifier{conn: a.conn, sessionID: sess.id}
|
||||
if operation == persistedSessionLoad && historyErr == nil {
|
||||
for _, message := range messages {
|
||||
if message.tool != nil {
|
||||
note.send(*message.tool)
|
||||
continue
|
||||
}
|
||||
note.send(replayMessageChunk(message.role, replayMessageID(message.eventID), message.content))
|
||||
}
|
||||
}
|
||||
a.warnPersistence(
|
||||
¬ifier{conn: a.conn, sessionID: sess.id},
|
||||
note,
|
||||
"rehydrate session compaction",
|
||||
"Could not apply session compaction. Raw session history was restored instead.",
|
||||
historyWarning,
|
||||
operation == persistedSessionLoad,
|
||||
)
|
||||
a.warnPersistence(
|
||||
note,
|
||||
"load session history",
|
||||
"Could not load session history. The session is open, but earlier turns may be missing until storage recovers.",
|
||||
historyErr,
|
||||
operation == persistedSessionLoad,
|
||||
)
|
||||
return LoadSessionResult{
|
||||
ConfigOptions: a.configOptions(sess),
|
||||
@@ -217,6 +366,80 @@ func (a *Agent) handleSessionLoad(ctx context.Context, params json.RawMessage) (
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleSessionList(_ context.Context, params json.RawMessage) (any, error) {
|
||||
var p ListSessionsParams
|
||||
if len(params) > 0 {
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, RPCError(codeInvalidParams, "invalid session/list params")
|
||||
}
|
||||
}
|
||||
if p.Cursor != "" {
|
||||
return nil, RPCError(codeInvalidParams, "invalid session/list cursor")
|
||||
}
|
||||
items, err := a.deps.Store.ListResumable()
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInternalError, "list sessions: "+err.Error())
|
||||
}
|
||||
// The filter is the one cwd ACP leaves optional, so a blank one means "no
|
||||
// filter" rather than an error — but a relative one is rebased onto this
|
||||
// process's directory exactly like the others, and would then silently answer
|
||||
// about a workspace the client never named.
|
||||
var cwd string
|
||||
if strings.TrimSpace(p.Cwd) != "" {
|
||||
filter, err := requestedWorkspace(p.Cwd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cwd, err = a.deps.ResolveWorkspaceRoot(filter)
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInvalidParams, err.Error())
|
||||
}
|
||||
}
|
||||
result := ListSessionsResult{Sessions: make([]SessionInfo, 0, len(items))}
|
||||
for _, item := range items {
|
||||
// EVERY ENTRY IS RESOLVED, FILTER OR NO FILTER. Listing is a menu, and
|
||||
// every item on it has to be orderable: activatePersistedSession
|
||||
// resolves the persisted workspace and refuses what it cannot reach, so
|
||||
// anything this loop cannot resolve is something the client would be
|
||||
// offered and then denied.
|
||||
//
|
||||
// Resolving only when a cwd filter was supplied left two shapes through —
|
||||
// a session whose workspace has since been deleted, and a legacy entry
|
||||
// holding a relative path, which was then reported as cwd "." even though
|
||||
// ACP requires SessionInfo.cwd to be absolute.
|
||||
if strings.TrimSpace(item.Cwd) == "" {
|
||||
continue
|
||||
}
|
||||
// A RELATIVE PERSISTED CWD HAS NO RECOVERABLE IDENTITY. Resolving one
|
||||
// rebases it onto wherever this ACP server happens to be running and
|
||||
// advertises that invented absolute path as the session's workspace — so a
|
||||
// conversation created for one project could be resumed against another
|
||||
// project's configuration, files and tools. The original base is not
|
||||
// knowable from the metadata, so the honest answer is to omit the entry
|
||||
// rather than to guess at it.
|
||||
if !filepath.IsAbs(item.Cwd) {
|
||||
continue
|
||||
}
|
||||
itemRoot, err := a.deps.ResolveWorkspaceRoot(item.Cwd)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if cwd != "" && !sameWorkspace(itemRoot, cwd) {
|
||||
continue
|
||||
}
|
||||
result.Sessions = append(result.Sessions, SessionInfo{
|
||||
SessionID: item.SessionID,
|
||||
Title: item.Title,
|
||||
// The RESOLVED root, not the stored string: absolute as ACP
|
||||
// requires, and the same value the client will hand back on resume.
|
||||
Cwd: itemRoot,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
Meta: &SessionInfoMeta{ModelID: item.ModelID, CreatedAt: item.CreatedAt},
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ---- prompt turn ----
|
||||
|
||||
func (a *Agent) handleSessionPrompt(ctx context.Context, params json.RawMessage) (any, error) {
|
||||
@@ -228,6 +451,9 @@ func (a *Agent) handleSessionPrompt(ctx context.Context, params json.RawMessage)
|
||||
if sess == nil {
|
||||
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
|
||||
}
|
||||
if sess.readOnly {
|
||||
return nil, RPCError(codeInvalidParams, "session is not resumable, so it was loaded for viewing only and cannot be prompted: "+p.SessionID)
|
||||
}
|
||||
|
||||
// Serialize turns for this session so two prompts can't interleave history or
|
||||
// fight over the single cancel slot. session/cancel still works concurrently
|
||||
@@ -293,6 +519,39 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
|
||||
note.text("\n\n[zero warning] " + text + "\n")
|
||||
}
|
||||
}
|
||||
// Buffer the complete turn until RunAgent has a valid ACP outcome. A hard run
|
||||
// failure is an aborted turn: neither live history nor durable history may
|
||||
// retain its user/tool prefix under an RPC error. StopCancelled is a valid
|
||||
// outcome and commits through the same boundary below.
|
||||
pendingEvents := make([]sessions.AppendEventInput, 0, 4)
|
||||
queue := func(input sessions.AppendEventInput) {
|
||||
pendingEvents = append(pendingEvents, input)
|
||||
}
|
||||
queue(messageEvent("user", userText))
|
||||
|
||||
visionCache := make(map[string]bool)
|
||||
var visionCacheMu sync.Mutex
|
||||
supportsVision := func(modelID string) bool {
|
||||
modelID = strings.TrimSpace(modelID)
|
||||
if modelID == "" {
|
||||
return false
|
||||
}
|
||||
visionCacheMu.Lock()
|
||||
defer visionCacheMu.Unlock()
|
||||
if cached, ok := visionCache[modelID]; ok {
|
||||
return cached
|
||||
}
|
||||
supported := a.modelSupportsVision(ctx, resolved.Provider, modelID)
|
||||
visionCache[modelID] = supported
|
||||
return supported
|
||||
}
|
||||
effectivePrompt := userText
|
||||
if len(images) > 0 && !supportsVision(resolved.Provider.Model) {
|
||||
msg := fmt.Sprintf("Model %s does not support image input; ignoring %d prompt image(s).", resolved.Provider.Model, len(images))
|
||||
note.text("[zero] " + msg + "\n\n")
|
||||
effectivePrompt = fmt.Sprintf("[Note: %s]\n\n%s", msg, userText)
|
||||
images = nil
|
||||
}
|
||||
|
||||
opts := agent.Options{
|
||||
Cwd: sess.cwd,
|
||||
@@ -305,10 +564,15 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
|
||||
DeferThreshold: workspace.DeferThreshold,
|
||||
MaxTurns: resolved.MaxTurns,
|
||||
Images: images,
|
||||
SupportsVision: supportsVision,
|
||||
OnText: note.text,
|
||||
OnReasoning: note.thought,
|
||||
OnToolCall: note.toolCall,
|
||||
OnToolCall: func(call agent.ToolCall) {
|
||||
queue(toolCallEvent(call))
|
||||
note.toolCall(call)
|
||||
},
|
||||
OnToolResult: func(result agent.ToolResult) {
|
||||
queue(toolResultEvent(result))
|
||||
note.toolResult(result)
|
||||
if result.Name == "update_plan" {
|
||||
a.emitPlan(registry, note)
|
||||
@@ -319,21 +583,38 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
|
||||
},
|
||||
}
|
||||
|
||||
agentPrompt := buildPrompt(sess.snapshotHistory(), userText)
|
||||
agentPrompt := buildPrompt(sess.snapshotHistory(), effectivePrompt)
|
||||
result, runErr := a.deps.RunAgent(ctx, agentPrompt, provider, opts)
|
||||
|
||||
if result.FinalAnswer != "" {
|
||||
queue(messageEvent("assistant", result.FinalAnswer))
|
||||
}
|
||||
reason, stopErr := stopReasonFor(result, runErr)
|
||||
if stopErr != nil {
|
||||
return "", RPCError(codeInternalError, stopErr.Error())
|
||||
}
|
||||
if err := a.persistTurn(sess, userText, result.FinalAnswer); err != nil {
|
||||
a.warnPersistence(
|
||||
note,
|
||||
"save session history",
|
||||
"Could not save session history. This turn is available in memory, but future resume may miss it until storage recovers.",
|
||||
err,
|
||||
)
|
||||
}
|
||||
// ONE BATCH, ONE LOCK. The outcome gate above decides WHETHER the turn is
|
||||
// committed; the store batch decides WHICH RECORDS STAY TOGETHER relative to
|
||||
// other writers, and these are different guarantees. Persisting the buffer
|
||||
// one event per call took and released the store's session lock between
|
||||
// events, so a second ACP instance holding the same session — its turnMu is
|
||||
// its own, only the store is shared — could append its whole turn in the
|
||||
// gap: user A, user B, answer B, answer A. Every write was individually
|
||||
// locked and the log was valid, but a fresh load pairs prose by order and
|
||||
// reconstructed three wrong turns from two right ones. Before this branch,
|
||||
// persistTurn handed the user and assistant events to AppendEvents together;
|
||||
// buffering until the outcome is known was correct, splitting the commit
|
||||
// into singleton writes was not. The store writes a batch under one lock and
|
||||
// one file write, which is the contiguity restored here — not a disk
|
||||
// transaction, and not a promise about crashes mid-fsync. Reported by @jatmn.
|
||||
persistenceErr := a.persistTurn(sess.id, pendingEvents)
|
||||
sess.appendHistory(turnRecord{user: userText, assistant: result.FinalAnswer})
|
||||
a.warnPersistence(
|
||||
note,
|
||||
"save session history",
|
||||
"Could not save session history. This turn is available in memory, but future resume may miss it until storage recovers.",
|
||||
persistenceErr,
|
||||
true,
|
||||
)
|
||||
return reason, nil
|
||||
}
|
||||
|
||||
@@ -592,39 +873,170 @@ func (a *Agent) configOptions(s *acpSession) []SessionConfigOption {
|
||||
|
||||
// ---- persistence + continuity ----
|
||||
|
||||
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) error {
|
||||
defer sess.appendHistory(turnRecord{user: user, assistant: assistant})
|
||||
// persistTurn commits a completed turn's buffered events as one store batch, so
|
||||
// no other writer's records can land between them. A failure is the failure of
|
||||
// the whole batch: nothing after a failed prerequisite is appended on its own.
|
||||
func (a *Agent) persistTurn(sessionID string, inputs []sessions.AppendEventInput) error {
|
||||
if len(inputs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if a.deps.PersistEvents != nil {
|
||||
return a.deps.PersistEvents(sessionID, inputs)
|
||||
}
|
||||
if a.deps.Store == nil {
|
||||
return nil
|
||||
}
|
||||
events := []sessions.AppendEventInput{
|
||||
{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{"role": "user", "content": user},
|
||||
},
|
||||
}
|
||||
if assistant != "" {
|
||||
events = append(events, sessions.AppendEventInput{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{"role": "assistant", "content": assistant},
|
||||
})
|
||||
}
|
||||
_, err := a.deps.Store.AppendEvents(sess.id, events)
|
||||
_, err := a.deps.Store.AppendEvents(sessionID, inputs)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Agent) loadHistory(sessionID string) ([]turnRecord, error) {
|
||||
if a.deps.Store == nil {
|
||||
return nil, nil
|
||||
func messageEvent(role, content string) sessions.AppendEventInput {
|
||||
return sessions.AppendEventInput{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{"role": role, "content": content},
|
||||
}
|
||||
events, err := a.deps.Store.ReadEvents(sessionID)
|
||||
}
|
||||
|
||||
func toolCallEvent(call agent.ToolCall) sessions.AppendEventInput {
|
||||
return sessions.AppendEventInput{
|
||||
Type: sessions.EventToolCall,
|
||||
Payload: map[string]any{
|
||||
"toolCallId": call.ID,
|
||||
"name": call.Name,
|
||||
"arguments": call.Arguments,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func toolResultEvent(result agent.ToolResult) sessions.AppendEventInput {
|
||||
return sessions.AppendEventInput{
|
||||
Type: sessions.EventToolResult,
|
||||
Payload: map[string]any{
|
||||
"toolCallId": result.ToolCallID,
|
||||
"name": result.Name,
|
||||
"status": result.Status,
|
||||
"output": result.Output,
|
||||
"changedFiles": append([]string(nil), result.ChangedFiles...),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// persistedMessage is one entry of the restored transcript in stored order. It
|
||||
// is either a message (role/content) or a tool-call notification (tool), never
|
||||
// both: keeping them in ONE ordered slice is what preserves the interleaving a
|
||||
// client needs to redraw the conversation as it originally happened.
|
||||
type persistedMessage struct {
|
||||
eventID string
|
||||
role string
|
||||
content string
|
||||
// tool is set for a replayed tool call or its result, and is already in wire
|
||||
// shape — it comes from the same toolCallStart/toolCallResult mapping the
|
||||
// live turn uses, so a replayed tool call renders identically to a fresh one
|
||||
// and there is no second mapping to drift out of sync.
|
||||
tool *ToolCallUpdate
|
||||
}
|
||||
|
||||
func (a *Agent) loadHistory(sessionID string, requireHistoryLog bool) ([]turnRecord, []persistedMessage, error, error) {
|
||||
if a.deps.Store == nil {
|
||||
return nil, nil, nil, nil
|
||||
}
|
||||
// THE EFFECTIVE CONVERSATION, NOT THE RAW LOG. A compacted session keeps its
|
||||
// original prefix on disk alongside an EventCompaction that names the events
|
||||
// it replaced and carries their durable summary. ReadEvents hands back both,
|
||||
// so restoring from it replayed superseded turns AND dropped the summary that
|
||||
// replaced them — resume then seeded the next turn with context the
|
||||
// conversation had already moved past, and load visibly resurrected
|
||||
// transcript entries the user had seen compacted away.
|
||||
//
|
||||
// ReadRehydratedEvents is the projection the TUI and exec paths already use
|
||||
// (internal/tui/session.go, internal/sessions/exec_session.go), so all three
|
||||
// now agree on what the conversation IS. Switching readers alone is not
|
||||
// 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 {
|
||||
return nil, err
|
||||
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 {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
}
|
||||
if requireHistoryLog && !eventLogPresent {
|
||||
return nil, nil, nil, fmt.Errorf("zero session %s event log is missing", sessionID)
|
||||
}
|
||||
var records []turnRecord
|
||||
var messages []persistedMessage
|
||||
activeToolCalls := make(map[string]string)
|
||||
var pendingUser string
|
||||
havePending := false
|
||||
for _, e := range events {
|
||||
if e.Type == sessions.EventCompaction {
|
||||
// The summary stands in for the turns it replaced, so it enters the
|
||||
// history as an assistant message: it is prior context the next turn
|
||||
// must see, and the wire has no separate role for it.
|
||||
raw, marshalErr := json.Marshal(e.Payload)
|
||||
if marshalErr != nil {
|
||||
continue
|
||||
}
|
||||
var payload struct {
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
if json.Unmarshal(raw, &payload) != nil || strings.TrimSpace(payload.Summary) == "" {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, persistedMessage{
|
||||
eventID: persistedMessageIdentity(sessionID, e),
|
||||
role: "assistant",
|
||||
content: payload.Summary,
|
||||
})
|
||||
records = append(records, turnRecord{user: pendingUser, assistant: payload.Summary})
|
||||
pendingUser = ""
|
||||
havePending = false
|
||||
continue
|
||||
}
|
||||
// TOOL ACTIVITY IS PART OF THE TRANSCRIPT, NOT DECORATION. A restored
|
||||
// conversation that shows only the prose reads as if the agent asserted
|
||||
// its edits and command output from nowhere: the user sees "I updated
|
||||
// scope.go" with no record that any tool ran. These are collected for
|
||||
// replay only. They deliberately do NOT enter turnRecord, so the prompt
|
||||
// context a resumed turn receives stays byte-identical to what it was —
|
||||
// load and resume continue to consume the SAME effective history, and
|
||||
// only the wire rendering differs. Reported by @jatmn.
|
||||
if e.Type == sessions.EventToolCall || e.Type == sessions.EventToolResult {
|
||||
if upd := replayToolUpdate(e); upd != nil {
|
||||
rawID := upd.ToolCallID
|
||||
if e.Type == sessions.EventToolResult {
|
||||
replayID, ok := activeToolCalls[rawID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
upd.ToolCallID = replayID
|
||||
delete(activeToolCalls, rawID)
|
||||
} else {
|
||||
replayID := replayToolCallID(persistedMessageIdentity(sessionID, e))
|
||||
activeToolCalls[rawID] = replayID
|
||||
upd.ToolCallID = replayID
|
||||
}
|
||||
messages = append(messages, persistedMessage{
|
||||
eventID: persistedMessageIdentity(sessionID, e),
|
||||
tool: upd,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if e.Type != sessions.EventMessage {
|
||||
continue
|
||||
}
|
||||
@@ -641,12 +1053,14 @@ func (a *Agent) loadHistory(sessionID string) ([]turnRecord, error) {
|
||||
}
|
||||
switch msg.Role {
|
||||
case "user":
|
||||
messages = append(messages, persistedMessage{eventID: persistedMessageIdentity(sessionID, e), role: msg.Role, content: msg.Content})
|
||||
if havePending {
|
||||
records = append(records, turnRecord{user: pendingUser})
|
||||
}
|
||||
pendingUser = msg.Content
|
||||
havePending = true
|
||||
case "assistant":
|
||||
messages = append(messages, persistedMessage{eventID: persistedMessageIdentity(sessionID, e), role: msg.Role, content: msg.Content})
|
||||
records = append(records, turnRecord{user: pendingUser, assistant: msg.Content})
|
||||
pendingUser = ""
|
||||
havePending = false
|
||||
@@ -655,10 +1069,89 @@ func (a *Agent) loadHistory(sessionID string) ([]turnRecord, error) {
|
||||
if havePending {
|
||||
records = append(records, turnRecord{user: pendingUser})
|
||||
}
|
||||
return records, nil
|
||||
return records, messages, rehydrateWarning, nil
|
||||
}
|
||||
|
||||
func (a *Agent) warnPersistence(note *notifier, action string, message string, err error) {
|
||||
// replayToolUpdate rebuilds the ACP notification for one stored tool event.
|
||||
//
|
||||
// The stored toolCallId is returned here as a correlation key. loadHistory maps
|
||||
// each call occurrence to a stable session-wide replay identity and applies that
|
||||
// identity to its result. Older records wrote the field as "id", so both
|
||||
// spellings are accepted — the TUI projection already does the same
|
||||
// (internal/tui/session.go).
|
||||
//
|
||||
// A record that carries no usable id is skipped rather than replayed under a
|
||||
// synthesized one: an unpairable update is worse than a missing row.
|
||||
func replayToolUpdate(event sessions.Event) *ToolCallUpdate {
|
||||
raw, err := json.Marshal(event.Payload)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var payload struct {
|
||||
Name string `json:"name"`
|
||||
ToolCallID string `json:"toolCallId"`
|
||||
ID string `json:"id"`
|
||||
Arguments string `json:"arguments"`
|
||||
Status string `json:"status"`
|
||||
Output string `json:"output"`
|
||||
ChangedFiles []string `json:"changedFiles"`
|
||||
}
|
||||
if json.Unmarshal(raw, &payload) != nil {
|
||||
return nil
|
||||
}
|
||||
id := payload.ToolCallID
|
||||
if id == "" {
|
||||
id = payload.ID
|
||||
}
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
if event.Type == sessions.EventToolCall {
|
||||
upd := toolCallStart(agent.ToolCall{ID: id, Name: payload.Name, Arguments: payload.Arguments})
|
||||
return &upd
|
||||
}
|
||||
status := tools.Status(payload.Status)
|
||||
if status == "" {
|
||||
status = tools.StatusOK
|
||||
}
|
||||
upd := toolCallResult(agent.ToolResult{
|
||||
ToolCallID: id,
|
||||
Name: payload.Name,
|
||||
Status: status,
|
||||
Output: payload.Output,
|
||||
ChangedFiles: append([]string(nil), payload.ChangedFiles...),
|
||||
})
|
||||
return &upd
|
||||
}
|
||||
|
||||
func persistedMessageIdentity(sessionID string, event sessions.Event) string {
|
||||
if event.ID != "" {
|
||||
return event.ID
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", sessionID, event.Sequence)
|
||||
}
|
||||
|
||||
// replayMessageID maps ZERO's stable event identity to a standards-shaped UUID
|
||||
// without leaking or parsing the event id on the wire. The same stored message
|
||||
// receives the same opaque id across loads, which also gives clients an exact
|
||||
// chunk boundary when two adjacent persisted messages have the same role.
|
||||
func replayMessageID(eventID string) string {
|
||||
return replayOpaqueID("message", eventID)
|
||||
}
|
||||
|
||||
func replayToolCallID(eventID string) string {
|
||||
return replayOpaqueID("tool-call", eventID)
|
||||
}
|
||||
|
||||
func replayOpaqueID(kind, eventID string) string {
|
||||
sum := sha256.Sum256([]byte("zero-acp-" + kind + ":" + eventID))
|
||||
b := sum[:16]
|
||||
b[6] = (b[6] & 0x0f) | 0x50
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||
}
|
||||
|
||||
func (a *Agent) warnPersistence(note *notifier, action string, message string, err error, notify bool) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
@@ -667,7 +1160,7 @@ func (a *Agent) warnPersistence(note *notifier, action string, message string, e
|
||||
sessionID = note.sessionID
|
||||
}
|
||||
log.Printf("zero acp: failed to %s for session %s: %v", action, sessionID, err)
|
||||
if note != nil {
|
||||
if note != nil && notify {
|
||||
note.text("\n\n[zero warning] " + message + "\n")
|
||||
}
|
||||
}
|
||||
@@ -714,18 +1207,34 @@ func promptImages(blocks []ContentBlock) []zeroruntime.ImageBlock {
|
||||
|
||||
// registerSession publishes a session under the agent's lock. If one is already
|
||||
// registered for id (e.g. a re-load of an in-flight session) the existing live
|
||||
// session is returned unchanged rather than orphaning its turn or resetting its
|
||||
// mode/model. history is set BEFORE publishing so no concurrent prompt can read a
|
||||
// half-initialized session.
|
||||
func (a *Agent) registerSession(id, cwd string, history []turnRecord, model string, models []SessionConfigOptionValue, restrictModels bool) *acpSession {
|
||||
// session is returned rather than orphaning its turn or resetting its mode/model.
|
||||
// The bool reports that case so persisted activation can re-read and apply disk
|
||||
// history under turnMu. history is set BEFORE first publication so no concurrent
|
||||
// prompt can read a half-initialized session.
|
||||
func (a *Agent) registerSession(id, cwd string, history []turnRecord, model string, models []SessionConfigOptionValue, restrictModels bool, readOnly bool) (*acpSession, bool) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if existing := a.sessions[id]; existing != nil {
|
||||
return existing
|
||||
return existing, true
|
||||
}
|
||||
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, model: model, models: models, restrictModels: restrictModels, history: history}
|
||||
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, model: model, models: models, restrictModels: restrictModels, history: history, readOnly: readOnly}
|
||||
a.sessions[id] = sess
|
||||
return sess
|
||||
return sess, false
|
||||
}
|
||||
|
||||
// refreshSessionHistory is the activation apply boundary for an already-live
|
||||
// session. The history is read while prompts are excluded, then applied to the
|
||||
// same object the prompt path uses. Re-reading here avoids overwriting a turn
|
||||
// that committed between activatePersistedSession's pre-publication read and
|
||||
// discovering that another activation had already registered the session.
|
||||
func (a *Agent) refreshSessionHistory(sess *acpSession, requireHistoryLog bool) ([]turnRecord, []persistedMessage, error, error) {
|
||||
sess.turnMu.Lock()
|
||||
defer sess.turnMu.Unlock()
|
||||
history, messages, warning, err := a.loadHistory(sess.id, requireHistoryLog)
|
||||
if err == nil {
|
||||
sess.setHistory(history)
|
||||
}
|
||||
return history, messages, warning, err
|
||||
}
|
||||
|
||||
func (a *Agent) session(id string) *acpSession {
|
||||
@@ -808,8 +1317,77 @@ func (s *acpSession) appendHistory(rec turnRecord) {
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *acpSession) setHistory(history []turnRecord) {
|
||||
s.mu.Lock()
|
||||
s.history = append([]turnRecord(nil), history...)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *acpSession) snapshotHistory() []turnRecord {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]turnRecord(nil), s.history...)
|
||||
}
|
||||
|
||||
// sameWorkspace reports whether two resolved roots name the same directory.
|
||||
//
|
||||
// STRING EQUALITY IS NOT DIRECTORY IDENTITY. ResolveWorkspaceRoot is abs plus
|
||||
// filepath.Clean plus a stat: it does not fold case and does not resolve
|
||||
// junctions, so one directory reached by two spellings produces two different
|
||||
// roots. A session persisted from the TUI was then unresumable from an editor
|
||||
// holding a different spelling of the same project folder, and session/list
|
||||
// filtered by the other spelling returned nothing — not a failed resume but an
|
||||
// invisible one, on exactly the case this feature exists for.
|
||||
//
|
||||
// filepath.EvalSymlinks is NOT the fix on Windows: it normalises a drive letter
|
||||
// but returns a junction path unchanged, so the alias survives it. Junctions need
|
||||
// no privilege, so this is ordinary rather than exotic. os.SameFile compares the
|
||||
// filesystem's own identity for the two directories, which is the question being
|
||||
// asked.
|
||||
//
|
||||
// The string comparison stays as the fast path, and a stat failure falls back to
|
||||
// it rather than widening the match — this gate refuses access to another
|
||||
// workspace's files and configuration, so an unanswerable comparison denies.
|
||||
func sameWorkspace(left, right string) bool {
|
||||
if left == right {
|
||||
return true
|
||||
}
|
||||
leftInfo, err := os.Stat(left)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
rightInfo, err := os.Stat(right)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return os.SameFile(leftInfo, rightInfo)
|
||||
}
|
||||
|
||||
func (a *Agent) modelSupportsVision(ctx context.Context, profile config.ProviderProfile, modelID string) bool {
|
||||
trimmed := strings.TrimSpace(modelID)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
if a.deps.DiscoverModels != nil {
|
||||
if discovered, err := a.deps.DiscoverModels(ctx, profile); err == nil {
|
||||
for _, dm := range discovered {
|
||||
if strings.EqualFold(strings.TrimSpace(dm.ID), trimmed) {
|
||||
if len(dm.InputModalities) > 0 {
|
||||
for _, mod := range dm.InputModalities {
|
||||
if strings.EqualFold(strings.TrimSpace(mod), "image") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
reg, _ := modelregistry.DefaultRegistry()
|
||||
if entry, known := reg.Resolve(trimmed); known {
|
||||
return entry.Supports(modelregistry.ModelCapabilityVision)
|
||||
}
|
||||
return modelregistry.SupportsVision(reg, trimmed)
|
||||
}
|
||||
@@ -89,6 +89,13 @@ type Conn struct {
|
||||
|
||||
handlers map[string]HandlerFunc
|
||||
notifiers map[string]NotifyFunc
|
||||
// Notifications for one method are an ordered stream: session/update in
|
||||
// particular is stateful, so dispatching every frame in an unrelated
|
||||
// goroutine can make a later chunk overtake an earlier one. Different
|
||||
// methods retain independent tails, keeping session/cancel responsive while
|
||||
// an update handler is busy.
|
||||
notifyMu sync.Mutex
|
||||
notifyTails map[string]chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
nextID int64
|
||||
@@ -106,11 +113,12 @@ type Conn struct {
|
||||
// lifetime.
|
||||
func NewConn(r io.Reader, w io.Writer) *Conn {
|
||||
return &Conn{
|
||||
rawReader: r,
|
||||
w: w,
|
||||
handlers: make(map[string]HandlerFunc),
|
||||
notifiers: make(map[string]NotifyFunc),
|
||||
pending: make(map[int64]chan rpcMessage),
|
||||
rawReader: r,
|
||||
w: w,
|
||||
handlers: make(map[string]HandlerFunc),
|
||||
notifiers: make(map[string]NotifyFunc),
|
||||
notifyTails: make(map[string]chan struct{}),
|
||||
pending: make(map[int64]chan rpcMessage),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,11 +311,7 @@ func (c *Conn) handleLine(ctx context.Context, line []byte) {
|
||||
}(msg)
|
||||
case msg.isNotify():
|
||||
if fn := c.notifiers[msg.Method]; fn != nil {
|
||||
c.wg.Add(1)
|
||||
go func(m rpcMessage) {
|
||||
defer c.wg.Done()
|
||||
fn(ctx, m.Params)
|
||||
}(msg)
|
||||
c.dispatchNotification(ctx, msg, fn)
|
||||
}
|
||||
default:
|
||||
// Malformed frame; reply only if we can identify a request id.
|
||||
@@ -317,6 +321,35 @@ func (c *Conn) handleLine(ctx context.Context, line []byte) {
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchNotification preserves wire order within one method without making
|
||||
// unrelated notification methods wait for each other. The read loop installs
|
||||
// each tail before starting its goroutine, so goroutine scheduling cannot
|
||||
// reorder the chain it observes.
|
||||
func (c *Conn) dispatchNotification(ctx context.Context, msg rpcMessage, fn NotifyFunc) {
|
||||
c.notifyMu.Lock()
|
||||
previous := c.notifyTails[msg.Method]
|
||||
done := make(chan struct{})
|
||||
c.notifyTails[msg.Method] = done
|
||||
c.notifyMu.Unlock()
|
||||
|
||||
c.wg.Add(1)
|
||||
go func() {
|
||||
defer c.wg.Done()
|
||||
defer func() {
|
||||
close(done)
|
||||
c.notifyMu.Lock()
|
||||
if c.notifyTails[msg.Method] == done {
|
||||
delete(c.notifyTails, msg.Method)
|
||||
}
|
||||
c.notifyMu.Unlock()
|
||||
}()
|
||||
if previous != nil {
|
||||
<-previous
|
||||
}
|
||||
fn(ctx, msg.Params)
|
||||
}()
|
||||
}
|
||||
|
||||
func (c *Conn) dispatchRequest(ctx context.Context, msg rpcMessage) {
|
||||
fn := c.handlers[msg.Method]
|
||||
if fn == nil {
|
||||
|
||||
@@ -360,6 +360,86 @@ func TestConnNotification(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnNotificationsForOneMethodStayInWireOrder(t *testing.T) {
|
||||
a, b, stop := connPair(t)
|
||||
defer stop()
|
||||
|
||||
firstStarted := make(chan struct{})
|
||||
releaseFirst := make(chan struct{})
|
||||
delivered := make(chan int, 2)
|
||||
b.HandleNotify("update", func(_ context.Context, params json.RawMessage) {
|
||||
var in struct{ Sequence int }
|
||||
_ = json.Unmarshal(params, &in)
|
||||
if in.Sequence == 1 {
|
||||
close(firstStarted)
|
||||
<-releaseFirst
|
||||
}
|
||||
delivered <- in.Sequence
|
||||
})
|
||||
|
||||
if err := a.Notify("update", map[string]int{"Sequence": 1}); err != nil {
|
||||
t.Fatalf("first notify: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-firstStarted:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("first notification did not start")
|
||||
}
|
||||
if err := a.Notify("update", map[string]int{"Sequence": 2}); err != nil {
|
||||
t.Fatalf("second notify: %v", err)
|
||||
}
|
||||
select {
|
||||
case sequence := <-delivered:
|
||||
t.Fatalf("notification %d overtook the blocked first notification", sequence)
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
close(releaseFirst)
|
||||
for want := 1; want <= 2; want++ {
|
||||
select {
|
||||
case got := <-delivered:
|
||||
if got != want {
|
||||
t.Fatalf("notification order = ...,%d; want %d", got, want)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("notification %d was not delivered", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnDifferentNotificationMethodsStayConcurrent(t *testing.T) {
|
||||
a, b, stop := connPair(t)
|
||||
defer stop()
|
||||
|
||||
updateStarted := make(chan struct{})
|
||||
releaseUpdate := make(chan struct{})
|
||||
cancelDelivered := make(chan struct{})
|
||||
b.HandleNotify("update", func(context.Context, json.RawMessage) {
|
||||
close(updateStarted)
|
||||
<-releaseUpdate
|
||||
})
|
||||
b.HandleNotify("cancel", func(context.Context, json.RawMessage) {
|
||||
close(cancelDelivered)
|
||||
})
|
||||
|
||||
if err := a.Notify("update", nil); err != nil {
|
||||
t.Fatalf("update notify: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-updateStarted:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("update notification did not start")
|
||||
}
|
||||
if err := a.Notify("cancel", nil); err != nil {
|
||||
t.Fatalf("cancel notify: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-cancelDelivered:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("different notification method was blocked behind update")
|
||||
}
|
||||
close(releaseUpdate)
|
||||
}
|
||||
|
||||
func TestConnMethodNotFound(t *testing.T) {
|
||||
a, _, stop := connPair(t)
|
||||
defer stop()
|
||||
|
||||
@@ -20,6 +20,14 @@ func agentMessageChunk(delta string) ContentChunk {
|
||||
return ContentChunk{SessionUpdate: UpdateAgentMessageChunk, Content: TextBlock(delta)}
|
||||
}
|
||||
|
||||
func replayMessageChunk(role, messageID, text string) ContentChunk {
|
||||
update := UpdateAgentMessageChunk
|
||||
if role == "user" {
|
||||
update = UpdateUserMessageChunk
|
||||
}
|
||||
return ContentChunk{SessionUpdate: update, MessageID: messageID, Content: TextBlock(text)}
|
||||
}
|
||||
|
||||
func agentThoughtChunk(delta string) ContentChunk {
|
||||
return ContentChunk{SessionUpdate: UpdateAgentThoughtChunk, Content: TextBlock(delta)}
|
||||
}
|
||||
@@ -131,7 +139,7 @@ func browserToolTitle(command, rawArgs string) string {
|
||||
}
|
||||
|
||||
// browserTitleTextSafe validates text after URL parsing has decoded escaped
|
||||
// UTF-8 in the host. Valid UTF-8 alone is not presentation-safe: control,
|
||||
// UTF-8 in the host. Valid UTF-8 alone is not presentation-safe: whitespace, control,
|
||||
// format/bidi, and line/paragraph separator runes can reorder or split the
|
||||
// permission label shown to a user. The execution URL remains unchanged.
|
||||
func browserTitleTextSafe(text string) bool {
|
||||
@@ -139,7 +147,7 @@ func browserTitleTextSafe(text string) bool {
|
||||
return false
|
||||
}
|
||||
for _, r := range text {
|
||||
if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) {
|
||||
if unicode.IsSpace(r) || unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,6 +224,9 @@ func TestBrowserPermissionTitlesMirrorSafeToolArguments(t *testing.T) {
|
||||
|
||||
func TestBrowserOpenTitlesRejectDecodedUnicodePresentationControls(t *testing.T) {
|
||||
for _, rawURL := range []string{
|
||||
"https://safe.example%C2%A0evil.test/path",
|
||||
"https://safe.example%E2%80%83evil.test/path",
|
||||
"https://safe.example%E3%80%80evil.test/path",
|
||||
"https://safe.example%E2%80%AEevil.test/path",
|
||||
"https://safe.example%E2%81%A6evil.test/path",
|
||||
"https://safe.example%C2%85evil.test/path",
|
||||
@@ -243,6 +246,9 @@ func TestBrowserOpenTitlesRejectDecodedUnicodePresentationControls(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if title := browserToolTitle("open", string(args)); title != "browser open" {
|
||||
t.Errorf("unsafe origin accepted before permission translation: %q", title)
|
||||
}
|
||||
updates := []ToolCallUpdate{
|
||||
toolCallStart(agent.ToolCall{ID: "start", Name: "browser_open", Arguments: string(args)}),
|
||||
permissionToolCall(agent.PermissionRequest{ToolCallID: "permission", ToolName: "browser_open", Args: map[string]any{"url": rawURL}}),
|
||||
|
||||
@@ -12,6 +12,8 @@ const (
|
||||
MethodAuthenticate = "authenticate"
|
||||
MethodSessionNew = "session/new"
|
||||
MethodSessionLoad = "session/load"
|
||||
MethodSessionList = "session/list"
|
||||
MethodSessionResume = "session/resume"
|
||||
MethodSessionPrompt = "session/prompt"
|
||||
MethodSessionCancel = "session/cancel" // notification
|
||||
MethodSessionUpdate = "session/update" // notification (agent -> client)
|
||||
@@ -62,8 +64,16 @@ type PromptCapabilities struct {
|
||||
}
|
||||
|
||||
type AgentCapabilities struct {
|
||||
LoadSession bool `json:"loadSession"`
|
||||
PromptCapabilities PromptCapabilities `json:"promptCapabilities"`
|
||||
LoadSession bool `json:"loadSession"`
|
||||
PromptCapabilities PromptCapabilities `json:"promptCapabilities"`
|
||||
SessionCapabilities *SessionCapabilities `json:"sessionCapabilities,omitempty"`
|
||||
}
|
||||
|
||||
// Empty capability objects are presence flags in ACP v1. Pointers preserve
|
||||
// the wire distinction between an advertised `{}` and an omitted capability.
|
||||
type SessionCapabilities struct {
|
||||
List *struct{} `json:"list,omitempty"`
|
||||
Resume *struct{} `json:"resume,omitempty"`
|
||||
}
|
||||
|
||||
type AuthMethod struct {
|
||||
@@ -117,9 +127,14 @@ type McpServer struct {
|
||||
}
|
||||
|
||||
type NewSessionParams struct {
|
||||
Cwd string `json:"cwd"`
|
||||
McpServers []McpServer `json:"mcpServers"`
|
||||
AdditionalDirectories []string `json:"additionalDirectories,omitempty"`
|
||||
Cwd string `json:"cwd"`
|
||||
McpServers []McpServer `json:"mcpServers"`
|
||||
// AdditionalDirectories is declared by the protocol and consumed nowhere in
|
||||
// this repo yet. When it is wired up it must go through requestedWorkspace
|
||||
// like Cwd does: these are client-supplied paths with no absoluteness rule of
|
||||
// their own, which is the same shape as the resume-cwd hole that made
|
||||
// {"sessionId":"known"} activate a session against this process's directory.
|
||||
AdditionalDirectories []string `json:"additionalDirectories,omitempty"`
|
||||
}
|
||||
|
||||
type NewSessionResult struct {
|
||||
@@ -140,6 +155,39 @@ type LoadSessionResult struct {
|
||||
Modes *SessionModeState `json:"modes,omitempty"`
|
||||
}
|
||||
|
||||
// ListSessionsParams and the following types implement ACP v1 session/list.
|
||||
// Transcript contents stay behind session/load; this method returns metadata
|
||||
// only and leaves the optional pagination cursor opaque.
|
||||
type ListSessionsParams struct {
|
||||
Cwd string `json:"cwd,omitempty"`
|
||||
Cursor string `json:"cursor,omitempty"`
|
||||
}
|
||||
|
||||
type SessionInfoMeta struct {
|
||||
ModelID string `json:"modelId,omitempty"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
}
|
||||
|
||||
type SessionInfo struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Cwd string `json:"cwd"`
|
||||
Title string `json:"title,omitempty"`
|
||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||
Meta *SessionInfoMeta `json:"_meta,omitempty"`
|
||||
}
|
||||
|
||||
type ListSessionsResult struct {
|
||||
Sessions []SessionInfo `json:"sessions"`
|
||||
NextCursor string `json:"nextCursor,omitempty"`
|
||||
}
|
||||
|
||||
// session/resume takes session/load's wire shape, so its cwd is a plain string
|
||||
// too and an omitted one decodes exactly like an empty one. Sharing the type is
|
||||
// safe only because activatePersistedSession requires an absolute cwd from the
|
||||
// request itself — nothing downstream may supply a default for either method.
|
||||
type ResumeSessionParams = LoadSessionParams
|
||||
type ResumeSessionResult = LoadSessionResult
|
||||
|
||||
// ---- prompt turn ----
|
||||
|
||||
type PromptParams struct {
|
||||
@@ -174,6 +222,7 @@ type SessionNotification struct {
|
||||
// ContentBlock under "content"; the variant is set via SessionUpdate.
|
||||
type ContentChunk struct {
|
||||
SessionUpdate string `json:"sessionUpdate"`
|
||||
MessageID string `json:"messageId,omitempty"`
|
||||
Content ContentBlock `json:"content"`
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/testutil"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
@@ -111,7 +112,11 @@ func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
|
||||
diagnostics := newAsyncDiagnostics(check, "/ws")
|
||||
|
||||
diagnostics.enqueue(context.Background(), []string{"a.go"})
|
||||
waitForIdle(t, diagnostics)
|
||||
testutil.WaitFor(t, "worker idle", func() bool {
|
||||
diagnostics.mu.Lock()
|
||||
defer diagnostics.mu.Unlock()
|
||||
return diagnostics.working == nil
|
||||
})
|
||||
mu.Lock()
|
||||
response = "ERR new"
|
||||
mu.Unlock()
|
||||
@@ -123,23 +128,6 @@ func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func waitForIdle(t *testing.T, diagnostics *asyncDiagnostics) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
diagnostics.mu.Lock()
|
||||
busy := diagnostics.working
|
||||
diagnostics.mu.Unlock()
|
||||
if busy == nil {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("worker never went idle")
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// changedFilesTool is a fake mutating tool reporting a changed file without
|
||||
// touching the filesystem.
|
||||
type changedFilesTool struct{}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/streamjson"
|
||||
@@ -25,14 +26,18 @@ const maxTurnsAnswer = "Agent reached maximum number of turns without a final an
|
||||
const maxTurnsFinalAnswerPrompt = "You have reached the tool-turn limit. Do not call tools. Give a concise final answer now: summarize what you completed, what you found, and any remaining blockers."
|
||||
|
||||
// maxStreamStallRetries bounds how many times a turn that timed out (idle/stall)
|
||||
// WITH NO OUTPUT yet is re-issued on a fresh connection before giving up. Only
|
||||
// the no-output case is retried (a partial turn would duplicate), so this is a
|
||||
// safe recovery for a stalled/dead pooled connection.
|
||||
// OR hit a mid-stream transport abort (#973) with no answer text yet is re-issued
|
||||
// on a fresh connection before giving up. Incomplete tool-call previews are
|
||||
// allowed through the gate (they are never dispatched before the error return).
|
||||
// The safety claim is only that visible answer prose is not duplicated on retry —
|
||||
// not that every partial stream shape is excluded. Covers a stalled/dead pooled
|
||||
// connection or a socket abort before tool dispatch.
|
||||
//
|
||||
// Set to 1 (not 2): each attempt can itself idle for the full stream timeout
|
||||
// (~5min) before the stall is even detected, so 2 retries left an interactive
|
||||
// session frozen for ~15min. One retry keeps the common single-hiccup recovery
|
||||
// while bounding the worst case to ~2× the idle timeout.
|
||||
// while bounding the worst case to ~2× the idle timeout. Mid-stream transport
|
||||
// aborts share this bound (do not raise it for #973).
|
||||
const maxStreamStallRetries = 1
|
||||
|
||||
const (
|
||||
@@ -374,7 +379,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
rec.StampFirstToken()
|
||||
}
|
||||
if onText != nil {
|
||||
forwardedVisibleText = true
|
||||
// Zero-length chunks are not committed answer prose; counting
|
||||
// them would block an eligible mid-stream abort retry.
|
||||
if s != "" {
|
||||
forwardedVisibleText = true
|
||||
}
|
||||
onText(s)
|
||||
}
|
||||
}
|
||||
@@ -450,9 +459,14 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, ctx.Err()
|
||||
}
|
||||
// A stream idle/stall timeout is safely re-issued when the turn committed NO
|
||||
// answer text — no forwarded visible prose (forwardedVisibleText) and no
|
||||
// collected final text (collected.Text). This covers two cases:
|
||||
// A stream idle/stall timeout OR mid-stream transport abort (#973 — wsarecv /
|
||||
// connection reset / unexpected EOF / connection closed / broken pipe) is
|
||||
// safely re-issued when the turn committed NO answer text — no forwarded
|
||||
// visible prose (forwardedVisibleText) and no collected final text
|
||||
// (collected.Text). The abort gate matches midStreamAbortNeedles only, not
|
||||
// the full shouldReconnect list: connect-phase timeouts and connection
|
||||
// refused are NOT retried here (a slow healthy server must not cost a
|
||||
// second prefill). This covers three cases:
|
||||
// 1. Nothing streamed at all before the connection died (the original
|
||||
// macOS stale-pooled-connection hang past the response-header timeout).
|
||||
// 2. The model streamed transient reasoning and began a tool call (e.g. a
|
||||
@@ -463,13 +477,27 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// re-render is transient previews, not duplicated answer text. This is
|
||||
// why the gate no longer excludes collected.ToolCalls: an incomplete
|
||||
// tool call from a timed-out stream is discard-and-retry, not output.
|
||||
// 3. Mid-stream socket abort after connect succeeded (Windows WSAECONNABORTED /
|
||||
// wsarecv, connection reset by peer). Same no-tool-executed safety: the
|
||||
// error returns before tool dispatch, so a bounded retry is safe.
|
||||
// A turn that forwarded real prose is NOT retried (it would duplicate visible
|
||||
// answer text) and falls through to the error return below. Capped +
|
||||
// exponential backoff, with a user-visible notice per attempt.
|
||||
for attempt := 1; attempt <= maxStreamStallRetries &&
|
||||
isStreamTimeoutError(collected.Error) && !forwardedVisibleText &&
|
||||
(isStreamTimeoutError(collected.Error) || isMidStreamTransportAbort(collected.Error)) &&
|
||||
!forwardedVisibleText &&
|
||||
collected.Text == ""; attempt++ {
|
||||
if notify := stallRetryNoticeFor(options); notify != nil {
|
||||
var notify reconnectNotifier
|
||||
if isStreamTimeoutError(collected.Error) {
|
||||
notify = stallRetryNoticeFor(options)
|
||||
} else {
|
||||
notify = reconnectNoticeFor(options)
|
||||
// Count this post-connect reissue even when the replacement
|
||||
// connect succeeds immediately. streamWithReconnect only
|
||||
// increments reconnect_count after a failed connect attempt.
|
||||
options.Trace.Counter(trace.CounterReconnectCount, 1)
|
||||
}
|
||||
if notify != nil {
|
||||
notify(attempt, maxStreamStallRetries)
|
||||
}
|
||||
if err := sleepWithContext(ctx, backoffFor(attempt)); err != nil {
|
||||
@@ -482,12 +510,22 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
retryStream, retryErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
|
||||
if retryErr != nil {
|
||||
result.Messages = copyMessages(messages)
|
||||
if ctx.Err() != nil {
|
||||
return result, ctx.Err()
|
||||
}
|
||||
return result, retryErr
|
||||
}
|
||||
stallGenSpan := options.Trace.Span(trace.SpanGeneration)
|
||||
collected = zeroruntime.CollectStreamWithOptions(ctx, retryStream, forwardingOpts)
|
||||
stallGenSpan.End()
|
||||
}
|
||||
// Recheck ctx after a retried CollectStream: helpers.go stores
|
||||
// ctx.Err().Error() in collected.Error, and errors.New of that string
|
||||
// drops the context.Canceled sentinel ACP branches on.
|
||||
if ctx.Err() != nil {
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, ctx.Err()
|
||||
}
|
||||
if collected.Error != "" {
|
||||
// Route a reissued stream's non-stall error through the SAME recovery as
|
||||
// the initial stream (image-rejection wrapping / context-limit compaction)
|
||||
@@ -498,6 +536,10 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, stop
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, ctx.Err()
|
||||
}
|
||||
if collected.Error != "" {
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, errors.New(collected.Error)
|
||||
@@ -646,9 +688,25 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// recorded. They travel as user messages, and a user message between two
|
||||
// tool_results breaks strict provider replay — Anthropic coalesces them
|
||||
// into one user block list and requires the tool_result blocks first, so
|
||||
// interleaving yields [tool_result, text, image, tool_result] and a 400.
|
||||
// Same reason the self-correction feedback below is deferred.
|
||||
var toolImageMessages []zeroruntime.Message
|
||||
// Images ride a following USER message rather than the tool result
|
||||
// above. Every provider drops images on a tool-role message —
|
||||
// Anthropic's tool_result content is a string, Gemini's is a
|
||||
// functionResponse, and OpenAI guards its image parts to the user role
|
||||
// — so attaching them there would silently deliver nothing. A separate
|
||||
// message also keeps the one-tool-result-per-tool-call pairing intact,
|
||||
// which the providers validate. Vision-gate evaluation is deferred until
|
||||
// after any model switch this turn resolves, so images can reach an
|
||||
// escalated vision-capable model.
|
||||
var toolResultsWithImages []ToolResult
|
||||
buildToolImageMessages := func() []zeroruntime.Message {
|
||||
var out []zeroruntime.Message
|
||||
for _, tr := range toolResultsWithImages {
|
||||
if imageMessage, ok := toolResultImageMessage(tr, options); ok {
|
||||
out = append(out, imageMessage)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
// Parallel read-ahead state: results for calls[precomputedStart:precomputedEnd]
|
||||
// executed concurrently, consumed strictly in order below.
|
||||
var precomputed []precomputedToolResult
|
||||
@@ -705,15 +763,8 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
IsError: toolResult.Status == tools.StatusError,
|
||||
ChangedFiles: append([]string(nil), toolResult.ChangedFiles...),
|
||||
})
|
||||
// Images ride a following USER message rather than the tool result
|
||||
// above. Every provider drops images on a tool-role message —
|
||||
// Anthropic's tool_result content is a string, Gemini's is a
|
||||
// functionResponse, and OpenAI guards its image parts to the user role
|
||||
// — so attaching them there would silently deliver nothing. A separate
|
||||
// message also keeps the one-tool-result-per-tool-call pairing intact,
|
||||
// which the providers validate.
|
||||
if imageMessage, ok := toolResultImageMessage(toolResult); ok {
|
||||
toolImageMessages = append(toolImageMessages, imageMessage)
|
||||
if len(toolResult.Images) > 0 {
|
||||
toolResultsWithImages = append(toolResultsWithImages, toolResult)
|
||||
}
|
||||
|
||||
// A tool may demand the run ABORT — a canceled/timed-out ask_user prompt
|
||||
@@ -725,13 +776,13 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
}
|
||||
if abortErr != nil {
|
||||
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
|
||||
messages = append(messages, toolImageMessages...)
|
||||
messages = append(messages, buildToolImageMessages()...)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, abortErr
|
||||
}
|
||||
if stopReason := stopReasonFromToolResult(toolResult); stopReason != "" {
|
||||
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
|
||||
messages = append(messages, toolImageMessages...)
|
||||
messages = append(messages, buildToolImageMessages()...)
|
||||
result.FinalAnswer = toolResult.ModelOutput()
|
||||
result.StopReason = stopReason
|
||||
result.Messages = copyMessages(messages)
|
||||
@@ -755,7 +806,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// messages stay valid for a strict provider replay (Anthropic
|
||||
// rejects a tool_use with no answering tool_result).
|
||||
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
|
||||
messages = append(messages, toolImageMessages...)
|
||||
messages = append(messages, buildToolImageMessages()...)
|
||||
result.FinalAnswer = toolFailureStopAnswer(call.Name, outcome.Count)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, nil
|
||||
@@ -774,10 +825,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
postEditDiagnostics.enqueue(ctx, toolResult.ChangedFiles)
|
||||
}
|
||||
}
|
||||
// Every tool_result for this turn is now recorded, including aborted
|
||||
// placeholders, so the images can follow without splitting them.
|
||||
messages = append(messages, toolImageMessages...)
|
||||
toolImageMessages = nil
|
||||
|
||||
// Run post-edit self-correction once over the union of files this turn
|
||||
// changed, then append any feedback after every tool_result is recorded so
|
||||
@@ -865,6 +912,12 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
}
|
||||
}
|
||||
|
||||
// Every tool_result, self-correct notice, and model-switch update for this
|
||||
// turn is now in place; evaluate and append image messages against the
|
||||
// effective (potentially escalated) model.
|
||||
messages = append(messages, buildToolImageMessages()...)
|
||||
toolResultsWithImages = nil
|
||||
|
||||
// A turn can mix valid tool calls with a dropped (nameless) one. The valid
|
||||
// calls executed above; surface the dropped call too so it is never
|
||||
// silently ignored just because the turn also did real work. This is
|
||||
@@ -3464,8 +3517,10 @@ func copyMessages(messages []Message) []Message {
|
||||
//
|
||||
// The text names the tool so the model can tell which call an image came from
|
||||
// when several ran in one turn — the images arrive detached from their tool
|
||||
// result, so nothing else associates them.
|
||||
func toolResultImageMessage(result ToolResult) (zeroruntime.Message, bool) {
|
||||
// result, so nothing else associates them. If the effective model cannot
|
||||
// accept images, the tool's text result is left unchanged and this follow-up
|
||||
// is a notice with no image parts, matching the CLI/TUI vision gate.
|
||||
func toolResultImageMessage(result ToolResult, options Options) (zeroruntime.Message, bool) {
|
||||
images := make([]zeroruntime.ImageBlock, 0, len(result.Images))
|
||||
for _, image := range result.Images {
|
||||
if len(image.Data) == 0 {
|
||||
@@ -3486,9 +3541,26 @@ func toolResultImageMessage(result ToolResult) (zeroruntime.Message, bool) {
|
||||
if label == "" {
|
||||
label = "tool"
|
||||
}
|
||||
if !modelAcceptsToolImages(options) {
|
||||
return zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: "Image output from " + label + " was not sent because the current model does not support image input. Switch to a vision-capable model before retrying image delivery, including any images skipped by the tool's image budget.",
|
||||
}, true
|
||||
}
|
||||
return zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: "Image output from " + label + ":",
|
||||
Images: images,
|
||||
}, true
|
||||
}
|
||||
|
||||
func modelAcceptsToolImages(options Options) bool {
|
||||
if options.SupportsVision != nil {
|
||||
return options.SupportsVision(options.Model)
|
||||
}
|
||||
registry, err := modelregistry.DefaultRegistry()
|
||||
if err != nil {
|
||||
return modelregistry.VisionCapableByName(options.Model)
|
||||
}
|
||||
return modelregistry.SupportsVision(registry, options.Model)
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// midStreamAbortProvider connects successfully but emits a transport-abort
|
||||
// StreamEventError on the first abortBefore calls, then succeeds with "done".
|
||||
// hangOnCall, if > 0, makes that 1-based call block until ctx is done so a
|
||||
// cancel during the retried CollectStream can be reproduced.
|
||||
// connectFailFrom, if > 0, makes that 1-based call and later return a
|
||||
// reconnectable connect error so streamWithReconnect enters its backoff.
|
||||
type midStreamAbortProvider struct {
|
||||
calls int32
|
||||
abortBefore int32
|
||||
abortError string
|
||||
partialText string
|
||||
emptyTextEvent bool
|
||||
partialToolCall string
|
||||
hangOnCall int32
|
||||
connectFailFrom int32
|
||||
connectErr string
|
||||
started chan struct{}
|
||||
}
|
||||
|
||||
func (p *midStreamAbortProvider) StreamCompletion(ctx context.Context, _ zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
n := atomic.AddInt32(&p.calls, 1)
|
||||
if p.connectFailFrom > 0 && n >= p.connectFailFrom {
|
||||
if p.started != nil {
|
||||
select {
|
||||
case <-p.started:
|
||||
default:
|
||||
close(p.started)
|
||||
}
|
||||
}
|
||||
errMsg := p.connectErr
|
||||
if errMsg == "" {
|
||||
errMsg = "connection reset by peer"
|
||||
}
|
||||
return nil, errors.New(errMsg)
|
||||
}
|
||||
if p.hangOnCall > 0 && n == p.hangOnCall {
|
||||
if p.started != nil {
|
||||
close(p.started)
|
||||
}
|
||||
ch := make(chan zeroruntime.StreamEvent)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
close(ch)
|
||||
}()
|
||||
return ch, nil
|
||||
}
|
||||
ch := make(chan zeroruntime.StreamEvent, 5)
|
||||
if n <= p.abortBefore {
|
||||
if p.emptyTextEvent {
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: ""}
|
||||
}
|
||||
if p.partialText != "" {
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: p.partialText}
|
||||
}
|
||||
if p.partialToolCall != "" {
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "tc_1", ToolName: p.partialToolCall}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "tc_1", ArgumentsFragment: `{"path":"x.html","content":"<!doctype`}
|
||||
}
|
||||
errMsg := p.abortError
|
||||
if errMsg == "" {
|
||||
errMsg = "provider stream error: read: connection reset by peer"
|
||||
}
|
||||
ch <- zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventError,
|
||||
Error: errMsg,
|
||||
}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func TestRunRetriesMidStreamConnectionReset(t *testing.T) {
|
||||
p := &midStreamAbortProvider{abortBefore: 1}
|
||||
result, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
|
||||
if err != nil {
|
||||
t.Fatalf("a mid-stream connection reset should retry to success, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 retry), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRetriesMidStreamWindowsAbort(t *testing.T) {
|
||||
p := &midStreamAbortProvider{
|
||||
abortBefore: 1,
|
||||
abortError: "provider stream error: read: wsarecv: An established connection was aborted by the software in your host machine",
|
||||
}
|
||||
result, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
|
||||
if err != nil {
|
||||
t.Fatalf("a Windows mid-stream abort should retry to success, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 retry), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// wsarecv alone (no "connection was aborted" substring) must still retry, so
|
||||
// deleting the wsarecv needle cannot go green.
|
||||
func TestRunRetriesMidStreamWsarecvNeedle(t *testing.T) {
|
||||
p := &midStreamAbortProvider{
|
||||
abortBefore: 1,
|
||||
abortError: "provider stream error: read: wsarecv: 10053",
|
||||
}
|
||||
result, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
|
||||
if err != nil {
|
||||
t.Fatalf("wsarecv without 'connection was aborted' should retry to success, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 retry), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotRetryMidStreamAbortAfterPartialOutput(t *testing.T) {
|
||||
sawText := 0
|
||||
p := &midStreamAbortProvider{abortBefore: 1, partialText: "partial"}
|
||||
_, err := Run(context.Background(), "go", p, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnText: func(string) { sawText++ },
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("a mid-stream abort after partial output must NOT be retried; want an error")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 1 {
|
||||
t.Fatalf("partial-then-abort must not retry, got %d calls", got)
|
||||
}
|
||||
if sawText == 0 {
|
||||
t.Fatal("OnText must have fired so forwardedVisibleText is the named guard")
|
||||
}
|
||||
}
|
||||
|
||||
// A zero-length OnText chunk is not committed answer prose, so an eligible
|
||||
// transport abort still retries.
|
||||
func TestRunRetriesMidStreamAbortAfterEmptyTextEvent(t *testing.T) {
|
||||
p := &midStreamAbortProvider{abortBefore: 1, emptyTextEvent: true}
|
||||
result, err := Run(context.Background(), "go", p, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnText: func(string) {},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("empty OnText must not block mid-stream abort retry, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 retry), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRetriesMidStreamAbortAfterIncompleteToolCall(t *testing.T) {
|
||||
starts := 0
|
||||
dispatched := 0
|
||||
p := &midStreamAbortProvider{abortBefore: 1, partialToolCall: "write_file"}
|
||||
result, err := Run(context.Background(), "go", p, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnToolCallStart: func(string, string) { starts++ },
|
||||
OnToolCallDelta: func(string, string) {},
|
||||
OnToolCall: func(ToolCall) { dispatched++ },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("a mid-stream abort mid-incomplete-tool-call should retry to success, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 retry), got %d", got)
|
||||
}
|
||||
if starts != 1 {
|
||||
t.Fatalf("the incomplete tool call should have been forwarded once before the abort, got %d starts", starts)
|
||||
}
|
||||
if dispatched != 0 {
|
||||
t.Fatalf("OnToolCall must never fire for an incomplete aborted tool call (no dispatch), got %d", dispatched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunGivesUpAfterMaxMidStreamAbortRetries(t *testing.T) {
|
||||
if maxStreamStallRetries != 1 {
|
||||
t.Fatalf("maxStreamStallRetries = %d, want 1 (#973 must not raise this bound)", maxStreamStallRetries)
|
||||
}
|
||||
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
|
||||
streamReconnectBase = time.Millisecond
|
||||
p := &midStreamAbortProvider{abortBefore: 99}
|
||||
_, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
|
||||
if err == nil {
|
||||
t.Fatal("a persistent mid-stream abort must surface an error after exhausting retries")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 retry); pin the bound at 1, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMidStreamAbortUsesReconnectNotice(t *testing.T) {
|
||||
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
|
||||
streamReconnectBase = time.Millisecond
|
||||
var notices string
|
||||
p := &midStreamAbortProvider{abortBefore: 1}
|
||||
result, err := Run(context.Background(), "go", p, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnReasoning: func(s string) { notices += s },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("retry should succeed, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
lower := strings.ToLower(notices)
|
||||
if !strings.Contains(lower, "connection lost") || !strings.Contains(lower, "reconnecting") {
|
||||
t.Fatalf("transport abort must use reconnect wording, notices = %q", notices)
|
||||
}
|
||||
if strings.Contains(lower, "model stalled") {
|
||||
t.Fatalf("transport abort must not use stall wording, notices = %q", notices)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCancelDuringMidStreamRetryPreservesContextCanceled(t *testing.T) {
|
||||
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
|
||||
streamReconnectBase = time.Millisecond
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
started := make(chan struct{})
|
||||
p := &midStreamAbortProvider{abortBefore: 1, hangOnCall: 2, started: started}
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := Run(ctx, "go", p, Options{Registry: tools.NewRegistry()})
|
||||
errCh <- err
|
||||
}()
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for retried stream")
|
||||
}
|
||||
cancel()
|
||||
err := <-errCh
|
||||
if err == nil {
|
||||
t.Fatal("want context.Canceled after cancel during retried stream")
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancel during retried stream must keep the sentinel, got %q (errors.Is Canceled = false)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCancelDuringReplacementConnectBackoffPreservesContextCanceled(t *testing.T) {
|
||||
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
|
||||
streamReconnectBase = 200 * time.Millisecond
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
started := make(chan struct{})
|
||||
p := &midStreamAbortProvider{abortBefore: 1, connectFailFrom: 2, started: started}
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := Run(ctx, "go", p, Options{Registry: tools.NewRegistry()})
|
||||
errCh <- err
|
||||
}()
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for replacement connect failure")
|
||||
}
|
||||
cancel()
|
||||
err := <-errCh
|
||||
if err == nil {
|
||||
t.Fatal("want context.Canceled after cancel during replacement connect backoff")
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancel during replacement connect backoff must keep the sentinel, got %q (errors.Is Canceled = false)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMidStreamAbortRecordsReconnectCountOnImmediateReplacement(t *testing.T) {
|
||||
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
|
||||
streamReconnectBase = time.Millisecond
|
||||
rec := trace.NewRecorder("midstream-abort", "run-1", "test")
|
||||
p := &midStreamAbortProvider{abortBefore: 1}
|
||||
result, err := Run(context.Background(), "go", p, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
Trace: rec,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("retry should succeed, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 immediate replacement), got %d", got)
|
||||
}
|
||||
gotTrace := rec.Finish()
|
||||
if got := gotTrace.Counter(trace.CounterReconnectCount); got != 1 {
|
||||
t.Fatalf("reconnect_count = %d, want 1 for a successful replacement connection", got)
|
||||
}
|
||||
if got := gotTrace.Counter(trace.CounterModelRequests); got != 2 {
|
||||
t.Fatalf("model_requests = %d, want 2 (initial + replacement)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotRetryResponseHeaderTimeout(t *testing.T) {
|
||||
p := &midStreamAbortProvider{
|
||||
abortBefore: 99,
|
||||
abortError: "net/http: timeout awaiting response headers",
|
||||
}
|
||||
_, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
|
||||
if err == nil {
|
||||
t.Fatal("a response-header timeout must not be retried as a mid-stream abort")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 1 {
|
||||
t.Fatalf("header timeout must not retry, got %d calls", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMidStreamTransportAbort(t *testing.T) {
|
||||
aborts := []string{
|
||||
"provider stream error: read: connection reset by peer",
|
||||
"provider stream error: unexpected EOF",
|
||||
"provider stream error: read: wsarecv: 10053",
|
||||
"provider stream error: read: wsarecv: An established connection was aborted by the software in your host machine",
|
||||
"An existing connection was forcibly closed by the remote host",
|
||||
"provider stream error: read: connection closed",
|
||||
"write: broken pipe",
|
||||
"provider stream error: server closed the connection",
|
||||
"provider stream error: software caused connection abort",
|
||||
}
|
||||
for _, m := range aborts {
|
||||
if !isMidStreamTransportAbort(m) {
|
||||
t.Fatalf("want mid-stream transport abort: %q", m)
|
||||
}
|
||||
}
|
||||
notAborts := []string{
|
||||
"",
|
||||
"context length exceeded",
|
||||
"rate limit error: slow down",
|
||||
"model not found",
|
||||
"net/http: timeout awaiting response headers",
|
||||
"dial tcp 10.0.0.1:443: connect: connection refused",
|
||||
"i/o timeout",
|
||||
"500 Internal Server Error",
|
||||
"502 Bad Gateway",
|
||||
"503 Service Unavailable",
|
||||
"504 Gateway Timeout",
|
||||
"provider request error: request does not satisfy oneOf schema",
|
||||
"provider request error: connection closed is not a supported finish reason",
|
||||
"provider error: connection closed is not a supported finish reason",
|
||||
"provider error: upstream connection reset by peer",
|
||||
"provider error: unexpected EOF while reading upstream response",
|
||||
"provider error: 502 Bad Gateway",
|
||||
"auth error: connection reset by peer",
|
||||
"rate limit error: server closed",
|
||||
"schema error: property oneOf is invalid",
|
||||
}
|
||||
for _, m := range notAborts {
|
||||
if isMidStreamTransportAbort(m) {
|
||||
t.Fatalf("must NOT classify as mid-stream transport abort: %q", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotRetryClassifiedProviderErrorWithSocketPhrase(t *testing.T) {
|
||||
p := &midStreamAbortProvider{
|
||||
abortBefore: 1,
|
||||
abortError: "provider request error: connection closed is not a supported finish reason",
|
||||
}
|
||||
var notices string
|
||||
opts := Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnReasoning: func(s string) { notices += s },
|
||||
}
|
||||
_, err := Run(context.Background(), "go", p, opts)
|
||||
if err == nil {
|
||||
t.Fatal("expected application error, got nil")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 1 {
|
||||
t.Fatalf("classified provider error with 'connection closed' must not retry (got %d calls, want 1)", got)
|
||||
}
|
||||
if notices != "" {
|
||||
t.Fatalf("expected no reconnect notices, got %q", notices)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "connection closed") {
|
||||
t.Fatalf("expected original error preserved, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotRetryProviderErrorWithSocketPhrase(t *testing.T) {
|
||||
p := &midStreamAbortProvider{
|
||||
abortBefore: 1,
|
||||
abortError: "provider error: unexpected EOF while reading upstream response",
|
||||
}
|
||||
var notices string
|
||||
opts := Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnReasoning: func(s string) { notices += s },
|
||||
}
|
||||
_, err := Run(context.Background(), "go", p, opts)
|
||||
if err == nil {
|
||||
t.Fatal("expected application error, got nil")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 1 {
|
||||
t.Fatalf("provider error with 'unexpected EOF' must not retry (got %d calls, want 1)", got)
|
||||
}
|
||||
if notices != "" {
|
||||
t.Fatalf("expected no reconnect notices, got %q", notices)
|
||||
}
|
||||
if got := err.Error(); got != p.abortError {
|
||||
t.Fatalf("error = %q, want original provider error %q", got, p.abortError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotRetryApplicationErrorContainingEOFSubstring(t *testing.T) {
|
||||
p := &midStreamAbortProvider{
|
||||
abortBefore: 1,
|
||||
abortError: "provider request error: request does not satisfy oneOf schema",
|
||||
}
|
||||
var notices string
|
||||
opts := Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnReasoning: func(s string) { notices += s },
|
||||
}
|
||||
_, err := Run(context.Background(), "go", p, opts)
|
||||
if err == nil {
|
||||
t.Fatal("expected application error, got nil")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 1 {
|
||||
t.Fatalf("application error with 'oneOf' must not retry (got %d calls, want 1)", got)
|
||||
}
|
||||
if notices != "" {
|
||||
t.Fatalf("expected no reconnect notices, got %q", notices)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "oneOf") {
|
||||
t.Fatalf("expected original error preserved, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRetriesMidStreamUnexpectedEOF(t *testing.T) {
|
||||
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
|
||||
streamReconnectBase = time.Millisecond
|
||||
p := &midStreamAbortProvider{
|
||||
abortBefore: 1,
|
||||
abortError: "provider stream error: unexpected EOF",
|
||||
}
|
||||
var notices string
|
||||
opts := Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnReasoning: func(s string) { notices += s },
|
||||
}
|
||||
result, err := Run(context.Background(), "go", p, opts)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected EOF should retry to success, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 abort + 1 retry), got %d", got)
|
||||
}
|
||||
lower := strings.ToLower(notices)
|
||||
if !strings.Contains(lower, "connection lost") || !strings.Contains(lower, "reconnecting") {
|
||||
t.Fatalf("expected reconnect notice, got %q", notices)
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,15 @@ func shouldReconnect(ctx context.Context, err error) bool {
|
||||
"i/o timeout",
|
||||
"server closed",
|
||||
"unexpected end",
|
||||
// Windows mid-stream aborts (WSAECONNABORTED / wsarecv) — host or AV
|
||||
// forcibly closes an established socket during CollectStream.
|
||||
// "software caused connection abort" is the Unix/macOS ECONNABORTED
|
||||
// text; it also appears in midStreamAbortNeedles, which must stay a
|
||||
// subset of this list.
|
||||
"wsarecv",
|
||||
"connection was aborted",
|
||||
"software caused connection abort",
|
||||
"forcibly closed",
|
||||
} {
|
||||
if strings.Contains(msg, needle) {
|
||||
return true
|
||||
@@ -152,6 +161,86 @@ func shouldReconnect(ctx context.Context, err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// midStreamAbortNeedles are the transport-abort classes retried AFTER connect
|
||||
// succeeded and the stream body has started. This is a subset of shouldReconnect:
|
||||
// connect-phase signals (timeout, connection refused, "temporarily unavailable")
|
||||
// stay on the connect retry path, because matching them here would re-prefill a
|
||||
// healthy-but-slow server (ollama cloud header timeouts) and tell the user the
|
||||
// connection was lost. Issue #973 names WSAECONNABORTED / connection reset /
|
||||
// unexpected stream EOF.
|
||||
var midStreamAbortNeedles = []string{
|
||||
"unexpected end",
|
||||
"connection reset",
|
||||
"broken pipe",
|
||||
"connection closed",
|
||||
"server closed",
|
||||
"wsarecv",
|
||||
"connection was aborted",
|
||||
"software caused connection abort",
|
||||
"forcibly closed",
|
||||
}
|
||||
|
||||
var classifiedNonTransportPrefixes = []string{
|
||||
"provider request error:",
|
||||
"provider error:",
|
||||
"auth error:",
|
||||
"rate limit error:",
|
||||
}
|
||||
|
||||
// isMidStreamTransportAbort reports whether a collected stream error string is a
|
||||
// retryable mid-stream transport abort. It does NOT delegate to shouldReconnect:
|
||||
// that list is a connect-phase argument ("no response was received"). The stream
|
||||
// body has already started here, so only abort/reset/EOF/close classes retry.
|
||||
//
|
||||
// Used for failures DURING the stream body AFTER a successful connect and BEFORE
|
||||
// tool dispatch — the incomplete turn committed no answer text and executed no
|
||||
// tools, so a bounded re-issue is safe (same safety rules as the stall path).
|
||||
func isMidStreamTransportAbort(message string) bool {
|
||||
lowered := strings.ToLower(strings.TrimSpace(message))
|
||||
if lowered == "" || isContextLimitError(lowered) {
|
||||
return false
|
||||
}
|
||||
for _, prefix := range classifiedNonTransportPrefixes {
|
||||
if strings.Contains(lowered, prefix) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if errhint.HasStatusCode(lowered, "500", "502", "503", "504") {
|
||||
return false
|
||||
}
|
||||
if containsWordBoundary(lowered, "eof") {
|
||||
return true
|
||||
}
|
||||
for _, needle := range midStreamAbortNeedles {
|
||||
if strings.Contains(lowered, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsWordBoundary(text, word string) bool {
|
||||
start := 0
|
||||
for {
|
||||
idx := strings.Index(text[start:], word)
|
||||
if idx < 0 {
|
||||
return false
|
||||
}
|
||||
pos := start + idx
|
||||
endPos := pos + len(word)
|
||||
leftBoundary := pos == 0 || !isAlphaNumByte(text[pos-1])
|
||||
rightBoundary := endPos == len(text) || !isAlphaNumByte(text[endPos])
|
||||
if leftBoundary && rightBoundary {
|
||||
return true
|
||||
}
|
||||
start = pos + 1
|
||||
}
|
||||
}
|
||||
|
||||
func isAlphaNumByte(c byte) bool {
|
||||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'
|
||||
}
|
||||
|
||||
// backoffFor is the deterministic exponential base delay for a 1-based attempt,
|
||||
// capped at streamReconnectMax. Jitter is layered on separately (jitteredBackoff).
|
||||
func backoffFor(attempt int) time.Duration {
|
||||
|
||||
@@ -92,6 +92,12 @@ func TestShouldReconnectClassification(t *testing.T) {
|
||||
disconnects := []string{
|
||||
"unexpected EOF", "connection reset by peer", "broken pipe",
|
||||
"i/o timeout", "server closed the connection", "connection refused",
|
||||
// Windows socket aborts also remain reconnectable at connect time.
|
||||
"wsarecv: An established connection was aborted by the software in your host machine",
|
||||
"read: connection was aborted",
|
||||
// Unix/macOS ECONNABORTED text.
|
||||
"read: software caused connection abort",
|
||||
"An existing connection was forcibly closed by the remote host",
|
||||
}
|
||||
for _, m := range disconnects {
|
||||
if !shouldReconnect(ctx, errors.New(m)) {
|
||||
|
||||
@@ -10,6 +10,24 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestNonVisionImageNoticeQualifiesBudgetRecovery(t *testing.T) {
|
||||
result := ToolResult{
|
||||
Name: "mcp_screenshot",
|
||||
Output: "One image exceeded this result's remaining image budget. Retrying with fewer images can recover this payload.",
|
||||
Images: []zeroruntime.ImageBlock{{MediaType: "image/png", Data: []byte("image")}},
|
||||
}
|
||||
notice, ok := toolResultImageMessage(result, Options{
|
||||
SupportsVision: func(string) bool { return false },
|
||||
})
|
||||
if !ok || len(notice.Images) != 0 {
|
||||
t.Fatal("non-vision delivery must produce a text-only notice")
|
||||
}
|
||||
if !strings.Contains(notice.Content, "Switch to a vision-capable model before retrying") ||
|
||||
!strings.Contains(notice.Content, "budget") {
|
||||
t.Fatalf("notice does not qualify budget recovery: %s", notice.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// imageTool returns a tool result carrying an image, the way a screenshot tool
|
||||
// should be able to.
|
||||
type imageTool struct {
|
||||
@@ -62,6 +80,7 @@ func TestRunDeliversToolResultImagesToTheModel(t *testing.T) {
|
||||
result, err := Run(context.Background(), "screenshot please", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 2,
|
||||
Model: "gpt-4o",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
@@ -86,11 +105,16 @@ func TestRunDeliversToolResultImagesToTheModel(t *testing.T) {
|
||||
|
||||
// The tool-result pairing must be untouched: one tool result per tool call.
|
||||
toolResults := 0
|
||||
var toolText string
|
||||
for _, message := range result.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
toolResults++
|
||||
toolText = message.Content
|
||||
}
|
||||
}
|
||||
if toolText != "Captured a screenshot." {
|
||||
t.Fatalf("tool text = %q, want preserved", toolText)
|
||||
}
|
||||
if toolResults != 1 {
|
||||
t.Errorf("got %d tool-result messages for 1 tool call", toolResults)
|
||||
}
|
||||
@@ -136,7 +160,7 @@ func TestRunKeepsToolResultsContiguousWhenAToolReturnsAnImage(t *testing.T) {
|
||||
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "two calls", provider, Options{Registry: registry, MaxTurns: 2})
|
||||
result, err := Run(context.Background(), "two calls", provider, Options{Registry: registry, MaxTurns: 2, Model: "gpt-4o"})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
@@ -205,3 +229,176 @@ func messageShape(messages []zeroruntime.Message) string {
|
||||
}
|
||||
return "[" + strings.Join(parts, " ") + "]"
|
||||
}
|
||||
|
||||
func TestRunDropsToolResultImagesForANonVisionModel(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(imageTool{media: "image/png", data: []byte("\x89PNG\r\n\x1a\nfake")})
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_1", ToolName: "capture"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{{Type: zeroruntime.StreamEventText, Content: "ok"}, {Type: zeroruntime.StreamEventDone}},
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "screenshot please", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 2,
|
||||
Model: "totally-made-up-model",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
var toolText string
|
||||
var noticed bool
|
||||
for _, message := range result.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
toolText = message.Content
|
||||
}
|
||||
if len(message.Images) > 0 {
|
||||
t.Fatalf("non-vision model received image bytes on %q: %s", message.Role, messageShape(result.Messages))
|
||||
}
|
||||
if strings.Contains(message.Content, "does not support image input") {
|
||||
noticed = true
|
||||
}
|
||||
}
|
||||
if toolText != "Captured a screenshot." {
|
||||
t.Fatalf("tool text = %q, want preserved on the non-vision path", toolText)
|
||||
}
|
||||
if !noticed {
|
||||
t.Fatalf("non-vision model was not told the image was dropped; recorded %s", messageShape(result.Messages))
|
||||
}
|
||||
}
|
||||
|
||||
type switchAndCaptureTool struct {
|
||||
targetModel string
|
||||
}
|
||||
|
||||
func (switchAndCaptureTool) Name() string { return "switch_and_capture" }
|
||||
func (switchAndCaptureTool) Description() string { return "Switches model and captures image" }
|
||||
func (switchAndCaptureTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
|
||||
}
|
||||
func (switchAndCaptureTool) Safety() tools.Safety {
|
||||
return tools.Safety{Permission: tools.PermissionAllow}
|
||||
}
|
||||
func (t switchAndCaptureTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{
|
||||
Status: tools.StatusOK,
|
||||
Output: "[image returned by tool]",
|
||||
Images: []zeroruntime.ImageBlock{{MediaType: "image/png", Data: []byte("\x89PNG\r\n\x1a\nfake")}},
|
||||
Meta: map[string]string{"escalate_to_model": t.targetModel},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunToolImagesRespectsModelSwitch(t *testing.T) {
|
||||
t.Run("Non-vision to vision model escalation forwards images", func(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(switchAndCaptureTool{targetModel: "gpt-4o"})
|
||||
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_1", ToolName: "switch_and_capture"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "I see it on gpt-4o."},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
|
||||
switched := false
|
||||
switcher := func(_ context.Context, modelID string) (Provider, error) {
|
||||
switched = true
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "test", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 2,
|
||||
Model: "non-vision-initial",
|
||||
ModelSwitcher: switcher,
|
||||
SupportsVision: func(modelID string) bool {
|
||||
return modelID == "gpt-4o"
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if !switched {
|
||||
t.Fatal("expected model switch to occur")
|
||||
}
|
||||
|
||||
var carrier *zeroruntime.Message
|
||||
for index := range result.Messages {
|
||||
if len(result.Messages[index].Images) > 0 {
|
||||
carrier = &result.Messages[index]
|
||||
}
|
||||
if strings.Contains(result.Messages[index].Content, "does not support image input") {
|
||||
t.Fatalf("images were unexpectedly dropped after switch to vision model: %v", result.Messages[index])
|
||||
}
|
||||
}
|
||||
if carrier == nil {
|
||||
t.Fatal("expected image carrier message after escalation to vision model")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Vision to non-vision model switch drops images with notice", func(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(switchAndCaptureTool{targetModel: "text-only-dest"})
|
||||
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call_1", ToolName: "switch_and_capture"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call_1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call_1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "ok."},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
|
||||
switcher := func(_ context.Context, modelID string) (Provider, error) {
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "test", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 2,
|
||||
Model: "gpt-4o",
|
||||
ModelSwitcher: switcher,
|
||||
SupportsVision: func(modelID string) bool {
|
||||
return modelID == "gpt-4o"
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
var noticed bool
|
||||
var toolOutputSeen bool
|
||||
for _, m := range result.Messages {
|
||||
if len(m.Images) > 0 {
|
||||
t.Fatalf("image delivered to non-vision destination model: %v", m)
|
||||
}
|
||||
if strings.Contains(m.Content, "does not support image input") {
|
||||
noticed = true
|
||||
}
|
||||
if strings.Contains(m.Content, "[image returned by tool]") {
|
||||
toolOutputSeen = true
|
||||
}
|
||||
}
|
||||
if !noticed {
|
||||
t.Fatal("expected drop notice when switching to non-vision model")
|
||||
}
|
||||
if !toolOutputSeen {
|
||||
t.Fatal("expected tool output preserved in message history")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -341,6 +341,11 @@ type Options struct {
|
||||
// nil for text-only runs (the seeded message then carries no images, exactly
|
||||
// as before).
|
||||
Images []zeroruntime.ImageBlock
|
||||
// SupportsVision, when set, reports whether the effective model accepts
|
||||
// image input. Tool-produced images are dropped at the shared delivery
|
||||
// boundary when this is false. nil uses the curated catalog plus the
|
||||
// name heuristic via modelregistry.SupportsVision.
|
||||
SupportsVision func(modelID string) bool
|
||||
// ContextWindow is the model's maximum input token budget. When > 0 the agent
|
||||
// loop compacts long conversations once the estimated size crosses a fraction
|
||||
// of this window. 0 DISABLES compaction entirely (every existing caller/test
|
||||
|
||||
@@ -4,6 +4,7 @@ package background
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
@@ -46,7 +47,13 @@ func terminateOwnedProcess(cmd *exec.Cmd) (bool, error) {
|
||||
terminateErr = terminateProcessForTest(cmd.Process.Pid)
|
||||
})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("pin process %d for termination: %w", cmd.Process.Pid, err)
|
||||
// WithHandle refuses only a process that has already been waited or
|
||||
// released, so it has finished. Say so in the form exec.Cmd understands:
|
||||
// a Cancel error wrapping os.ErrProcessDone is "nothing to cancel", where
|
||||
// the unexported "already released" error it would otherwise carry made
|
||||
// Wait report a spurious cancel failure. A dead root has no tree left to
|
||||
// find either way.
|
||||
return true, fmt.Errorf("pin process %d for termination: %w: %w", cmd.Process.Pid, os.ErrProcessDone, err)
|
||||
}
|
||||
return alreadyExited, terminateErr
|
||||
}
|
||||
|
||||
@@ -73,6 +73,29 @@ func TerminateCommand(cmd *exec.Cmd) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TerminateCommandGroup stops a started command's process group on POSIX, or its
|
||||
// process tree on Windows, WITHOUT reaping it.
|
||||
//
|
||||
// It is TerminateCommand without the Wait, for a caller whose Wait already runs
|
||||
// somewhere else: a worker pool waiting on the command in its own goroutine, or
|
||||
// the command's own Cancel hook, which exec.Cmd invokes while its Wait is in
|
||||
// progress. TerminateCommand would be a second Wait there. Both used to pass
|
||||
// cmd.Process.Pid to TerminateProcess, which rediscovers the group at signal time
|
||||
// with Getpgid, and once the leader has exited that lookup fails, so only the dead
|
||||
// leader was signalled and its descendants kept running. When cmd was configured
|
||||
// by ConfigureChildProcessGroup the group now comes from that launch-time
|
||||
// configuration instead. On Windows it is the tree kill TerminateProcess performs,
|
||||
// with the process handle pinned while it runs so the PID cannot be recycled
|
||||
// underneath it; once the command has been waited that pin fails with an error
|
||||
// wrapping os.ErrProcessDone.
|
||||
func TerminateCommandGroup(cmd *exec.Cmd) error {
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := terminateOwnedProcess(cmd)
|
||||
return err
|
||||
}
|
||||
|
||||
// classifyWaitError treats a non-zero exit status as success: a process being
|
||||
// terminated is expected to report one (or a signal), so the only interesting
|
||||
// failure is Wait itself not working.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package background
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TerminateCommandGroup LEAVES THE WAIT TO ITS CALLER. Its callers already have a
|
||||
// Wait running or about to run: the daemon pool waits on its workers, and
|
||||
// exec.Cmd calls the Cancel hook from inside Wait. Reaping here, the way
|
||||
// TerminateCommand does, would make their Wait fail with "Wait was already
|
||||
// called" instead of reporting how the command ended.
|
||||
func TestTerminateCommandGroupLeavesTheWaitToTheCaller(t *testing.T) {
|
||||
cmd := exec.Command("sleep", "30")
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd = exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 30")
|
||||
}
|
||||
ConfigureChildProcessGroup(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
if err := TerminateCommandGroup(cmd); err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
t.Fatalf("TerminateCommandGroup: %v", err)
|
||||
}
|
||||
|
||||
waited := make(chan error, 1)
|
||||
go func() { waited <- cmd.Wait() }()
|
||||
select {
|
||||
case err := <-waited:
|
||||
var exitErr *exec.ExitError
|
||||
if err != nil && !errors.As(err, &exitErr) {
|
||||
t.Fatalf("the caller's own Wait failed after TerminateCommandGroup: %v", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
_ = cmd.Process.Kill()
|
||||
t.Fatal("the command was still running 10s after TerminateCommandGroup")
|
||||
}
|
||||
}
|
||||
|
||||
// A command that was never started, or a nil one, is nothing to stop.
|
||||
func TestTerminateCommandGroupWithNoProcessIsANoOp(t *testing.T) {
|
||||
if err := TerminateCommandGroup(nil); err != nil {
|
||||
t.Fatalf("nil command: %v", err)
|
||||
}
|
||||
if err := TerminateCommandGroup(exec.Command("sleep", "1")); err != nil {
|
||||
t.Fatalf("unstarted command: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// After the caller has waited, Windows cannot pin the process any more. The error
|
||||
// must still read as "already finished", which is what exec.Cmd's Cancel contract
|
||||
// and the pool both treat as nothing wrong.
|
||||
func TestTerminateCommandGroupAfterWaitReportsProcessDone(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("POSIX signals the group, which is simply gone after the wait")
|
||||
}
|
||||
cmd := exec.Command("cmd.exe", "/c", "exit", "0")
|
||||
ConfigureChildProcessGroup(cmd)
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
if err := TerminateCommandGroup(cmd); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||
t.Fatalf("after Wait the error is %v, want nil or one wrapping os.ErrProcessDone", err)
|
||||
}
|
||||
}
|
||||
@@ -695,7 +695,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
// Refresh the models.dev pricing/limits cache in the background when stale;
|
||||
// the overlay is read at registry construction from the cache file, so this
|
||||
// benefits the next run and never blocks or fails this one.
|
||||
go func() { _ = modelregistry.RefreshModelsDevCache(context.Background()) }()
|
||||
modelregistry.StartModelsDevRefresh()
|
||||
|
||||
workspaceRoot, err := deps.getwd()
|
||||
if err != nil {
|
||||
@@ -727,7 +727,13 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
resolved.Provider = usable
|
||||
resolved.ActiveProvider = usable.Name
|
||||
} else {
|
||||
resolved = config.ResolvedConfig{}
|
||||
// Fresh-onboarding reset, but the user's notification preference is
|
||||
// NOT config-to-redo: clearing it here would surface as an empty
|
||||
// Options.Notify, and the TUI's unconfigured default (both/unfocused)
|
||||
// would then resurrect alerts a user explicitly turned off while the
|
||||
// setup wizard runs (maintainer review, PR #1001). Carry the stored
|
||||
// block through; other wizard-visible state starts clean as before.
|
||||
resolved = config.ResolvedConfig{Notify: resolved.Notify}
|
||||
forceSetup = true
|
||||
}
|
||||
}
|
||||
@@ -959,7 +965,12 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
// notice when project hooks/plugins were dropped for an untrusted workspace.
|
||||
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot, executionRunner)
|
||||
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
|
||||
// From the engine the session's commands run through, so the notice
|
||||
// describes the sandbox they actually get.
|
||||
startupNotices := []string{sandboxEngine.DegradedNotice()}
|
||||
modelRPM := zeroruntime.NewModelRPMLimiter(resolved.ModelRPM)
|
||||
return deps.runTUI(context.Background(), tui.Options{
|
||||
ModelRPM: modelRPM,
|
||||
Cwd: workspaceRoot,
|
||||
Version: version,
|
||||
Theme: theme,
|
||||
@@ -982,7 +993,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
if provider == nil {
|
||||
return nil
|
||||
}
|
||||
if optimized, ok := providers.OptimizedTurnSessions(profile, provider, providers.Options{}); ok {
|
||||
if optimized, ok := providers.ConfiguredTurnSessions(profile, provider, providers.Options{ModelRPM: modelRPM}); ok {
|
||||
return optimized
|
||||
}
|
||||
return providers.DefaultTurnSessions(profile, provider, providers.Options{})
|
||||
@@ -1046,6 +1057,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
PermissionMode: permissionMode,
|
||||
Notify: resolved.Notify,
|
||||
KeyBindings: resolved.KeyBindings,
|
||||
StartupNotices: startupNotices,
|
||||
STT: resolved.STT,
|
||||
BuildDictationTranscriber: newDictationTranscriberFactory(resolved, userConfigPath, sttServerManager),
|
||||
ShutdownDictationServer: sttServerManager.Shutdown,
|
||||
|
||||
@@ -343,8 +343,10 @@ func TestRunNoArgsFallsBackToUsableProviderWhenNoneMarkedActive(t *testing.T) {
|
||||
// config.json has providers configured but none marked active (e.g. a
|
||||
// blank/stale activeProvider field) — Resolve returns the successfully
|
||||
// normalized providers list alongside ErrNoActiveProvider.
|
||||
return config.ResolvedConfig{Providers: []config.ProviderProfile{usable}},
|
||||
fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "")
|
||||
return config.ResolvedConfig{
|
||||
Providers: []config.ProviderProfile{usable},
|
||||
ModelRPM: map[string]int{"gpt-test": 1},
|
||||
}, fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "")
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
providerProfile = profile
|
||||
@@ -374,6 +376,77 @@ func TestRunNoArgsFallsBackToUsableProviderWhenNoneMarkedActive(t *testing.T) {
|
||||
}
|
||||
if providerProfile.Name != "work" {
|
||||
t.Fatalf("provider used = %q, want fallback to the usable saved provider %q", providerProfile.Name, "work")
|
||||
|
||||
}
|
||||
if launchedOptions.ModelRPM == nil {
|
||||
t.Fatal("ModelRPM = nil, want limiter preserved through provider fallback")
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainer regression (PR #1001): a stored explicit notify opt-out must
|
||||
// survive the provider-recovery startup path. With a stale activeProvider and
|
||||
// another usable saved provider, Resolve returns the partial config alongside
|
||||
// ErrNoActiveProvider and the recovery branch forwards it — dropping the
|
||||
// notify policy there would surface an empty Options.Notify, and the TUI's
|
||||
// unconfigured default (both) would resurrect alerts the user explicitly
|
||||
// turned off. The same recovery branch that clears the resolved config for
|
||||
// the setup wizard carries the stored notify block through.
|
||||
func TestRunNoArgsPreservesNotifyOptOutThroughProviderRecovery(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
setCLIUserConfigRoot(t)
|
||||
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
var launchedOptions tui.Options
|
||||
launched := false
|
||||
|
||||
usable := config.ProviderProfile{
|
||||
Name: "work",
|
||||
ProviderKind: config.ProviderKindOpenAI,
|
||||
BaseURL: config.OpenAIBaseURL,
|
||||
APIKey: "sk-test",
|
||||
Model: "gpt-test",
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
// Stale activeProvider + usable saved provider, and the user's
|
||||
// config.json explicitly opts out of notifications. The fixed
|
||||
// resolver carries the parsed notify block through the error path.
|
||||
return config.ResolvedConfig{
|
||||
Providers: []config.ProviderProfile{usable},
|
||||
Notify: config.NotifyConfig{Mode: "off", FocusMode: "always"},
|
||||
}, fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "ghost")
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return &cliFakeProvider{}, nil
|
||||
},
|
||||
userConfigPath: func() (string, error) {
|
||||
return userConfigPath, nil
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
launched = true
|
||||
launchedOptions = options
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exit code = %d, want 0, stderr=%q", exitCode, stderr.String())
|
||||
}
|
||||
if !launched {
|
||||
t.Fatal("TUI was not launched")
|
||||
}
|
||||
// The explicit opt-out reaches the TUI intact — NOT the both/unfocused
|
||||
// unconfigured default.
|
||||
if launchedOptions.Notify.Mode != "off" || launchedOptions.Notify.FocusMode != "always" {
|
||||
t.Fatalf("Options.Notify = %+v, want the stored off/always opt-out preserved through recovery", launchedOptions.Notify)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,30 @@ type modelSummary = zerocommands.ModelSnapshot
|
||||
type providerCatalogSummary = zerocommands.ProviderCatalogSnapshot
|
||||
|
||||
func runConfig(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
// The first non-flag argument is a subcommand. With no positional argument
|
||||
// (or only flag arguments), the read-only summary path runs — this keeps
|
||||
// `zero config` and `zero config --json` working unchanged.
|
||||
command := "summary"
|
||||
rest := args
|
||||
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
|
||||
command = strings.ToLower(strings.TrimSpace(args[0]))
|
||||
rest = args[1:]
|
||||
}
|
||||
switch command {
|
||||
case "summary":
|
||||
return runConfigSummary(rest, stdout, stderr, deps)
|
||||
case "notify":
|
||||
return runConfigNotify(rest, stdout, stderr, deps)
|
||||
case "help":
|
||||
if err := writeConfigHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unknown config command %q", command))
|
||||
}
|
||||
|
||||
func runConfigSummary(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseCommandCenterArgs(args, false, false)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
@@ -466,8 +490,12 @@ func formatProviderCatalogValue(value string, fallback string) string {
|
||||
func writeConfigHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero config [flags]
|
||||
zero config notify [flags]
|
||||
|
||||
Inspects resolved Go configuration without printing secrets.
|
||||
Inspects resolved Go configuration without printing secrets. The notify
|
||||
subcommand reads or updates the stored global notification preference,
|
||||
which controls both the completion and needs-input alerts —
|
||||
run "zero config notify --help" for details.
|
||||
|
||||
Flags:
|
||||
--json Print JSON summary
|
||||
|
||||
@@ -40,7 +40,11 @@ var completionRoot = completionNode{
|
||||
},
|
||||
{names: []string{"daemon"}, children: leafNodes("start", "stop", "status", "run", "attach", "serve-remote", "link")},
|
||||
{names: []string{"setup"}},
|
||||
{names: []string{"config"}},
|
||||
{names: []string{"config"}, flags: []string{"-h", "--help", "--json"}, children: []completionNode{
|
||||
{names: []string{"notify"}, flags: []string{
|
||||
"-h", "--help", "--mode", "--focus", "--reset", "--json",
|
||||
}},
|
||||
}},
|
||||
{names: []string{"models"}, children: []completionNode{{names: []string{"list", "ls"}}}},
|
||||
{names: []string{"providers"}, children: []completionNode{
|
||||
{names: []string{"current"}}, {names: []string{"list"}}, {names: []string{"catalog"}},
|
||||
|
||||
@@ -165,6 +165,11 @@ func TestCompletionTreeCoversAliasesNestingAndCommonFlags(t *testing.T) {
|
||||
assertCandidates(t, byPath["completions"], "bash", "zsh", "fish", "powershell", "elvish")
|
||||
assertCandidates(t, byPath["plugins"], "list", "add", "info", "remove", "rm")
|
||||
assertCandidates(t, byPath["plugin"], "list", "add", "info", "remove", "rm")
|
||||
// `config` gained the notify subcommand; the completion tree must expose
|
||||
// it (and its flags) so shell completion cannot go stale for new CLI
|
||||
// surfaces (maintainer review, PR #1001).
|
||||
assertCandidates(t, byPath["config"], "notify", "--json", "--help")
|
||||
assertCandidates(t, byPath["config notify"], "--mode", "--focus", "--reset", "--json", "--help")
|
||||
}
|
||||
|
||||
func assertCandidates(t *testing.T, got []string, wants ...string) {
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
// runConfigNotify implements `zero config notify`: with no flags it prints the
|
||||
// stored mode/focusMode; --mode/--focus update them via the same
|
||||
// config.UpdateNotify writer the TUI /notify command uses, so all surfaces stay
|
||||
// in lockstep; --reset blanks both fields so the TUI's effective default
|
||||
// applies again (an unconfigured headless run stays silent).
|
||||
//
|
||||
// The command manages a user preference, so it talks ONLY to the user's own
|
||||
// config file (config.UserNotify / config.UpdateNotify) and never runs the full
|
||||
// config resolution: resolving providers would fail with ErrNoActiveProvider
|
||||
// for a brand-new user, locking them out of setting notifications before they
|
||||
// have even configured a provider (CodeRabbit review, PR #1001). The display
|
||||
// therefore reports the USER'S stored values — a project config that
|
||||
// overrides notify for one repo is not shown here, by the same logic the
|
||||
// maintainer applied to the write path.
|
||||
func runConfigNotify(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseConfigNotifyArgs(args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeConfigNotifyHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
configPath, err := deps.userConfigPath()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
|
||||
if options.mode != "" || options.focus != "" || options.reset {
|
||||
// One serialized read-merge-write transaction: the lock covers
|
||||
// reading the stored block, applying only the explicit fields, and
|
||||
// replacing the file, so two concurrent partial updates (e.g.
|
||||
// --mode off and --focus always from two terminals) cannot lose
|
||||
// each other's change (maintainer review, PR #1001). Omitted
|
||||
// fields preserve the values stored in the user's OWN file — blank
|
||||
// stays blank; --reset is the only path that clears both fields.
|
||||
_, err := config.UpdateNotify(configPath, func(current config.NotifyConfig) config.NotifyConfig {
|
||||
if options.reset {
|
||||
return config.NotifyConfig{}
|
||||
}
|
||||
if options.mode != "" {
|
||||
current.Mode = options.mode
|
||||
}
|
||||
if options.focus != "" {
|
||||
current.FocusMode = options.focus
|
||||
}
|
||||
return current
|
||||
})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
// Report the stored values (blank renders as "(default)").
|
||||
current, err := config.UserNotify(configPath)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitUsage)
|
||||
}
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, map[string]any{
|
||||
"mode": current.Mode,
|
||||
"focusMode": current.FocusMode,
|
||||
}); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
lines := []string{
|
||||
"Notify",
|
||||
"mode: " + displayCLIValue(current.Mode, "(default)"),
|
||||
"focusMode: " + displayCLIValue(current.FocusMode, "(default)"),
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, strings.Join(lines, "\n")); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
type configNotifyOptions struct {
|
||||
mode string
|
||||
focus string
|
||||
reset bool
|
||||
json bool
|
||||
}
|
||||
|
||||
func parseConfigNotifyArgs(args []string) (configNotifyOptions, bool, error) {
|
||||
options := configNotifyOptions{}
|
||||
for index := 0; index < len(args); index++ {
|
||||
arg := args[index]
|
||||
switch {
|
||||
case arg == "-h" || arg == "--help" || arg == "help":
|
||||
return options, true, nil
|
||||
case arg == "--json":
|
||||
options.json = true
|
||||
case arg == "--reset":
|
||||
options.reset = true
|
||||
case arg == "--mode":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.mode = value
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--mode="):
|
||||
value, err := requiredInlineFlagValue(arg, "--mode")
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.mode = value
|
||||
case arg == "--focus":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.focus = value
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--focus="):
|
||||
value, err := requiredInlineFlagValue(arg, "--focus")
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.focus = value
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
|
||||
default:
|
||||
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
|
||||
}
|
||||
}
|
||||
return options, false, nil
|
||||
}
|
||||
|
||||
func writeConfigNotifyHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, "Usage:\n"+
|
||||
" zero config notify [flags]\n"+
|
||||
"\n"+
|
||||
"Print or update the stored global notification preference.\n"+
|
||||
"\n"+
|
||||
"The preference controls BOTH notification kinds: the turn-completion\n"+
|
||||
"(\"Zero: ready\") alert and the needs-input alert. mode off silences both;\n"+
|
||||
"the focus mode (unfocused, always, focused) applies to both.\n"+
|
||||
"\n"+
|
||||
"When run with no flag, prints the stored mode and focusMode; a field you\n"+
|
||||
"never set shows as (default) — the TUI alerts with bell + notification,\n"+
|
||||
"firing only when the terminal is unfocused, while an unconfigured\n"+
|
||||
"headless run stays silent. Omitted flags preserve the values stored in\n"+
|
||||
"YOUR config file; --reset clears both so the TUI's effective default\n"+
|
||||
"applies again. If --reset is combined with --mode/--focus, --reset wins\n"+
|
||||
"and the explicit values are ignored.\n"+
|
||||
"\n"+
|
||||
"Examples:\n"+
|
||||
" zero config notify\n"+
|
||||
" zero config notify --json\n"+
|
||||
" zero config notify --mode both --focus unfocused\n"+
|
||||
" zero config notify --mode off\n"+
|
||||
" zero config notify --reset # clear the stored preference\n"+
|
||||
"\n"+
|
||||
"Flags:\n"+
|
||||
" --mode <off|bell|notify|both> Notification mechanism (both kinds)\n"+
|
||||
" --focus <unfocused|always|focused> When the alert fires\n"+
|
||||
" --reset Clear the stored preference so the TUI effective default applies\n"+
|
||||
" --json Machine-readable output\n"+
|
||||
" -h, --help Show this help\n")
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
// `zero config notify` with no flag and a fresh config: nothing is configured,
|
||||
// the resolver leaves the fields blank (defaults deliberately live in the TUI,
|
||||
// not the resolver — maintainer review, PR #1001), and the command reports
|
||||
// "(default)" for both.
|
||||
func TestRunConfigNotifyPrintsUnconfiguredAsDefault(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
// A valid openai profile so the resolver does not error with
|
||||
// ErrNoActiveProvider. The notify defaults are applied independently of
|
||||
// the provider resolution path.
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}]
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "mode: (default)") {
|
||||
t.Errorf("stdout should show unconfigured mode as (default), got: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "focusMode: (default)") {
|
||||
t.Errorf("stdout should show unconfigured focus as (default), got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// `zero config notify --json` emits a machine-readable payload. A configured
|
||||
// pair round-trips; unconfigured fields are empty strings (never defaults
|
||||
// filled in), so scripts can distinguish "user chose" from "default".
|
||||
func TestRunConfigNotifyPrintsJSON(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}],
|
||||
"notify": {"mode": "bell", "focusMode": "always"}
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["mode"] != "bell" {
|
||||
t.Errorf("mode = %v, want bell", payload["mode"])
|
||||
}
|
||||
if payload["focusMode"] != "always" {
|
||||
t.Errorf("focusMode = %v, want always", payload["focusMode"])
|
||||
}
|
||||
}
|
||||
|
||||
// The unconfigured JSON shape: fields are empty strings, never defaults
|
||||
// filled in, so scripts can tell "user chose" from "default".
|
||||
func TestRunConfigNotifyPrintsJSONUnconfigured(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}]
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["mode"] != "" {
|
||||
t.Errorf("mode = %v, want empty (unconfigured)", payload["mode"])
|
||||
}
|
||||
if payload["focusMode"] != "" {
|
||||
t.Errorf("focusMode = %v, want empty (unconfigured)", payload["focusMode"])
|
||||
}
|
||||
}
|
||||
|
||||
// `zero config notify --mode off` writes the new value to disk and prints
|
||||
// confirmation. The user can read it back by running the command again.
|
||||
func TestRunConfigNotifyWritesModeChange(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}],
|
||||
"notify": {"mode": "both", "focusMode": "always"}
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--mode", "off"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "off" {
|
||||
t.Errorf("Notify.Mode = %q, want off", cfg.Notify.Mode)
|
||||
}
|
||||
// A mode-only update must preserve the configured focusMode, not wipe it.
|
||||
if cfg.Notify.FocusMode != "always" {
|
||||
t.Errorf("Notify.FocusMode = %q, want preserved %q", cfg.Notify.FocusMode, "always")
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "mode: off") {
|
||||
t.Errorf("stdout should confirm the change, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A focus-only update preserves the configured mode.
|
||||
func TestRunConfigNotifyFocusOnlyPreservesMode(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}],
|
||||
"notify": {"mode": "bell", "focusMode": "unfocused"}
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--focus", "always"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "bell" {
|
||||
t.Errorf("Notify.Mode = %q, want preserved %q", cfg.Notify.Mode, "bell")
|
||||
}
|
||||
if cfg.Notify.FocusMode != "always" {
|
||||
t.Errorf("Notify.FocusMode = %q, want always", cfg.Notify.FocusMode)
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainer regression (PR #1001): a partial update must seed from the USER'S
|
||||
// OWN file, never from the resolved view. A project .zero/config.json setting
|
||||
// mode=off resolves into the session, but `--focus always` inside that repo
|
||||
// must NOT copy the project's off into the user's global config.
|
||||
func TestRunConfigNotifyDoesNotCopyProjectNotifyIntoUserConfig(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "user.json")
|
||||
projectPath := filepath.Join(t.TempDir(), "project.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"activeProvider": "openai"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(projectPath, []byte(`{"notify": {"mode": "off"}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
// Resolve EXACTLY the way production does: user config + project config
|
||||
// merged, so resolved.Notify.Mode is the project's "off".
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{
|
||||
UserConfigPath: configPath,
|
||||
ProjectConfigPath: projectPath,
|
||||
Env: map[string]string{"OPENAI_API_KEY": "sk-test"},
|
||||
})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--focus", "always"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
// The user's file must keep an unspecified mode unspecified — the
|
||||
// project's "off" stays in the project file where it belongs.
|
||||
if cfg.Notify.Mode != "" {
|
||||
t.Errorf("user Notify.Mode = %q, want blank (project's off must not leak into the user file)", cfg.Notify.Mode)
|
||||
}
|
||||
if cfg.Notify.FocusMode != "always" {
|
||||
t.Errorf("Notify.FocusMode = %q, want always", cfg.Notify.FocusMode)
|
||||
}
|
||||
// The project file is untouched.
|
||||
project := readFileConfig(t, projectPath)
|
||||
if project.Notify.Mode != "off" {
|
||||
t.Errorf("project Notify.Mode = %q, want untouched off", project.Notify.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainer regression (PR #1001): with a clean config, `--mode off` must not
|
||||
// also pin focusMode as an explicit choice — blank means "use the built-in
|
||||
// default", and a partial update keeps it that way.
|
||||
func TestRunConfigNotifyDoesNotPinDefaultsAsExplicitChoices(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"activeProvider": "openai"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--mode", "off"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "off" {
|
||||
t.Errorf("Notify.Mode = %q, want off", cfg.Notify.Mode)
|
||||
}
|
||||
if cfg.Notify.FocusMode != "" {
|
||||
t.Errorf("Notify.FocusMode = %q, want blank (unspecified stays unspecified; the default must not be pinned)", cfg.Notify.FocusMode)
|
||||
}
|
||||
}
|
||||
|
||||
// `--mode` and `--focus` together update both fields in one call.
|
||||
func TestRunConfigNotifyWritesModeAndFocus(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}]
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--mode", "both", "--focus", "unfocused"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "both" || cfg.Notify.FocusMode != "unfocused" {
|
||||
t.Errorf("Notify = %+v, want mode=both focusMode=unfocused", cfg.Notify)
|
||||
}
|
||||
}
|
||||
|
||||
// `--mode loud` is a usage error. The config must not be mutated.
|
||||
func TestRunConfigNotifyRejectsInvalidMode(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}],
|
||||
"notify": {"mode": "off"}
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--mode", "loud"}, &stdout, &stderr, deps)
|
||||
if exitCode == exitSuccess {
|
||||
t.Fatalf("expected failure for invalid mode, got success; stdout=%s", stdout.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "off" {
|
||||
t.Errorf("Notify.Mode = %q, want off (unchanged after failed write)", cfg.Notify.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
// `--reset` blanks both fields so the TUI's effective default applies again
|
||||
// (an unconfigured headless run stays silent). Useful for "go back to the
|
||||
// recommended setup" after a custom value.
|
||||
func TestRunConfigNotifyResetClearsStoredValues(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := `{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"providerKind": "openai",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKeyEnv": "OPENAI_API_KEY"
|
||||
}],
|
||||
"notify": {"mode": "off", "focusMode": "always"}
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{"OPENAI_API_KEY": "sk-test"}})
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify", "--reset"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "" || cfg.Notify.FocusMode != "" {
|
||||
t.Errorf("Notify after reset = %+v, want empty (defaults apply)", cfg.Notify)
|
||||
}
|
||||
}
|
||||
|
||||
// CodeRabbit regression (PR #1001): the command manages a user preference and
|
||||
// must not require provider resolution. A brand-new user with NO provider
|
||||
// configured (the resolver would return ErrNoActiveProvider) can still read,
|
||||
// set, and reset their notification preference.
|
||||
func TestRunConfigNotifyWorksWithoutAnyProviderConfigured(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// resolveConfig fails the way the real resolver does for a fresh user —
|
||||
// the command must never call it, so a panic-free stub is enough to prove
|
||||
// the point; use the failing resolver to catch any regression to the
|
||||
// resolve-first shape.
|
||||
deps := commandCenterDeps(t)
|
||||
deps.userConfigPath = func() (string, error) { return configPath, nil }
|
||||
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{}})
|
||||
}
|
||||
|
||||
// Read works and shows defaults.
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config", "notify"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("read: exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "mode: (default)") {
|
||||
t.Errorf("read should show (default), got: %s", stdout.String())
|
||||
}
|
||||
|
||||
// Write works.
|
||||
stdout.Reset()
|
||||
exitCode = runWithDeps([]string{"config", "notify", "--mode", "bell", "--focus", "always"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("write: exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg := readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "bell" || cfg.Notify.FocusMode != "always" {
|
||||
t.Fatalf("write: Notify = %+v, want bell/always", cfg.Notify)
|
||||
}
|
||||
|
||||
// JSON read reflects the stored pair.
|
||||
stdout.Reset()
|
||||
exitCode = runWithDeps([]string{"config", "notify", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("json: exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json output invalid: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["mode"] != "bell" || payload["focusMode"] != "always" {
|
||||
t.Errorf("json = mode %v focus %v, want bell/always", payload["mode"], payload["focusMode"])
|
||||
}
|
||||
|
||||
// Reset works.
|
||||
stdout.Reset()
|
||||
exitCode = runWithDeps([]string{"config", "notify", "--reset"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("reset: exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
cfg = readFileConfig(t, configPath)
|
||||
if cfg.Notify.Mode != "" || cfg.Notify.FocusMode != "" {
|
||||
t.Errorf("reset: Notify = %+v, want empty", cfg.Notify)
|
||||
}
|
||||
}
|
||||
|
||||
// `zero config` (no subcommand) still works after the dispatch change.
|
||||
func TestRunConfigSummaryStillWorks(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"config"}, &stdout, &stderr, commandCenterDeps(t))
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Config") {
|
||||
t.Errorf("stdout should show the config summary, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,10 @@ package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -39,3 +43,115 @@ func TestFireJobClaimPreventsDoubleFire(t *testing.T) {
|
||||
t.Fatalf("NextRunAt must advance past now, got %s", d.NextRunAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFireJobOverlappingAdjacentSlots(t *testing.T) {
|
||||
for _, olderFirst := range []bool{false, true} {
|
||||
name := "newer completes first"
|
||||
if olderFirst {
|
||||
name = "older completes first"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
stores := []*cron.Store{
|
||||
cron.NewStore(cron.StoreOptions{RootDir: root}),
|
||||
cron.NewStore(cron.StoreOptions{RootDir: root}),
|
||||
}
|
||||
now := time.Date(2026, 6, 9, 9, 0, 0, 0, time.UTC)
|
||||
job, err := stores[0].Add(cron.Job{Expr: "* * * * *", Prompt: "fire me", NextRunAt: now, FireCount: 7})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var release, done [2]chan struct{}
|
||||
for i := range stores {
|
||||
release[i], done[i] = make(chan struct{}), make(chan struct{})
|
||||
// Always release blocked runners, including on assertion failures.
|
||||
defer func() {
|
||||
select {
|
||||
case <-release[i]:
|
||||
default:
|
||||
close(release[i])
|
||||
}
|
||||
<-done[i]
|
||||
}()
|
||||
started := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done[i])
|
||||
fireJob(stores[i], func() time.Time { return now.Add(time.Duration(i) * time.Minute) }, job, io.Discard, io.Discard,
|
||||
func(_ []string, _, _ io.Writer) int {
|
||||
close(started)
|
||||
<-release[i]
|
||||
return i // Include a failed execution in the accounting.
|
||||
})
|
||||
}()
|
||||
select {
|
||||
case <-started:
|
||||
case <-done[i]:
|
||||
t.Fatal("runner did not claim its slot")
|
||||
}
|
||||
}
|
||||
order := []int{1, 0}
|
||||
if olderFirst {
|
||||
order = []int{0, 1}
|
||||
}
|
||||
for n, i := range order {
|
||||
close(release[i])
|
||||
<-done[i]
|
||||
current, err := stores[0].Get(job.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if current.FireCount != 8+n || !current.NextRunAt.Equal(now.Add(2*time.Minute)) {
|
||||
t.Fatalf("after completion %d: FireCount=%d NextRunAt=%s; want count %d and next %s", n+1, current.FireCount, current.NextRunAt, 8+n, now.Add(2*time.Minute))
|
||||
}
|
||||
claimed, err := claimFire(stores[1], now.Add(time.Minute), job.ID)
|
||||
if err != nil || claimed {
|
||||
t.Fatalf("already claimed slot became available: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
}
|
||||
runs, err := stores[0].Runs(job.ID, 0)
|
||||
if err != nil || len(runs) != 2 {
|
||||
t.Fatalf("runs=%v err=%v", runs, err)
|
||||
}
|
||||
for n, i := range order {
|
||||
if !runs[n].At.Equal(now.Add(time.Duration(i)*time.Minute)) || runs[n].ExitCode != i {
|
||||
t.Fatalf("unexpected run record: %+v", runs[n])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFireJobCompletionReadErrorDoesNotOverwriteState(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store := cron.NewStore(cron.StoreOptions{RootDir: root})
|
||||
now := time.Date(2026, 6, 9, 9, 0, 0, 0, time.UTC)
|
||||
job, err := store.Add(cron.Job{Expr: "* * * * *", Prompt: "fire me", NextRunAt: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metadata := filepath.Join(root, job.ID, "metadata.json")
|
||||
var stdout, stderr bytes.Buffer
|
||||
fireJob(store, func() time.Time { return now }, job, &stdout, &stderr,
|
||||
func(_ []string, _, _ io.Writer) int {
|
||||
// A corrupt read must not cause completion to replace unknown newer state
|
||||
// with the stale pre-execution job. This works without permission tricks.
|
||||
if err := os.WriteFile(metadata, []byte("unreadable job"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return 0
|
||||
})
|
||||
data, err := os.ReadFile(metadata)
|
||||
if err != nil || string(data) != "unreadable job" {
|
||||
t.Fatalf("completion overwrote unreadable state: %q, err=%v", data, err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "failed to persist job state") {
|
||||
t.Fatalf("missing persistence warning: %s", stderr.String())
|
||||
}
|
||||
if want := "fired " + job.ID + " -> exit 0 (next: unknown)\n"; stdout.String() != want {
|
||||
t.Fatalf("completion summary = %q; want %q", stdout.String(), want)
|
||||
}
|
||||
runs, err := store.Runs(job.ID, 0)
|
||||
if err != nil || len(runs) != 1 || runs[0].ExitCode != 0 {
|
||||
t.Fatalf("execution should still be recorded: runs=%v err=%v", runs, err)
|
||||
}
|
||||
}
|
||||
@@ -129,17 +129,15 @@ func reconcileOverdue(store *cron.Store, now func() time.Time, ids []string, std
|
||||
}
|
||||
}
|
||||
|
||||
// fireJob runs one job via the exec runner, records the outcome, advances the
|
||||
// schedule, and persists. The foreground loop is single-goroutine, so the
|
||||
// previous fire has already returned before the next tick — no overlap.
|
||||
// fireJob claims one job, runs it via the exec runner, and records the outcome.
|
||||
// Separate scheduler processes can have adjacent slots executing concurrently.
|
||||
func fireJob(store *cron.Store, now func() time.Time, job cron.Job, stdout io.Writer, stderr io.Writer, exec execRunner) {
|
||||
fired := now()
|
||||
// Atomically claim this fire before running it so concurrent schedulers (or
|
||||
// --once overlapping the forever loop) can't both execute the same due job: the
|
||||
// winner advances NextRunAt under the per-job lock, and a loser sees the
|
||||
// advanced time and skips. The post-exec advance recomputes the same
|
||||
// NextRunAt (sched.Next(fired) is deterministic), so the claim serializes the
|
||||
// fire decision without changing any persisted schedule state (M9).
|
||||
// advanced time and skips. Only the claim writes schedule state; completion
|
||||
// must not overwrite a later slot's claim with this run's stale job copy.
|
||||
claimed, claimErr := claimFire(store, fired, job.ID)
|
||||
if claimErr != nil {
|
||||
if errors.Is(claimErr, cron.ErrJobNotFound) {
|
||||
@@ -178,44 +176,25 @@ func fireJob(store *cron.Store, now func() time.Time, job cron.Job, stdout io.Wr
|
||||
rec.Error = cronTruncate(detail, 500)
|
||||
}
|
||||
|
||||
job.FireCount++
|
||||
// Advance the schedule. If the expression can no longer produce a future run
|
||||
// (became invalid, or is an impossible spec whose Next is zero), pause the job
|
||||
// so it cannot re-fire on every tick.
|
||||
// Explain schedules that claimFire paused because they cannot advance.
|
||||
if sched, perr := cron.Parse(job.Expr); perr != nil {
|
||||
job.Status = cron.StatusPaused
|
||||
if rec.Error == "" {
|
||||
rec.Error = "invalid schedule; job paused: " + perr.Error()
|
||||
}
|
||||
} else if nxt := sched.Next(fired.Truncate(time.Minute)); nxt.IsZero() {
|
||||
job.Status = cron.StatusPaused
|
||||
if rec.Error == "" {
|
||||
rec.Error = "schedule no longer fires; job paused"
|
||||
}
|
||||
} else {
|
||||
// Minute-aligned input keeps this advance identical to claimFire's and lets
|
||||
// the DST fall-back collapse guard engage (AUDIT-M4).
|
||||
job.NextRunAt = nxt
|
||||
}
|
||||
// Re-read and persist the advanced state ATOMICALLY under the per-job lock. The
|
||||
// job may have been paused or removed while it executed, and this in-memory copy
|
||||
// is stale from tick start. Mutate closes the read-modify-write window a single
|
||||
// scheduler alone could only narrow (Store.Mutate took a cross-process lock): a
|
||||
// concurrent scheduler or an external pause/remove landing here can no longer be
|
||||
// clobbered.
|
||||
// Count this completion against current state under the cross-process lock.
|
||||
// The claim already advanced or paused the schedule. Leave that state alone:
|
||||
// another scheduler may have claimed a later slot or a user may have edited it.
|
||||
persisted, err := store.Mutate(job.ID, func(current cron.Job, readErr error) (cron.Job, error) {
|
||||
if readErr != nil {
|
||||
// A transient read failure (IO/permission) is NOT removal — persist the
|
||||
// computed next state anyway so the schedule advances and the job does not
|
||||
// re-fire next tick.
|
||||
fmt.Fprintf(stderr, "warning: could not re-read job %s before persist: %v\n", job.ID, readErr)
|
||||
return job, nil
|
||||
}
|
||||
current.FireCount = job.FireCount
|
||||
current.NextRunAt = job.NextRunAt
|
||||
if current.Status != cron.StatusPaused {
|
||||
current.Status = job.Status
|
||||
// Never replace unknown newer state with the pre-execution snapshot.
|
||||
return current, readErr
|
||||
}
|
||||
current.FireCount++
|
||||
return current, nil
|
||||
})
|
||||
if errors.Is(err, cron.ErrJobNotFound) {
|
||||
@@ -229,19 +208,21 @@ func fireJob(store *cron.Store, now func() time.Time, job cron.Job, stdout io.Wr
|
||||
if aerr := store.AppendRun(job.ID, rec); aerr != nil {
|
||||
fmt.Fprintf(stderr, "warning: failed to record run for %s: %v\n", job.ID, aerr)
|
||||
}
|
||||
next := "unknown"
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "warning: failed to persist job state for %s: %v\n", job.ID, err)
|
||||
persisted = job
|
||||
} else {
|
||||
next = formatCronTime(persisted.NextRunAt)
|
||||
}
|
||||
fmt.Fprintf(stdout, "fired %s -> exit %d (next: %s)\n", job.ID, code, formatCronTime(persisted.NextRunAt))
|
||||
fmt.Fprintf(stdout, "fired %s -> exit %d (next: %s)\n", job.ID, code, next)
|
||||
}
|
||||
|
||||
// claimFire atomically claims a due job's fire under the per-job lock and reports
|
||||
// whether THIS caller should run it. It returns false only when the job was paused
|
||||
// externally or another scheduler already advanced NextRunAt past `fired` (so a
|
||||
// valid recurring job never double-fires). A valid due job has its NextRunAt
|
||||
// advanced here to claim the slot; an invalid or exhausted schedule is left for the
|
||||
// caller to fire-once and pause (preserving existing single-scheduler behavior).
|
||||
// advanced here to claim the slot; an invalid or exhausted schedule is paused
|
||||
// here and the caller still fires once (preserving single-scheduler behavior).
|
||||
func claimFire(store *cron.Store, fired time.Time, id string) (bool, error) {
|
||||
claimed := true
|
||||
_, err := store.Mutate(id, func(current cron.Job, readErr error) (cron.Job, error) {
|
||||
@@ -268,8 +249,8 @@ func claimFire(store *cron.Store, fired time.Time, id string) (bool, error) {
|
||||
}
|
||||
// Unparseable or unadvanceable schedule (an impossible spec whose Next is
|
||||
// zero): pause the job inside this same locked claim so a concurrent scheduler
|
||||
// sees a non-active job and does NOT also fire it. The winner still fires once
|
||||
// and fireJob's post-exec keeps it paused. Without this, both callers leave
|
||||
// sees a non-active job and does NOT also fire it. The winner still fires once.
|
||||
// Without this, both callers leave
|
||||
// NextRunAt unchanged, both see the job due, and both fire the same slot
|
||||
// (AUDIT-M5).
|
||||
current.Status = cron.StatusPaused
|
||||
|
||||
@@ -53,7 +53,7 @@ func TestCronRunOnceFiresDueJobs(t *testing.T) {
|
||||
if d.FireCount != 1 || !d.NextRunAt.After(now) {
|
||||
t.Fatalf("due job not advanced: %+v", d)
|
||||
}
|
||||
runs, _ := store.Runs(due.ID)
|
||||
runs, _ := store.Runs(due.ID, 0)
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("expected 1 run record, got %d", len(runs))
|
||||
}
|
||||
@@ -146,7 +146,7 @@ func TestCronRunPausesUnadvanceableJob(t *testing.T) {
|
||||
if d.Status != cron.StatusPaused {
|
||||
t.Fatalf("unadvanceable job must be paused, got status=%q", d.Status)
|
||||
}
|
||||
runs, _ := store.Runs(job.ID)
|
||||
runs, _ := store.Runs(job.ID, 0)
|
||||
if len(runs) != 1 || runs[0].Error == "" {
|
||||
t.Fatalf("expected one run record with an error, got %+v", runs)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -147,11 +146,7 @@ func runDaemonStartDetached(paths daemon.Paths, stdout io.Writer, stderr io.Writ
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(paths.Socket), 0o700); err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
logPath := filepath.Join(filepath.Dir(paths.Socket), "daemon.log")
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
logFile, logPath, err := daemon.OpenRuntimeLog(paths)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
|
||||
@@ -49,6 +49,14 @@ const (
|
||||
exitInterrupted = 130
|
||||
)
|
||||
|
||||
// defaultExecMaxTurns is the headless tool-turn budget when no source sets one.
|
||||
// An interactive run can be continued past the shared interactive default by the
|
||||
// user; an exec run has nobody to unstick it, so evals and automation would
|
||||
// truncate mid-task at 80 turns. The ceiling is the already-justified bound —
|
||||
// still finite against a genuinely runaway loop, which the loop's empty-turn
|
||||
// guard also cuts independently of this budget.
|
||||
const defaultExecMaxTurns = config.MaxTurnsCeiling
|
||||
|
||||
type execOutputFormat string
|
||||
type execInputFormat string
|
||||
|
||||
@@ -173,7 +181,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
// Refresh the models.dev pricing/limits cache in the background when stale;
|
||||
// the overlay is read at registry construction from the cache file, so this
|
||||
// benefits the next run and never blocks or fails this one.
|
||||
go func() { _ = modelregistry.RefreshModelsDevCache(context.Background()) }()
|
||||
modelregistry.StartModelsDevRefresh()
|
||||
|
||||
// A mode seeds model/effort/max-turns/tool filters as a preset. Expand it up
|
||||
// front — before tool-filter validation and the --list-tools branch — so a
|
||||
@@ -320,6 +328,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
}
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
|
||||
}
|
||||
resolved.MaxTurns = execTurnBudget(resolved)
|
||||
var displacedMaxTurns int
|
||||
resolved.MaxTurns, displacedMaxTurns = applyProfileTurnBudget(execProfile, options.maxTurns, resolved.MaxTurns)
|
||||
execScope, err := sandbox.NewScope(workspaceRoot, append(append([]string{}, resolved.Sandbox.AdditionalWriteRoots...), options.addDirs...))
|
||||
@@ -338,6 +347,17 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
} else if notice != "" {
|
||||
_, _ = fmt.Fprintln(stderr, "[zero] "+notice)
|
||||
}
|
||||
// Before any tool runs, and before the MCP servers and plugins below start
|
||||
// through the same sandbox, so a run that goes ahead with reduced isolation
|
||||
// says so where its output starts, not only when someone runs `zero doctor`.
|
||||
// A notice that cannot be written stops the run, as the image and effort
|
||||
// notices further down do: going ahead would mean reduced isolation and no
|
||||
// warning delivered.
|
||||
if notice := sandboxEngine.DegradedNotice(); notice != "" {
|
||||
if _, err := fmt.Fprintln(stderr, "[zero] "+notice); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
executionRunner.SetPreparer(sandboxEngine)
|
||||
if permissionMode != agent.PermissionModePlan {
|
||||
mcpRuntime, mcpSkip, err = registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options), trustRoot, executionRunner)
|
||||
@@ -443,20 +463,20 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
// escalation so post-switch turns are attributed to the escalated model.
|
||||
currentModel := resolved.Provider.Model
|
||||
// Optimized OpenAI turn sessions (ZERO_OPENAI_TURN_SESSION, default off). nil
|
||||
// when gated off or the profile is ineligible: agent.Run then wraps the
|
||||
// provider in its default adapter. This is the run's STARTING session provider
|
||||
// and is used whether or not escalation is enabled.
|
||||
turnSessions, _ := providers.OptimizedTurnSessions(resolved.Provider, provider, providers.Options{})
|
||||
// when gated off or ineligible, unless a configured RPM limiter needs to
|
||||
// wrap the default adapter. The limiter is shared across model switches.
|
||||
sessionOptions := providers.Options{ModelRPM: zeroruntime.NewModelRPMLimiter(resolved.ModelRPM)}
|
||||
turnSessions, _ := providers.ConfiguredTurnSessions(resolved.Provider, provider, sessionOptions)
|
||||
// Both switchers come from one shared builder so exec and the interactive TUI
|
||||
// cannot drift on the nil contracts the agent loop depends on. The session
|
||||
// switcher is nil unless this run STARTED optimized, which is what keeps a
|
||||
// default-adapter run on the default adapter.
|
||||
// switcher preserves the starting transport and shares the RPM limiter.
|
||||
var modelSwitcher func(context.Context, string) (agent.Provider, error)
|
||||
var modelSessionSwitcher func(context.Context, string) (zeroruntime.TurnSessionProvider, error)
|
||||
if options.allowEscalation {
|
||||
modelSwitcher, modelSessionSwitcher = providers.EscalationSwitchers(
|
||||
resolved.Provider, provider, deps.newProvider,
|
||||
func(modelID string) { currentModel = modelID },
|
||||
sessionOptions,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -651,6 +671,9 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
ContextWindowFor: func(modelID string) int {
|
||||
return modelregistry.AgentContextWindow(modelContextWindow(modelRegistry, modelID))
|
||||
},
|
||||
SupportsVision: func(modelID string) bool {
|
||||
return modelregistry.SupportsVision(modelRegistry, modelID)
|
||||
},
|
||||
ReasoningEffort: forwardEffort,
|
||||
Trace: traceRecorder,
|
||||
Cwd: workspaceRoot,
|
||||
@@ -1222,6 +1245,20 @@ func specProfileEffortFilled(effortFilled bool, specReasoningEffort string) bool
|
||||
return effortFilled && strings.TrimSpace(specReasoningEffort) == ""
|
||||
}
|
||||
|
||||
// execTurnBudget applies the headless default to the resolved turn budget: an
|
||||
// interactive run can be continued past the shared default by the user, but an
|
||||
// exec run has nobody to unstick it, so an unconfigured budget starts at the
|
||||
// documented ceiling instead of truncating mid-task. Explicit sources
|
||||
// (--max-turns, mode presets, ZERO_MAX_TURNS, config files) resolve as
|
||||
// MaxTurnsSet and pass through; an exec-profile budget still wins via
|
||||
// applyProfileTurnBudget afterward.
|
||||
func execTurnBudget(resolved config.ResolvedConfig) int {
|
||||
if resolved.MaxTurnsSet {
|
||||
return resolved.MaxTurns
|
||||
}
|
||||
return defaultExecMaxTurns
|
||||
}
|
||||
|
||||
// applyProfileTurnBudget decides the run's turn budget once config is resolved.
|
||||
// The profile displaces the RESOLVED value, not the flag: a pinned budget
|
||||
// always wins and the profile backs off entirely, while an env/config budget
|
||||
|
||||
@@ -70,8 +70,8 @@ func TestRunExecListsFilteredToolsWithoutPromptOrProvider(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
if stderr.String() != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String())
|
||||
}
|
||||
if providerBuilt {
|
||||
t.Fatal("provider should not be constructed for --list-tools")
|
||||
@@ -108,8 +108,8 @@ func TestRunExecListsToolsAsStreamJSONWhenRequested(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
if stderr.String() != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String())
|
||||
}
|
||||
if providerBuilt {
|
||||
t.Fatal("provider should not be constructed for --list-tools")
|
||||
@@ -176,8 +176,8 @@ func TestRunExecListsMCPToolsWithoutProviderConstruction(t *testing.T) {
|
||||
if !closed {
|
||||
t.Fatal("MCP runtime was not closed after --list-tools")
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
if stderr.String() != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "mcp_docs_lookup") || !strings.Contains(stdout.String(), "Lookup documentation") {
|
||||
t.Fatalf("expected MCP tool in list output, got %q", stdout.String())
|
||||
@@ -323,8 +323,8 @@ func TestRunExecStreamJSONOutputsRunEndAndRecordsSession(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
if stderr.String() != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String())
|
||||
}
|
||||
|
||||
events := decodeJSONLines(t, stdout.String())
|
||||
@@ -388,8 +388,8 @@ func TestRunExecStreamJSONEmitsAndRecordsPermissionEvents(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
if stderr.String() != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String())
|
||||
}
|
||||
|
||||
events := decodeJSONLines(t, stdout.String())
|
||||
@@ -460,7 +460,7 @@ func TestRunExecStreamJSONEmitsAndRecordsPermissionEvents(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("approved exec exit = %d, stderr = %q", exitCode, stderr.String())
|
||||
}
|
||||
if stderr.String() != "" {
|
||||
if stderr.String() != hostSandboxNotice(t) {
|
||||
t.Fatalf("approved exec wrote stderr = %q", stderr.String())
|
||||
}
|
||||
approvedEvents := decodeJSONLines(t, stdout.String())
|
||||
|
||||
@@ -118,12 +118,15 @@ func runExecSpecDraft(run execSpecDraftRun) int {
|
||||
hookDispatcher, hookSkip := newHookDispatcher(run.workspaceRoot, run.trustRoot, execution.NewRunner(run.sandboxEngine))
|
||||
emitTrustNotice(run.stderr, hookSkip, run.mcpSkip)
|
||||
result, err := agent.Run(runCtx, run.prompt, run.provider, agent.Options{
|
||||
MaxTurns: run.resolved.MaxTurns,
|
||||
ContextWindow: resolveAgentContextWindow(runCtx, run.modelRegistry, run.resolved.Provider),
|
||||
SessionID: draftSession.SessionID,
|
||||
SessionTitle: run.sessionTitle,
|
||||
ProviderName: run.resolved.Provider.Name,
|
||||
Model: run.resolved.Provider.Model,
|
||||
MaxTurns: run.resolved.MaxTurns,
|
||||
ContextWindow: resolveAgentContextWindow(runCtx, run.modelRegistry, run.resolved.Provider),
|
||||
SessionID: draftSession.SessionID,
|
||||
SessionTitle: run.sessionTitle,
|
||||
ProviderName: run.resolved.Provider.Name,
|
||||
Model: run.resolved.Provider.Model,
|
||||
SupportsVision: func(modelID string) bool {
|
||||
return modelregistry.SupportsVision(run.modelRegistry, modelID)
|
||||
},
|
||||
ReasoningEffort: run.reasoningEffort,
|
||||
Profile: run.profilePolicy,
|
||||
Cwd: run.workspaceRoot,
|
||||
|
||||
@@ -568,8 +568,8 @@ func TestRunExecAcceptsLegacyModelProfileFlags(t *testing.T) {
|
||||
if !strings.Contains(stdout, "hello") {
|
||||
t.Fatalf("expected prompt output, got %q", stdout)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr)
|
||||
if stderr != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -696,8 +696,8 @@ func TestRunExecAssemblesInlineAndFilePromptRelativeToCwd(t *testing.T) {
|
||||
if !strings.Contains(stdout, "inline prompt\n\nfile prompt") {
|
||||
t.Fatalf("expected inline and file prompt joined by blank line, got %q", stdout)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr)
|
||||
if stderr != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -715,8 +715,8 @@ func TestRunExecAcceptsFileOnlyPrompt(t *testing.T) {
|
||||
if !strings.Contains(stdout, "file only prompt") {
|
||||
t.Fatalf("expected file prompt output, got %q", stdout)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr)
|
||||
if stderr != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -798,8 +798,8 @@ func TestRunExecJSONOutputsNDJSONEvents(t *testing.T) {
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr)
|
||||
if stderr != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr)
|
||||
}
|
||||
|
||||
events := decodeJSONLines(t, stdout)
|
||||
@@ -833,8 +833,8 @@ func TestRunExecResolvesCanonicalModelAlias(t *testing.T) {
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr for active model, got %q", stderr)
|
||||
if stderr != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice for active model, got %q", stderr)
|
||||
}
|
||||
events := decodeJSONLines(t, stdout)
|
||||
if got := events[0]["model"]; got != "gpt-4.1" {
|
||||
@@ -926,8 +926,8 @@ func TestRunExecJSONUnsafeOutputsWarningEvent(t *testing.T) {
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr)
|
||||
if stderr != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr)
|
||||
}
|
||||
|
||||
events := decodeJSONLines(t, stdout)
|
||||
@@ -970,10 +970,10 @@ func TestRunExecUsesProjectConfigAndOpenAICompatibleProvider(t *testing.T) {
|
||||
"name": "local",
|
||||
"provider_kind": "openai-compatible",
|
||||
"base_url": "` + server.URL + `",
|
||||
"api_key": "sk-local",
|
||||
"model": "local-model"
|
||||
}]
|
||||
}`
|
||||
"api_key": "sk-local",
|
||||
"model": "local-model"
|
||||
}]
|
||||
}`
|
||||
if err := os.WriteFile(filepath.Join(configDir, "config.json"), []byte(writeConfig), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -988,8 +988,8 @@ func TestRunExecUsesProjectConfigAndOpenAICompatibleProvider(t *testing.T) {
|
||||
if strings.TrimSpace(stdout.String()) != "provider ok" {
|
||||
t.Fatalf("stdout = %q, want provider response", stdout.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
if stderr.String() != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr.String())
|
||||
}
|
||||
if gotAuth != "Bearer sk-local" {
|
||||
t.Fatalf("Authorization = %q, want project config token", gotAuth)
|
||||
@@ -1204,8 +1204,8 @@ func TestRunExecAutoHighEmitsUnsafeWarning(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr)
|
||||
if stderr != hostSandboxNotice(t) {
|
||||
t.Fatalf("expected no stderr beyond this host's sandbox notice, got %q", stderr)
|
||||
}
|
||||
|
||||
events := decodeJSONLines(t, stdout)
|
||||
@@ -1975,3 +1975,23 @@ func TestRunExecTopTierDeclineNoSwitch(t *testing.T) {
|
||||
t.Fatalf("provider model = %q, want claude-opus-4.1", providerModels[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecTurnBudgetDefaultsToCeilingUnlessSet(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
resolved config.ResolvedConfig
|
||||
want int
|
||||
}{
|
||||
// A headless run has no user to continue past the shared interactive
|
||||
// default, so an unconfigured budget starts at the ceiling.
|
||||
{"unconfigured default", config.ResolvedConfig{MaxTurns: 80, MaxTurnsSet: false}, config.MaxTurnsCeiling},
|
||||
// A user who configured 80 on purpose (e.g. cost control in CI) keeps 80.
|
||||
{"explicit default value", config.ResolvedConfig{MaxTurns: 80, MaxTurnsSet: true}, 80},
|
||||
{"configured value passes through", config.ResolvedConfig{MaxTurns: 42, MaxTurnsSet: true}, 42},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := execTurnBudget(tc.resolved); got != tc.want {
|
||||
t.Fatalf("%s: execTurnBudget = %d, want %d", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func TestRunExecListToolsHonorsDisabledLocalControlConfig(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
if stderr.String() != hostSandboxNotice(t) {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/tui"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type rpmExecProvider struct{ calls int }
|
||||
|
||||
func (p *rpmExecProvider) StreamCompletion(context.Context, zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
p.calls++
|
||||
ch := make(chan zeroruntime.StreamEvent, 4)
|
||||
if p.calls == 1 {
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "fixture", ToolName: "read_file"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "fixture", ArgumentsFragment: `{"path":"missing-fixture.txt"}`}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "fixture"}
|
||||
} else {
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "unexpected second completion"}
|
||||
}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
func rpmTestDeps(t *testing.T, p *rpmExecProvider) appDeps {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for _, k := range []string{"HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME"} {
|
||||
t.Setenv(k, root)
|
||||
}
|
||||
return appDeps{getwd: func() (string, error) { return root, nil }, resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
c := execResolvedConfig()
|
||||
c.ModelRPM = map[string]int{c.Provider.Model: 1}
|
||||
return c, nil
|
||||
}, newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { return p, nil }, registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
}}
|
||||
}
|
||||
func TestRunExecRPMRefusesSecondCompletion(t *testing.T) {
|
||||
p := &rpmExecProvider{}
|
||||
deps := rpmTestDeps(t, p)
|
||||
var out, err bytes.Buffer
|
||||
code := runWithDeps([]string{"exec", "fixture"}, &out, &err, deps)
|
||||
if code != exitProvider || p.calls != 1 || !strings.Contains(err.String(), "Local modelRPM cap reached") {
|
||||
t.Fatalf("exit=%d calls=%d stdout=%s stderr=%s", code, p.calls, out.String(), err.String())
|
||||
}
|
||||
}
|
||||
func TestInteractiveRPMFactorySharesWindow(t *testing.T) {
|
||||
p := &rpmExecProvider{}
|
||||
deps := rpmTestDeps(t, p)
|
||||
launched := false
|
||||
deps.runTUI = func(ctx context.Context, o tui.Options) int {
|
||||
launched = true
|
||||
if o.ModelRPM == nil {
|
||||
t.Fatal("TUI escalation lacks limiter")
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
ss := o.NewTurnSessionProvider(o.ProviderProfile, p)
|
||||
s, e := ss.OpenTurnSession(ctx)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
_, e = s.Stream(ctx, zeroruntime.CompletionRequest{})
|
||||
var hit *zeroruntime.RPMLimitError
|
||||
if (i == 0 && e != nil) || (i == 1 && !errors.As(e, &hit)) {
|
||||
t.Fatalf("run=%d err=%v", i, e)
|
||||
}
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
var out, err bytes.Buffer
|
||||
code := runWithDeps(nil, &out, &err, deps)
|
||||
if !launched || code != exitSuccess || p.calls != 1 {
|
||||
t.Fatalf("launched=%v exit=%d calls=%d stderr=%s", launched, code, p.calls, err.String())
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
|
||||
{id: "groq", name: "Groq", transport: "openai-compatible", defaultModel: "llama-3.3-70b-versatile"},
|
||||
{id: "longcat", name: "LongCat", transport: "openai-compatible", defaultModel: "LongCat-2.0"},
|
||||
{id: "fireworks", name: "Fireworks AI", transport: "openai-compatible", defaultModel: "accounts/fireworks/models/kimi-k2p7-code"},
|
||||
{id: "gmicloud", name: "GMI Cloud", transport: "openai-compatible", defaultModel: "Qwen/Qwen3.8-Max"},
|
||||
{id: "ollama-cloud", name: "Ollama Cloud", transport: "openai-compatible", defaultModel: "qwen3-coder:480b"},
|
||||
{id: "ollama", name: "Ollama Local", transport: "openai-compatible", defaultModel: "llama3.1"},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/tui"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// hostSandboxNotice is the degraded-sandbox line exec prints on THIS machine for
|
||||
// a run under the default sandbox policy, or "" where the sandbox is not
|
||||
// degraded. Exec tests that expect a quiet stderr compare against it rather than
|
||||
// against "": unless a test pins a backend, the run gets the host's own, and a
|
||||
// Linux runner without the sandbox helper is degraded where macOS and Windows
|
||||
// are not.
|
||||
func hostSandboxNotice(t *testing.T) string {
|
||||
t.Helper()
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: t.TempDir(),
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Backend: sandbox.SelectBackend(sandbox.BackendOptions{}),
|
||||
})
|
||||
notice := engine.DegradedNotice()
|
||||
if notice == "" {
|
||||
return ""
|
||||
}
|
||||
return "[zero] " + notice + "\n"
|
||||
}
|
||||
|
||||
// clearSandboxNestingMarkers keeps a test that pins the notice from inheriting
|
||||
// the nesting markers when the suite itself runs inside a Zero sandbox.
|
||||
func clearSandboxNestingMarkers(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv(sandbox.EnvSandboxed, "")
|
||||
t.Setenv(sandbox.EnvSandboxBackend, "")
|
||||
}
|
||||
|
||||
// unavailableTestSandbox is the backend a Linux box without the helper gets,
|
||||
// pinned so a test sees the degraded case on every platform.
|
||||
func unavailableTestSandbox(sandbox.BackendOptions) sandbox.Backend {
|
||||
return sandbox.Backend{Name: sandbox.BackendUnavailable, Platform: "linux", Fallback: true, Message: "Linux sandbox helper is not available"}
|
||||
}
|
||||
|
||||
const unavailableTestSandboxNotice = "[zero] Sandbox enforcement is degraded: Linux sandbox helper is not available. See `zero sandbox policy --effective`.\n"
|
||||
|
||||
func runExecWithSandbox(t *testing.T, args []string, backend func(sandbox.BackendOptions) sandbox.Backend, sandboxConfig config.SandboxConfig) (int, string, string) {
|
||||
t.Helper()
|
||||
isolateCLIUserState(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{
|
||||
ActiveProvider: "echo",
|
||||
Provider: config.ProviderProfile{
|
||||
Name: "echo",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "http://127.0.0.1/v1",
|
||||
Model: "echo-model",
|
||||
},
|
||||
MaxTurns: 3,
|
||||
Sandbox: sandboxConfig,
|
||||
}, nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return echoExecProvider{}, nil
|
||||
},
|
||||
selectSandboxBackend: backend,
|
||||
})
|
||||
return exitCode, stdout.String(), stderr.String()
|
||||
}
|
||||
|
||||
// A RUN THAT GOES AHEAD WITH REDUCED ISOLATION SAYS SO. Before this only `zero
|
||||
// doctor` and `zero sandbox policy` did, so `zero exec` ran every tool with the
|
||||
// native sandbox effectively off and printed nothing about it. #1041.
|
||||
func TestExecSaysOnStderrWhenTheSandboxIsDegraded(t *testing.T) {
|
||||
clearSandboxNestingMarkers(t)
|
||||
exitCode, _, stderr := runExecWithSandbox(t, []string{"exec", "hello"}, unavailableTestSandbox, config.SandboxConfig{})
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exit code = %d, stderr %q", exitCode, stderr)
|
||||
}
|
||||
if stderr != unavailableTestSandboxNotice {
|
||||
t.Fatalf("stderr = %q, want exactly the degraded notice %q", stderr, unavailableTestSandboxNotice)
|
||||
}
|
||||
}
|
||||
|
||||
// The notice goes to stderr in the JSON modes as well, so stdout stays one JSON
|
||||
// object per line for whatever reads it.
|
||||
func TestExecKeepsTheDegradedNoticeOffStdoutInJSONMode(t *testing.T) {
|
||||
clearSandboxNestingMarkers(t)
|
||||
exitCode, stdout, stderr := runExecWithSandbox(t, []string{"exec", "--output-format", "json", "hello"}, unavailableTestSandbox, config.SandboxConfig{})
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exit code = %d, stderr %q", exitCode, stderr)
|
||||
}
|
||||
if stderr != unavailableTestSandboxNotice {
|
||||
t.Fatalf("stderr = %q, want exactly the degraded notice", stderr)
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSpace(stdout), "\n") {
|
||||
var event map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
t.Fatalf("stdout carries a line that is not JSON: %q (%v)", line, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A sandbox the user turned off is their choice, not a degradation.
|
||||
func TestExecSaysNothingWhenTheSandboxIsTurnedOff(t *testing.T) {
|
||||
clearSandboxNestingMarkers(t)
|
||||
off := false
|
||||
exitCode, _, stderr := runExecWithSandbox(t, []string{"exec", "hello"}, unavailableTestSandbox, config.SandboxConfig{Enabled: &off})
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exit code = %d, stderr %q", exitCode, stderr)
|
||||
}
|
||||
if stderr != "" {
|
||||
t.Fatalf("stderr = %q, want nothing for a disabled sandbox", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// countingExecProvider is echoExecProvider, counting the completions it streams.
|
||||
type countingExecProvider struct {
|
||||
echoExecProvider
|
||||
streamed *int
|
||||
}
|
||||
|
||||
func (provider countingExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
*provider.streamed++
|
||||
return provider.echoExecProvider.StreamCompletion(ctx, request)
|
||||
}
|
||||
|
||||
// A degraded notice that cannot be written stops the run before the model is
|
||||
// asked for anything, as the image and effort notices do, instead of going ahead
|
||||
// with reduced isolation and no warning delivered.
|
||||
func TestExecStopsWhenTheDegradedNoticeCannotBeWritten(t *testing.T) {
|
||||
clearSandboxNestingMarkers(t)
|
||||
isolateCLIUserState(t)
|
||||
var stdout bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
streamed := 0
|
||||
exitCode := runWithDeps([]string{"exec", "hello"}, &stdout, failingWriter{}, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{
|
||||
ActiveProvider: "echo",
|
||||
Provider: config.ProviderProfile{
|
||||
Name: "echo",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "http://127.0.0.1/v1",
|
||||
Model: "echo-model",
|
||||
},
|
||||
MaxTurns: 3,
|
||||
}, nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return countingExecProvider{streamed: &streamed}, nil
|
||||
},
|
||||
selectSandboxBackend: unavailableTestSandbox,
|
||||
})
|
||||
if exitCode != exitCrash {
|
||||
t.Fatalf("exit code = %d, want %d when the degraded notice cannot be written", exitCode, exitCrash)
|
||||
}
|
||||
if streamed != 0 {
|
||||
t.Fatalf("the model was asked %d times: the run went ahead without its warning", streamed)
|
||||
}
|
||||
}
|
||||
|
||||
// launchTUIWithSandbox runs the root command the way a bare `zero` does, with
|
||||
// the sandbox backend pinned to the unavailable one, and returns the options the
|
||||
// TUI would have started with.
|
||||
func launchTUIWithSandbox(t *testing.T, sandboxConfig config.SandboxConfig) tui.Options {
|
||||
t.Helper()
|
||||
isolateCLIUserState(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
var launched tui.Options
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{MaxTurns: 12, Sandbox: sandboxConfig}, nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
t.Fatal("newProvider should not be called without a resolved provider")
|
||||
return nil, nil
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
selectSandboxBackend: unavailableTestSandbox,
|
||||
runTUI: func(_ context.Context, options tui.Options) int {
|
||||
launched = options
|
||||
return 0
|
||||
},
|
||||
})
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exit code = %d, stderr %q", exitCode, stderr.String())
|
||||
}
|
||||
return launched
|
||||
}
|
||||
|
||||
// The TUI shows the same notice when the session opens, taken from the engine
|
||||
// its commands run through.
|
||||
func TestTUILaunchCarriesTheDegradedNotice(t *testing.T) {
|
||||
clearSandboxNestingMarkers(t)
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
sandbox config.SandboxConfig
|
||||
want bool
|
||||
}{
|
||||
{name: "degraded", want: true},
|
||||
{name: "turned off", sandbox: config.SandboxConfig{Enabled: func() *bool { off := false; return &off }()}},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
launched := launchTUIWithSandbox(t, testCase.sandbox)
|
||||
var notices []string
|
||||
for _, notice := range launched.StartupNotices {
|
||||
if strings.TrimSpace(notice) != "" {
|
||||
notices = append(notices, notice)
|
||||
}
|
||||
}
|
||||
want := strings.TrimSuffix(strings.TrimPrefix(unavailableTestSandboxNotice, "[zero] "), "\n")
|
||||
if testCase.want && (len(notices) != 1 || notices[0] != want) {
|
||||
t.Fatalf("StartupNotices = %q, want exactly %q", notices, want)
|
||||
}
|
||||
if !testCase.want && len(notices) != 0 {
|
||||
t.Fatalf("StartupNotices = %q, want none", notices)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ type sessionCommandOptions struct {
|
||||
excludeTarget bool
|
||||
preserveLast int
|
||||
maxPromptChars int
|
||||
olderThan string
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
func runSessions(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
@@ -72,6 +74,11 @@ func runSessions(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
return writeExecUsageError(stderr, "sessions compact-plan requires a session id")
|
||||
}
|
||||
return runSessionsCompactPlan(store, remaining[0], options, stdout, stderr)
|
||||
case "prune":
|
||||
if len(remaining) != 0 {
|
||||
return writeExecUsageError(stderr, "sessions prune does not accept positional arguments")
|
||||
}
|
||||
return runSessionsPrune(store, options, stdout, stderr, deps)
|
||||
default:
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unknown sessions command %q", command))
|
||||
}
|
||||
@@ -91,6 +98,8 @@ func parseSessionsArgs(args []string) (string, []string, sessionCommandOptions,
|
||||
options.json = true
|
||||
case "--exclude-target":
|
||||
options.excludeTarget = true
|
||||
case "--dry-run":
|
||||
options.dryRun = true
|
||||
case "--kind":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
@@ -168,6 +177,21 @@ func parseSessionsArgs(args []string) (string, []string, sessionCommandOptions,
|
||||
}
|
||||
options.preserveLast = preserveLast
|
||||
continue
|
||||
case arg == "--older-than":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return command, remaining, options, false, err
|
||||
}
|
||||
options.olderThan = value
|
||||
index = next
|
||||
continue
|
||||
case strings.HasPrefix(arg, "--older-than="):
|
||||
value, err := parseNonEmptySessionsFlag("--older-than", strings.TrimPrefix(arg, "--older-than="))
|
||||
if err != nil {
|
||||
return command, remaining, options, false, err
|
||||
}
|
||||
options.olderThan = value
|
||||
continue
|
||||
case arg == "--max-prompt-chars":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
@@ -225,7 +249,7 @@ func parseSessionKindFlag(value string) (sessions.SessionKind, error) {
|
||||
|
||||
func isSessionsCommand(command string) bool {
|
||||
switch command {
|
||||
case "list", "children", "lineage", "tree", "rewind-plan", "rewind", "compact-plan":
|
||||
case "list", "children", "lineage", "tree", "rewind-plan", "rewind", "compact-plan", "prune":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -244,6 +268,9 @@ func validateSessionCommandFlags(command string, options sessionCommandOptions)
|
||||
if hasCompactionFlag && command != "compact-plan" {
|
||||
return execUsageError{"--preserve-last and --max-prompt-chars are only valid for sessions compact-plan"}
|
||||
}
|
||||
if (options.olderThan != "" || options.dryRun) && command != "prune" {
|
||||
return execUsageError{"--older-than and --dry-run are only valid for sessions prune"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -532,6 +559,7 @@ Commands:
|
||||
rewind-plan <id> Preview events kept and dropped by a rewind
|
||||
rewind <id> Restore workspace files and truncate the log to a checkpoint
|
||||
compact-plan <id> Preview events compacted and preserved by compaction
|
||||
prune Remove sessions last updated before a cutoff
|
||||
|
||||
Flags:
|
||||
--json Print JSON output
|
||||
@@ -541,7 +569,13 @@ Flags:
|
||||
--exclude-target Drop the target event (rewind-plan, rewind)
|
||||
--preserve-last <n> Keep recent events in compact-plan
|
||||
--max-prompt-chars <n> Limit compact-plan summary prompt
|
||||
--older-than <age> Prune cutoff, as days (30d) or a duration (720h); at least 1d.
|
||||
Without it, prune uses sessions.retentionDays from your user config.
|
||||
--dry-run List what prune would remove, and why it keeps the rest
|
||||
-h, --help Show this help
|
||||
|
||||
prune only runs when you run it. It never removes a session another Zero process
|
||||
has open, or an ancestor of a session it keeps, and --dry-run shows why.
|
||||
`)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
// maxPruneDays keeps a day count from overflowing time.Duration.
|
||||
const maxPruneDays = 36500
|
||||
|
||||
// runSessionsPrune removes sessions last updated before a cutoff, or with
|
||||
// --dry-run lists what would go and why. It runs only when asked (#971).
|
||||
func runSessionsPrune(store *sessions.Store, options sessionCommandOptions, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
olderThan, err := pruneCutoff(options, deps)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
report, err := store.Prune(sessions.PruneOptions{OlderThan: olderThan, DryRun: options.dryRun})
|
||||
if err != nil {
|
||||
return writeSessionCommandError(stderr, err)
|
||||
}
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, redaction.RedactValue(report, redaction.Options{})); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
} else if _, err := fmt.Fprint(stdout, formatPruneReport(report)); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if len(report.Failed) > 0 {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// pruneCutoff is --older-than, or else sessions.retentionDays from the user's
|
||||
// own config, and never below sessions.MinimumPruneAge. Project config has no
|
||||
// say in it: see config.SessionsConfig.
|
||||
func pruneCutoff(options sessionCommandOptions, deps appDeps) (time.Duration, error) {
|
||||
var age time.Duration
|
||||
if options.olderThan != "" {
|
||||
parsed, err := parsePruneAge(options.olderThan)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
age = parsed
|
||||
} else {
|
||||
days := 0
|
||||
if deps.userConfigPath != nil {
|
||||
path, err := deps.userConfigPath()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("sessions prune could not find your config: %w", err)
|
||||
}
|
||||
settings, err := config.ReadSessionsConfig(path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("sessions prune could not read sessions.retentionDays: %w", err)
|
||||
}
|
||||
days = settings.RetentionDays
|
||||
}
|
||||
if days == 0 {
|
||||
return 0, execUsageError{"sessions prune needs a cutoff: pass --older-than (for example --older-than 30d) or set sessions.retentionDays in your user config"}
|
||||
}
|
||||
if days > maxPruneDays {
|
||||
return 0, execUsageError{fmt.Sprintf("sessions.retentionDays %d is more than %d", days, maxPruneDays)}
|
||||
}
|
||||
age = time.Duration(days) * 24 * time.Hour
|
||||
}
|
||||
if age < sessions.MinimumPruneAge {
|
||||
return 0, execUsageError{fmt.Sprintf("sessions prune does not remove anything updated in the last %d hours; use --older-than 1d or more", int(sessions.MinimumPruneAge.Hours()))}
|
||||
}
|
||||
return age, nil
|
||||
}
|
||||
|
||||
// parsePruneAge accepts a whole number of days ("30d") or a Go duration
|
||||
// ("720h").
|
||||
func parsePruneAge(value string) (time.Duration, error) {
|
||||
invalid := execUsageError{fmt.Sprintf("invalid --older-than %q: expected days like 30d or a duration like 720h", value)}
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if days, ok := strings.CutSuffix(trimmed, "d"); ok {
|
||||
count, err := strconv.Atoi(days)
|
||||
if err != nil || count <= 0 || count > maxPruneDays {
|
||||
return 0, invalid
|
||||
}
|
||||
return time.Duration(count) * 24 * time.Hour, nil
|
||||
}
|
||||
age, err := time.ParseDuration(trimmed)
|
||||
if err != nil || age <= 0 {
|
||||
return 0, invalid
|
||||
}
|
||||
return age, nil
|
||||
}
|
||||
|
||||
func formatPruneReport(report sessions.PruneReport) string {
|
||||
var out strings.Builder
|
||||
verb := "Removed"
|
||||
if report.DryRun {
|
||||
verb = "Would remove"
|
||||
}
|
||||
if len(report.Removed) == 0 {
|
||||
fmt.Fprintf(&out, "No sessions to remove: nothing last updated before %s can go.\n", report.Cutoff)
|
||||
} else {
|
||||
var total int64
|
||||
for _, entry := range report.Removed {
|
||||
total += entry.Bytes
|
||||
}
|
||||
fmt.Fprintf(&out, "%s %d %s last updated before %s (%s):\n", verb, len(report.Removed), plural(len(report.Removed), "session", "sessions"), report.Cutoff, formatPruneBytes(total))
|
||||
for _, entry := range report.Removed {
|
||||
fmt.Fprintf(&out, " %s\n", formatPruneEntry(entry, redact(entry.Title)))
|
||||
}
|
||||
}
|
||||
if len(report.Kept) > 0 {
|
||||
fmt.Fprintf(&out, "Kept %d old enough to remove:\n", len(report.Kept))
|
||||
for _, entry := range report.Kept {
|
||||
fmt.Fprintf(&out, " %s\n", formatPruneEntry(entry, entry.Reason))
|
||||
}
|
||||
}
|
||||
if len(report.Failed) > 0 {
|
||||
failed := "Could not remove"
|
||||
if report.DryRun {
|
||||
failed = "Could not check"
|
||||
}
|
||||
fmt.Fprintf(&out, "%s %d:\n", failed, len(report.Failed))
|
||||
for _, entry := range report.Failed {
|
||||
fmt.Fprintf(&out, " %s\n", formatPruneEntry(entry, redact(entry.Reason)))
|
||||
}
|
||||
}
|
||||
if report.DryRun {
|
||||
out.WriteString("Dry run: nothing was removed.\n")
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func formatPruneEntry(entry sessions.PruneEntry, note string) string {
|
||||
updated := entry.UpdatedAt
|
||||
if len(updated) >= len("2006-01-02") {
|
||||
updated = updated[:len("2006-01-02")]
|
||||
}
|
||||
line := redact(entry.SessionID) + " " + updated
|
||||
if note = strings.TrimSpace(note); note != "" {
|
||||
line += " " + note
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func formatPruneBytes(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
value, suffix := float64(bytes)/unit, "KB"
|
||||
for _, next := range []string{"MB", "GB", "TB"} {
|
||||
if value < unit {
|
||||
break
|
||||
}
|
||||
value, suffix = value/unit, next
|
||||
}
|
||||
return fmt.Sprintf("%.1f %s", value, suffix)
|
||||
}
|
||||
|
||||
func plural(count int, one, many string) string {
|
||||
if count == 1 {
|
||||
return one
|
||||
}
|
||||
return many
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
// pruneCLIFixture is a session root holding one session last written in 2020
|
||||
// and one written now, neither held open, plus deps whose user config path is
|
||||
// the returned file (which does not exist until a test writes it).
|
||||
func pruneCLIFixture(t *testing.T) (string, string, appDeps) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for _, session := range []struct {
|
||||
id string
|
||||
at func() time.Time
|
||||
}{
|
||||
{"old-session", func() time.Time { return time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) }},
|
||||
{"recent-session", time.Now},
|
||||
} {
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: root, Now: session.at})
|
||||
if _, err := store.Create(sessions.CreateInput{SessionID: session.id, Title: "title of " + session.id}); err != nil {
|
||||
t.Fatalf("create %s: %v", session.id, err)
|
||||
}
|
||||
store.Release(session.id)
|
||||
}
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
deps := appDeps{
|
||||
newSessionStore: func() *sessions.Store { return sessions.NewStore(sessions.StoreOptions{RootDir: root}) },
|
||||
userConfigPath: func() (string, error) { return configPath, nil },
|
||||
}
|
||||
return root, configPath, deps
|
||||
}
|
||||
|
||||
func runPruneCLI(t *testing.T, deps appDeps, args ...string) (int, string, string) {
|
||||
t.Helper()
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := runWithDeps(append([]string{"sessions"}, args...), &stdout, &stderr, deps)
|
||||
return code, stdout.String(), stderr.String()
|
||||
}
|
||||
|
||||
func pruneSessionExists(t *testing.T, root, id string) bool {
|
||||
t.Helper()
|
||||
_, err := os.Stat(filepath.Join(root, id))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func TestSessionsPruneDryRunListsWithoutRemoving(t *testing.T) {
|
||||
root, _, deps := pruneCLIFixture(t)
|
||||
code, stdout, stderr := runPruneCLI(t, deps, "prune", "--older-than", "30d", "--dry-run")
|
||||
if code != exitSuccess {
|
||||
t.Fatalf("exit %d, stderr %q", code, stderr)
|
||||
}
|
||||
for _, want := range []string{"Would remove 1 session", "old-session", "Dry run: nothing was removed."} {
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Errorf("output does not contain %q:\n%s", want, stdout)
|
||||
}
|
||||
}
|
||||
if strings.Contains(stdout, "recent-session") {
|
||||
t.Errorf("the recent session was listed:\n%s", stdout)
|
||||
}
|
||||
if !pruneSessionExists(t, root, "old-session") {
|
||||
t.Fatal("a dry run removed the session")
|
||||
}
|
||||
}
|
||||
|
||||
// A dry run removes nothing, so a session it could not check is not reported
|
||||
// as one it could not remove.
|
||||
func TestSessionsPruneDryRunReportsFailuresAsUnchecked(t *testing.T) {
|
||||
failed := []sessions.PruneEntry{{SessionID: "old-session", UpdatedAt: "2020-01-01T00:00:00Z", Reason: "the session disappeared while pruning"}}
|
||||
dryRun := formatPruneReport(sessions.PruneReport{Cutoff: "2026-08-27T00:00:00Z", DryRun: true, Failed: failed})
|
||||
if !strings.Contains(dryRun, "Could not check 1:") || strings.Contains(dryRun, "Could not remove") {
|
||||
t.Errorf("dry run report:\n%s", dryRun)
|
||||
}
|
||||
run := formatPruneReport(sessions.PruneReport{Cutoff: "2026-08-27T00:00:00Z", Failed: failed})
|
||||
if !strings.Contains(run, "Could not remove 1:") {
|
||||
t.Errorf("report:\n%s", run)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionsPruneRemovesOnlyOldSessions(t *testing.T) {
|
||||
root, _, deps := pruneCLIFixture(t)
|
||||
code, stdout, stderr := runPruneCLI(t, deps, "prune", "--older-than=30d")
|
||||
if code != exitSuccess {
|
||||
t.Fatalf("exit %d, stderr %q", code, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout, "Removed 1 session") || !strings.Contains(stdout, "old-session") {
|
||||
t.Fatalf("output does not report the removal:\n%s", stdout)
|
||||
}
|
||||
if pruneSessionExists(t, root, "old-session") {
|
||||
t.Fatal("the old session is still there")
|
||||
}
|
||||
if !pruneSessionExists(t, root, "recent-session") {
|
||||
t.Fatal("the recent session was removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionsPruneReportsJSON(t *testing.T) {
|
||||
_, _, deps := pruneCLIFixture(t)
|
||||
code, stdout, stderr := runPruneCLI(t, deps, "prune", "--older-than", "720h", "--json")
|
||||
if code != exitSuccess {
|
||||
t.Fatalf("exit %d, stderr %q", code, stderr)
|
||||
}
|
||||
var report sessions.PruneReport
|
||||
if err := json.Unmarshal([]byte(stdout), &report); err != nil {
|
||||
t.Fatalf("output is not a prune report: %v\n%s", err, stdout)
|
||||
}
|
||||
if len(report.Removed) != 1 || report.Removed[0].SessionID != "old-session" || report.DryRun {
|
||||
t.Fatalf("report = %+v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionsPruneRefusesABadOrShortCutoff(t *testing.T) {
|
||||
root, _, deps := pruneCLIFixture(t)
|
||||
for _, tc := range []struct {
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
{"12h", "last 24 hours"},
|
||||
{"0d", "invalid --older-than"},
|
||||
{"soon", "invalid --older-than"},
|
||||
{"-5d", "invalid --older-than"},
|
||||
} {
|
||||
code, _, stderr := runPruneCLI(t, deps, "prune", "--older-than="+tc.value)
|
||||
if code != exitUsage || !strings.Contains(stderr, tc.want) {
|
||||
t.Errorf("--older-than %s: exit %d, stderr %q, want a usage error naming %q", tc.value, code, stderr, tc.want)
|
||||
}
|
||||
}
|
||||
if !pruneSessionExists(t, root, "old-session") {
|
||||
t.Fatal("a refused prune removed a session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionsPruneNeedsACutoff(t *testing.T) {
|
||||
root, _, deps := pruneCLIFixture(t)
|
||||
code, _, stderr := runPruneCLI(t, deps, "prune")
|
||||
if code != exitUsage || !strings.Contains(stderr, "needs a cutoff") {
|
||||
t.Fatalf("exit %d, stderr %q, want a usage error asking for a cutoff", code, stderr)
|
||||
}
|
||||
if !pruneSessionExists(t, root, "old-session") {
|
||||
t.Fatal("prune without a cutoff removed a session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionsPruneUsesTheUserRetentionSetting(t *testing.T) {
|
||||
root, configPath, deps := pruneCLIFixture(t)
|
||||
if err := os.WriteFile(configPath, []byte(`{"sessions":{"retentionDays":30}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, stdout, stderr := runPruneCLI(t, deps, "prune")
|
||||
if code != exitSuccess {
|
||||
t.Fatalf("exit %d, stderr %q", code, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout, "Removed 1 session") || pruneSessionExists(t, root, "old-session") {
|
||||
t.Fatalf("the user retention setting was not applied:\n%s", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// A REPOSITORY CANNOT CHOOSE WHAT PRUNE DELETES. Sessions are the user's, not
|
||||
// the project's, so sessions.retentionDays in a workspace's .zero/config.json
|
||||
// is not a cutoff, even when prune runs from inside that workspace.
|
||||
func TestSessionsPruneIgnoresRetentionInProjectConfig(t *testing.T) {
|
||||
root, _, deps := pruneCLIFixture(t)
|
||||
workspace := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workspace, ".zero"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(workspace, ".zero", "config.json"), []byte(`{"sessions":{"retentionDays":1}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deps.getwd = func() (string, error) { return workspace, nil }
|
||||
t.Chdir(workspace)
|
||||
|
||||
code, _, stderr := runPruneCLI(t, deps, "prune")
|
||||
if code != exitUsage || !strings.Contains(stderr, "needs a cutoff") {
|
||||
t.Fatalf("exit %d, stderr %q: the project's retention setting was used", code, stderr)
|
||||
}
|
||||
if !pruneSessionExists(t, root, "old-session") {
|
||||
t.Fatal("a project config setting removed a session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionsPruneFlagsBelongToPrune(t *testing.T) {
|
||||
_, _, deps := pruneCLIFixture(t)
|
||||
for _, args := range [][]string{{"list", "--dry-run"}, {"list", "--older-than", "30d"}} {
|
||||
code, _, stderr := runPruneCLI(t, deps, args...)
|
||||
if code != exitUsage || !strings.Contains(stderr, "only valid for sessions prune") {
|
||||
t.Errorf("%v: exit %d, stderr %q", args, code, stderr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An empty cutoff is a usage error, never a fall back to sessions.retentionDays.
|
||||
func TestSessionsPruneRejectsAnEmptyCutoff(t *testing.T) {
|
||||
root, configPath, deps := pruneCLIFixture(t)
|
||||
if err := os.WriteFile(configPath, []byte(`{"sessions":{"retentionDays":1}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, args := range [][]string{
|
||||
{"prune", "--older-than", ""},
|
||||
{"prune", "--older-than", " "},
|
||||
{"prune", "--older-than="},
|
||||
{"list", "--older-than", ""},
|
||||
} {
|
||||
code, _, stderr := runPruneCLI(t, deps, args...)
|
||||
if code != exitUsage || !strings.Contains(stderr, "--older-than requires a value") {
|
||||
t.Errorf("%q: exit %d, stderr %q, want a usage error", args, code, stderr)
|
||||
}
|
||||
}
|
||||
if !pruneSessionExists(t, root, "old-session") {
|
||||
t.Fatal("an empty cutoff fell back to the retention setting and removed a session")
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,10 @@ func TestCLILaunchHelpersLeaveUserStateAlone(t *testing.T) {
|
||||
{"execAdvertisesEscalateModel", func(t *testing.T) {
|
||||
_ = execAdvertisesEscalateModel(t, []string{"--allow-escalation", "exec", "say hi"})
|
||||
}},
|
||||
{"runExecWithSandbox", func(t *testing.T) {
|
||||
_, _, _ = runExecWithSandbox(t, []string{"exec", "hello"}, unavailableTestSandbox, config.SandboxConfig{})
|
||||
}},
|
||||
{"launchTUIWithSandbox", func(t *testing.T) { _ = launchTUIWithSandbox(t, config.SandboxConfig{}) }},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
seedRoot, configPath, seeded, before := seedEmulatedUserConfig(t)
|
||||
|
||||
@@ -244,6 +244,86 @@ func petJSONObject(encodedPet []byte) []byte {
|
||||
return append(result, '}')
|
||||
}
|
||||
|
||||
// setNotifyJSONObject replaces only the notify member's value, keeping the
|
||||
// original bytes of every unrelated member so a notification save cannot
|
||||
// reorder, reformat, or drop the user's other settings — including values whose
|
||||
// EXPLICIT presence matters (tools.deferThreshold: 0, mcp servers'
|
||||
// disabled: false) and keys the typed FileConfig does not model at all. The
|
||||
// typed serializer cannot round-trip explicit zeros (omitempty drops them), so
|
||||
// notification writes go through this byte-preserving editor instead
|
||||
// (maintainer review, PR #1001). An empty notify block removes the member
|
||||
// entirely so a reset leaves no `"notify": {}` husk behind.
|
||||
//
|
||||
// A hand-edited file may contain DUPLICATE notify members. Go's decoder
|
||||
// MERGES the fields of duplicate object members into the same struct, so an
|
||||
// earlier duplicate's field survives a replacement that omits it — e.g.
|
||||
// {"notify":{"mode":"off"},"notify":{"focusMode":"focused"}} + a
|
||||
// replacement {"focusMode":"always"} would leave mode "off" effective.
|
||||
// The nonempty path therefore removes EVERY duplicate and inserts the
|
||||
// replacement fresh (full-replace semantics: nothing inherited), and the
|
||||
// reset path removes every member the same way. UpdateNotify's partial
|
||||
// merge is unaffected: it seeds omitted fields from the stored value BEFORE
|
||||
// calling this editor, so the value it passes is already complete.
|
||||
// Mirrors setPetPreferenceJSON's duplicate handling.
|
||||
func setNotifyJSONObject(data []byte, notify NotifyConfig) ([]byte, error) {
|
||||
encoded, err := json.Marshal(notify)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode notify preference: %w", err)
|
||||
}
|
||||
|
||||
rootStart := skipJSONSpace(data, 0)
|
||||
root, err := parseJSONObject(data, rootStart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notifyIndex := lastJSONMember(root.members, "notify")
|
||||
// Count duplicates: the ordinary file has exactly one notify member, and
|
||||
// for it a positional replace keeps the key where the user put it and
|
||||
// preserves their formatting (byte-preserving contract). The
|
||||
// remove-all-and-reinsert path below is ONLY for files that actually
|
||||
// contain duplicates, where Go's field-merging decode would otherwise let
|
||||
// an earlier block's fields leak into the replacement.
|
||||
duplicates := 0
|
||||
for _, member := range root.members {
|
||||
if member.key == "notify" {
|
||||
duplicates++
|
||||
}
|
||||
}
|
||||
if string(encoded) == "{}" {
|
||||
for notifyIndex >= 0 {
|
||||
data = removeJSONMember(data, root, notifyIndex)
|
||||
rootStart = skipJSONSpace(data, 0)
|
||||
root, err = parseJSONObject(data, rootStart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notifyIndex = lastJSONMember(root.members, "notify")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
if duplicates <= 1 {
|
||||
if notifyIndex < 0 {
|
||||
return insertJSONMember(data, root, "notify", encoded), nil
|
||||
}
|
||||
// Single member: replace its value in place — no key movement, no
|
||||
// reformatting of the surrounding bytes.
|
||||
return replaceJSONRange(data, root.members[notifyIndex].valueStart, root.members[notifyIndex].valueEnd, encoded), nil
|
||||
}
|
||||
// Multiple duplicates: Go merges their fields at decode time, so the
|
||||
// replacement must not inherit anything — remove every duplicate and
|
||||
// insert the complete value fresh.
|
||||
for notifyIndex >= 0 {
|
||||
data = removeJSONMember(data, root, notifyIndex)
|
||||
rootStart = skipJSONSpace(data, 0)
|
||||
root, err = parseJSONObject(data, rootStart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notifyIndex = lastJSONMember(root.members, "notify")
|
||||
}
|
||||
return insertJSONMember(data, root, "notify", encoded), nil
|
||||
}
|
||||
|
||||
func replaceJSONRange(data []byte, start, end int, replacement []byte) []byte {
|
||||
result := make([]byte, 0, len(data)-(end-start)+len(replacement))
|
||||
result = append(result, data[:start]...)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func normalizeModelRPM(limits map[string]int) (map[string]int, error) {
|
||||
if len(limits) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
registry, err := modelregistry.DefaultRegistry()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]int, len(limits))
|
||||
for key, n := range limits {
|
||||
model := strings.TrimSpace(key)
|
||||
if model == "" || n < 0 {
|
||||
return nil, fmt.Errorf("invalid modelRPM entry %q: model must be non-empty and limit >= 0", key)
|
||||
}
|
||||
if id, ok := registry.ResolveID(model); ok {
|
||||
model = id
|
||||
}
|
||||
if old, exists := out[model]; exists && old != n {
|
||||
return nil, fmt.Errorf("conflicting modelRPM entries resolve to %q", model)
|
||||
}
|
||||
out[model] = n
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Project config can tighten a user cap but cannot relax it.
|
||||
func mergeModelRPM(dst *map[string]int, src map[string]int, tighten bool) {
|
||||
if len(src) == 0 {
|
||||
return
|
||||
}
|
||||
if *dst == nil {
|
||||
*dst = make(map[string]int)
|
||||
}
|
||||
for model, n := range src {
|
||||
old := (*dst)[model]
|
||||
if !tighten || old == 0 || (n > 0 && n < old) {
|
||||
(*dst)[model] = n
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestModelRPMRoundTrip(t *testing.T) {
|
||||
var c FileConfig
|
||||
if e := json.Unmarshal([]byte(`{"modelRPM":{" OPENAI:GPT-4.1 ":2,"custom":0},"future":true}`), &c); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
want := map[string]int{"gpt-4.1": 2, "custom": 0}
|
||||
if !reflect.DeepEqual(c.ModelRPM, want) {
|
||||
t.Fatal(c.ModelRPM)
|
||||
}
|
||||
b, e := json.Marshal(c)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
var d FileConfig
|
||||
if e = json.Unmarshal(b, &d); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if !reflect.DeepEqual(d.ModelRPM, want) || string(d.Extra["future"]) != "true" {
|
||||
t.Fatalf("round trip=%s", b)
|
||||
}
|
||||
}
|
||||
func TestModelRPMInvalid(t *testing.T) {
|
||||
for _, s := range []string{`{"modelRPM":{"":1}}`, `{"modelRPM":{"a":-1}}`, `{"modelRPM":{"a":1.5}}`, `{"modelRPM":{"a":"2"}}`, `{"modelRPM":{"gpt-4.1":1,"openai:gpt-4.1":2}}`} {
|
||||
var c FileConfig
|
||||
if e := json.Unmarshal([]byte(s), &c); e == nil {
|
||||
t.Fatalf("accepted %s", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestModelRPMProjectCanOnlyTighten(t *testing.T) {
|
||||
user := writeConfig(t, `{"modelRPM":{"gpt-4.1":2},"activeProvider":"test","providers":[{"name":"test","provider":"openai","model":"gpt-4.1"}]}`)
|
||||
for _, n := range []string{"0", "1", "5"} {
|
||||
project := writeConfig(t, `{"modelRPM":{"openai:gpt-4.1":`+n+`}}`)
|
||||
c, e := Resolve(ResolveOptions{UserConfigPath: user, ProjectConfigPath: project, Env: map[string]string{}})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
want := 2
|
||||
if n == "1" {
|
||||
want = 1
|
||||
}
|
||||
if c.ModelRPM["gpt-4.1"] != want {
|
||||
t.Fatal(c.ModelRPM)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,9 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
// trusted global-config merge, which must keep honouring the setting.
|
||||
commandConfig.Sandbox.Enabled = nil
|
||||
commandConfig.CrossSessionInbound = ""
|
||||
// External provider commands cannot relax the user cap.
|
||||
mergeModelRPM(&cfg.ModelRPM, commandConfig.ModelRPM, true)
|
||||
commandConfig.ModelRPM = nil
|
||||
mergeConfig(&cfg, commandConfig)
|
||||
}
|
||||
|
||||
@@ -157,15 +160,36 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
// On ErrNoActiveProvider, providers may still hold the successfully
|
||||
// normalized (but active-less) profile list — keep it so a caller can fall
|
||||
// back to an already-configured usable provider instead of treating this
|
||||
// like a config with nothing set up at all.
|
||||
return ResolvedConfig{Providers: providers}, err
|
||||
// like a config with nothing set up at all. The non-provider fields parsed
|
||||
// before the failure (notify, sandbox, tools, preferences, ...) are kept
|
||||
// too: the TUI's provider-recovery path forwards this partial config, and
|
||||
// dropping them would let the TUI's unconfigured notify default overwrite
|
||||
// a stored explicit opt-out on startup (maintainer review, PR #1001).
|
||||
partial := ResolvedConfig{
|
||||
ModelRPM: cfg.ModelRPM,
|
||||
Providers: providers,
|
||||
MaxTurns: cfg.MaxTurns,
|
||||
MCP: cfg.MCP,
|
||||
Sandbox: cfg.Sandbox,
|
||||
Notify: cfg.Notify,
|
||||
Tools: cfg.Tools,
|
||||
Swarm: cfg.Swarm,
|
||||
Preferences: cfg.Preferences,
|
||||
KeyBindings: cfg.KeyBindings,
|
||||
LocalControl: cfg.LocalControl,
|
||||
STT: cfg.STT,
|
||||
CrossSessionInbound: cfg.CrossSessionInbound,
|
||||
}
|
||||
return partial, err
|
||||
}
|
||||
|
||||
return ResolvedConfig{
|
||||
ModelRPM: cfg.ModelRPM,
|
||||
ActiveProvider: active.Name,
|
||||
Providers: providers,
|
||||
Provider: active,
|
||||
MaxTurns: cfg.MaxTurns,
|
||||
MaxTurnsSet: cfg.maxTurnsSet,
|
||||
MCP: cfg.MCP,
|
||||
Sandbox: cfg.Sandbox,
|
||||
Notify: cfg.Notify,
|
||||
@@ -229,11 +253,13 @@ 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
|
||||
}
|
||||
if src.MaxTurns > 0 {
|
||||
dst.MaxTurns = src.MaxTurns
|
||||
dst.maxTurnsSet = true
|
||||
}
|
||||
for _, provider := range src.Providers {
|
||||
mergeProvider(dst, provider)
|
||||
@@ -289,11 +315,13 @@ 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
|
||||
}
|
||||
if src.MaxTurns > 0 {
|
||||
dst.MaxTurns = src.MaxTurns
|
||||
dst.maxTurnsSet = true
|
||||
}
|
||||
for _, provider := range src.Providers {
|
||||
candidate := providerMergeCandidate(*dst, provider)
|
||||
@@ -606,6 +634,7 @@ func applyEnv(cfg *FileConfig, env map[string]string) {
|
||||
n = MaxTurnsCeiling
|
||||
}
|
||||
cfg.MaxTurns = n
|
||||
cfg.maxTurnsSet = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -726,6 +755,7 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
|
||||
}
|
||||
if overrides.MaxTurns > 0 {
|
||||
cfg.MaxTurns = overrides.MaxTurns
|
||||
cfg.maxTurnsSet = true
|
||||
}
|
||||
if overrides.Sandbox.Enabled != nil {
|
||||
cfg.Sandbox.Enabled = overrides.Sandbox.Enabled
|
||||
|
||||
@@ -1109,6 +1109,34 @@ func TestResolveProviderCommandOverridesEnvProviderFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveProviderCommandCannotRelaxUserModelRPM(t *testing.T) {
|
||||
userPath := writeConfig(t, `{
|
||||
"modelRPM": {
|
||||
"gpt-command": 5
|
||||
}
|
||||
}`)
|
||||
|
||||
command := writeCommand(t, commandScript{
|
||||
Stdout: `{"activeProvider":"cmd","providers":[{"name":"cmd","provider":"openai","apiKey":"sk-command","model":"gpt-command"}],"modelRPM":{"gpt-command":10}}`,
|
||||
})
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
UserConfigPath: userPath,
|
||||
ProviderCommand: command,
|
||||
Env: map[string]string{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
|
||||
if got := resolved.ModelRPM["gpt-command"]; got != 5 {
|
||||
t.Fatalf(
|
||||
"ModelRPM[gpt-command] = %d, want user cap 5 preserved against provider-command relaxation",
|
||||
got,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAllowsNoConfiguredProviders(t *testing.T) {
|
||||
resolved, err := Resolve(ResolveOptions{Env: map[string]string{}})
|
||||
if err != nil {
|
||||
@@ -1151,8 +1179,13 @@ func TestResolveRejectsActiveProviderWithoutConfiguredProfiles(t *testing.T) {
|
||||
if HasProviderProfile(resolved.Provider) {
|
||||
t.Fatalf("Provider = %#v, want zero value", resolved.Provider)
|
||||
}
|
||||
if resolved.MaxTurns != 0 {
|
||||
t.Fatalf("MaxTurns = %d, want zero on failed resolve", resolved.MaxTurns)
|
||||
// The error path now carries the parsed non-provider fields through
|
||||
// (maintainer review, PR #1001): the TUI's provider-recovery startup
|
||||
// forwards this partial config, and a zero MaxTurns there would silently
|
||||
// discard the user's turn budget. Matches the no-providers SUCCESS path,
|
||||
// which has always defaulted MaxTurns.
|
||||
if resolved.MaxTurns != defaultMaxTurns {
|
||||
t.Fatalf("MaxTurns = %d, want default %d carried through the partial resolve", resolved.MaxTurns, defaultMaxTurns)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1161,10 +1194,13 @@ func TestResolveKeepsNormalizedProvidersWhenNoneMarkedActive(t *testing.T) {
|
||||
// activeProvider is blank/stale — a caller like the interactive TUI still
|
||||
// needs the normalized list to fall back to an already-usable provider
|
||||
// instead of forcing a full re-onboarding wizard.
|
||||
path := writeConfig(t, `{"providers":[
|
||||
{"name":"work","provider_kind":"openai","apiKey":"sk-test","model":"gpt-test"},
|
||||
{"name":"other","provider_kind":"openai","apiKey":"sk-other","model":"gpt-test"}
|
||||
]}`)
|
||||
path := writeConfig(t, `{
|
||||
"modelRPM":{"gpt-test":1},
|
||||
"providers":[
|
||||
{"name":"work","provider_kind":"openai","apiKey":"sk-test","model":"gpt-test"},
|
||||
{"name":"other","provider_kind":"openai","apiKey":"sk-other","model":"gpt-test"}
|
||||
]
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
|
||||
if !errors.Is(err, ErrNoActiveProvider) {
|
||||
@@ -1173,6 +1209,9 @@ func TestResolveKeepsNormalizedProvidersWhenNoneMarkedActive(t *testing.T) {
|
||||
if len(resolved.Providers) != 2 {
|
||||
t.Fatalf("Providers = %#v, want the 2 normalized profiles preserved despite the error", resolved.Providers)
|
||||
}
|
||||
if got := resolved.ModelRPM["gpt-test"]; got != 1 {
|
||||
t.Fatalf("ModelRPM[gpt-test] = %d, want 1 preserved despite ErrNoActiveProvider", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTrimsProviderProfileAliasesBeforeFallback(t *testing.T) {
|
||||
@@ -1864,14 +1903,43 @@ func TestResolveNotifyInvalidFocusMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNotifyDefaultEmpty(t *testing.T) {
|
||||
path := writeConfig(t, `{}`)
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
||||
// An unconfigured notify block must resolve EMPTY. Defaults live in the TUI
|
||||
// (effectiveTUINotifyMode) and in `zero config notify`'s display, not in the
|
||||
// resolver: a filled-in default here leaks into headless `zero exec` (BEL +
|
||||
// OSC-9 bytes on stderr under -o json) and into the CLI/TUI preserve paths,
|
||||
// which must treat "user never chose" differently from "user chose both"
|
||||
// (maintainer review, PR #1001).
|
||||
func TestResolveNotifyUnconfiguredStaysEmpty(t *testing.T) {
|
||||
// No config file at all.
|
||||
resolved, err := Resolve(ResolveOptions{Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if resolved.Notify.Mode != "" || resolved.Notify.FocusMode != "" {
|
||||
t.Fatalf("unset notify should be empty, got %+v", resolved.Notify)
|
||||
t.Fatalf("no config file: notify = %+v, want empty (resolver must not default)", resolved.Notify)
|
||||
}
|
||||
|
||||
for name, body := range map[string]string{
|
||||
"empty config": `{}`,
|
||||
"empty block": `{"notify":{}}`,
|
||||
"mode only": `{"notify":{"mode":"off"}}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: writeConfig(t, body), Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if name == "mode only" {
|
||||
if resolved.Notify.Mode != "off" {
|
||||
t.Errorf("notify.mode = %q, want preserved off", resolved.Notify.Mode)
|
||||
}
|
||||
} else if resolved.Notify.Mode != "" {
|
||||
t.Errorf("notify.mode = %q, want empty (resolver must not default)", resolved.Notify.Mode)
|
||||
}
|
||||
if resolved.Notify.FocusMode != "" {
|
||||
t.Errorf("notify.focusMode = %q, want empty (resolver must not default)", resolved.Notify.FocusMode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2252,3 +2320,64 @@ func TestResolveRejectsInvalidCrossSessionInbound(t *testing.T) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveReportsExplicitMaxTurns(t *testing.T) {
|
||||
// A nil ResolveOptions.Env means "read the real process environment" (see
|
||||
// envValue), so a case that leaves it nil inherits whatever the developer
|
||||
// happens to export. ANTHROPIC_BASE_URL alone is enough: applyProviderEnv
|
||||
// synthesizes a second, keyless "anthropic" profile from a base URL on its
|
||||
// own, the single-provider shortcut in normalizeProviders stops applying,
|
||||
// and Resolve fails with `active provider "" not found` before this test
|
||||
// reaches its assertion. Every variable the resolver reads does it, so the
|
||||
// cases below state an EMPTY environment rather than no environment, and
|
||||
// these three are set to a hostile value so the test proves it.
|
||||
t.Setenv("ANTHROPIC_BASE_URL", "https://proxy.example/v1")
|
||||
t.Setenv("OPENAI_API_KEY", "from-the-developer-shell")
|
||||
t.Setenv(MaxTurnsEnv, "7")
|
||||
|
||||
const providerJSON = `{
|
||||
"activeProvider": "p",
|
||||
"providers": [{"name": "p", "provider_kind": "openai-compatible", "base_url": "https://x.example/v1", "apiKey": "k", "model": "m"}]
|
||||
}`
|
||||
providerWithTurns := strings.Replace(providerJSON, `"activeProvider": "p"`, `"activeProvider": "p", "maxTurns": 40`, 1)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
opts ResolveOptions
|
||||
set bool
|
||||
}{
|
||||
{"built-in default only", ResolveOptions{
|
||||
Env: map[string]string{},
|
||||
Overrides: Overrides{Provider: ProviderProfile{Name: "p", ProviderKind: "openai-compatible", BaseURL: "https://x.example/v1", APIKey: "k", Model: "m"}},
|
||||
}, false},
|
||||
{"user config", ResolveOptions{
|
||||
Env: map[string]string{},
|
||||
UserConfigPath: writeConfig(t, providerWithTurns),
|
||||
}, true},
|
||||
{"project config", ResolveOptions{
|
||||
Env: map[string]string{},
|
||||
UserConfigPath: writeConfig(t, providerJSON),
|
||||
ProjectConfigPath: writeConfig(t, `{"maxTurns": 40}`),
|
||||
}, true},
|
||||
{"env", ResolveOptions{
|
||||
Env: map[string]string{MaxTurnsEnv: "120"},
|
||||
Overrides: Overrides{Provider: ProviderProfile{Name: "p", ProviderKind: "openai-compatible", BaseURL: "https://x.example/v1", APIKey: "k", Model: "m"}},
|
||||
}, true},
|
||||
{"cli override", ResolveOptions{
|
||||
Env: map[string]string{},
|
||||
Overrides: Overrides{
|
||||
MaxTurns: 42,
|
||||
Provider: ProviderProfile{Name: "p", ProviderKind: "openai-compatible", BaseURL: "https://x.example/v1", APIKey: "k", Model: "m"},
|
||||
},
|
||||
}, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
resolved, err := Resolve(tc.opts)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: Resolve() error = %v", tc.name, err)
|
||||
}
|
||||
if resolved.MaxTurnsSet != tc.set {
|
||||
t.Fatalf("%s: MaxTurnsSet = %v, want %v (MaxTurns=%d)", tc.name, resolved.MaxTurnsSet, tc.set, resolved.MaxTurns)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
)
|
||||
|
||||
// ReadSessionsConfig reads the sessions settings from the config file at path,
|
||||
// which callers pass as the user config. A missing file is an empty config. See
|
||||
// SessionsConfig for why nothing else supplies these settings.
|
||||
func ReadSessionsConfig(path string) (SessionsConfig, error) {
|
||||
cfg, err := loadConfigFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return SessionsConfig{}, nil
|
||||
}
|
||||
return SessionsConfig{}, err
|
||||
}
|
||||
return cfg.Sessions, nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSessionsConfigRoundTripsAsAKnownKey(t *testing.T) {
|
||||
var cfg FileConfig
|
||||
if err := json.Unmarshal([]byte(`{"sessions":{"retentionDays":14}}`), &cfg); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if cfg.Sessions.RetentionDays != 14 {
|
||||
t.Fatalf("retentionDays = %d, want 14", cfg.Sessions.RetentionDays)
|
||||
}
|
||||
if _, stray := cfg.Extra["sessions"]; stray {
|
||||
t.Fatal("sessions was kept as an unknown key as well as parsed")
|
||||
}
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"sessions":{"retentionDays":14}`) {
|
||||
t.Fatalf("marshalled config lost the setting: %s", data)
|
||||
}
|
||||
empty, err := json.Marshal(FileConfig{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(empty), "sessions") {
|
||||
t.Fatalf("an unset sessions block was written out: %s", empty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionsConfigRejectsANegativeRetention(t *testing.T) {
|
||||
var cfg FileConfig
|
||||
err := json.Unmarshal([]byte(`{"sessions":{"retentionDays":-1}}`), &cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "retentionDays") {
|
||||
t.Fatalf("a negative retention was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A write that changes something else must not drop the setting.
|
||||
func TestSessionsConfigSurvivesAnUnrelatedWrite(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"sessions":{"retentionDays":21}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SetTheme(path, "dark"); err != nil {
|
||||
t.Fatalf("SetTheme: %v", err)
|
||||
}
|
||||
settings, err := ReadSessionsConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadSessionsConfig: %v", err)
|
||||
}
|
||||
if settings.RetentionDays != 21 {
|
||||
t.Fatalf("after an unrelated write retentionDays = %d, want 21", settings.RetentionDays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSessionsConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if settings, err := ReadSessionsConfig(filepath.Join(dir, "missing.json")); err != nil || settings != (SessionsConfig{}) {
|
||||
t.Fatalf("a missing config = %+v, %v; want empty and no error", settings, err)
|
||||
}
|
||||
broken := filepath.Join(dir, "broken.json")
|
||||
if err := os.WriteFile(broken, []byte(`{"sessions":`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadSessionsConfig(broken); err == nil {
|
||||
t.Fatal("a config that does not parse was read as empty")
|
||||
}
|
||||
}
|
||||
@@ -177,6 +177,19 @@ const (
|
||||
STTProviderDeepgram STTProviderKind = "deepgram"
|
||||
)
|
||||
|
||||
// SessionsConfig holds settings for Zero's saved sessions.
|
||||
//
|
||||
// READ FROM THE USER'S OWN CONFIG ONLY. It decides what `zero sessions prune`
|
||||
// deletes, and sessions belong to the user rather than to a repository, so a
|
||||
// project's .zero/config.json has no say: the resolver does not merge it, and
|
||||
// the prune command reads it straight from the user config file.
|
||||
type SessionsConfig struct {
|
||||
// RetentionDays is the cutoff `zero sessions prune` uses when it is given no
|
||||
// --older-than. Unset (0) means prune needs an explicit cutoff; nothing is
|
||||
// ever removed without one being asked for.
|
||||
RetentionDays int `json:"retentionDays,omitempty"`
|
||||
}
|
||||
|
||||
// STTConfig configures speech-to-text dictation. All fields are optional; empty
|
||||
// values take the documented defaults. Booleans that need a real tri-state
|
||||
// (distinguishing "unset" from "false") use *bool, matching PreferencesConfig.Recaps.
|
||||
@@ -351,6 +364,7 @@ func (cfg *ToolsConfig) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
type FileConfig struct {
|
||||
ModelRPM map[string]int `json:"modelRPM,omitempty"`
|
||||
ActiveProvider string `json:"activeProvider,omitempty"`
|
||||
Providers []ProviderProfile `json:"providers,omitempty"`
|
||||
MaxTurns int `json:"maxTurns,omitempty"`
|
||||
@@ -364,6 +378,11 @@ 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.
|
||||
maxTurnsSet bool
|
||||
// Extra preserves top-level fields written by newer Zero versions or
|
||||
// extensions so a read-modify-write through this version is non-destructive.
|
||||
Extra map[string]json.RawMessage `json:"-"`
|
||||
@@ -371,6 +390,7 @@ type FileConfig struct {
|
||||
|
||||
func (cfg FileConfig) MarshalJSON() ([]byte, error) {
|
||||
type rawConfig struct {
|
||||
ModelRPM map[string]int `json:"modelRPM,omitempty"`
|
||||
ActiveProvider string `json:"activeProvider,omitempty"`
|
||||
Providers []ProviderProfile `json:"providers,omitempty"`
|
||||
MaxTurns int `json:"maxTurns,omitempty"`
|
||||
@@ -384,8 +404,10 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
|
||||
LocalControl *LocalControlConfig `json:"localControl,omitempty"`
|
||||
STT *STTConfig `json:"stt,omitempty"`
|
||||
CrossSessionInbound string `json:"crossSessionInbound,omitempty"`
|
||||
Sessions *SessionsConfig `json:"sessions,omitempty"`
|
||||
}
|
||||
raw := rawConfig{
|
||||
ModelRPM: cfg.ModelRPM,
|
||||
ActiveProvider: cfg.ActiveProvider,
|
||||
Providers: cfg.Providers,
|
||||
MaxTurns: cfg.MaxTurns,
|
||||
@@ -404,6 +426,9 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
|
||||
if !cfg.STT.Empty() {
|
||||
raw.STT = &cfg.STT
|
||||
}
|
||||
if cfg.Sessions != (SessionsConfig{}) {
|
||||
raw.Sessions = &cfg.Sessions
|
||||
}
|
||||
known, err := json.Marshal(raw)
|
||||
if err != nil || len(cfg.Extra) == 0 {
|
||||
return known, err
|
||||
@@ -461,10 +486,16 @@ type Overrides struct {
|
||||
}
|
||||
|
||||
type ResolvedConfig struct {
|
||||
ActiveProvider string
|
||||
Providers []ProviderProfile
|
||||
Provider ProviderProfile
|
||||
MaxTurns int
|
||||
ModelRPM map[string]int
|
||||
ActiveProvider string
|
||||
Providers []ProviderProfile
|
||||
Provider ProviderProfile
|
||||
MaxTurns int
|
||||
// MaxTurnsSet reports whether MaxTurns came from an explicit source (a
|
||||
// config file, a provider command, ZERO_MAX_TURNS, or CLI overrides) rather
|
||||
// than the built-in default — callers that need a different default can
|
||||
// tell the two apart.
|
||||
MaxTurnsSet bool
|
||||
MCP MCPConfig
|
||||
Sandbox SandboxConfig
|
||||
Notify NotifyConfig
|
||||
@@ -524,6 +555,7 @@ type MCPOAuthConfig struct {
|
||||
|
||||
func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
type rawConfig struct {
|
||||
ModelRPM map[string]int `json:"modelRPM"`
|
||||
ActiveProvider string `json:"activeProvider"`
|
||||
Providers []ProviderProfile `json:"providers"`
|
||||
MaxTurns int `json:"maxTurns"`
|
||||
@@ -537,6 +569,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
LocalControl LocalControlConfig `json:"localControl"`
|
||||
STT STTConfig `json:"stt"`
|
||||
CrossSessionInbound string `json:"crossSessionInbound"`
|
||||
Sessions SessionsConfig `json:"sessions"`
|
||||
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
|
||||
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
|
||||
}
|
||||
@@ -558,6 +591,11 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
extra = nil
|
||||
}
|
||||
cfg.ActiveProvider = raw.ActiveProvider
|
||||
limits, err := normalizeModelRPM(raw.ModelRPM)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.ModelRPM = limits
|
||||
cfg.Providers = raw.Providers
|
||||
// A negative maxTurns is unambiguously invalid; without this it would be
|
||||
// silently dropped by the `MaxTurns > 0` merge gates and fall back to the
|
||||
@@ -578,6 +616,10 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
cfg.LocalControl = raw.LocalControl
|
||||
cfg.STT = raw.STT
|
||||
cfg.CrossSessionInbound = raw.CrossSessionInbound
|
||||
if raw.Sessions.RetentionDays < 0 {
|
||||
return fmt.Errorf("invalid sessions.retentionDays %d: must be >= 0", raw.Sessions.RetentionDays)
|
||||
}
|
||||
cfg.Sessions = raw.Sessions
|
||||
cfg.Extra = extra
|
||||
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
|
||||
cfg.MCP.Servers = map[string]MCPServerConfig{}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/notify"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
)
|
||||
|
||||
@@ -756,6 +757,128 @@ func SetTheme(path string, theme string) (result FileConfig, err error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// UserNotify returns the notify block stored in the user's own config file at
|
||||
// path, trimmed. A missing file or missing/empty block returns the zero value:
|
||||
// callers that need to preserve "whatever the user already chose" on a partial
|
||||
// update must seed from THIS value, not from the resolved view — the resolver
|
||||
// merges project config and the TUI applies its own defaults, so seeding from
|
||||
// resolved copies choices the user never made into their global file (a
|
||||
// project's mode: off, or a pinned default) and across every other project.
|
||||
func UserNotify(path string) (NotifyConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return NotifyConfig{}, nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return NotifyConfig{}, nil
|
||||
}
|
||||
return NotifyConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
var cfg FileConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return NotifyConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
cfg.Notify.Mode = strings.TrimSpace(cfg.Notify.Mode)
|
||||
cfg.Notify.FocusMode = strings.TrimSpace(cfg.Notify.FocusMode)
|
||||
return cfg.Notify, nil
|
||||
}
|
||||
|
||||
// validateNotify trims both fields and checks them against the accepted vocab
|
||||
// (mode in {off,bell,notify,both}; focusMode in {unfocused,always,focused}) so
|
||||
// a bad caller cannot write a value the resolver would later reject at
|
||||
// startup. An empty Mode or FocusMode is stored as-is — blank means "use the
|
||||
// built-in defaults" (the TUI's effectiveTUINotifyMode maps an empty mode to
|
||||
// both; the notify package treats an empty focusMode as unfocused), not "off".
|
||||
func validateNotify(value NotifyConfig) (NotifyConfig, error) {
|
||||
value.Mode = strings.TrimSpace(value.Mode)
|
||||
value.FocusMode = strings.TrimSpace(value.FocusMode)
|
||||
if mode := value.Mode; mode != "" {
|
||||
switch notify.Mode(mode) {
|
||||
case notify.ModeOff, notify.ModeBell, notify.ModeNotify, notify.ModeBoth:
|
||||
default:
|
||||
return NotifyConfig{}, fmt.Errorf("invalid notify.mode %q: expected off, bell, notify, or both", mode)
|
||||
}
|
||||
}
|
||||
if focus := value.FocusMode; focus != "" {
|
||||
switch notify.FocusMode(focus) {
|
||||
case notify.FocusUnfocused, notify.FocusAlways, notify.FocusFocused:
|
||||
default:
|
||||
return NotifyConfig{}, fmt.Errorf("invalid notify.focusMode %q: expected unfocused, always, or focused", focus)
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// UpdateNotify runs one SERIALIZED read-modify-write transaction on the user's
|
||||
// notify block. merge receives the currently stored block and returns the
|
||||
// block to store; it must preserve any field the caller is not explicitly
|
||||
// changing. The lock covers reading the stored block, applying merge, and
|
||||
// replacing the file, so two concurrent partial updates (for example
|
||||
// `--mode off` and `--focus always` from two terminals) cannot interleave
|
||||
// their reads and writes and silently undo each other's explicit change
|
||||
// (maintainer review, PR #1001 reproduced a 50/50 lost-update without it).
|
||||
//
|
||||
// The write replaces ONLY the notify member's bytes (setNotifyJSONObject),
|
||||
// preserving every unrelated value and its explicit presence — including
|
||||
// explicit zeros/falses like tools.deferThreshold: 0 or an MCP server's
|
||||
// disabled: false, which the typed serializer's omitempty cannot round-trip —
|
||||
// through the existing temp-file-and-rename atomic publish. Locking only the
|
||||
// final write would leave the stale-field merge outside the transaction, so
|
||||
// the whole sequence runs under one lock — the SAME lockConfigFileFn authority
|
||||
// every other config mutator uses (provider/theme/credential/MCP writes), not
|
||||
// a private lock: two authorities over the same file would not contend
|
||||
// (maintainer review, PR #1001). The release error is joined into the
|
||||
// result like every other writer: a failed unlock would leave the advisory
|
||||
// lock held and later updates blocked while the caller is told success.
|
||||
func UpdateNotify(path string, merge func(current NotifyConfig) NotifyConfig) (result NotifyConfig, err error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return NotifyConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
if merge == nil {
|
||||
return NotifyConfig{}, fmt.Errorf("merge function is required")
|
||||
}
|
||||
release, err := lockConfigFileFn(path)
|
||||
if err != nil {
|
||||
return NotifyConfig{}, err
|
||||
}
|
||||
// Joined, not chosen between: a release failure annotates the result
|
||||
// instead of masking the mutation error that actually explains what went
|
||||
// wrong. Reporting success after a failed release would claim a state the
|
||||
// next update cannot reproduce.
|
||||
defer func() { err = errors.Join(err, release()) }()
|
||||
|
||||
current, err := UserNotify(path)
|
||||
if err != nil {
|
||||
return NotifyConfig{}, err
|
||||
}
|
||||
next, err := validateNotify(merge(current))
|
||||
if err != nil {
|
||||
return NotifyConfig{}, err
|
||||
}
|
||||
|
||||
// Read the raw file and edit only the notify member. A missing file starts
|
||||
// from an empty object so a first-time write does not need a pre-seeded
|
||||
// config.
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return NotifyConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
data = []byte("{}")
|
||||
}
|
||||
updated, err := setNotifyJSONObject(data, next)
|
||||
if err != nil {
|
||||
return NotifyConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
if err := writeConfigData(path, updated); err != nil {
|
||||
return NotifyConfig{}, err
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// SetPet persists only the terminal-pet preference while preserving every
|
||||
// unrelated user setting through the config writer's atomic replace path.
|
||||
func SetPet(path string, pet string) (result FileConfig, err error) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -426,6 +428,286 @@ func TestSetThemePreservesUnknownTopLevelFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// setNotify replaces the stored notify block with value through UpdateNotify
|
||||
// and returns the re-read config, so the tests below can assert on a
|
||||
// FileConfig-shaped result. Every product surface uses UpdateNotify's merge
|
||||
// form directly; this replace form exists only for these tests.
|
||||
func setNotify(path string, value NotifyConfig) (FileConfig, error) {
|
||||
next, err := UpdateNotify(path, func(NotifyConfig) NotifyConfig { return value })
|
||||
if err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
cfg, err := loadConfigFile(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
cfg.Notify = next
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func TestSetNotifyPersistsValidValues(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "openai",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "openai", ProviderKind: ProviderKindOpenAI, Model: "gpt-4.1"},
|
||||
},
|
||||
}, 0o600)
|
||||
|
||||
cfg, err := setNotify(path, NotifyConfig{Mode: " both ", FocusMode: " unfocused "})
|
||||
if err != nil {
|
||||
t.Fatalf("setNotify() error = %v", err)
|
||||
}
|
||||
if cfg.Notify.Mode != "both" || cfg.Notify.FocusMode != "unfocused" {
|
||||
t.Fatalf("Notify = %+v, want mode=both focusMode=unfocused (trimmed)", cfg.Notify)
|
||||
}
|
||||
persisted := readConfigFixture(t, path)
|
||||
if persisted.Notify.Mode != "both" || persisted.Notify.FocusMode != "unfocused" {
|
||||
t.Fatalf("persisted Notify = %+v, want mode=both focusMode=unfocused", persisted.Notify)
|
||||
}
|
||||
if persisted.ActiveProvider != "openai" || len(persisted.Providers) != 1 {
|
||||
t.Fatalf("provider config was not preserved by setNotify: %#v", persisted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetNotifyRejectsInvalidMode(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{ActiveProvider: "openai"}, 0o600)
|
||||
if _, err := setNotify(path, NotifyConfig{Mode: "loud", FocusMode: "unfocused"}); err == nil {
|
||||
t.Fatal("expected error for invalid notify.mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetNotifyRejectsInvalidFocusMode(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{ActiveProvider: "openai"}, 0o600)
|
||||
if _, err := setNotify(path, NotifyConfig{Mode: "off", FocusMode: "sideways"}); err == nil {
|
||||
t.Fatal("expected error for invalid notify.focusMode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetNotifyRejectsEmptyConfigPath(t *testing.T) {
|
||||
if _, err := setNotify("", NotifyConfig{Mode: "off"}); err == nil {
|
||||
t.Fatal("expected error for empty config path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetNotifyBlankValuesPreservedAsDefaults(t *testing.T) {
|
||||
// An empty mode/focusMode stored on disk is a valid "use the built-in
|
||||
// defaults" signal — setNotify must not reject blanks, and they must round
|
||||
// trip unchanged.
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{ActiveProvider: "openai"}, 0o600)
|
||||
if _, err := setNotify(path, NotifyConfig{}); err != nil {
|
||||
t.Fatalf("setNotify({}) should accept blank values, got error: %v", err)
|
||||
}
|
||||
persisted := readConfigFixture(t, path)
|
||||
if persisted.Notify.Mode != "" || persisted.Notify.FocusMode != "" {
|
||||
t.Fatalf("blank notify values should round-trip, got %+v", persisted.Notify)
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainer regression (PR #1001): a notification save must preserve unrelated
|
||||
// values whose EXPLICIT presence matters. The typed serializer's omitempty
|
||||
// cannot round-trip tools.deferThreshold: 0 (explicit "never defer" vs unset
|
||||
// "use default 3") or an MCP server's disabled: false (explicitly enabled), so
|
||||
// UpdateNotify edits only the notify member's bytes instead. The
|
||||
// assertions run against the RAW file, because reading through FileConfig
|
||||
// would hide exactly the presence loss being tested.
|
||||
func TestSetNotifyPreservesExplicitUnrelatedValues(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
for name, body := range map[string]string{
|
||||
"explicit zero deferThreshold": `{
|
||||
"tools": {"deferThreshold": 0},
|
||||
"mcp": {"servers": {"firecrawl": {"command": "npx", "disabled": false}}}
|
||||
}`,
|
||||
"unknown keys stay untouched": `{
|
||||
"activeProvider": "openai",
|
||||
"customTopLevel": {"nested": [1, 2, 3]},
|
||||
"notify": {"mode": "both"}
|
||||
}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
path := filepath.Join(dir, name+".json")
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := setNotify(path, NotifyConfig{Mode: "off", FocusMode: "always"}); err != nil {
|
||||
t.Fatalf("setNotify: %v", err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if strings.Contains(name, "explicit zero") {
|
||||
tools, ok := obj["tools"]
|
||||
if !ok || !strings.Contains(string(tools), `"deferThreshold": 0`) {
|
||||
t.Errorf("tools.deferThreshold: 0 lost through a notify write; tools = %s", string(tools))
|
||||
}
|
||||
mcp, ok := obj["mcp"]
|
||||
if !ok || !strings.Contains(string(mcp), `"disabled": false`) {
|
||||
t.Errorf("mcp disabled: false lost through a notify write; mcp = %s", string(mcp))
|
||||
}
|
||||
}
|
||||
if strings.Contains(name, "unknown keys") {
|
||||
if _, ok := obj["customTopLevel"]; !ok {
|
||||
t.Error("unknown top-level key lost through a notify write")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainer regression (PR #1001, CodeRabbit follow-up): a hand-edited config
|
||||
// may contain DUPLICATE notify members (JSON decoders tolerate them and the
|
||||
// last occurrence wins). A reset must remove EVERY notify member — removing
|
||||
// only the last leaves the earlier block as the new effective preference, so
|
||||
// `zero config notify --reset` would report success while the old value still
|
||||
// applies.
|
||||
func TestSetNotifyResetRemovesEveryDuplicateNotifyMember(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
duplicated := `{"notify":{"mode":"off"},"activeProvider":"openai","notify":{"mode":"bell"}}`
|
||||
if err := os.WriteFile(path, []byte(duplicated), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := setNotify(path, NotifyConfig{}); err != nil {
|
||||
t.Fatalf("setNotify({}) reset: %v", err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if _, still := obj["notify"]; still {
|
||||
t.Fatalf("reset left a notify member behind: %s", string(raw))
|
||||
}
|
||||
if obj["activeProvider"] == nil || string(obj["activeProvider"]) != `"openai"` {
|
||||
t.Errorf("unrelated member lost through the reset: %s", string(raw))
|
||||
}
|
||||
|
||||
// A reset on a file with NO notify member is a no-op, and a partial update
|
||||
// against duplicates replaces the LAST member (the effective one under
|
||||
// last-occurrence-wins decoding).
|
||||
if err := os.WriteFile(path, []byte(duplicated), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := setNotify(path, NotifyConfig{Mode: "off", FocusMode: "always"}); err != nil {
|
||||
t.Fatalf("setNotify replace: %v", err)
|
||||
}
|
||||
raw, err = os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, err := UserNotify(path)
|
||||
if err != nil {
|
||||
t.Fatalf("UserNotify: %v", err)
|
||||
}
|
||||
if stored.Mode != "off" || stored.FocusMode != "always" {
|
||||
t.Fatalf("after replace, effective notify = %+v, want off/always (last member replaced)", stored)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"activeProvider"`) {
|
||||
t.Errorf("unrelated member lost through the replace: %s", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainer regression (PR #1001): concurrent partial updates must not lose
|
||||
// each other's explicit change. Two `zero config notify` calls (--mode off and
|
||||
// --focus always) racing from both/unfocused previously interleaved their
|
||||
// read-merge-write and one write silently undid the other (reproduced 50/50
|
||||
// without the lock). UpdateNotify serializes the whole transaction.
|
||||
func TestUpdateNotifyConcurrentPartialUpdatesLoseNoField(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
if err := os.WriteFile(path, []byte(`{"notify":{"mode":"both","focusMode":"unfocused"}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 2)
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := UpdateNotify(path, func(current NotifyConfig) NotifyConfig {
|
||||
current.Mode = "off"
|
||||
return current
|
||||
}); err != nil {
|
||||
errs <- fmt.Errorf("mode update: %w", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := UpdateNotify(path, func(current NotifyConfig) NotifyConfig {
|
||||
current.FocusMode = "always"
|
||||
return current
|
||||
}); err != nil {
|
||||
errs <- fmt.Errorf("focus update: %w", err)
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatalf("concurrent update failed: %v", err)
|
||||
}
|
||||
|
||||
stored, err := UserNotify(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read stored: %v", err)
|
||||
}
|
||||
// BOTH explicit changes must survive: mode from writer 1, focus from
|
||||
// writer 2. The old race let the second writer's stale focus
|
||||
// ("unfocused") or stale mode ("both") win.
|
||||
if stored.Mode != "off" {
|
||||
t.Errorf("mode = %q, want off (concurrent focus update must not clobber it)", stored.Mode)
|
||||
}
|
||||
if stored.FocusMode != "always" {
|
||||
t.Errorf("focusMode = %q, want always (concurrent mode update must not clobber it)", stored.FocusMode)
|
||||
}
|
||||
}
|
||||
|
||||
// UserNotify reads the notify block from the user's own file. Partial updates
|
||||
// seed from this value so they preserve what the USER chose (blank included)
|
||||
// instead of copying a project config's setting or a pinned default into the
|
||||
// global file (maintainer review, PR #1001).
|
||||
func TestUserNotify(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Missing file: zero value, no error.
|
||||
got, err := UserNotify(filepath.Join(dir, "missing.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing file should not error: %v", err)
|
||||
}
|
||||
if got.Mode != "" || got.FocusMode != "" {
|
||||
t.Fatalf("missing file = %+v, want zero value", got)
|
||||
}
|
||||
|
||||
// Present block: trimmed values returned.
|
||||
path := filepath.Join(dir, "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{Notify: NotifyConfig{Mode: " bell ", FocusMode: " always "}}, 0o600)
|
||||
got, err = UserNotify(path)
|
||||
if err != nil {
|
||||
t.Fatalf("UserNotify: %v", err)
|
||||
}
|
||||
if got.Mode != "bell" || got.FocusMode != "always" {
|
||||
t.Fatalf("UserNotify = %+v, want bell/always (trimmed)", got)
|
||||
}
|
||||
|
||||
// Blank path: zero value, no error.
|
||||
if got, err = UserNotify(""); err != nil || got.Mode != "" || got.FocusMode != "" {
|
||||
t.Fatalf("blank path = %+v err=%v, want zero value", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecapsPreferenceRoundTrips(t *testing.T) {
|
||||
// Default (unset) is ON.
|
||||
if !(PreferencesConfig{}).RecapsEnabled() {
|
||||
@@ -1078,3 +1360,109 @@ func TestEditProviderRejectsCollisionAndUnknown(t *testing.T) {
|
||||
t.Fatalf("config was rewritten by a rejected edit")
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainer regression (PR #1001): notify writes must contend with EXISTING
|
||||
// config mutators, not only each other. UpdateNotify originally took a private
|
||||
// lock (acquireConfigLock); on Windows its LockFileEx range did not overlap
|
||||
// lockutil's, so `zero config notify --mode off` could race a theme (or
|
||||
// provider/credential/MCP) write and silently discard it — the later rename
|
||||
// wins and the other acknowledged change is gone. UpdateNotify now uses the
|
||||
// same lockConfigFileFn authority as every other mutator; this race holds it
|
||||
// against SetTheme and requires BOTH acknowledged edits to survive.
|
||||
func TestUpdateNotifyRacesExistingConfigMutatorWithoutLoss(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
if err := os.WriteFile(path, []byte(`{"notify":{"mode":"both","focusMode":"unfocused"}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 2)
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := UpdateNotify(path, func(NotifyConfig) NotifyConfig {
|
||||
return NotifyConfig{Mode: "off", FocusMode: "always"}
|
||||
}); err != nil {
|
||||
errs <- fmt.Errorf("notify write: %w", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := SetTheme(path, "dracula"); err != nil {
|
||||
errs <- fmt.Errorf("theme write: %w", err)
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := readConfigFixture(t, path)
|
||||
if cfg.Notify.Mode != "off" || cfg.Notify.FocusMode != "always" {
|
||||
t.Errorf("notify lost the race: %+v, want off/always — the two writers did not contend on one lock", cfg.Notify)
|
||||
}
|
||||
if cfg.Preferences.Theme != "dracula" {
|
||||
t.Errorf("theme lost the race: %q, want dracula — the two writers did not contend on one lock", cfg.Preferences.Theme)
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainer regression (PR #1001, jatmn review): Go's decoder MERGES the
|
||||
// fields of duplicate JSON members into one struct, so a full replacement that
|
||||
// omits a field could inherit it from an earlier duplicate —
|
||||
// {"notify":{"mode":"off"},"notify":{"focusMode":"focused"}} +
|
||||
// setNotify({FocusMode:"always"}) must yield blank/always, not off/always.
|
||||
// The editor removes every duplicate and inserts the replacement fresh.
|
||||
func TestSetNotifyReplacementInheritsNoFieldFromEarlierDuplicate(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
duplicated := `{"notify":{"mode":"off"},"notify":{"focusMode":"focused"},"activeProvider":"openai"}`
|
||||
if err := os.WriteFile(path, []byte(duplicated), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := setNotify(path, NotifyConfig{FocusMode: "always"}); err != nil {
|
||||
t.Fatalf("setNotify: %v", err)
|
||||
}
|
||||
stored, err := UserNotify(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.Mode != "" || stored.FocusMode != "always" {
|
||||
t.Fatalf("replacement inherited a field from an earlier duplicate: %+v, want blank/always", stored)
|
||||
}
|
||||
|
||||
// Unrelated bytes still survive the rewrite.
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"activeProvider":"openai"`) {
|
||||
t.Fatalf("unrelated member lost through the replace: %s", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// Maintainer regression (PR #1001, Vasanthdev2004 review): the duplicate-key
|
||||
// fix must not reach the ORDINARY single-member file. Removing and re-inserting
|
||||
// the notify member there moved the key to the end of the object and collapsed
|
||||
// its formatting, so every /notify toggle reordered the user's config — JSON
|
||||
// member order carries no meaning, but a byte-preserving write should not
|
||||
// reformat anything it does not have to. A single notify member is replaced
|
||||
// in place: key position and surrounding formatting unchanged.
|
||||
func TestSetNotifySingleMemberReplacedInPlace(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
original := "{\n \"activeProvider\": \"openai\",\n \"notify\": {\"mode\": \"both\", \"focusMode\": \"unfocused\"},\n \"preferences\": {\"theme\": \"dracula\"}\n}\n"
|
||||
if err := os.WriteFile(path, []byte(original), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := setNotify(path, NotifyConfig{Mode: "off", FocusMode: "always"}); err != nil {
|
||||
t.Fatalf("setNotify: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "{\n \"activeProvider\": \"openai\",\n \"notify\": {\"mode\":\"off\",\"focusMode\":\"always\"},\n \"preferences\": {\"theme\": \"dracula\"}\n}\n"
|
||||
if string(raw) != want {
|
||||
t.Fatalf("single-member replace moved or reformatted unrelated bytes:\n got: %q\nwant: %q", string(raw), want)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,61 @@
|
||||
package cron
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/fsutil"
|
||||
)
|
||||
|
||||
func TestAppendRunRetainsNewestThousand(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job, err := store.Add(Job{Expr: "* * * * *", Prompt: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Seed an oversized legacy log, then cross the boundary again after compaction.
|
||||
var history bytes.Buffer
|
||||
for i := 0; i < 1207; i++ {
|
||||
if err := json.NewEncoder(&history).Encode(RunRecord{ExitCode: i}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
|
||||
if err := os.WriteFile(path, history.Bytes(), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, next := range []int{1207, 1208} {
|
||||
if err := store.AppendRun(job.ID, RunRecord{ExitCode: next}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := bytes.Split(bytes.TrimSpace(data), []byte("\n"))
|
||||
if len(lines) != 1000 {
|
||||
t.Fatalf("retained %d records, want 1000", len(lines))
|
||||
}
|
||||
for i, line := range lines {
|
||||
var rec RunRecord
|
||||
if err := json.Unmarshal(line, &rec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.ExitCode != next-999+i {
|
||||
t.Fatalf("record %d = %d, want %d", i, rec.ExitCode, next-999+i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AppendRun on a job removed mid-run must NOT resurrect its directory (which the
|
||||
// old unconditional MkdirAll did, leaving an orphaned runs.jsonl with no
|
||||
// metadata.json).
|
||||
@@ -24,7 +75,7 @@ func TestAppendRunDoesNotResurrectRemovedJob(t *testing.T) {
|
||||
if _, err := os.Stat(store.jobDir(job.ID)); !os.IsNotExist(err) {
|
||||
t.Fatalf("AppendRun resurrected the removed job directory (stat err = %v)", err)
|
||||
}
|
||||
runs, err := store.Runs(job.ID)
|
||||
runs, err := store.Runs(job.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Runs: %v", err)
|
||||
}
|
||||
@@ -32,3 +83,256 @@ func TestAppendRunDoesNotResurrectRemovedJob(t *testing.T) {
|
||||
t.Fatalf("expected no runs for a removed job, got %d", len(runs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunsTail(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job, err := store.Add(Job{Prompt: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var history bytes.Buffer
|
||||
// A forward scan would fail on this old oversized line. A recent-window
|
||||
// reader must stop before reaching it, rather than merely cap its output.
|
||||
history.WriteString(strings.Repeat("x", 1024*1024+1) + "\n")
|
||||
for i := 0; i < 1103; i++ {
|
||||
if err := json.NewEncoder(&history).Encode(RunRecord{ExitCode: i, SessionTitle: strings.Repeat("界", 1500)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
history.WriteString("bad json\n\n{\"exitCode\":1103}") // no final newline
|
||||
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
|
||||
if err := os.WriteFile(path, history.Bytes(), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, limit := range []int{1, 7, 999, 1000, 1001, 0, -1} {
|
||||
runs, err := store.Runs(job.ID, limit)
|
||||
want := limit
|
||||
if want <= 0 || want > 1000 {
|
||||
want = 1000
|
||||
}
|
||||
if err != nil || len(runs) != want {
|
||||
t.Fatalf("limit %d: got %d records, err %v", limit, len(runs), err)
|
||||
}
|
||||
for i, run := range runs {
|
||||
if run.ExitCode != 1104-want+i {
|
||||
t.Fatalf("limit %d, record %d = %d", limit, i, run.ExitCode)
|
||||
}
|
||||
if run.ExitCode != 1103 && run.SessionTitle != strings.Repeat("界", 1500) {
|
||||
t.Fatal("record spanning read blocks was corrupted")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendRunFailurePreservesHistory(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job, err := store.Add(Job{Prompt: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
|
||||
for _, data := range []string{"{\"exitCode\":7}\n", strings.Repeat("x", 1024*1024)} {
|
||||
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := RunRecord{ExitCode: 8, Error: strings.Repeat("x", 1024*1024)}
|
||||
if err := store.AppendRun(job.ID, rec); err == nil {
|
||||
t.Fatal("expected oversized new record error")
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil || string(got) != data {
|
||||
t.Fatalf("failed append changed history: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendRunWithOversizedHistory(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job, err := store.Add(Job{Prompt: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
|
||||
// An oversized unterminated legacy record must not block future outcomes.
|
||||
if err := os.WriteFile(path, []byte(strings.Repeat("x", maxRunBytes)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.AppendRun(job.ID, RunRecord{ExitCode: 42}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runs, err := store.Runs(job.ID, 1)
|
||||
if err != nil || len(runs) != 1 || runs[0].ExitCode != 42 {
|
||||
t.Fatalf("new outcome lost: %+v, %v", runs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunsSkipOversizedLines(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job, err := store.Add(Job{Prompt: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
|
||||
for _, size := range []int{maxRunBytes - 1, maxRunBytes, maxRunBytes + 4096} {
|
||||
// Valid JSON plus trailing whitespace catches accidental decoding of a
|
||||
// prefix after dropping only the oversized suffix. The exact boundary
|
||||
// also distinguishes an oversized line from the largest allowed line.
|
||||
line := `{"exitCode":99}`
|
||||
line += strings.Repeat(" ", size-len(line))
|
||||
data := line + "\n{\"exitCode\":7}\n" + line + "\n{\"exitCode\":42}\n" + line
|
||||
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runs, err := store.Runs(job.ID, 10)
|
||||
want := []int{7, 42}
|
||||
if size < maxRunBytes {
|
||||
want = []int{99, 7, 99, 42, 99}
|
||||
}
|
||||
if err != nil || len(runs) != len(want) {
|
||||
t.Fatalf("size %d: got %+v, err %v; want %v", size, runs, err, want)
|
||||
}
|
||||
for i, rec := range runs {
|
||||
if rec.ExitCode != want[i] {
|
||||
t.Fatalf("size %d: record %d = %d, want %d", size, i, rec.ExitCode, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An outside reader holding runs.jsonl open makes the Windows replace fail;
|
||||
// the outcome must still be recorded rather than dropped.
|
||||
func TestAppendRunWhileHistoryHeldOpen(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job, err := store.Add(Job{Prompt: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
|
||||
if err := os.WriteFile(path, []byte("{\"exitCode\":7}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
held, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer held.Close()
|
||||
if err := store.AppendRun(job.ID, RunRecord{ExitCode: 42}); err != nil {
|
||||
t.Fatalf("AppendRun with history held open: %v", err)
|
||||
}
|
||||
runs, err := store.Runs(job.ID, 10)
|
||||
if err != nil || len(runs) != 2 || runs[0].ExitCode != 7 || runs[1].ExitCode != 42 {
|
||||
t.Fatalf("outcome lost while history held open: %+v, %v", runs, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Only a sharing or lock violation falls back to appending. Any other replace
|
||||
// failure, including a persistent access denial, and a committed replacement
|
||||
// whose backup cleanup failed must leave the log untouched, so history can
|
||||
// neither grow past retention nor record an outcome twice.
|
||||
func TestAppendRunFallbackOnlyForSharingViolation(t *testing.T) {
|
||||
const seed = "{\"exitCode\":7}\n"
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
append bool
|
||||
}{
|
||||
{"sharing violation", syscall.Errno(32), runtime.GOOS == "windows"},
|
||||
{"access denied", os.ErrPermission, false},
|
||||
{"other", errors.New("boom"), false},
|
||||
{"committed", &fsutil.CommittedReplacementCleanupError{Cause: syscall.Errno(32)}, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job, err := store.Add(Job{Prompt: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
|
||||
if err := os.WriteFile(path, []byte(seed), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.replace = func(string, string) error { return tc.err }
|
||||
err = store.AppendRun(job.ID, RunRecord{ExitCode: 42})
|
||||
data, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if tc.append {
|
||||
if err != nil || !strings.HasPrefix(string(data), seed) || !strings.Contains(string(data), "\"exitCode\":42") {
|
||||
t.Fatalf("sharing violation dropped the outcome: err %v, log %q", err, data)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, tc.err) {
|
||||
t.Fatalf("AppendRun error = %v, want %v", err, tc.err)
|
||||
}
|
||||
if string(data) != seed {
|
||||
t.Fatalf("non-transient replace failure changed the log: %q", data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The fallback append must start a fresh line even when the log ends in an
|
||||
// unterminated record, so neither record is corrupted.
|
||||
func TestAppendRunLineAfterUnterminatedTail(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job, err := store.Add(Job{Prompt: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
|
||||
if err := os.WriteFile(path, []byte("{\"exitCode\":7}"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := appendRunLine(path, []byte("{\"exitCode\":42}")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runs, err := store.Runs(job.ID, 10)
|
||||
if err != nil || len(runs) != 2 || runs[0].ExitCode != 7 || runs[1].ExitCode != 42 {
|
||||
t.Fatalf("fallback append corrupted history: %+v, %v", runs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHistoryConcurrentStores(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job, err := store.Add(Job{Prompt: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var history bytes.Buffer
|
||||
for i := 0; i < 1000; i++ {
|
||||
if err := json.NewEncoder(&history).Encode(RunRecord{ExitCode: -1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(store.jobDir(job.ID), "runs.jsonl"), history.Bytes(), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 12; i++ {
|
||||
wg.Go(func() {
|
||||
other := NewStore(StoreOptions{RootDir: store.root})
|
||||
if err := other.AppendRun(job.ID, RunRecord{ExitCode: i}); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
runs, err := other.Runs(job.ID, 1000)
|
||||
if err != nil || len(runs) != 1000 {
|
||||
t.Errorf("inconsistent history: %d records, err %v", len(runs), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
runs, err := store.Runs(job.ID, 12)
|
||||
if err != nil || len(runs) != 12 {
|
||||
t.Fatalf("final tail: %d records, err %v", len(runs), err)
|
||||
}
|
||||
seen := make(map[int]bool)
|
||||
for _, run := range runs {
|
||||
if run.ExitCode < 0 || run.ExitCode >= 12 || seen[run.ExitCode] {
|
||||
t.Fatalf("lost or duplicate concurrent append: %+v", runs)
|
||||
}
|
||||
seen[run.ExitCode] = true
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -16,6 +17,10 @@ import (
|
||||
const (
|
||||
StatusActive = "active"
|
||||
StatusPaused = "paused"
|
||||
|
||||
// MaxRunHistory is the number of recent outcomes retained per job.
|
||||
MaxRunHistory = 1000
|
||||
maxRunBytes = 1024 * 1024
|
||||
)
|
||||
|
||||
// ErrJobNotFound is returned (wrapped) by Get when a job's metadata file is
|
||||
@@ -56,6 +61,9 @@ type StoreOptions struct {
|
||||
type Store struct {
|
||||
root string
|
||||
now func() time.Time
|
||||
// replace overrides the run-history replacement primitive in tests; nil
|
||||
// uses the platform default.
|
||||
replace func(src, dst string) error
|
||||
}
|
||||
|
||||
func NewStore(opts StoreOptions) *Store {
|
||||
@@ -228,8 +236,7 @@ func (s *Store) Update(job Job) error {
|
||||
// Remove). It re-reads the CURRENT on-disk job and passes it to mutate along with
|
||||
// any read error; mutate returns the job to persist. A removed job aborts with
|
||||
// ErrJobNotFound (no recreate). A transient read error (not removal) is surfaced
|
||||
// via readErr so the caller can still persist a best-effort state — the fire path
|
||||
// advances the schedule regardless, to avoid a re-fire.
|
||||
// via readErr; the callback can return it to abort without overwriting state.
|
||||
func (s *Store) Mutate(id string, mutate func(current Job, readErr error) (Job, error)) (Job, error) {
|
||||
if !validID(id) {
|
||||
return Job{}, fmt.Errorf("invalid cron job id %q", id)
|
||||
@@ -309,10 +316,8 @@ func (s *Store) AppendRun(id string, rec RunRecord) error {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
// Bail if the job was removed (e.g. mid-run) — otherwise the MkdirAll below
|
||||
// would resurrect a deleted job's directory with an orphaned runs.jsonl and no
|
||||
// metadata.json (which Runs would then still return). Under the per-job lock
|
||||
// this stat-then-write is race-free against a concurrent Remove.
|
||||
// Do not resurrect a job removed mid-run. The lock serializes this check
|
||||
// and publication against Remove and other history writers/readers.
|
||||
if _, err := os.Stat(filepath.Join(s.jobDir(id), "metadata.json")); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
@@ -320,30 +325,98 @@ func (s *Store) AppendRun(id string, rec RunRecord) error {
|
||||
return err
|
||||
}
|
||||
dir := s.jobDir(id)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(filepath.Join(dir, "runs.jsonl"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
line, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(line) >= maxRunBytes {
|
||||
return fmt.Errorf("cron run record exceeds %d bytes", maxRunBytes-1)
|
||||
}
|
||||
runs, err := s.readRuns(id, MaxRunHistory-1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Publish a complete snapshot even below the retention boundary so readers
|
||||
// never see a partial append. Legacy oversized logs compact on their next run.
|
||||
f, err := os.CreateTemp(dir, "runs-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
w := bufio.NewWriter(f)
|
||||
for _, run := range runs {
|
||||
if err := json.NewEncoder(w).Encode(run); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := w.Write(append(line, '\n')); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := f.Write(append(line, '\n')); err != nil {
|
||||
if err := w.Flush(); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
// Surface a buffered-write failure that only materializes on Close.
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(dir, "runs.jsonl")
|
||||
// ReplaceWithRetry keeps a DACL applied to runs.jsonl itself; a rename would
|
||||
// publish the temporary file's inherited directory DACL instead.
|
||||
err = fsutil.ReplaceWithRetry(f.Name(), path, s.replace)
|
||||
var committed *fsutil.CommittedReplacementCleanupError
|
||||
if err == nil || errors.As(err, &committed) || !fsutil.IsSharingOrLockViolation(err) {
|
||||
return err
|
||||
}
|
||||
// Windows refuses to replace a log another process holds open. Append the
|
||||
// record instead so the outcome survives; compaction resumes on a later run.
|
||||
// Other failures are returned so a log that can never be replaced cannot
|
||||
// grow past the retention limit. The leading newline ends any unterminated
|
||||
// tail, and readRuns skips the blank line it may leave. Zero's own readers
|
||||
// hold the job lock.
|
||||
if appendErr := appendRunLine(path, line); appendErr != nil {
|
||||
return errors.Join(err, appendErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendRunLine(path string, line []byte) error {
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
record := append(append([]byte{'\n'}, line...), '\n')
|
||||
if _, err := f.Write(record); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
func (s *Store) Runs(id string) ([]RunRecord, error) {
|
||||
// Runs returns the newest limit valid records in append order (oldest first).
|
||||
// Non-positive limits and limits above MaxRunHistory use MaxRunHistory. It reads
|
||||
// backwards from the tail, including for legacy logs not yet compacted.
|
||||
func (s *Store) Runs(id string, limit int) ([]RunRecord, error) {
|
||||
if !validID(id) {
|
||||
return nil, fmt.Errorf("invalid cron job id %q", id)
|
||||
}
|
||||
if limit <= 0 || limit > MaxRunHistory {
|
||||
limit = MaxRunHistory
|
||||
}
|
||||
unlock, err := s.lockJob(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer unlock()
|
||||
return s.readRuns(id, limit)
|
||||
}
|
||||
|
||||
// readRuns requires the job lock. Memory is bounded by limit records plus one
|
||||
// line. Malformed and oversized legacy lines are skipped so they cannot prevent
|
||||
// recording new outcomes during compaction.
|
||||
func (s *Store) readRuns(id string, limit int) ([]RunRecord, error) {
|
||||
f, err := os.Open(filepath.Join(s.jobDir(id), "runs.jsonl"))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
@@ -352,14 +425,48 @@ func (s *Store) Runs(id string) ([]RunRecord, error) {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var runs []RunRecord
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
var line []byte
|
||||
oversized := false
|
||||
decode := func() {
|
||||
slices.Reverse(line)
|
||||
var rec RunRecord
|
||||
if json.Unmarshal(scanner.Bytes(), &rec) == nil {
|
||||
if json.Unmarshal(line, &rec) == nil {
|
||||
runs = append(runs, rec)
|
||||
}
|
||||
line = line[:0]
|
||||
}
|
||||
return runs, scanner.Err()
|
||||
var block [4096]byte
|
||||
for end := info.Size(); end > 0 && len(runs) < limit; {
|
||||
start := max(int64(0), end-int64(len(block)))
|
||||
n, err := f.ReadAt(block[:end-start], start)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := n - 1; i >= 0 && len(runs) < limit; i-- {
|
||||
if block[i] == '\n' {
|
||||
if !oversized {
|
||||
decode()
|
||||
}
|
||||
oversized = false
|
||||
} else if !oversized {
|
||||
if len(line) >= maxRunBytes-1 {
|
||||
line = line[:0]
|
||||
oversized = true
|
||||
continue
|
||||
}
|
||||
line = append(line, block[i])
|
||||
}
|
||||
}
|
||||
end = start
|
||||
}
|
||||
if len(runs) < limit && len(line) > 0 {
|
||||
decode()
|
||||
}
|
||||
slices.Reverse(runs)
|
||||
return runs, nil
|
||||
}
|
||||
@@ -238,7 +238,7 @@ func TestStoreAppendRun(t *testing.T) {
|
||||
t.Fatalf("AppendRun: %v", err)
|
||||
}
|
||||
}
|
||||
runs, err := s.Runs(job.ID)
|
||||
runs, err := s.Runs(job.ID, 0)
|
||||
if err != nil || len(runs) != 3 || runs[2].ExitCode != 2 {
|
||||
t.Fatalf("Runs=%v err=%v", runs, err)
|
||||
}
|
||||
@@ -280,7 +280,7 @@ func TestStoreRejectsUnsafeID(t *testing.T) {
|
||||
if _, err := s.Get(id); err == nil {
|
||||
t.Fatalf("Get(%q) must be rejected", id)
|
||||
}
|
||||
if _, err := s.Runs(id); err == nil {
|
||||
if _, err := s.Runs(id, 0); err == nil {
|
||||
t.Fatalf("Runs(%q) must be rejected", id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
//go:build windows
|
||||
|
||||
package cron
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// Compacting runs.jsonl publishes a temporary file carrying the job directory's
|
||||
// inherited DACL. A rename would replace a restrictive DACL applied to the log
|
||||
// itself, so AppendRun must keep the log's own descriptor.
|
||||
func TestAppendRunPreservesHistoryDACL(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
job, err := store.Add(Job{Prompt: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(store.jobDir(job.ID), "runs.jsonl")
|
||||
if err := os.WriteFile(path, []byte("{\"exitCode\":7}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A protected DACL granting only the owner: distinct from whatever the
|
||||
// temporary file inherits from the directory.
|
||||
restricted, err := windows.SecurityDescriptorFromString("D:P(A;;FA;;;OW)")
|
||||
if err != nil {
|
||||
t.Skipf("cannot build a test security descriptor: %v", err)
|
||||
}
|
||||
dacl, _, err := restricted.DACL()
|
||||
if err != nil {
|
||||
t.Skipf("cannot read the test DACL: %v", err)
|
||||
}
|
||||
if err := windows.SetNamedSecurityInfo(
|
||||
path,
|
||||
windows.SE_FILE_OBJECT,
|
||||
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
|
||||
nil, nil, dacl, nil,
|
||||
); err != nil {
|
||||
t.Skipf("cannot apply a restrictive DACL on this filesystem: %v", err)
|
||||
}
|
||||
want := describeDACL(t, path)
|
||||
if inherited := describeDACL(t, store.jobDir(job.ID)); strings.Contains(inherited, want) {
|
||||
t.Skip("the job directory already carries the same DACL; this filesystem cannot show the difference")
|
||||
}
|
||||
|
||||
if err := store.AppendRun(job.ID, RunRecord{ExitCode: 42}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := describeDACL(t, path); got != want {
|
||||
t.Fatalf("DACL after AppendRun = %q, want the log's own %q", got, want)
|
||||
}
|
||||
runs, err := store.Runs(job.ID, 10)
|
||||
if err != nil || len(runs) != 2 || runs[1].ExitCode != 42 {
|
||||
t.Fatalf("outcome not published by replacement: %+v, %v", runs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func describeDACL(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
t.Skipf("cannot read the security descriptor of %s: %v", path, err)
|
||||
}
|
||||
text := sd.String()
|
||||
if index := strings.Index(text, "D:"); index >= 0 {
|
||||
return text[index:]
|
||||
}
|
||||
return text
|
||||
}
|
||||
@@ -72,9 +72,10 @@ func (w *execWorker) Kill() error {
|
||||
if w.cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
// background.TerminateProcess is the cross-platform terminate (kills the
|
||||
// process group on POSIX, taskkill /T on Windows).
|
||||
return background.TerminateProcess(w.cmd.Process.Pid)
|
||||
// The group the worker was launched into, not one rediscovered from its PID:
|
||||
// the pool may call this after the worker has exited and been waited, and its
|
||||
// children can still be running. See background.TerminateCommandGroup.
|
||||
return background.TerminateCommandGroup(w.cmd)
|
||||
}
|
||||
|
||||
// readerLines adapts a bufio.Reader to the Lines interface. Unlike a capped
|
||||
@@ -154,13 +155,11 @@ func NewExecLauncher(cfg ExecLauncherConfig) (Launcher, error) {
|
||||
background.ConfigureChildProcessGroup(cmd)
|
||||
// CommandContext's default cancel sends os.Process.Kill to the LEADER only,
|
||||
// orphaning the process group we just configured (a stuck worker's children
|
||||
// would survive ctx cancellation). Terminate the whole group instead — the
|
||||
// same cross-platform group terminate Kill() uses (D11).
|
||||
// would survive ctx cancellation). Terminate the whole group instead, the
|
||||
// same launch-time group terminate Kill() uses (D11). Cancel runs while Wait
|
||||
// is in progress, so it must not reap.
|
||||
cmd.Cancel = func() error {
|
||||
if cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
return background.TerminateProcess(cmd.Process.Pid)
|
||||
return background.TerminateCommandGroup(cmd)
|
||||
}
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A WORKER'S CHILDREN ARE STOPPED THROUGH THE GROUP IT WAS LAUNCHED INTO.
|
||||
//
|
||||
// Kill and the Cancel hook used to hand the worker's PID to
|
||||
// background.TerminateProcess, which looks the group up again with Getpgid when it
|
||||
// signals. Once the worker has exited and been waited, that lookup fails, only the
|
||||
// dead PID is signalled, and whatever the worker left running survives; on Darwin
|
||||
// the lookup already fails while the exited worker is still a zombie. The worker
|
||||
// here is a shell that backgrounds a sleep, prints its PID and exits, so after the
|
||||
// wait the sleep is the only process left in the worker's group. Reported in #861.
|
||||
func TestExecWorkerStopsWhatItLeftRunningAfterItExits(t *testing.T) {
|
||||
for _, stop := range []struct {
|
||||
name string
|
||||
run func(*execWorker) error
|
||||
}{
|
||||
{"Kill", func(worker *execWorker) error { return worker.Kill() }},
|
||||
{"Cancel hook", func(worker *execWorker) error { return worker.cmd.Cancel() }},
|
||||
} {
|
||||
t.Run(stop.name, func(t *testing.T) {
|
||||
launcher, err := NewExecLauncher(ExecLauncherConfig{
|
||||
Executable: "/bin/sh",
|
||||
BaseArgs: []string{"-c", "sleep 300 >/dev/null 2>&1 & echo $!"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewExecLauncher: %v", err)
|
||||
}
|
||||
handle, err := launcher(context.Background(), WorkerSpec{})
|
||||
if err != nil {
|
||||
t.Fatalf("launch: %v", err)
|
||||
}
|
||||
worker, ok := handle.(*execWorker)
|
||||
if !ok {
|
||||
t.Fatalf("SETUP INVALID: the launcher returned %T, not the exec worker under test", handle)
|
||||
}
|
||||
line, ok, err := worker.Stdout().Next()
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("read the child's pid: ok=%v err=%v", ok, err)
|
||||
}
|
||||
childPID, err := strconv.Atoi(strings.TrimSpace(line))
|
||||
if err != nil {
|
||||
t.Fatalf("parse the child's pid %q: %v", line, err)
|
||||
}
|
||||
t.Cleanup(func() { _ = syscall.Kill(childPID, syscall.SIGKILL) })
|
||||
|
||||
if _, err := worker.Wait(); err != nil {
|
||||
t.Fatalf("wait for the worker: %v", err)
|
||||
}
|
||||
if childStopped(childPID) {
|
||||
t.Fatalf("SETUP INVALID: the worker's child %d is not running, so there is nothing left to stop", childPID)
|
||||
}
|
||||
|
||||
// The pool ignores what Kill returns; the outcome is what matters.
|
||||
_ = stop.run(worker)
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for !childStopped(childPID) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("the worker's child %d survived %s after the worker was reaped", childPID, stop.name)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// childStopped reports whether pid is gone or only waiting to be reaped.
|
||||
func childStopped(pid int) bool {
|
||||
if errors.Is(syscall.Kill(pid, syscall.Signal(0)), syscall.ESRCH) {
|
||||
return true
|
||||
}
|
||||
state, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output()
|
||||
if err != nil {
|
||||
return errors.Is(syscall.Kill(pid, syscall.Signal(0)), syscall.ESRCH)
|
||||
}
|
||||
return strings.HasPrefix(strings.TrimSpace(string(state)), "Z")
|
||||
}
|
||||
@@ -30,15 +30,35 @@ var processAlive = osProcessAlive
|
||||
// only to enrich a contention error with the current holder's PID; the kernel
|
||||
// lock, not PID metadata, is authoritative.
|
||||
func acquireLock(path string, isAlive func(pid int) bool) (*fileLock, error) {
|
||||
return acquireLockWith(isAlive, func() (*lockutil.FileLock, error) {
|
||||
return lockutil.TryAcquireFileLock(path)
|
||||
}, func() (int, error) {
|
||||
return readPidFile(path)
|
||||
})
|
||||
}
|
||||
|
||||
func acquireLockRoot(root *os.Root, name, displayPath string, isAlive func(pid int) bool) (*fileLock, error) {
|
||||
return acquireLockWith(isAlive, func() (*lockutil.FileLock, error) {
|
||||
return lockutil.TryAcquireFileLockRoot(root, name, displayPath)
|
||||
}, func() (int, error) {
|
||||
return readPidFileRoot(root, name)
|
||||
})
|
||||
}
|
||||
|
||||
func acquireLockWith(
|
||||
isAlive func(pid int) bool,
|
||||
acquire func() (*lockutil.FileLock, error),
|
||||
readPID func() (int, error),
|
||||
) (*fileLock, error) {
|
||||
if isAlive == nil {
|
||||
isAlive = processAlive
|
||||
}
|
||||
lock, err := lockutil.TryAcquireFileLock(path)
|
||||
lock, err := acquire()
|
||||
if err != nil {
|
||||
if !errors.Is(err, lockutil.ErrLockHeld) {
|
||||
return nil, err
|
||||
}
|
||||
pid, perr := readPidFile(path)
|
||||
pid, perr := readPID()
|
||||
if perr == nil && pid > 0 && isAlive(pid) {
|
||||
return nil, fmt.Errorf("%w (pid %d)", ErrAlreadyRunning, pid)
|
||||
}
|
||||
@@ -62,6 +82,15 @@ func (l *fileLock) release() error {
|
||||
// readPidFile reads and parses the PID recorded in a lock file.
|
||||
func readPidFile(path string) (int, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
return parsePidFile(data, err)
|
||||
}
|
||||
|
||||
func readPidFileRoot(root *os.Root, name string) (int, error) {
|
||||
data, err := root.ReadFile(name)
|
||||
return parsePidFile(data, err)
|
||||
}
|
||||
|
||||
func parsePidFile(data []byte, err error) (int, error) {
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -17,6 +17,28 @@ const (
|
||||
ExitPermanent = 76
|
||||
)
|
||||
|
||||
// Exit codes `zero exec` (internal/cli/exec.go) uses to report a run that
|
||||
// finished, or was stopped on purpose, rather than crashed. They are duplicated
|
||||
// here because the daemon cannot import the CLI package; keep them in sync.
|
||||
const (
|
||||
exitUsage = 2 // bad flags or arguments: a retry fails the same way
|
||||
exitProvider = 3 // provider failure the run already surfaced
|
||||
exitIncomplete = 4 // the agent ran (edits, commands) and stopped unfinished
|
||||
exitInterrupted = 130 // SIGINT: someone stopped it on purpose
|
||||
)
|
||||
|
||||
// isFinalExit reports whether a worker exit code means the run is over and must
|
||||
// not be retried. Retrying would repeat the agent's side effects and re-bill the
|
||||
// provider, and append a second run to the same session stream. Only crash-type
|
||||
// exits (1, signals) and launch/read errors are worth another attempt.
|
||||
func isFinalExit(code int) bool {
|
||||
switch code {
|
||||
case exitUsage, exitProvider, exitIncomplete, exitInterrupted:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// defaultPoolSize, defaultMaxAttempts and defaultKillTimeout are used when a
|
||||
// PoolOptions field is left zero.
|
||||
const (
|
||||
@@ -176,7 +198,8 @@ type Sink interface {
|
||||
// Run leases a worker slot and dispatches spec to a worker, streaming its
|
||||
// stream-json lines to sink. It is at-least-once with bounded retries: a worker
|
||||
// that crashes (non-zero, non-permanent) is retried on a fresh worker after a
|
||||
// backoff; ExitPermanent or exhausting MaxAttempts returns ErrPermanent. Run
|
||||
// backoff; ExitPermanent, a final `zero exec` exit (usage, provider, incomplete,
|
||||
// interrupted) or exhausting MaxAttempts returns ErrPermanent. Run
|
||||
// queues when all slots are busy. The returned int is the final worker exit code.
|
||||
func (p *Pool) Run(ctx context.Context, spec WorkerSpec, sink Sink) (int, error) {
|
||||
if err := p.acquire(ctx); err != nil {
|
||||
@@ -209,6 +232,9 @@ func (p *Pool) Run(ctx context.Context, spec WorkerSpec, sink Sink) (int, error)
|
||||
case code == ExitPermanent:
|
||||
p.logf("worker %d exited permanently (code=%d) — not retrying", stat.id, code)
|
||||
return code, ErrPermanent
|
||||
case isFinalExit(code):
|
||||
p.logf("worker %d finished with exit code %d — not retrying", stat.id, code)
|
||||
return code, fmt.Errorf("%w: worker %d exited code=%d", ErrPermanent, stat.id, code)
|
||||
case code == ExitTempfail:
|
||||
lastErr = fmt.Errorf("worker %d tempfail (code=%d)", stat.id, code)
|
||||
p.logf("worker %d tempfail — retry after %s", stat.id, p.opts.TempfailDelay)
|
||||
|
||||
@@ -3,10 +3,13 @@ package daemon
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/testutil"
|
||||
)
|
||||
|
||||
// --- test doubles ---------------------------------------------------------
|
||||
@@ -146,6 +149,35 @@ func TestPoolRunPermanentStopsImmediately(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A `zero exec` run that ended with usage (2), provider (3), incomplete (4) or
|
||||
// interrupted (130) is over: retrying would repeat its side effects and merge a
|
||||
// second run into the same session stream.
|
||||
func TestPoolRunFinalExitCodesDoNotRetry(t *testing.T) {
|
||||
for _, code := range []int{2, 3, 4, 130} {
|
||||
t.Run(fmt.Sprintf("exit_%d", code), func(t *testing.T) {
|
||||
launcher, calls := seqLauncher(
|
||||
&fakeWorker{pid: 1, exitCode: code, out: []string{"first run"}},
|
||||
&fakeWorker{pid: 2, exitCode: 0, out: []string{"second run"}},
|
||||
)
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher, MaxAttempts: 5, Backoff: func(int) time.Duration { return 0 }})
|
||||
sink := &collectSink{}
|
||||
got, err := pool.Run(context.Background(), WorkerSpec{Session: "s"}, sink)
|
||||
if !errors.Is(err, ErrPermanent) {
|
||||
t.Fatalf("Run err = %v, want ErrPermanent", err)
|
||||
}
|
||||
if got != code {
|
||||
t.Fatalf("Run code = %d, want the worker's exit code %d", got, code)
|
||||
}
|
||||
if *calls != 1 {
|
||||
t.Fatalf("launcher calls = %d, want 1 (no retry)", *calls)
|
||||
}
|
||||
if len(sink.lines) != 1 || sink.lines[0] != "first run" {
|
||||
t.Fatalf("sink lines = %v, want only the first run's output", sink.lines)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolRunCapExhausted(t *testing.T) {
|
||||
launcher, calls := seqLauncher(
|
||||
&fakeWorker{pid: 1, exitCode: 1},
|
||||
@@ -191,7 +223,7 @@ func TestPoolQueuesWhenFull(t *testing.T) {
|
||||
}()
|
||||
<-started
|
||||
// Wait until the first run holds the only slot.
|
||||
waitFor(t, func() bool { return pool.QueueDepth() == 1 })
|
||||
testutil.WaitFor(t, "queue depth 1", func() bool { return pool.QueueDepth() == 1 })
|
||||
|
||||
secondDone := make(chan struct{})
|
||||
go func() {
|
||||
@@ -224,7 +256,7 @@ func TestPoolDrainKillsStraggler(t *testing.T) {
|
||||
_, _ = pool.Run(context.Background(), WorkerSpec{Session: "a"}, &collectSink{})
|
||||
close(runDone)
|
||||
}()
|
||||
waitFor(t, func() bool { return pool.QueueDepth() == 1 })
|
||||
testutil.WaitFor(t, "queue depth 1", func() bool { return pool.QueueDepth() == 1 })
|
||||
|
||||
pool.Drain() // KillTimeout elapses, straggler is force-killed
|
||||
if atomic.LoadInt32(&straggler.killed) != 1 {
|
||||
@@ -242,18 +274,6 @@ func TestPoolDrainKillsStraggler(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatal("condition not met within timeout")
|
||||
}
|
||||
|
||||
func TestPoolRunSurfacesStdoutReadError(t *testing.T) {
|
||||
// A worker stdout read error must surface as a failure (not be swallowed and
|
||||
// reported as clean success), and the worker is killed so it can't block on an
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -23,6 +24,23 @@ type Server struct {
|
||||
listener net.Listener
|
||||
conns map[net.Conn]struct{} // open connections, closed on Shutdown so blocked reads return
|
||||
lock *fileLock
|
||||
// runtimeRoot is retained for the full default-daemon lifecycle. Every file
|
||||
// child is addressed relative to this capability; runtimeDir is used only to
|
||||
// verify the unavoidable AF_UNIX pathname bind still names the same object.
|
||||
runtimeRoot *os.Root
|
||||
runtimeDir string
|
||||
// socketRoot captures the directory that actually received the pathname
|
||||
// bind. It can differ from runtimeRoot only when the configured directory is
|
||||
// exchanged in the final pre-bind window; retaining it makes rollback target
|
||||
// the socket that was really created rather than re-resolving the pathname.
|
||||
socketRoot *os.Root
|
||||
socketName string
|
||||
// statusRoot binds status publication and shutdown cleanup to the same
|
||||
// directory object. statusCommitted is set only after this server publishes
|
||||
// its document, so a failed startup never removes a previous daemon's status.
|
||||
statusRoot *os.Root
|
||||
statusName string
|
||||
statusCommitted bool
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
@@ -41,6 +59,14 @@ type ServerOptions struct {
|
||||
Now func() time.Time
|
||||
Log func(string)
|
||||
isAlive func(int) bool // test hook for the single-instance lock
|
||||
// beforeStatusReplace, replaceStatusFile, and syncStatusParent are test hooks
|
||||
// for the status-file commit boundary. nil selects production behavior.
|
||||
beforeStatusReplace func()
|
||||
replaceStatusFile func(root *os.Root, src, dst string) error
|
||||
syncStatusParent func(root *os.Root) error
|
||||
afterRuntimeRootOpen func() // test hook at the default-root trust boundary
|
||||
beforeSocketBind func() // test hook before the final root verification
|
||||
afterSocketPreflight func() // test hook in the unavoidable pathname-bind window
|
||||
}
|
||||
|
||||
// NewServer validates options and builds a Server.
|
||||
@@ -80,11 +106,41 @@ func (s *Server) Serve() error {
|
||||
if err := checkSocketPathLength(s.opts.Paths.Socket); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := secureSocketParent(s.opts.Paths.Socket); err != nil {
|
||||
defaultRoot, isDefault, err := openDefaultRuntimeRoot(s.opts.Paths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lock, err := acquireLock(s.opts.Paths.Lock, s.opts.isAlive)
|
||||
if isDefault {
|
||||
s.runtimeRoot = defaultRoot
|
||||
s.runtimeDir = filepath.Dir(s.opts.Paths.Socket)
|
||||
s.statusRoot = defaultRoot
|
||||
s.statusName = filepath.Base(s.opts.Paths.Status)
|
||||
if s.opts.afterRuntimeRootOpen != nil {
|
||||
s.opts.afterRuntimeRootOpen()
|
||||
}
|
||||
if err := runtimeRootStillNamesPath(defaultRoot, s.runtimeDir); err != nil {
|
||||
s.closeRuntimeRoots()
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := secureCustomRuntimeParents(s.opts.Paths); err != nil {
|
||||
return err
|
||||
}
|
||||
statusRoot, err := openStatusRoot(s.opts.Paths.Status)
|
||||
if err != nil {
|
||||
return fmt.Errorf("daemon: open status directory: %w", err)
|
||||
}
|
||||
s.statusRoot = statusRoot
|
||||
s.statusName = filepath.Base(s.opts.Paths.Status)
|
||||
}
|
||||
var lock *fileLock
|
||||
if s.runtimeRoot != nil {
|
||||
lock, err = acquireLockRoot(s.runtimeRoot, filepath.Base(s.opts.Paths.Lock), s.opts.Paths.Lock, s.opts.isAlive)
|
||||
} else {
|
||||
lock, err = acquireLock(s.opts.Paths.Lock, s.opts.isAlive)
|
||||
}
|
||||
if err != nil {
|
||||
s.closeRuntimeRoots()
|
||||
return err
|
||||
}
|
||||
s.lock = lock
|
||||
@@ -92,7 +148,22 @@ func (s *Server) Serve() error {
|
||||
|
||||
// A leftover socket file from an unclean exit would make Listen fail with
|
||||
// "address already in use"; we hold the lock, so any socket here is stale.
|
||||
_ = os.Remove(s.opts.Paths.Socket)
|
||||
if s.runtimeRoot != nil {
|
||||
if err := s.runtimeRoot.Remove(filepath.Base(s.opts.Paths.Socket)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("daemon: remove stale control socket: %w", err)
|
||||
}
|
||||
if s.opts.beforeSocketBind != nil {
|
||||
s.opts.beforeSocketBind()
|
||||
}
|
||||
if err := runtimeRootStillNamesPath(s.runtimeRoot, s.runtimeDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.opts.afterSocketPreflight != nil {
|
||||
s.opts.afterSocketPreflight()
|
||||
}
|
||||
} else {
|
||||
_ = os.Remove(s.opts.Paths.Socket)
|
||||
}
|
||||
|
||||
listener, err := net.Listen("unix", s.opts.Paths.Socket)
|
||||
if err != nil {
|
||||
@@ -101,6 +172,22 @@ func (s *Server) Serve() error {
|
||||
s.mu.Lock()
|
||||
s.listener = listener
|
||||
s.mu.Unlock()
|
||||
if s.runtimeRoot != nil {
|
||||
boundRoot, boundName, err := captureBoundSocketRoot(s.opts.Paths.Socket)
|
||||
if err != nil {
|
||||
_ = listener.Close()
|
||||
return fmt.Errorf("daemon: capture bound control socket: %w", err)
|
||||
}
|
||||
s.socketRoot = boundRoot
|
||||
s.socketName = boundName
|
||||
if err := runtimeRootStillNamesPath(s.runtimeRoot, s.runtimeDir); err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := s.runtimeRoot.Lstat(filepath.Base(s.opts.Paths.Socket))
|
||||
if err != nil || info.Mode()&os.ModeSocket == 0 {
|
||||
return fmt.Errorf("daemon: bound control socket is outside the secured runtime directory")
|
||||
}
|
||||
}
|
||||
// If Shutdown already fired during the bind window, close now and bail so a
|
||||
// shutdown requested at startup is never lost (the accept loop would otherwise
|
||||
// block forever waiting for a connection that never comes) (D4).
|
||||
@@ -111,7 +198,7 @@ func (s *Server) Serve() error {
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
if err := hardenSocketFile(s.opts.Paths.Socket); err != nil {
|
||||
if err := s.hardenSocket(); err != nil {
|
||||
return fmt.Errorf("daemon: harden control socket: %w", err)
|
||||
}
|
||||
s.startedAt = s.opts.Now()
|
||||
@@ -190,11 +277,56 @@ func (s *Server) cleanup() {
|
||||
if s.listener != nil {
|
||||
_ = s.listener.Close()
|
||||
}
|
||||
_ = os.Remove(s.opts.Paths.Socket)
|
||||
_ = os.Remove(s.opts.Paths.Status)
|
||||
if s.socketRoot != nil {
|
||||
if err := s.socketRoot.Remove(s.socketName); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
s.logf("daemon: remove control socket: %v", err)
|
||||
}
|
||||
} else if s.runtimeRoot != nil {
|
||||
if err := s.runtimeRoot.Remove(filepath.Base(s.opts.Paths.Socket)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
s.logf("daemon: remove control socket: %v", err)
|
||||
}
|
||||
} else {
|
||||
_ = os.Remove(s.opts.Paths.Socket)
|
||||
}
|
||||
if s.statusRoot != nil {
|
||||
if s.statusCommitted {
|
||||
if err := s.statusRoot.Remove(s.statusName); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
s.logf("daemon: remove status file: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.lock != nil {
|
||||
_ = s.lock.release()
|
||||
}
|
||||
s.closeRuntimeRoots()
|
||||
}
|
||||
|
||||
func (s *Server) hardenSocket() error {
|
||||
if s.runtimeRoot != nil {
|
||||
return hardenSocketFileRoot(s.runtimeRoot, filepath.Base(s.opts.Paths.Socket))
|
||||
}
|
||||
return hardenSocketFile(s.opts.Paths.Socket)
|
||||
}
|
||||
|
||||
func (s *Server) closeRuntimeRoots() {
|
||||
if s.socketRoot != nil {
|
||||
if err := s.socketRoot.Close(); err != nil {
|
||||
s.logf("daemon: close bound socket directory: %v", err)
|
||||
}
|
||||
}
|
||||
s.socketRoot = nil
|
||||
if s.statusRoot != nil && s.statusRoot != s.runtimeRoot {
|
||||
if err := s.statusRoot.Close(); err != nil {
|
||||
s.logf("daemon: close status directory: %v", err)
|
||||
}
|
||||
}
|
||||
s.statusRoot = nil
|
||||
if s.runtimeRoot != nil {
|
||||
if err := s.runtimeRoot.Close(); err != nil {
|
||||
s.logf("daemon: close runtime directory: %v", err)
|
||||
}
|
||||
}
|
||||
s.runtimeRoot = nil
|
||||
}
|
||||
|
||||
func (s *Server) writeStatusFile() error {
|
||||
@@ -208,7 +340,30 @@ func (s *Server) writeStatusFile() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(s.opts.Paths.Status, data, 0o600); err != nil {
|
||||
root := s.statusRoot
|
||||
ownedRoot := false
|
||||
if root == nil {
|
||||
var err error
|
||||
root, err = openStatusRoot(s.opts.Paths.Status)
|
||||
if err != nil {
|
||||
return fmt.Errorf("daemon: write status file: %w", err)
|
||||
}
|
||||
ownedRoot = true
|
||||
}
|
||||
committed, err := writeStatusFileAtomicallyRoot(root, filepath.Base(s.opts.Paths.Status), data, 0o600, s.opts.beforeStatusReplace, s.opts.replaceStatusFile, s.opts.syncStatusParent)
|
||||
if ownedRoot {
|
||||
if closeErr := root.Close(); closeErr != nil {
|
||||
err = errors.Join(err, fmt.Errorf("close status directory: %w", closeErr))
|
||||
}
|
||||
}
|
||||
if committed {
|
||||
s.statusCommitted = true
|
||||
}
|
||||
if err != nil {
|
||||
if committed {
|
||||
s.logf("daemon: status file publication committed with warning: %v", err)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("daemon: write status file: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -2,20 +2,202 @@ package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/observability"
|
||||
"github.com/Gitlawb/zero/internal/testutil"
|
||||
)
|
||||
|
||||
func TestServeSupportsReadOnlyCustomRuntimeDirectory(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Unix permission compatibility regression")
|
||||
}
|
||||
dir, err := os.MkdirTemp("/tmp", "zero-serve-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
||||
if err := os.Chmod(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
paths := Paths{
|
||||
Socket: filepath.Join(dir, "daemon.sock"),
|
||||
Lock: filepath.Join(dir, "daemon.lock"),
|
||||
Status: filepath.Join(dir, "daemon.status"),
|
||||
}
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1})
|
||||
srv := newTestServerWithPaths(t, launcher, paths)
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- srv.Serve() }()
|
||||
testutil.WaitFor(t, "file "+paths.Status, func() bool { _, err := os.Stat(paths.Status); return err == nil })
|
||||
srv.Shutdown()
|
||||
if err := <-serveErr; err != nil {
|
||||
t.Fatalf("Serve with 0755 custom runtime directory: %v", err)
|
||||
}
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != 0o755 {
|
||||
t.Fatalf("custom runtime directory permissions = %04o, want unchanged 0755", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeKeepsDefaultRootBoundAcrossCoordination(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows may deny renaming a directory with an open AF_UNIX lifecycle handle")
|
||||
}
|
||||
home, err := os.MkdirTemp("/tmp", "zero-root-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(home) })
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_RUNTIME_DIR", "")
|
||||
paths, err := DefaultPaths()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Dir(paths.Socket)
|
||||
moved := filepath.Join(home, "bound-runtime")
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1})
|
||||
srv := newTestServerWithPaths(t, launcher, paths)
|
||||
srv.opts.beforeSocketBind = func() {
|
||||
if err := os.Rename(dir, moved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
err = srv.Serve()
|
||||
if err == nil || !strings.Contains(err.Error(), "changed after it was secured") {
|
||||
t.Fatalf("Serve error = %v, want replaced-runtime rejection", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(moved, filepath.Base(paths.Lock))); err != nil {
|
||||
t.Fatalf("rooted lock was not created in the bound directory: %v", err)
|
||||
}
|
||||
for _, path := range []string{paths.Lock, paths.Socket, paths.Status} {
|
||||
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("substitute runtime entry %s was touched: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeRemovesSocketBoundAfterFinalRuntimeSwap(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows may deny renaming a directory with an open AF_UNIX lifecycle handle")
|
||||
}
|
||||
home, err := os.MkdirTemp("/tmp", "zero-bind-swap-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(home) })
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_RUNTIME_DIR", "")
|
||||
paths, err := DefaultPaths()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Dir(paths.Socket)
|
||||
moved := filepath.Join(home, "trusted-runtime")
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1})
|
||||
srv := newTestServerWithPaths(t, launcher, paths)
|
||||
srv.opts.afterSocketPreflight = func() {
|
||||
if err := os.Rename(dir, moved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
err = srv.Serve()
|
||||
if err == nil || !strings.Contains(err.Error(), "changed after it was secured") {
|
||||
t.Fatalf("Serve error = %v, want post-bind runtime replacement rejection", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(moved, filepath.Base(paths.Lock))); err != nil {
|
||||
t.Fatalf("rooted lock was not retained in the trusted directory: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(paths.Socket); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("substitute runtime retained stale socket: %v", err)
|
||||
}
|
||||
listener, err := net.Listen("unix", paths.Socket)
|
||||
if err != nil {
|
||||
t.Fatalf("substitute runtime remained unbindable after rollback: %v", err)
|
||||
}
|
||||
if err := listener.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRuntimeLogHardensDefaultRootBeforeOpen(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows DACL hardening has platform-specific coverage")
|
||||
}
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_RUNTIME_DIR", "")
|
||||
paths, err := DefaultPaths()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Dir(paths.Socket)
|
||||
if err := os.MkdirAll(dir, 0o777); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(dir, 0o777); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, logPath, err := OpenRuntimeLog(paths)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenRuntimeLog: %v", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if logPath != filepath.Join(dir, "daemon.log") {
|
||||
t.Fatalf("log path = %q", logPath)
|
||||
}
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got&0o077 != 0 {
|
||||
t.Fatalf("default runtime directory permissions = %04o, want owner-only", got)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T, launcher Launcher) (*Server, Paths) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
secureStatusTestDir(t, dir)
|
||||
paths := Paths{
|
||||
Socket: filepath.Join(dir, "d.sock"),
|
||||
Lock: filepath.Join(dir, "d.lock"),
|
||||
Status: filepath.Join(dir, "d.status"),
|
||||
}
|
||||
return newTestServerWithPaths(t, launcher, paths), paths
|
||||
}
|
||||
|
||||
func newTestServerWithPaths(t *testing.T, launcher Launcher, paths Paths) *Server {
|
||||
t.Helper()
|
||||
pool, err := NewPool(PoolOptions{Size: 2, Launcher: launcher, KillTimeout: 200 * time.Millisecond})
|
||||
if err != nil {
|
||||
t.Fatalf("NewPool: %v", err)
|
||||
@@ -28,19 +210,7 @@ func newTestServer(t *testing.T, launcher Launcher) (*Server, Paths) {
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
return srv, paths
|
||||
}
|
||||
|
||||
func waitForFile(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("file %s did not appear within timeout", path)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestServerEndToEnd(t *testing.T) {
|
||||
@@ -50,7 +220,7 @@ func TestServerEndToEnd(t *testing.T) {
|
||||
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- srv.Serve() }()
|
||||
waitForFile(t, paths.Status)
|
||||
testutil.WaitFor(t, "file "+paths.Status, func() bool { _, err := os.Stat(paths.Status); return err == nil })
|
||||
|
||||
// --- run a session and collect its stream-json output ---
|
||||
runClient, err := Dial(paths.Socket)
|
||||
@@ -136,6 +306,154 @@ func TestServerEndToEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServePreservesPreviousStatusWhenReplacementFails(t *testing.T) {
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, exitCode: 0})
|
||||
dir, err := os.MkdirTemp("", "zero-daemon-status-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
||||
secureStatusTestDir(t, dir)
|
||||
paths := Paths{Socket: filepath.Join(dir, "d.sock"), Lock: filepath.Join(dir, "d.lock"), Status: filepath.Join(dir, "d.status")}
|
||||
srv := newTestServerWithPaths(t, launcher, paths)
|
||||
previous := []byte(`{"pid":7,"socket":"previous","version":1}`)
|
||||
if err := os.WriteFile(paths.Status, previous, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv.opts.replaceStatusFile = func(*os.Root, string, string) error {
|
||||
return errors.New("injected replacement failure")
|
||||
}
|
||||
|
||||
if err := srv.Serve(); err == nil || !strings.Contains(err.Error(), "injected replacement failure") {
|
||||
t.Fatal("Serve succeeded despite injected status replacement failure")
|
||||
}
|
||||
got, err := os.ReadFile(paths.Status)
|
||||
if err != nil {
|
||||
t.Fatalf("previous status was removed after failed publication: %v", err)
|
||||
}
|
||||
if string(got) != string(previous) {
|
||||
t.Fatalf("previous status changed after failed publication: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeCleansStatusThroughBoundDirectoryAfterSwap(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows does not permit renaming the directory containing the live socket")
|
||||
}
|
||||
parent, err := os.MkdirTemp("", "zero-daemon-swap-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(parent) })
|
||||
dir := filepath.Join(parent, "live")
|
||||
movedDir := filepath.Join(parent, "moved")
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secureStatusTestDir(t, dir)
|
||||
paths := Paths{
|
||||
Socket: filepath.Join(dir, "d.sock"),
|
||||
Lock: filepath.Join(dir, "d.lock"),
|
||||
Status: filepath.Join(dir, "d.status"),
|
||||
}
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, exitCode: 0})
|
||||
srv := newTestServerWithPaths(t, launcher, paths)
|
||||
substitute := []byte(`{"pid":999,"socket":"substitute"}`)
|
||||
srv.opts.beforeStatusReplace = func() {
|
||||
if err := os.Rename(dir, movedDir); err != nil {
|
||||
t.Fatalf("move status directory: %v", err)
|
||||
}
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatalf("create substitute directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(paths.Status, substitute, 0o600); err != nil {
|
||||
t.Fatalf("write substitute status: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- srv.Serve() }()
|
||||
movedStatus := filepath.Join(movedDir, "d.status")
|
||||
testutil.WaitFor(t, "file "+movedStatus, func() bool { _, err := os.Stat(movedStatus); return err == nil })
|
||||
srv.Shutdown()
|
||||
select {
|
||||
case err := <-serveErr:
|
||||
if err != nil {
|
||||
t.Fatalf("Serve: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Serve did not return after shutdown")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(movedDir, "d.status")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("bound status survived cleanup: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(paths.Status)
|
||||
if err != nil {
|
||||
t.Fatalf("substitute status was removed by pathname cleanup: %v", err)
|
||||
}
|
||||
if string(got) != string(substitute) {
|
||||
t.Fatalf("substitute status changed during cleanup: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerPublishesDefaultStatusAfterCrashReportCreatesRuntimeDirectory(t *testing.T) {
|
||||
home, err := os.MkdirTemp("", "zero-home-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(home) })
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_RUNTIME_DIR", "")
|
||||
|
||||
if _, err := observability.WriteCrashReport(
|
||||
observability.DefaultCrashDir(),
|
||||
"cli",
|
||||
"boom",
|
||||
[]byte("stack"),
|
||||
time.Now(),
|
||||
); err != nil {
|
||||
t.Fatalf("WriteCrashReport: %v", err)
|
||||
}
|
||||
paths, err := DefaultPaths()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultPaths: %v", err)
|
||||
}
|
||||
if paths.Status != filepath.Join(home, ".zero", "daemon.status") {
|
||||
t.Fatalf("default status path = %q, want path beneath temporary home", paths.Status)
|
||||
}
|
||||
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1})
|
||||
srv := newTestServerWithPaths(t, launcher, paths)
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- srv.Serve() }()
|
||||
|
||||
deadline := time.NewTimer(3 * time.Second)
|
||||
defer deadline.Stop()
|
||||
for {
|
||||
if _, err := os.Stat(paths.Status); err == nil {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case err := <-serveErr:
|
||||
t.Fatalf("Serve returned before publishing status: %v", err)
|
||||
case <-deadline.C:
|
||||
t.Fatal("daemon did not publish its default status file")
|
||||
case <-time.After(2 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
srv.Shutdown()
|
||||
select {
|
||||
case err := <-serveErr:
|
||||
if err != nil {
|
||||
t.Fatalf("Serve returned error: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Serve did not return after shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerSecondInstanceFails(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
defer close(block)
|
||||
@@ -144,7 +462,7 @@ func TestServerSecondInstanceFails(t *testing.T) {
|
||||
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- srv1.Serve() }()
|
||||
waitForFile(t, paths.Status)
|
||||
testutil.WaitFor(t, "file "+paths.Status, func() bool { _, err := os.Stat(paths.Status); return err == nil })
|
||||
|
||||
// A second server on the same paths must refuse to start (single instance).
|
||||
pool2, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
|
||||
@@ -165,7 +483,7 @@ func TestServerRejectsUnknownCommand(t *testing.T) {
|
||||
srv, paths := newTestServer(t, launcher)
|
||||
go func() { _ = srv.Serve() }()
|
||||
defer srv.Shutdown()
|
||||
waitForFile(t, paths.Status)
|
||||
testutil.WaitFor(t, "file "+paths.Status, func() bool { _, err := os.Stat(paths.Status); return err == nil })
|
||||
|
||||
client, err := Dial(paths.Socket)
|
||||
if err != nil {
|
||||
|
||||
@@ -32,6 +32,9 @@ const (
|
||||
// ErrSessionExists is returned when starting a session ID already in use.
|
||||
var ErrSessionExists = errors.New("daemon: session already exists")
|
||||
|
||||
// ErrSessionOverloaded means the manager's unfinished-session limit is reached.
|
||||
var ErrSessionOverloaded = errors.New("daemon: pending session limit reached")
|
||||
|
||||
// ErrSessionNotFound is returned by Get/Attach for an unknown session ID.
|
||||
var ErrSessionNotFound = errors.New("daemon: session not found")
|
||||
|
||||
@@ -219,6 +222,9 @@ func (s *Session) isFinished() bool {
|
||||
// daemon does not accumulate finished sessions without limit.
|
||||
const defaultMaxSessions = 256
|
||||
|
||||
// defaultMaxPending bounds queued and running sessions independently of history.
|
||||
const defaultMaxPending = 256
|
||||
|
||||
// SessionManager owns the live session registry and routes each session to a
|
||||
// pool worker. Mirrors session-manager.js + roster.js. Finished sessions are
|
||||
// retained (so a late `attach` sees history) up to MaxSessions, past which the
|
||||
@@ -227,8 +233,10 @@ type SessionManager struct {
|
||||
pool *Pool
|
||||
maxBuffer int
|
||||
maxSessions int
|
||||
maxPending int
|
||||
|
||||
mu sync.Mutex
|
||||
pending int // queued + running; protected by mu
|
||||
sessions map[string]*Session
|
||||
order []string // creation order, for FIFO eviction of finished sessions
|
||||
}
|
||||
@@ -240,6 +248,9 @@ type SessionManagerOptions struct {
|
||||
// MaxSessions caps the retained session registry; 0 => default. The oldest
|
||||
// FINISHED sessions are evicted once the cap is exceeded.
|
||||
MaxSessions int
|
||||
// MaxPending caps unfinished sessions (queued plus running), independently
|
||||
// of retained history. Non-positive values use the default of 256.
|
||||
MaxPending int
|
||||
}
|
||||
|
||||
// NewSessionManager builds a manager over pool.
|
||||
@@ -251,10 +262,15 @@ func NewSessionManager(opts SessionManagerOptions) (*SessionManager, error) {
|
||||
if maxSessions <= 0 {
|
||||
maxSessions = defaultMaxSessions
|
||||
}
|
||||
maxPending := opts.MaxPending
|
||||
if maxPending <= 0 {
|
||||
maxPending = defaultMaxPending
|
||||
}
|
||||
return &SessionManager{
|
||||
pool: opts.Pool,
|
||||
maxBuffer: opts.MaxBuffer,
|
||||
maxSessions: maxSessions,
|
||||
maxPending: maxPending,
|
||||
sessions: map[string]*Session{},
|
||||
}, nil
|
||||
}
|
||||
@@ -262,13 +278,23 @@ func NewSessionManager(opts SessionManagerOptions) (*SessionManager, error) {
|
||||
// Start creates a session for spec and dispatches it to the pool. It returns the
|
||||
// Session immediately (non-blocking); the run proceeds in the background and the
|
||||
// session transitions queued -> running -> done/failed. A duplicate ID is
|
||||
// rejected with ErrSessionExists.
|
||||
// rejected with ErrSessionExists; a full admission limit returns
|
||||
// ErrSessionOverloaded without creating a session or background goroutine.
|
||||
func (m *SessionManager) Start(ctx context.Context, spec WorkerSpec) (*Session, error) {
|
||||
m.mu.Lock()
|
||||
if _, exists := m.sessions[spec.Session]; exists {
|
||||
m.mu.Unlock()
|
||||
return nil, ErrSessionExists
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
m.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
if m.pending >= m.maxPending {
|
||||
m.mu.Unlock()
|
||||
return nil, ErrSessionOverloaded
|
||||
}
|
||||
m.pending++
|
||||
sess := newSession(spec.Session, spec.Cwd, m.maxBuffer)
|
||||
m.sessions[spec.Session] = sess
|
||||
m.order = append(m.order, spec.Session)
|
||||
@@ -277,7 +303,11 @@ func (m *SessionManager) Start(ctx context.Context, spec WorkerSpec) (*Session,
|
||||
|
||||
go func() {
|
||||
code, err := m.pool.Run(ctx, spec, sess)
|
||||
m.mu.Lock()
|
||||
sess.finish(code, err)
|
||||
m.pending--
|
||||
m.pruneLocked()
|
||||
m.mu.Unlock()
|
||||
}()
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
@@ -3,9 +3,13 @@ package daemon
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/testutil"
|
||||
)
|
||||
|
||||
func drain(t *testing.T, buffered []string, live <-chan string) []string {
|
||||
@@ -111,7 +115,7 @@ func TestSessionLeaseQueuesWhenPoolFull(t *testing.T) {
|
||||
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool})
|
||||
|
||||
sa, _ := mgr.Start(context.Background(), WorkerSpec{Session: "a"})
|
||||
waitFor(t, func() bool { return sa.State() == SessionRunning })
|
||||
testutil.WaitFor(t, "session running", func() bool { return sa.State() == SessionRunning })
|
||||
|
||||
sb, _ := mgr.Start(context.Background(), WorkerSpec{Session: "b"})
|
||||
// b must stay queued (its worker not yet launched) while a holds the slot.
|
||||
@@ -197,7 +201,7 @@ func TestSessionManagerKeepsRunningOverCap(t *testing.T) {
|
||||
|
||||
s1, _ := mgr.Start(context.Background(), WorkerSpec{Session: "r1"})
|
||||
s2, _ := mgr.Start(context.Background(), WorkerSpec{Session: "r2"})
|
||||
waitFor(t, func() bool { return s1.State() == SessionRunning && s2.State() == SessionRunning })
|
||||
testutil.WaitFor(t, "both sessions running", func() bool { return s1.State() == SessionRunning && s2.State() == SessionRunning })
|
||||
if _, ok := mgr.Get("r1"); !ok {
|
||||
t.Fatal("a running session must never be evicted, even past the cap")
|
||||
}
|
||||
@@ -206,6 +210,130 @@ func TestSessionManagerKeepsRunningOverCap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManagerAdmissionBound(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, waitCh: block})
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
|
||||
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool, MaxSessions: 2})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer func() {
|
||||
cancel()
|
||||
close(block)
|
||||
testutil.WaitFor(t, "all admitted sessions finished", func() bool {
|
||||
mgr.mu.Lock()
|
||||
defer mgr.mu.Unlock()
|
||||
return mgr.pending == 0
|
||||
})
|
||||
}()
|
||||
var wg sync.WaitGroup
|
||||
var accepted atomic.Int32
|
||||
for i := 0; i < 300; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := mgr.Start(ctx, WorkerSpec{Session: fmt.Sprintf("s%d", i)}); err == nil {
|
||||
accepted.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if got := accepted.Load(); got != 256 {
|
||||
t.Fatalf("accepted %d unfinished sessions, want default bound 256", got)
|
||||
}
|
||||
if got := len(mgr.Statuses()); got != 256 {
|
||||
t.Fatalf("registered %d sessions, want 256", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManagerCancellationReleasesAdmission(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
var release sync.Once
|
||||
defer release.Do(func() { close(block) })
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, waitCh: block}, &fakeWorker{pid: 2, exitCode: ExitPermanent}, &fakeWorker{pid: 3})
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
|
||||
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool, MaxPending: 2, MaxSessions: 10})
|
||||
first, err := mgr.Start(context.Background(), WorkerSpec{Session: "running"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testutil.WaitFor(t, "first running", func() bool { return first.State() == SessionRunning })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
queued, err := mgr.Start(ctx, WorkerSpec{Session: "queued"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s, err := mgr.Start(context.Background(), WorkerSpec{Session: "retry"}); s != nil || !errors.Is(err, ErrSessionOverloaded) {
|
||||
t.Fatalf("overloaded Start = (%v, %v)", s, err)
|
||||
}
|
||||
if _, ok := mgr.Get("retry"); ok {
|
||||
t.Fatal("rejected session was registered")
|
||||
}
|
||||
if _, err := mgr.Start(context.Background(), WorkerSpec{Session: "running"}); !errors.Is(err, ErrSessionExists) {
|
||||
t.Fatalf("duplicate at capacity: %v", err)
|
||||
}
|
||||
cancel()
|
||||
testutil.WaitFor(t, "queued cancellation", func() bool { return queued.State() == SessionFailed })
|
||||
if !errors.Is(queued.Err(), context.Canceled) {
|
||||
t.Fatalf("queued error = %v", queued.Err())
|
||||
}
|
||||
if s, err := mgr.Start(ctx, WorkerSpec{Session: "canceled"}); s != nil || !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("canceled Start = (%v, %v)", s, err)
|
||||
}
|
||||
retry, err := mgr.Start(context.Background(), WorkerSpec{Session: "retry"})
|
||||
if err != nil {
|
||||
t.Fatalf("capacity not released after cancellation: %v", err)
|
||||
}
|
||||
release.Do(func() { close(block) })
|
||||
testutil.WaitFor(t, "first completed", func() bool { return first.State() == SessionDone })
|
||||
testutil.WaitFor(t, "retry failed", func() bool { return retry.State() == SessionFailed })
|
||||
if !errors.Is(retry.Err(), ErrPermanent) {
|
||||
t.Fatalf("retry error = %v", retry.Err())
|
||||
}
|
||||
last, err := mgr.Start(context.Background(), WorkerSpec{Session: "last"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testutil.WaitFor(t, "last completed", func() bool { return last.State() == SessionDone })
|
||||
mgr.mu.Lock()
|
||||
defer mgr.mu.Unlock()
|
||||
if mgr.pending != 0 || len(mgr.sessions) != 4 {
|
||||
t.Fatalf("pending=%d retained=%d, want 0 and 4", mgr.pending, len(mgr.sessions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManagerPrunesOnCompletion(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
var release sync.Once
|
||||
defer release.Do(func() { close(block) })
|
||||
launcher, _ := seqLauncher(
|
||||
&fakeWorker{pid: 1, waitCh: block},
|
||||
&fakeWorker{pid: 2},
|
||||
&fakeWorker{pid: 3, exitCode: ExitPermanent},
|
||||
)
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
|
||||
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool, MaxSessions: 2})
|
||||
sessions := make([]*Session, 3)
|
||||
for i := range sessions {
|
||||
var err error
|
||||
sessions[i], err = mgr.Start(context.Background(), WorkerSpec{Session: fmt.Sprintf("s%d", i)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
release.Do(func() { close(block) })
|
||||
for _, s := range sessions {
|
||||
select {
|
||||
case <-s.Done():
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("session did not finish")
|
||||
}
|
||||
}
|
||||
if got := len(mgr.Statuses()); got != 2 {
|
||||
t.Fatalf("retained %d completed sessions without another Start, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStatuses(t *testing.T) {
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, out: []string{"l"}, exitCode: 0})
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/privatedir"
|
||||
)
|
||||
|
||||
// maxUnixSocketPath bounds the socket path to the smallest platform sun_path
|
||||
@@ -12,10 +15,182 @@ import (
|
||||
// safe cross-platform ceiling.
|
||||
const maxUnixSocketPath = 103
|
||||
|
||||
// secureSocketParent creates the socket's parent directory owner-only (0700 on
|
||||
// POSIX; on Windows the per-user profile directory is already ACL-restricted).
|
||||
func secureSocketParent(socketPath string) error {
|
||||
return os.MkdirAll(filepath.Dir(socketPath), 0o700)
|
||||
func secureCustomRuntimeParents(paths Paths) error {
|
||||
parents := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{name: "socket", path: filepath.Dir(paths.Socket)},
|
||||
{name: "lock", path: filepath.Dir(paths.Lock)},
|
||||
{name: "status", path: filepath.Dir(paths.Status)},
|
||||
}
|
||||
seen := make(map[string]struct{}, len(parents))
|
||||
for _, parent := range parents {
|
||||
absolute, err := filepath.Abs(parent.path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("daemon: resolve %s directory: %w", parent.name, err)
|
||||
}
|
||||
if _, ok := seen[absolute]; ok {
|
||||
continue
|
||||
}
|
||||
seen[absolute] = struct{}{}
|
||||
if err := os.MkdirAll(absolute, 0o700); err != nil {
|
||||
return fmt.Errorf("daemon: create %s directory: %w", parent.name, err)
|
||||
}
|
||||
root, err := os.OpenRoot(absolute)
|
||||
if err != nil {
|
||||
return fmt.Errorf("daemon: open custom %s directory: %w", parent.name, err)
|
||||
}
|
||||
validateErr := validateStatusRoot(root)
|
||||
closeErr := root.Close()
|
||||
if validateErr != nil {
|
||||
return fmt.Errorf("daemon: validate custom %s directory: %w", parent.name, validateErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("daemon: close custom %s directory: %w", parent.name, closeErr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OpenRuntimeLog secures the default runtime root before acquiring the
|
||||
// detached daemon's log descriptor. Custom endpoint layouts remain supported,
|
||||
// but their existing parent permissions are caller-owned and left untouched.
|
||||
func OpenRuntimeLog(paths Paths) (*os.File, string, error) {
|
||||
logPath := filepath.Join(filepath.Dir(paths.Socket), "daemon.log")
|
||||
root, isDefault, err := openDefaultRuntimeRoot(paths)
|
||||
if err != nil {
|
||||
return nil, logPath, err
|
||||
}
|
||||
if isDefault {
|
||||
defer root.Close()
|
||||
file, err := openRootAppendRegular(root, filepath.Base(logPath))
|
||||
if err != nil {
|
||||
return nil, logPath, fmt.Errorf("daemon: open runtime log: %w", err)
|
||||
}
|
||||
return file, logPath, nil
|
||||
}
|
||||
|
||||
parent := filepath.Dir(logPath)
|
||||
if err := os.MkdirAll(parent, 0o700); err != nil {
|
||||
return nil, logPath, fmt.Errorf("daemon: create custom log directory: %w", err)
|
||||
}
|
||||
info, err := os.Lstat(logPath)
|
||||
if err == nil && !info.Mode().IsRegular() {
|
||||
return nil, logPath, fmt.Errorf("daemon: custom runtime log is not a regular file")
|
||||
}
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, logPath, fmt.Errorf("daemon: inspect custom runtime log: %w", err)
|
||||
}
|
||||
file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return nil, logPath, fmt.Errorf("daemon: open custom runtime log: %w", err)
|
||||
}
|
||||
return file, logPath, nil
|
||||
}
|
||||
|
||||
func openDefaultRuntimeRoot(paths Paths) (*os.Root, bool, error) {
|
||||
defaults, err := DefaultPaths()
|
||||
if err != nil {
|
||||
// A caller-supplied layout remains usable even when the process has no
|
||||
// resolvable home directory. Production default paths can only reach this
|
||||
// helper after DefaultPaths has already succeeded.
|
||||
return nil, false, nil
|
||||
}
|
||||
matches, err := samePaths(paths, defaults)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !matches {
|
||||
return nil, false, nil
|
||||
}
|
||||
root, err := privatedir.Open(filepath.Dir(defaults.Socket))
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("daemon: secure default runtime directory: %w", err)
|
||||
}
|
||||
return root, true, nil
|
||||
}
|
||||
|
||||
func samePaths(left, right Paths) (bool, error) {
|
||||
pairs := [][2]string{
|
||||
{left.Socket, right.Socket},
|
||||
{left.Lock, right.Lock},
|
||||
{left.Status, right.Status},
|
||||
}
|
||||
for _, pair := range pairs {
|
||||
leftAbs, err := filepath.Abs(pair[0])
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("daemon: resolve runtime path: %w", err)
|
||||
}
|
||||
rightAbs, err := filepath.Abs(pair[1])
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("daemon: resolve default runtime path: %w", err)
|
||||
}
|
||||
if filepath.Clean(leftAbs) != filepath.Clean(rightAbs) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func runtimeRootStillNamesPath(root *os.Root, path string) error {
|
||||
opened, err := root.Stat(".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect bound runtime directory: %w", err)
|
||||
}
|
||||
current, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect runtime directory entry: %w", err)
|
||||
}
|
||||
if current.Mode()&os.ModeSymlink != 0 || !os.SameFile(opened, current) {
|
||||
return fmt.Errorf("daemon: default runtime directory changed after it was secured")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func captureBoundSocketRoot(path string) (*os.Root, string, error) {
|
||||
root, err := os.OpenRoot(filepath.Dir(path))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("open bound socket directory: %w", err)
|
||||
}
|
||||
name := filepath.Base(path)
|
||||
info, err := root.Lstat(name)
|
||||
if err != nil {
|
||||
_ = root.Close()
|
||||
return nil, "", fmt.Errorf("inspect bound socket: %w", err)
|
||||
}
|
||||
if info.Mode()&os.ModeSocket == 0 {
|
||||
_ = root.Close()
|
||||
return nil, "", fmt.Errorf("bound socket path is not a socket")
|
||||
}
|
||||
return root, name, nil
|
||||
}
|
||||
|
||||
func openRootAppendRegular(root *os.Root, name string) (*os.File, error) {
|
||||
info, err := root.Lstat(name)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("runtime log is not a regular file")
|
||||
}
|
||||
file, err := root.OpenFile(name, os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opened, err := file.Stat()
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return nil, err
|
||||
}
|
||||
if !opened.Mode().IsRegular() {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("runtime log changed while opening")
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// checkSocketPathLength rejects an over-long unix socket path before bind.
|
||||
|
||||
@@ -10,3 +10,7 @@ import "os"
|
||||
func hardenSocketFile(path string) error {
|
||||
return os.Chmod(path, 0o600)
|
||||
}
|
||||
|
||||
func hardenSocketFileRoot(root *os.Root, name string) error {
|
||||
return root.Chmod(name, 0o600)
|
||||
}
|
||||
@@ -2,8 +2,12 @@
|
||||
|
||||
package daemon
|
||||
|
||||
import "os"
|
||||
|
||||
// hardenSocketFile is a no-op on Windows: AF_UNIX socket files do not honor POSIX
|
||||
// mode bits, and the daemon places the socket under the per-user profile
|
||||
// directory, which is already ACL-restricted to the owner. A dedicated restricted
|
||||
// ACL (or a named pipe with an explicit DACL) is a documented follow-up.
|
||||
func hardenSocketFile(string) error { return nil }
|
||||
|
||||
func hardenSocketFileRoot(*os.Root, string) error { return nil }
|
||||
@@ -0,0 +1,20 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func checkStatusDirOwner(_ *os.Root, info os.FileInfo) error {
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return fmt.Errorf("status directory ownership metadata is unavailable")
|
||||
}
|
||||
if int(stat.Uid) != os.Geteuid() {
|
||||
return fmt.Errorf("status directory is owned by uid %d, not the current user", stat.Uid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func secureStatusTestDirPlatform(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
if err := os.Chmod(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func broadenStatusTestDirPlatform(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
if err := os.Chmod(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusDirOwnerRejectsMissingMetadata(t *testing.T) {
|
||||
err := checkStatusDirOwner(nil, statusDirOwnerTestInfo{})
|
||||
if err == nil || !strings.Contains(err.Error(), "metadata is unavailable") {
|
||||
t.Fatalf("checkStatusDirOwner error = %v, want unavailable metadata rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusDirOwnerRejectsDifferentUser(t *testing.T) {
|
||||
info := statusDirOwnerTestInfo{sys: &syscall.Stat_t{Uid: uint32(os.Geteuid() + 1)}}
|
||||
err := checkStatusDirOwner(nil, info)
|
||||
if err == nil || !strings.Contains(err.Error(), "not the current user") {
|
||||
t.Fatalf("checkStatusDirOwner error = %v, want owner mismatch rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
type statusDirOwnerTestInfo struct {
|
||||
sys any
|
||||
}
|
||||
|
||||
func (statusDirOwnerTestInfo) Name() string { return "." }
|
||||
func (statusDirOwnerTestInfo) Size() int64 { return 0 }
|
||||
func (statusDirOwnerTestInfo) Mode() os.FileMode { return os.ModeDir | 0o700 }
|
||||
func (statusDirOwnerTestInfo) ModTime() time.Time { return time.Time{} }
|
||||
func (statusDirOwnerTestInfo) IsDir() bool { return true }
|
||||
func (info statusDirOwnerTestInfo) Sys() any { return info.sys }
|
||||
@@ -0,0 +1,137 @@
|
||||
//go:build windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const statusDirectoryWriteAccess = windows.ACCESS_MASK(
|
||||
windows.GENERIC_ALL |
|
||||
windows.GENERIC_WRITE |
|
||||
windows.DELETE |
|
||||
windows.WRITE_DAC |
|
||||
windows.WRITE_OWNER |
|
||||
windows.FILE_WRITE_DATA |
|
||||
windows.FILE_APPEND_DATA |
|
||||
windows.FILE_WRITE_ATTRIBUTES |
|
||||
windows.FILE_WRITE_EA |
|
||||
0x40, // FILE_DELETE_CHILD
|
||||
)
|
||||
|
||||
// checkStatusDirOwner validates ownership and write access through a handle
|
||||
// opened beneath root. Path-based ACL inspection would recreate the ancestor
|
||||
// swap race that Root is intended to close.
|
||||
func checkStatusDirOwner(root *os.Root, _ os.FileInfo) (returnErr error) {
|
||||
directory, err := root.Open(".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("open status directory for access validation: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := directory.Close(); err != nil {
|
||||
returnErr = errors.Join(returnErr, fmt.Errorf("close status directory access handle: %w", err))
|
||||
}
|
||||
}()
|
||||
|
||||
raw, err := directory.SyscallConn()
|
||||
if err != nil {
|
||||
return fmt.Errorf("access status directory handle: %w", err)
|
||||
}
|
||||
var descriptor *windows.SECURITY_DESCRIPTOR
|
||||
var queryErr error
|
||||
if err := raw.Control(func(handle uintptr) {
|
||||
descriptor, queryErr = windows.GetSecurityInfo(
|
||||
windows.Handle(handle),
|
||||
windows.SE_FILE_OBJECT,
|
||||
windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION,
|
||||
)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("inspect status directory access: %w", err)
|
||||
}
|
||||
if queryErr != nil {
|
||||
return fmt.Errorf("inspect status directory owner and DACL: %w", queryErr)
|
||||
}
|
||||
if descriptor == nil {
|
||||
return fmt.Errorf("status directory security descriptor is unavailable")
|
||||
}
|
||||
|
||||
token := windows.GetCurrentProcessToken()
|
||||
user, err := token.GetTokenUser()
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve current Windows user: %w", err)
|
||||
}
|
||||
tokenOwner, err := currentWindowsTokenOwner(token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve current Windows token owner: %w", err)
|
||||
}
|
||||
owner, _, err := descriptor.Owner()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read status directory owner: %w", err)
|
||||
}
|
||||
if owner == nil || (!owner.Equals(user.User.Sid) && !owner.Equals(tokenOwner)) {
|
||||
return fmt.Errorf("status directory is not owned by the current Windows token")
|
||||
}
|
||||
|
||||
dacl, _, err := descriptor.DACL()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read status directory DACL: %w", err)
|
||||
}
|
||||
if dacl == nil {
|
||||
return fmt.Errorf("status directory has an unrestricted Windows DACL")
|
||||
}
|
||||
for index := uint16(0); index < dacl.AceCount; index++ {
|
||||
var ace *windows.ACCESS_ALLOWED_ACE
|
||||
if err := windows.GetAce(dacl, uint32(index), &ace); err != nil {
|
||||
return fmt.Errorf("read status directory DACL entry %d: %w", index, err)
|
||||
}
|
||||
switch ace.Header.AceType {
|
||||
case windows.ACCESS_DENIED_ACE_TYPE:
|
||||
continue
|
||||
case windows.ACCESS_ALLOWED_ACE_TYPE:
|
||||
default:
|
||||
return fmt.Errorf("status directory DACL entry %d has unsupported type %d", index, ace.Header.AceType)
|
||||
}
|
||||
if ace.Mask&statusDirectoryWriteAccess == 0 {
|
||||
continue
|
||||
}
|
||||
trustee := (*windows.SID)(unsafe.Pointer(&ace.SidStart))
|
||||
if !allowedStatusDirectoryTrustee(trustee, user.User.Sid) {
|
||||
return fmt.Errorf("status directory DACL grants write access to unexpected trustee %s", trustee.String())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type statusDirectoryTokenOwner struct {
|
||||
owner *windows.SID
|
||||
}
|
||||
|
||||
func currentWindowsTokenOwner(token windows.Token) (*windows.SID, error) {
|
||||
var size uint32
|
||||
err := windows.GetTokenInformation(token, windows.TokenOwner, nil, 0, &size)
|
||||
if err != windows.ERROR_INSUFFICIENT_BUFFER {
|
||||
return nil, err
|
||||
}
|
||||
buffer := make([]byte, size)
|
||||
if err := windows.GetTokenInformation(token, windows.TokenOwner, &buffer[0], size, &size); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owner := (*statusDirectoryTokenOwner)(unsafe.Pointer(&buffer[0])).owner
|
||||
if owner == nil {
|
||||
return nil, errors.New("windows access token has no default owner")
|
||||
}
|
||||
return owner.Copy()
|
||||
}
|
||||
|
||||
func allowedStatusDirectoryTrustee(trustee, user *windows.SID) bool {
|
||||
return trustee != nil && (trustee.Equals(user) ||
|
||||
trustee.IsWellKnown(windows.WinLocalSystemSid) ||
|
||||
trustee.IsWellKnown(windows.WinBuiltinAdministratorsSid) ||
|
||||
trustee.IsWellKnown(windows.WinCreatorOwnerSid) ||
|
||||
trustee.IsWellKnown(windows.WinCreatorOwnerRightsSid))
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//go:build windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func secureStatusTestDirPlatform(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
user, err := windows.GetCurrentProcessToken().GetTokenUser()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
descriptor, err := windows.SecurityDescriptorFromString(
|
||||
fmt.Sprintf("O:%sD:P(A;OICI;GA;;;%s)(A;OICI;GA;;;SY)", user.User.Sid.String(), user.User.Sid.String()),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dacl, _, err := descriptor.DACL()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := windows.SetNamedSecurityInfo(
|
||||
dir,
|
||||
windows.SE_FILE_OBJECT,
|
||||
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
|
||||
nil,
|
||||
nil,
|
||||
dacl,
|
||||
nil,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func broadenStatusTestDirPlatform(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
worldSID, err := windows.CreateWellKnownSid(windows.WinWorldSid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var pinner runtime.Pinner
|
||||
pinner.Pin(worldSID)
|
||||
defer pinner.Unpin()
|
||||
broadDACL, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{{
|
||||
AccessPermissions: windows.GENERIC_ALL,
|
||||
AccessMode: windows.GRANT_ACCESS,
|
||||
Inheritance: windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT,
|
||||
Trustee: windows.TRUSTEE{
|
||||
TrusteeForm: windows.TRUSTEE_IS_SID,
|
||||
TrusteeType: windows.TRUSTEE_IS_WELL_KNOWN_GROUP,
|
||||
TrusteeValue: windows.TrusteeValueFromSID(worldSID),
|
||||
},
|
||||
}}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := windows.SetNamedSecurityInfo(
|
||||
dir,
|
||||
windows.SE_FILE_OBJECT,
|
||||
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
|
||||
nil,
|
||||
nil,
|
||||
broadDACL,
|
||||
nil,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatusDirOwnerRejectsBroadDACL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secureStatusTestDirPlatform(t, dir)
|
||||
broadenStatusTestDirPlatform(t, dir)
|
||||
|
||||
root, err := os.OpenRoot(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer root.Close()
|
||||
info, err := root.Stat(".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = checkStatusDirOwner(root, info)
|
||||
if err == nil || !strings.Contains(err.Error(), "unexpected trustee") {
|
||||
t.Fatalf("checkStatusDirOwner error = %v, want broad DACL rejection", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/fsutil"
|
||||
)
|
||||
|
||||
const (
|
||||
statusTempPrefix = ".daemon-status-"
|
||||
statusTempPattern = statusTempPrefix + "*"
|
||||
)
|
||||
|
||||
func openStatusRoot(path string) (*os.Root, error) {
|
||||
root, err := os.OpenRoot(filepath.Dir(path))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open status directory: %w", err)
|
||||
}
|
||||
if err := validateStatusRoot(root); err != nil {
|
||||
_ = root.Close()
|
||||
return nil, err
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
// writeStatusFileAtomicallyRoot publishes through a caller-owned, already
|
||||
// bound directory root. The returned boolean crosses the commit boundary: once
|
||||
// true, err is a durability/cleanup warning and the complete document is live.
|
||||
func writeStatusFileAtomicallyRoot(
|
||||
root *os.Root,
|
||||
statusName string,
|
||||
data []byte,
|
||||
perm os.FileMode,
|
||||
beforeReplace func(),
|
||||
replace func(root *os.Root, src, dst string) error,
|
||||
syncParent func(root *os.Root) error,
|
||||
) (committed bool, returnErr error) {
|
||||
if err := validateStatusRoot(root); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
temp, tempName, err := createStatusTemp(root, perm)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
closed := false
|
||||
defer func() {
|
||||
if !closed {
|
||||
if err := temp.Close(); err != nil {
|
||||
returnErr = errors.Join(returnErr, fmt.Errorf("close temporary status file during cleanup: %w", err))
|
||||
}
|
||||
closed = true
|
||||
}
|
||||
if err := root.Remove(tempName); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
returnErr = errors.Join(returnErr, fmt.Errorf("remove temporary status file: %w", err))
|
||||
}
|
||||
}()
|
||||
|
||||
if err := temp.Chmod(perm); err != nil {
|
||||
return false, fmt.Errorf("set temporary status file permissions: %w", err)
|
||||
}
|
||||
if _, err := temp.Write(data); err != nil {
|
||||
return false, fmt.Errorf("write temporary status file: %w", err)
|
||||
}
|
||||
if err := temp.Sync(); err != nil {
|
||||
return false, fmt.Errorf("sync temporary status file: %w", err)
|
||||
}
|
||||
closeErr := temp.Close()
|
||||
closed = true
|
||||
if closeErr != nil {
|
||||
return false, fmt.Errorf("close temporary status file: %w", closeErr)
|
||||
}
|
||||
if beforeReplace != nil {
|
||||
beforeReplace()
|
||||
}
|
||||
if err := prepareStatusReplacement(root, tempName, statusName); err != nil {
|
||||
return false, fmt.Errorf("prepare status replacement: %w", err)
|
||||
}
|
||||
|
||||
rename := root.Rename
|
||||
if replace != nil {
|
||||
rename = func(src, dst string) error { return replace(root, src, dst) }
|
||||
}
|
||||
// On Windows an existing reader that omitted FILE_SHARE_DELETE can block
|
||||
// this replacement even though an in-place write would succeed. Keep the
|
||||
// atomic publication boundary: RenameWithRetry bounds transient sharing
|
||||
// failures to ten short attempts, and a persistent failure leaves the old
|
||||
// status document intact instead of exposing a partially written one.
|
||||
var committedWarning error
|
||||
if err := fsutil.RenameWithRetry(tempName, statusName, rename); err != nil {
|
||||
var committedReplacement *fsutil.CommittedReplacementCleanupError
|
||||
if !errors.As(err, &committedReplacement) {
|
||||
return false, fmt.Errorf("replace status file: %w", err)
|
||||
}
|
||||
committedWarning = fmt.Errorf("clean up replaced status file: %w", err)
|
||||
}
|
||||
committed = true
|
||||
if syncParent == nil {
|
||||
syncParent = syncStatusRoot
|
||||
}
|
||||
if err := syncParent(root); err != nil {
|
||||
committedWarning = errors.Join(committedWarning, fmt.Errorf("sync status directory: %w", err))
|
||||
}
|
||||
if committedWarning != nil {
|
||||
return committed, committedWarning
|
||||
}
|
||||
return committed, nil
|
||||
}
|
||||
|
||||
func validateStatusRoot(root *os.Root) error {
|
||||
info, err := root.Stat(".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect status directory: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("status directory is not a directory")
|
||||
}
|
||||
if runtime.GOOS != "windows" && info.Mode().Perm()&0o022 != 0 {
|
||||
return fmt.Errorf("status directory permissions are %04o, want no group/other write access", info.Mode().Perm())
|
||||
}
|
||||
if err := checkStatusDirOwner(root, info); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func createStatusTemp(root *os.Root, perm os.FileMode) (*os.File, string, error) {
|
||||
for range 100 {
|
||||
var suffix [16]byte
|
||||
if _, err := rand.Read(suffix[:]); err != nil {
|
||||
return nil, "", fmt.Errorf("generate temporary status file name: %w", err)
|
||||
}
|
||||
name := statusTempPrefix + hex.EncodeToString(suffix[:])
|
||||
file, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm)
|
||||
if err == nil {
|
||||
return file, name, nil
|
||||
}
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
continue
|
||||
}
|
||||
return nil, "", fmt.Errorf("create temporary status file: %w", err)
|
||||
}
|
||||
return nil, "", fmt.Errorf("create temporary status file: exhausted unique names")
|
||||
}
|
||||
|
||||
// syncStatusRoot makes the replacement directory entry durable through the
|
||||
// same bound root used for creation and replacement. Windows directory sync is
|
||||
// best-effort because Go cannot fsync a directory there.
|
||||
func syncStatusRoot(root *os.Root) error {
|
||||
if runtime.GOOS == "windows" {
|
||||
return nil
|
||||
}
|
||||
directory, err := root.Open(".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
syncErr := directory.Sync()
|
||||
closeErr := directory.Close()
|
||||
if syncErr != nil {
|
||||
return syncErr
|
||||
}
|
||||
return closeErr
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/fsutil"
|
||||
"github.com/Gitlawb/zero/internal/privatedir"
|
||||
)
|
||||
|
||||
func TestWriteStatusFilePreservesPreviousDocumentWhenReplaceFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secureStatusTestDir(t, dir)
|
||||
path := filepath.Join(dir, "daemon.status")
|
||||
previous := []byte(`{"pid":7,"socket":"old.sock","version":1,"startedAt":"2026-08-01T00:00:00Z"}`)
|
||||
if err := os.WriteFile(path, previous, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
replaceErr := errors.New("injected replacement failure")
|
||||
server := &Server{
|
||||
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
|
||||
opts: ServerOptions{
|
||||
Paths: Paths{Status: path},
|
||||
Version: 2,
|
||||
replaceStatusFile: func(root *os.Root, src, dst string) error {
|
||||
staged, err := root.ReadFile(src)
|
||||
if err != nil {
|
||||
t.Fatalf("read staged status: %v", err)
|
||||
}
|
||||
var decoded StatusFile
|
||||
if err := json.Unmarshal(staged, &decoded); err != nil {
|
||||
t.Fatalf("staged status is incomplete JSON: %v", err)
|
||||
}
|
||||
current, err := root.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatalf("read previous status at commit boundary: %v", err)
|
||||
}
|
||||
if string(current) != string(previous) {
|
||||
t.Fatalf("previous status changed before commit: %q", current)
|
||||
}
|
||||
return replaceErr
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := server.writeStatusFile()
|
||||
if !errors.Is(err, replaceErr) {
|
||||
t.Fatalf("writeStatusFile error = %v, want injected replacement failure", err)
|
||||
}
|
||||
current, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read preserved status: %v", err)
|
||||
}
|
||||
if string(current) != string(previous) {
|
||||
t.Fatalf("status after failed publication = %q, want previous document", current)
|
||||
}
|
||||
assertNoStatusTemps(t, dir)
|
||||
}
|
||||
|
||||
func TestWriteStatusFilePublishesCompleteRestrictedDocument(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secureStatusTestDir(t, dir)
|
||||
path := filepath.Join(dir, "daemon.status")
|
||||
if err := os.WriteFile(path, []byte(`{"pid":7}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
startedAt := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC)
|
||||
parentSynced := false
|
||||
server := &Server{
|
||||
startedAt: startedAt,
|
||||
opts: ServerOptions{
|
||||
Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path},
|
||||
Version: 3,
|
||||
syncStatusParent: func(root *os.Root) error {
|
||||
if _, err := root.Stat("."); err != nil {
|
||||
t.Fatalf("stat bound status parent: %v", err)
|
||||
}
|
||||
parentSynced = true
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := server.writeStatusFile(); err != nil {
|
||||
t.Fatalf("writeStatusFile: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var status StatusFile
|
||||
if err := json.Unmarshal(data, &status); err != nil {
|
||||
t.Fatalf("published status is invalid JSON: %v", err)
|
||||
}
|
||||
if status.PID != os.Getpid() || status.Socket != server.opts.Paths.Socket || status.Version != 3 || !status.StartedAt.Equal(startedAt) {
|
||||
t.Fatalf("published status = %+v", status)
|
||||
}
|
||||
if !parentSynced {
|
||||
t.Fatal("status parent directory was not synced")
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != 0o600 {
|
||||
t.Fatalf("status mode = %04o, want 0600", got)
|
||||
}
|
||||
}
|
||||
assertNoStatusTemps(t, dir)
|
||||
}
|
||||
|
||||
func TestWriteStatusFileReaderSeesCompleteDocumentsDuringPublication(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secureStatusTestDir(t, dir)
|
||||
path := filepath.Join(dir, "daemon.status")
|
||||
previous := StatusFile{
|
||||
PID: 7,
|
||||
Socket: filepath.Join(dir, "old.sock"),
|
||||
Version: 1,
|
||||
StartedAt: time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
previousData, err := json.Marshal(previous)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, previousData, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
replacementReady := make(chan struct{})
|
||||
publish := make(chan struct{})
|
||||
writeDone := make(chan error, 1)
|
||||
server := &Server{
|
||||
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
|
||||
opts: ServerOptions{
|
||||
Paths: Paths{Socket: filepath.Join(dir, "new.sock"), Status: path},
|
||||
Version: 2,
|
||||
replaceStatusFile: func(root *os.Root, src, dst string) error {
|
||||
close(replacementReady)
|
||||
<-publish
|
||||
return fsutil.RenameWithRetry(src, dst, root.Rename)
|
||||
},
|
||||
},
|
||||
}
|
||||
go func() {
|
||||
writeDone <- server.writeStatusFile()
|
||||
}()
|
||||
|
||||
<-replacementReady
|
||||
for range 100 {
|
||||
status := readStatusDocument(t, path)
|
||||
if status != previous {
|
||||
t.Fatalf("status before commit = %+v, want previous document %+v", status, previous)
|
||||
}
|
||||
}
|
||||
close(publish)
|
||||
if err := <-writeDone; err != nil {
|
||||
t.Fatalf("writeStatusFile: %v", err)
|
||||
}
|
||||
|
||||
status := readStatusDocument(t, path)
|
||||
if status.PID != os.Getpid() || status.Socket != server.opts.Paths.Socket || status.Version != 2 || !status.StartedAt.Equal(server.startedAt) {
|
||||
t.Fatalf("status after commit = %+v", status)
|
||||
}
|
||||
assertNoStatusTemps(t, dir)
|
||||
}
|
||||
|
||||
func TestWriteStatusFileContinuesAfterDirectorySyncWarning(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secureStatusTestDir(t, dir)
|
||||
path := filepath.Join(dir, "daemon.status")
|
||||
if err := os.WriteFile(path, []byte(`{"pid":7}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
syncErr := errors.New("injected directory sync failure")
|
||||
var logs []string
|
||||
server := &Server{
|
||||
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
|
||||
opts: ServerOptions{
|
||||
Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path},
|
||||
Version: 4,
|
||||
Log: func(message string) { logs = append(logs, message) },
|
||||
syncStatusParent: func(*os.Root) error { return syncErr },
|
||||
},
|
||||
}
|
||||
|
||||
if err := server.writeStatusFile(); err != nil {
|
||||
t.Fatalf("writeStatusFile returned a post-commit warning as failure: %v", err)
|
||||
}
|
||||
status := readStatusDocument(t, path)
|
||||
if status.Version != 4 {
|
||||
t.Fatalf("published status = %+v, want version 4", status)
|
||||
}
|
||||
if len(logs) != 1 || !strings.Contains(logs[0], syncErr.Error()) {
|
||||
t.Fatalf("logs = %q, want directory sync warning", logs)
|
||||
}
|
||||
assertNoStatusTemps(t, dir)
|
||||
}
|
||||
|
||||
func TestWriteStatusFileContinuesAfterCommittedReplacementWarning(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secureStatusTestDir(t, dir)
|
||||
path := filepath.Join(dir, "daemon.status")
|
||||
if err := os.WriteFile(path, []byte(`{"pid":7}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cleanupErr := errors.New("injected backup cleanup failure")
|
||||
parentSynced := false
|
||||
var logs []string
|
||||
server := &Server{
|
||||
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
|
||||
opts: ServerOptions{
|
||||
Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path},
|
||||
Version: 5,
|
||||
Log: func(message string) { logs = append(logs, message) },
|
||||
replaceStatusFile: func(root *os.Root, src, dst string) error {
|
||||
if err := fsutil.RenameWithRetry(src, dst, root.Rename); err != nil {
|
||||
return err
|
||||
}
|
||||
return &fsutil.CommittedReplacementCleanupError{
|
||||
BackupPath: filepath.Join(dir, ".zero-replace-backup"),
|
||||
Cause: cleanupErr,
|
||||
}
|
||||
},
|
||||
syncStatusParent: func(*os.Root) error {
|
||||
parentSynced = true
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := server.writeStatusFile(); err != nil {
|
||||
t.Fatalf("writeStatusFile returned a post-commit warning as failure: %v", err)
|
||||
}
|
||||
if !parentSynced {
|
||||
t.Fatal("status directory was not synced after committed replacement warning")
|
||||
}
|
||||
status := readStatusDocument(t, path)
|
||||
if status.Version != 5 {
|
||||
t.Fatalf("published status = %+v, want version 5", status)
|
||||
}
|
||||
if len(logs) != 1 || !strings.Contains(logs[0], cleanupErr.Error()) {
|
||||
t.Fatalf("logs = %q, want committed cleanup warning", logs)
|
||||
}
|
||||
assertNoStatusTemps(t, dir)
|
||||
}
|
||||
|
||||
func TestWriteStatusFileContinuesAfterCommittedTempCleanupWarning(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secureStatusTestDir(t, dir)
|
||||
path := filepath.Join(dir, "daemon.status")
|
||||
if err := os.WriteFile(path, []byte(`{"pid":7}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var tempName string
|
||||
var logs []string
|
||||
server := &Server{
|
||||
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
|
||||
opts: ServerOptions{
|
||||
Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path},
|
||||
Version: 6,
|
||||
Log: func(message string) { logs = append(logs, message) },
|
||||
replaceStatusFile: func(root *os.Root, src, dst string) error {
|
||||
if err := fsutil.RenameWithRetry(src, dst, root.Rename); err != nil {
|
||||
return err
|
||||
}
|
||||
tempName = src
|
||||
if err := root.Mkdir(src, 0o700); err != nil {
|
||||
t.Fatalf("recreate staged path as directory: %v", err)
|
||||
}
|
||||
keep, err := root.OpenFile(filepath.Join(src, "keep"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("make staged directory non-empty: %v", err)
|
||||
}
|
||||
if err := keep.Close(); err != nil {
|
||||
t.Fatalf("close staged directory marker: %v", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := server.writeStatusFile(); err != nil {
|
||||
t.Fatalf("writeStatusFile returned a post-commit warning as failure: %v", err)
|
||||
}
|
||||
status := readStatusDocument(t, path)
|
||||
if status.Version != 6 {
|
||||
t.Fatalf("published status = %+v, want version 6", status)
|
||||
}
|
||||
if len(logs) != 1 || !strings.Contains(logs[0], "remove temporary status file") {
|
||||
t.Fatalf("logs = %q, want temporary cleanup warning", logs)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(dir, tempName, "keep")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(dir, tempName)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertNoStatusTemps(t, dir)
|
||||
}
|
||||
|
||||
func TestWriteStatusFileBindsDirectoryDuringAncestorSwap(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
dir := filepath.Join(parent, "live")
|
||||
movedDir := filepath.Join(parent, "moved")
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secureStatusTestDir(t, dir)
|
||||
path := filepath.Join(dir, "daemon.status")
|
||||
if err := os.WriteFile(path, []byte(`{"pid":7}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
substitute := []byte(`{"pid":999,"socket":"substitute"}`)
|
||||
var swapErr error
|
||||
server := &Server{
|
||||
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
|
||||
opts: ServerOptions{
|
||||
Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path},
|
||||
Version: 6,
|
||||
beforeStatusReplace: func() {
|
||||
swapErr = os.Rename(dir, movedDir)
|
||||
if swapErr != nil {
|
||||
if runtime.GOOS == "windows" {
|
||||
return
|
||||
}
|
||||
t.Fatalf("move bound status directory: %v", swapErr)
|
||||
}
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatalf("create substitute status directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, substitute, 0o600); err != nil {
|
||||
t.Fatalf("write substitute status: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := server.writeStatusFile(); err != nil {
|
||||
t.Fatalf("writeStatusFile: %v", err)
|
||||
}
|
||||
if swapErr != nil {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Fatalf("unexpected directory-swap error: %v", swapErr)
|
||||
}
|
||||
status := readStatusDocument(t, path)
|
||||
if status.Version != 6 {
|
||||
t.Fatalf("status after blocked swap = %+v, want version 6", status)
|
||||
}
|
||||
if _, err := os.Lstat(movedDir); !os.IsNotExist(err) {
|
||||
t.Fatalf("moved directory exists after blocked Windows swap: %v", err)
|
||||
}
|
||||
assertNoStatusTemps(t, dir)
|
||||
return
|
||||
}
|
||||
if got, err := os.ReadFile(path); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if string(got) != string(substitute) {
|
||||
t.Fatalf("substitute destination changed: %q", got)
|
||||
}
|
||||
status := readStatusDocument(t, filepath.Join(movedDir, "daemon.status"))
|
||||
if status.Version != 6 {
|
||||
t.Fatalf("bound status = %+v, want version 6", status)
|
||||
}
|
||||
assertNoStatusTemps(t, dir)
|
||||
assertNoStatusTemps(t, movedDir)
|
||||
}
|
||||
|
||||
func TestWriteStatusFileAllowsReadOnlyCustomStatusDirectory(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows directory access is governed by DACLs, not Unix mode bits")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
if err := os.Chmod(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(dir, "daemon.status")
|
||||
|
||||
server := &Server{
|
||||
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
|
||||
opts: ServerOptions{
|
||||
Paths: Paths{Status: path},
|
||||
Version: 6,
|
||||
},
|
||||
}
|
||||
err := server.writeStatusFile()
|
||||
if err != nil {
|
||||
t.Fatalf("writeStatusFile in 0755 directory: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(path); err != nil {
|
||||
t.Fatalf("status file not created in read-only custom directory: %v", err)
|
||||
}
|
||||
assertNoStatusTemps(t, dir)
|
||||
}
|
||||
|
||||
func TestWriteStatusFileRejectsWritableStatusDirectory(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows directory access is governed by DACLs, not Unix mode bits")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
if err := os.Chmod(dir, 0o777); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(dir, "daemon.status")
|
||||
server := &Server{
|
||||
startedAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
|
||||
opts: ServerOptions{Paths: Paths{Status: path}, Version: 6},
|
||||
}
|
||||
err := server.writeStatusFile()
|
||||
if err == nil || !strings.Contains(err.Error(), "no group/other write access") {
|
||||
t.Fatalf("writeStatusFile error = %v, want writable-directory rejection", err)
|
||||
}
|
||||
if _, err := os.Lstat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("status file created in writable directory: %v", err)
|
||||
}
|
||||
assertNoStatusTemps(t, dir)
|
||||
}
|
||||
|
||||
func TestPrivateDirHardensBroadCurrentUserStatusDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
broadenStatusTestDirPlatform(t, dir)
|
||||
if err := privatedir.Ensure(dir); err != nil {
|
||||
t.Fatalf("privatedir.Ensure: %v", err)
|
||||
}
|
||||
root, err := os.OpenRoot(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer root.Close()
|
||||
|
||||
if err := validateStatusRoot(root); err != nil {
|
||||
t.Fatalf("validate hardened status root: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func readStatusDocument(t *testing.T, path string) StatusFile {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read status: %v", err)
|
||||
}
|
||||
var status StatusFile
|
||||
if err := json.Unmarshal(data, &status); err != nil {
|
||||
t.Fatalf("status is incomplete JSON: %v (content %q)", err, data)
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func assertNoStatusTemps(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
matches, err := filepath.Glob(filepath.Join(dir, statusTempPattern))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(matches) != 0 {
|
||||
t.Fatalf("temporary status files remain: %v", matches)
|
||||
}
|
||||
}
|
||||
|
||||
func secureStatusTestDir(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
secureStatusTestDirPlatform(t, dir)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//go:build windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func TestWriteStatusFilePreservesExistingProtectedDACL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secureStatusTestDir(t, dir)
|
||||
path := filepath.Join(dir, "daemon.status")
|
||||
if err := os.WriteFile(path, []byte(`{"pid":7}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restricted, err := windows.SecurityDescriptorFromString("D:P(A;;FA;;;OW)")
|
||||
if err != nil {
|
||||
t.Skipf("cannot build restrictive DACL: %v", err)
|
||||
}
|
||||
dacl, _, err := restricted.DACL()
|
||||
if err != nil {
|
||||
t.Skipf("cannot read restrictive DACL: %v", err)
|
||||
}
|
||||
if err := windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT,
|
||||
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
|
||||
nil, nil, dacl, nil); err != nil {
|
||||
t.Skipf("cannot apply restrictive DACL: %v", err)
|
||||
}
|
||||
want := statusDACL(t, path)
|
||||
|
||||
server := &Server{
|
||||
startedAt: time.Date(2026, 8, 30, 18, 0, 0, 0, time.UTC),
|
||||
opts: ServerOptions{
|
||||
Paths: Paths{Socket: filepath.Join(dir, "daemon.sock"), Status: path},
|
||||
Version: 2,
|
||||
},
|
||||
}
|
||||
if err := server.writeStatusFile(); err != nil {
|
||||
t.Fatalf("writeStatusFile: %v", err)
|
||||
}
|
||||
if got := statusDACL(t, path); got != want {
|
||||
t.Fatalf("status DACL after replacement = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func statusDACL(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
descriptor, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
t.Skipf("cannot read status DACL: %v", err)
|
||||
}
|
||||
text := descriptor.String()
|
||||
if index := strings.Index(text, "D:"); index >= 0 {
|
||||
return text[index:]
|
||||
}
|
||||
return text
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemon
|
||||
|
||||
import "os"
|
||||
|
||||
func prepareStatusReplacement(*os.Root, string, string) error { return nil }
|
||||
@@ -0,0 +1,136 @@
|
||||
//go:build windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// prepareStatusReplacement copies the existing status document's DACL to the
|
||||
// complete staging file before Root.Rename replaces it. Both files are opened
|
||||
// relative to the already-bound Root directory handle, so preserving Windows
|
||||
// security metadata does not reintroduce a pathname/ancestor-swap boundary.
|
||||
func prepareStatusReplacement(root *os.Root, src, dst string) (returnErr error) {
|
||||
if filepath.Base(src) != src || filepath.Base(dst) != dst {
|
||||
return fmt.Errorf("status replacement names must be root-relative base names")
|
||||
}
|
||||
directory, err := root.Open(".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("open bound status directory for DACL preservation: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := directory.Close(); err != nil {
|
||||
returnErr = errors.Join(returnErr, fmt.Errorf("close bound status directory for DACL preservation: %w", err))
|
||||
}
|
||||
}()
|
||||
|
||||
raw, err := directory.SyscallConn()
|
||||
if err != nil {
|
||||
return fmt.Errorf("access bound status directory handle: %w", err)
|
||||
}
|
||||
var destination, source windows.Handle
|
||||
var openErr error
|
||||
if err := raw.Control(func(rootHandle uintptr) {
|
||||
destination, openErr = openStatusSecurityHandle(windows.Handle(rootHandle), dst, windows.READ_CONTROL|windows.FILE_READ_ATTRIBUTES)
|
||||
if isMissingStatusObject(openErr) {
|
||||
openErr = nil // no destination descriptor exists to preserve
|
||||
return
|
||||
}
|
||||
if openErr != nil {
|
||||
return
|
||||
}
|
||||
source, openErr = openStatusSecurityHandle(windows.Handle(rootHandle), src, windows.READ_CONTROL|windows.WRITE_DAC|windows.FILE_READ_ATTRIBUTES)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("open status files through bound directory: %w", err)
|
||||
}
|
||||
if destination != 0 {
|
||||
defer windows.CloseHandle(destination)
|
||||
}
|
||||
if source != 0 {
|
||||
defer windows.CloseHandle(source)
|
||||
}
|
||||
if openErr != nil {
|
||||
return fmt.Errorf("open status files for DACL preservation: %w", openErr)
|
||||
}
|
||||
if destination == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
descriptor, err := windows.GetSecurityInfo(destination, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read existing status DACL: %w", err)
|
||||
}
|
||||
dacl, _, err := descriptor.DACL()
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode existing status DACL: %w", err)
|
||||
}
|
||||
if dacl == nil {
|
||||
return fmt.Errorf("existing status file has an unrestricted Windows DACL")
|
||||
}
|
||||
control, _, err := descriptor.Control()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read existing status DACL control: %w", err)
|
||||
}
|
||||
securityInfo := windows.SECURITY_INFORMATION(windows.DACL_SECURITY_INFORMATION)
|
||||
if control&windows.SE_DACL_PROTECTED != 0 {
|
||||
securityInfo |= windows.SECURITY_INFORMATION(windows.PROTECTED_DACL_SECURITY_INFORMATION)
|
||||
} else {
|
||||
securityInfo |= windows.SECURITY_INFORMATION(windows.UNPROTECTED_DACL_SECURITY_INFORMATION)
|
||||
}
|
||||
if err := windows.SetSecurityInfo(source, windows.SE_FILE_OBJECT, securityInfo, nil, nil, dacl, nil); err != nil {
|
||||
return fmt.Errorf("preserve existing status DACL on replacement: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func openStatusSecurityHandle(root windows.Handle, name string, access uint32) (windows.Handle, error) {
|
||||
objectName, err := windows.NewNTUnicodeString(name)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
attributes := &windows.OBJECT_ATTRIBUTES{
|
||||
Length: uint32(unsafe.Sizeof(windows.OBJECT_ATTRIBUTES{})),
|
||||
RootDirectory: root,
|
||||
ObjectName: objectName,
|
||||
Attributes: windows.OBJ_CASE_INSENSITIVE | windows.OBJ_DONT_REPARSE,
|
||||
}
|
||||
var handle windows.Handle
|
||||
var status windows.IO_STATUS_BLOCK
|
||||
err = windows.NtCreateFile(
|
||||
&handle,
|
||||
access,
|
||||
attributes,
|
||||
&status,
|
||||
nil,
|
||||
0,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
|
||||
windows.FILE_OPEN,
|
||||
windows.FILE_NON_DIRECTORY_FILE|windows.FILE_OPEN_REPARSE_POINT,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var info windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(handle, &info); err != nil {
|
||||
windows.CloseHandle(handle)
|
||||
return 0, err
|
||||
}
|
||||
if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||
windows.CloseHandle(handle)
|
||||
return 0, fmt.Errorf("refusing status DACL operation on reparse point %q", name)
|
||||
}
|
||||
return handle, nil
|
||||
}
|
||||
|
||||
func isMissingStatusObject(err error) bool {
|
||||
return errors.Is(err, windows.STATUS_OBJECT_NAME_NOT_FOUND) ||
|
||||
errors.Is(err, windows.STATUS_OBJECT_PATH_NOT_FOUND)
|
||||
}
|
||||
@@ -24,6 +24,7 @@ const (
|
||||
Connectivity
|
||||
ModelNotFound
|
||||
ContextOverflow
|
||||
LocalRPM
|
||||
)
|
||||
|
||||
// providerMarkers are the prefixes the provider layer attaches to every
|
||||
@@ -59,6 +60,8 @@ func Classify(err error) Category {
|
||||
return Unknown
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(m, "rate limit error: local rpm limit for"):
|
||||
return LocalRPM
|
||||
case containsAny(m, "auth error:", "unauthorized", "api key", "api_key", "invalid_api_key",
|
||||
"authentication", "permission denied", "forbidden") || containsStatusCode(m, "401", "403"):
|
||||
return Auth
|
||||
@@ -89,6 +92,8 @@ func TUIHint(err error) string {
|
||||
return "API key rejected — run /provider to re-check your credentials"
|
||||
case RateLimit:
|
||||
return "Rate limited — wait a moment, or switch model with /model"
|
||||
case LocalRPM:
|
||||
return "Local modelRPM cap reached — wait for the window to expire, or switch model with /model"
|
||||
case Connectivity:
|
||||
return "Can't reach the provider — run /doctor --connectivity"
|
||||
case ModelNotFound:
|
||||
@@ -109,6 +114,8 @@ func CLIHint(err error) string {
|
||||
return "API key rejected — run `zero setup`, `zero auth openrouter` for OpenRouter, or set the provider's API key"
|
||||
case RateLimit:
|
||||
return "Rate limited — wait a moment, or switch model with --model"
|
||||
case LocalRPM:
|
||||
return "Local modelRPM cap reached — wait for the window to expire, or switch model with --model"
|
||||
case Connectivity:
|
||||
return "Can't reach the provider — run `zero doctor`"
|
||||
case ModelNotFound:
|
||||
|
||||
@@ -17,6 +17,7 @@ func TestClassify(t *testing.T) {
|
||||
{"raw 401", "provider request error: 401 Unauthorized", Auth},
|
||||
{"invalid api key", "provider request error: invalid_api_key: incorrect key provided", Auth},
|
||||
{"rate limit prefix", "rate limit error: 429 too many requests", RateLimit},
|
||||
{"local RPM", `rate limit error: local RPM limit for "gpt-4.1" (2 requests/60s); retry in 12s`, LocalRPM},
|
||||
{"overloaded", "provider error: model is overloaded, please retry", RateLimit},
|
||||
{"resource exhausted gemini", "provider stream error: rpc error: code = ResourceExhausted desc = quota exceeded", RateLimit},
|
||||
{"context length openai", "provider request error: this model's maximum context length is 128000 tokens", ContextOverflow},
|
||||
|
||||
@@ -24,11 +24,15 @@ const (
|
||||
var (
|
||||
ErrProcessNotFound = errors.New("execution process not found")
|
||||
ErrProcessStdinDisabled = errors.New("execution process does not accept stdin")
|
||||
ErrProcessCapacity = errors.New("execution process capacity reached; stop a running process before starting another")
|
||||
)
|
||||
|
||||
type ProcessManagerOptions struct {
|
||||
CompletedRetention time.Duration
|
||||
MaxProcesses int
|
||||
// MaxProcesses bounds retained processes and in-flight launches. Completed
|
||||
// history is pruned before rejecting a start; live processes are never evicted.
|
||||
// Zero uses the default limit; a negative value disables the limit.
|
||||
MaxProcesses int
|
||||
}
|
||||
|
||||
// ProcessManager owns retained interactive-process identity, transport,
|
||||
@@ -37,6 +41,7 @@ type ProcessManager struct {
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
processes map[int]*managedProcess
|
||||
starting int
|
||||
completedRetention time.Duration
|
||||
maxProcesses int
|
||||
startTransport processTransportStarter
|
||||
@@ -120,12 +125,21 @@ func (manager *ProcessManager) Start(ctx context.Context, input ProcessStart, wa
|
||||
if input.Prepared.Command == nil {
|
||||
return ProcessResult{}, errors.New("prepared execution has no command")
|
||||
}
|
||||
if err := manager.reserve(); err != nil {
|
||||
if input.Prepared.Cleanup != nil {
|
||||
input.Prepared.Cleanup()
|
||||
}
|
||||
return ProcessResult{}, err
|
||||
}
|
||||
command := input.Prepared.Command
|
||||
buffer := newProcessOutputBuffer()
|
||||
request := input.Request
|
||||
observer := NewChangeObserver(request.WorkspaceRoots[0])
|
||||
stdin, tty, transportCleanup, err := manager.startTransport(command, buffer, input.TTY)
|
||||
if err != nil {
|
||||
manager.mu.Lock()
|
||||
manager.starting--
|
||||
manager.mu.Unlock()
|
||||
if input.Prepared.Cleanup != nil {
|
||||
input.Prepared.Cleanup()
|
||||
}
|
||||
@@ -224,6 +238,49 @@ func (manager *ProcessManager) Continue(ctx context.Context, input ProcessContin
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// WriteInput writes bytes to a retained interactive process's stdin without
|
||||
// collecting output. Unlike Continue it never drains the pending output
|
||||
// buffer, so a concurrent write_stdin poll still sees everything the process
|
||||
// emitted; callers that only need the rolling tail use Snapshot instead.
|
||||
func (manager *ProcessManager) WriteInput(id int, data []byte) error {
|
||||
process, ok := manager.get(id)
|
||||
if !ok {
|
||||
return ErrProcessNotFound
|
||||
}
|
||||
process.touch()
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
if !process.tty || process.stdin == nil {
|
||||
return ErrProcessStdinDisabled
|
||||
}
|
||||
if _, err := process.stdin.Write(data); err != nil && !process.doneClosed() {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResizeInput updates the PTY window size of a retained interactive process
|
||||
// so an attached terminal can fill its viewport. Non-positive dimensions are
|
||||
// a no-op; platforms without PTY support report the transport's error.
|
||||
func (manager *ProcessManager) ResizeInput(id int, cols, rows int) error {
|
||||
if cols <= 0 || rows <= 0 {
|
||||
return nil
|
||||
}
|
||||
process, ok := manager.get(id)
|
||||
if !ok {
|
||||
return ErrProcessNotFound
|
||||
}
|
||||
process.touch()
|
||||
if !process.tty || process.stdin == nil {
|
||||
return ErrProcessStdinDisabled
|
||||
}
|
||||
if err := resizePTY(process.stdin, cols, rows); err != nil && !process.doneClosed() {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clampInitialProcessWait(wait time.Duration) time.Duration {
|
||||
return min(wait, maxInteractiveYield)
|
||||
}
|
||||
@@ -288,9 +345,13 @@ func (manager *ProcessManager) StopAll() []int {
|
||||
return ids
|
||||
}
|
||||
|
||||
// Remove forgets completed history only. A live process must remain tracked
|
||||
// until completion, even if a termination attempt fails.
|
||||
func (manager *ProcessManager) Remove(id int) {
|
||||
manager.mu.Lock()
|
||||
delete(manager.processes, id)
|
||||
if process, ok := manager.processes[id]; ok && process.doneClosed() {
|
||||
delete(manager.processes, id)
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -315,22 +376,25 @@ func (manager *ProcessManager) get(id int) (*managedProcess, bool) {
|
||||
return process, ok
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) reserve() error {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
if manager.maxProcesses > 0 && len(manager.processes)+manager.starting >= manager.maxProcesses {
|
||||
completed := manager.processToPruneLocked()
|
||||
if completed == nil {
|
||||
return ErrProcessCapacity
|
||||
}
|
||||
delete(manager.processes, completed.id)
|
||||
}
|
||||
manager.starting++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) store(process *managedProcess) {
|
||||
manager.mu.Lock()
|
||||
var evicted *managedProcess
|
||||
if manager.maxProcesses > 0 && len(manager.processes) >= manager.maxProcesses {
|
||||
evicted = manager.processToPruneLocked()
|
||||
if evicted != nil && evicted.doneClosed() {
|
||||
delete(manager.processes, evicted.id)
|
||||
evicted = nil
|
||||
}
|
||||
}
|
||||
manager.starting--
|
||||
manager.processes[process.id] = process
|
||||
manager.mu.Unlock()
|
||||
if evicted != nil {
|
||||
_, _ = evicted.output.Write([]byte("[zero] session evicted: too many background terminals\n"))
|
||||
evicted.terminate()
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *ProcessManager) processToPruneLocked() *managedProcess {
|
||||
@@ -344,9 +408,6 @@ func (manager *ProcessManager) processToPruneLocked() *managedProcess {
|
||||
return process
|
||||
}
|
||||
}
|
||||
if len(processes) > 8 {
|
||||
return processes[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ package execution
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -22,6 +24,145 @@ func processManagerRequest(root string, command *exec.Cmd) Request {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessManagerReservesCapacityBeforeTransport(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manager := NewProcessManager(ProcessManagerOptions{MaxProcesses: 1})
|
||||
entered := make(chan struct{}, 20)
|
||||
release := make(chan struct{})
|
||||
launchErr := errors.New("transport failed")
|
||||
manager.startTransport = func(*exec.Cmd, io.Writer, bool) (io.WriteCloser, bool, func(), error) {
|
||||
entered <- struct{}{}
|
||||
<-release
|
||||
return nil, false, nil, launchErr
|
||||
}
|
||||
start := func() error {
|
||||
command := exec.Command(os.Args[0])
|
||||
cleaned := false
|
||||
_, err := manager.Start(context.Background(), ProcessStart{
|
||||
Prepared: PreparedCommand{Command: command, Cleanup: func() { cleaned = true }},
|
||||
Request: processManagerRequest(root, command),
|
||||
}, 0)
|
||||
if !cleaned {
|
||||
t.Error("failed admission or launch did not clean prepared resources")
|
||||
}
|
||||
return err
|
||||
}
|
||||
first := make(chan error, 1)
|
||||
go func() { first <- start() }()
|
||||
<-entered
|
||||
results := make(chan error, 16)
|
||||
for range 16 {
|
||||
go func() { results <- start() }()
|
||||
}
|
||||
// Release blocked transports even when testing the unfixed implementation.
|
||||
select {
|
||||
case <-entered:
|
||||
close(release)
|
||||
<-first
|
||||
for range 16 {
|
||||
<-results
|
||||
}
|
||||
t.Fatal("additional transport launched while the only slot was reserved")
|
||||
case err := <-results:
|
||||
if err == nil || errors.Is(err, launchErr) {
|
||||
t.Errorf("full manager returned %v, want capacity error", err)
|
||||
}
|
||||
}
|
||||
for range 15 {
|
||||
if err := <-results; err == nil || errors.Is(err, launchErr) {
|
||||
t.Errorf("full manager returned %v, want capacity error", err)
|
||||
}
|
||||
}
|
||||
close(release)
|
||||
if err := <-first; !errors.Is(err, launchErr) {
|
||||
t.Fatalf("first launch = %v", err)
|
||||
}
|
||||
if err := start(); !errors.Is(err, launchErr) {
|
||||
t.Fatalf("failed launch did not release reservation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessManagerCapacityDoesNotEvictLiveProcesses(t *testing.T) {
|
||||
for _, limit := range []int{1, 9} {
|
||||
t.Run(fmt.Sprint(limit), func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manager := NewProcessManager(ProcessManagerOptions{MaxProcesses: limit})
|
||||
kills := 0
|
||||
for id := range limit {
|
||||
manager.processes[id] = &managedProcess{
|
||||
id: id, command: &exec.Cmd{Process: &os.Process{Pid: 123}},
|
||||
done: make(chan struct{}), output: newProcessOutputBuffer(),
|
||||
kill: func(int) error { kills++; return errors.New("kill failed") },
|
||||
}
|
||||
}
|
||||
manager.Stop(0) // A failed kill must not free capacity.
|
||||
manager.Remove(0) // Nor may removing a live identity bypass the limit.
|
||||
launches := 0
|
||||
launchErr := errors.New("unexpected launch")
|
||||
manager.startTransport = func(*exec.Cmd, io.Writer, bool) (io.WriteCloser, bool, func(), error) {
|
||||
launches++
|
||||
return nil, false, nil, launchErr
|
||||
}
|
||||
command := exec.Command(os.Args[0])
|
||||
input := ProcessStart{Prepared: PreparedCommand{Command: command}, Request: processManagerRequest(root, command)}
|
||||
if _, err := manager.Start(context.Background(), input, 0); err == nil || errors.Is(err, launchErr) {
|
||||
t.Fatalf("full manager returned %v, want capacity error before launch", err)
|
||||
}
|
||||
if launches != 0 || kills != 1 || manager.Len() != limit {
|
||||
t.Fatalf("launches=%d kills=%d retained=%d", launches, kills, manager.Len())
|
||||
}
|
||||
manager.processes[0].markDone(nil, 0, AdapterReport{}, nil, nil)
|
||||
if _, err := manager.Start(context.Background(), input, 0); !errors.Is(err, launchErr) {
|
||||
t.Fatalf("completed history did not free capacity: %v", err)
|
||||
}
|
||||
if launches != 1 || manager.Len() != limit-1 {
|
||||
t.Fatalf("launches=%d retained=%d after completion", launches, manager.Len())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessManagerCapacityHelper(t *testing.T) {
|
||||
if os.Getenv("ZERO_PROCESS_CAPACITY_HELPER") != "1" {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Minute)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func TestProcessManagerCapacityWithRunningProcess(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
manager := NewProcessManager(ProcessManagerOptions{MaxProcesses: 1})
|
||||
t.Cleanup(func() { manager.StopAll() })
|
||||
start := func() (ProcessResult, *exec.Cmd, error) {
|
||||
command := exec.Command(os.Args[0], "-test.run=^TestProcessManagerCapacityHelper$")
|
||||
command.Env = append(os.Environ(), "ZERO_PROCESS_CAPACITY_HELPER=1")
|
||||
result, err := manager.Start(context.Background(), ProcessStart{
|
||||
Prepared: PreparedCommand{Command: command}, Request: processManagerRequest(root, command),
|
||||
}, 0)
|
||||
return result, command, err
|
||||
}
|
||||
first, _, err := start()
|
||||
if err != nil || first.Exited {
|
||||
t.Fatalf("first start = %+v, %v", first, err)
|
||||
}
|
||||
if _, command, err := start(); err == nil || command.Process != nil {
|
||||
t.Fatalf("second start = %v, process=%v; want rejection before OS launch", err, command.Process)
|
||||
}
|
||||
if got := len(manager.List()); got != 1 {
|
||||
t.Fatalf("live processes = %d, want 1", got)
|
||||
}
|
||||
stopped, err := manager.Continue(context.Background(), ProcessContinue{
|
||||
ProcessID: first.ProcessID, Interrupt: true, Wait: 10 * time.Second,
|
||||
})
|
||||
if err != nil || !stopped.Exited {
|
||||
t.Fatalf("stop = %+v, %v", stopped, err)
|
||||
}
|
||||
if next, _, err := start(); err != nil || next.Exited {
|
||||
t.Fatalf("start after completion = %+v, %v", next, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessManagerRetainsAndContinuesWithStableIdentity(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("test command uses a POSIX shell")
|
||||
@@ -85,6 +226,126 @@ func TestProcessManagerInterruptsRetainedProcess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessManagerWriteInputUnknownProcess(t *testing.T) {
|
||||
manager := NewProcessManager(ProcessManagerOptions{})
|
||||
if err := manager.WriteInput(4242, []byte("x")); !errors.Is(err, ErrProcessNotFound) {
|
||||
t.Fatalf("WriteInput unknown id = %v, want ErrProcessNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessManagerWriteInputRejectsPipeProcess(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("test command uses a POSIX shell")
|
||||
}
|
||||
root := t.TempDir()
|
||||
manager := NewProcessManager(ProcessManagerOptions{})
|
||||
command := exec.Command("/bin/sh", "-c", "sleep 30")
|
||||
started, err := manager.Start(context.Background(), ProcessStart{
|
||||
Prepared: PreparedCommand{Command: command}, Request: processManagerRequest(root, command),
|
||||
}, time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
defer manager.Stop(started.ProcessID)
|
||||
if started.TTY {
|
||||
t.Fatal("pipe process reported a TTY")
|
||||
}
|
||||
if err := manager.WriteInput(started.ProcessID, []byte("x")); !errors.Is(err, ErrProcessStdinDisabled) {
|
||||
t.Fatalf("WriteInput pipe process = %v, want ErrProcessStdinDisabled", err)
|
||||
}
|
||||
if err := manager.WriteInput(started.ProcessID, nil); err != nil {
|
||||
t.Fatalf("WriteInput empty data = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessManagerWriteInputDoesNotDrainPendingOutput(t *testing.T) {
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("PTY sessions are only available on Linux")
|
||||
}
|
||||
root := t.TempDir()
|
||||
manager := NewProcessManager(ProcessManagerOptions{})
|
||||
command := exec.CommandContext(context.Background(), "cat")
|
||||
started, err := manager.Start(context.Background(), ProcessStart{
|
||||
Prepared: PreparedCommand{Command: command}, Request: processManagerRequest(root, command),
|
||||
CommandText: "cat", TTY: true,
|
||||
}, 50*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Skipf("PTY transport unavailable: %v", err)
|
||||
}
|
||||
if !started.TTY {
|
||||
t.Skip("PTY transport fell back to pipes")
|
||||
}
|
||||
defer manager.Stop(started.ProcessID)
|
||||
|
||||
if err := manager.WriteInput(started.ProcessID, []byte("hello\r")); err != nil {
|
||||
t.Fatalf("WriteInput: %v", err)
|
||||
}
|
||||
// The PTY echoes input back into the output stream, so the rolling recent
|
||||
// tail shows what was typed without anything being drained.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
snapshot, ok := manager.Snapshot(started.ProcessID)
|
||||
if ok && strings.Contains(snapshot.RecentOutput, "hello") {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("recent output never echoed the input: %q", snapshot.RecentOutput)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
// WriteInput must not have consumed the pending buffer: a write_stdin-style
|
||||
// Continue still collects the echoed bytes.
|
||||
continued, err := manager.Continue(context.Background(), ProcessContinue{
|
||||
ProcessID: started.ProcessID, Wait: 200 * time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Continue: %v", err)
|
||||
}
|
||||
if !strings.Contains(continued.Output, "hello") {
|
||||
t.Fatalf("WriteInput drained the pending output: continued output = %q", continued.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessManagerResizeInputUnknownProcess(t *testing.T) {
|
||||
manager := NewProcessManager(ProcessManagerOptions{})
|
||||
if err := manager.ResizeInput(4242, 100, 40); !errors.Is(err, ErrProcessNotFound) {
|
||||
t.Fatalf("ResizeInput unknown id = %v, want ErrProcessNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessManagerResizeInputUpdatesWindowSize(t *testing.T) {
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("PTY sessions are only available on Linux")
|
||||
}
|
||||
root := t.TempDir()
|
||||
manager := NewProcessManager(ProcessManagerOptions{})
|
||||
command := exec.CommandContext(context.Background(), "/bin/sh", "-c", "sleep 0.3; stty size")
|
||||
started, err := manager.Start(context.Background(), ProcessStart{
|
||||
Prepared: PreparedCommand{Command: command}, Request: processManagerRequest(root, command),
|
||||
CommandText: "stty size", TTY: true,
|
||||
}, time.Millisecond)
|
||||
if err != nil {
|
||||
t.Skipf("PTY transport unavailable: %v", err)
|
||||
}
|
||||
if !started.TTY {
|
||||
t.Skip("PTY transport fell back to pipes")
|
||||
}
|
||||
defer manager.Stop(started.ProcessID)
|
||||
|
||||
if err := manager.ResizeInput(started.ProcessID, 100, 40); err != nil {
|
||||
t.Fatalf("ResizeInput: %v", err)
|
||||
}
|
||||
continued, err := manager.Continue(context.Background(), ProcessContinue{
|
||||
ProcessID: started.ProcessID, Wait: 2 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Continue: %v", err)
|
||||
}
|
||||
if combined := started.Output + continued.Output; !strings.Contains(combined, "40 100") {
|
||||
t.Fatalf("stty size output = %q, want %q", combined, "40 100")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedProcessTerminateSkipsReapedProcess(t *testing.T) {
|
||||
reaped := make(chan struct{})
|
||||
close(reaped)
|
||||
|
||||
@@ -11,3 +11,7 @@ import (
|
||||
func startPTYProcess(_ *exec.Cmd, _ io.Writer) (io.WriteCloser, func(), error) {
|
||||
return nil, nil, errors.New("pty transport is unavailable on this platform")
|
||||
}
|
||||
|
||||
func resizePTY(_ io.Writer, _, _ int) error {
|
||||
return errors.New("pty transport is unavailable on this platform")
|
||||
}
|
||||