Compare commits

..
Author SHA1 Message Date
Gnanam 5746424272 fix(tui): render the ? help as an overlay, not a full-screen replace
Pressing ? replaced the entire TUI with the shortcut block: View() short-
circuited to renderKeybindingHelpOverlay, which centers the block on a blank
canvas, so the transcript, title bar, and composer all vanished. Every other
popup (model picker, suggestions, wizards) composites over the chat instead.

Route the help overlay through the SAME viewport-overlay path the pickers use:
add keybindingHelpOverlay(width) (returns "" when closed, else the centered
block) as the highest-priority case in transcriptView's overlay switch and in
twoColumnTranscriptView, and drop the content-replacement branch in View(). The
overlay now draws on top of the chat via the existing overlayViewportLines
compositing, keeping the transcript/title bar/composer visible behind it. Input
handling is unchanged — the modal key-swallow + ?/Esc/q/Enter dismiss run before
render — so only the presentation changes.

Tests: the help overlay composites over the chat chrome (title bar + composer
survive) and over a populated transcript row (red-green: with the old
full-replace both the chrome and the body are gone); existing
open/close/swallow/content tests unchanged.

Fixes #419
2026-07-03 06:57:18 +05:30
381 changed files with 1388 additions and 39418 deletions

No files matched your search

+3 -14
View File
@@ -100,10 +100,9 @@ jobs:
go-version-file: go.mod
cache: true
# govulncheck, deadcode, and golangci-lint each resolve a toolchain from
# their own modules, which can drop below the version go.mod requires and
# then fail to load our packages. Pin GOTOOLCHAIN to the go.mod toolchain
# so all three run under it.
# govulncheck and deadcode resolve a toolchain from their own modules, which
# can drop below the version go.mod requires and then fail to load our
# packages. Pin GOTOOLCHAIN to the go.mod toolchain so both run under it.
- name: Pin toolchain from go.mod
run: |
toolchain="$(awk '/^toolchain /{print $2}' go.mod)"
@@ -121,13 +120,3 @@ jobs:
- name: deadcode (advisory)
continue-on-error: true
run: go run golang.org/x/tools/cmd/[email protected] -test=false ./...
# Advisory: catches what deadcode's whole-program reachability analysis
# doesn't, unused private functions/assignments reachable within a
# package but never actually called, plus staticcheck-style correctness
# and readability issues. Scoped to a few linters rather than the full
# default battery, and non-blocking, while the existing findings across
# the repo are cleaned up incrementally (see #527).
- name: golangci-lint (advisory)
continue-on-error: true
run: go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...
+1 -17
View File
@@ -4,14 +4,12 @@ on:
pull_request:
paths:
- action.yml
- docs/GITHUB_ACTION.md
- .github/workflows/zero-action-smoke.yml
push:
branches:
- main
paths:
- action.yml
- docs/GITHUB_ACTION.md
- .github/workflows/zero-action-smoke.yml
jobs:
@@ -31,8 +29,7 @@ jobs:
import sys, yaml
with open("action.yml", "r", encoding="utf-8") as handle:
action_raw = handle.read()
doc = yaml.safe_load(action_raw)
doc = yaml.safe_load(handle)
assert isinstance(doc, dict), "action.yml must be a mapping"
assert doc.get("name"), "action must declare a name"
@@ -68,19 +65,6 @@ jobs:
assert "--skip-permissions-unsafe" not in serialized, \
"action must never pass --skip-permissions-unsafe"
run_zero = next((step for step in steps if step.get("name") == "Run ZERO"), None)
assert run_zero is not None, "action.yml must contain a Run ZERO step"
run_zero_script = run_zero.get("run") or ""
assert 'export "${ZERO_INPUT_API_KEY_ENV}=${ZERO_INPUT_API_KEY}"' in run_zero_script, \
"Run ZERO must export the provider key for the current step"
assert "later steps inherit" not in action_raw.lower(), \
"provider keys must not be documented as inherited by later steps"
for line in run_zero_script.splitlines():
if "GITHUB_ENV" in line and (
"ZERO_INPUT_API_KEY" in line or "ZERO_INPUT_API_KEY_ENV" in line
):
raise AssertionError("provider key must not be persisted to GITHUB_ENV")
print(f"action.yml OK: {len(inputs)} inputs, {len(outputs)} outputs, {len(steps)} steps")
PY
-13
View File
@@ -27,10 +27,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
.cache
*.tsbuildinfo
# Go build/module caches (GOCACHE/GOMODCACHE pointed here for local runs)
.gocache/
.gomodcache/
# IntelliJ based IDEs
.idea
@@ -41,17 +37,8 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
/zero
/zero.exe
/zero-windows-command-runner.exe
/zero-windows-sandbox-setup.exe
/zero-skills
/.zero-binary-version
/snake-game.html
# Superpowers skill artifacts (brainstorm mockups, specs, plans) — local only
.superpowers/
docs/superpowers/
# Termux/Android build artifacts
zero-termux-arm64
zero-linux-sandbox
zero-seccomp
+1 -1
View File
@@ -1 +1 @@
{".":"0.3.0"}
{".":"0.1.0"}
+3 -10
View File
@@ -26,7 +26,7 @@ Accepted file names, in priority order at each level:
| `./ZERO.md` | Brand-specific alias. Same format, lower priority. |
| `./.zero/AGENTS.md` | Project-local, hidden, gitignored. Personal notes that stay out of git. |
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.md` — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.MD` (uppercase `MD`) — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
Both files use the same format. YAML frontmatter is optional; the markdown body is loaded as instructions for the agent. Zero reads the file once at session start, so changes take effect on the next `zero` launch — not mid-session.
@@ -47,12 +47,6 @@ Tips:
- In a monorepo, drop a narrower `AGENTS.md` in each sub-tree (e.g. `services/api/AGENTS.md`). Zero picks those up automatically when you launch from inside the sub-tree.
- A YAML frontmatter block (`---\n...\n---`) at the top is preserved verbatim in the injected prompt but is not parsed for `globs:` or `alwaysApply:` scoping today — keep the body self-contained.
### Personal guidelines, across every project
For preferences that follow *you*, not a specific repo (tone, tooling habits, workflow), drop a `ZERO.md` in your user config directory: `~/.config/zero/ZERO.md` on Linux/macOS, `%AppData%\Roaming\zero\ZERO.md` on Windows — the same directory as `config.json` and your personal specialists. Same format and 8 KiB cap as the project files above, and the same case-insensitive basename match.
This file is injected as its own `## User guidelines` section, before the project's `AGENTS.md`/`ZERO.md`, and is labeled as personal preference in the prompt: project guidelines are the later, more specific instruction and take precedence over it when the two conflict.
## 2. Custom specialists
Specialists are Zero's sub-agents. Three scopes, in priority order:
@@ -132,7 +126,7 @@ description: Run the project's benchmark suite and summarize the deltas.
3. Report any regression > 5% with the function name and the previous value.
```
Only `name` and `description` are recognized in the frontmatter today. The `name` defaults to the directory name. Within a single skills root, duplicate names are resolved by lexicographic directory order. During an agent run, Zero loads the default skills directory before plugin skill roots; earlier roots win name collisions silently. Plugin-declared skills (section 6) are merged into the active agent run at plugin activation time, so bundled skills appear in the available skills list and can be loaded with the `skill` tool.
Only `name` and `description` are recognized in the frontmatter today. The `name` defaults to the directory name. If two skills declare the same name, the one in the lexicographically-first directory wins — duplicates are dropped silently. Plugin-declared skills (section 6) are not yet merged into the loader, so a `skills:` entry inside a plugin's `plugin.json` is not visible to the `skill` tool today.
The `skill` core tool lets the agent load any discovered skill by name.
@@ -282,7 +276,7 @@ A plugin is enabled by being present in the plugins directory and disabled by re
Plugin commands run with the plugin directory as their working directory. Use relative paths; the loader resolves them at activation time.
> **Roadmap.** An in-UI plugins manager (browse, install, enable / disable) is on the backlog. Today you use the `zero plugins` CLI subcommands above.
> **Roadmap.** An in-UI plugins manager (browse, install, enable / disable) is on the backlog. Today you use the `zero plugins` CLI subcommands above. Skills declared inside a plugin's `plugin.json` are not yet merged into the `skill` tool's discovery (see section 3).
## 7. Configuration locations
@@ -313,7 +307,6 @@ A team that wants every contributor's Zero to behave the same way commits:
Each contributor adds only:
- `~/.config/zero/config.json` — their personal API keys, theme, default mode.
- `~/.config/zero/ZERO.md` — personal preferences that follow them across every project (see section 1).
- `~/.local/share/zero/skills/` — personal skills they keep across projects.
That's it. Run `zero` from the repo root and the agent has the team's full instruction set, every contributor's personal setup, and nothing else.
-105
View File
@@ -6,111 +6,6 @@ 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.3.0](https://github.com/Gitlawb/zero/compare/v0.2.0...v0.3.0) (2026-07-09)
### Features
* gate project-scoped hooks, plugins, and MCP servers behind workspace trust ([#529](https://github.com/Gitlawb/zero/issues/529)) ([a880ce8](https://github.com/Gitlawb/zero/commit/a880ce80a6ec72da511fb9bdf6dd69291c72a64b))
* **modelregistry:** infer reasoning efforts for Hunyuan and vendor-prefixed model ids ([#599](https://github.com/Gitlawb/zero/issues/599)) ([92a92ce](https://github.com/Gitlawb/zero/commit/92a92ceb29dc63f5216ab140ef9a6dd9afe17df8))
* **providers:** OAuth login profiles and list-first /provider manager ([#560](https://github.com/Gitlawb/zero/issues/560)) ([1655056](https://github.com/Gitlawb/zero/commit/16550569c1be615cfaf244dab25909ec37f6dee6))
* **tui:** remember recent provider+model selections in /model picker ([#568](https://github.com/Gitlawb/zero/issues/568)) ([d0c4e62](https://github.com/Gitlawb/zero/commit/d0c4e62cd429a0614c15296934c740a08bc0e07b))
* **tui:** show CLI version on the startup home screen ([#538](https://github.com/Gitlawb/zero/issues/538)) ([fd69233](https://github.com/Gitlawb/zero/commit/fd69233e334f1823a06b5794085a9255b3abdfa8))
* voice dictation (speech-to-text) ([#557](https://github.com/Gitlawb/zero/issues/557)) ([87158a1](https://github.com/Gitlawb/zero/commit/87158a1c90b4f91fc5f2bb8178ebaf46d7654680))
### Bug Fixes
* address bugs found in a multi-agent codebase audit ([#481](https://github.com/Gitlawb/zero/issues/481)) ([008bc9b](https://github.com/Gitlawb/zero/commit/008bc9b3f3ba13c7d4822b9559b020f381ff555b))
* **agent:** keep tools exposed for max-turn finalization ([#533](https://github.com/Gitlawb/zero/issues/533)) ([3f0503b](https://github.com/Gitlawb/zero/commit/3f0503bc2312ae29d5ade784d8824dc9a3524958))
* **auth:** persist OpenRouter API key after login ([#595](https://github.com/Gitlawb/zero/issues/595)) ([2a062aa](https://github.com/Gitlawb/zero/commit/2a062aa4014c6cd5e20a57dad4a685f88966f109))
* bump Go to 1.26.5 for crypto/tls fix (GO-2026-5856) ([#607](https://github.com/Gitlawb/zero/issues/607)) ([a7cfb99](https://github.com/Gitlawb/zero/commit/a7cfb99fed7b88ebc09a2f251cb82864d3c2cade))
* gitignore Windows sandbox helpers and npm version marker ([#578](https://github.com/Gitlawb/zero/issues/578)) ([25653f6](https://github.com/Gitlawb/zero/commit/25653f686c016f95b81ceb7ff5d5452d37c4d4f3))
* **mcp:** silence startup warning for unconfigured default servers ([#563](https://github.com/Gitlawb/zero/issues/563)) ([302f58b](https://github.com/Gitlawb/zero/commit/302f58bb5f2a03ec7230354ed4747e4e55c16c50))
* **mcp:** skip RFC 8414 discovery when OAuth endpoints are preconfigured ([#586](https://github.com/Gitlawb/zero/issues/586)) ([8a52d98](https://github.com/Gitlawb/zero/commit/8a52d98cad7cd0086dee9aede4ce477e432bd385))
* **modelregistry:** reject oversized models.dev cache responses ([#602](https://github.com/Gitlawb/zero/issues/602)) ([66a6396](https://github.com/Gitlawb/zero/commit/66a63964149fb2f07e646e5f1987627c5cd9ac28))
* **provider:** stop dropping custom no-auth providers on restart ([#558](https://github.com/Gitlawb/zero/issues/558)) ([ba99fa8](https://github.com/Gitlawb/zero/commit/ba99fa8d487fb28f2700e0ff10b2a25c75303cf7))
* **sandbox:** Windows-appropriate suggestions when blocking interactive commands ([#414](https://github.com/Gitlawb/zero/issues/414)) ([ba4c007](https://github.com/Gitlawb/zero/commit/ba4c00755dc1c31b3dcca18e50b20f62c6bf5d1f))
* **tools:** block MSYS and WSL shells under the Windows sandbox ([#587](https://github.com/Gitlawb/zero/issues/587)) ([0666818](https://github.com/Gitlawb/zero/commit/066681855b80e3baf5d07d7397610b25f724e353))
* **tools:** platform-specific pager suggestions, quote/caret-safe cd detection ([#543](https://github.com/Gitlawb/zero/issues/543)) ([8b248f4](https://github.com/Gitlawb/zero/commit/8b248f4e1198dc86ab332a697fba4cf520823cbd))
* **tools:** Windows cmd.exe quoting guidance and clipboard escaping fix ([#468](https://github.com/Gitlawb/zero/issues/468)) ([f10ed0c](https://github.com/Gitlawb/zero/commit/f10ed0c893ce6de08923f143d681ba96f0fcfe3a))
* **tui:** bypass toggleSidebar and toggleMouse global shortcuts when composer is non-empty ([#576](https://github.com/Gitlawb/zero/issues/576)) ([c7346fb](https://github.com/Gitlawb/zero/commit/c7346fbcf01fe7e70ed2fcfccbf9965e5985727b))
* **tui:** paste protection for Termux char-by-char paste ([#573](https://github.com/Gitlawb/zero/issues/573)) ([8e9149f](https://github.com/Gitlawb/zero/commit/8e9149f1a296bebebf95cae9dd2a7c5156c9dbb6))
* **tui:** update picker_test for switchProviderModel 4-value return ([#589](https://github.com/Gitlawb/zero/issues/589)) ([8f15650](https://github.com/Gitlawb/zero/commit/8f156506dc92449bd24caa14e36f28477ba00fff))
* **update:** clearer error on unsupported release platform (android/termux) ([#603](https://github.com/Gitlawb/zero/issues/603)) ([1fc9b2d](https://github.com/Gitlawb/zero/commit/1fc9b2d25c79e089f34cb7b5b6a7f7c7b8233123))
* **update:** support safe symlink extraction during updates ([#575](https://github.com/Gitlawb/zero/issues/575)) ([ce9cb91](https://github.com/Gitlawb/zero/commit/ce9cb912958a3d9ae0b052bfebb849c28e0e719b))
* warn about untracked scratch files left behind after a run ([#571](https://github.com/Gitlawb/zero/issues/571)) ([062328b](https://github.com/Gitlawb/zero/commit/062328b632a2d27353a6d47c522bbd22d7282539))
### Performance Improvements
* **grep:** stop content scan after head limit ([#601](https://github.com/Gitlawb/zero/issues/601)) ([8a05e64](https://github.com/Gitlawb/zero/commit/8a05e6486f7a63281b869064db03dfc5531e6a04))
## [0.2.0](https://github.com/Gitlawb/zero/compare/v0.1.0...v0.2.0) (2026-07-06)
### Features
* add --auto flag for LLM-generated commit messages ([#423](https://github.com/Gitlawb/zero/issues/423)) ([b0abde7](https://github.com/Gitlawb/zero/commit/b0abde7d0697e808480cd59d69a6f4d0c6320475))
* add zero changes push and pr subcommands, and extra repo-info metrics ([#391](https://github.com/Gitlawb/zero/issues/391)) ([2312abe](https://github.com/Gitlawb/zero/commit/2312abe5ddd95f4c6ef373cfb61cc03092f48cdd))
* agent quality, caching, retry, and tooling upgrades ([#506](https://github.com/Gitlawb/zero/issues/506)) ([3c81fea](https://github.com/Gitlawb/zero/commit/3c81fea22873ee3df7fc97b10cb4f77792706c4b))
* **agent:** curb over-engineering the solution in the editing discipline ([#517](https://github.com/Gitlawb/zero/issues/517)) ([f4c998a](https://github.com/Gitlawb/zero/commit/f4c998ac30c4f07ff313a2d706791e857293be49)), closes [#516](https://github.com/Gitlawb/zero/issues/516)
* **agent:** inject per-user config.UserConfigDir()/zero/ZERO.md guidelines into system prompt ([#475](https://github.com/Gitlawb/zero/issues/475)) ([7b10aab](https://github.com/Gitlawb/zero/commit/7b10aab74bf14a01166b2cea22deab79bba9850b))
* **openai:** forward prompt_cache_key for server-side prefix cache routing ([#515](https://github.com/Gitlawb/zero/issues/515)) ([87e7e69](https://github.com/Gitlawb/zero/commit/87e7e69afd18b5539579856f3a61c6a95bc445ae))
* **providers:** add `zero providers models` to discover a provider's models ([#386](https://github.com/Gitlawb/zero/issues/386)) ([0bc8074](https://github.com/Gitlawb/zero/commit/0bc8074c97b0310e4a9d70c3f967003ee5e8a59f))
* **providers:** add KiloCode and OpenCode provider support ([#388](https://github.com/Gitlawb/zero/issues/388)) ([b1ccb6d](https://github.com/Gitlawb/zero/commit/b1ccb6d9c1875377f5e5ea81a1304edd1e41ab4f))
* **providers:** add Meituan LongCat catalog preset ([#424](https://github.com/Gitlawb/zero/issues/424)) ([b4275e3](https://github.com/Gitlawb/zero/commit/b4275e350472b2490212bf814709819d354c1216))
* **providers:** split minimax zai into intl cn ([#398](https://github.com/Gitlawb/zero/issues/398)) ([aaad4d2](https://github.com/Gitlawb/zero/commit/aaad4d271270f41af837b6f3b60ae80beba0c645))
* require manual approval before npm publish + drop release-as pin ([#369](https://github.com/Gitlawb/zero/issues/369)) ([bd89a1f](https://github.com/Gitlawb/zero/commit/bd89a1f451643c1b65ec803070abc7b116631ebe))
* **sandbox:** unelevated Windows fallback tier instead of prompts-only degrade ([#427](https://github.com/Gitlawb/zero/issues/427)) ([b9ddd6f](https://github.com/Gitlawb/zero/commit/b9ddd6f42138312a1fee8d8bb67c46c8eb1dea2f))
* support shift enter for composer newlines ([#462](https://github.com/Gitlawb/zero/issues/462)) ([daf65e0](https://github.com/Gitlawb/zero/commit/daf65e0af9a040314d4ab337b0ad59c55416b7bc))
* **tui:** /loop — repeat a prompt or command on an interval or self-paced ([#502](https://github.com/Gitlawb/zero/issues/502)) ([387fe67](https://github.com/Gitlawb/zero/commit/387fe67ee7cd81317c9c969f5906a4437080fea3))
* **tui:** add search/filter to provider picker in setup wizard ([#400](https://github.com/Gitlawb/zero/issues/400)) ([2fcea71](https://github.com/Gitlawb/zero/commit/2fcea71778d23e050c93409c471aef45b68c1621))
* **update:** add zero upgrade command to apply self-updates ([#461](https://github.com/Gitlawb/zero/issues/461)) ([5f36349](https://github.com/Gitlawb/zero/commit/5f36349c1884e81fa9bc66bb5fe813b627e897b7))
### Bug Fixes
* **action:** keep provider key scoped to zero step ([#448](https://github.com/Gitlawb/zero/issues/448)) ([407a927](https://github.com/Gitlawb/zero/commit/407a92739ff508cba32d2c12b3f36f0efcdd54c3))
* add android platform support for Termux npm install ([#455](https://github.com/Gitlawb/zero/issues/455)) ([9bd93c6](https://github.com/Gitlawb/zero/commit/9bd93c62f8d57fb74057284aa66a1b6e1429dcdd)), closes [#449](https://github.com/Gitlawb/zero/issues/449)
* **agent:** reject a malformed additional_permissions payload before prompting ([#453](https://github.com/Gitlawb/zero/issues/453)) ([e4f760e](https://github.com/Gitlawb/zero/commit/e4f760ee8bd57299cd2fcb37e8e23130037c2607))
* allow non-TLS connections to private-network provider endpoints ([#444](https://github.com/Gitlawb/zero/issues/444)) ([1d86384](https://github.com/Gitlawb/zero/commit/1d8638466ca31517eb9db2b9353d3dce1cbeeabc))
* **auth:** route zero auth login chatgpt to the dedicated ChatGPT flow ([#443](https://github.com/Gitlawb/zero/issues/443)) ([305a62c](https://github.com/Gitlawb/zero/commit/305a62c954ca6cec00bc58d5398f933415156aff))
* **config:** fall back to a usable saved provider instead of forcing full re-onboarding ([#410](https://github.com/Gitlawb/zero/issues/410)) ([c60ad87](https://github.com/Gitlawb/zero/commit/c60ad8729f79bb841114d352ee2d2fe29d5d0e41))
* **config:** let a gateway ANTHROPIC_BASE_URL resolve as anthropic-compatible ([#497](https://github.com/Gitlawb/zero/issues/497)) ([30dd7c3](https://github.com/Gitlawb/zero/commit/30dd7c3112ad22d42fa12b5addd4e38f4beda42a)), closes [#479](https://github.com/Gitlawb/zero/issues/479)
* **config:** unbrick first-run setup — default google/anthropic models, enter setup on fixable config errors ([#385](https://github.com/Gitlawb/zero/issues/385)) ([72eed06](https://github.com/Gitlawb/zero/commit/72eed06b4f94c43d75d31fe54a58d2f566de059e))
* **config:** use ~/.config on macOS and enter setup when no provider ([#371](https://github.com/Gitlawb/zero/issues/371)) ([#372](https://github.com/Gitlawb/zero/issues/372)) ([027a8f2](https://github.com/Gitlawb/zero/commit/027a8f2768b17b89f5c8270887f156e2ccda69ea))
* **docs:** rename AGENTS.MD > AGENTS.md ([#438](https://github.com/Gitlawb/zero/issues/438)) ([4266baf](https://github.com/Gitlawb/zero/commit/4266baf222df583ed2078b776687f12d496475b5))
* **gemini:** strip unsupported JSON Schema fields from tool declarations ([#374](https://github.com/Gitlawb/zero/issues/374)) ([39e7100](https://github.com/Gitlawb/zero/commit/39e7100674150144a1152e3110c64c7cf0321d64)), closes [#373](https://github.com/Gitlawb/zero/issues/373)
* **install:** persist install dir to user PATH on Windows ([#407](https://github.com/Gitlawb/zero/issues/407)) ([bdb1b0e](https://github.com/Gitlawb/zero/commit/bdb1b0ecd15859b1712a6037d296dace7f9c3c3f))
* **mcp:** block cross-origin credential redirects ([#396](https://github.com/Gitlawb/zero/issues/396)) ([f915f70](https://github.com/Gitlawb/zero/commit/f915f70e5a3096e2419fa8d961a0f84a626fa4a9))
* **oauth:** treat Windows ERROR_ACCESS_DENIED as lock contention in createSecretFile ([#445](https://github.com/Gitlawb/zero/issues/445)) ([d05e914](https://github.com/Gitlawb/zero/commit/d05e9148a7f79f67d1d3c31fca2775f21fbd331e))
* **openai:** handle Ollama reasoning stream deltas ([#486](https://github.com/Gitlawb/zero/issues/486)) ([f6c0606](https://github.com/Gitlawb/zero/commit/f6c060631e18e082dda24cc4dc0903c31c2120d6))
* preserve conversation context in exec prompts ([#460](https://github.com/Gitlawb/zero/issues/460)) ([949ee43](https://github.com/Gitlawb/zero/commit/949ee43f71e5cb7fab4695c5cb7b442fe4ecfbf7))
* **provider-wizard:** allow multiple custom OpenAI-compatible providers ([#403](https://github.com/Gitlawb/zero/issues/403)) ([3fbbd28](https://github.com/Gitlawb/zero/commit/3fbbd28e4c586822cc4312c86232d94befe56e87))
* **sandbox:** fix nested pipe creation under the Windows restricted token ([#456](https://github.com/Gitlawb/zero/issues/456)) ([563a6db](https://github.com/Gitlawb/zero/commit/563a6dbe91e65d5daeefd7626e8a77e30a6d8fb2))
* **sandbox:** gate /tmp test assertions on GOOS, not path existence ([#426](https://github.com/Gitlawb/zero/issues/426)) ([f653dca](https://github.com/Gitlawb/zero/commit/f653dcac363fb69ad7be5b35e6e0fa6d2bce476d))
* **sandbox:** self-heal a corrupt unelevated setup marker ([#437](https://github.com/Gitlawb/zero/issues/437)) ([8d0c5fe](https://github.com/Gitlawb/zero/commit/8d0c5feccb8bdbfb015df0508aa6e3bcbd1fd0e8))
* **specialist:** cap max specialist nesting depth ([#491](https://github.com/Gitlawb/zero/issues/491)) ([177442c](https://github.com/Gitlawb/zero/commit/177442cfe4015bd8df04cc9894f98b468ee796d4))
* Termux/Android support — PRoot scroll, SIGSYS sandbox, build docs ([#509](https://github.com/Gitlawb/zero/issues/509)) ([0f69d99](https://github.com/Gitlawb/zero/commit/0f69d995e9b586b774f66c066b21abab5e03024a))
* **tools:** Block MSYS coreutils under Windows sandbox ([#476](https://github.com/Gitlawb/zero/issues/476)) ([81aad58](https://github.com/Gitlawb/zero/commit/81aad58d97839e51c068e2f08907618991fdc3fb))
* **tools:** CRLF line ending mismatch in edit_file tool on Windows ([#378](https://github.com/Gitlawb/zero/issues/378)) ([33dc7ae](https://github.com/Gitlawb/zero/commit/33dc7ae2cc82c5389675531e1416856dae7151ce))
* **tools:** fix cmd.exe /S/C corrupting commands with embedded quotes ([#465](https://github.com/Gitlawb/zero/issues/465)) ([190241b](https://github.com/Gitlawb/zero/commit/190241bd593f43211b766e0b13c8e89802d4bb37))
* **tools:** flag piped POSIX utilities before running on Windows ([#412](https://github.com/Gitlawb/zero/issues/412)) ([5658a36](https://github.com/Gitlawb/zero/commit/5658a366274fc59a9d5336b06a21019c9c25cbf1))
* **tools:** make grep and glob respect run cancellation ([#464](https://github.com/Gitlawb/zero/issues/464)) ([ba6c026](https://github.com/Gitlawb/zero/commit/ba6c0264697b7d7ed479f6e782fba9700a481e3d))
* **tools:** require permission before web_search requests ([#382](https://github.com/Gitlawb/zero/issues/382)) ([960db96](https://github.com/Gitlawb/zero/commit/960db9660e4e31dc588fe8f7d6f116ff5e225566))
* **tui:** compose help overlay through the viewport overlay pipeline ([#421](https://github.com/Gitlawb/zero/issues/421)) ([5b2b4de](https://github.com/Gitlawb/zero/commit/5b2b4dea1aaf9e0f68baa25e97e83296fb17b1a2))
* **tui:** keep the profile name on /model switch so the stored key resolves ([#441](https://github.com/Gitlawb/zero/issues/441)) ([9134148](https://github.com/Gitlawb/zero/commit/9134148f4df3e4e556fba6c2f8babfdf6fcfeee1)), closes [#440](https://github.com/Gitlawb/zero/issues/440)
* **tui:** resolve every permission request so the agent can't deadlock ([#397](https://github.com/Gitlawb/zero/issues/397)) ([952788f](https://github.com/Gitlawb/zero/commit/952788f72d32957659fe004521fcc8372b9ba9b4))
* **tui:** show an M suffix for million-scale token counts ([#457](https://github.com/Gitlawb/zero/issues/457)) ([0562e3b](https://github.com/Gitlawb/zero/commit/0562e3bef7df2328610a48a1e81632a8da4aec64))
* **tui:** title /model rows by model name, not the catalog description ([#395](https://github.com/Gitlawb/zero/issues/395)) ([cdf9d83](https://github.com/Gitlawb/zero/commit/cdf9d839ae57a729f292f36f7c5b0c67b41b288d))
### Performance Improvements
* cache TUI model registry ([#496](https://github.com/Gitlawb/zero/issues/496)) ([e7d88b4](https://github.com/Gitlawb/zero/commit/e7d88b4b518049733da25a8447c00144bd1da518))
* universal tool-output ceiling with spill + async post-edit diagnostics ([#518](https://github.com/Gitlawb/zero/issues/518)) ([95ccd5b](https://github.com/Gitlawb/zero/commit/95ccd5bc327f6fb464ff0239f7229de789f578dc))
## 0.1.0 (2026-07-02)
+1 -1
View File
@@ -1,4 +1,4 @@
# Zero build/test/lint targets. AGENTS.md says "Build with `make`" and "Run `make
# Zero build/test/lint targets. AGENTS.MD says "Build with `make`" and "Run `make
# lint` before opening a PR" — these targets back those instructions.
.DEFAULT_GOAL := build
.PHONY: build build-all test test-race vet fmt fmt-check lint tidy clean help
+8 -55
View File
@@ -8,8 +8,6 @@
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.25+" src="https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<br>
<strong>English</strong> | <a href="README_ZH.md">中文</a>
</p>
Zero is an AI coding agent for your local terminal. It can inspect a repository,
@@ -59,24 +57,20 @@ Bun does not run dependency lifecycle scripts by default, so the `postinstall`
that fetches the Zero binary is skipped and the first run fails with
`No native binary found next to the npm wrapper`.
The simplest fix is to trust the package after installing, which runs the
blocked postinstall. This works for project and global installs:
Install with Bun, then fetch the binary in one of two ways:
```bash
# project install
# Option A: run the installer manually
bun add @gitlawb/zero
bun pm trust @gitlawb/zero
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
# global install
bun add -g @gitlawb/zero
bun pm -g trust @gitlawb/zero
# Option B: allow the postinstall to run on install
# add to your package.json: "trustedDependencies": ["@gitlawb/zero"]
bun add @gitlawb/zero
```
Alternatives: allow the postinstall up front by adding
`"trustedDependencies": ["@gitlawb/zero"]` to your project's package.json
before `bun add`, or run the installer manually
(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`) on Bun versions
that do not have `bun pm trust`.
For global installs (`bun add -g @gitlawb/zero`), use Option A against the
global install path, or prefer the install scripts below.
### Install scripts
@@ -148,13 +142,6 @@ the key in the wizard:
export OPENAI_API_KEY=sk-...
export ANTHROPIC_API_KEY=...
export GEMINI_API_KEY=...
export LONGCAT_API_KEY=...
```
To configure Meituan LongCat (LongCat-2.0) directly, run:
```bash
zero providers setup longcat --set-active
```
For local models, run Ollama or LM Studio and then use `zero setup` or
@@ -186,7 +173,6 @@ Common slash commands:
| `/spec`, `/plan` | draft and review a plan before building |
| `/image` | attach an image for vision-capable models |
| `/resume`, `/rewind` | continue or roll back local sessions |
| `/loop` | repeat a prompt or custom `/command` on an interval (`/loop 5m /babysit-prs`) or self-paced |
| `/compact`, `/context` | manage context usage |
| `/permissions`, `/tools` | inspect available tools and policy |
| `/add-dir` | allow an extra write directory for this session |
@@ -284,39 +270,6 @@ zero cron scheduled agent jobs
zero update check for newer releases
```
## Extending Zero
### Project and personal instructions
Zero appends project-specific guidance to the system prompt from the first
`AGENTS.md`, `ZERO.md`, or `.zero/AGENTS.md` file found in each directory from
the git root down to your current working directory (checked in that order
per directory). Files are injected general-to-specific, capped at 8 KiB per
file and 32 KiB total.
A personal `ZERO.md` under `config.UserConfigDir()/zero/ZERO.md`
(`$XDG_CONFIG_HOME/zero/ZERO.md` or `~/.config/zero/ZERO.md` on Linux/macOS,
`%AppData%\Roaming\zero\ZERO.md` on Windows) applies across every workspace, ahead of any project guidelines.
### Plugins
Plugins are discovered from `~/.config/zero/plugins/<name>/plugin.json` (user
scope — `$XDG_CONFIG_HOME` or `~/.config` on every OS, independent of the
`config.UserConfigDir()` path used above) and `<cwd>/.zero/plugins/<name>/plugin.json`
(project scope — resolved from the current working directory, not the repo
root), and managed with `zero plugins`. A manifest can declare:
- `tools` — custom tools (`command`, `args`, `inputSchema`, and a
`permission` of `prompt` or `deny`; `allow` is honored only when manifest tool
auto-approval is enabled)
- `hooks` — commands run on `beforeTool`, `afterTool`, `sessionStart`, or
`sessionEnd`
- `prompts` and `skills` — additional prompt/skill files
MCP servers (`zero mcp`) and standalone markdown skills (`zero skills`) use
the same extension points and can also be wired up outside of a plugin
manifest.
## Appearance And Accessibility
| Control | Effect |
-297
View File
@@ -1,297 +0,0 @@
<p align="center">
<img src="docs/assets/zero-logo.png" alt="Zero" width="385">
</p>
<p align="center"><strong>一个独属于你的终端编码智能体。</strong></p>
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.25+" src="https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<br>
<a href="README.md">English</a> | <strong>中文</strong>
</p>
Zero 是一个用于本地终端的 AI 编码智能体。它可以检查仓库、编辑文件、运行命令、使用浏览器/终端辅助工具,并在你选择模型和权限级别的同时保持持久的本地会话。
```bash
zero
zero exec "修复 ./pkg 中失败的测试"
zero exec --output-format stream-json < turns.jsonl
```
## 为什么选择 Zero
- **使用你想要的模型。** 支持 OpenAI、Anthropic、Gemini、Groq、OpenRouter、DeepSeek、Mistral、xAI、Qwen、Kimi、GitHub Models、Ollama、LM Studio,或任何 OpenAI/Anthropic 兼容端点。
- **保持控制权。** 文件写入、Shell 命令、网络访问和工作区外写入都经过 Zero 的权限和沙箱策略。
- **在终端中工作。** TUI 具有模型/提供商选择器、图片输入、斜杠命令、实时计划/工具渲染、回滚滚动、主题以及恢复/分叉支持。
- **无 TUI 也能工作。** `zero exec` 可脚本化,支持文本/JSON/stream-JSON I/O、隔离的工作树、规范优先运行,以及用于 CI 的有意义的退出码。
- **保持上下文本地化。** 会话存储在磁盘上,可搜索、可恢复,且 Zero 不会作为遥测数据上传。
- **可扩展。** 使用 MCP 服务器、技能、插件、钩子和来自同一 CLI 的专业子智能体。
## 安装
### npm
```bash
npm install -g @gitlawb/zero
zero
```
npm 包安装一个小型包装器以及与你平台匹配的 Zero 二进制文件(从 GitHub Releases 获取)。支持 Linux、macOS 和 Windows 的 x64 和 arm64。
### Bun
Bun 默认不运行依赖的生命周期脚本,因此获取 Zero 二进制文件的 `postinstall` 会被跳过,首次运行会失败并显示 `No native binary found next to the npm wrapper`。
最简单的解决方法是安装后信任该包,这会运行被阻止的 postinstall。项目安装和全局安装均适用:
```bash
# 项目安装
bun add @gitlawb/zero
bun pm trust @gitlawb/zero
# 全局安装
bun add -g @gitlawb/zero
bun pm -g trust @gitlawb/zero
```
其他方式:在 `bun add` 之前将 `"trustedDependencies": ["@gitlawb/zero"]` 添加到项目的 package.json 以提前允许 postinstall;或在不支持 `bun pm trust` 的旧版 Bun 上手动运行安装程序(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`)。
### 安装脚本
Linux/macOS:
```bash
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
```
Windows PowerShell:
```powershell
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
```
### 从源码构建
源码构建需要 Go 1.25+。
```bash
git clone https://github.com/Gitlawb/zero.git
cd zero
go run ./cmd/zero
```
发布安装器和 npm 包需要已发布的 GitHub Release 资源。如果你在首次公开发布之前进行测试,请从源码构建:
```bash
go build -o zero ./cmd/zero
```
在 Linux 上,如果你需要原生沙箱,还需要构建沙箱辅助程序:
```bash
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
go build -o zero-seccomp ./cmd/zero-seccomp # 可选的兼容性包装器
```
将 `zero` 和 `zero-linux-sandbox` 放在 `PATH` 上的同一目录中(`~/.local/bin` 是一个好的默认选择)。macOS 不需要额外的辅助二进制文件。Windows 源码构建可以使用主 `zero.exe` 作为沙箱辅助程序;发布包仍然附带独立的 Windows 辅助可执行文件。
更多安装细节:[docs/INSTALL.md](docs/INSTALL.md)。
## 首次运行
启动 TUI:
```bash
zero
```
设置向导帮助你选择提供商和模型。你也可以从命令行配置提供商:
```bash
zero setup
zero providers list
zero models list
zero doctor
```
对于 API 提供商,在设置之前设置匹配的环境变量或在向导中输入密钥:
```bash
export OPENAI_API_KEY=sk-...
export ANTHROPIC_API_KEY=...
export GEMINI_API_KEY=...
export LONGCAT_API_KEY=...
```
要直接配置美团 LongCat(LongCat-2.0),运行:
```bash
zero providers setup longcat --set-active
```
对于本地模型,运行 Ollama 或 LM Studio,然后使用 `zero setup` 或 `zero providers detect`。
## 日常使用
### 交互式 TUI
```bash
zero
```
常用控制:
| 控制 | 操作 |
|---|---|
| `Enter` | 发送提示 |
| `/` | 打开斜杠命令建议 |
| `Shift+Tab` | 切换权限模式 |
| `Ctrl+B` | 显示/隐藏侧边栏 |
| `Ctrl+C` | 取消或退出 |
常用斜杠命令:
| 命令 | 用途 |
|---|---|
| `/model`、`/provider` | 切换活动模型/提供商 |
| `/spec`、`/plan` | 在构建之前起草和审查计划 |
| `/image` | 为视觉模型附加图片 |
| `/resume`、`/rewind` | 继续或回滚本地会话 |
| `/compact`、`/context` | 管理上下文使用 |
| `/permissions`、`/tools` | 检查可用工具和策略 |
| `/add-dir` | 为此会话授予额外的写入目录 |
| `/theme`、`/doctor`、`/config` | 调整外观和检查设置 |
### 无头 `exec` 模式
```bash
zero exec "解释 internal/agent/loop.go"
zero exec --model claude-sonnet-4.5 "重构配置加载器"
zero exec --use-spec "为 API 客户端添加速率限制"
zero exec --worktree "在隔离的工作树中尝试迁移"
zero exec --resume
zero exec --fork <session-id> "尝试另一种方法"
```
编程使用:
```bash
zero exec --input-format stream-json --output-format stream-json < turns.jsonl
```
Stream-JSON 协议文档在 [docs/STREAM_JSON_PROTOCOL.md](docs/STREAM_JSON_PROTOCOL.md)。
## 安全模型
Zero 旨在使副作用可见。
- 工作区读取默认允许。
- 文件写入限制在工作区内,除非你授予其他目录。
- Shell 命令、网络访问、破坏性命令和提权操作需要权限授权。
- `--add-dir <path>` 和 `/add-dir <path>` 授予额外的写入根目录,而不会给智能体整个文件系统。
- 不安全/自主模式是显式选择加入。
- 在 Zero 控制的界面上,密钥会从工具输出和日志中被脱敏。
示例:
```bash
zero --add-dir ../docs-site
zero exec --add-dir ../shared "更新两个仓库"
```
沙箱行为可以通过以下方式检查:
```bash
zero sandbox policy
zero sandbox grants list
```
## Web 和本地控制
Zero 包含本地文件/搜索/编辑/Shell 工具、用于公共 URL 的 `web_fetch`,以及用于额外工具的 MCP 支持。
对于本地开发服务器,使用 Shell 命令(如通过 `exec_command` 的 `curl`),这样正常的沙箱和权限策略就会生效。长时间运行的命令保持附加到后台终端会话,可以从 TUI 中列出或停止。
npm 包还包含本地浏览器/终端工具使用的浏览器和终端辅助包。源码构建可以在它们位于 `PATH` 上或在 Zero 的本地控制设置中配置时使用相同的辅助工具。
## 常用命令
```text
zero 交互式 TUI
zero exec 一次性或脚本化智能体运行
zero setup 首次运行提供商设置
zero auth 支持提供商的 OAuth/登录辅助
zero models 模型注册表和能力
zero providers 提供商配置和检测
zero doctor 设置、密钥和连接检查
zero context 上下文预算报告
zero repo-map 确定性仓库映射
zero repo-info 本地仓库摘要
zero search | find 搜索本地会话历史
zero sessions 检查、恢复、分叉和回滚会话
zero spec 管理规范模式草稿
zero specialist 管理专业子智能体
zero skills 管理 Markdown 指令技能
zero plugins 管理插件
zero hooks 管理生命周期钩子
zero mcp 管理 MCP 服务器和工具
zero serve --mcp 通过 MCP stdio 暴露 Zero 工具
zero sandbox 检查沙箱策略和授权
zero worktrees 准备隔离的 Git 工作树
zero verify 检测和运行本地验证检查
zero changes 检查和提交本地 Git 变更
zero usage Token 使用量和估算成本
zero cron 定时智能体任务
zero update 检查更新版本
```
## 外观和无障碍
| 控制 | 效果 |
|---|---|
| `NO_COLOR=<任意值>` | 禁用颜色输出 |
| `ZERO_THEME=<名称>` | 选择启动主题(`auto`、`dark`、`light`,或颜色主题如 `dracula`、`nord`、`gruvbox`、`tokyo-night`、`catppuccin`、`one-dark`、`solarized-dark`、`rose-pine`、`everforest`、`solarized-light`) |
| `--theme <名称>` | 从 CLI 选择 TUI 主题(相同名称) |
| `/theme` | 在 TUI 中打开主题选择器(实时预览;`/theme <名称>` 直接切换) |
| `ZERO_NO_FADE=1` | 禁用流式淡入动画 |
含义不仅仅依赖于颜色;差异、权限和状态也使用文本或标记符号。
## 开发
```bash
go test ./...
go run ./cmd/zero-release build
go run ./cmd/zero-release smoke
go run ./cmd/zero-perf-bench
```
交叉编译示例:
```bash
go run ./cmd/zero-release build --goos linux --goarch amd64
go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero.exe
```
## 文档
- [安装](docs/INSTALL.md)
- [更新流程](docs/UPDATE.md)
- [Stream-JSON 协议](docs/STREAM_JSON_PROTOCOL.md)
- [专家](docs/SPECIALISTS.md)
- [GitHub Action](docs/GITHUB_ACTION.md)
- [基准测试](docs/BENCHMARK.md)
- [性能](docs/PERFORMANCE.md)
- [智能体评估](docs/AGENT_EVALS.md)
## 贡献
欢迎贡献。阅读 [CONTRIBUTING.md](CONTRIBUTING.md),运行相关测试,然后提交一个聚焦的拉取请求。
安全报告应遵循 [SECURITY.md](SECURITY.md)。
## 许可证
Zero 基于 [MIT 许可证](LICENSE) 发布。
+9 -8
View File
@@ -27,14 +27,14 @@ inputs:
api-key:
description: >-
The provider API key. Pass it from a repository or organization secret;
it is exported only for the ZERO step and never written to the log.
it is exported into the step environment and never written to the log.
required: false
default: ""
api-key-env:
description: >-
Name of the environment variable the chosen provider reads its key from
(for example OPENAI_API_KEY or ANTHROPIC_API_KEY). When set together with
api-key, that variable is exported only for the ZERO step. Leave the action
api-key, that variable is exported for the run. Leave the action
provider-agnostic by passing the env name your provider expects rather
than hardcoding one here.
required: false
@@ -180,7 +180,6 @@ runs:
ZERO_INPUT_ADD_DIR: ${{ inputs.add-dir }}
ZERO_INPUT_WORKTREE: ${{ inputs.worktree }}
ZERO_INPUT_OUTPUT_FORMAT: ${{ inputs.output-format }}
ACTION_PATH: ${{ github.action_path }}
RUNNER_TEMP: ${{ runner.temp }}
run: |
set -euo pipefail
@@ -193,9 +192,10 @@ runs:
echo "::error::api-key-env must be a valid environment variable name." >&2
exit 2
fi
# Export in-process for this Run ZERO step so zero can read it; do
# not persist provider credentials to later job steps.
# Export in-process so the zero invocation in this same run block sees
# it, and append to GITHUB_ENV so any later steps inherit it too.
export "${ZERO_INPUT_API_KEY_ENV}=${ZERO_INPUT_API_KEY}"
printf '%s=%s\n' "${ZERO_INPUT_API_KEY_ENV}" "${ZERO_INPUT_API_KEY}" >> "${GITHUB_ENV}"
fi
if [ -n "${ZERO_INPUT_PROVIDER}" ]; then
export ZERO_PROVIDER="${ZERO_INPUT_PROVIDER}"
@@ -248,9 +248,10 @@ runs:
echo "exit-code=${code}" >> "${GITHUB_OUTPUT}"
echo "output-file=${output_file}" >> "${GITHUB_OUTPUT}"
# Best-effort one-line summary. Structured output ends with lifecycle
# metadata, so parse final/error events instead of tailing stdout.
summary="$(node "${ACTION_PATH}/scripts/action-summary.mjs" "${ZERO_INPUT_OUTPUT_FORMAT}" "${output_file}" || true)"
# Best-effort one-line summary: last non-empty line of the captured output.
summary="$(grep -v '^[[:space:]]*$' "${output_file}" | tail -n 1 || true)"
# Keep the summary single-line and bounded for the step output.
summary="$(printf '%s' "${summary}" | cut -c1-280)"
{
echo "summary<<ZERO_EOF"
printf '%s\n' "${summary}"
+3 -5
View File
@@ -66,12 +66,10 @@ if (!existsSync(nativePath)) {
'\n' +
(ranByBun
? 'You installed with Bun, which does not run dependency lifecycle scripts\n' +
'by default. Trust the package to run the blocked postinstall:\n' +
' bun pm trust @gitlawb/zero (project install)\n' +
' bun pm -g trust @gitlawb/zero (global install)\n' +
'On Bun versions without `bun pm trust`, add\n' +
'by default. To let it run on future installs, add zero to your\n' +
'project\'s trustedDependencies:\n' +
' "trustedDependencies": ["@gitlawb/zero"]\n' +
'to your project package.json and reinstall.\n' +
'then reinstall.\n' +
'\n'
: '') +
'If that fails, build from source: https://github.com/Gitlawb/zero\n' +
+7 -9
View File
@@ -41,9 +41,8 @@ jobs:
> provider-neutral: `api-key-env` is the environment variable name the chosen
> provider reads its key from (for example `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`,
> `OPENROUTER_API_KEY`), and `api-key` is the secret value. The action exports
> that variable only for the ZERO step and never prints it. If your repository
> already commits a `.zero/config.json` with an active provider, you can omit
> `provider`.
> that variable for the run and never prints it. If your repository already
> commits a `.zero/config.json` with an active provider, you can omit `provider`.
## Inputs
@@ -52,8 +51,8 @@ jobs:
| `prompt` | one of `prompt`/`prompt-file` | `""` | The instruction for ZERO to execute. |
| `prompt-file` | one of `prompt`/`prompt-file` | `""` | Path (relative to `working-directory`) to a file whose contents are the prompt. |
| `provider` | no | `""` | Provider id to activate (e.g. `openai`, `anthropic`, `gemini`, `ollama`, or any compatible endpoint). |
| `api-key` | no | `""` | The provider API key. Pass from a secret; exported only for the ZERO step and never logged. |
| `api-key-env` | no | `""` | Env var name the provider reads its key from (e.g. `OPENAI_API_KEY`). Exported only for the ZERO step when used with `api-key`. |
| `api-key` | no | `""` | The provider API key. Pass from a secret; never logged. |
| `api-key-env` | no | `""` | Env var name the provider reads its key from (e.g. `OPENAI_API_KEY`). Exported with `api-key`. |
| `model` | no | `""` | Model id. Defaults to the resolved provider's default. |
| `mode` | no | `""` | Run mode (`zero exec --mode`), e.g. `smart`, `deep`, `fast`. |
| `auto` | no | `low` | Autonomy ceiling (`zero exec --auto`): `low`, `medium`, or `high`. Conservative by default. |
@@ -163,10 +162,9 @@ jobs:
## Security notes
- **Secrets are passed as Action secrets and never logged.** The action exports
`api-key` (under `api-key-env`) only for the ZERO step. `slack-webhook-url`
is passed only to the Slack post step. They are never echoed or persisted to
later workflow steps, and ZERO redacts secret-shaped strings from its own
output.
`api-key` (under `api-key-env`) and `slack-webhook-url` into the step
environment only. They are never echoed, and ZERO redacts secret-shaped
strings from its own output.
- **The sandbox is always active.** This action never passes
`--skip-permissions-unsafe`, so writes stay inside the checked-out repository
(plus any roots you grant with `add-dir`). Unsafe mode is never enabled
-787
View File
@@ -1,787 +0,0 @@
# How Zero Works
This document explains Zero's architecture at a high level: the layers, module
boundaries, data flow, and runtime concepts behind the interactive TUI and the
headless `zero exec` mode.
It is written as a progressive map: each high-level section introduces the
concepts you need for orientation, then points to the later section that explains
the mechanics in more detail.
## Big Picture
Zero is a Go CLI application with two main user-facing surfaces:
- **Interactive TUI** (`zero`): a Bubble Tea application that renders chat,
tool cards, permission prompts, model/provider pickers, and session controls.
- **Headless exec** (`zero exec ...`): a scriptable command runner that uses the
same agent loop and tools, but writes text, JSON, or stream-JSON events.
Both surfaces converge on the same core agent runtime:
```mermaid
flowchart TD
User[User / automation] --> CLI[cmd/zero -> internal/cli]
CLI --> TUI[Interactive TUI<br/>internal/tui]
CLI --> Exec[Headless exec<br/>internal/cli/exec.go]
CLI --> ACP[ACP / editor bridge<br/>internal/acp]
TUI --> Agent[Agent loop<br/>internal/agent]
Exec --> Agent
ACP --> Agent
Agent --> Provider[Provider runtime<br/>internal/zeroruntime.Provider]
Provider --> APIs[OpenAI / Anthropic / Gemini / compatible APIs]
Agent --> Registry[Tool registry<br/>internal/tools.Registry]
Registry --> CoreTools[Core tools<br/>read/edit/shell/search/plan]
Registry --> MCP[MCP tools<br/>internal/mcp]
Registry --> Specialists[Specialist tools<br/>internal/specialist]
Registry --> Plugins[Plugin tools<br/>internal/plugins]
Registry --> LocalControl[Browser / terminal / desktop helpers]
Agent --> Sandbox[Permission + sandbox engine<br/>internal/sandbox]
Agent --> Hooks[Hooks dispatcher<br/>internal/hooks]
Agent --> Sessions[Session event store<br/>internal/sessions]
```
The important split is:
- `internal/tui` is the **presentation layer**. For the mechanics, see
[Presentation Layer: Bubble Tea TUI](#presentation-layer-bubble-tea-tui) and
[How the TUI and Agent Loop Connect](#how-the-tui-and-agent-loop-connect).
- `internal/agent` is the **reasoning/tool loop**. For the per-turn mechanics,
see [Agent Loop](#agent-loop), [Tool Execution Lifecycle](#tool-execution-lifecycle),
and [Context Budget and Compaction](#context-budget-and-compaction).
- `internal/tools`, `internal/sandbox`, and provider packages are the **runtime
capability layer**. For the details, see [Runtime Provider Layer](#runtime-provider-layer),
[Tools and Capabilities](#tools-and-capabilities), and
[Permission and Sandbox Model](#permission-and-sandbox-model).
- `internal/sessions` is the **durable event log** used for resume, fork,
rewind, sub-agents, and history. See [Session Storage and History](#session-storage-and-history).
## Architecture Spine
The easiest way to understand Zero is not as a set of independent packages, but
as one spine with several mechanisms attached to it:
```mermaid
flowchart LR
Surface[Surface<br/>TUI / exec / ACP] --> Run[Run request<br/>prompt + runtime options]
Run --> Agent[Agent loop<br/>turns, tools, recovery]
Agent --> Capabilities[Capabilities<br/>providers + tools + sandbox]
Capabilities --> Events[Runtime events<br/>text, tool calls, permissions, usage]
Events --> Surface
Events --> Sessions[Session log<br/>resume, fork, rewind, history]
Config[Config / guidelines / skills] --> Run
Extensions[MCP / specialists / plugins / hooks] --> Capabilities
```
The **TUI + agent loop** is the main story because it explains the normal user
experience: type a prompt, watch the model stream, approve tool use, see file
changes, and receive a final answer. Other mechanisms are still important, but
most of them attach to the same spine:
| Mechanism | Where it attaches | What it changes |
| --- | --- | --- |
| Config, provider profiles, model registry | Before a run starts | Which provider/model is used and what defaults apply. |
| Project/user guidelines, skills, specialists context | Prompt construction | What instructions and available capabilities the model sees. |
| Tool registry, MCP, plugins, local-control helpers | Capability layer | Which actions the model may request. |
| Permission policy, sandbox, command-prefix grants | Tool execution | Whether a requested action can run, needs approval, or is denied. |
| Hooks | Tool lifecycle | Optional before/after observers or blockers around tool calls. |
| Sessions, checkpoints, compaction metadata | Event persistence/context | What can be resumed, forked, rewound, or summarized for later turns. |
| Stream-JSON | Surface layer | How the same runtime events are emitted for automation instead of rendered in the TUI. |
So this document focuses first on the TUI/agent-loop bridge, then explains the
supporting mechanisms in terms of where they connect to that bridge.
Use this as the reading path:
| If you see this concept in a high-level flow | Read this detail section |
| --- | --- |
| Surface submits a run | [Startup Flow](#startup-flow), then [How the TUI and Agent Loop Connect](#how-the-tui-and-agent-loop-connect) |
| Agent builds context and advances turns | [Agent Loop](#agent-loop) |
| “Maybe compact context” | [Context Budget and Compaction](#context-budget-and-compaction) |
| Model requests a tool | [Tool Execution Lifecycle](#tool-execution-lifecycle), then [Tools and Capabilities](#tools-and-capabilities) |
| Tool needs approval or sandbox access | [Permission and Sandbox Model](#permission-and-sandbox-model) |
| Events appear in the UI or stream-JSON | [How the TUI and Agent Loop Connect](#how-the-tui-and-agent-loop-connect), [Stream-JSON Protocol](#stream-json-protocol) |
| Resume, fork, rewind, or sub-agent history | [Session Storage and History](#session-storage-and-history) |
## Main Packages
The sections above describe the architecture in concepts. If you want to connect
those concepts to implementation details, use this table as the code map before
reading the deeper flows below.
| Package | Role |
| --- | --- |
| `cmd/zero` | Tiny binary entrypoint; calls `internal/cli.Run`. |
| `internal/cli` | Command parsing, config resolution, provider creation, registry setup, TUI/exec/ACP launch paths. |
| `internal/tui` | Bubble Tea model/update/view code, transcript rendering, composer, prompts, slash commands, setup wizard. |
| `internal/agent` | Core loop that sends messages to the model, streams output, executes tools, handles permissions, compaction, retries, and final answers. |
| `internal/zeroruntime` | Provider-neutral message, tool-call, stream, usage, and image types. Providers implement `Provider.StreamCompletion`. |
| `internal/providers` | Factory that turns a resolved provider profile into an OpenAI, Anthropic, Gemini, Codex, or compatible provider adapter. |
| `internal/tools` | Tool interface, registry, core read/write/shell/network tools, local-control wrappers, tool safety metadata, redaction, output ceilings. |
| `internal/sandbox` | Permission policy, path/network checks, grant store, command-prefix approvals, and platform sandbox backends. |
| `internal/sessions` | Local append-only session storage: `metadata.json` + `events.jsonl`. |
| `internal/config` | User/project config resolution, active provider, preferences, sandbox/tool settings. |
| `internal/mcp` | MCP server config, client runtime, permission store, and MCP tool registration. |
| `internal/specialist` / `internal/swarm` | Sub-agent manifests and team/member orchestration exposed as tools. |
| `internal/localcontrol` / `internal/browser` | Runtime helpers used by config-gated local-control tool wrappers. |
| `internal/plugins` / `internal/skills` / `internal/hooks` | Extension surfaces loaded into the agent context, tool registry, or tool lifecycle. |
| `internal/streamjson` | Machine-readable protocol for `zero exec --input-format/--output-format stream-json`. |
## Startup Flow
### Interactive TUI (`zero`)
```mermaid
sequenceDiagram
participant Main as cmd/zero
participant CLI as internal/cli
participant Config as internal/config
participant TUI as internal/tui
participant Agent as internal/agent
Main->>CLI: Run(args)
CLI->>Config: resolve config for workspace
CLI->>CLI: build provider
CLI->>CLI: build tool registry
CLI->>CLI: register core, config-gated local-control, specialists, MCP, plugins
CLI->>CLI: create sandbox engine + session store
CLI->>TUI: run Bubble Tea with Options
TUI->>TUI: Init / Update / View event loop
TUI->>Agent: agent.Run on submitted prompt
```
The TUI owns terminal interaction. It tracks composer state, transcript rows,
scrolling, permission dialogs, model/provider pickers, setup wizard state, and
visual updates. When the user submits a prompt, the TUI builds `agent.Options`
with callbacks like `OnText`, `OnToolCall`, `OnToolResult`, `OnPermission`,
`OnAskUser`, and `OnUsage`. Those callbacks convert runtime events into visible
transcript rows and session events.
### Headless exec (`zero exec`)
```mermaid
flowchart TD
A[Parse exec flags] --> B[Resolve config + provider]
B --> C[Build tool registry]
C --> D[Resolve prompt / stream-json input / images]
D --> E[Prepare or resume session]
E --> F[Create stream-json/text/json writer]
F --> G[agent.Run]
G --> H[Write final output and exit code]
```
`zero exec` uses the same `agent.Run` loop as the TUI, but without interactive
terminal state. It can persist sessions, resume/fork existing sessions, run in
stream-JSON mode for automation, and always requires a stronger completion
signal so a no-tool assistant response is not treated as success while work is
still pending.
## Agent Loop
The core loop lives in `internal/agent.Run`. Conceptually, every turn follows
this cycle:
```mermaid
flowchart TD
Start[Build system prompt + user prompt] --> Expose[Partition visible tools]
Expose --> Compact[Maybe compact context]
Compact --> Request[Send CompletionRequest to provider]
Request --> Stream[Collect streamed text, reasoning, usage, tool calls]
Stream --> HasTools{Tool calls?}
HasTools -- No --> FinalCheck{Final answer acceptable?}
FinalCheck -- Yes --> Done[Return Result]
FinalCheck -- No --> Nudge[Append user nudge and continue]
HasTools -- Yes --> Execute[Execute tool calls]
Execute --> Permission[Permission / sandbox / hooks]
Permission --> ToolResult[Append tool result messages]
ToolResult --> Diagnostics[Post-edit diagnostics / self-correct]
Diagnostics --> Expose
```
Key responsibilities of the loop:
1. **Build prompt state** — combines the system prompt, project/user guidelines,
loaded skills/specialist instructions, the current user prompt, and images. The
resulting message list is the model-facing context that later tool results are
appended to. Resumed session history is replayed by the caller before a new run,
not passed as a separate `agent.Run` history field.
2. **Expose tools** — starts from the registry built by the CLI, then filters
tool definitions by permission mode, enabled or disabled filters, and
deferred-loading rules. Tool schemas also count toward context budget, so they
matter for compaction decisions.
3. **Call provider** — sends a provider-neutral `zeroruntime.CompletionRequest`.
4. **Stream events** — forwards text, reasoning, tool-call deltas, and usage to
the caller via callbacks.
5. **Execute tools** — decodes tool arguments, checks filters, applies
permissions and sandbox policy, dispatches hooks, runs the registered tool,
redacts output, and appends a tool message. See
[Tool Execution Lifecycle](#tool-execution-lifecycle).
6. **Recover and guard** — handles malformed tool calls, repeated failures,
empty turns, context pressure, stream reconnect/retry, and max-turn fallback.
Context pressure is handled by [Context Budget and Compaction](#context-budget-and-compaction).
7. **Finalize** — returns a final answer or a structured incomplete/stop reason.
The model never directly mutates the workspace. It asks for tool calls; Zero
validates and executes those calls through the registry, permission policy, and
sandbox engine.
## Tool Execution Lifecycle
A tool call is part of the model conversation, not a side channel that bypasses
it. The model proposes an action, Zero decides whether that action is allowed to
run, the tool returns a structured result, and that result becomes context for the
next provider request.
```mermaid
sequenceDiagram
participant Model as LLM / provider
participant Agent as agent.Run
participant Policy as filters + permissions + sandbox
participant Hooks as hooks
participant Tool as tool implementation
participant Surface as TUI / exec callbacks
participant Transcript as model conversation + session events
Agent->>Model: request with messages + available tool schemas
Model-->>Agent: assistant text and/or tool_call(name, args)
Agent-->>Surface: stream text / tool-call preview
Agent->>Agent: collect and decode tool arguments
Agent->>Policy: check tool filters, permission mode, sandbox policy
alt denied before execution
Policy-->>Agent: denied result
Agent-->>Surface: tool failed/denied event
Agent->>Transcript: append tool result explaining denial
else needs user approval
Policy-->>Surface: permission request
Surface-->>Policy: allow / deny / remember decision
Policy-->>Agent: decision
end
alt approved to execute
Agent->>Hooks: beforeTool hooks
Hooks-->>Agent: allow / block / annotate
Agent->>Tool: execute(ctx, args)
Tool-->>Agent: output, error, metadata
Agent->>Hooks: afterTool hooks
Agent->>Agent: redact, truncate, classify success/failure
Agent-->>Surface: OnToolResult callback
Agent->>Transcript: append tool result message
end
Agent->>Model: next request includes prior assistant tool_call + tool result
Model-->>Agent: continue with more tools or final answer
```
Conceptually, one requested tool goes through these phases:
1. **Proposal** — the provider streams assistant content and may emit one or more
tool calls. Zero can show the partial tool arguments in the TUI while they are
still streaming, but nothing has executed yet.
2. **Decode and gate** — the agent validates JSON arguments, checks whether the
tool is enabled, applies permission mode, asks the sandbox engine about path,
network, shell, or command-prefix constraints, and may invoke permission
callbacks.
3. **Execute** — the selected registry entry runs locally, through MCP, as a
specialist/sub-agent, or through a plugin/local-control helper. Hooks can run
before or after this step.
4. **Normalize result** — Zero turns the outcome into an agent `ToolResult`: a
success, failure, denial, cancellation, timeout, or validation error with
output/metadata that may be redacted or truncated.
5. **Append to context** — the result is appended as a tool-result message paired
with the assistant tool call. The next model request includes that result, so
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.
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
an error such as “old string not found”; a denied shell command tells the model
that the action is not currently permitted. The model is expected to use that
feedback to continue, choose another tool, ask the user, or explain the blocker.
The judgement that “information is sufficient” is made by the model, not by the
TUI. Zero controls the loop mechanics: it keeps appending messages, enforces
safety, retries or nudges around malformed/empty/incomplete turns, and stops on
hard limits. But the semantic decision to call another tool versus produce a
final answer comes from the next provider response. In other words:
```mermaid
flowchart TD
Result[Tool result appended to conversation] --> Next[Next provider request]
Next --> Decide{Model response}
Decide -- More information/action needed --> MoreTools[Request another tool]
MoreTools --> Result
Decide -- User input needed --> AskUser[Call ask_user or explain question]
AskUser --> Result
Decide -- Enough information --> Final[Final assistant answer]
Decide -- Invalid / empty / unsafe --> Guard[Agent guardrail, retry, denial, or stop reason]
Guard --> Next
```
This is why tool output is usually more than UI decoration. It is part of the
working memory for the next model turn, subject to context-window pressure and
compaction. The TUI may summarize or prettify it for the user, but the agent loop
also stores the machine-facing result that the model sees.
## Context Budget and Compaction
The `Maybe compact context` step in the agent-loop diagram is Zero's context-window
pressure valve. It is not a separate memory engine; it rewrites the current
model-facing conversation so the next provider request fits within the configured
context window.
Compaction is enabled only when the run knows a positive `ContextWindow`. When it
is enabled, the loop does this near the top of each turn:
```mermaid
flowchart TD
Tools[Partition visible tools] --> Estimate[Estimate messages + tool-definition tokens]
Estimate --> Under{Under threshold?}
Under -- Yes --> Keep[Keep context as-is]
Under -- No --> Grown{Grown past low-water mark?}
Grown -- No --> Keep
Grown -- Yes --> Prune[Prune stale large tool outputs]
Prune --> Recheck{Back under threshold?}
Recheck -- Yes --> KeepPruned[Use pruned context]
Recheck -- No --> Summarize[Summarize oldest middle]
Summarize --> Replace[system messages + summary + recent suffix]
```
The trigger is intentionally approximate:
- Zero estimates request size using message text, images, tool-call arguments, and
the tool definitions advertised on that turn.
- The proactive threshold is roughly **two-thirds of the context window**.
- Provider usage from completed turns is used to calibrate the rough estimate over
time, so the budget gauge and compaction decision track the provider more
closely after a few turns.
- A low-water mark prevents the loop from re-summarizing the same already-compacted
history on every turn.
When compaction actually runs, it tries to preserve the parts most likely to be
needed by the model:
- leading system messages stay verbatim;
- the most recent turns stay verbatim (`CompactionPreserveLast`, defaulting to the
agent's normal preserve count);
- the preserved suffix is widened to a provider-valid turn boundary so tool-result
messages are not replayed without their corresponding assistant tool call;
- the older middle is summarized into one user-role message labeled
`[Summary of earlier conversation]`;
- structured state that should not be paraphrased away, such as active plan/loaded
skill state, is preserved alongside the prose summary.
There are two compaction paths:
1. **Proactive compaction** runs before the provider call when the estimated
request is over threshold.
2. **Reactive compaction** runs after a provider returns a context-limit style
error. Zero compacts once and retries that same turn; if the provider still
rejects the request, the error is surfaced rather than looping forever.
Compaction failures are conservative. If the summarizer fails during proactive
compaction, Zero keeps the original messages and tries to continue. It does not
drop conversation history just to make room.
## Presentation Layer: Bubble Tea TUI
`internal/tui` is a Bubble Tea application. The usual Bubble Tea shape applies:
```mermaid
flowchart LR
Init[model.Init] --> Update[model.Update]
Update --> View[model.View]
View --> Terminal[Terminal frame]
Terminal --> Msg[tea.Msg: key, mouse, window, agent event]
Msg --> Update
```
The TUI is intentionally not the agent brain. It is responsible for:
- collecting user input from the composer;
- rendering assistant text, reasoning, tool cards, permissions, plans, usage,
files, and sidebars;
- handling slash commands and modal surfaces;
- converting agent callbacks into Bubble Tea messages;
- persisting visible session events;
- allowing live provider/model/permission changes.
When a prompt is submitted, the TUI starts a background command that invokes
`agent.Run`. Streaming callbacks send messages back into the Bubble Tea update
loop, so rendering remains responsive while the agent is working.
## How the TUI and Agent Loop Connect
A common source of confusion is that Zero has two event loops running at once:
1. **Bubble Tea loop** — owns terminal input, in-memory UI state, and frames.
2. **Agent loop** — owns model turns, tool execution, permissions, retries, and
stop conditions.
They are connected by `tea.Cmd` and `agent.Options` callbacks. The TUI never
runs provider/tool logic directly inside `Update`; instead, submit handling gates
and dispatches a prompt, the TUI starts a background command, and that command
calls `agent.Run` with callbacks that send runtime events back to the Bubble Tea
program. Function names in this section are illustrative code anchors, not API
boundaries.
```mermaid
sequenceDiagram
participant User
participant TUI as Bubble Tea model<br/>internal/tui
participant Cmd as tea.Cmd goroutine<br/>runAgentWithOptions
participant Agent as agent.Run<br/>internal/agent
participant Provider as Provider.StreamCompletion
participant Tools as Tool registry + sandbox
participant Store as Session store
User->>TUI: type prompt / press Enter
TUI->>TUI: validate/gate submit and append visible user row
TUI->>Cmd: launch tea.Cmd with runID + cancellable context
Cmd->>Agent: agent.Run(prompt, provider, options)
Agent->>Provider: stream completion request
Provider-->>Agent: text / reasoning / tool-call deltas
Agent-->>Cmd: OnText / OnReasoning / OnToolCallStart callbacks
Cmd-->>TUI: runtimeMessageSink(tea.Msg)
TUI->>TUI: Update appends/updates transcript rows
TUI-->>User: View renders new frame
Agent->>Tools: execute collected tool call
Tools->>Tools: permission, sandbox, hooks, redaction
Tools-->>Agent: tool result
Agent-->>Cmd: OnToolCall / OnToolResult / OnPermission / OnUsage
Cmd->>Store: batch session events for resume/history
Cmd-->>TUI: final agentResponseMsg
TUI->>TUI: mark run complete, flush rows/events, unlock composer
```
In the TUI path, the same event can have three representations:
| Runtime thing | Agent representation | TUI representation | Session representation |
| --- | --- | --- | --- |
| Assistant text | streamed provider text forwarded through `OnText` | incremental assistant row text | assistant message event |
| Reasoning preview | `OnReasoning` callback | collapsible/preview reasoning row | usually not the same as final assistant text |
| Tool call | `agent.ToolCall` from collected provider stream | tool card, specialist card, or plan panel update | `EventToolCall` |
| Tool result | `agent.ToolResult` | result card/status, changed files, plan/specialist updates | `EventToolResult` |
| Permission prompt/audit | `PermissionRequest` / `PermissionEvent` | modal prompt or permission row | permission request/decision events |
| Usage/context | provider usage + measured context | context gauge / usage metadata | usage event |
### One interactive turn, end to end
For a normal prompt in the interactive TUI:
1. Submit handling validates composer state and command gates, then dispatches
the prompt to the launch path.
2. The launch path appends immediate UI rows, creates a cancellable run context,
and starts `runAgent` as a `tea.Cmd`; run bookkeeping assigns the `runID` used
to correlate later events.
3. `runAgentWithOptions` copies the current UI-controlled runtime choices into
`agent.Options`: provider/model labels, permission mode, cwd, session ID,
images, registry, response style, context window, and self-correction hooks.
4. The command wraps agent callbacks. For example:
- `OnText` sends incremental assistant text into the TUI message sink.
- `OnToolCallStart` / `OnToolCallDelta` show live tool-argument streaming.
- `OnToolCall` creates visible tool rows and appends session events.
- `OnToolResult` creates result rows, updates plan/specialist side panels, and
records changed files.
- `OnPermissionRequest` sends a prompt to the TUI and blocks the agent loop
until the user answers or the run context is cancelled.
- `OnAskUser` does the same for model-initiated clarification questions.
5. `agent.Run` builds provider-neutral messages and tool definitions, streams a
provider response, executes any requested tools through the registry/sandbox,
appends tool results back into the model conversation, and repeats until it has
a final answer or a stop condition.
6. When `agent.Run` returns, the command emits `agentResponseMsg`. The Bubble Tea
loop marks the run complete, flushes queued rows/session events, updates usage
and file state, and re-enables the composer.
The important architectural point: **the TUI is stateful and reactive, but the
agent loop is the source of runtime truth for model/tool progress**. Presentation
state is derived from callback events and persisted session events; workspace
changes happen only through tools executed by the agent runtime.
### Feature flows use the same bridge
For one interactive turn, the supporting systems do not run beside the TUI/agent
loop; they run inside or around it:
```mermaid
flowchart TD
Submit[TUI submit] --> Options[Build agent.Options]
Options --> Context[Assemble context<br/>config, guidelines, history, skills]
Context --> ProviderCall[Provider stream]
ProviderCall --> StreamEvents[Text/reasoning/tool-call events]
StreamEvents --> Render[TUI render callbacks]
ProviderCall --> ToolCall{Tool call?}
ToolCall -- No --> Final[Final response]
ToolCall -- Yes --> ToolGate[Tool gate<br/>filters, permissions, sandbox]
ToolGate --> HooksBefore[beforeTool hooks]
HooksBefore --> Execute[Tool / MCP / specialist / plugin]
Execute --> HooksAfter[afterTool hooks]
HooksAfter --> ToolResult[Tool result message]
ToolResult --> Persist[Session events]
ToolResult --> Context
Final --> Persist
Persist --> Resume[Resume / fork / rewind / parent metadata]
```
Most interactive features are variations of that same bridge rather than separate
architectures:
- **Slash commands** either mutate TUI state directly (`/model`, `/permission`,
`/help`) or start short `tea.Cmd` jobs that later return a message to `Update`
(`/doctor`, `/search`, `/compact`, session commands).
- **Permissions** sit between a model-requested tool call and actual execution:
the agent blocks on `OnPermissionRequest`, the TUI surfaces a modal and sends
the decision back through a channel, and the session log records the
request/decision events.
- **Plans, specialists/sub-agents, and ask-user prompts** all follow the same
runtime pattern: the model requests a capability, the agent records the
call/result in its conversation, and the TUI mirrors the visible state as plan
panels, specialist cards, or question forms. Specialist session metadata can
carry parent/root and sub-agent/spec fields, with richer linkage depending on
the specialist or swarm path.
- **Streaming tool writes** start as provider tool-call deltas before execution;
the TUI shows the arguments live, then replaces/augments them with the actual
`ToolResult` after the tool runs.
- **Compaction** is an agent-loop context-management step; the TUI may show that
it happened, but the purpose is to keep the next provider request within budget.
- **Stream-JSON** is another surface over the same callbacks: `zero exec`
serializes the events as JSON protocol events instead of Bubble Tea views.
So a feature-specific deep dive should usually explain: what user event starts it,
which `tea.Msg`/callback carries it, which agent/tool event is authoritative, and
which session events make it resumable.
## Runtime Provider Layer
Providers are hidden behind this interface in `internal/zeroruntime`:
```go
type Provider interface {
StreamCompletion(ctx context.Context, request CompletionRequest) (<-chan StreamEvent, error)
}
```
The agent loop only knows about provider-neutral messages, tool definitions,
stream events, reasoning blocks, images, and token usage. Provider adapters in
`internal/providers/*` translate those neutral types to provider-specific APIs:
- OpenAI and OpenAI-compatible APIs;
- Anthropic and Anthropic-compatible APIs;
- Gemini;
- Codex / ChatGPT OAuth-backed runtime.
`internal/providers.New` resolves the configured model through the model
registry/catalog, normalizes API model IDs and base URLs, attaches API keys or
OAuth resolvers, and returns the concrete adapter.
## Tools and Capabilities
Tools implement a common interface and are registered by name in
`internal/tools.Registry`.
```mermaid
flowchart TD
AgentToolCall[Tool call from model] --> Decode[Decode JSON args]
Decode --> Filter[Enabled/disabled + permission-mode filter]
Filter --> Precheck[Pre-permission rejecters]
Precheck --> SandboxEval[Sandbox Evaluate]
SandboxEval --> Prompt{Needs user approval?}
Prompt -- Yes --> Decision[Permission callback]
Prompt -- No --> Run[Registry.RunWithOptions]
Decision --> Run
Run --> Redact[Redact secrets + enforce output ceiling]
Redact --> Message[Return tool result to model]
```
Core tool groups include:
- **Read-only tools**: file reads, directory listing, glob, grep, LSP navigation,
skills, `ask_user`, permission requests.
- **Write tools**: `write_file`, `edit_file`, `apply_patch`, `update_plan`.
- **Shell tools**: `exec_command`, `write_stdin`, legacy `bash`.
- **Network tools**: `web_fetch` and optional web search backend.
- **Local-control tools**: config-gated wrappers for browser, terminal, desktop,
and artifact helpers backed by packages such as `internal/localcontrol` and
`internal/browser`.
- **Extension tools**: MCP tools, specialists/sub-agents, swarm tools, plugins.
Tool definitions include safety metadata: side-effect class, permission level,
reason, and whether prompt-gated tools are advertised in auto-like modes. The
agent uses this metadata plus per-turn filters and deferred-loading state to
choose which registered tools the model can see and whether a call can run
immediately.
## Permission and Sandbox Model
Zero has two related but separate checks:
1. **Tool permission metadata** says whether a tool is allowed, denied, or needs
approval.
2. **Sandbox policy** evaluates the actual request: path scope, network access,
destructive shell patterns, explicit escalation, persistent/session grants,
and platform isolation availability.
```mermaid
flowchart TD
Call[Tool call] --> Safety[Tool Safety metadata]
Call --> Scope[Workspace/additional roots scope]
Safety --> Engine[Sandbox Engine Evaluate]
Scope --> Engine
Engine --> Allow[Allow]
Engine --> Prompt[Prompt user]
Engine --> Deny[Deny]
Prompt --> Grant[Turn/session/persistent grant]
Grant --> Execute[Execute tool]
Allow --> Execute
Deny --> Result[Return denied tool result]
```
The sandbox engine can:
- auto-allow workspace writes for safe file-edit tools;
- block reads/writes outside the allowed scope;
- prompt for recoverable outside-workspace access;
- deny or prompt for network depending on policy;
- detect destructive shell commands;
- run shell commands through a platform backend when available;
- remember session or persistent grants;
- support narrow command-prefix approvals.
The user-facing TUI and `zero exec` mode both route permission requests through
callbacks, but the TUI can show interactive modals while headless mode generally
returns non-interactive fallback behavior unless a protocol/client handles it.
## Session Storage and History
Sessions live locally under Zero's data directory. A session directory contains:
- `metadata.json` — session ID, title, cwd, provider/model, parent/root IDs,
spec/sub-agent fields, timestamps, and event count.
- `events.jsonl` — append-only event log with messages, tool calls/results,
permissions, usage, checkpoints, forks, compaction, and specs. TUI and exec use
the richer event stream; ACP currently records conversational message history
while streaming tool and permission updates to the client.
```mermaid
flowchart LR
TUI[TUI callbacks] --> Store[internal/sessions.Store]
Exec[exec recorder] --> Store
Store --> Metadata[metadata.json]
Store --> Events[events.jsonl]
Events --> Resume[resume]
Events --> Fork[fork]
Events --> Rewind[rewind/checkpoints]
Events --> Children[parent/root metadata]
```
This is why sessions can be resumed, forked, rewound, and inspected without a
remote service. Zero stores the local event history; providers only receive the
messages needed for the current completion request.
## Stream-JSON Protocol
`zero exec --output-format stream-json` emits newline-delimited JSON events for
integrations and CI. The protocol is implemented in `internal/streamjson` and is
specified in [STREAM_JSON_PROTOCOL.md](STREAM_JSON_PROTOCOL.md).
Common output event types include:
- `run_start`, `text`, `reasoning`, `tool_call`, `tool_result`;
- `permission`, `permission_request`, `permission_decision`;
- `checkpoint`, `restore`, `usage`, `final`, `warning`, `error`, `run_end`.
Input stream-JSON can provide `prompt` or `message` events and image payloads.
The CLI parses those events, resolves a single prompt, validates/decodes images,
and passes the result into the same agent loop used everywhere else.
## Extensibility Flow
Zero's extension surfaces are loaded before the agent loop starts:
```mermaid
flowchart TD
Config[Config resolution] --> Registry[Tool registry]
Registry --> Core[Core tools]
Registry --> MCP[MCP server tools]
Registry --> Specialist[Specialist / Task tools]
Registry --> Plugin[Plugin tools]
Config --> Prompt[System prompt context]
Prompt --> Guidelines[AGENTS.md / ZERO.md]
Prompt --> Skills[Skills list]
Prompt --> SpecialistContext[Specialist descriptions]
ToolLifecycle[Tool lifecycle] --> Hooks[beforeTool / afterTool hooks]
```
- **Project/user guidelines** become prompt context.
- **Skills** are instruction packs the model can load on demand.
- **Specialists** are sub-agents callable through the `Task` tool.
- **MCP servers** contribute external tools.
- **Plugins** can add tools, hooks, and skill roots. Bootstrap merges plugin skill
roots into the runtime skill tool and prompt-visible skill list; the base
`internal/skills` scanner still scans one root at a time.
- **Hooks** can observe or block tool lifecycle events.
## End-to-End Data Flow
A typical interactive edit request looks like this:
```mermaid
sequenceDiagram
participant U as User
participant T as TUI
participant A as Agent loop
participant P as Provider
participant R as Tool registry
participant S as Sandbox
participant FS as Workspace/session files
U->>T: Type prompt and press Enter
T->>A: agent.Run(prompt, provider, options)
A->>P: CompletionRequest(messages, tool definitions)
P-->>A: Stream text + tool call edit_file
A->>S: Evaluate edit_file args
S-->>A: allow/prompt/deny decision
A->>T: permission callback if prompt needed
T-->>A: user decision
A->>R: Run edit_file
R->>FS: Write workspace file
R-->>A: Tool result + changed files
A->>P: Next CompletionRequest with tool result
P-->>A: Final answer
A-->>T: Result + callbacks
T->>FS: Append session events
T-->>U: Render final transcript
```
The same request in `zero exec` follows the same provider/tool/sandbox path, but
streaming events are written to stdout/stderr instead of rendered in Bubble Tea.
## Mental Model
When you work on Zero, start by locating the change on the four-layer spine from
[Architecture Spine](#architecture-spine) — Surface, Agent loop, Capabilities,
Persistence — then jump to the package that owns that boundary:
- UI behavior? Start in `internal/tui`.
- Command-line behavior? Start in `internal/cli`.
- Model/tool-turn behavior? Start in `internal/agent`.
- A new capability? Register a tool in `internal/tools` or an extension surface.
- Safety policy? Start in `internal/sandbox` and agent permission wiring.
- Resume/history behavior? Start in `internal/sessions` and session recorders.
+6 -74
View File
@@ -31,24 +31,15 @@ dependencies (only the installing project's own scripts), so the `postinstall`
that fetches the Zero binary is silently skipped. The first run then fails with
`No native binary found next to the npm wrapper`.
The simplest fix is to trust the package after installing, which runs the
blocked postinstall. This works for project and global installs:
To install with Bun, either run the installer manually after installing:
```bash
# project install
bun add @gitlawb/zero
bun pm trust @gitlawb/zero
# global install
bun add -g @gitlawb/zero
bun pm -g trust @gitlawb/zero
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
```
`bun pm untrusted` (or `bun pm -g untrusted`) lists the blocked postinstalls if
you want to inspect before trusting.
Alternatively, allow the postinstall to run at install time by adding the
package to your project's `trustedDependencies` before installing:
Or allow the postinstall to run by adding the package to your project's
`trustedDependencies` before installing:
```json
{
@@ -60,12 +51,8 @@ package to your project's `trustedDependencies` before installing:
bun add @gitlawb/zero
```
On Bun versions that do not have `bun pm trust`, run the installer manually
after installing:
```bash
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
```
For global installs (`bun add -g @gitlawb/zero`), run the installer manually
against the global install path, or use the install scripts below.
Reference: <https://bun.sh/docs/pm/lifecycle>
@@ -173,61 +160,6 @@ Linux native sandboxing also requires Bubblewrap to be installed.
macOS uses the system sandbox and does not need an extra helper binary.
### Termux (Android)
Zero can run natively on Android via [Termux](https://termux.dev/). Build with
`GOOS=android` to avoid the `faccessat2` syscall that is blocked by Samsung's
seccomp filter on Android:
```bash
# Install Go in Termux
pkg install golang
# Build Zero for Android
git clone https://github.com/Gitlawb/zero.git
cd zero
CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -ldflags="-s -w" -o zero ./cmd/zero
# Move into PATH
mv zero ~/.local/bin/
```
> **Why `GOOS=android`?** Go 1.26+ detects `runtime.GOOS == "android"` and skips
> the `faccessat2` syscall inside `os/exec.findExecutable`, falling back to
> permission-bit checks. Without this flag, Android's seccomp sends SIGSYS and
> kills the process whenever Zero looks up a binary on `PATH` (git, sh, etc.).
**DNS.** Android does not expose `/etc/resolv.conf`. Go's pure-Go DNS resolver
needs one. Use `proot` to bind-mount Termux's resolver config:
```bash
pkg install proot
proot -b "$PREFIX/etc/resolv.conf:/etc/resolv.conf" zero
```
Create a wrapper at `~/.local/bin/zero` to avoid typing proot every time:
```bash
#!/data/data/com.termux/files/usr/bin/bash
exec proot -b "$PREFIX/etc/resolv.conf:/etc/resolv.conf" ~/.local/bin/zero.bin "$@"
```
**Scroll.** On native Termux (not under PRoot), mouse scrolling works out of the
box. The TUI uses Bubble Tea's `AllMotion` mouse mode by default. If you run Zero
inside PRoot (e.g. through proot-distro), the scroll fix activates `CellMotion`
to avoid PRoot's ptrace interference with the 1003 escape sequence.
**Providers.** Zero works with any OpenAI-compatible provider on Termux. For
example, to use OpenCode Zen's free tier:
```bash
zero providers add opencode \
--name opencode \
--model deepseek-v4-flash-free \
--base-url https://opencode.ai/zen/v1 \
--set-active
```
Windows source builds can use the main `zero.exe` as the command runner and setup
helper through Zero's built-in self-dispatch path. If you want a release-style
layout anyway, build the standalone helper executables next to `zero.exe`:
-1
View File
@@ -17,7 +17,6 @@ Zero.
## Maintainer Docs
- [How Zero Works](HOW_ZERO_WORKS.md)
- [npm wrapper smoke checklist](NPM_WRAPPER_SMOKE.md)
- [Benchmarks](BENCHMARK.md)
- [Performance benchmarks](PERFORMANCE.md)
-5
View File
@@ -41,7 +41,6 @@ events when present.
```json
{ "schemaVersion": 2, "type": "run_start", "runId": "run_20260603_abc123", "sessionId": "zero_20260603100000_abc123", "cwd": "/repo", "provider": "openai", "model": "gpt-4.1", "apiModel": "gpt-4.1" }
{ "schemaVersion": 2, "type": "reasoning", "runId": "run_20260603_abc123", "delta": "Thinking..." }
{ "schemaVersion": 2, "type": "text", "runId": "run_20260603_abc123", "delta": "..." }
{ "schemaVersion": 2, "type": "tool_call", "runId": "run_20260603_abc123", "id": "call_1", "name": "read_file", "args": { "path": "README.md" }, "sideEffect": "read" }
{ "schemaVersion": 2, "type": "permission_request", "runId": "run_20260603_abc123", "id": "call_2", "name": "write_file", "action": "prompt", "permission": "prompt", "permissionMode": "ask", "sideEffect": "write", "reason": "Creates or overwrites files." }
@@ -52,10 +51,6 @@ events when present.
{ "schemaVersion": 2, "type": "run_end", "runId": "run_20260603_abc123", "status": "success", "exitCode": 0 }
```
`reasoning` events carry live model reasoning/status deltas for providers that
stream them separately from answer text. They are liveness/progress events only:
they are not folded into `text` or the final answer.
Permission events may include structured sandbox metadata:
```json
+3 -2
View File
@@ -1,6 +1,8 @@
module github.com/Gitlawb/zero
go 1.26.5
go 1.25.0
toolchain go1.26.4
require (
charm.land/bubbles/v2 v2.1.0
@@ -12,7 +14,6 @@ require (
github.com/charmbracelet/colorprofile v0.4.3
github.com/charmbracelet/x/ansi v0.11.7
github.com/charmbracelet/x/term v0.2.2
github.com/coder/websocket v1.8.15
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
golang.org/x/sys v0.46.0
mvdan.cc/sh/v3 v3.13.1
-2
View File
@@ -32,8 +32,6 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
+21 -54
View File
@@ -5,7 +5,6 @@ import (
"encoding/base64"
"encoding/json"
"errors"
"log"
"strings"
"sync"
@@ -160,14 +159,8 @@ func (a *Agent) handleSessionLoad(_ context.Context, params json.RawMessage) (an
// 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)
history := a.loadHistory(meta.SessionID)
sess := a.registerSession(meta.SessionID, root, history)
a.warnPersistence(
&notifier{conn: a.conn, sessionID: sess.id},
"load session history",
"Could not load session history. The session is open, but earlier turns may be missing until storage recovers.",
historyErr,
)
return LoadSessionResult{
Modes: a.modeState(sess),
}, nil
@@ -260,14 +253,7 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
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,
)
}
a.persistTurn(sess, userText, result.FinalAnswer)
return reason, nil
}
@@ -400,34 +386,29 @@ func (a *Agent) modeState(s *acpSession) *SessionModeState {
// ---- persistence + continuity ----
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) error {
defer sess.appendHistory(turnRecord{user: user, assistant: assistant})
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) {
if a.deps.Store != nil {
_, _ = a.deps.Store.AppendEvent(sess.id, sessions.AppendEventInput{
Type: sessions.EventMessage,
Payload: map[string]any{"role": "user", "content": user},
})
if assistant != "" {
_, _ = a.deps.Store.AppendEvent(sess.id, sessions.AppendEventInput{
Type: sessions.EventMessage,
Payload: map[string]any{"role": "assistant", "content": assistant},
})
}
}
sess.appendHistory(turnRecord{user: user, assistant: assistant})
}
func (a *Agent) loadHistory(sessionID string) []turnRecord {
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)
return err
}
func (a *Agent) loadHistory(sessionID string) ([]turnRecord, error) {
if a.deps.Store == nil {
return nil, nil
}
events, err := a.deps.Store.ReadEvents(sessionID)
if err != nil {
return nil, err
return nil
}
var records []turnRecord
var pendingUser string
@@ -463,21 +444,7 @@ func (a *Agent) loadHistory(sessionID string) ([]turnRecord, error) {
if havePending {
records = append(records, turnRecord{user: pendingUser})
}
return records, nil
}
func (a *Agent) warnPersistence(note *notifier, action string, message string, err error) {
if err == nil {
return
}
sessionID := ""
if note != nil {
sessionID = note.sessionID
}
log.Printf("zero acp: failed to %s for session %s: %v", action, sessionID, err)
if note != nil {
note.text("\n\n[zero warning] " + message + "\n")
}
return records
}
// buildPrompt prepends prior conversation as context, since agent.Run drives a
+1 -72
View File
@@ -5,8 +5,6 @@ import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -244,77 +242,8 @@ func TestACPRejectsInvalidCwd(t *testing.T) {
}
}
func TestACPPromptWarnsWhenTurnPersistenceFails(t *testing.T) {
deps := testDeps(t)
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var newRes NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
t.Fatalf("session/new: %v", err)
}
metadataPath := filepath.Join(deps.Store.RootDir, newRes.SessionID, sessions.MetadataFile)
if err := os.Remove(metadataPath); err != nil {
t.Fatalf("remove metadata: %v", err)
}
var promptRes PromptResult
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: newRes.SessionID,
Prompt: []ContentBlock{TextBlock("hi")},
}, &promptRes); err != nil {
t.Fatalf("session/prompt: %v", err)
}
if promptRes.StopReason != StopEndTurn {
t.Fatalf("stopReason = %q, want %q", promptRes.StopReason, StopEndTurn)
}
got := drainTextUntil(t, h.updates, func(text string) bool {
return strings.Contains(text, "Hello from ZERO") &&
strings.Contains(text, "Could not save session history")
})
if !strings.Contains(got, "Could not save session history") {
t.Fatalf("streamed text = %q, want persistence warning", got)
}
}
func TestACPLoadWarnsWhenHistoryReadFails(t *testing.T) {
deps := testDeps(t)
cwd := t.TempDir()
meta, err := deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: cwd})
if err != nil {
t.Fatalf("create session: %v", err)
}
eventsPath := filepath.Join(deps.Store.RootDir, meta.SessionID, sessions.EventsFile)
if err := os.WriteFile(eventsPath, []byte("{bad json}\n"), 0o600); err != nil {
t.Fatalf("write corrupt events: %v", err)
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: meta.SessionID, Cwd: cwd, McpServers: []McpServer{}}, &LoadSessionResult{}); err != nil {
t.Fatalf("session/load: %v", err)
}
got := drainTextUntil(t, h.updates, func(text string) bool {
return strings.Contains(text, "Could not load session history")
})
if !strings.Contains(got, "Could not load session history") {
t.Fatalf("streamed text = %q, want load warning", got)
}
}
// drainText collects streamed chunks for a short window and concatenates them.
func drainText(t *testing.T, ch <-chan string) string {
t.Helper()
return drainTextUntil(t, ch, func(text string) bool {
return strings.Contains(text, "Hello from ZERO")
})
}
func drainTextUntil(t *testing.T, ch <-chan string, done func(string) bool) string {
t.Helper()
var b strings.Builder
deadline := time.After(2 * time.Second)
@@ -322,7 +251,7 @@ func drainTextUntil(t *testing.T, ch <-chan string, done func(string) bool) stri
select {
case s := <-ch:
b.WriteString(s)
if done(b.String()) {
if strings.Contains(b.String(), "Hello from ZERO") {
return b.String()
}
case <-deadline:
@@ -1,146 +0,0 @@
package agent
import (
"context"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
)
// A shell call that sets sandbox_permissions: with_additional_permissions but
// omits (or malforms) additional_permissions can never succeed: the same
// validation runs again at grant time regardless of the user's decision. It
// must be rejected as a plain tool error BEFORE any permission prompt, not
// surfaced as a confusing "approved but denied" prompt outcome.
func TestExecuteToolCallRejectsMissingAdditionalPermissionsBeforePrompt(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
promptCalls := 0
result, err := executeToolCall(
context.Background(),
registry,
ToolCall{
ID: "c1",
Name: "bash",
Arguments: `{"command":"echo hi","sandbox_permissions":"with_additional_permissions"}`,
},
PermissionModeAuto,
Options{
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
promptCalls++
return PermissionDecision{Action: PermissionDecisionAllow}, nil
},
},
)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status != tools.StatusError {
t.Fatalf("status = %q, want error", result.Status)
}
if promptCalls != 0 {
t.Fatalf("OnPermissionRequest called %d times, want 0 (must reject before prompting)", promptCalls)
}
}
// The mismatched-flag case (additional_permissions present without the
// sandbox_permissions flag) must be rejected the same way.
func TestExecuteToolCallRejectsAdditionalPermissionsWithoutFlagBeforePrompt(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
promptCalls := 0
result, err := executeToolCall(
context.Background(),
registry,
ToolCall{
ID: "c1",
Name: "bash",
Arguments: `{"command":"echo hi","additional_permissions":{"network":{"enabled":true}}}`,
},
PermissionModeAuto,
Options{
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
promptCalls++
return PermissionDecision{Action: PermissionDecisionAllow}, nil
},
},
)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status != tools.StatusError {
t.Fatalf("status = %q, want error", result.Status)
}
if promptCalls != 0 {
t.Fatalf("OnPermissionRequest called %d times, want 0 (must reject before prompting)", promptCalls)
}
}
// The rejection message must show a concrete, correctly-shaped example: a
// model that gets this wrong once (attaching sandbox_permissions out of habit
// from an earlier call, without a valid payload) needs enough in the error to
// self-correct on retry instead of repeating the identical mistake.
func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
result, err := executeToolCall(
context.Background(),
registry,
ToolCall{
ID: "c1",
Name: "bash",
Arguments: `{"command":"ping example.com","sandbox_permissions":"with_additional_permissions"}`,
},
PermissionModeAuto,
Options{},
)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if !strings.Contains(result.Output, `{"network": {"enabled": true}}`) {
t.Fatalf("output = %q, want a concrete additional_permissions example", result.Output)
}
if !strings.Contains(result.Output, "omit sandbox_permissions") {
t.Fatalf("output = %q, want guidance that the flag can simply be omitted", result.Output)
}
}
// A well-formed additional_permissions payload must still reach the normal
// permission prompt: this fix only rejects malformed payloads.
func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
promptCalls := 0
_, err := executeToolCall(
context.Background(),
registry,
ToolCall{
ID: "c1",
Name: "bash",
Arguments: `{"command":"echo hi","sandbox_permissions":"with_additional_permissions",` +
`"additional_permissions":{"network":{"enabled":true}}}`,
},
PermissionModeAuto,
Options{
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
promptCalls++
return PermissionDecision{Action: PermissionDecisionDeny}, nil
},
},
)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if promptCalls != 1 {
t.Fatalf("OnPermissionRequest called %d times, want exactly 1 for a valid elevation request", promptCalls)
}
}
-185
View File
@@ -1,185 +0,0 @@
package agent
import (
"context"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// Post-edit LSP diagnostics used to run synchronously inside edit_file /
// write_file: every edit blocked its tool result on a fresh-publish wait with
// a quiet-period debounce (≥300ms floor that reset on each publish, 10s cap) —
// the largest recurring latency tax in an edit-heavy session. Collection is
// now asynchronous: a file is enqueued the moment a mutating tool finishes, a
// single worker checks it while the rest of the tool batch (and any
// self-correct verification) runs, and the loop drains completed results just
// before building the NEXT provider request, appending errors as a user-role
// nudge. The model always issues another request after an edit turn — it has
// to read its tool results — so it still sees introduced errors at the same
// decision point as before, without any tool call stalling on the language
// server.
//
// A file re-edited before its check runs is checked once in its final state
// (the checker re-reads the file); a file re-edited after its check completed
// is re-enqueued and the newer result wins. When the worker is still busy at
// drain time, the drain gives up after a short wait and the results are
// delivered on the following turn instead — they stay accurate because any
// further edit re-enqueues the file.
// asyncDiagnosticsDrainTimeout bounds how long a turn waits on the in-flight
// check before deferring delivery to the next turn. Most checks finish well
// under it (debounce floor + analysis); a cold language server on a large
// repo does not get to stall the loop the way the old inline 10s cap could.
// A var so tests can shorten the wait.
var asyncDiagnosticsDrainTimeout = 3 * time.Second
// asyncDiagnosticsFinalDrainTimeout is the wait used when the run is about to
// FINALIZE (natural completion or the max-turns summary): there is no later
// turn to defer to, so the gate waits out the old inline-era budget rather
// than silently dropping an error the last edit introduced. A var so tests
// can shorten the wait.
var asyncDiagnosticsFinalDrainTimeout = 10 * time.Second
// asyncDiagnosticsNudge prefixes the drained diagnostics blocks; phrased like
// the old inline block so the model reacts the same way.
const asyncDiagnosticsNudge = "Diagnostics after your recent edits (fix any errors you introduced):\n"
type asyncDiagnostics struct {
check func(context.Context, string) string
workspaceRoot string
mu sync.Mutex
queue []string
queued map[string]bool
results map[string]string
// working is non-nil while the worker goroutine runs and is closed when it
// exits, so drain can wait for quiescence without polling.
working chan struct{}
}
// newAsyncDiagnostics returns nil when check is nil, and every method on a nil
// receiver is a no-op — callers wire it unconditionally.
func newAsyncDiagnostics(check func(context.Context, string) string, workspaceRoot string) *asyncDiagnostics {
if check == nil {
return nil
}
return &asyncDiagnostics{
check: check,
workspaceRoot: workspaceRoot,
queued: map[string]bool{},
results: map[string]string{},
}
}
// enqueue schedules changed files for a background check. Paths are
// workspace-relative from Result.ChangedFiles (absolute when under a granted
// extra write root), mirroring SelfCorrector's resolution.
func (diagnostics *asyncDiagnostics) enqueue(ctx context.Context, files []string) {
if diagnostics == nil || len(files) == 0 {
return
}
diagnostics.mu.Lock()
defer diagnostics.mu.Unlock()
for _, file := range files {
path := diagnostics.absPath(file)
if diagnostics.queued[path] {
continue
}
diagnostics.queued[path] = true
// A completed result for this file describes a pre-edit state now.
delete(diagnostics.results, path)
diagnostics.queue = append(diagnostics.queue, path)
}
if diagnostics.working == nil && len(diagnostics.queue) > 0 {
done := make(chan struct{})
diagnostics.working = done
go diagnostics.work(ctx, done)
}
}
func (diagnostics *asyncDiagnostics) work(ctx context.Context, done chan struct{}) {
defer close(done)
for {
diagnostics.mu.Lock()
if len(diagnostics.queue) == 0 || ctx.Err() != nil {
diagnostics.working = nil
diagnostics.mu.Unlock()
return
}
path := diagnostics.queue[0]
diagnostics.queue = diagnostics.queue[1:]
// Un-mark before checking so an edit that lands DURING the check
// re-enqueues the file and it is checked again in its final state.
delete(diagnostics.queued, path)
diagnostics.mu.Unlock()
block := diagnostics.check(ctx, path)
diagnostics.mu.Lock()
if block != "" {
diagnostics.results[path] = block
} else {
delete(diagnostics.results, path)
}
diagnostics.mu.Unlock()
}
}
// drain waits briefly for the worker to go quiet, then formats and consumes
// all completed error blocks as one nudge. Returns "" when there is nothing
// to report or the worker is still busy (results then surface next turn).
func (diagnostics *asyncDiagnostics) drain(ctx context.Context) string {
return diagnostics.drainWithin(ctx, asyncDiagnosticsDrainTimeout)
}
// drainFinal is drain with the finalization budget: called before the run's
// last model request, where an undelivered result would otherwise be lost.
func (diagnostics *asyncDiagnostics) drainFinal(ctx context.Context) string {
return diagnostics.drainWithin(ctx, asyncDiagnosticsFinalDrainTimeout)
}
func (diagnostics *asyncDiagnostics) drainWithin(ctx context.Context, timeout time.Duration) string {
if diagnostics == nil {
return ""
}
diagnostics.mu.Lock()
busy := diagnostics.working
diagnostics.mu.Unlock()
if busy != nil {
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-busy:
case <-timer.C:
return ""
case <-ctx.Done():
return ""
}
}
diagnostics.mu.Lock()
defer diagnostics.mu.Unlock()
if len(diagnostics.results) == 0 {
return ""
}
paths := make([]string, 0, len(diagnostics.results))
for path := range diagnostics.results {
paths = append(paths, path)
}
sort.Strings(paths)
blocks := make([]string, 0, len(paths))
for _, path := range paths {
blocks = append(blocks, diagnostics.results[path])
}
diagnostics.results = map[string]string{}
return asyncDiagnosticsNudge + strings.Join(blocks, "\n")
}
func (diagnostics *asyncDiagnostics) absPath(path string) string {
if filepath.IsAbs(path) {
return path
}
return filepath.Join(diagnostics.workspaceRoot, path)
}
-338
View File
@@ -1,338 +0,0 @@
package agent
import (
"context"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestAsyncDiagnosticsNilCollectorNoOps(t *testing.T) {
var diagnostics *asyncDiagnostics
if diagnostics != newAsyncDiagnostics(nil, "/ws") {
t.Fatal("nil check must yield a nil collector")
}
diagnostics.enqueue(context.Background(), []string{"a.go"})
if nudge := diagnostics.drain(context.Background()); nudge != "" {
t.Fatalf("nil collector drain = %q, want empty", nudge)
}
}
func TestAsyncDiagnosticsCollectsAndDrainsOnce(t *testing.T) {
// A real absolute root: a literal "/ws" is not filepath.IsAbs on Windows
// (no drive letter), which is exactly what the assertions below check.
root := t.TempDir()
var mu sync.Mutex
var checked []string
check := func(_ context.Context, absPath string) string {
mu.Lock()
checked = append(checked, absPath)
mu.Unlock()
if filepath.Base(absPath) == "clean.go" {
return ""
}
return "ERR " + filepath.Base(absPath)
}
diagnostics := newAsyncDiagnostics(check, root)
diagnostics.enqueue(context.Background(), []string{"broken.go", "clean.go"})
nudge := diagnostics.drain(context.Background())
if !strings.HasPrefix(nudge, asyncDiagnosticsNudge) {
t.Fatalf("nudge missing prefix: %q", nudge)
}
if !strings.Contains(nudge, "ERR broken.go") || strings.Contains(nudge, "clean.go") {
t.Fatalf("nudge = %q, want broken.go errors only", nudge)
}
mu.Lock()
for _, path := range checked {
if !filepath.IsAbs(path) || !strings.HasPrefix(path, root+string(filepath.Separator)) {
t.Fatalf("check received %q, want path under workspace root %q", path, root)
}
}
mu.Unlock()
if second := diagnostics.drain(context.Background()); second != "" {
t.Fatalf("second drain = %q, want empty (results are consumed)", second)
}
}
// A check still running at drain time defers delivery to the next drain
// instead of blocking the turn.
func TestAsyncDiagnosticsSlowCheckDefersToNextDrain(t *testing.T) {
previous := asyncDiagnosticsDrainTimeout
asyncDiagnosticsDrainTimeout = 20 * time.Millisecond
defer func() { asyncDiagnosticsDrainTimeout = previous }()
release := make(chan struct{})
check := func(_ context.Context, absPath string) string {
<-release
return "ERR " + filepath.Base(absPath)
}
diagnostics := newAsyncDiagnostics(check, "/ws")
diagnostics.enqueue(context.Background(), []string{"slow.go"})
start := time.Now()
if nudge := diagnostics.drain(context.Background()); nudge != "" {
t.Fatalf("drain during in-flight check = %q, want empty", nudge)
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Fatalf("drain blocked %v, want bounded by the (shortened) timeout", elapsed)
}
close(release)
deadline := time.Now().Add(2 * time.Second)
for {
if nudge := diagnostics.drain(context.Background()); nudge != "" {
if !strings.Contains(nudge, "ERR slow.go") {
t.Fatalf("deferred nudge = %q", nudge)
}
return
}
if time.Now().After(deadline) {
t.Fatal("deferred diagnostics never delivered")
}
time.Sleep(5 * time.Millisecond)
}
}
// Re-enqueueing a file after its check completed replaces the stale result.
func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
var mu sync.Mutex
response := "ERR old"
check := func(context.Context, string) string {
mu.Lock()
defer mu.Unlock()
return response
}
diagnostics := newAsyncDiagnostics(check, "/ws")
diagnostics.enqueue(context.Background(), []string{"a.go"})
waitForIdle(t, diagnostics)
mu.Lock()
response = "ERR new"
mu.Unlock()
diagnostics.enqueue(context.Background(), []string{"a.go"})
nudge := diagnostics.drain(context.Background())
if !strings.Contains(nudge, "ERR new") || strings.Contains(nudge, "ERR old") {
t.Fatalf("nudge = %q, want only the re-check result", nudge)
}
}
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{}
func (changedFilesTool) Name() string { return "fake_edit" }
func (changedFilesTool) Description() string { return "test mutating tool" }
func (changedFilesTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
}
func (changedFilesTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionAllow, Reason: "test"}
}
func (changedFilesTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "edited", ChangedFiles: []string{"main.go"}}
}
// End-to-end through Run: an edit turn does NOT carry diagnostics in the tool
// result (the old inline path), but the next request contains the background
// diagnostics nudge, before the model finalizes.
func TestRunDeliversAsyncDiagnosticsNudgeNextTurn(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(changedFilesTool{})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
root := t.TempDir()
var checkedPath string
var mu sync.Mutex
result, err := Run(context.Background(), "fix main.go", provider, Options{
Registry: registry,
Cwd: root,
FileDiagnostics: func(_ context.Context, absPath string) string {
mu.Lock()
checkedPath = absPath
mu.Unlock()
return "main.go:1:1 error: boom"
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
if len(provider.requests) != 2 {
t.Fatalf("expected 2 provider turns, got %d", len(provider.requests))
}
// The tool result itself must NOT block on / embed diagnostics.
for _, message := range provider.requests[1].Messages {
if message.Role == zeroruntime.MessageRoleTool && strings.Contains(message.Content, "boom") {
t.Fatalf("diagnostics leaked into the tool result: %q", message.Content)
}
}
// The nudge must be present in the second request as a user message.
found := false
for _, message := range provider.requests[1].Messages {
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
if !strings.Contains(message.Content, "boom") {
t.Fatalf("nudge missing diagnostics: %q", message.Content)
}
found = true
}
}
if !found {
t.Fatal("no async diagnostics nudge in the follow-up request")
}
mu.Lock()
defer mu.Unlock()
if want := filepath.Join(root, "main.go"); checkedPath != want {
t.Fatalf("checked path = %q, want %q", checkedPath, want)
}
}
// When the run hits the maxTurns ceiling right after an edit turn, the
// final-answer request must still carry the diagnostics nudge — otherwise an
// error introduced by the last edit would go unreported in the summary.
func TestRunDrainsAsyncDiagnosticsBeforeMaxTurnsFinalAnswer(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(changedFilesTool{})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "summary"},
{Type: zeroruntime.StreamEventDone},
},
},
}
result, err := Run(context.Background(), "fix main.go", provider, Options{
Registry: registry,
Cwd: t.TempDir(),
MaxTurns: 1,
FileDiagnostics: func(context.Context, string) string {
return "main.go:1:1 error: boom"
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "summary" {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
if len(provider.requests) != 2 {
t.Fatalf("expected the max-turns final-answer request, got %d requests", len(provider.requests))
}
found := false
for _, message := range provider.requests[1].Messages {
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
found = true
}
}
if !found {
t.Fatal("max-turns final-answer request missing the diagnostics nudge")
}
}
// When the model finalizes while a slow check is still in flight (the
// per-turn drain missed it), the finalization gate must wait it out and give
// the model one more turn with the nudge instead of dropping the errors.
func TestRunFinalizationGateDeliversLateDiagnostics(t *testing.T) {
previous := asyncDiagnosticsDrainTimeout
asyncDiagnosticsDrainTimeout = time.Millisecond
defer func() { asyncDiagnosticsDrainTimeout = previous }()
registry := tools.NewRegistry()
registry.Register(changedFilesTool{})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done after fix"},
{Type: zeroruntime.StreamEventDone},
},
},
}
result, err := Run(context.Background(), "fix main.go", provider, Options{
Registry: registry,
Cwd: t.TempDir(),
FileDiagnostics: func(context.Context, string) string {
// Slower than the (shortened) per-turn drain, well under the
// finalization budget.
time.Sleep(100 * time.Millisecond)
return "main.go:1:1 error: boom"
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done after fix" {
t.Fatalf("final answer = %q, want the post-nudge answer", result.FinalAnswer)
}
if len(provider.requests) != 3 {
t.Fatalf("expected a third request carrying the late nudge, got %d requests", len(provider.requests))
}
found := false
for _, message := range provider.requests[2].Messages {
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
if !strings.Contains(message.Content, "boom") {
t.Fatalf("nudge missing diagnostics: %q", message.Content)
}
found = true
}
}
if !found {
t.Fatal("finalization gate did not deliver the late diagnostics nudge")
}
}
+16 -33
View File
@@ -2,7 +2,6 @@ package agent
import (
"fmt"
"runtime"
"strings"
"github.com/Gitlawb/zero/internal/sandbox"
@@ -28,20 +27,10 @@ func proposedCommandPrefix(toolName string, args map[string]any) []string {
}
return nil
}
// Only propose approving a prefix of one segment when every other segment
// in the command is independently known-safe. Once a prefix is approved,
// shellExecutionArgsForApproval escalates the whole command (every
// segment, not just the approved one) to bypass the sandbox, so offering a
// prefix that leaves an MSYS-prone (or otherwise unsafe) segment uncovered
// would let that segment run unsandboxed without ever being reviewed.
for index, tokens := range segments {
if knownSafeCommandSegment(tokens) {
continue
for _, tokens := range segments {
if !knownSafeCommandSegment(tokens) && sandbox.ValidCommandPrefix(tokens) {
return append([]string(nil), tokens...)
}
if !sandbox.ValidCommandPrefix(tokens) || !otherSegmentsKnownSafe(segments, index) {
return nil
}
return append([]string(nil), tokens...)
}
if len(segments) == 0 || !sandbox.ValidCommandPrefix(segments[0]) {
return nil
@@ -49,20 +38,6 @@ func proposedCommandPrefix(toolName string, args map[string]any) []string {
return append([]string(nil), segments[0]...)
}
// otherSegmentsKnownSafe reports whether every segment other than the one at
// skip is known-safe on its own.
func otherSegmentsKnownSafe(segments [][]string, skip int) bool {
for index, tokens := range segments {
if index == skip {
continue
}
if !knownSafeCommandSegment(tokens) {
return false
}
}
return true
}
func matchCommandPrefix(toolName string, args map[string]any, options Options) (sandbox.CommandPrefixGrant, bool, bool) {
if !isShellCommandTool(toolName) || options.Sandbox == nil {
return sandbox.CommandPrefixGrant{}, false, false
@@ -193,6 +168,10 @@ func cleanPrefixRule(prefix []string) []string {
return cleaned
}
func safeRequestedPrefix(prefix []string, command []string) bool {
return safeRequestedPrefixForSegments(prefix, [][]string{command})
}
func safeRequestedPrefixForSegments(prefix []string, segments [][]string) bool {
if len(prefix) == 0 || !sandbox.ValidCommandPrefix(prefix) {
return false
@@ -217,6 +196,14 @@ func safeRequestedPrefixForSegments(prefix []string, segments [][]string) bool {
return matched
}
func safeShellCommandTokens(command string) ([]string, bool) {
segments, ok := safeShellCommandSegments(command)
if !ok || len(segments) != 1 {
return nil, false
}
return segments[0], true
}
func safeShellCommandSegments(command string) ([][]string, bool) {
command = strings.TrimSpace(command)
if command == "" {
@@ -288,11 +275,7 @@ func knownSafeCommandSegment(command []string) bool {
if len(command) == 0 {
return false
}
name := commandName(command[0])
if runtime.GOOS == "windows" && tools.MsysProneCommandName(name) {
return false
}
switch name {
switch commandName(command[0]) {
case "cat", "cd", "cut", "echo", "expr", "false", "grep", "head", "id",
"ls", "nl", "paste", "pwd", "rev", "seq", "stat", "tail", "tr",
"true", "uname", "uniq", "wc", "which", "whoami":
+1 -50
View File
@@ -1,11 +1,9 @@
package agent
import (
"runtime"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
@@ -37,37 +35,12 @@ func TestProposedCommandPrefixHonorsValidatedRequestedPrefix(t *testing.T) {
func TestProposedCommandPrefixSupportsSegmentedCommands(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"})
if runtime.GOOS == "windows" {
// head is MSYS-prone on Windows (#458), so proposedCommandPrefix must
// not offer "ps aux" as a reusable prefix here: approving it would
// escalate the whole command, including the uncovered head segment,
// to bypass the sandbox unreviewed. See
// TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered for
// the platform-independent regression coverage of this behavior.
if got != nil {
t.Fatalf("expected no prefix on Windows because head is MSYS-prone, got %#v", got)
}
return
}
want := []string{"ps", "aux"}
if !equalStringSlices(got, want) {
t.Fatalf("prefix = %#v, want %#v", got, want)
}
}
// TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered guards
// against proposedCommandPrefix offering to approve one segment of a
// multi-segment command (e.g. "ps aux") while a different segment (e.g. "npm
// install") is not known-safe. shellExecutionArgsForApproval escalates the
// entire command once any prefix is approved, so an uncovered unsafe segment
// would bypass the sandbox unreviewed. Uses npm, which is never known-safe on
// any platform, so the assertion does not depend on runtime.GOOS.
func TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered(t *testing.T) {
if got := proposedCommandPrefix("bash", map[string]any{"command": "ps aux && npm install"}); got != nil {
t.Fatalf("expected no prefix because npm segment is not known-safe, got %#v", got)
}
}
func TestProposedCommandPrefixHonorsRequestedPrefixAcrossSegments(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{
"command": "git status --short && git status --branch",
@@ -147,35 +120,13 @@ func TestProposedCommandPrefixRejectsUnsafeLaunchers(t *testing.T) {
func TestMatchCommandPrefixCoversSegmentedCommandWithSafeTail(t *testing.T) {
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
// head is MSYS-prone on Windows (#458) and must not count as a known-safe tail.
command := "ps aux | head -5"
if runtime.GOOS == "windows" {
command = "ps aux | echo ok"
}
grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": command}, Options{Sandbox: engine})
grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"}, Options{Sandbox: engine})
if !ok || !session || !equalStringSlices(grant.Prefix, []string{"ps", "aux"}) {
t.Fatalf("match = %#v ok=%v session=%v, want session ps aux prefix", grant, ok, session)
}
}
func TestKnownSafeCommandSegmentRejectsMsysProneOnWindows(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("windows-only known-safe MSYS guard")
}
for _, command := range [][]string{{"head", "-5"}, {"cat", "file.txt"}, {"grep", "pat"}} {
if knownSafeCommandSegment(command) {
t.Fatalf("expected %q to be unsafe on Windows, got known-safe", command)
}
}
if !knownSafeCommandSegment([]string{"echo", "ok"}) {
t.Fatal("expected echo to remain known-safe on Windows")
}
if !tools.MsysProneCommandName("head") {
t.Fatal("expected head to be MSYS-prone")
}
}
func TestMatchCommandPrefixRejectsUncoveredSegment(t *testing.T) {
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
+8 -50
View File
@@ -21,17 +21,12 @@ import (
// the agent loop owns the provider wiring.
// defaultCompactionPreserveLast is how many trailing messages are kept verbatim
// when the caller does not specify a preserve count. Kept in sync with the replay
// reconstruction default in internal/sessions/replay.go.
const defaultCompactionPreserveLast = 6
// when the caller does not specify a preserve count.
const defaultCompactionPreserveLast = 8
// compactionTriggerRatio is the fraction of the context window at which
// proactive compaction kicks in (top of each turn). NOTE: this is speculative
// tuning — validate against the per-turn latency/compaction metrics (roadmap 5.2)
// before trusting it. Measured fixed overhead is ~7.4k tokens (not the ~16k the
// roadmap assumed), so context fills slower; if data shows 0.7 triggers redundant
// summarization, raise it back toward 0.8.
const compactionTriggerRatio = 0.7
// proactive compaction kicks in (top of each turn).
const compactionTriggerRatio = 0.8
// summaryLabel prefixes the injected summary so it is unmistakable in the
// transcript (and so tests can assert on it).
@@ -325,43 +320,6 @@ type compactionState struct {
// OnText is deliberately NOT forwarded (compaction stays invisible to the user),
// but its token COST must still be counted so usage reports and budgets include it.
onUsage func(Usage)
// calibrationRatio scales the raw byte/4 token estimate toward the provider's
// real prompt-token count. ApproxTextTokens over-counts code-heavy content by
// ~15-20%, which would trip compaction early (at ~60% of true capacity). It
// starts at 1.0 and converges via an EMA as each turn reports actual usage, so
// later turns compact nearer to real capacity. Zero is treated as 1.0.
calibrationRatio float64
}
// calibrate folds one turn's (rawEstimate, actualPromptTokens) sample into the
// running calibration ratio. A single sample is clamped to a sane band so an
// outlier (a huge cache-read turn, a provider-overhead spike) can't skew the
// estimate enough to disable or thrash compaction.
func (state *compactionState) calibrate(rawEstimate int, actualPromptTokens int) {
if !state.enabled || rawEstimate <= 0 || actualPromptTokens <= 0 {
return
}
sample := float64(actualPromptTokens) / float64(rawEstimate)
if sample < 0.5 {
sample = 0.5
} else if sample > 2.0 {
sample = 2.0
}
if state.calibrationRatio <= 0 {
state.calibrationRatio = 1.0
}
const alpha = 0.3 // weight on the newest sample; smooths jitter across turns
state.calibrationRatio = state.calibrationRatio*(1-alpha) + sample*alpha
}
// calibratedTokens applies the learned ratio to a raw estimate. Before any sample
// arrives (ratio unset) it returns the raw estimate unchanged.
func (state *compactionState) calibratedTokens(raw int) int {
if state.calibrationRatio <= 0 {
return raw
}
return int(float64(raw) * state.calibrationRatio)
}
func newCompactionState(options Options) *compactionState {
@@ -390,7 +348,7 @@ func (state *compactionState) maybeCompact(
// the messages; both the threshold check and the shrink check below use the
// same term so they stay consistent.
toolTokens := estimateToolDefTokens(tools)
size := state.calibratedTokens(estimateTokens(messages) + toolTokens)
size := estimateTokens(messages) + toolTokens
if size <= state.threshold {
return messages
}
@@ -407,7 +365,7 @@ func (state *compactionState) maybeCompact(
// entirely and preserve recent turns verbatim.
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
messages = pruned
size = state.calibratedTokens(estimateTokens(messages) + toolTokens)
size = estimateTokens(messages) + toolTokens
if size <= state.threshold {
state.lowWaterMark = size
return messages
@@ -423,7 +381,7 @@ func (state *compactionState) maybeCompact(
// later turn) can try again; we never drop messages on failure here.
return messages
}
newSize := state.calibratedTokens(estimateTokens(compacted) + toolTokens)
newSize := estimateTokens(compacted) + toolTokens
if newSize >= size {
// Compaction did not actually shrink anything (e.g. nothing to
// summarize). Leave the history untouched and don't churn next turn.
@@ -481,7 +439,7 @@ func (state *compactionState) recover(
// the SAME combined (messages + tool-defs) domain maybeCompact uses, so the
// proactive shrink-guard compares like with like.
state.reactiveAttempted = true
state.lowWaterMark = state.calibratedTokens(estimateTokens(result) + estimateToolDefTokens(tools))
state.lowWaterMark = estimateTokens(result) + estimateToolDefTokens(tools)
return result, true, nil
}
@@ -1,50 +0,0 @@
package agent
import "testing"
func TestCalibrationConvergesTowardActual(t *testing.T) {
state := &compactionState{enabled: true}
// Before any sample, the estimate is unchanged.
if got := state.calibratedTokens(1000); got != 1000 {
t.Fatalf("uncalibrated tokens = %d, want 1000", got)
}
// The estimator over-counts: our raw estimate is 1000 but the provider reports
// 850 (a 0.85 ratio). Feeding that sample repeatedly should pull the ratio
// toward 0.85, so a 1000-token raw estimate calibrates downward.
for range 20 {
state.calibrate(1000, 850)
}
got := state.calibratedTokens(1000)
if got >= 1000 || got < 820 || got > 880 {
t.Fatalf("calibrated tokens = %d, want ~850 after convergence", got)
}
}
func TestCalibrateIgnoresDegenerateSamples(t *testing.T) {
state := &compactionState{enabled: true}
state.calibrate(0, 500) // zero estimate
state.calibrate(500, 0) // zero actual
if state.calibrationRatio != 0 {
t.Fatalf("degenerate samples must not move the ratio, got %v", state.calibrationRatio)
}
// Disabled state never calibrates.
disabled := &compactionState{enabled: false}
disabled.calibrate(1000, 850)
if disabled.calibrationRatio != 0 {
t.Fatalf("disabled compaction must not calibrate, got %v", disabled.calibrationRatio)
}
}
func TestCalibrateClampsOutliers(t *testing.T) {
state := &compactionState{enabled: true}
// A wild outlier (10x) is clamped to 2.0, so one bad sample can't blow up the
// ratio and disable compaction.
for range 50 {
state.calibrate(1000, 10000)
}
if state.calibrationRatio > 2.0 {
t.Fatalf("ratio should be clamped at 2.0, got %v", state.calibrationRatio)
}
}
+4 -194
View File
@@ -20,16 +20,6 @@ const (
toolNameUpdatePlan = "update_plan"
toolNameToolSearch = "tool_search"
toolNameSkill = "skill"
toolNameWriteFile = "write_file"
toolNameEditFile = "edit_file"
)
// maxRecentEdits caps how many edited files are carried across a compaction, and
// maxEditNoteBytes caps each edit's one-line note, so a long editing run can't
// bloat the preserved-state block.
const (
maxRecentEdits = 20
maxEditNoteBytes = 160
)
const (
@@ -110,123 +100,12 @@ func formatPlanArguments(arguments string) string {
}
// skillEntry is a named preserved body. It began as loaded-skill state and is
// reused for loaded tools, project instruction blocks, and recent file edits.
// reused for loaded tools and project instruction blocks.
type skillEntry struct {
name string
body string
}
// recentEdits returns the files mutated by write_file/edit_file calls in messages
// — latest note per path, in last-seen order — as skillEntry{name: path, body:
// note}. After compaction elides the editing turns, this tells the model WHAT it
// changed in each file (from the tool's result) so it need not re-read to
// rediscover its own footprint. Capped at maxRecentEdits paths.
//
// Ordering is by LAST edit, not first: re-editing a file moves it to the newest
// position so the tail cap below keeps the file the model most recently touched
// rather than an earlier, now-stale entry.
func recentEdits(messages []zeroruntime.Message) []skillEntry {
pathByID := map[string]string{}
sequence := make([]string, 0)
for _, message := range messages {
for _, call := range message.ToolCalls {
if call.Name != toolNameWriteFile && call.Name != toolNameEditFile {
continue
}
path := editPathFromArguments(call.Arguments)
if path == "" {
continue
}
if call.ID != "" {
pathByID[call.ID] = path
}
sequence = append(sequence, path)
}
}
order := lastSeenOrder(sequence)
if len(order) == 0 {
return nil
}
noteByPath := map[string]string{}
for _, message := range messages {
if message.Role != zeroruntime.MessageRoleTool || message.ToolCallID == "" {
continue
}
if path, ok := pathByID[message.ToolCallID]; ok {
noteByPath[path] = editNote(message.Content)
}
}
// Keep the most recent maxRecentEdits paths (the tail of last-seen order).
if len(order) > maxRecentEdits {
order = order[len(order)-maxRecentEdits:]
}
entries := make([]skillEntry, 0, len(order))
for _, path := range order {
entries = append(entries, skillEntry{name: path, body: noteByPath[path]})
}
return entries
}
// lastSeenOrder dedupes paths keeping each at its LAST occurrence, preserving
// chronological order otherwise. A re-edited path therefore lands at the newest
// position rather than staying pinned to where it first appeared.
func lastSeenOrder(paths []string) []string {
lastIdx := make(map[string]int, len(paths))
for i, p := range paths {
lastIdx[p] = i
}
order := make([]string, 0, len(lastIdx))
for i, p := range paths {
if lastIdx[p] == i {
order = append(order, p)
}
}
return order
}
// editPathFromArguments pulls the target file path from a write_file/edit_file
// call's JSON arguments (path/file/file_path/filename aliases). Returns "" on
// malformed arguments or no path.
func editPathFromArguments(arguments string) string {
var parsed struct {
Path string `json:"path"`
File string `json:"file"`
FilePath string `json:"file_path"`
Filename string `json:"filename"`
}
if err := json.Unmarshal([]byte(strings.TrimSpace(arguments)), &parsed); err != nil {
return ""
}
for _, candidate := range []string{parsed.Path, parsed.File, parsed.FilePath, parsed.Filename} {
if s := strings.TrimSpace(candidate); s != "" {
return s
}
}
return ""
}
// editNote reduces an edit tool's result to a single short line (its first
// non-empty line, byte-capped on a rune boundary) for the preserved summary.
func editNote(content string) string {
for _, line := range strings.Split(content, "\n") {
s := strings.TrimSpace(line)
if s == "" {
continue
}
if len(s) > maxEditNoteBytes {
limit := maxEditNoteBytes
for limit > 0 && !utf8.RuneStart(s[limit]) {
limit--
}
s = s[:limit] + "…"
}
return s
}
return ""
}
// loadedSkills returns the skills loaded via the skill tool in messages — the
// latest body per name, in first-seen order — matching each skill tool call to
// its tool result by id.
@@ -431,17 +310,11 @@ func capBody(body string) string {
// preservedState is the JSON shape of the carried-across-compaction block.
type preservedState struct {
Plan string `json:"plan,omitempty"`
RecentEdits []preservedEdit `json:"recent_edits,omitempty"`
Tools []preservedTool `json:"tools,omitempty"`
Skills []preservedSkill `json:"skills,omitempty"`
ProjectInstructions []preservedInstruction `json:"project_instructions,omitempty"`
}
type preservedEdit struct {
Path string `json:"path"`
Note string `json:"note,omitempty"`
}
type preservedTool struct {
Name string `json:"name"`
Body string `json:"body"`
@@ -474,12 +347,6 @@ func appendPreservedState(summary string, middle []zeroruntime.Message) string {
plan = priorState.Plan
}
// Recent edits: merge edits preserved earlier with fresh write_file/edit_file
// results in middle (newer note per path wins AND moves to the newest
// position), so the tail cap keeps the files the model most recently touched
// across repeated compactions rather than dropping a just-re-edited file.
edits := capRecentEdits(mergeRecentEdits(preservedEditsToEntries(priorState.RecentEdits), recentEdits(middle)))
// Tools: preserve deferred tool_search schemas from the transcript. Fresh
// loads override older carried copies by name.
tools := mergeSkillEntries(preservedToolsToEntries(priorState.Tools), loadedToolSchemas(middle))
@@ -493,76 +360,19 @@ func appendPreservedState(summary string, middle []zeroruntime.Message) string {
projectInstructionEntries(middle),
)
if block := formatPreservedState(plan, edits, tools, skills, instructions); block != "" {
if block := formatPreservedState(plan, tools, skills, instructions); block != "" {
summary += "\n\n" + block
}
return summary
}
// mergeRecentEdits overlays fresh edits onto edits preserved by an earlier
// compaction. Unlike mergeSkillEntries (which keeps refreshed entries in their
// original slot), a path touched again by a fresh edit MOVES to the newest
// position and takes the fresh note, so capRecentEdits — which keeps only the
// most-recent tail — never drops a file the model just re-edited. Paths present
// only in the older set keep their relative order, ahead of the fresh ones.
func mergeRecentEdits(older, newer []skillEntry) []skillEntry {
if len(newer) == 0 {
return older
}
freshBody := make(map[string]string, len(newer))
freshOrder := make([]string, 0, len(newer))
for _, e := range newer {
if _, ok := freshBody[e.name]; !ok {
freshOrder = append(freshOrder, e.name)
}
freshBody[e.name] = e.body
}
merged := make([]skillEntry, 0, len(older)+len(freshOrder))
// Older entries that were NOT re-edited keep their position, oldest first.
for _, e := range older {
if _, refreshed := freshBody[e.name]; refreshed {
continue // re-appended at its newest position below
}
merged = append(merged, e)
}
// Fresh edits follow, in last-seen order, each carrying the fresh note — so
// the most recently touched files sit at the tail the cap preserves.
for _, name := range freshOrder {
merged = append(merged, skillEntry{name: name, body: freshBody[name]})
}
return merged
}
// capRecentEdits bounds the preserved edit list to the most recent maxRecentEdits
// entries after a merge, so repeated compactions can't grow it without limit.
func capRecentEdits(entries []skillEntry) []skillEntry {
if len(entries) > maxRecentEdits {
return entries[len(entries)-maxRecentEdits:]
}
return entries
}
func preservedEditsToEntries(edits []preservedEdit) []skillEntry {
entries := make([]skillEntry, 0, len(edits))
for _, e := range edits {
if e.Path == "" {
continue
}
entries = append(entries, skillEntry{name: e.Path, body: e.Note})
}
return entries
}
// formatPreservedState renders state as the labelled, single-line
// JSON block. Returns "" when there is nothing to preserve.
func formatPreservedState(plan string, edits, tools, skills, instructions []skillEntry) string {
if plan == "" && len(edits) == 0 && len(tools) == 0 && len(skills) == 0 && len(instructions) == 0 {
func formatPreservedState(plan string, tools, skills, instructions []skillEntry) string {
if plan == "" && len(tools) == 0 && len(skills) == 0 && len(instructions) == 0 {
return ""
}
state := preservedState{Plan: plan}
for _, e := range edits {
state.RecentEdits = append(state.RecentEdits, preservedEdit{Path: e.name, Note: e.body})
}
for _, t := range tools {
state.Tools = append(state.Tools, preservedTool{Name: t.name, Body: t.body})
}
@@ -1,121 +0,0 @@
package agent
import (
"fmt"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// recentEdits extracts each mutated file's path and a one-line note from the
// matching tool result, latest note per path in last-seen order.
func TestRecentEditsExtractsPathsAndNotes(t *testing.T) {
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
{ID: "e1", Name: "write_file", Arguments: `{"path":"internal/foo.go","content":"package foo"}`},
}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e1", Content: "Wrote internal/foo.go (12 lines)"},
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
{ID: "e2", Name: "edit_file", Arguments: `{"path":"internal/bar.go","old_string":"a","new_string":"b"}`},
}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e2", Content: "Applied edit to internal/bar.go"},
}
edits := recentEdits(messages)
if len(edits) != 2 {
t.Fatalf("expected 2 edited files, got %d: %#v", len(edits), edits)
}
if edits[0].name != "internal/foo.go" || !strings.Contains(edits[0].body, "12 lines") {
t.Fatalf("first edit = %#v, want foo.go with its note", edits[0])
}
if edits[1].name != "internal/bar.go" || !strings.Contains(edits[1].body, "Applied edit") {
t.Fatalf("second edit = %#v, want bar.go with its note", edits[1])
}
}
// After compaction elides the editing turns, the preserved-state block still
// names the edited files and what changed, so the model needn't re-read them.
func TestCompactionPreservesRecentEdits(t *testing.T) {
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
{Role: zeroruntime.MessageRoleUser, Content: "add a flag"},
{Role: zeroruntime.MessageRoleAssistant, Content: "editing", ToolCalls: []zeroruntime.ToolCall{
{ID: "e1", Name: "write_file", Arguments: `{"path":"cmd/main.go","content":"..."}`},
}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e1", Content: "Wrote cmd/main.go (adds --version flag)"},
{Role: zeroruntime.MessageRoleAssistant, Content: "done"},
{Role: zeroruntime.MessageRoleUser, Content: "continue"},
{Role: zeroruntime.MessageRoleAssistant, Content: "continuing"},
}
summary := compactStateConversation(t, messages)
state := parsePreservedStateBlock(summary)
if len(state.RecentEdits) != 1 {
t.Fatalf("expected 1 preserved edit, got %#v", state.RecentEdits)
}
if state.RecentEdits[0].Path != "cmd/main.go" || !strings.Contains(state.RecentEdits[0].Note, "--version") {
t.Fatalf("preserved edit = %#v, want cmd/main.go + its note", state.RecentEdits[0])
}
}
// A fresh edit note for a path overrides the one carried from an earlier
// compaction (newer wins) and moves the path to the newest position rather than
// duplicating it or leaving it pinned to its old slot.
func TestRecentEditsMergeNewerWins(t *testing.T) {
prior := preservedState{RecentEdits: []preservedEdit{{Path: "a.go", Note: "old note"}, {Path: "z.go", Note: "z"}}}
older := preservedEditsToEntries(prior.RecentEdits)
newer := []skillEntry{{name: "a.go", body: "new note"}, {name: "b.go", body: "added"}}
merged := mergeRecentEdits(older, newer)
// z.go (untouched) keeps its lead; a.go is re-edited so it takes the new note
// and moves behind z.go, ahead of the brand-new b.go: [z.go, a.go, b.go].
if len(merged) != 3 {
t.Fatalf("expected z.go + refreshed a.go + b.go, got %#v", merged)
}
if merged[0].name != "z.go" {
t.Fatalf("untouched z.go should stay first, got %#v", merged)
}
if merged[1].name != "a.go" || merged[1].body != "new note" {
t.Fatalf("re-edited a.go should move to the newest edits with the new note, got %#v", merged[1])
}
if merged[2].name != "b.go" {
t.Fatalf("brand-new b.go should be last, got %#v", merged[2])
}
}
// Regression: once more than maxRecentEdits distinct files are tracked, re-editing
// an early file must move it into the capped tail rather than leaving it to be
// dropped — otherwise the next compaction omits exactly the file the model most
// recently touched.
func TestRecentEditsCapKeepsReeditedFile(t *testing.T) {
// Earlier compaction preserved exactly maxRecentEdits files, f0 the oldest.
older := make([]skillEntry, 0, maxRecentEdits)
for i := 0; i < maxRecentEdits; i++ {
older = append(older, skillEntry{name: fmt.Sprintf("f%d.go", i), body: "old"})
}
// Fresh window re-edits the OLDEST file (f0) and adds a brand-new file, so the
// merged list exceeds the cap and something must be dropped.
newer := []skillEntry{{name: "f0.go", body: "re-edited"}, {name: "fresh.go", body: "new"}}
merged := capRecentEdits(mergeRecentEdits(older, newer))
if len(merged) != maxRecentEdits {
t.Fatalf("cap should keep %d entries, got %d", maxRecentEdits, len(merged))
}
var f0 *skillEntry
for i := range merged {
if merged[i].name == "f0.go" {
f0 = &merged[i]
}
if merged[i].name == "f1.go" {
t.Fatalf("f1.go (now the least-recently touched) should have been dropped, got %#v", merged)
}
}
if f0 == nil {
t.Fatalf("re-edited f0.go must survive the cap, got %#v", merged)
}
if f0.body != "re-edited" {
t.Fatalf("surviving f0.go should carry the fresh note, got %q", f0.body)
}
}
-62
View File
@@ -1,62 +0,0 @@
package agent
import (
"context"
"os"
"path/filepath"
"strings"
"time"
"github.com/Gitlawb/zero/internal/lsp"
)
// fileDiagnosticsTimeout bounds one inline post-edit diagnostics check so a
// slow or wedged language server can never hang a tool call; on timeout the
// edit simply reports without a diagnostics block.
const fileDiagnosticsTimeout = 10 * time.Second
// NewFileDiagnostics adapts an *lsp.Manager to the per-edit inline diagnostics
// callback (tools.RunOptions.Diagnostics): it reads the just-written file,
// checks it against the file's language server, and formats error-severity
// diagnostics for the model. Warnings and hints are excluded — nagging about
// style on every edit is noise, while a type error the edit just introduced is
// exactly what the model should see before its next step. Diagnostics are
// rendered with workspace-relative paths: the absolute path would put the
// local username/home directory into the model prompt and session transcript
// on every edit. Returns nil when manager is nil, disabling inline diagnostics
// entirely.
func NewFileDiagnostics(manager *lsp.Manager, workspaceRoot string) func(context.Context, string) string {
if manager == nil {
return nil
}
return func(ctx context.Context, absPath string) string {
text, err := os.ReadFile(absPath)
if err != nil {
return ""
}
checkCtx, cancel := context.WithTimeout(ctx, fileDiagnosticsTimeout)
defer cancel()
diagnostics, err := manager.Check(checkCtx, absPath, string(text))
if err != nil {
return ""
}
errors := lsp.FilterBySeverity(diagnostics, lsp.SeverityError)
if len(errors) == 0 {
return ""
}
return lsp.FormatDiagnostics(diagnosticsDisplayPath(workspaceRoot, absPath), errors)
}
}
// diagnosticsDisplayPath renders absPath relative to the workspace root for
// model-facing output, falling back to the file's base name when the path is
// outside the workspace (a bare name still identifies the file without
// exposing the directory layout).
func diagnosticsDisplayPath(workspaceRoot, absPath string) string {
if workspaceRoot != "" {
if rel, err := filepath.Rel(workspaceRoot, absPath); err == nil && !strings.HasPrefix(rel, "..") {
return rel
}
}
return filepath.Base(absPath)
}
-24
View File
@@ -1,24 +0,0 @@
package agent
import (
"path/filepath"
"testing"
)
// Diagnostics are model-facing: absolute paths would leak the local username
// and directory layout into the prompt and session transcript on every edit.
func TestDiagnosticsDisplayPath(t *testing.T) {
root := filepath.Join("/Users", "someone", "project")
cases := []struct {
root, abs, want string
}{
{root, filepath.Join(root, "internal", "a.go"), filepath.Join("internal", "a.go")},
{root, filepath.Join("/etc", "other.go"), "other.go"}, // outside root -> base name only
{"", filepath.Join("/home", "user", "x.go"), "x.go"}, // no root -> base name only
}
for _, c := range cases {
if got := diagnosticsDisplayPath(c.root, c.abs); got != c.want {
t.Errorf("diagnosticsDisplayPath(%q, %q) = %q, want %q", c.root, c.abs, got, c.want)
}
}
}
+9 -32
View File
@@ -22,12 +22,6 @@ const (
// calls have executed since the last update_plan call.
staleToolCallThreshold = 10
// stalePlanTurnThreshold injects the same one-shot reminder once this many
// turns have passed since the last update_plan while plan items are still
// pending — the turn-based complement to staleToolCallThreshold, catching a
// plan that drifts stale across many turns that each make few tool calls.
stalePlanTurnThreshold = 8
// toolOnlyProgressReminderAt injects a one-shot progress nudge after this
// many consecutive turns contain tool calls but no visible assistant text.
// It does not stop the run; it tells the model to synthesize what it already
@@ -49,12 +43,8 @@ const (
toolFailureHintAt = 2
// toolFailureStopAt halts the run after a tool fails this many times in a row
// with the same error, so NO model (weak or strong) burns turns looping on a
// bad call. Set to 6 (not 4): a corrective hint fires at toolFailureHintAt (2),
// and a model iterating on a genuinely tricky edit can legitimately fail a few
// times after the hint while converging — stopping at 4 cut those runs short.
// The streak still resets the moment the tool succeeds or hits a different
// error, so this only affects true same-error loops.
toolFailureStopAt = 6
// bad call.
toolFailureStopAt = 4
// maxContinueNudges bounds how many times the headless completion gate
// (Options.RequireCompletionSignal) re-prompts a model that stopped without a
@@ -364,13 +354,8 @@ type guardState struct {
emptyTurns int
totalToolCalls int
toolCallsSincePlanUpdate int
// turnsSincePlanUpdate counts turns (not individual tool calls) since the last
// update_plan, so a plan that goes stale across many low-tool-call turns is
// still caught — the tool-call counter alone can take many turns to trip when
// the model makes only one call per turn.
turnsSincePlanUpdate int
planEverCalled bool
notCalledReminderSent bool
planEverCalled bool
notCalledReminderSent bool
// staleReminderSent records whether the stale reminder has already fired for
// the current stale interval. It is cleared when a plan update opens a new
// interval, making the reminder one-shot per interval rather than per turn.
@@ -429,9 +414,8 @@ func (state *guardState) observeToolResult(name string, failed bool, output stri
func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (stop bool) {
hasToolCalls := len(collected.ToolCalls) > 0
hasVisibleText := strings.TrimSpace(collected.Text) != ""
hasReasoning := collected.HasReasoning || len(collected.ReasoningBlocks) > 0
if hasToolCalls || hasVisibleText || hasReasoning {
if hasToolCalls || hasVisibleText {
state.emptyTurns = 0
} else {
state.emptyTurns++
@@ -443,16 +427,11 @@ func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (sto
state.toolOnlyReminderSent = false
}
// One turn has passed; the plan-update below resets this to 0 when the model
// refreshes the plan this turn.
state.turnsSincePlanUpdate++
for _, call := range collected.ToolCalls {
state.totalToolCalls++
if call.Name == planToolName {
state.planEverCalled = true
state.toolCallsSincePlanUpdate = 0
state.turnsSincePlanUpdate = 0
// A fresh plan update opens a new stale interval.
state.staleReminderSent = false
// Record how many items remain so the completion gate knows whether
@@ -506,12 +485,10 @@ func (state *guardState) progressReminder() string {
// number of turns completed so far).
func (state *guardState) planReminder(turn int) string {
// STALE reminder takes priority: a long run without a plan update is the
// stronger signal. Fires on either the tool-call streak OR a turn streak with
// pending items (so a plan drifting stale across many low-call turns is caught,
// while a fully-completed plan is left alone). One-shot per stale interval.
if state.planEverCalled && !state.staleReminderSent &&
(state.toolCallsSincePlanUpdate >= staleToolCallThreshold ||
(state.turnsSincePlanUpdate >= stalePlanTurnThreshold && state.planItemsPending > 0)) {
// stronger signal. One-shot per stale interval.
if state.planEverCalled &&
!state.staleReminderSent &&
state.toolCallsSincePlanUpdate >= staleToolCallThreshold {
state.staleReminderSent = true
return planStaleReminder(state.toolCallsSincePlanUpdate)
}
-33
View File
@@ -22,14 +22,6 @@ func textTurn(content string) []zeroruntime.StreamEvent {
}
}
// reasoningTurn produces live reasoning without visible assistant text.
func reasoningTurn(content string) []zeroruntime.StreamEvent {
return []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventReasoning, Content: content},
{Type: zeroruntime.StreamEventDone},
}
}
// toolTurn produces a turn that calls a named tool with the given args JSON.
func toolTurn(callID string, toolName string, args string) []zeroruntime.StreamEvent {
return []zeroruntime.StreamEvent{
@@ -109,31 +101,6 @@ func TestRunResetsEmptyTurnCounterOnVisibleOutput(t *testing.T) {
}
}
func TestRunResetsEmptyTurnCounterOnReasoning(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
reasoningTurn("thinking 1"),
reasoningTurn("thinking 2"),
reasoningTurn("thinking 3"),
textTurn("done"),
},
}
result, err := Run(context.Background(), "go", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 12,
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("expected reasoning-only turns to keep the run live until final answer, got %q", result.FinalAnswer)
}
if len(provider.requests) != 4 {
t.Fatalf("expected 4 turns, got %d", len(provider.requests))
}
}
func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
root := t.TempDir()
writeAgentTestFile(t, root+"/notes.txt", "alpha")
+67 -296
View File
@@ -25,12 +25,7 @@ const maxTurnsFinalAnswerPrompt = "You have reached the tool-turn limit. Do not
// 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.
//
// 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.
const maxStreamStallRetries = 1
const maxStreamStallRetries = 2
const (
toolResultMetaControl = "control"
@@ -109,7 +104,7 @@ const escalationFailedNoticePrefix = "Note: could not switch to the requested mo
// The system prompt (core coding-craft instructions + workspace context + safety
// confirmation policy) is assembled in system_prompt.go via buildSystemPrompt.
func Run(ctx context.Context, prompt string, provider Provider, options Options) (result Result, err error) {
func Run(ctx context.Context, prompt string, provider Provider, options Options) (Result, error) {
if provider == nil {
return Result{}, errors.New("agent provider is required")
}
@@ -137,12 +132,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
guards := newGuardState()
compactor := newCompactionState(options)
// Background post-edit diagnostics: files changed by mutating tools are
// checked off the tool-call critical path and any errors are appended as a
// nudge before the NEXT request (see async_diagnostics.go). nil when no
// FileDiagnostics callback is wired; every method no-ops on nil.
postEditDiagnostics := newAsyncDiagnostics(options.FileDiagnostics, options.Cwd)
// loaded tracks deferred-eligible tools the model has pulled via tool_search
// during THIS run. It is consulted by partitionTools each turn to expose a
// loaded tool's full schema; it lives only for the run (v1 within-run scope).
@@ -156,35 +145,15 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// has already been demanded this run, so it fires at most once.
acceptanceRequested := false
// toolDefCache memoizes each tool's rendered JSON-schema definition across
// turns (a tool's advertised schema is stable for the run), so partitionTools
// doesn't re-run the recursive schema→map conversion for every tool every turn.
toolDefCache := map[string]zeroruntime.ToolDefinition{}
result = Result{Messages: copyMessages(messages)}
dispatchSessionStart(ctx, options)
defer func() {
dispatchSessionEnd(ctx, options, result, err)
}()
result := Result{Messages: copyMessages(messages)}
for turn := 0; turn < maxTurns; turn++ {
result.Turns = turn + 1
// Deliver background post-edit diagnostics from the previous turn's edits
// BEFORE compaction so the nudge is part of the request being budgeted.
// A brief wait at most (asyncDiagnosticsDrainTimeout); an unfinished check
// simply delivers on a later turn.
if nudge := postEditDiagnostics.drain(ctx); nudge != "" {
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: nudge,
})
}
// Build the per-turn tool list first so proactive compaction can include
// the tool-definition tokens (they ride on every request) in its estimate.
// partitionTools depends only on registry/permissions/options/loaded, not on
// the messages, so computing it before compaction is safe.
exposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
exposed, _ := partitionTools(registry, permissionMode, options, loaded)
// PROACTIVE compaction: if the history is approaching the model's
// context window, summarize the oldest middle before building the
@@ -194,7 +163,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
PromptCacheKey: options.SessionID,
}
// Report the per-category context budget for this turn so a surface can
@@ -230,7 +198,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
PromptCacheKey: options.SessionID,
}
// Pre-content connect after a context-limit compaction: route through the
// reconnect helper so a transient upstream hiccup here doesn't fail the
@@ -291,7 +258,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
PromptCacheKey: options.SessionID,
}
retryStream, retryStreamErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
if retryStreamErr != nil {
@@ -350,7 +316,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
PromptCacheKey: options.SessionID,
}
retryStream, retryErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
if retryErr != nil {
@@ -375,16 +340,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
}
// Calibrate the compaction token estimator against the provider's real
// prompt-token count for the request we just sent, so later turns trigger
// compaction near true capacity instead of ~15% early on code-heavy history.
// Recompute the estimate HERE (not at request-build time): a reactive
// compaction may have replaced `messages` with a smaller set and re-sent, so
// this reflects the request that actually produced collected.Usage. The
// assistant reply is appended below, after this, so `messages` is still the
// sent request.
compactor.calibrate(estimateTokens(messages)+estimateToolDefTokens(exposed), collected.Usage.InputTokens)
// Carry the turn's terminal stop reason so a final answer cut off at the
// output token cap (or by a content filter) is reported as truncated. A
// tool-call turn normalizes to "" and clears any prior reason.
@@ -495,19 +450,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
continue
}
}
// Finalization diagnostics gate: edits from this run may still have
// checks in flight — the per-turn drain waits only briefly and defers
// to "a later turn", but a final answer means there is no later turn.
// Wait out the full inline-era budget once; an introduced error gives
// the model one more turn to see (and fix) it instead of being lost.
// Free for runs that never edited: an idle collector returns "".
if nudge := postEditDiagnostics.drainFinal(ctx); nudge != "" {
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: nudge,
})
continue
}
result.FinalAnswer = collected.Text
result.Messages = copyMessages(messages)
return result, nil
@@ -533,35 +475,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// between tool_results breaks strict provider replay) — same after-batch
// rationale as turnRequestedModel above.
var changedFilesThisBatch []string
// Parallel read-ahead state: results for calls[precomputedStart:precomputedEnd]
// executed concurrently, consumed strictly in order below.
var precomputed []precomputedToolResult
precomputedStart, precomputedEnd := 0, 0
for index, call := range collected.ToolCalls {
// When this call starts a consecutive run of >= 2 auto-allowed read-only
// calls, execute the whole run concurrently now (see parallel_tools.go).
// The scan happens lazily at the run's first index — never ahead of a
// pending mutating call — so read-after-write ordering is preserved.
if index >= precomputedEnd {
runEnd := index
for runEnd < len(collected.ToolCalls) && parallelSafeToolCall(registry, collected.ToolCalls[runEnd], options) {
runEnd++
}
if runEnd-index >= 2 {
precomputed = executeParallelReadBatch(ctx, registry, collected.ToolCalls, index, runEnd, permissionMode, options)
precomputedStart, precomputedEnd = index, runEnd
}
}
if options.OnToolCall != nil {
options.OnToolCall(call)
}
var toolResult ToolResult
var abortErr error
if index >= precomputedStart && index < precomputedEnd {
toolResult, abortErr = precomputed[index-precomputedStart].result, precomputed[index-precomputedStart].abortErr
} else {
toolResult, abortErr = executeToolCall(ctx, registry, call, permissionMode, options)
}
toolResult, abortErr := executeToolCall(ctx, registry, call, permissionMode, options)
if options.OnToolResult != nil {
options.OnToolResult(toolResult)
}
@@ -624,14 +542,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
failureHint = toolFailureHint(call.Name, toolSchemaJSON(registry, call.Name), toolResult.Output)
}
// Collect the files this successful mutating tool changed. Self-correct
// verification runs once over the union after the batch; background
// diagnostics start NOW so the language server analyzes while the rest
// of the batch executes. A read-only tool (no ChangedFiles) never
// contributes.
if toolResult.Status == tools.StatusOK && len(toolResult.ChangedFiles) > 0 {
// Post-edit self-correction: collect the files this successful mutating
// tool changed; verification runs once over the union after the batch.
// A read-only tool (no ChangedFiles) never contributes.
if options.SelfCorrect != nil && toolResult.Status == tools.StatusOK && len(toolResult.ChangedFiles) > 0 {
changedFilesThisBatch = append(changedFilesThisBatch, toolResult.ChangedFiles...)
postEditDiagnostics.enqueue(ctx, toolResult.ChangedFiles)
}
}
@@ -714,19 +629,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// mid-run, not a natural completion (a finished run returns via the no-tool-call
// success path before this). Under the headless completion gate that is INCOMPLETE,
// not success, so a run that loops to the turn limit isn't reported as done.
//
// The final-answer call is one more model request, so diagnostics from the
// LAST turn's edits get a drain too — with the finalization budget, since
// there is no later turn to defer to — otherwise an error introduced by
// the final edit would go unreported in the summary.
if nudge := postEditDiagnostics.drainFinal(ctx); nudge != "" {
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: nudge,
})
}
finalExposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, finalExposed, options); strings.TrimSpace(answer) != "" {
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, options); strings.TrimSpace(answer) != "" {
result.FinalAnswer = answer
result.FinishReason = finishReason
result.Messages = copyMessages(finalMessages)
@@ -746,7 +649,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
return result, nil
}
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, options Options) (string, []zeroruntime.Message, string) {
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages []zeroruntime.Message, options Options) (string, []zeroruntime.Message, string) {
finalMessages := copyMessages(messages)
finalMessages = append(finalMessages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
@@ -757,9 +660,7 @@ func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages [
// transient hiccup doesn't drop the final summary (AUDIT-L1).
stream, err := streamWithReconnect(ctx, provider, zeroruntime.CompletionRequest{
Messages: copyMessages(finalMessages),
Tools: toolDefs,
ReasoningEffort: options.ReasoningEffort,
PromptCacheKey: options.SessionID,
}, reconnectNoticeFor(options))
if err != nil {
return "", messages, ""
@@ -915,25 +816,6 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
}
}
// A shell command that sets sandbox_permissions: with_additional_permissions
// must carry a valid additional_permissions payload; inlineAdditionalPermissionsProfile
// is the single source of truth for that shape, and buildPermissionEvent (below)
// calls it again to render the prompt's scope text. Check it here, before any
// permission prompt is shown: a malformed payload can never be satisfied no
// matter what the user decides, so surfacing it as a plain tool error (which the
// model can see and retry) is both clearer and avoids presenting a normal-looking
// prompt whose "allow" path is guaranteed to fail with a confusing denial.
if toolFound && isShellCommandTool(call.Name) {
if _, _, err := inlineAdditionalPermissionsProfile(args, options.Cwd); err != nil {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: "Error: " + err.Error(),
}, nil
}
}
// ask_user is intercepted in the loop (like permissions) so the question can
// be routed to an interactive front-end instead of blocking inside the tool.
// When no front-end is wired up it degrades to the tool's own graceful Run().
@@ -1150,12 +1032,6 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
// Per-session file version tracker so write_file/edit_file refuse to clobber
// a file that changed on disk outside Zero since it was last read.
FileTracker: options.FileTracker,
// Post-edit diagnostics are NOT forwarded to the tools: they used to run
// synchronously inside edit_file/write_file and block every mutating tool
// call on the language server (≥300ms debounce floor, 10s cap). The loop
// now checks changed files in the background and nudges the model before
// its next request instead (see async_diagnostics.go). The tools' inline
// mechanism (tools.RunOptions.Diagnostics) stays for direct API callers.
// Forward the run's operator tool filters so a filter-aware tool
// (tool_search) never discloses or loads an operator-hidden deferred tool.
EnabledTools: options.EnabledTools,
@@ -1574,54 +1450,6 @@ func dispatchAfterTool(ctx context.Context, options Options, call ToolCall, args
return strings.TrimSpace(strings.Join(outcome.Messages, "\n"))
}
// dispatchSessionStart runs configured sessionStart hooks once before the first
// model turn. Lifecycle hooks are advisory: dispatcher failures are audited but
// never block the run.
func dispatchSessionStart(ctx context.Context, options Options) {
if options.Hooks == nil {
return
}
options.Hooks.Dispatch(ctx, hooks.DispatchInput{
Event: hooks.EventSessionStart,
Payload: map[string]any{
"event": string(hooks.EventSessionStart),
"sessionId": options.SessionID,
"cwd": options.Cwd,
"provider": options.ProviderName,
"model": options.Model,
"depth": options.Depth,
"tag": options.Tag,
"sessionTitle": options.SessionTitle,
},
})
}
// dispatchSessionEnd runs configured sessionEnd hooks once when the agent run
// exits, including early error returns. Lifecycle hooks are advisory.
func dispatchSessionEnd(ctx context.Context, options Options, result Result, runErr error) {
if options.Hooks == nil {
return
}
payload := map[string]any{
"event": string(hooks.EventSessionEnd),
"sessionId": options.SessionID,
"cwd": options.Cwd,
"provider": options.ProviderName,
"model": options.Model,
"turns": result.Turns,
"stopReason": string(result.StopReason),
"finishReason": result.FinishReason,
"incomplete": result.Incomplete,
}
if runErr != nil {
payload["error"] = runErr.Error()
}
options.Hooks.Dispatch(ctx, hooks.DispatchInput{
Event: hooks.EventSessionEnd,
Payload: payload,
})
}
// blockedByHookResult is the tool result for a call vetoed by a beforeTool hook.
func blockedByHookResult(call ToolCall, outcome hooks.DispatchOutcome) ToolResult {
// The hook reason (its stdout/stderr) is model-visible here, an intercepted
@@ -2494,12 +2322,7 @@ func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (s
}
raw, ok := args["additional_permissions"]
if !ok || raw == nil {
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf(
"sandbox_permissions was set to %q but no additional_permissions object was provided. "+
`Include one, for example additional_permissions: {"network": {"enabled": true}} or `+
`{"file_system": {"write": ["/path"]}}. If this command does not need elevated permissions, `+
"omit sandbox_permissions entirely and retry",
tools.SandboxPermissionsWithAdditionalPermissions)
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf("missing additional_permissions; provide at least one of network or file_system")
}
data, err := json.Marshal(raw)
if err != nil {
@@ -2514,9 +2337,7 @@ func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (s
return sandbox.RequestPermissionProfile{}, true, err
}
if normalized.Empty() {
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf(
"additional_permissions must include at least one of network or file_system, for example " +
`{"network": {"enabled": true}} or {"file_system": {"write": ["/path"]}}`)
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf("additional_permissions must include at least one requested permission in network or file_system")
}
grantProfile, err := sandbox.RequestPermissionGrantProfile(normalized)
if err != nil {
@@ -2615,17 +2436,6 @@ func permissionActionFromSandbox(action sandbox.Action) PermissionAction {
// exposed. The exposed slice is alpha-sorted by name, matching the legacy order
// so the inactive path is stable.
func partitionTools(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool) ([]zeroruntime.ToolDefinition, string) {
return partitionToolsCached(registry, permissionMode, options, loaded, nil)
}
// partitionToolsCached is partitionTools with an optional per-tool definition
// cache. The partitioning itself (visibility, deferral, ordering) is recomputed
// every call — it must be, because a tool's deferred state can flip mid-run (e.g.
// swarm tools un-defer once a swarm is active). Only the expensive part —
// rendering each tool's JSON-schema parameters — is memoized by tool name, since a
// tool's advertised name/description/schema is stable for the run. defCache nil
// disables caching (used by tests and the plain partitionTools entrypoint).
func partitionToolsCached(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool, defCache map[string]zeroruntime.ToolDefinition) ([]zeroruntime.ToolDefinition, string) {
registeredTools := registry.All()
visible := make([]tools.Tool, 0, len(registeredTools))
@@ -2660,115 +2470,76 @@ func partitionToolsCached(registry *tools.Registry, permissionMode PermissionMod
active := options.DeferThreshold > 0 && eligible >= options.DeferThreshold && loaderUsable
// INACTIVE: every visible tool eager (except tool_search), alpha-sorted. With no
// deferral there is no mid-session loading, so this is byte-stable across turns
// and byte-identical to the pre-deferral output.
if !active {
definitions := make([]zeroruntime.ToolDefinition, 0, len(visible))
for _, tool := range visible {
if tool.Name() == tools.ToolSearchToolName {
continue
}
definitions = append(definitions, cachedRuntimeToolDefinition(defCache, tool))
}
sort.Slice(definitions, func(left int, right int) bool {
return definitions[left].Name < definitions[right].Name
})
return definitions, ""
}
// ACTIVE: lay the tools array out so its cacheable prefix does NOT shift when a
// deferred tool loads mid-session. The tools block is part of the provider's
// cached prefix; if a load reorders it, the cache invalidates from that point
// through the system prompt and messages — on EVERY load. Previously the whole
// array was alpha-sorted each turn, so a newly loaded tool was INSERTED into the
// middle and shifted every later definition. Instead we build three append-only
// regions:
// 1. non-deferred tools, alpha-sorted — always present, identical every turn;
// 2. tool_search — always present, right after the stable block;
// 3. loaded deferred tools, alpha-sorted — APPENDED, so an unloaded->loaded
// transition grows the tail instead of inserting into the eager block.
// (tool_search's discovery text still shrinks as tools load, so keeping it and
// the loaded tail AFTER the eager block preserves the eager tools' cache across a
// load; fully stabilizing the loader's own description is a scoped follow-up.)
eager := make([]zeroruntime.ToolDefinition, 0, len(visible))
loadedTail := make([]zeroruntime.ToolDefinition, 0)
definitions := make([]zeroruntime.ToolDefinition, 0, len(visible))
exposedNames := make(map[string]bool, len(visible))
var hiddenTools []tools.Tool
for _, tool := range visible {
name := tool.Name()
if name == tools.ToolSearchToolName {
continue // added explicitly between the eager block and the loaded tail
}
if tools.IsDeferred(tool) {
if loaded[name] {
loadedTail = append(loadedTail, cachedRuntimeToolDefinition(defCache, tool))
} else {
hiddenTools = append(hiddenTools, tool)
deferred := tools.IsDeferred(tool)
if !active {
// Inactive: byte-identical to legacy, but tool_search is never advertised.
if name == tools.ToolSearchToolName {
continue
}
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: name,
Description: tool.Description(),
Parameters: schemaToRuntimeMap(tool.Parameters()),
})
exposedNames[name] = true
continue
}
eager = append(eager, cachedRuntimeToolDefinition(defCache, tool))
}
sort.Slice(eager, func(left int, right int) bool {
return eager[left].Name < eager[right].Name
})
sort.Slice(loadedTail, func(left int, right int) bool {
return loadedTail[left].Name < loadedTail[right].Name
})
// Active path.
if deferred && !loaded[name] {
hiddenTools = append(hiddenTools, tool)
continue
}
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: name,
Description: tool.Description(),
Parameters: schemaToRuntimeMap(tool.Parameters()),
})
exposedNames[name] = true
}
// On the ACTIVE path tool_search is guaranteed runnable (active implies
// loaderUsable), so it must ALWAYS be reachable — even when a non-empty
// EnabledTools allowlist omits it (the operator allowlisted the deferred
// tools, not the loader). Expose its full definition whenever it is not
// already in the exposed set. This never runs on the inactive path, so the
// byte-identical below-threshold output is preserved.
discovery := ""
if len(hiddenTools) > 0 {
if active && len(hiddenTools) > 0 {
discovery = tools.BuildToolSearchDescription(hiddenTools)
}
// tool_search is guaranteed runnable on the active path, so it is ALWAYS exposed
// — even when a non-empty EnabledTools allowlist omits it (the operator
// allowlisted the deferred tools, not the loader). It sits right after the stable
// eager block and carries the discovery list for still-hidden tools.
description := loader.Description()
if discovery != "" {
description = discovery
if active && !exposedNames[tools.ToolSearchToolName] {
description := loader.Description()
if discovery != "" {
description = discovery
}
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: loader.Name(),
Description: description,
Parameters: schemaToRuntimeMap(loader.Parameters()),
})
} else if active && discovery != "" {
for i := range definitions {
if definitions[i].Name == tools.ToolSearchToolName {
definitions[i].Description = discovery
break
}
}
}
definitions := make([]zeroruntime.ToolDefinition, 0, len(eager)+1+len(loadedTail))
definitions = append(definitions, eager...)
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: loader.Name(),
Description: description,
Parameters: schemaToRuntimeMap(loader.Parameters()),
sort.Slice(definitions, func(left int, right int) bool {
return definitions[left].Name < definitions[right].Name
})
definitions = append(definitions, loadedTail...)
return definitions, discovery
}
// cachedRuntimeToolDefinition returns the tool's rendered definition, reusing a
// cached render when defCache holds one for this tool name. A tool's advertised
// definition is stable across a run, so caching skips the recursive schema→map
// conversion (schemaToRuntimeMap) that would otherwise run for every tool on every
// turn. tool_search is excluded by its callers (its description is dynamic), so it
// never poisons the cache. A nil cache computes fresh.
func cachedRuntimeToolDefinition(defCache map[string]zeroruntime.ToolDefinition, tool tools.Tool) zeroruntime.ToolDefinition {
if defCache == nil {
return runtimeToolDefinition(tool)
}
if def, ok := defCache[tool.Name()]; ok {
return def
}
def := runtimeToolDefinition(tool)
defCache[tool.Name()] = def
return def
}
// runtimeToolDefinition renders a tool's advertised definition (name, description,
// JSON-schema parameters) as sent to the provider.
func runtimeToolDefinition(tool tools.Tool) zeroruntime.ToolDefinition {
return zeroruntime.ToolDefinition{
Name: tool.Name(),
Description: tool.Description(),
Parameters: schemaToRuntimeMap(tool.Parameters()),
}
}
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
return ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) && ToolAdvertised(tool, permissionMode)
}
+6 -79
View File
@@ -4,14 +4,12 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/specmode"
"github.com/Gitlawb/zero/internal/tools"
@@ -39,59 +37,6 @@ func (provider *mockProvider) StreamCompletion(ctx context.Context, request zero
return ch, nil
}
func TestRunDispatchesSessionLifecycleHooks(t *testing.T) {
audit, err := hooks.NewAuditStore(hooks.AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
if err != nil {
t.Fatalf("NewAuditStore: %v", err)
}
dispatcher := hooks.NewDispatcher(hooks.DispatcherOptions{
Config: hooks.Config{
Enabled: true,
Hooks: []hooks.Definition{
{ID: "zero.session-start", Event: hooks.EventSessionStart, Command: "zero-missing-hook-command", Enabled: true},
{ID: "zero.session-end", Event: hooks.EventSessionEnd, Command: "zero-missing-hook-command", Enabled: true},
},
},
Audit: audit,
})
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
}}}
_, err = Run(context.Background(), "hi", provider, Options{
SessionID: "session-123",
Cwd: t.TempDir(),
ProviderName: "test-provider",
Model: "test-model",
Hooks: dispatcher,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
events, err := audit.ReadEvents()
if err != nil {
t.Fatalf("ReadEvents: %v", err)
}
started := map[hooks.Event]int{}
completed := map[hooks.Event]int{}
for _, event := range events {
switch event.Type {
case "hook_execution_started":
started[event.Event]++
case "hook_execution_completed":
completed[event.Event]++
}
}
if started[hooks.EventSessionStart] != 1 || completed[hooks.EventSessionStart] != 1 {
t.Fatalf("sessionStart audit counts started/completed = %d/%d, events=%#v", started[hooks.EventSessionStart], completed[hooks.EventSessionStart], events)
}
if started[hooks.EventSessionEnd] != 1 || completed[hooks.EventSessionEnd] != 1 {
t.Fatalf("sessionEnd audit counts started/completed = %d/%d, events=%#v", started[hooks.EventSessionEnd], completed[hooks.EventSessionEnd], events)
}
}
type recordingWebSearchTool struct {
calls []map[string]any
}
@@ -2000,11 +1945,6 @@ func TestRunCommandPrefixApprovalBypassesSandboxForMatchingShellCalls(t *testing
func TestRunCommandPrefixApprovalCoversSegmentedShellWithSafeTail(t *testing.T) {
root := t.TempDir()
segmentedCommand := `ps aux | head -5`
if runtime.GOOS == "windows" {
// head is MSYS-prone on Windows (#458) and no longer counts as a known-safe tail.
segmentedCommand = `ps aux | echo ok`
}
retryTool := &sandboxDeniedRetryTool{}
registry := tools.NewRegistry()
registry.Register(retryTool)
@@ -2018,7 +1958,7 @@ func TestRunCommandPrefixApprovalCoversSegmentedShellWithSafeTail(t *testing.T)
},
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: fmt.Sprintf(`{"command":%q}`, segmentedCommand)},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"command":"ps aux | head -5"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
{Type: zeroruntime.StreamEventDone},
},
@@ -2822,11 +2762,11 @@ func TestRunRequestsFinalAnswerAfterMaxTurns(t *testing.T) {
t.Fatalf("expected final answer from finalization turn, got %q", result.FinalAnswer)
}
if len(provider.requests) != 2 {
t.Fatalf("expected finalization request after max turns, got %d requests", len(provider.requests))
t.Fatalf("expected final no-tools request after max turns, got %d requests", len(provider.requests))
}
finalRequest := provider.requests[1]
if toolDefinitionByName(finalRequest.Tools, "read_file") == nil {
t.Fatalf("finalization request must keep prior tools exposed for strict provider replay, got %#v", finalRequest.Tools)
if len(finalRequest.Tools) != 0 {
t.Fatalf("finalization request must not advertise tools, got %#v", finalRequest.Tools)
}
lastMessage := finalRequest.Messages[len(finalRequest.Messages)-1]
if lastMessage.Role != zeroruntime.MessageRoleUser || !strings.Contains(lastMessage.Content, "tool-turn limit") {
@@ -2967,21 +2907,8 @@ func TestBuildSystemPromptInjectsHostShellContext(t *testing.T) {
t.Fatalf("expected operating system in environment block, got %q", prompt)
}
if runtime.GOOS == "windows" {
for _, want := range []string{"Windows cmd.exe syntax", "cwd argument", "MSYS binaries", "grep", "require_escalated", "use double quotes around the value"} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected Windows shell guidance to mention %q, got %q", want, prompt)
}
}
// The examples must themselves use the safe (double-quoted) form; a
// single-quoted example here would teach the model the exact syntax
// that fails under cmd.exe.
for _, want := range []string{`--jq ".a | b"`, `-run "A|B"`} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected double-quoted example %q in Windows shell guidance, got %q", want, prompt)
}
}
if strings.Contains(prompt, `'.a | b'`) || strings.Contains(prompt, `'A|B'`) {
t.Fatalf("Windows shell guidance must not show the unsafe single-quoted form, got %q", prompt)
if !strings.Contains(prompt, "Windows cmd.exe syntax") || !strings.Contains(prompt, "cwd argument") {
t.Fatalf("expected Windows cmd.exe shell guidance in prompt, got %q", prompt)
}
} else if !strings.Contains(prompt, "/bin/sh syntax") {
t.Fatalf("expected POSIX shell guidance in prompt, got %q", prompt)
-97
View File
@@ -1,97 +0,0 @@
package agent
import (
"context"
"sync"
"github.com/Gitlawb/zero/internal/tools"
)
// Parallel read-ahead for tool batches. When a turn requests several
// independent lookups (read_file + grep + glob is the common shape), executing
// them one after another serializes pure I/O waits. A consecutive run of
// auto-allowed read-only calls is executed concurrently instead; results are
// then consumed in the original call order, so guard counters, message
// ordering, abort semantics, and the surface's call/result event pairing are
// byte-identical to sequential execution. Runs never span a mutating call: a
// read that follows a write must observe the write, so eligibility is decided
// per consecutive run, not per batch.
// maxParallelReadTools bounds concurrent read-only tool executions in a turn.
const maxParallelReadTools = 8
// precomputedToolResult is one parallel read-ahead execution, keyed back to
// its batch index by the caller.
type precomputedToolResult struct {
result ToolResult
abortErr error
}
// parallelSafeToolCall reports whether call may run concurrently with its
// neighbors: the tool must exist, be side-effect-free (SideEffectRead), and be
// auto-allowed for these args, so no interactive prompt or workspace mutation
// is on the hot path. Loop-intercepted tools (ask_user, request_permissions)
// and tool_search (mutates the deferred-tool set) stay sequential.
func parallelSafeToolCall(registry *tools.Registry, call ToolCall, options Options) bool {
switch call.Name {
case "ask_user", tools.RequestPermissionsToolName, tools.ToolSearchToolName:
return false
}
tool, found := registry.Get(call.Name)
if !found || tool.Safety().SideEffect != tools.SideEffectRead {
return false
}
args := map[string]any{}
if call.Arguments != "" {
if err := decodeToolArguments(call.Arguments, &args); err != nil {
return false
}
}
return effectivePermission(tool, args) == tools.PermissionAllow
}
// executeParallelReadBatch runs calls[start:end] concurrently (bounded by
// maxParallelReadTools) and returns results indexed relative to start. All
// execution-side callbacks that can fire inside executeToolCall are serialized
// behind one mutex: a permission prompt (a sandbox preflight can demand one
// even for an auto-allowed read) must never appear twice at once on an
// interactive front-end, and OnPermission event handlers append to shared
// session-recording state without their own locking — two batched reads under
// a granted extra root would otherwise race (the pre-batch serial loop never
// had two callbacks in flight at once).
func executeParallelReadBatch(ctx context.Context, registry *tools.Registry, calls []ToolCall, start, end int, permissionMode PermissionMode, options Options) []precomputedToolResult {
batchOptions := options
var callbackMutex sync.Mutex
if options.OnPermissionRequest != nil {
inner := options.OnPermissionRequest
batchOptions.OnPermissionRequest = func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
callbackMutex.Lock()
defer callbackMutex.Unlock()
return inner(ctx, request)
}
}
if options.OnPermission != nil {
inner := options.OnPermission
batchOptions.OnPermission = func(event PermissionEvent) {
callbackMutex.Lock()
defer callbackMutex.Unlock()
inner(event)
}
}
results := make([]precomputedToolResult, end-start)
semaphore := make(chan struct{}, maxParallelReadTools)
var waitGroup sync.WaitGroup
for index := start; index < end; index++ {
waitGroup.Add(1)
go func(index int) {
defer waitGroup.Done()
semaphore <- struct{}{}
defer func() { <-semaphore }()
result, abortErr := executeToolCall(ctx, registry, calls[index], permissionMode, batchOptions)
results[index-start] = precomputedToolResult{result: result, abortErr: abortErr}
}(index)
}
waitGroup.Wait()
return results
}
-204
View File
@@ -1,204 +0,0 @@
package agent
import (
"context"
"fmt"
"sync"
"testing"
"time"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// probeTool records execution overlap and ordering so tests can assert what
// actually ran concurrently.
type probeTool struct {
name string
sideEffect tools.SideEffect
delay time.Duration
// shared, when set, receives this probe's start/end entries too, so a test
// can observe ordering ACROSS probes (e.g. reads vs a write barrier).
shared *probeLog
mu sync.Mutex
active int
maxActive int
log []string
}
// probeLog is a mutex-guarded event log shared between probes.
type probeLog struct {
mu sync.Mutex
entries []string
}
func (log *probeLog) append(entry string) {
log.mu.Lock()
defer log.mu.Unlock()
log.entries = append(log.entries, entry)
}
func (log *probeLog) snapshot() []string {
log.mu.Lock()
defer log.mu.Unlock()
return append([]string(nil), log.entries...)
}
func (tool *probeTool) Name() string { return tool.name }
func (tool *probeTool) Description() string { return "test probe tool" }
func (tool *probeTool) Parameters() tools.Schema {
return tools.Schema{
Type: "object",
Properties: map[string]tools.PropertySchema{"id": {Type: "string"}},
AdditionalProperties: false,
}
}
func (tool *probeTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tool.sideEffect, Permission: tools.PermissionAllow, Reason: "test"}
}
func (tool *probeTool) Run(_ context.Context, args map[string]any) tools.Result {
id, _ := args["id"].(string)
tool.mu.Lock()
tool.active++
if tool.active > tool.maxActive {
tool.maxActive = tool.active
}
tool.log = append(tool.log, "start:"+id)
tool.mu.Unlock()
if tool.shared != nil {
tool.shared.append("start:" + id)
}
time.Sleep(tool.delay)
if tool.shared != nil {
tool.shared.append("end:" + id)
}
tool.mu.Lock()
tool.active--
tool.log = append(tool.log, "end:"+id)
tool.mu.Unlock()
return tools.Result{Status: tools.StatusOK, Output: "probe " + id}
}
func probeCallEvents(callID, toolName, id string) []zeroruntime.StreamEvent {
return []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: callID, ToolName: toolName},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: callID, ArgumentsFragment: fmt.Sprintf(`{"id":%q}`, id)},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: callID},
}
}
func TestParallelSafeToolCall(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(&probeTool{name: "probe_read", sideEffect: tools.SideEffectRead})
registry.Register(&probeTool{name: "probe_write", sideEffect: tools.SideEffectWrite})
call := func(name, args string) ToolCall { return ToolCall{ID: "c", Name: name, Arguments: args} }
if !parallelSafeToolCall(registry, call("probe_read", `{"id":"a"}`), Options{}) {
t.Fatal("auto-allowed read tool must be parallel-safe")
}
if parallelSafeToolCall(registry, call("probe_write", `{"id":"a"}`), Options{}) {
t.Fatal("mutating tool must not be parallel-safe")
}
if parallelSafeToolCall(registry, call("unknown_tool", `{}`), Options{}) {
t.Fatal("unknown tool must not be parallel-safe")
}
if parallelSafeToolCall(registry, call("probe_read", `{"id":`), Options{}) {
t.Fatal("undecodable arguments must not be parallel-safe")
}
if parallelSafeToolCall(registry, call("ask_user", `{}`), Options{}) {
t.Fatal("loop-intercepted tools must stay sequential")
}
}
func TestRunExecutesConsecutiveReadsConcurrently(t *testing.T) {
probe := &probeTool{name: "probe_read", sideEffect: tools.SideEffectRead, delay: 60 * time.Millisecond}
registry := tools.NewRegistry()
registry.Register(probe)
turnOne := append(probeCallEvents("call-1", "probe_read", "a"), probeCallEvents("call-2", "probe_read", "b")...)
turnOne = append(turnOne, probeCallEvents("call-3", "probe_read", "c")...)
turnOne = append(turnOne, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
turnOne,
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
},
}
var results []ToolResult
_, err := Run(context.Background(), "probe", provider, Options{
Registry: registry,
OnToolResult: func(result ToolResult) { results = append(results, result) },
})
if err != nil {
t.Fatal(err)
}
if probe.maxActive < 2 {
t.Fatalf("consecutive read-only calls must overlap, max concurrency was %d", probe.maxActive)
}
// Results must still be recorded in original call order.
if len(results) != 3 || results[0].ToolCallID != "call-1" || results[1].ToolCallID != "call-2" || results[2].ToolCallID != "call-3" {
t.Fatalf("tool results out of order: %#v", results)
}
messages := provider.requests[1].Messages
var toolOrder []string
for _, message := range messages {
if message.Role == zeroruntime.MessageRoleTool {
toolOrder = append(toolOrder, message.ToolCallID)
}
}
if len(toolOrder) != 3 || toolOrder[0] != "call-1" || toolOrder[1] != "call-2" || toolOrder[2] != "call-3" {
t.Fatalf("recorded tool messages out of order: %v", toolOrder)
}
}
func TestRunParallelReadsNeverSpanMutatingCall(t *testing.T) {
shared := &probeLog{}
read := &probeTool{name: "probe_read", sideEffect: tools.SideEffectRead, delay: 30 * time.Millisecond, shared: shared}
write := &probeTool{name: "probe_write", sideEffect: tools.SideEffectWrite, shared: shared}
registry := tools.NewRegistry()
registry.Register(read)
registry.Register(write)
turnOne := append(probeCallEvents("call-1", "probe_read", "r1"), probeCallEvents("call-2", "probe_read", "r2")...)
turnOne = append(turnOne, probeCallEvents("call-3", "probe_write", "w")...)
turnOne = append(turnOne, probeCallEvents("call-4", "probe_read", "r3")...)
turnOne = append(turnOne, probeCallEvents("call-5", "probe_read", "r4")...)
turnOne = append(turnOne, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
turnOne,
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
},
}
_, err := Run(context.Background(), "probe", provider, Options{Registry: registry})
if err != nil {
t.Fatal(err)
}
// Cross-probe ordering on the SHARED log: the write must start only after
// both first-batch reads finished, and both second-batch reads must start
// only after the write finished — batches never cross a mutating call.
log := shared.snapshot()
index := func(entry string) int {
for i, e := range log {
if e == entry {
return i
}
}
t.Fatalf("entry %q missing from shared log: %v", entry, log)
return -1
}
writeStart, writeEnd := index("start:w"), index("end:w")
if firstBatchMaxEnd := max(index("end:r1"), index("end:r2")); writeStart < firstBatchMaxEnd {
t.Fatalf("write started before the first read batch finished: %v", log)
}
if secondBatchMinStart := min(index("start:r3"), index("start:r4")); secondBatchMinStart < writeEnd {
t.Fatalf("second read batch started before the write finished: %v", log)
}
if read.maxActive < 2 {
t.Fatalf("reads within a batch must overlap, max concurrency was %d", read.maxActive)
}
}
@@ -1,44 +0,0 @@
package agent
import (
"testing"
"github.com/Gitlawb/zero/internal/tools"
)
func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
// Cache-stability property: loading a deferred tool must APPEND it after the
// always-eager tools, not sort it into the middle of them — otherwise the tool
// definitions (the provider's cacheable prefix) shift on every load. The old
// behaviour alpha-sorted the whole array, so "mcp__srv__alpha" jumped ahead of
// "read_file".
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root)) // non-deferred, "read_file"
registry.Register(fakeToolSearchTool{})
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
exposed, _ := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 2}, map[string]bool{"mcp__srv__alpha": true})
pos := func(name string) int {
for i := range exposed {
if exposed[i].Name == name {
return i
}
}
return -1
}
readPos, searchPos, alphaPos := pos("read_file"), pos("tool_search"), pos("mcp__srv__alpha")
if readPos < 0 || searchPos < 0 || alphaPos < 0 {
t.Fatalf("expected read_file, tool_search and loaded alpha all exposed, got %#v", exposed)
}
// The eager tools must precede the loaded deferred tool (appended, not inserted).
if readPos >= alphaPos || searchPos >= alphaPos {
t.Fatalf("loaded deferred tool must be appended after the eager block; got read=%d search=%d alpha=%d", readPos, searchPos, alphaPos)
}
// beta was never loaded — it stays hidden.
if pos("mcp__srv__beta") != -1 {
t.Fatalf("unloaded deferred tool must stay hidden, got %#v", exposed)
}
}
-63
View File
@@ -1,63 +0,0 @@
package agent
import (
"context"
"reflect"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// countingSchemaTool records how many times its Parameters() schema is read, so a
// test can prove the definition cache avoids re-rendering across turns.
type countingSchemaTool struct {
name string
calls *int
}
func (t countingSchemaTool) Name() string { return t.name }
func (t countingSchemaTool) Description() string { return "counts schema reads" }
func (t countingSchemaTool) Parameters() tools.Schema {
*t.calls++
return tools.Schema{Type: "object", AdditionalProperties: false, Properties: map[string]tools.PropertySchema{
"x": {Type: "string"},
}}
}
func (t countingSchemaTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}
func (t countingSchemaTool) Run(_ context.Context, _ map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK}
}
// The cache renders a tool's schema once and reuses it across calls, and its
// output is identical to the uncached path.
func TestPartitionToolsCacheRendersOnceAndMatchesUncached(t *testing.T) {
calls := 0
registry := tools.NewRegistry()
registry.Register(countingSchemaTool{name: "alpha", calls: &calls})
registry.Register(countingSchemaTool{name: "beta", calls: &calls})
uncached, _ := partitionTools(registry, PermissionModeAuto, Options{}, map[string]bool{})
base := calls
cache := map[string]zeroruntime.ToolDefinition{}
first, _ := partitionToolsCached(registry, PermissionModeAuto, Options{}, map[string]bool{}, cache)
rendersAfterFirst := calls - base
second, _ := partitionToolsCached(registry, PermissionModeAuto, Options{}, map[string]bool{}, cache)
rendersAfterSecond := calls - base
if rendersAfterFirst != 2 {
t.Fatalf("first cached call should render 2 tools once each, rendered %d times", rendersAfterFirst)
}
if rendersAfterSecond != 2 {
t.Fatalf("second cached call must reuse the cache (no new schema reads), total renders = %d", rendersAfterSecond)
}
if !reflect.DeepEqual(first, second) {
t.Fatal("cached calls must return identical definitions")
}
if !reflect.DeepEqual(first, uncached) {
t.Fatal("cached output must match the uncached partitionTools output")
}
}
-64
View File
@@ -1,64 +0,0 @@
package agent
import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func planUpdateTurn(status string) zeroruntime.CollectedStream {
return zeroruntime.CollectedStream{ToolCalls: []zeroruntime.ToolCall{
{Name: "update_plan", Arguments: `{"plan":[{"content":"step one","status":"` + status + `"}]}`},
}}
}
// Turn-based staleness: a plan that goes many turns without an update — while
// items are still pending — draws the stale reminder even though few tool calls
// have run (the tool-call streak alone would not have tripped).
func TestPlanReminderFiresOnTurnStaleness(t *testing.T) {
state := newGuardState()
state.observeTurn(planUpdateTurn("in_progress"))
// Text-only turns advance the turn counter without adding tool calls.
for range stalePlanTurnThreshold {
state.observeTurn(zeroruntime.CollectedStream{Text: "still working…"})
}
if state.toolCallsSincePlanUpdate >= staleToolCallThreshold {
t.Fatalf("precondition: the tool-call trigger must not fire (%d calls)", state.toolCallsSincePlanUpdate)
}
if got := state.planReminder(stalePlanTurnThreshold + 2); !strings.Contains(got, planStaleReminderMarker) {
t.Fatalf("expected a turn-based stale reminder, got %q", got)
}
}
// A fully-completed plan is NOT stale: no pending items means the turn-based
// trigger stays quiet even after many turns, so a finished plan isn't nagged.
func TestPlanReminderSkipsTurnStalenessWhenComplete(t *testing.T) {
state := newGuardState()
state.observeTurn(planUpdateTurn("completed"))
for range stalePlanTurnThreshold + 3 {
state.observeTurn(zeroruntime.CollectedStream{Text: "wrapping up…"})
}
if got := state.planReminder(stalePlanTurnThreshold + 5); got != "" {
t.Fatalf("a completed plan must not draw a stale reminder, got %q", got)
}
}
// A plan update resets the turn counter, so the stale reminder does not fire
// right after a fresh update.
func TestPlanUpdateResetsTurnStaleness(t *testing.T) {
state := newGuardState()
state.observeTurn(planUpdateTurn("in_progress"))
for range stalePlanTurnThreshold - 1 {
state.observeTurn(zeroruntime.CollectedStream{Text: "working…"})
}
// Refresh the plan — resets the turn streak.
state.observeTurn(planUpdateTurn("in_progress"))
if got := state.planReminder(stalePlanTurnThreshold + 1); got != "" {
t.Fatalf("a just-refreshed plan must not be stale, got %q", got)
}
}
-51
View File
@@ -1,51 +0,0 @@
package agent
import (
"testing"
"github.com/Gitlawb/zero/internal/tools"
)
// These ceilings are a deliberate ratchet on Zero's fixed per-turn overhead — the
// system prompt and the eager tool schemas that ride on EVERY request. They are
// set ~10% above the current measured cost, using ApproxTextTokens (the same
// estimate the compaction loop and /context use, ~non-whitespace-bytes/4). A change
// that pushes either past its ceiling must be justified (and the ceiling raised
// deliberately) or trimmed — the per-turn floor should not creep up silently.
//
// Measured baselines (2026-07): base system prompt ~3160 tokens; the tools a normal
// (auto permission-mode) interactive turn sends ~2430 tokens. The tool figure is
// the auto-mode advertised set, not the full core registry — higher-risk tools that
// only advertise in other modes are excluded, matching what a real turn actually
// pays.
const (
maxBaseSystemPromptTokens = 3500
maxEagerToolSchemaTokens = 2700
)
func TestSystemPromptTokenBudget(t *testing.T) {
// Minimal render: a model is set (so the session block renders) but no Cwd, so
// the workspace map, project guidelines, and repo map are excluded — this is the
// fixed base every session pays regardless of the workspace it runs in.
prompt := buildSystemPrompt(Options{Model: "claude-opus-4-8"})
got := ApproxTextTokens(prompt)
t.Logf("base system prompt: %d tokens (%d bytes)", got, len(prompt))
if got > maxBaseSystemPromptTokens {
t.Fatalf("base system prompt is %d tokens, over the %d ceiling — trim it or raise the ceiling deliberately", got, maxBaseSystemPromptTokens)
}
}
func TestEagerToolSchemaTokenBudget(t *testing.T) {
registry := tools.NewRegistry()
for _, tool := range tools.CoreToolsScoped(t.TempDir(), nil) {
registry.Register(tool)
}
// Options{} keeps DeferThreshold at 0, so deferral is inactive and every core
// tool is exposed eagerly — exactly what a plugin-free session sends each turn.
exposed, _ := partitionTools(registry, PermissionModeAuto, Options{}, map[string]bool{})
got := estimateToolDefTokens(exposed)
t.Logf("eager core tool schemas: %d tokens across %d tools", got, len(exposed))
if got > maxEagerToolSchemaTokens {
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — defer a tool or raise the ceiling deliberately", got, maxEagerToolSchemaTokens)
}
}
+5 -45
View File
@@ -3,11 +3,9 @@ package agent
import (
"context"
"fmt"
"math/rand"
"strings"
"time"
"github.com/Gitlawb/zero/internal/errhint"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -19,20 +17,10 @@ import (
// times. We retry ONLY the connect (not a partially-consumed stream), so no
// already-forwarded OnText is ever duplicated.
const (
// maxStreamReconnects is how many times the connect is re-issued after a
// transient disconnect. 4 (not 2): with jittered exponential backoff this rides
// out a multi-second network blip (~0.5s + 1s + 2s + 4s worst case) instead of
// dying on a 2s hiccup and re-burning every token on a restart.
maxStreamReconnects = 4
// streamReconnectMax caps a single backoff so the tail attempts don't wait
// minutes on a long outage.
streamReconnectMax = 8 * time.Second
maxStreamReconnects = 2
streamReconnectBase = 500 * time.Millisecond
)
// streamReconnectBase is the first-retry delay (doubled each subsequent attempt).
// A var, not a const, so tests can shrink it to keep the exhaustion path fast.
var streamReconnectBase = 500 * time.Millisecond
// reconnectNotifier is called before each retry with the 1-based attempt number
// and the max, so the caller can surface a "Reconnecting N/max" notice. Nil is
// fine.
@@ -85,7 +73,7 @@ func streamWithReconnect(ctx context.Context, provider Provider, request zerorun
if notify != nil {
notify(attempt, maxStreamReconnects)
}
if waitErr := sleepWithContext(ctx, jitteredBackoff(attempt)); waitErr != nil {
if waitErr := sleepWithContext(ctx, backoffFor(attempt)); waitErr != nil {
return nil, err // ctx cancelled while waiting; surface the original error
}
stream, err = provider.StreamCompletion(ctx, request)
@@ -108,19 +96,6 @@ func shouldReconnect(ctx context.Context, err error) bool {
if isContextLimitError(msg) || isImageRejectionError(err) {
return false
}
// HTTP 5xx statuses are non-reconnectable and must be excluded BEFORE the
// transport substring match below — otherwise "504 Gateway Timeout" would slip
// through on the generic "timeout" needle. 503 already exhausted
// providerio.SendWithRetry (retrying is a redundant double-retry); 500/502/504
// are non-idempotent by providerio's rule — the completion POST may already
// have reached the model before the upstream/gateway gave up, so replaying the
// connect risks duplicate billable work. Digit-boundary matched so an
// incidental "504" inside a latency/id number is not mistaken for a status.
if errhint.HasStatusCode(msg, "500", "502", "503", "504") {
return false
}
// Transport-level disconnects only. A genuine transport failure (EOF, reset,
// refused, timeout) means no response was received, which is safe to reconnect.
for _, needle := range []string{
"eof",
"connection reset",
@@ -131,6 +106,8 @@ func shouldReconnect(ctx context.Context, err error) bool {
"timed out",
"temporarily unavailable",
"i/o timeout",
"503",
"502",
"server closed",
"unexpected end",
} {
@@ -141,31 +118,14 @@ func shouldReconnect(ctx context.Context, err error) bool {
return false
}
// 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 {
d := streamReconnectBase
for i := 1; i < attempt; i++ {
if d >= streamReconnectMax {
return streamReconnectMax
}
d *= 2
}
if d > streamReconnectMax {
d = streamReconnectMax
}
return d
}
// jitteredBackoff adds up to 50% random jitter on top of backoffFor so concurrent
// runs (swarm members, a cron fleet) that all trip on the same outage don't
// reconnect in lockstep and hammer a recovering endpoint. Never shorter than the
// deterministic base, so backoff still grows attempt over attempt.
func jitteredBackoff(attempt int) time.Duration {
base := backoffFor(attempt)
return base + time.Duration(rand.Int63n(int64(base/2)+1))
}
func sleepWithContext(ctx context.Context, d time.Duration) error {
timer := time.NewTimer(d)
defer timer.Stop()
+1 -30
View File
@@ -44,10 +44,6 @@ func TestStreamWithReconnectRecoversFromTransientDisconnect(t *testing.T) {
}
func TestStreamWithReconnectGivesUpAfterMax(t *testing.T) {
// Shrink the backoff so exhausting all retries stays fast (real base would
// sleep ~7.5s across 4 attempts).
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
streamReconnectBase = time.Millisecond
// Always fails with a disconnect error → exhausts retries and returns it.
p := &flakyProvider{failBefore: 99, failErr: errors.New("connection reset by peer")}
_, err := streamWithReconnect(context.Background(), p, zeroruntime.CompletionRequest{}, nil)
@@ -91,7 +87,7 @@ func TestShouldReconnectClassification(t *testing.T) {
ctx := context.Background()
disconnects := []string{
"unexpected EOF", "connection reset by peer", "broken pipe",
"i/o timeout", "server closed the connection", "connection refused",
"i/o timeout", "503 Service Unavailable", "server closed the connection",
}
for _, m := range disconnects {
if !shouldReconnect(ctx, errors.New(m)) {
@@ -101,13 +97,6 @@ func TestShouldReconnectClassification(t *testing.T) {
notDisconnects := []string{
"context length exceeded", "invalid api key", "model not found",
"400 bad request: unsupported parameter",
// HTTP 5xx statuses are handled by providerio.SendWithRetry (503) or are
// non-idempotent (500/502/504); the reconnect path must not double-retry
// them. "504 Gateway Timeout" must NOT slip through on the generic "timeout"
// transport signal — a gateway timeout is non-idempotent, since the upstream
// may have already processed the completion POST before giving up.
"503 Service Unavailable", "502 Bad Gateway",
"504 Gateway Timeout", "500 Internal Server Error",
}
for _, m := range notDisconnects {
if shouldReconnect(ctx, errors.New(m)) {
@@ -123,24 +112,6 @@ func TestBackoffGrows(t *testing.T) {
if backoffFor(2) != 2*streamReconnectBase {
t.Fatalf("attempt 2 backoff = %v, want %v", backoffFor(2), 2*streamReconnectBase)
}
// The exponential base is capped so late attempts don't wait minutes.
if got := backoffFor(20); got != streamReconnectMax {
t.Fatalf("attempt 20 backoff = %v, want cap %v", got, streamReconnectMax)
}
}
func TestJitteredBackoffStaysInBounds(t *testing.T) {
// Jitter never drops below the deterministic base and never exceeds base*1.5,
// so backoff still grows attempt over attempt while decorrelating retries.
for attempt := 1; attempt <= 5; attempt++ {
base := backoffFor(attempt)
for i := 0; i < 200; i++ {
got := jitteredBackoff(attempt)
if got < base || got > base+base/2 {
t.Fatalf("attempt %d jittered backoff %v out of [%v, %v]", attempt, got, base, base+base/2)
}
}
}
}
func TestReconnectNoticeRoutesThroughReasoning(t *testing.T) {
-88
View File
@@ -1,88 +0,0 @@
package agent
import (
"strconv"
"strings"
"testing"
)
func TestSkillsContextCapsLongList(t *testing.T) {
// 60 skills with realistic trigger-rich descriptions (~180 chars each, under
// the 200-rune truncation) total well past the 4096-byte list budget, so the
// overflow summary must kick in while the block stays bounded.
longDesc := strings.Repeat("use when the request touches deployments, release notes, or verification; ", 2) + "number "
skills := make([]SkillInfo, 0, 60)
for i := 0; i < 60; i++ {
n := strconv.Itoa(i)
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: longDesc + n})
}
got := skillsContext(Options{Skills: skills})
if len(got) > skillsContextListBudget+1000 {
t.Fatalf("skills block should stay bounded near the %d-byte budget, got %d bytes:\n%s", skillsContextListBudget, len(got), got)
}
if !strings.Contains(got, "more (call skill") {
t.Fatalf("expected an overflow summary line, got:\n%s", got)
}
// The first skill is always listed regardless of budget.
if !strings.Contains(got, "- skill-0:") {
t.Fatalf("expected the first skill to always be listed, got:\n%s", got)
}
}
// A realistic mid-size skill set (20 skills, trigger-rich descriptions) must be
// listed in FULL — no overflow summary. This pins the fix for the old 640-byte
// budget, under which skills past ~#6 were invisible to the model and therefore
// never triggered.
func TestSkillsContextListsRealisticSetInFull(t *testing.T) {
desc := "Use when the user asks about deployments, release notes, or pre-merge verification runs."
skills := make([]SkillInfo, 0, 20)
for i := 0; i < 20; i++ {
skills = append(skills, SkillInfo{Name: "skill-" + strconv.Itoa(i), Description: desc})
}
got := skillsContext(Options{Skills: skills})
if strings.Contains(got, "more (call skill") {
t.Fatalf("20 described skills must all be listed without overflow, got:\n%s", got)
}
for i := 0; i < 20; i++ {
if !strings.Contains(got, "- skill-"+strconv.Itoa(i)+":") {
t.Fatalf("skill-%d missing from the list:\n%s", i, got)
}
}
}
func TestSkillsContext(t *testing.T) {
if got := skillsContext(Options{}); got != "" {
t.Fatalf("no skills should yield an empty section, got %q", got)
}
got := skillsContext(Options{Skills: []SkillInfo{
{Name: "commit-writer", Description: "Write a conventional-commit message."},
{Name: " ", Description: "nameless, should be skipped"},
{Name: "reviewer"},
}})
if !strings.Contains(got, "<available_skills>") || !strings.Contains(got, "</available_skills>") {
t.Fatalf("missing available_skills block: %q", got)
}
if !strings.Contains(got, "- commit-writer: Write a conventional-commit message.") {
t.Fatalf("missing commit-writer line: %q", got)
}
if !strings.Contains(got, "- reviewer\n") {
t.Fatalf("reviewer (no description) line missing: %q", got)
}
if strings.Contains(got, "nameless") {
t.Fatalf("nameless entry should be skipped: %q", got)
}
}
func TestSystemPromptIncludesSkillsOnlyWhenInstalled(t *testing.T) {
with := buildSystemPrompt(Options{Skills: []SkillInfo{
{Name: "commit-writer", Description: "Write a commit message."},
}})
if !strings.Contains(with, "<available_skills>") || !strings.Contains(with, "skill tool") {
t.Fatalf("expected available_skills guidance in system prompt: %q", with)
}
// Default (no skills) must reproduce the prior prompt: no skills block.
without := buildSystemPrompt(Options{})
if strings.Contains(without, "<available_skills>") {
t.Fatalf("available_skills block must not appear without skills")
}
}
+23 -185
View File
@@ -5,11 +5,9 @@ import (
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/repomap"
"github.com/Gitlawb/zero/internal/workspaceseed"
)
@@ -39,14 +37,6 @@ const fallbackSystemPrompt = "You are Zero, a terminal coding agent. Help with t
// general-to-specific order.
var projectContextFiles = []string{"AGENTS.md", "ZERO.md", ".zero/AGENTS.md"}
// userContextFile is the per-user instruction file, resolved under
// config.UserConfigDir()/zero/ alongside the rest of Zero's per-user config
// (config.json, commands, specialists) so users can keep personal guidance out
// of individual repositories.
const userContextFile = "ZERO.md"
var userConfigDirForPrompt = config.UserConfigDir
// maxProjectContextBytes caps how much of a single project doc is injected so
// a large guidelines file can't blow the context budget.
const maxProjectContextBytes = 8 << 10 // 8 KiB per file
@@ -90,22 +80,12 @@ func buildSystemPrompt(options Options) string {
if seed := workspaceSeedContext(options.Cwd); seed != "" {
sections = append(sections, seed)
}
// User guidelines are injected before workspace/project guidelines so the
// project's AGENTS.md/ZERO.md is the later, more specific instruction
// block. See userGuidelines for the explicit precedence note carried in
// the section text itself.
if user := userGuidelines(); user != "" {
sections = append(sections, user)
}
if ws := workspaceContext(options.Cwd); ws != "" {
sections = append(sections, ws)
}
if delegation := specialistDelegationContext(options); delegation != "" {
sections = append(sections, delegation)
}
if skillsBlock := skillsContext(options); skillsBlock != "" {
sections = append(sections, skillsBlock)
}
if style := responseStyleContext(options); style != "" {
sections = append(sections, style)
}
@@ -184,75 +164,6 @@ func specialistDelegationContext(options Options) string {
return b.String()
}
// skillsContext lists the reusable skills the model can pull in on demand via the
// skill tool, so it invokes the right one on the first try instead of guessing a
// name, failing, and reading the error. It mirrors specialistDelegationContext:
// names + one-line descriptions only (never skill bodies), and it renders nothing
// when no skills are installed, so a skill-less run reproduces the previous prompt
// byte-for-byte.
// skillsContextListBudget bounds the bytes spent listing individual skills so a
// workspace with an extreme number of them can't bloat every turn's prompt.
// Skills past the budget are summarized as a count rather than dropped — the
// model can still load any of them by name (an unknown name returns the full
// list from the skill tool).
//
// The budget is deliberately generous — roughly 18 skills with maximally long
// (200-rune, ASCII) descriptions, or 40+ with typical terser ones; non-ASCII
// descriptions consume it faster since the budget counts bytes. This list is
// the model's ONLY discovery surface, so a skill squeezed out of it effectively
// never triggers. The previous 640-byte cap silently broke invocation for
// anyone with more than ~6 skills — the model cannot match a request against a
// skill it cannot see.
const skillsContextListBudget = 4096
func skillsContext(options Options) string {
if len(options.Skills) == 0 {
return ""
}
var b strings.Builder
b.WriteString("<available_skills>\n")
b.WriteString("Reusable, on-demand instruction sets you can load with the skill tool. Before acting on a request, scan this list; when the request matches a skill's name or description, call skill with its exact name FIRST and follow the loaded guidance — do not guess names, do not skip a matching skill, and do not substitute your own approach for its instructions.\n")
listed, spent, omitted := 0, 0, 0
for _, info := range options.Skills {
name := strings.TrimSpace(info.Name)
if name == "" {
continue
}
line := "- " + name
if desc := strings.TrimSpace(info.Description); desc != "" {
line += ": " + truncateForSkillLine(desc)
}
line += "\n"
// Always list at least one skill; past the budget, summarize the remainder
// as a count instead of silently dropping it.
if listed > 0 && spent+len(line) > skillsContextListBudget {
omitted++
continue
}
b.WriteString(line)
spent += len(line)
listed++
}
if omitted > 0 {
b.WriteString("- …and " + strconv.Itoa(omitted) + " more (call skill with a name; an unknown name lists them all)\n")
}
b.WriteString("</available_skills>")
return b.String()
}
// truncateForSkillLine keeps a skill's one-line description short so a single
// verbose description can't dominate the skills-list budget. The cap is roomy on
// purpose: the description carries the skill's trigger conditions ("use when…"),
// and truncating those is what stops the model from ever invoking the skill.
func truncateForSkillLine(desc string) string {
const maxDescRunes = 200
runes := []rune(desc)
if len(runes) <= maxDescRunes {
return desc
}
return strings.TrimSpace(string(runes[:maxDescRunes])) + "…"
}
func sessionRuntimeContext(options Options) string {
provider := strings.TrimSpace(options.ProviderName)
model := strings.TrimSpace(options.Model)
@@ -286,7 +197,7 @@ func workspaceContext(cwd string) string {
b.WriteString("Working directory: " + cwd + "\n")
b.WriteString("Operating system: " + runtime.GOOS + "\n")
if runtime.GOOS == "windows" {
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools. To put | & > < etc inside an arg value, use double quotes around the value, not single quotes (single quotes do not protect metachars in cmd.exe): gh --jq \".a | b\", go test -run \"A|B\". Do not pipe to or invoke POSIX coreutils from Git for Windows (usr\\bin head/grep/tail/cat/...): they are MSYS binaries and fail under the write-restricted sandbox; use native Zero tools (grep, read_file, list_directory, glob) or cmd.exe findstr/more instead, or sandbox_permissions require_escalated only when host-level execution is truly required. Prefer the workdir/cwd argument over cd when changing directories.\n")
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
} else {
b.WriteString("Shell syntax: /bin/sh syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
}
@@ -339,7 +250,13 @@ func projectGuidelines(cwd, gitRoot string) string {
if remaining := maxProjectContextTotalBytes - totalUsed; remaining < limit {
limit = remaining
}
content = truncateGuidelineContent(content, limit)
if len(content) > limit {
cut := limit
for cut > 0 && !utf8.RuneStart(content[cut]) {
cut--
}
content = content[:cut] + "\n… (truncated)"
}
label := projectGuidelineLabel(match, gitRoot)
b.WriteString("\n\n## Project guidelines (" + label + ")\n\n" + content)
totalUsed += len(content)
@@ -347,92 +264,6 @@ func projectGuidelines(cwd, gitRoot string) string {
return b.String()
}
// userGuidelines returns the per-user ZERO.md instructions block, if present.
// The file lives in config.UserConfigDir()/zero/ next to Zero's other
// per-user config; the basename match is case-insensitive so a file saved as
// zero.md still resolves on case-sensitive filesystems, mirroring the project
// guideline loader. The section carries an explicit precedence note because
// this is a global, personal preferences file: it is injected earlier in the
// prompt than the project's AGENTS.md/ZERO.md (see buildSystemPrompt), and
// the note keeps that precedence unambiguous even if a model weighs later
// context more heavily than section order alone implies.
func userGuidelines() string {
configDir, err := userConfigDirForPrompt()
if err != nil {
return ""
}
configDir = strings.TrimSpace(configDir)
if configDir == "" {
return ""
}
match := findCaseInsensitiveFile(filepath.Join(configDir, "zero"), userContextFile)
if match == "" {
return ""
}
data, err := os.ReadFile(match)
if err != nil {
return ""
}
content := strings.TrimSpace(string(data))
if content == "" {
return ""
}
content = truncateGuidelineContent(content, maxProjectContextBytes)
return "## User guidelines (" + filepath.Base(match) + ")\n\n" +
"These are the operator's personal preferences, not project policy. " +
"Where they conflict with a repository's project guidelines below (AGENTS.md/ZERO.md), the project guidelines take precedence.\n\n" +
content
}
// truncationMarker is appended to guideline content that was cut short.
const truncationMarker = "\n… (truncated)"
// truncateGuidelineContent caps content at limit bytes without splitting a
// UTF-8 rune, appending a truncation marker when anything was cut. Space for
// the marker is reserved before choosing the cut point. When limit is too
// small to fit the marker itself, the marker is dropped instead, so the
// returned string never exceeds limit for any non-negative limit.
func truncateGuidelineContent(content string, limit int) string {
if len(content) <= limit {
return content
}
if limit <= 0 {
return ""
}
cut := limit - len(truncationMarker)
if cut < 0 {
cut = 0
}
for cut > 0 && !utf8.RuneStart(content[cut]) {
cut--
}
if truncated := content[:cut] + truncationMarker; len(truncated) <= limit {
return truncated
}
// limit is smaller than the marker itself: fall back to a hard,
// rune-safe cut at limit bytes with no marker rather than exceeding it.
end := limit
for end > 0 && !utf8.RuneStart(content[end]) {
end--
}
return content[:end]
}
// findCaseInsensitiveFile returns the on-disk path of the regular file in dir
// whose name matches basename case-insensitively, or "" when absent.
func findCaseInsensitiveFile(dir, basename string) string {
entries, err := os.ReadDir(dir)
if err != nil {
return ""
}
for _, e := range entries {
if !e.IsDir() && strings.EqualFold(e.Name(), basename) {
return filepath.Join(dir, e.Name())
}
}
return ""
}
// projectGuidelineDirs returns the directory chain from gitRoot to cwd
// (inclusive), in root-to-leaf order. If gitRoot is empty or unreachable
// from cwd, the chain collapses to [cwd].
@@ -479,22 +310,29 @@ func projectGuidelineLabel(match, gitRoot string) string {
// findProjectContextFile returns the first matching project guideline file
// in dir. Lookup is case-insensitive on the basename and on the actual
// filename returned, so a git-tracked file like AGENTS.md still resolves to
// its true filename on case-sensitive filesystems — the returned path is what
// should appear in the project guidelines label.
// filename returned, so a git-tracked file like AGENTS.MD (uppercase MD)
// still resolves to its true filename on case-sensitive filesystems — the
// returned path is what should appear in the project guidelines label.
// Returns "" when nothing matches.
func findProjectContextFile(dir string) string {
for _, name := range projectContextFiles {
baseLower := strings.ToLower(filepath.Base(name))
// Walk to the file's parent through dir with case-insensitive segment
// matching, then find a regular file with the same basename. This works
// on both case-sensitive and case-insensitive filesystems and always
// returns the on-disk filename.
// matching, then find a regular file with the same lowercased
// basename. This works on both case-sensitive and case-insensitive
// filesystems and always returns the on-disk filename.
parent, ok := resolveDirCaseInsensitive(filepath.Dir(filepath.Join(dir, name)), dir)
if !ok {
continue
}
if match := findCaseInsensitiveFile(parent, filepath.Base(name)); match != "" {
return match
entries, err := os.ReadDir(parent)
if err != nil {
continue
}
for _, e := range entries {
if !e.IsDir() && strings.ToLower(e.Name()) == baseLower {
return filepath.Join(parent, e.Name())
}
}
}
return ""
+9 -11
View File
@@ -46,10 +46,14 @@ work.
formatting-only edits unless the user asked for them.
4. **Verify.** Verify after edits; see the testing gate below. This is
mandatory.
5. **Summarize.** Close with a summary scaled to the work: a trivial fix earns one
line; a substantial change earns a few short bullets covering what changed, where
it lives, and what you did and did not verify. Lead with the outcome, and never
pad — do not restate the task or narrate steps the user already watched you take.
5. **Summarize.** For a substantial task, close with an ELABORATE, well-structured
summary — never a single throwaway line. Use short headings and bullets: a
one-line "what I built/changed"; **Where it lives** — the key files and how they
fit together; **How each requirement is met** — walk through each requirement
from the request and name the specifics that satisfy it; what validation ran and
what was NOT verified; and a brief "Next steps" or "Want me to…?" offer. Scale
the depth to the work — a big build earns several sections, a trivial fix earns
a line or two.
## Editing discipline
@@ -60,13 +64,7 @@ work.
- Make one tool call per file. Do not batch multi-file writes into a single
shell or script invocation.
- For edits to existing files, prefer edit_file or apply_patch with minimal,
targeted diffs. Match the existing indentation, imports, and idioms. Match the
file's comment density: do not add explanatory comments unless the user asks or
the code is already comment-dense.
- Solve the problem as posed, not a more general version of it. Add no
speculative abstraction, configurability, or handling for cases that cannot
occur, and nothing the user did not ask for. A small diff can still be
over-built; if a 200-line solution could be 50, rewrite it.
targeted diffs. Match the existing indentation, imports, and idioms.
- Preserve behavior you were not asked to change. Do not delete or rewrite code
you did not author unless the task requires it; if you must, say so.
+12 -10
View File
@@ -6,13 +6,11 @@ import "strings"
//
// Different model families respond to different framing: the GPT family benefits
// from an explicit markdown/output spec and a strong "prefer native tools" nudge;
// Gemini benefits from explicit tool-preference and conciseness guidance. The
// Claude family is already aligned with the core prompt and needs no addendum —
// comment discipline is universal in the core prompt now, so every family gets it.
// modelPromptAddendum returns the family-specific block appended after the core
// prompt, or "" when the family is unknown or needs no specialization.
// Classification is by model id (the agent only has the model string — no registry
// dependency).
// Gemini benefits from explicit tool-preference and conciseness guidance; the
// Claude family is already well-aligned with the core prompt and only needs a
// light comment-discipline nudge. modelPromptAddendum returns the family-specific
// block appended after the core prompt. Classification is by model id (the agent
// only has the model string — no registry dependency).
const (
familyOpenAI = "openai"
@@ -47,10 +45,9 @@ func modelPromptAddendum(model string) string {
return openAIPromptAddendum
case familyGemini:
return geminiPromptAddendum
case familyAnthropic:
return anthropicPromptAddendum
default:
// Anthropic (aligned with the core prompt) and unknown families get no
// family-specific block; comment discipline now lives in the core prompt
// for every model.
return ""
}
}
@@ -73,3 +70,8 @@ const geminiPromptAddendum = `<model_guidance>
in one short clause why it is needed.
- Use update_plan for any multi-step task and keep it current.
</model_guidance>`
const anthropicPromptAddendum = `<model_guidance>
- Do not add code comments unless the user asks or the surrounding code is
comment-dense; match the file's existing comment density.
</model_guidance>`
+2 -4
View File
@@ -29,11 +29,9 @@ func TestBuildSystemPromptAppendsModelAddendum(t *testing.T) {
if got := buildSystemPrompt(Options{Model: "gpt-5"}); !strings.Contains(got, openAIPromptAddendum) {
t.Fatalf("expected the OpenAI addendum in the gpt-5 prompt")
}
// Claude is aligned with the core prompt and gets no family addendum now that
// comment discipline is universal; it must not pick up another family's block.
claude := buildSystemPrompt(Options{Model: "claude-opus-4-6"})
if strings.Contains(claude, "<model_guidance>") {
t.Fatalf("expected no model_guidance block for Claude (aligned with core prompt)")
if !strings.Contains(claude, anthropicPromptAddendum) {
t.Fatalf("expected the Anthropic addendum in the claude prompt")
}
if strings.Contains(claude, openAIPromptAddendum) {
t.Fatalf("the claude prompt must not contain the OpenAI addendum")
+2 -128
View File
@@ -9,25 +9,6 @@ import (
"github.com/Gitlawb/zero/internal/sandbox"
)
// TestMain points userConfigDirForPrompt at an empty, package-wide temp
// directory for every test in this package. Without this, buildSystemPrompt
// falls back to the real config.UserConfigDir, so any developer with a
// personal ~/.config/zero/ZERO.md would get its content folded into
// otherwise-unrelated prompt assertions, making test runs non-deterministic
// across contributor machines. Tests that specifically exercise user
// guidelines stub userConfigDirForPrompt themselves via
// withSystemPromptTestUserConfigDir(Func) and restore this default on cleanup.
func TestMain(m *testing.M) {
dir, err := os.MkdirTemp("", "zero-agent-tests-*")
if err != nil {
panic(err)
}
userConfigDirForPrompt = func() (string, error) { return dir, nil }
code := m.Run()
os.RemoveAll(dir)
os.Exit(code)
}
func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
prompt := strings.ToLower(buildSystemPrompt(Options{}))
@@ -42,8 +23,7 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
"avoid broad refactors",
"search the web before answering",
"do not recognize",
"scaled to the work",
"comment density",
"how each requirement is met",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected core system prompt to include %q, got:\n%s", want, buildSystemPrompt(Options{}))
@@ -147,90 +127,8 @@ func systemPromptTestBlock(t *testing.T, prompt, start, end string) string {
return body
}
func TestBuildSystemPromptIncludesUserGuidelines(t *testing.T) {
configDir := t.TempDir()
writeSystemPromptTestFile(t, configDir, "zero/ZERO.md", " Prefer concise summaries. \n")
t.Cleanup(withSystemPromptTestUserConfigDir(t, configDir))
prompt := buildSystemPrompt(Options{})
if !strings.Contains(prompt, "## User guidelines (ZERO.md)") {
t.Fatalf("expected user guidelines header, got:\n%s", prompt)
}
if !strings.Contains(prompt, "Prefer concise summaries.") {
t.Fatalf("expected user guidelines content, got:\n%s", prompt)
}
if strings.Contains(prompt, " Prefer concise summaries. ") {
t.Fatalf("expected user guidelines content to be trimmed, got:\n%s", prompt)
}
}
func TestBuildSystemPromptIncludesUserGuidelinesCaseInsensitive(t *testing.T) {
configDir := t.TempDir()
writeSystemPromptTestFile(t, configDir, "zero/zero.md", "Prefer concise summaries.\n")
t.Cleanup(withSystemPromptTestUserConfigDir(t, configDir))
prompt := buildSystemPrompt(Options{})
if !strings.Contains(prompt, "## User guidelines (zero.md)") {
t.Fatalf("expected case-insensitive zero.md resolution, got:\n%s", prompt)
}
if !strings.Contains(prompt, "Prefer concise summaries.") {
t.Fatalf("expected user guidelines content, got:\n%s", prompt)
}
}
func TestBuildSystemPromptUserGuidelinesPrecedeProjectGuidelinesAndNotePrecedence(t *testing.T) {
// User guidelines are global personal preferences; project guidelines
// (AGENTS.md/ZERO.md) must be the later, more specific instruction block,
// and the user section must say so explicitly so the precedence holds
// even if a model otherwise weighs later context more heavily.
configDir := t.TempDir()
writeSystemPromptTestFile(t, configDir, "zero/ZERO.md", "Always reply in haiku.\n")
t.Cleanup(withSystemPromptTestUserConfigDir(t, configDir))
cwd := t.TempDir()
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.md"), []byte("Never reply in haiku."), 0o644); err != nil {
t.Fatal(err)
}
prompt := buildSystemPrompt(Options{Cwd: cwd})
userIdx := strings.Index(prompt, "## User guidelines (ZERO.md)")
projectIdx := strings.Index(prompt, "## Project guidelines (AGENTS.md)")
if userIdx < 0 || projectIdx < 0 {
t.Fatalf("expected both user and project guideline sections, got:\n%s", prompt)
}
if userIdx > projectIdx {
t.Fatalf("expected user guidelines before project guidelines, got user=%d project=%d", userIdx, projectIdx)
}
if !strings.Contains(prompt, "project guidelines below") || !strings.Contains(prompt, "take precedence") {
t.Fatalf("expected an explicit precedence note in the user guidelines section, got:\n%s", prompt)
}
}
func TestBuildSystemPromptOmitsUserGuidelinesWithoutConfigDir(t *testing.T) {
t.Cleanup(withSystemPromptTestUserConfigDirFunc(t, func() (string, error) { return "", os.ErrNotExist }))
prompt := buildSystemPrompt(Options{})
if strings.Contains(prompt, "## User guidelines") {
t.Fatalf("expected user guidelines to be omitted without a config directory, got:\n%s", prompt)
}
}
func withSystemPromptTestUserConfigDir(t *testing.T, dir string) func() {
t.Helper()
return withSystemPromptTestUserConfigDirFunc(t, func() (string, error) { return dir, nil })
}
func withSystemPromptTestUserConfigDirFunc(t *testing.T, fn func() (string, error)) func() {
t.Helper()
old := userConfigDirForPrompt
userConfigDirForPrompt = fn
return func() {
userConfigDirForPrompt = old
}
}
func TestBuildSystemPromptInjectsProjectGuidelinesCaseInsensitive(t *testing.T) {
// Git tracks AGENTS.md on a case-sensitive filesystem; the
// Git tracks AGENTS.MD (uppercase MD) on a case-sensitive filesystem; the
// loader must still resolve it when the cwd lookup uses lowercase.
cwd := t.TempDir()
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.MD"), []byte("Always run `make lint`."), 0o644); err != nil {
@@ -358,30 +256,6 @@ func TestBuildSystemPromptProjectGuidelinesTruncatesAtTotalCap(t *testing.T) {
}
}
func TestTruncateGuidelineContentStaysWithinLimit(t *testing.T) {
limit := 64
content := strings.Repeat("x", limit+1)
got := truncateGuidelineContent(content, limit)
if len(got) > limit {
t.Fatalf("truncated result is %d bytes, want at most %d", len(got), limit)
}
if !strings.Contains(got, "truncated") {
t.Fatalf("expected truncation marker, got %q", got)
}
}
func TestTruncateGuidelineContentStaysWithinLimitForSmallBudgets(t *testing.T) {
// Limits at or below the truncation marker's own length can't fit the
// marker at all; the helper must still never exceed the requested limit.
content := strings.Repeat("x", 100)
for _, limit := range []int{0, 1, len(truncationMarker) - 1, len(truncationMarker), len(truncationMarker) + 1} {
got := truncateGuidelineContent(content, limit)
if len(got) > limit {
t.Fatalf("truncateGuidelineContent(_, %d) = %q (%d bytes), want at most %d bytes", limit, got, len(got), limit)
}
}
}
func TestProjectGuidelineDirsOrdersRootToLeaf(t *testing.T) {
root := filepath.Join("r")
leaf := filepath.Join(root, "a", "b")
-24
View File
@@ -174,16 +174,6 @@ type SpecialistInfo struct {
WhenToUse string
}
// SkillInfo is a one-line summary of a reusable, on-demand skill (its name and
// frontmatter description) surfaced to the system prompt so the model can invoke
// the right skill with the skill tool on the first try instead of guessing a name
// and reading the failure. Like SpecialistInfo it is plain data, so the agent
// package needs no dependency on internal/skills.
type SkillInfo struct {
Name string
Description string
}
type Options struct {
MaxTurns int
// DeferThreshold activates deferred MCP-tool loading: when the number of
@@ -199,12 +189,6 @@ type Options struct {
// populated only where the Task tool is actually registered, so an empty slice
// (the default) reproduces the previous prompt byte-for-byte.
Specialists []SpecialistInfo
// Skills lists the reusable skills installed for this run (the default skills
// dir merged with any plugin skill roots). When non-empty the system prompt
// gains an <available_skills> block naming them so the model loads the right one
// via the skill tool on the first try. Empty (the default) reproduces the
// previous prompt byte-for-byte.
Skills []SkillInfo
// Specialist/sub-agent metadata is carried through exec now and consumed by
// the specialist runtime in later slices.
SessionID string
@@ -285,14 +269,6 @@ type Options struct {
// ceiling and the autonomy gate. nil disables it entirely (the loop is
// byte-identical to before). One instance per run — it holds attempt state.
SelfCorrect *SelfCorrector
// FileDiagnostics, when set, checks files changed by mutating tools for
// error-severity language diagnostics IN THE BACKGROUND and appends any
// errors as a nudge before the model's next request — the model still sees
// an error it introduced at its next decision point, but no tool call ever
// blocks on the language server (the old inline path stalled every edit on
// a ≥300ms debounce, 10s cap). Build one with NewFileDiagnostics. nil
// disables post-edit diagnostics.
FileDiagnostics func(ctx context.Context, absPath string) string
// RequireCompletionSignal gates run completion for HEADLESS exec. Without it,
// any assistant turn that produces text but no tool call is accepted as the
+20 -159
View File
@@ -12,7 +12,6 @@ import (
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/Gitlawb/zero/internal/agent"
@@ -20,12 +19,10 @@ import (
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/localcontrol"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/observability"
"github.com/Gitlawb/zero/internal/plugins"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/provideroauth"
"github.com/Gitlawb/zero/internal/provideronboarding"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/redaction"
@@ -51,7 +48,7 @@ type appDeps struct {
stdin io.Reader
userConfigPath func() (string, error)
resolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
resolveMCPConfig func(workspaceRoot string, excludeProject bool) (config.MCPConfig, error)
resolveMCPConfig func(workspaceRoot string) (config.MCPConfig, error)
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
// exportActiveProvider pins spawned children to the run's provider (production:
// config.SetActiveProviderEnv, set in defaultAppDeps — deliberately NOT filled
@@ -61,7 +58,6 @@ type appDeps struct {
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
openRouterLogin func(context.Context, provideroauth.OpenRouterOptions) (string, error)
newSessionStore func() *sessions.Store
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
@@ -81,12 +77,9 @@ type appDeps struct {
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
pushChanges func(context.Context, zerogit.PushOptions) (zerogit.PushResult, error)
createPR func(context.Context, zerogit.PROptions) (zerogit.PRResult, error)
runTUI func(context.Context, tui.Options) int
runEditor func(string) error
checkUpdate func(context.Context, update.Options) (update.Result, error)
applyUpdate func(context.Context, update.Options) (update.ApplyResult, error)
now func() time.Time
}
@@ -125,12 +118,11 @@ func defaultAppDeps() appDeps {
options.Overrides = overrides
return config.Resolve(options)
},
resolveMCPConfig: func(workspaceRoot string, excludeProject bool) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
options, err := config.DefaultResolveOptions(workspaceRoot)
if err != nil {
return config.MCPConfig{}, err
}
options.ExcludeProject = excludeProject
return config.ResolveMCP(options)
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
@@ -147,7 +139,6 @@ func defaultAppDeps() appDeps {
probeProviderHealth: providerhealth.Probe,
discoverProviderModels: defaultDiscoverProviderModels,
detectLocalRuntimes: provideronboarding.DetectLocalRuntimes,
openRouterLogin: provideroauth.OpenRouterLogin,
newSessionStore: func() *sessions.Store {
return sessions.NewStore(sessions.StoreOptions{})
},
@@ -186,12 +177,9 @@ func defaultAppDeps() appDeps {
runAgentEval: defaultRunAgentEval,
inspectChanges: zerogit.Inspect,
commitChanges: zerogit.Commit,
pushChanges: zerogit.Push,
createPR: zerogit.CreatePR,
runTUI: tui.Run,
runEditor: openEditor,
checkUpdate: update.Check,
applyUpdate: update.Apply,
now: time.Now,
}
}
@@ -239,12 +227,6 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
defer observability.Recover(observability.DefaultCrashDir(), "cli", stderr, &exitCode)
deps = fillAppDeps(deps)
// CLI runs opt into the models.dev overlay (cached live context limits and
// pricing on top of the curated catalog). Explicitly enabled here — and only
// here — so library consumers and hermetic tests are never perturbed by a
// cache file on the machine. The refresh itself is fired in exec/TUI startup.
modelregistry.EnableModelsDevOverlay()
addDirs, args, err := splitLeadingAddDirFlags(args)
if err != nil {
return writeAppError(stderr, err.Error(), 1)
@@ -401,14 +383,10 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
return runSandbox(args[1:], stdout, stderr, deps)
case "update":
return runUpdate(args[1:], stdout, stderr, deps)
case "upgrade":
return runUpgrade(args[1:], stdout, stderr, deps)
case "worktrees", "worktree":
return runWorktrees(args[1:], stdout, stderr, deps)
case "verify":
return runVerifyCommand(args[1:], stdout, stderr, deps)
case "trust":
return runTrust(args[1:], stdout, stderr, deps)
case "eval":
return runAgentEvalCommand(args[1:], stdout, stderr, deps)
case "changes", "change":
@@ -471,9 +449,6 @@ func fillAppDeps(deps appDeps) appDeps {
if deps.detectLocalRuntimes == nil {
deps.detectLocalRuntimes = defaults.detectLocalRuntimes
}
if deps.openRouterLogin == nil {
deps.openRouterLogin = defaults.openRouterLogin
}
if deps.newSessionStore == nil {
deps.newSessionStore = defaults.newSessionStore
}
@@ -531,12 +506,6 @@ func fillAppDeps(deps appDeps) appDeps {
if deps.commitChanges == nil {
deps.commitChanges = defaults.commitChanges
}
if deps.pushChanges == nil {
deps.pushChanges = defaults.pushChanges
}
if deps.createPR == nil {
deps.createPR = defaults.createPR
}
if deps.runTUI == nil {
deps.runTUI = defaults.runTUI
}
@@ -546,9 +515,6 @@ func fillAppDeps(deps appDeps) appDeps {
if deps.checkUpdate == nil {
deps.checkUpdate = defaults.checkUpdate
}
if deps.applyUpdate == nil {
deps.applyUpdate = defaults.applyUpdate
}
if deps.now == nil {
deps.now = defaults.now
}
@@ -563,11 +529,6 @@ func fillAppDeps(deps appDeps) appDeps {
deps.newProvider = func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
return baseNewProvider(applyStoredProviderKeyAt(profile, userConfigPath))
}
baseProbeProviderHealth := deps.probeProviderHealth
deps.probeProviderHealth = func(ctx context.Context, options providerhealth.Options) providerhealth.Result {
options.Profile = applyStoredProviderKeyAt(options.Profile, userConfigPath)
return baseProbeProviderHealth(ctx, options)
}
return deps
}
@@ -576,11 +537,6 @@ func runInteractiveTUI(stderr io.Writer, deps appDeps, permissionMode agent.Perm
}
func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string, theme string, forceSetup bool) int {
// 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()) }()
workspaceRoot, err := deps.getwd()
if err != nil {
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), 1)
@@ -601,19 +557,8 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
if !errors.Is(err, config.ErrNoActiveProvider) && !errors.Is(err, config.ErrProviderRequiresModel) {
return writeAppError(stderr, err.Error(), 1)
}
// ErrNoActiveProvider can mean "nothing configured yet" (needs onboarding)
// or "providers ARE configured, just none marked active" (e.g. config.json's
// activeProvider is blank/stale). In the second case resolved.Providers still
// carries the already-normalized list — prefer falling back to one of those
// over wiping everything and forcing the user to re-enter credentials they
// already saved.
if usable, ok := firstUsableProvider(resolved.Providers); errors.Is(err, config.ErrNoActiveProvider) && ok {
resolved.Provider = usable
resolved.ActiveProvider = usable.Name
} else {
resolved = config.ResolvedConfig{}
forceSetup = true
}
resolved = config.ResolvedConfig{}
forceSetup = true
}
userConfigPath, err := deps.userConfigPath()
if err != nil {
@@ -672,17 +617,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1)
}
defer closeSpecialistRuntime(stderr, specialistRuntime)
// The TUI has no --worktree reassignment, so trustRoot == workspaceRoot here.
// Gate the project MCP layer behind the workspace-trust check (fail-closed): an
// untrusted workspace must not spawn its ./.zero/config.json stdio MCP servers.
// Keep the store-read error so the notice below can distinguish a fail-closed
// store error from a clean untrusted verdict.
mcpExcludeProject, mcpTrustErrored := resolveTrust(workspaceRoot)
mcpSkip := trustSkip{
excludedProjectConfig: mcpExcludeProject && projectMCPConfigExists(workspaceRoot),
trustCheckErrored: mcpTrustErrored,
}
mcpConfig, err := deps.resolveMCPConfig(workspaceRoot, mcpExcludeProject)
mcpConfig, err := deps.resolveMCPConfig(workspaceRoot)
if err != nil {
return writeAppError(stderr, err.Error(), 1)
}
@@ -710,24 +645,15 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
defer closeMCPRuntime(stderr, mcpRuntime)
// A server that could not be reached or validated is skipped, not fatal (one
// bad MCP server must not abort startup) — surface each so a missing tool set is
// explained rather than silently absent. A built-in default the user never
// configured (e.g. keyless Firecrawl with no credentials) is the exception: it
// was never asked for, so its failure is not worth a startup warning — only
// servers the user actually configured warn on failure (issue #552).
// explained rather than silently absent.
for _, skipped := range mcpRuntime.Skipped() {
if skipped.UnconfiguredDefault {
continue
}
fmt.Fprintf(stderr, "warning: MCP server %s unavailable, skipped: %s\n", skipped.Name, redaction.ErrorMessage(skipped.Err, redaction.Options{}))
}
// Make local plugins live: register their declared tools into the registry and
// collect their hooks + skill roots for the dispatcher and skill tool below.
// Done after specialist + MCP registration so plugin tools are part of the
// deferral count, and it fails OPEN — a malformed plugin is warned and skipped.
// The interactive TUI is not worktree-reassigned, so the trust root is the
// launch directory itself.
trustRoot := workspaceRoot
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot)
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr)
// Ask (not Auto) is the interactive default: in Auto, ToolAdvertised exposes
// only PermissionAllow tools, so prompt-gated tools (write_file/edit_file/bash/
// apply_patch) would never be offered to the model — the TUI could neither edit
@@ -762,26 +688,8 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
Scope: scope,
})
lastKnownMCPConfig := mcpConfig
fileTracker := tools.NewFileTracker()
var scratchBaseline scratchFileBaseline
sttServerManager := newDictationServerManager(resolved.STT)
// Keep STT downloads in the SAME config tree the rest of the TUI uses. Deriving
// from userConfigPath (rather than config.UserConfigDir()) matters when the config
// root is overridden — e.g. in tests or a custom ZERO config dir — so the two
// don't diverge. userConfigPath points at .../zero/config.json, so its dir is the
// zero config dir.
sttDownloadRoot := ""
if userConfigPath != "" {
sttDownloadRoot = filepath.Join(filepath.Dir(userConfigPath), "stt")
}
// Build the hooks dispatcher out of the AgentOptions literal so its trust skip
// report can be combined with the plugin activation's, and emit at most one
// notice when project hooks/plugins were dropped for an untrusted workspace.
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot)
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
return deps.runTUI(context.Background(), tui.Options{
Cwd: workspaceRoot,
Version: version,
Theme: theme,
SavedTheme: resolved.Preferences.Theme,
UserConfigPath: userConfigPath,
@@ -792,24 +700,17 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
ProviderProfile: resolved.Provider,
SavedProviders: usableSavedProviders(resolved.Providers),
FavoriteModels: resolved.Preferences.FavoriteModels,
RecentModels: resolved.Preferences.RecentModels,
RecapsEnabled: resolved.Preferences.RecapsEnabled(),
Provider: provider,
NewProvider: deps.newProvider,
ProbeProviderHealth: deps.probeProviderHealth,
UserAgent: userAgent(),
PrepareRunCompletionWarning: func() {
scratchBaseline = scratchFileSnapshot(workspaceRoot)
},
RunCompletionWarning: func() string {
return scratchFileWarning(workspaceRoot, scratchBaseline)
},
Registry: registry,
SessionStore: deps.newSessionStore(),
SandboxStore: sandboxStore,
MCPConfig: mcpConfig,
MCPPermissionStore: mcpPermissionStore,
MCPTokenStore: mcpTokenStore,
Registry: registry,
SessionStore: deps.newSessionStore(),
SandboxStore: sandboxStore,
MCPConfig: mcpConfig,
MCPPermissionStore: mcpPermissionStore,
MCPTokenStore: mcpTokenStore,
MCPCommand: func(ctx context.Context, args []string) tui.MCPCommandResult {
if ctx == nil {
ctx = context.Background()
@@ -817,7 +718,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
var stdout, stderr bytes.Buffer
exitCode := runMCPWithContext(ctx, args, &stdout, &stderr, deps)
nextConfig := lastKnownMCPConfig
if refreshed, err := deps.resolveMCPConfig(workspaceRoot, mcpExcludeProject); err == nil {
if refreshed, err := deps.resolveMCPConfig(workspaceRoot); err == nil {
lastKnownMCPConfig = refreshed
nextConfig = refreshed
}
@@ -835,29 +736,13 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
PermissionMode: permissionMode,
Autonomy: "low",
Sandbox: sandboxEngine,
FileTracker: fileTracker,
Hooks: hookDispatcher,
FileTracker: tools.NewFileTracker(),
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
DeferThreshold: resolved.Tools.DeferThreshold,
Specialists: specialistRuntime.specialists,
Skills: pluginActivation.skillInfos(deps.skillsDir()),
},
// LoadSkills backs /skills and direct /<skill-name> invocation in the TUI.
// It resolves against the same merged set (default dir + plugin skill
// roots) as the skill tool and the system-prompt list, re-read per use so
// newly installed skills work without a restart.
LoadSkills: cachedSkillsLoader(func() []skills.Skill {
merged, _ := plugins.MergedSkillsLoaded(deps.skillsDir(), pluginActivation.skillRoots)
return merged
}),
PermissionMode: permissionMode,
Notify: resolved.Notify,
KeyBindings: resolved.KeyBindings,
STT: resolved.STT,
BuildDictationTranscriber: newDictationTranscriberFactory(resolved, userConfigPath, sttServerManager),
ShutdownDictationServer: sttServerManager.Shutdown,
STTDownloadRoot: sttDownloadRoot,
STTKeyStatus: newSTTKeyStatus(resolved, userConfigPath),
SaveSTTKey: newSaveSTTKey(userConfigPath),
PermissionMode: permissionMode,
Notify: resolved.Notify,
Setup: tui.SetupOptions{
Visible: setupVisible,
Required: needsSetup,
@@ -1273,8 +1158,7 @@ func profileHasCredential(profile config.ProviderProfile) bool {
}
// baseURLIsLoopback reports whether a provider base_url points at a loopback host
// or a private-network host (192.168.x.y / 10.x.y.z / 172.16.x.y) — a local
// provider like Ollama/LM Studio that needs no API key.
// (a keyless local provider like Ollama/LM Studio), which needs no API key.
func baseURLIsLoopback(baseURL string) bool {
u := strings.TrimSpace(baseURL)
if u == "" {
@@ -1289,7 +1173,7 @@ func baseURLIsLoopback(baseURL string) bool {
return true
}
if addr, err := netip.ParseAddr(host); err == nil {
return addr.IsLoopback() || addr.IsPrivate()
return addr.IsLoopback()
}
return false
}
@@ -1350,26 +1234,3 @@ Flags:
`)
return err
}
// cachedSkillsLoader memoizes a skills loader for a short interval. The TUI
// calls the loader from autocomplete on every "/x" keystroke; without a cache
// each keystroke would re-read every SKILL.md body. Two seconds is fresh enough
// that a newly installed skill still shows up "immediately" while typing.
func cachedSkillsLoader(load func() []skills.Skill) func() []skills.Skill {
var (
mu sync.Mutex
at time.Time
cached []skills.Skill
)
const ttl = 2 * time.Second
return func() []skills.Skill {
mu.Lock()
defer mu.Unlock()
if cached != nil && time.Since(at) < ttl {
return cached
}
cached = load()
at = time.Now()
return cached
}
}
+4 -339
View File
@@ -20,7 +20,6 @@ import (
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/tui"
"github.com/Gitlawb/zero/internal/update"
"github.com/Gitlawb/zero/internal/workspacetrust"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -159,86 +158,6 @@ func TestRunNoArgsLaunchesSetupTUIWithNilProviderWhenNoProviderConfigured(t *tes
assertAgentOptions(t, launchedOptions, 12, agent.PermissionModeAsk)
}
type fakeMCPRuntimeWithSkips struct {
skipped []mcp.SkippedServer
}
func (r fakeMCPRuntimeWithSkips) Close() error { return nil }
func (r fakeMCPRuntimeWithSkips) Skipped() []mcp.SkippedServer { return r.skipped }
func TestTUIStartupSuppressesWarningForUnconfiguredDefaultServer(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cwd := t.TempDir()
setCLIUserConfigRoot(t)
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{MaxTurns: 8}, nil
},
resolveMCPConfig: func(workspaceRoot string, excludeProject bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"firecrawl": config.DefaultMCPServers()["firecrawl"],
}}, nil
},
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return fakeMCPRuntimeWithSkips{skipped: []mcp.SkippedServer{
{Name: "firecrawl", Err: errors.New("returned HTTP 401"), UnconfiguredDefault: true},
}}, nil
},
runTUI: func(ctx context.Context, options tui.Options) int {
return 0
},
})
if exitCode != 0 {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
if strings.Contains(stderr.String(), "firecrawl") {
t.Fatalf("stderr = %q, want no warning for an unconfigured default server", stderr.String())
}
}
func TestTUIStartupWarnsForUserConfiguredServerSkip(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cwd := t.TempDir()
setCLIUserConfigRoot(t)
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{MaxTurns: 8}, nil
},
resolveMCPConfig: func(workspaceRoot string, excludeProject bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"custom": {Type: "stdio", Command: "custom-mcp"},
}}, nil
},
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return fakeMCPRuntimeWithSkips{skipped: []mcp.SkippedServer{
{Name: "custom", Err: errors.New("connect failed"), UnconfiguredDefault: false},
}}, nil
},
runTUI: func(ctx context.Context, options tui.Options) int {
return 0
},
})
if exitCode != 0 {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stderr.String(), "warning: MCP server custom unavailable") {
t.Fatalf("stderr = %q, want a warning for a user-configured server that failed", stderr.String())
}
}
func TestRunNoArgsEntersSetupWhenResolveReportsNoActiveProvider(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -286,67 +205,6 @@ func TestRunNoArgsEntersSetupWhenResolveReportsNoActiveProvider(t *testing.T) {
}
}
func TestRunNoArgsFallsBackToUsableProviderWhenNoneMarkedActive(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
var providerProfile config.ProviderProfile
fake := &cliFakeProvider{}
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) {
// 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, "")
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
providerProfile = profile
return fake, 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")
}
if launchedOptions.Setup.Visible || launchedOptions.Setup.Required {
t.Fatalf("Setup = %#v, want no setup wizard — a usable saved provider exists", launchedOptions.Setup)
}
if providerProfile.Name != "work" {
t.Fatalf("provider used = %q, want fallback to the usable saved provider %q", providerProfile.Name, "work")
}
}
func TestRunNoArgsFailsWhenResolveErrorIsNotProviderRelated(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -405,7 +263,7 @@ func TestRunNoArgsLaunchesTUIWithMCPState(t *testing.T) {
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{MaxTurns: 8}, nil
},
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
if workspaceRoot != cwd {
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
}
@@ -480,7 +338,7 @@ func TestTUIMCPCommandUsesLastGoodConfigOnRefreshError(t *testing.T) {
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{MaxTurns: 8}, nil
},
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
resolveCalls++
switch resolveCalls {
case 1:
@@ -547,7 +405,7 @@ func TestRunNoArgsClosesPartialMCPRuntimeWhenRegistrationFails(t *testing.T) {
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{MaxTurns: 8}, nil
},
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
}}, nil
@@ -602,7 +460,7 @@ func TestRunNoArgsSoftFailsMCPTokenStoreInit(t *testing.T) {
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{MaxTurns: 8}, nil
},
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
return config.MCPConfig{}, nil
},
newMCPStore: func() (*mcp.PermissionStore, error) {
@@ -741,52 +599,6 @@ func TestRunNoArgsLaunchesTUIInAskPermissionMode(t *testing.T) {
}
}
// TestRunInteractiveSurfacesMCPTrustNotice executes the interactive (TUI) path's MCP
// trust notice (app.go), the third notice site alongside exec and spec-draft. An
// untrusted repo whose only project config is MCP must print the notice before runTUI;
// trusting it silences it. runTUI is stubbed so nothing renders; resolveMCPConfig
// returns no servers so nothing spawns -- the notice depends only on the trust verdict
// and the real ./.zero/config.json.
func TestRunInteractiveSurfacesMCPTrustNotice(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir()
if err := os.MkdirAll(filepath.Join(repo, ".zero"), 0o700); err != nil {
t.Fatal(err)
}
body := `{"mcp":{"servers":{"proj":{"type":"stdio","command":"proj-cmd"}}}}`
if err := os.WriteFile(filepath.Join(repo, ".zero", "config.json"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
run := func() string {
var stdout, stderr bytes.Buffer
code := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return repo, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{MaxTurns: 3}, nil
},
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
runTUI: func(context.Context, tui.Options) int { return 0 },
})
if code != 0 {
t.Fatalf("interactive launch exit = %d, stderr=%q", code, stderr.String())
}
return stderr.String()
}
untrusted := run()
if !strings.Contains(untrusted, "MCP servers") || !strings.Contains(untrusted, "zero trust") {
t.Fatalf("untrusted interactive launch must surface the MCP trust notice, stderr=%q", untrusted)
}
if err := workspacetrust.Trust(repo); err != nil {
t.Fatal(err)
}
if trusted := run(); strings.Contains(trusted, "ignoring project") {
t.Fatalf("trusted interactive launch must not emit a trust notice, stderr=%q", trusted)
}
}
func TestRunSkipPermissionsUnsafeLaunchesTUIInUnsafeMode(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -1455,153 +1267,6 @@ func TestRunUpdateReportsUpToDate(t *testing.T) {
}
}
func TestRunUpdateApplyTextAndJSON(t *testing.T) {
applyResult := update.ApplyResult{
Result: update.Result{
CurrentVersion: "dev",
LatestVersion: "0.2.0",
UpdateAvailable: true,
},
Applied: true,
InstallMethod: update.InstallMethodStandalone,
BinaryPath: "/usr/local/bin/zero",
Message: "updated to 0.2.0",
}
var gotOptions update.Options
deps := appDeps{
applyUpdate: func(_ context.Context, options update.Options) (update.ApplyResult, error) {
gotOptions = options
return applyResult, nil
},
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
t.Fatal("checkUpdate should not run for --apply")
return update.Result{}, nil
},
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"update", "--apply"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "updated to 0.2.0") {
t.Fatalf("unexpected apply text: %q", stdout.String())
}
if gotOptions.CurrentVersion != "dev" {
t.Fatalf("unexpected options passed to applyUpdate: %#v", gotOptions)
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"update", "--apply", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
var payload struct {
Applied bool `json:"applied"`
InstallMethod string `json:"installMethod"`
BinaryPath string `json:"binaryPath"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("apply JSON did not decode: %v\n%s", err, stdout.String())
}
if !payload.Applied || payload.InstallMethod != string(update.InstallMethodStandalone) || payload.BinaryPath != applyResult.BinaryPath {
t.Fatalf("unexpected apply JSON: %#v", payload)
}
}
func TestRunUpgradeDefaultsToApply(t *testing.T) {
var applyCalled bool
deps := appDeps{
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
applyCalled = true
return update.ApplyResult{Message: "already up to date"}, nil
},
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
t.Fatal("checkUpdate should not run for `zero upgrade`")
return update.Result{}, nil
},
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"upgrade"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if !applyCalled {
t.Fatal("expected `zero upgrade` to call applyUpdate")
}
}
func TestRunUpdateRejectsCheckAndApplyTogether(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"update", "--check", "--apply"}, &stdout, &stderr, appDeps{
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
t.Fatal("checkUpdate should not run")
return update.Result{}, nil
},
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
t.Fatal("applyUpdate should not run")
return update.ApplyResult{}, nil
},
})
if exitCode != exitUsage {
t.Fatalf("expected usage exit code, got %d", exitCode)
}
if got := stderr.String(); !strings.Contains(got, "only one of --check or --apply") {
t.Fatalf("expected --check/--apply usage error, got %q", got)
}
}
func TestRunUpdateRejectsTargetWithApply(t *testing.T) {
for _, args := range [][]string{
{"update", "--apply", "--target", "linux-arm64"},
{"upgrade", "--target", "linux-arm64"},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
t.Fatal("applyUpdate should not run when --target is combined with --apply")
return update.ApplyResult{}, nil
},
})
if exitCode != exitUsage {
t.Fatalf("expected usage exit code, got %d", exitCode)
}
if got := stderr.String(); !strings.Contains(got, "--target cannot be combined with --apply") {
t.Fatalf("expected --target/--apply usage error, got %q", got)
}
})
}
}
func TestRunUpdateReportsApplyError(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"update", "--apply"}, &stdout, &stderr, appDeps{
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
return update.ApplyResult{}, errors.New("download failed")
},
})
if exitCode == exitSuccess {
t.Fatalf("expected non-success exit code, got %d", exitCode)
}
if got := stderr.String(); !strings.Contains(got, "download failed") {
t.Fatalf("expected apply error, got %q", got)
}
}
func assertHelpOutput(t *testing.T, args []string) {
t.Helper()
+12 -119
View File
@@ -6,52 +6,15 @@ import (
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/provideroauth"
"github.com/Gitlawb/zero/internal/redaction"
)
// ensureLoginProviderProfile makes a freshly stored OAuth login visible as a
// provider: without a profile in config.json the login shows up nowhere — not in
// `zero providers list`, not in the TUI picker — and `zero providers use <name>`
// fails "not found", so the user's working active provider looks broken while
// the new login looks lost. Returns user-facing guidance lines ("" when the
// login has no catalog entry to scaffold from). Best-effort by design: the
// token is already stored, so a profile-write failure degrades to a warning
// line instead of failing a login that succeeded.
func ensureLoginProviderProfile(deps appDeps, provider string) string {
if _, ok := providercatalog.Get(provider); !ok {
// Custom OAuth server without a catalog entry — no endpoint/model defaults
// to scaffold a profile from; the user wires their own profile.
return ""
}
configPath, err := deps.userConfigPath()
if err != nil {
return "warning: login saved, but no provider profile was written: " + err.Error()
}
ensured, err := config.EnsureCatalogProvider(configPath, provider)
if err != nil {
return "warning: login saved, but no provider profile was written: " + err.Error()
}
active := strings.EqualFold(strings.TrimSpace(ensured.Active), strings.TrimSpace(ensured.Name))
switch {
case ensured.Created && active:
return fmt.Sprintf("Added provider %q to your config and set it active.", ensured.Name)
case ensured.Created:
return fmt.Sprintf("Added provider %q to your config; the active provider is still %q.\nSwitch with: zero providers use %s", ensured.Name, ensured.Active, ensured.Name)
case active:
return fmt.Sprintf("Provider %q is configured and active.", ensured.Name)
default:
return fmt.Sprintf("Provider %q is already configured.\nSwitch with: zero providers use %s", ensured.Name, ensured.Name)
}
}
// runAuth dispatches `zero auth <command>` for provider OAuth login. It is
// additive and independent of `zero mcp oauth` (MCP server auth), which is
// unchanged.
@@ -83,11 +46,11 @@ func runAuth(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
}
// runAuthOpenRouter runs OpenRouter's browser PKCE login and saves the freshly
// minted API key into the user's provider credential store. Unlike `auth login`
// (which stores an OAuth bearer token), OpenRouter's flow mints a normal API key;
// persist it immediately so the provider is usable after the command completes.
func runAuthOpenRouter(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
// runAuthOpenRouter runs OpenRouter's browser PKCE login and prints the freshly
// minted API key. Unlike `auth login` (which stores an OAuth bearer token),
// OpenRouter's flow mints a normal API key; the setup wizard saves it to a
// provider profile, while this command prints it for manual configuration.
func runAuthOpenRouter(args []string, stdout io.Writer, stderr io.Writer, _ appDeps) int {
for _, a := range args {
if a == "-h" || a == "--help" || a == "help" {
_ = writeAuthHelp(stdout)
@@ -99,7 +62,7 @@ func runAuthOpenRouter(args []string, stdout io.Writer, stderr io.Writer, deps a
if len(args) > 0 {
return writeExecUsageError(stderr, fmt.Sprintf("zero auth openrouter takes no arguments (got %q)", args[0]))
}
key, err := deps.openRouterLogin(context.Background(), provideroauth.OpenRouterOptions{
key, err := provideroauth.OpenRouterLogin(context.Background(), provideroauth.OpenRouterOptions{
Out: stdout,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
// ZERO_OPENROUTER_BASE_URL overrides the endpoint (self-hosted gateway or tests).
@@ -108,59 +71,12 @@ func runAuthOpenRouter(args []string, stdout io.Writer, stderr io.Writer, deps a
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
key = strings.TrimSpace(key)
line, err := saveOpenRouterProviderKey(deps, key)
if err != nil {
if _, writeErr := fmt.Fprintf(stdout, "\nOpenRouter login complete — new API key minted, but Zero could not save it: %s\nUse it manually, e.g.:\n export OPENROUTER_API_KEY=%s\n", err, key); writeErr != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintf(stdout, "\nOpenRouter login complete — new API key saved.\n%s\n", line); err != nil {
if _, err := fmt.Fprintf(stdout, "\nOpenRouter login complete — new API key minted.\nUse it with zero, e.g.:\n export OPENROUTER_API_KEY=%s\n(or add it to a provider profile with catalogID \"openrouter\").\n", key); err != nil {
return exitCrash
}
return exitSuccess
}
func saveOpenRouterProviderKey(deps appDeps, key string) (string, error) {
key = strings.TrimSpace(key)
if key == "" {
return "", fmt.Errorf("OpenRouter returned an empty API key")
}
configPath, err := deps.userConfigPath()
if err != nil {
return "", err
}
ensured, err := config.EnsureCatalogProvider(configPath, "openrouter")
if err != nil {
return "", err
}
store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath))
if err != nil {
return "", err
}
if err := store.Set(ensured.Name, key); err != nil {
return "", err
}
if err := config.MarkProviderAPIKeyStored(configPath, ensured.Name); err != nil {
// Best-effort rollback: don't leave the key orphaned in the credential
// store while config.json still says it isn't there.
_, _ = store.Delete(ensured.Name)
return "", err
}
active := strings.EqualFold(strings.TrimSpace(ensured.Active), strings.TrimSpace(ensured.Name))
switch {
case ensured.Created && active:
return fmt.Sprintf("Added provider %q to your config and set it active.", ensured.Name), nil
case ensured.Created:
return fmt.Sprintf("Added provider %q to your config; the active provider is still %q.\nSwitch with: zero providers use %s", ensured.Name, ensured.Active, ensured.Name), nil
case active:
return fmt.Sprintf("Provider %q is configured, active, and ready to use.", ensured.Name), nil
default:
return fmt.Sprintf("Provider %q is configured with the new key.\nSwitch with: zero providers use %s", ensured.Name, ensured.Name), nil
}
}
// runAuthChatGPT runs the ChatGPT (Codex) browser PKCE login, persists the
// bearer + chatgpt-account-id claim under the "chatgpt" provider key, and
// prints a status block. The bearer routes to chatgpt.com/backend-api/codex
@@ -222,10 +138,8 @@ func runAuthChatGPT(args []string, stdout io.Writer, stderr io.Writer, deps appD
if _, err := fmt.Fprint(stdout, statuses); err != nil {
return exitCrash
}
if line := ensureLoginProviderProfile(deps, "chatgpt"); line != "" {
if _, err := fmt.Fprintf(stdout, "\n%s\n", line); err != nil {
return exitCrash
}
if _, err := fmt.Fprint(stdout, "\nUse it with zero, e.g.:\n zero --provider chatgpt --model gpt-5.5\n"); err != nil {
return exitCrash
}
return exitSuccess
}
@@ -387,27 +301,12 @@ func runAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps appDep
if len(parsed.positional) != 1 {
return writeExecUsageError(stderr, "usage: zero auth login <provider> [--device] [--scope <scope>]")
}
provider := parsed.positional[0]
// ChatGPT (Codex) requires a fixed redirect_uri (http://localhost:1455/
// auth/callback) and mandatory authorize params (id_token_add_organizations,
// codex_cli_simplified_flow, originator) that the generic loopback flow
// cannot supply. Route it to the dedicated ChatGPT login so
// `zero auth login chatgpt` behaves identically to `zero auth chatgpt`.
if strings.EqualFold(provider, "chatgpt") {
if parsed.device {
return writeExecUsageError(stderr, "ChatGPT login does not support --device (it is loopback-only)")
}
if len(parsed.scopes) > 0 {
return writeExecUsageError(stderr, "ChatGPT login does not support --scope (the required scopes are fixed by the Codex client registration)")
}
return runAuthChatGPT(nil, stdout, stderr, deps)
}
manager, err := newAuthManager(deps, stdout)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
status, err := manager.Login(context.Background(), oauth.LoginOptions{
Provider: provider,
Provider: parsed.positional[0],
Device: parsed.device,
ExtraScopes: parsed.scopes,
})
@@ -417,11 +316,6 @@ func runAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps appDep
if _, err := fmt.Fprintf(stdout, "Logged in to %s.\n%s\n", parsed.positional[0], oauth.FormatStatuses([]oauth.Status{status})); err != nil {
return exitCrash
}
if line := ensureLoginProviderProfile(deps, provider); line != "" {
if _, err := fmt.Fprintln(stdout, line); err != nil {
return exitCrash
}
}
return exitSuccess
}
@@ -595,9 +489,8 @@ Commands:
A provider is any OAuth 2.0 / OIDC server. "openrouter" ('zero auth openrouter')
works out of the box. "xai" ('zero auth login xai') uses a built-in preset that is
off by default — enable it with ZERO_OAUTH_ALLOW_PRESETS=1, or set the
ZERO_OAUTH_XAI_* vars yourself. "chatgpt" ('zero auth login chatgpt' or
'zero auth chatgpt') uses a fixed-port loopback flow against the Codex backend.
Any preset field is overridable via the env vars below. For a custom provider named <name>, set:
ZERO_OAUTH_XAI_* vars yourself. Any preset field is overridable via the env vars
below. For a custom provider named <name>, set:
ZERO_OAUTH_<NAME>_CLIENT_ID (required)
ZERO_OAUTH_<NAME>_CLIENT_SECRET (optional)
ZERO_OAUTH_<NAME>_AUTHORIZE_URL ZERO_OAUTH_<NAME>_TOKEN_URL
-28
View File
@@ -2,14 +2,12 @@ package cli
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -74,32 +72,6 @@ func TestFillAppDepsWrapsNewProviderWithStoredKey(t *testing.T) {
}
}
// fillAppDeps also wraps probeProviderHealth, the code path zero providers
// check --connectivity, zero doctor --connectivity, and the TUI doctor panel
// all go through. Passing the raw resolved profile previously sent an
// unauthenticated (keyless) health probe for apiKeyStored profiles, same
// regression class as TestFillAppDepsWrapsNewProviderWithStoredKey but for
// health checks instead of the runtime provider build.
func TestFillAppDepsWrapsProbeProviderHealthWithStoredKey(t *testing.T) {
configPath := seedStoredProviderKey(t, "echo", "sk-stored-health")
var captured config.ProviderProfile
deps := fillAppDeps(appDeps{
userConfigPath: func() (string, error) { return configPath, nil },
probeProviderHealth: func(_ context.Context, options providerhealth.Options) providerhealth.Result {
captured = options.Profile
return providerhealth.Result{Status: providerhealth.StatusPass}
},
})
deps.probeProviderHealth(context.Background(), providerhealth.Options{
Profile: config.ProviderProfile{Name: "echo", APIKeyStored: true},
})
if captured.APIKey != "sk-stored-health" {
t.Fatalf("wrapped probeProviderHealth ran with APIKey = %q, want the stored key (keyless health-check regression)", captured.APIKey)
}
}
// buildProvider (the TUI/exec STARTUP construction site) must export
// ZERO_PROVIDER so children spawned at any point in the run are pinned to the
// parent's provider from launch — not only after an in-session switch. Without
+1 -166
View File
@@ -2,26 +2,18 @@ package cli
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/provideroauth"
)
// withAuthStore points the provider OAuth store at a temp file for the test,
// pinning the file backend so an inherited ZERO_OAUTH_STORAGE=keyring can't
// ignore the temp path and hit the OS keychain.
// withAuthStore points the provider OAuth store at a temp file for the test.
func withAuthStore(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
t.Setenv("ZERO_OAUTH_STORAGE", "file")
return path
}
@@ -153,44 +145,6 @@ func TestRunAuthOpenRouterRejectsArgs(t *testing.T) {
}
}
func TestRunAuthOpenRouterSavesMintedKey(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
var stdout, stderr bytes.Buffer
code := runWithDeps([]string{"auth", "openrouter"}, &stdout, &stderr, appDeps{
userConfigPath: func() (string, error) { return configPath, nil },
openRouterLogin: func(context.Context, provideroauth.OpenRouterOptions) (string, error) {
return "sk-openrouter-test", nil
},
})
if code != exitSuccess {
t.Fatalf("exit = %d, stderr = %q", code, stderr.String())
}
if !strings.Contains(stdout.String(), "new API key saved") {
t.Fatalf("expected saved-key confirmation, got %q", stdout.String())
}
cfg := readCLIConfigFixture(t, configPath)
if cfg.ActiveProvider != "openrouter" || len(cfg.Providers) != 1 {
t.Fatalf("config = %#v", cfg)
}
profile := cfg.Providers[0]
if profile.Name != "openrouter" || profile.CatalogID != "openrouter" || !profile.APIKeyStored || profile.APIKey != "" || profile.APIKeyEnv != "" {
t.Fatalf("provider not stored-key sanitized: %#v", profile)
}
store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath))
if err != nil {
t.Fatal(err)
}
key, ok, err := store.Get("openrouter")
if err != nil {
t.Fatal(err)
}
if !ok || key != "sk-openrouter-test" {
t.Fatalf("stored key = %q, %v", key, ok)
}
}
func TestRunAuthHelp(t *testing.T) {
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"auth", "--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
@@ -202,122 +156,3 @@ func TestRunAuthHelp(t *testing.T) {
}
}
}
// TestRunAuthLoginChatGPTRoutesToDedicatedFlow verifies `zero auth login
// chatgpt` reaches the dedicated ChatGPT login (fixed-port loopback + mandatory
// authorize params), not the generic manager path. The generic login accepts
// --device, so a ChatGPT-specific rejection proves the routing took effect.
// See issue #430: the generic path built a random-port 127.0.0.1 redirect_uri
// without the required extra params, so OpenAI's authorize endpoint rejected it.
func TestRunAuthLoginChatGPTRoutesToDedicatedFlow(t *testing.T) {
withAuthStore(t)
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"auth", "login", "chatgpt", "--device"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
t.Fatal("auth login chatgpt --device should be rejected (ChatGPT is loopback-only)")
}
if !strings.Contains(stderr.String(), "ChatGPT login does not support --device") {
t.Fatalf("stderr = %q, want the ChatGPT-specific --device rejection", stderr.String())
}
// Case-insensitive provider name should still route.
stdout.Reset()
stderr.Reset()
if code := runWithDeps([]string{"auth", "login", "ChatGPT", "--device"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
t.Fatal("auth login ChatGPT --device should be rejected")
}
if !strings.Contains(stderr.String(), "ChatGPT login does not support --device") {
t.Fatalf("stderr = %q, want the ChatGPT-specific rejection (case-insensitive)", stderr.String())
}
}
// TestRunAuthLoginChatGPTRejectsScope mirrors the --device rejection: --scope
// must not be silently dropped on the ChatGPT path. The Codex client
// registration pins a fixed scope set (incl. api.connectors.*), so custom
// scopes are rejected up front rather than plumbed through.
func TestRunAuthLoginChatGPTRejectsScope(t *testing.T) {
withAuthStore(t)
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"auth", "login", "chatgpt", "--scope", "custom-scope"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
t.Fatal("auth login chatgpt --scope should be rejected")
}
if !strings.Contains(stderr.String(), "ChatGPT login does not support --scope") {
t.Fatalf("stderr = %q, want the ChatGPT-specific --scope rejection", stderr.String())
}
}
func TestEnsureLoginProviderProfileAddsProviderWithoutStealingActive(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
seed := `{"activeProvider":"opengateway","providers":[{"name":"opengateway","provider_kind":"openai-compatible","baseURL":"https://gateway.example.com/v1","apiKeyStored":true,"model":"some-model"}]}`
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
t.Fatalf("seed config: %v", err)
}
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
line := ensureLoginProviderProfile(deps, "chatgpt")
if !strings.Contains(line, `Added provider "chatgpt"`) {
t.Fatalf("expected added-provider guidance, got %q", line)
}
if !strings.Contains(line, "zero providers use chatgpt") {
t.Fatalf("expected switch hint, got %q", line)
}
cfg := readCLIConfigFixture(t, configPath)
if cfg.ActiveProvider != "opengateway" {
t.Fatalf("active provider changed to %q", cfg.ActiveProvider)
}
names := make([]string, 0, len(cfg.Providers))
for _, provider := range cfg.Providers {
names = append(names, provider.Name)
}
if len(cfg.Providers) != 2 || cfg.Providers[1].CatalogID != "chatgpt" {
t.Fatalf("expected chatgpt profile appended, got %v", names)
}
// A second login must be a no-op with switch guidance, not a duplicate.
line = ensureLoginProviderProfile(deps, "chatgpt")
if !strings.Contains(line, "already configured") {
t.Fatalf("expected already-configured guidance, got %q", line)
}
cfg = readCLIConfigFixture(t, configPath)
if len(cfg.Providers) != 2 {
t.Fatalf("repeat login duplicated the profile: %d providers", len(cfg.Providers))
}
}
func TestEnsureLoginProviderProfileActivatesOnFreshConfig(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
line := ensureLoginProviderProfile(deps, "chatgpt")
if !strings.Contains(line, "set it active") {
t.Fatalf("fresh config should adopt the login as active, got %q", line)
}
cfg := readCLIConfigFixture(t, configPath)
if cfg.ActiveProvider != "chatgpt" {
t.Fatalf("active provider = %q, want chatgpt", cfg.ActiveProvider)
}
}
func TestEnsureLoginProviderProfileSkipsNonCatalogProviders(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
if line := ensureLoginProviderProfile(deps, "my-custom-oauth-server"); line != "" {
t.Fatalf("custom OAuth server must not scaffold a profile, got %q", line)
}
if _, err := os.Stat(configPath); err == nil {
t.Fatalf("config must not be created for a non-catalog login")
}
}
func readCLIConfigFixture(t *testing.T, path string) config.FileConfig {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config: %v", err)
}
var cfg config.FileConfig
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("decode config: %v", err)
}
return cfg
}
+2 -6
View File
@@ -125,9 +125,7 @@ func backendLifecycleSnapshot(deps appDeps) (zerocommands.BackendLifecycleSnapsh
return zerocommands.BackendLifecycleSnapshot{}, fmt.Errorf("failed to resolve workspace: %w", err)
}
// Reporting/enumeration only, never spawns a server, so it is left ungated
// (excludeProject=false) to mirror the doctor/status hooks and plugins reports.
cfg, err := deps.resolveMCPConfig(cwd, false)
cfg, err := deps.resolveMCPConfig(cwd)
if err != nil {
return zerocommands.BackendLifecycleSnapshot{}, err
}
@@ -154,9 +152,7 @@ func backendDoctorReport(deps appDeps) (zerocommands.BackendDoctorReport, error)
return zerocommands.BackendDoctorReport{}, fmt.Errorf("failed to resolve workspace: %w", err)
}
// Reporting/enumeration only, never spawns a server, so it is left ungated
// (excludeProject=false) to mirror the doctor/status hooks and plugins reports.
cfg, err := deps.resolveMCPConfig(cwd, false)
cfg, err := deps.resolveMCPConfig(cwd)
if err != nil {
return zerocommands.BackendDoctorReport{}, err
}
+4 -4
View File
@@ -26,7 +26,7 @@ func TestRunBackendsJSONUsesLifecycleSnapshotWithoutConnectingMCP(t *testing.T)
secret := "sk-proj-" + strings.Repeat("a", 24)
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
if workspaceRoot != cwd {
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
}
@@ -123,7 +123,7 @@ func TestRunBackendsJSONUsesLifecycleSnapshotWithoutConnectingMCP(t *testing.T)
func TestRunBackendsTextAndHelp(t *testing.T) {
deps := appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
resolveMCPConfig: func(string) (config.MCPConfig, error) {
return config.MCPConfig{}, nil
},
loadHooks: func(hooks.LoadOptions) (hooks.LoadResult, error) {
@@ -164,7 +164,7 @@ func TestRunBackendsDoctorJSONAndTextWithoutConnectingMCP(t *testing.T) {
secret := "sk-proj-" + strings.Repeat("b", 24)
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
if workspaceRoot != cwd {
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
}
@@ -301,7 +301,7 @@ func TestRunBackendsDoctorDoesNotConnectOrExecuteConfiguredBackends(t *testing.T
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
resolveMCPConfig: func(string) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"remote": {Type: "http", URL: server.URL + "/mcp"},
}}, nil
+2 -33
View File
@@ -75,12 +75,6 @@ func runProviders(args []string, stdout io.Writer, stderr io.Writer, deps appDep
if command == "use" {
return runProvidersUse(args, stdout, stderr, deps)
}
if command == "remove" || command == "rm" {
return runProvidersRemove(args, stdout, stderr, deps)
}
if command == "rename" {
return runProvidersRename(args, stdout, stderr, deps)
}
if command == "setup" {
return runProvidersSetup(args, stdout, stderr, deps)
}
@@ -252,20 +246,6 @@ func parseCommandCenterArgs(args []string, allowModelFilters bool, allowProvider
func summarizeConfig(resolved config.ResolvedConfig) configSummary {
summary := zerocommands.ConfigSnapshotFromResolved(resolved)
// Mark keyless profiles that authenticate via a stored OAuth login (e.g.
// ChatGPT) so the list shows "oauth login" instead of "api key: not set" —
// the same candidate matching the runtime resolver uses.
logins := oauthLoggedInProviders()
if len(logins) > 0 {
for index := range summary.Providers {
for _, profile := range resolved.Providers {
if profile.Name == summary.Providers[index].Name {
summary.Providers[index].OAuthLogin = providerHasOAuthLogin(profile, logins)
break
}
}
}
}
sort.SliceStable(summary.Providers, func(i int, j int) bool {
if summary.Providers[i].Active != summary.Providers[j].Active {
return summary.Providers[i].Active
@@ -343,7 +323,7 @@ func formatProviderSummaries(command string, providers []providerSummary) string
"model: "+displayCLIValue(provider.Model, "none"),
"api model: "+displayCLIValue(provider.APIModel, "unknown"),
"base url: "+displayCLIValue(provider.BaseURL, "default"),
"api key: "+providerCredentialState(provider),
"api key: "+apiKeyState(provider.APIKeySet),
)
if provider.Message != "" {
lines = append(lines, "status: "+provider.Status+" - "+provider.Message)
@@ -360,7 +340,7 @@ func formatProviderLine(provider providerSummary) string {
if provider.Active {
marker = "*"
}
line := fmt.Sprintf("%s %s [%s] model=%s apiModel=%s api key: %s", marker, displayCLIValue(provider.Name, "none"), displayCLIValue(provider.ProviderKind, "unknown"), displayCLIValue(provider.Model, "none"), displayCLIValue(provider.APIModel, "unknown"), providerCredentialState(provider))
line := fmt.Sprintf("%s %s [%s] model=%s apiModel=%s api key: %s", marker, displayCLIValue(provider.Name, "none"), displayCLIValue(provider.ProviderKind, "unknown"), displayCLIValue(provider.Model, "none"), displayCLIValue(provider.APIModel, "unknown"), apiKeyState(provider.APIKeySet))
if provider.Message != "" {
line += " (" + provider.Status + ": " + provider.Message + ")"
}
@@ -433,15 +413,6 @@ func apiKeyState(set bool) string {
return "not set"
}
// providerCredentialState renders the credential column: a configured key wins,
// then a stored OAuth login, then "not set".
func providerCredentialState(provider providerSummary) string {
if !provider.APIKeySet && provider.OAuthLogin {
return "oauth login"
}
return apiKeyState(provider.APIKeySet)
}
func displayCLIValue(value string, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
@@ -495,8 +466,6 @@ func writeProvidersHelp(w io.Writer) error {
zero providers add <catalog-id> [flags]
zero providers check [name] [flags]
zero providers use <name> [flags]
zero providers remove <name> [flags]
zero providers rename <old> <new> [flags]
zero providers setup <catalog-id> [flags]
zero providers detect [flags]
zero providers models [name] [flags]
-55
View File
@@ -11,7 +11,6 @@ import (
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/zerocommands"
"github.com/Gitlawb/zero/internal/zeroruntime"
@@ -836,57 +835,3 @@ func (commandCenterProvider) StreamCompletion(context.Context, zeroruntime.Compl
close(ch)
return ch, nil
}
func TestProviderCredentialStateShowsOAuthLogin(t *testing.T) {
if got := providerCredentialState(providerSummary{APIKeySet: true}); got != "set" {
t.Fatalf("keyed provider = %q, want set", got)
}
if got := providerCredentialState(providerSummary{OAuthLogin: true}); got != "oauth login" {
t.Fatalf("token-login provider = %q, want oauth login", got)
}
if got := providerCredentialState(providerSummary{}); got != "not set" {
t.Fatalf("credential-less provider = %q, want not set", got)
}
}
// TestProvidersListMarksOAuthLoginProviders: a keyless profile whose credential
// is a stored OAuth login (the shape `zero auth chatgpt` now writes) must render
// as "oauth login", not as a broken "api key: not set" entry.
func TestProvidersListMarksOAuthLoginProviders(t *testing.T) {
tokensPath := filepath.Join(t.TempDir(), "oauth-tokens.json")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokensPath)
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
t.Fatalf("oauth store: %v", err)
}
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "bearer-123"}); err != nil {
t.Fatalf("save token: %v", err)
}
resolved := config.ResolvedConfig{
ActiveProvider: "opengateway",
Providers: []config.ProviderProfile{
{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", APIKeyStored: true, Model: "some-model"},
{Name: "chatgpt", CatalogID: "chatgpt", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://chatgpt.com/backend-api/codex", Model: "gpt-5.5"},
},
}
summary := summarizeConfig(resolved)
byName := map[string]providerSummary{}
for _, provider := range summary.Providers {
byName[provider.Name] = provider
}
if !byName["opengateway"].APIKeySet {
t.Fatalf("stored-key provider must report a credential: %+v", byName["opengateway"])
}
if byName["opengateway"].OAuthLogin {
t.Fatalf("key-authed provider must not claim the OAuth login: %+v", byName["opengateway"])
}
if !byName["chatgpt"].OAuthLogin || byName["chatgpt"].APIKeySet {
t.Fatalf("chatgpt must be marked oauth-login and keyless: %+v", byName["chatgpt"])
}
rendered := formatProviderSummaries("list", summary.Providers)
if !strings.Contains(rendered, "oauth login") {
t.Fatalf("list should render the oauth login state, got:\n%s", rendered)
}
}
+3 -3
View File
@@ -142,7 +142,7 @@ func TestRunExecListToolsAdvertisesMCPToolsWithoutToolSearch(t *testing.T) {
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
resolveMCPConfig: func(string) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
}}, nil
@@ -177,7 +177,7 @@ func TestRunExecListToolsHonorsJSONFormat(t *testing.T) {
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
resolveMCPConfig: func(string) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
registerMCPTools: func(_ context.Context, _ *tools.Registry, _ config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
return closeFunc(func() error { return nil }), nil
@@ -234,7 +234,7 @@ func TestTUIRunThreadsDeferThresholdAndRegistersToolSearch(t *testing.T) {
Tools: config.ToolsConfig{DeferThreshold: 2},
}, nil
},
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
resolveMCPConfig: func(string) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
}}, nil
-248
View File
@@ -1,248 +0,0 @@
package cli
import (
"fmt"
neturl "net/url"
"os"
"path/filepath"
"strings"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/dictation"
"github.com/Gitlawb/zero/internal/tui"
)
// Default batch cloud models (§8). Overridable via stt.model.
const (
defaultGroqSTTModel = "whisper-large-v3-turbo"
defaultOpenAISTTModel = "whisper-1"
groqSTTBaseURL = "https://api.groq.com/openai/v1"
)
// newDictationTranscriberFactory returns the closure the TUI calls to build a
// transcriber when a recording starts. It lives here, not in the TUI, because it
// resolves provider API keys (credstore + env + configured providers) — logic
// that belongs with the rest of the CLI's credential handling.
//
// serverMgr is the shared, session-long sherpa-onnx streaming server; it is
// spawned lazily on the first local-streaming recording and torn down on exit.
//
// preferStreaming asks for the streaming backend; the returned bool reports
// whether streaming was actually built (false → the caller uses the batch
// pipeline). A misconfigured streaming backend surfaces its own SetupError.
func newDictationTranscriberFactory(resolved config.ResolvedConfig, userConfigPath string, serverMgr *dictation.ServerManager) func(config.STTConfig, bool) (tui.Transcriber, bool, error) {
return func(stt config.STTConfig, preferStreaming bool) (tui.Transcriber, bool, error) {
// stt is passed in (not captured) so a mid-session change — e.g. the F9
// auto-download writing localModelPath — takes effect on the next call.
if serverMgr != nil {
serverMgr.SetModelPath(stt.LocalModelPath)
}
if preferStreaming {
t, err := buildStreamingTranscriber(stt, resolved, userConfigPath, serverMgr)
switch {
case err == nil && t != nil:
return t, true, nil
case err != nil && stt.STTStreamProvider() != config.STTProviderLocal:
// An explicitly-chosen cloud streaming backend that failed to build
// (e.g. a missing key) should surface its own error, not silently
// fall back to batch — the user asked for that backend specifically.
return nil, true, err
}
// The default local streaming backend isn't set up: fall through to the
// batch pipeline so a cloud-batch provider (or a batch-only setup) still
// works instead of erroring on a streaming server the user never asked for.
}
t, err := buildBatchTranscriber(stt, resolved, userConfigPath)
return t, false, err
}
}
// buildStreamingTranscriber constructs the configured streaming backend, or
// returns (nil, nil) when streaming is not applicable so the caller falls back
// to batch.
func buildStreamingTranscriber(stt config.STTConfig, resolved config.ResolvedConfig, userConfigPath string, serverMgr *dictation.ServerManager) (dictation.Transcriber, error) {
switch stt.STTStreamProvider() {
case config.STTProviderDeepgram:
key := resolveSTTKey(resolved, userConfigPath, dictation.StreamProviderDeepgram, "DEEPGRAM_API_KEY")
return dictation.NewDeepgramTranscriber(dictation.DeepgramConfig{
APIKey: key,
Model: stt.StreamModel,
Language: stt.Language,
})
case config.STTProviderOpenAI:
key := resolveSTTKey(resolved, userConfigPath, dictation.ProviderOpenAI, "OPENAI_API_KEY")
return dictation.NewOpenAIRealtimeTranscriber(dictation.OpenAIRealtimeConfig{
APIKey: key,
Model: stt.StreamModel,
})
default: // local streaming via the warm sherpa-onnx server
// STTStreamProvider() defaults to "local" whenever streamProvider is unset —
// including when the user picked a CLOUD batch provider (Groq/OpenAI) and
// never touched streaming. Don't silently stream through the local sherpa
// server in that case: local streaming is only appropriate when it was
// explicitly requested (streamProvider: "local") OR the batch provider is
// itself local. Otherwise report "not applicable" so the factory falls back
// to the cloud batch pipeline the user actually chose.
localWanted := stt.StreamProvider == config.STTProviderLocal || stt.STTProvider() == config.STTProviderLocal
if !localWanted {
return nil, nil
}
// The local streaming transcriber validates the model only lazily (at
// connect time), so gate on the model path here — with none set, report
// "not applicable" (nil, nil) so the factory falls back to the batch
// pipeline instead of committing to a server that can't start.
if serverMgr == nil || strings.TrimSpace(stt.LocalModelPath) == "" {
return nil, nil
}
return dictation.NewLocalStreamingTranscriber(serverMgr), nil
}
}
// newDictationServerManager builds the warm sherpa-onnx streaming server manager
// from config. Construction is side-effect free — the server spawns lazily on
// the first local-streaming recording — so it is always safe to create.
func newDictationServerManager(stt config.STTConfig) *dictation.ServerManager {
return dictation.NewServerManager(dictation.ServerConfig{
Binary: stt.LocalServerBinary,
ModelPath: stt.LocalModelPath,
Port: stt.LocalServerPort,
NumThreads: stt.NumThreads,
})
}
// buildBatchTranscriber constructs the configured batch transcriber, resolving
// credentials for the cloud providers.
func buildBatchTranscriber(stt config.STTConfig, resolved config.ResolvedConfig, userConfigPath string) (dictation.Transcriber, error) {
switch stt.STTProvider() {
case config.STTProviderGroq:
key := resolveSTTKey(resolved, userConfigPath, dictation.ProviderGroq, "GROQ_API_KEY")
model := stt.Model
if model == "" {
model = defaultGroqSTTModel
}
return dictation.NewCloudTranscriber(dictation.CloudConfig{
Provider: dictation.ProviderGroq,
BaseURL: groqSTTBaseURL,
APIKey: key,
Model: model,
Language: stt.Language,
})
case config.STTProviderOpenAI:
key := resolveSTTKey(resolved, userConfigPath, dictation.ProviderOpenAI, "OPENAI_API_KEY")
model := stt.Model
if model == "" {
model = defaultOpenAISTTModel
}
return dictation.NewCloudTranscriber(dictation.CloudConfig{
Provider: dictation.ProviderOpenAI,
BaseURL: config.OpenAIBaseURL,
APIKey: key,
Model: model,
Language: stt.Language,
})
default: // local
return dictation.NewLocalTranscriber(dictation.LocalConfig{
Binary: stt.LocalBinary,
ModelPath: stt.LocalModelPath,
NumThreads: stt.NumThreads,
})
}
}
// sttEnvVar returns the conventional environment variable a cloud STT provider's
// key is read from.
func sttEnvVar(provider string) string {
switch provider {
case dictation.ProviderGroq:
return "GROQ_API_KEY"
case dictation.ProviderOpenAI: // == StreamProviderOpenAI ("openai")
return "OPENAI_API_KEY"
case dictation.StreamProviderDeepgram:
return "DEEPGRAM_API_KEY"
}
return ""
}
// newSTTKeyStatus returns a predicate reporting whether a cloud STT provider
// already has a resolvable API key (configured provider, credstore, or env).
func newSTTKeyStatus(resolved config.ResolvedConfig, userConfigPath string) func(string) bool {
return func(provider string) bool {
return resolveSTTKey(resolved, userConfigPath, provider, sttEnvVar(provider)) != ""
}
}
// newSaveSTTKey returns a function that stores a cloud STT provider's API key in
// the encrypted credential store, so the inline prompt can persist it.
func newSaveSTTKey(userConfigPath string) func(string, string) error {
return func(provider, key string) error {
if strings.TrimSpace(userConfigPath) == "" {
return fmt.Errorf("no config path to store the key")
}
store, err := config.ProviderKeyStoreAt(filepath.Dir(userConfigPath))
if err != nil {
return err
}
return store.Set(provider, strings.TrimSpace(key))
}
}
// resolveSTTKey finds an API key for an STT cloud provider, reusing Zero's
// existing credentials (§2 — "zero new credential UI"). Precedence: a matching
// configured provider's resolved key, then the encrypted credential store, then
// the provider's conventional environment variable.
func resolveSTTKey(resolved config.ResolvedConfig, userConfigPath, provider, envVar string) string {
if key := keyFromConfiguredProviders(resolved, provider); key != "" {
return key
}
if userConfigPath != "" {
if store, err := config.ProviderKeyStoreAt(filepath.Dir(userConfigPath)); err == nil {
if key, ok, _ := store.Get(provider); ok && strings.TrimSpace(key) != "" {
return strings.TrimSpace(key)
}
}
}
return strings.TrimSpace(os.Getenv(envVar))
}
// keyFromConfiguredProviders returns the resolved API key of a configured
// provider that belongs to the given cloud provider (matched by catalog id,
// provider name/kind, or base-URL host), or "" when none is configured.
func keyFromConfiguredProviders(resolved config.ResolvedConfig, provider string) string {
provider = strings.ToLower(provider)
profiles := append([]config.ProviderProfile{resolved.Provider}, resolved.Providers...)
for _, p := range profiles {
if strings.TrimSpace(p.APIKey) == "" {
continue
}
if providerMatches(p, provider) {
return strings.TrimSpace(p.APIKey)
}
}
return ""
}
func providerMatches(p config.ProviderProfile, provider string) bool {
provider = strings.ToLower(provider)
// Match on stable identifiers (catalog id, provider name/kind, profile
// name) rather than raw BaseURL substring matching: a Groq profile's
// BaseURL (api.groq.com/openai/v1) contains "openai", so a substring
// check can hand the wrong key to an OpenAI STT lookup and fail auth.
for _, f := range []string{
strings.ToLower(p.CatalogID),
strings.ToLower(p.Provider),
strings.ToLower(string(p.ProviderKind)),
strings.ToLower(p.Name),
} {
if f != "" && strings.Contains(f, provider) {
return true
}
}
// BaseURL: compare the parsed host only, not a raw substring on the
// full URL (which lets "openai" match api.groq.com/openai/v1).
if u, err := neturl.Parse(p.BaseURL); err == nil && u.Host != "" {
if host := strings.ToLower(u.Host); strings.Contains(host, provider) {
return true
}
}
return false
}
-96
View File
@@ -1,96 +0,0 @@
package cli
import (
"errors"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/dictation"
)
func TestFactoryFallsBackToBatchWhenLocalStreamingNotConfigured(t *testing.T) {
t.Setenv("GROQ_API_KEY", "gsk-test")
resolved := config.ResolvedConfig{STT: config.STTConfig{
Provider: config.STTProviderGroq, // cloud batch
// StreamProvider unset → defaults to local, which has no model configured.
}}
factory := newDictationTranscriberFactory(resolved, "", newDictationServerManager(resolved.STT))
tr, streaming, err := factory(resolved.STT, true) // prefer streaming
if err != nil {
t.Fatalf("expected batch fallback, got error: %v", err)
}
if streaming {
t.Error("should have fallen back to batch (streaming=false)")
}
if tr == nil {
t.Error("expected a Groq batch transcriber")
}
}
func TestFactoryCloudProviderIgnoresLeftoverLocalModel(t *testing.T) {
// Regression: pick local once (LocalModelPath gets set), then switch to a
// cloud batch provider. streamProvider stays unset (defaults to "local"), so
// the streaming factory must NOT fire up the sherpa server on the leftover
// path — it should fall back to the cloud batch pipeline the user chose.
t.Setenv("GROQ_API_KEY", "gsk-test")
stt := config.STTConfig{
Provider: config.STTProviderGroq, // cloud batch, chosen now
LocalModelPath: "/leftover/model/dir", // set by an earlier local selection
}
resolved := config.ResolvedConfig{STT: stt}
factory := newDictationTranscriberFactory(resolved, "", newDictationServerManager(stt))
tr, streaming, err := factory(stt, true) // prefer streaming
if err != nil {
t.Fatalf("expected cloud batch fallback, got error: %v", err)
}
if streaming {
t.Error("a cloud provider must not stream through the local sherpa server")
}
if tr == nil {
t.Error("expected a Groq batch transcriber")
}
}
func TestFactorySurfacesExplicitCloudStreamingError(t *testing.T) {
// Deepgram chosen explicitly but no key resolvable → surface the setup error.
resolved := config.ResolvedConfig{STT: config.STTConfig{
StreamProvider: config.STTProviderDeepgram,
}}
factory := newDictationTranscriberFactory(resolved, "", nil)
_, streaming, err := factory(resolved.STT, true)
if err == nil {
t.Fatal("expected a setup error for missing Deepgram key")
}
if !streaming {
t.Error("an explicit cloud streaming choice should report streaming=true even on error")
}
var setupErr *dictation.SetupError
if !errors.As(err, &setupErr) {
t.Errorf("want *SetupError, got %v", err)
}
}
func TestFactoryLocalBatchSetupErrorWhenNoModel(t *testing.T) {
resolved := config.ResolvedConfig{STT: config.STTConfig{Provider: config.STTProviderLocal}}
factory := newDictationTranscriberFactory(resolved, "", newDictationServerManager(resolved.STT))
_, _, err := factory(resolved.STT, true)
var setupErr *dictation.SetupError
if !errors.As(err, &setupErr) {
t.Fatalf("want *SetupError guiding local model setup, got %v", err)
}
}
func TestResolveSTTKeyPrefersConfiguredProvider(t *testing.T) {
resolved := config.ResolvedConfig{
Providers: []config.ProviderProfile{
{Name: "groq", CatalogID: "groq", BaseURL: "https://api.groq.com/openai/v1", APIKey: "gsk-configured"},
},
}
if got := resolveSTTKey(resolved, "", dictation.ProviderGroq, "GROQ_API_KEY"); got != "gsk-configured" {
t.Errorf("key = %q, want the configured provider's key", got)
}
}
+16 -65
View File
@@ -12,7 +12,6 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/errhint"
"github.com/Gitlawb/zero/internal/imageinput"
"github.com/Gitlawb/zero/internal/lsp"
"github.com/Gitlawb/zero/internal/modelregistry"
@@ -136,11 +135,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
return exitSuccess
}
// 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()) }()
// 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
// mode-injected tool filter is validated and reflected in --list-tools, and a
@@ -155,10 +149,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if err != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
}
// trustRoot is the ORIGINAL launch directory, captured before any --worktree
// reassignment below, so a worktree of a trusted repo inherits that repo's
// trust instead of being seen as a fresh untrusted path.
trustRoot := workspaceRoot
if options.worktree {
preparedWorktree, err := deps.prepareWorktree(context.Background(), worktrees.Options{
Cwd: workspaceRoot,
@@ -205,7 +195,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if options.useSpec {
permissionMode = agent.PermissionModeSpecDraft
}
mcpRuntime, mcpSkip, err := registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options), trustRoot)
mcpRuntime, err := registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options))
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error())
}
@@ -214,7 +204,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// registry and collect their hooks + skill roots for the dispatcher and skill
// tool below. Done before --list-tools and filter validation so plugin tools
// are listable and filter-validatable; it fails OPEN (a bad plugin is skipped).
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot)
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr)
if options.useSpec {
specmode.RegisterDraftTools(registry, workspaceRoot, deps.now)
}
@@ -419,8 +409,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
stderr: stderr,
deps: deps,
workspaceRoot: workspaceRoot,
trustRoot: trustRoot,
mcpSkip: mcpSkip,
registry: registry,
modelRegistry: modelRegistry,
resolved: resolved,
@@ -511,31 +499,15 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// by default the corrector is nil, leaving the agent loop byte-identical. When
// on we verify with both the workspace test plan and LSP diagnostics over the
// changed files; the autonomy gate inside the corrector still decides whether
// failures auto-fix or just report. fileDiagnostics (always on, lazy) gives
// edit_file/write_file inline error diagnostics for the file they just wrote.
// lspShutdown tears down any language-server sessions either half spawned.
selfCorrector, fileDiagnostics, lspShutdown := newExecSelfCorrector(options.selfCorrect, workspaceRoot, options.autonomy)
// failures auto-fix or just report. lspShutdown tears down any language-server
// sessions the LSP half spawned (no-op when self-correct is off).
selfCorrector, lspShutdown := newExecSelfCorrector(options.selfCorrect, workspaceRoot, options.autonomy)
defer lspShutdown()
// Kept as a named variable (rather than inlined into Options below) because
// the write tools also use it for conflict checks across a run.
fileTracker := tools.NewFileTracker()
scratchBaseline := scratchFileSnapshot(workspaceRoot)
emitScratchWarning := func() {
if notice := scratchFileWarning(workspaceRoot, scratchBaseline); notice != "" {
writer.warning(notice)
}
}
// Build the hooks dispatcher out of the struct literal so its trust skip report
// can be combined with the plugin activation's, and emit at most one notice when
// project hooks/plugins were dropped for an untrusted workspace.
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot)
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
MaxTurns: resolved.MaxTurns,
ContextWindow: resolveAgentContextWindow(runCtx, modelRegistry, resolved.Provider),
DeferThreshold: effectiveDeferThreshold,
Specialists: specialistRuntime.specialistInfos(),
Skills: pluginActivation.skillInfos(deps.skillsDir()),
SessionID: preparedSession.Session.SessionID,
CallingSessionID: options.callingSessionID,
CallingToolUseID: options.callingToolUseID,
@@ -552,19 +524,17 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
PermissionMode: permissionMode,
Autonomy: options.autonomy,
SelfCorrect: selfCorrector,
FileDiagnostics: fileDiagnostics,
// Headless exec: don't accept a no-tool-call turn as "done" while work
// clearly remains (pending plan items / a mid-step continuation cue) —
// nudge to continue, and finalize as INCOMPLETE rather than false success
// if the model keeps stalling. The interactive TUI leaves this off.
RequireCompletionSignal: true,
Sandbox: sandboxEngine,
FileTracker: fileTracker,
Hooks: hookDispatcher,
FileTracker: tools.NewFileTracker(),
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
EnabledTools: options.enabledTools,
DisabledTools: options.disabledTools,
OnText: writer.text,
OnReasoning: writer.reasoning,
OnToolCall: func(call agent.ToolCall) {
writer.toolCall(call, registry)
sessionRecorder.append(sessions.EventToolCall, map[string]any{
@@ -618,7 +588,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
return exitCrash
}
if err != nil {
emitScratchWarning()
// A Ctrl+C / SIGTERM cancellation is a clean shutdown, not a provider error.
if errors.Is(err, context.Canceled) || runCtx.Err() != nil {
sessionRecorder.append(sessions.EventError, map[string]any{"message": "interrupted"})
@@ -669,10 +638,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if notice := result.TruncationNotice(); notice != "" {
writer.warning(notice)
}
// Surface new scratch/debug-like files left untracked in git when we finish —
// including files created by shell commands that bypass the file tools (issue
// #551). Normal new deliverable files stay quiet to avoid warning fatigue.
emitScratchWarning()
// A headless run the completion gate marked INCOMPLETE (no-tool-call stall,
// self-reported non-completion, or max-turns cutoff) must NOT be reported as a
// success: exit 4 AND a machine-readable terminal event saying so. Handled per
@@ -734,29 +699,23 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// unless a changed file's language actually has one installed on PATH. The
// returned cleanup shuts that manager down (terminating any spawned server
// sessions); it is a no-op when self-correct is off.
func newExecSelfCorrector(enabled bool, workspaceRoot string, autonomy string) (*agent.SelfCorrector, func(context.Context, string) string, func()) {
// The manager is created regardless of --self-correct: it also backs the
// always-on background post-edit diagnostics (agent.NewFileDiagnostics,
// collected off the tool-call path by the loop), and it stays lazy — no
// language server is spawned unless an edited file's language actually has
// one installed on PATH.
manager := lsp.NewManager(workspaceRoot)
cleanup := func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = manager.Shutdown(ctx)
}
fileDiagnostics := agent.NewFileDiagnostics(manager, workspaceRoot)
func newExecSelfCorrector(enabled bool, workspaceRoot string, autonomy string) (*agent.SelfCorrector, func()) {
if !enabled {
return nil, fileDiagnostics, cleanup
return nil, func() {}
}
manager := lsp.NewManager(workspaceRoot)
corrector := agent.NewSelfCorrector(workspaceRoot, agent.NewLSPDiagnosticsChecker(manager), agent.NewProjectVerifier(workspaceRoot), agent.SelfCorrectConfig{
Enabled: true,
IncludeTests: true,
IncludeLSP: true,
Autonomy: autonomy,
})
return corrector, fileDiagnostics, cleanup
cleanup := func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = manager.Shutdown(ctx)
}
return corrector, cleanup
}
func deferredEligibleCount(registry *tools.Registry, permissionMode agent.PermissionMode, enabledTools []string, disabledTools []string) int {
@@ -989,14 +948,6 @@ func writeExecProviderError(stdout io.Writer, stderr io.Writer, format execOutpu
if _, err := fmt.Fprintf(stderr, "[zero] %s\n", message); err != nil {
return exitCrash
}
// Append a one-line next step for recognized provider failures (auth /
// rate-limit / connectivity / …). The classifier gates on a provider-origin
// marker, so non-provider codes (sandbox_error, mcp_error) never draw a hint.
if hint := errhint.CLIHint(errors.New(message)); hint != "" {
if _, err := fmt.Fprintf(stderr, "[zero] %s\n", hint); err != nil {
return exitCrash
}
}
return exitProvider
}
+5 -131
View File
@@ -5,7 +5,6 @@ import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"slices"
"strings"
@@ -150,7 +149,7 @@ func TestRunExecListsMCPToolsWithoutProviderConstruction(t *testing.T) {
providerBuilt = true
return nil, errors.New("provider should not be constructed for --list-tools")
},
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
if workspaceRoot != cwd {
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
}
@@ -193,7 +192,7 @@ func TestRunExecLogsMCPRuntimeCloseError(t *testing.T) {
getwd: func() (string, error) {
return cwd, nil
},
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
if workspaceRoot != cwd {
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
}
@@ -530,45 +529,6 @@ func TestRunExecStreamJSONRunStartUsesResolvedAPIModel(t *testing.T) {
}
}
func TestRunExecStreamJSONEmitsReasoningEvents(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
cwd := t.TempDir()
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"exec", "--output-format", "stream-json", "think then answer"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return reasoningExecProvider{}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
events := decodeJSONLines(t, stdout.String())
reasoningIdx, reasoning := findJSONEventIndex(t, events, "reasoning")
if reasoning["delta"] != "Thinking. " {
t.Fatalf("unexpected reasoning event: %#v", reasoning)
}
textIdx, text := findJSONEventIndex(t, events, "text")
if text["delta"] != "done" {
t.Fatalf("unexpected text event: %#v", text)
}
if reasoningIdx >= textIdx {
t.Fatalf("expected reasoning event before text event, got indices %d and %d", reasoningIdx, textIdx)
}
final := findJSONEvent(t, events, "final")
if final["text"] != "done" {
t.Fatalf("reasoning must not be folded into final answer: %#v", final)
}
}
func TestExecEventWriterTruncatesStreamJSONToolResults(t *testing.T) {
var stdout bytes.Buffer
writer := execEventWriter{
@@ -713,48 +673,6 @@ func TestRunExecJSONInterruptedEmitsTerminalEvents(t *testing.T) {
}
}
func TestRunExecWarnsAboutScratchFileWhenProviderErrors(t *testing.T) {
dataHome := t.TempDir()
t.Setenv("XDG_DATA_HOME", dataHome)
cwd := t.TempDir()
runGitForScratchTest(t, cwd, "init")
if err := os.WriteFile(filepath.Join(cwd, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGitForScratchTest(t, cwd, "add", "README.md")
runGitForScratchTest(t, cwd, "commit", "-m", "init")
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"exec", "--output-format", "stream-json", "write scratch then fail"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return toolThenErrorExecProvider{
toolCallID: "call_scratch",
toolName: "write_file",
arguments: `{"path":"_debug.py","content":"print('debug')"}`,
err: errors.New("provider failed after tool"),
}, nil
},
newSandboxStore: func() (*sandbox.GrantStore, error) {
return sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
},
})
if exitCode != exitProvider {
t.Fatalf("expected provider exit %d, got %d: stdout=%q stderr=%q", exitProvider, exitCode, stdout.String(), stderr.String())
}
events := decodeJSONLines(t, stdout.String())
warning := findJSONEvent(t, events, "warning")
message, _ := warning["message"].(string)
if !strings.Contains(message, "_debug.py") {
t.Fatalf("expected scratch warning to mention _debug.py, got %#v", warning)
}
}
func TestRunExecReadsStreamJSONPromptFromStdin(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
cwd := t.TempDir()
@@ -833,24 +751,6 @@ type toolCallingExecProvider struct {
answer string
}
type toolThenErrorExecProvider struct {
toolCallID string
toolName string
arguments string
err error
}
type reasoningExecProvider struct{}
func (reasoningExecProvider) StreamCompletion(context.Context, zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
ch := make(chan zeroruntime.StreamEvent, 3)
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventReasoning, Content: "Thinking. "}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
func (provider toolCallingExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
for _, message := range request.Messages {
if message.Role == zeroruntime.MessageRoleTool {
@@ -875,41 +775,15 @@ func (provider toolCallingExecProvider) StreamCompletion(ctx context.Context, re
return ch, nil
}
func (provider toolThenErrorExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
for _, message := range request.Messages {
if message.Role == zeroruntime.MessageRoleTool {
return nil, provider.err
}
}
ch := make(chan zeroruntime.StreamEvent, 4)
select {
case <-ctx.Done():
close(ch)
return ch, ctx.Err()
case ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: provider.toolCallID, ToolName: provider.toolName}:
}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: provider.toolCallID, ArgumentsFragment: provider.arguments}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: provider.toolCallID}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
func findJSONEvent(t *testing.T, events []map[string]any, eventType string) map[string]any {
t.Helper()
_, event := findJSONEventIndex(t, events, eventType)
return event
}
func findJSONEventIndex(t *testing.T, events []map[string]any, eventType string) (int, map[string]any) {
t.Helper()
for idx, event := range events {
for _, event := range events {
if event["type"] == eventType {
return idx, event
return event
}
}
t.Fatalf("event %q not found in %#v", eventType, events)
return -1, nil
return nil
}
func findSessionEvent(t *testing.T, events []sessions.Event, eventType sessions.EventType) sessions.Event {
-50
View File
@@ -1,50 +0,0 @@
package cli
import (
"bytes"
"strings"
"testing"
)
// A recognized provider error in text mode prints the raw message plus a one-line
// actionable hint; a non-provider error (or JSON mode) prints no hint.
func TestWriteExecProviderErrorAppendsHint(t *testing.T) {
t.Run("provider auth error gets a hint", func(t *testing.T) {
var stdout, stderr bytes.Buffer
code := writeExecProviderError(&stdout, &stderr, execOutputText, "provider_error",
"auth error: your API key is missing or invalid")
if code != exitProvider {
t.Fatalf("exit code = %d, want exitProvider", code)
}
out := stderr.String()
if !strings.Contains(out, "auth error:") {
t.Fatalf("expected raw message, got %q", out)
}
if !strings.Contains(out, "zero setup") || !strings.Contains(out, "zero auth openrouter") {
t.Fatalf("expected an actionable setup/auth hint, got %q", out)
}
})
t.Run("non-provider error gets no hint", func(t *testing.T) {
var stdout, stderr bytes.Buffer
writeExecProviderError(&stdout, &stderr, execOutputText, "sandbox_error",
"sandbox setup failed: permission denied")
out := stderr.String()
// Exactly one "[zero]" line — no spurious hint attached to a local error.
if n := strings.Count(out, "[zero]"); n != 1 {
t.Fatalf("expected exactly one [zero] line for a non-provider error, got %d:\n%s", n, out)
}
})
t.Run("json mode never appends a hint line", func(t *testing.T) {
var stdout, stderr bytes.Buffer
writeExecProviderError(&stdout, &stderr, execOutputJSON, "provider_error",
"auth error: bad key")
if stderr.Len() != 0 {
t.Fatalf("json mode must not write to stderr, got %q", stderr.String())
}
if strings.Contains(stdout.String(), "zero setup") {
t.Fatalf("json mode must not inject a hint into the structured payload, got %q", stdout.String())
}
})
}
+6 -21
View File
@@ -21,19 +21,11 @@ import (
)
type execSpecDraftRun struct {
options execOptions
stdout io.Writer
stderr io.Writer
deps appDeps
workspaceRoot string
// trustRoot is the ORIGINAL launch directory (captured before any --worktree
// reassignment), so a --use-spec run inside a --worktree of a trusted repo
// still keys the trust check on the source repo, not the generated worktree path.
trustRoot string
// mcpSkip is the trust verdict from the workspace MCP registration in runExec
// (which runs before this spec-draft path), so the spec-draft notice can report a
// dropped project MCP layer instead of leaving it silent.
mcpSkip trustSkip
options execOptions
stdout io.Writer
stderr io.Writer
deps appDeps
workspaceRoot string
registry *tools.Registry
modelRegistry modelregistry.Registry
resolved config.ResolvedConfig
@@ -104,13 +96,6 @@ func runExecSpecDraft(run execSpecDraftRun) int {
var draftInfo execSpecDraftInfo
runCtx, stopSignals := signalContext()
defer stopSignals()
// The spec-draft path activates no plugins, so the plugin skip is omitted. Trust
// keys on run.trustRoot (the original launch dir), not run.workspaceRoot, which
// may be a --worktree path; this keeps a --use-spec --worktree run of a trusted
// repo trusted. Emit at most one notice when project hooks or the project MCP
// layer (registered earlier in runExec, carried in run.mcpSkip) were dropped.
hookDispatcher, hookSkip := newHookDispatcher(run.workspaceRoot, run.trustRoot)
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),
@@ -127,7 +112,7 @@ func runExecSpecDraft(run execSpecDraftRun) int {
Autonomy: "low",
Sandbox: run.sandboxEngine,
FileTracker: tools.NewFileTracker(),
Hooks: hookDispatcher,
Hooks: newHookDispatcher(run.workspaceRoot),
EnabledTools: run.options.enabledTools,
DisabledTools: run.options.disabledTools,
OnText: writer.text,
+1 -1
View File
@@ -51,7 +51,7 @@ func TestRunExecUseSpecCreatesDraftSession(t *testing.T) {
MaxTurns: 3,
}, nil
},
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
resolveMCPConfig: func(string) (config.MCPConfig, error) {
return config.MCPConfig{}, nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
-10
View File
@@ -80,16 +80,6 @@ func (writer *execEventWriter) text(delta string) {
writer.writeStdout(delta)
}
func (writer *execEventWriter) reasoning(delta string) {
if writer.format == execOutputJSON {
writer.writeJSON(map[string]any{"type": "reasoning", "delta": delta})
return
}
if writer.format == execOutputStreamJSON {
writer.writeStreamJSON(streamjson.Event{Type: streamjson.EventReasoning, RunID: writer.runID, Delta: delta})
}
}
func (writer *execEventWriter) toolCall(call agent.ToolCall, registry *tools.Registry) {
if writer.format == execOutputJSON {
writer.writeJSON(map[string]any{
+2 -14
View File
@@ -205,9 +205,7 @@ func runMCPLegacyList(args []string, stdout io.Writer, stderr io.Writer, deps ap
if err != nil {
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash)
}
// Enumeration for the extensions listing, never spawns a server, so it is left
// ungated (excludeProject=false) like the other doctor/status report sites.
cfg, err := deps.resolveMCPConfig(cwd, false)
cfg, err := deps.resolveMCPConfig(cwd)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
@@ -288,21 +286,11 @@ func runMCPTools(ctx context.Context, args []string, stdout io.Writer, stderr io
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash)
}
registry := tools.NewRegistry()
// `mcp tools list` connects to (spawns) each server to enumerate its live tools,
// so it is a spawn site and gates the project layer behind the trust check. No
// --worktree reassignment on this command path, so trustRoot == cwd.
mcpRuntime, mcpSkip, err := registerMCPToolsForWorkspace(ctx, cwd, registry, deps, mcp.AutonomyLow, cwd)
mcpRuntime, err := registerMCPToolsForWorkspace(ctx, cwd, registry, deps, mcp.AutonomyLow)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
defer closeMCPRuntime(stderr, mcpRuntime)
// Surface the trust skip on stderr so an empty (or short) list in an untrusted
// workspace is explained rather than read as "nothing configured". The notice
// goes to stderr, leaving the list (text or --json) on stdout intact.
emitTrustNotice(stderr, mcpSkip)
// Surface the trust skip on stderr so an empty (or short) list in an untrusted
// workspace is explained rather than read as "nothing configured". The notice
// goes to stderr, leaving the list (text or --json) on stdout intact.
items := mcpToolList(registry)
if options.json {
payload := struct {
+2 -2
View File
@@ -204,7 +204,7 @@ func TestRunMCPToolsListJSONAndText(t *testing.T) {
closeCalls := 0
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
if workspaceRoot != cwd {
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
}
@@ -264,7 +264,7 @@ func TestRunMCPLegacyListAliases(t *testing.T) {
closeCalls := 0
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
if workspaceRoot != cwd {
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
}
+6 -101
View File
@@ -1,63 +1,17 @@
package cli
import (
"fmt"
"io"
"os"
"path/filepath"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/workspacetrust"
)
// trustSkip reports whether a runtime chokepoint dropped the project layer because
// the workspace was not trusted, so the caller can emit a single combined notice.
// Both chokepoints (hooks and plugins) return one, and the caller ORs them.
type trustSkip struct {
// excludedProjectConfig is true when the project layer was excluded AND a
// project config for this surface actually exists on disk (so a notice is
// worth showing). It stays false in a trusted workspace, or when the
// workspace has no project config to skip.
excludedProjectConfig bool
// trustCheckErrored is true when the exclusion was caused by a trust-store
// read error rather than a clean untrusted verdict, so the caller can name
// the error in its notice.
trustCheckErrored bool
}
// resolveTrust computes the fail-closed trust verdict for a chokepoint. It treats
// any error OR an empty trustRoot as untrusted, so a forgotten or future call site
// cannot fail open. It returns whether the project layer should be excluded and
// whether the decision was driven by a store-read error.
//
// Each chokepoint (hooks and plugins) calls this itself rather than the caller
// resolving trust once and passing a bool down. That is deliberate: keeping the
// check inside the chokepoint is what makes the gate fail-closed by construction.
// Do NOT hoist it to the callers behind an excludeProject bool whose zero value
// includes the project layer, that reintroduces a fail-open default. The extra
// store read per session is negligible.
func resolveTrust(trustRoot string) (excludeProject bool, trustCheckErrored bool) {
if trustRoot == "" {
return true, false
}
trusted, err := workspacetrust.IsTrusted(trustRoot)
if err != nil {
return true, true
}
return !trusted, false
}
// newHookDispatcher builds the per-session hooks dispatcher for a workspace,
// merging user + project hooks.json and wiring the audit store. It fails OPEN:
// any load or setup error yields a nil dispatcher, which Dispatch treats as a
// no-op, so a malformed hooks config can never wedge tool execution. With no
// hooks configured the dispatcher selects nothing and runs no commands, so the
// hot path stays free of overhead until a user opts in via hooks.json.
//
// trustRoot is the original launch directory (resolved before any --worktree
// reassignment); the project layer loads only when that root is trusted.
func newHookDispatcher(workspaceRoot string, trustRoot string) (*hooks.Dispatcher, trustSkip) {
return newHookDispatcherWithExtra(workspaceRoot, nil, trustRoot)
func newHookDispatcher(workspaceRoot string) *hooks.Dispatcher {
return newHookDispatcherWithExtra(workspaceRoot, nil)
}
// newHookDispatcherWithExtra builds the dispatcher like newHookDispatcher but also
@@ -66,22 +20,10 @@ func newHookDispatcher(workspaceRoot string, trustRoot string) (*hooks.Dispatche
// appended after the configured hooks; their ids are plugin-namespaced (plugin
// id + hook name) so they never collide with hooks.json ids. A nil/empty extra
// slice is byte-equivalent to newHookDispatcher.
//
// The trust check lives here, inside the chokepoint, so no caller can bypass it:
// the project layer is dropped (ExcludeProject) whenever trustRoot is empty, the
// trust store cannot be read, or the workspace is not trusted (fail-closed). The
// returned trustSkip lets the caller emit one combined notice; the notice itself
// is NOT emitted here.
func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition, trustRoot string) (*hooks.Dispatcher, trustSkip) {
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
skip := trustSkip{
excludedProjectConfig: excludeProject && projectHooksFileExists(workspaceRoot),
trustCheckErrored: trustCheckErrored,
}
loaded, err := hooks.LoadConfig(hooks.LoadOptions{Cwd: workspaceRoot, ExcludeProject: excludeProject})
func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition) *hooks.Dispatcher {
loaded, err := hooks.LoadConfig(hooks.LoadOptions{Cwd: workspaceRoot})
if err != nil {
return nil, skip
return nil
}
var audit *hooks.AuditStore
if store, err := hooks.NewAuditStore(hooks.AuditStoreOptions{}); err == nil {
@@ -110,42 +52,5 @@ func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition,
Config: config,
Audit: audit,
Cwd: workspaceRoot,
}), skip
}
// projectHooksFileExists reports whether a ./.zero/hooks.json is present under
// workspaceRoot, so the caller only notices about config it actually skipped.
func projectHooksFileExists(workspaceRoot string) bool {
if workspaceRoot == "" {
return false
}
info, err := os.Stat(filepath.Join(workspaceRoot, ".zero", "hooks.json"))
return err == nil && !info.IsDir()
}
// emitTrustNotice writes at most one stderr line summarizing that project-scoped
// hooks, plugins, and/or MCP servers were skipped in an untrusted workspace. It
// takes the skip report from each trust-gated surface (hooks, plugins, MCP) and ORs
// them, so a workspace that only drops one surface still gets the notice. It is
// computed once per session by the caller (each session-setup site runs once), so it
// is naturally once-per-process. When any surface's skip was a trust-store read
// error, the notice names that so a transient config-dir problem is diagnosable.
func emitTrustNotice(stderr io.Writer, skips ...trustSkip) {
if stderr == nil {
return
}
excluded := false
storeErrored := false
for _, skip := range skips {
excluded = excluded || skip.excludedProjectConfig
storeErrored = storeErrored || skip.trustCheckErrored
}
if !excluded {
return
}
if storeErrored {
_, _ = fmt.Fprintln(stderr, "zero: the workspace-trust store could not be read; ignoring project hooks/plugins/MCP servers (fail-closed). Run 'zero trust' to enable.")
return
}
_, _ = fmt.Fprintln(stderr, "zero: ignoring project hooks/plugins/MCP servers in an untrusted workspace. Run 'zero trust' to enable.")
})
}
+3 -3
View File
@@ -519,7 +519,7 @@ func TestRunMCPListRedactsURLCredentialsAndSensitiveQueryParams(t *testing.T) {
commandSecret := "sk-proj-" + strings.Repeat("a", 24)
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
if workspaceRoot != cwd {
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
}
@@ -566,7 +566,7 @@ func TestRunMCPCheckRegistersOnlyRequestedServer(t *testing.T) {
exitCode := runWithDeps([]string{"mcp", "check", "docs", "--json"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
if workspaceRoot != cwd {
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
}
@@ -618,7 +618,7 @@ func TestRunMCPCheckClosesRuntimeReturnedWithError(t *testing.T) {
exitCode := runWithDeps([]string{"mcp", "check", "docs"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
}}, nil
+1 -35
View File
@@ -29,27 +29,6 @@ type mcpWritableConfig struct {
serverRaw map[string]json.RawMessage
}
// projectMCPConfigExists reports whether the workspace's project ./.zero/config.json
// declares any MCP servers, so the trust notice fires only when project MCP config was
// actually skipped (mirroring projectHooksFileExists / projectPluginsDirExists). A
// missing or unparseable file, or one that declares no servers, returns false: there
// is nothing to notice about. This only reads the file; it never spawns a server, so
// it is safe to call on an untrusted workspace.
func projectMCPConfigExists(workspaceRoot string) bool {
if workspaceRoot == "" {
return false
}
data, err := os.ReadFile(filepath.Join(workspaceRoot, ".zero", "config.json"))
if err != nil {
return false
}
var fc config.FileConfig
if err := json.Unmarshal(data, &fc); err != nil {
return false
}
return len(fc.MCP.Servers) > 0
}
func runMCPAdd(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseMCPAddArgs(args)
if err != nil {
@@ -264,25 +243,12 @@ func runMCPCheck(ctx context.Context, args []string, stdout io.Writer, stderr io
if err != nil {
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash)
}
// `mcp check` connects to (spawns) the named server below to enumerate its tools,
// so it is a spawn site: gate the project layer behind the trust check (fail-closed)
// so a cloned repo cannot have `zero mcp check <its-server>` run its command. No
// --worktree reassignment on this command path, so trustRoot == cwd.
mcpExcludeProject, trustCheckErrored := resolveTrust(cwd)
cfg, err := deps.resolveMCPConfig(cwd, mcpExcludeProject)
cfg, err := deps.resolveMCPConfig(cwd)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
raw, ok := cfg.Servers[serverName]
if !ok {
// The server may be missing because the project layer was gated out in an
// untrusted workspace; emit the same one-line notice the other spawn sites use so
// a dropped project server reads as "run zero trust", not a bare miss. The notice
// self-gates: it stays silent unless a project MCP config was actually excluded.
emitTrustNotice(stderr, trustSkip{
excludedProjectConfig: mcpExcludeProject && projectMCPConfigExists(cwd),
trustCheckErrored: trustCheckErrored,
})
return writeAppError(stderr, fmt.Sprintf("MCP server %q is not configured", serverName), exitCrash)
}
if raw.Disabled {
+11 -29
View File
@@ -55,12 +55,8 @@ func runMCPOAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps ap
}
serverName := positional[0]
server, skip, err := resolveOAuthServer(deps, serverName)
server, err := resolveOAuthServer(deps, serverName)
if err != nil {
// When the named server was dropped because the workspace is untrusted, explain
// that with the same one-line notice the other trust-gated MCP paths emit, so a
// gated project OAuth server reads as "run zero trust", not a bare miss.
emitTrustNotice(stderr, skip)
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
@@ -197,47 +193,33 @@ func filterTokenStatuses(statuses []mcp.TokenStatus, serverName string) []mcp.To
}
// resolveOAuthServer loads the workspace MCP config and returns the named server
// after verifying it declares OAuth authentication. It also returns a trustSkip so the
// caller can emit the workspace-trust notice when a project server was dropped.
//
// Login is gated behind workspace trust (fail-closed) like the stdio spawn paths: it
// loads a project-scoped MCP server and drives its OAuth flow (endpoint discovery,
// dynamic client registration, code exchange, token persist) against the server's
// configured URL. That is a project-scoped server being activated and contacted, so an
// untrusted clone's ./.zero/config.json OAuth server must be dropped rather than loaded —
// leaving it ungated would let a cloned repo initiate outbound OAuth I/O and persist a
// token. There is no --worktree reassignment on this command path, so trustRoot == cwd.
func resolveOAuthServer(deps appDeps, serverName string) (mcp.Server, trustSkip, error) {
// after verifying it declares OAuth authentication.
func resolveOAuthServer(deps appDeps, serverName string) (mcp.Server, error) {
if err := mcp.ValidateServerName(serverName); err != nil {
return mcp.Server{}, trustSkip{}, err
return mcp.Server{}, err
}
cwd, err := deps.getwd()
if err != nil {
return mcp.Server{}, trustSkip{}, fmt.Errorf("failed to resolve workspace: %w", err)
return mcp.Server{}, fmt.Errorf("failed to resolve workspace: %w", err)
}
excludeProject, trustCheckErrored := resolveTrust(cwd)
skip := trustSkip{
excludedProjectConfig: excludeProject && projectMCPConfigExists(cwd),
trustCheckErrored: trustCheckErrored,
}
cfg, err := deps.resolveMCPConfig(cwd, excludeProject)
cfg, err := deps.resolveMCPConfig(cwd)
if err != nil {
return mcp.Server{}, skip, err
return mcp.Server{}, err
}
servers, err := mcp.NormalizeConfig(cfg)
if err != nil {
return mcp.Server{}, skip, err
return mcp.Server{}, err
}
for _, server := range servers {
if server.Name != serverName {
continue
}
if !strings.EqualFold(server.Auth, mcp.ServerAuthOAuth) {
return mcp.Server{}, skip, fmt.Errorf("MCP server %q does not declare auth: \"oauth\"", serverName)
return mcp.Server{}, fmt.Errorf("MCP server %q does not declare auth: \"oauth\"", serverName)
}
return server, skip, nil
return server, nil
}
return mcp.Server{}, skip, fmt.Errorf("MCP server %q is not configured", serverName)
return mcp.Server{}, fmt.Errorf("MCP server %q is not configured", serverName)
}
func oauthConfigForServer(server mcp.Server) mcp.OAuthConfig {
+2 -284
View File
@@ -6,17 +6,14 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/workspacetrust"
)
// syncBuffer is a goroutine-safe writer used when a background goroutine reads
@@ -125,7 +122,7 @@ func TestRunMCPOAuthLoginStoresTokens(t *testing.T) {
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
newMCPTokenStore: func() (*mcp.TokenStore, error) { return store, nil },
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"remote": {
Type: "http",
@@ -197,7 +194,7 @@ func TestRunMCPOAuthLoginRejectsNonOAuthServer(t *testing.T) {
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
newMCPTokenStore: func() (*mcp.TokenStore, error) { return store, nil },
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"plain": {Type: "http", URL: "https://plain.invalid/mcp"},
}}, nil
@@ -213,285 +210,6 @@ func TestRunMCPOAuthLoginRejectsNonOAuthServer(t *testing.T) {
}
}
// TestRunMCPOAuthLoginGatedInUntrustedWorkspace is the load-bearing security test for
// the OAuth login gate: an OAuth MCP server defined ONLY in an untrusted workspace's
// ./.zero/config.json must be dropped before login. `zero mcp oauth login` must refuse
// with "not configured", surface the trust notice, and store NO token — so the
// discovery/registration/token-exchange flow never fires against the project URL. If the
// gate is reverted to resolveMCPConfig(cwd, false), the server survives, login proceeds,
// a token is stored, and every assertion below fails.
func TestRunMCPOAuthLoginGatedInUntrustedWorkspace(t *testing.T) {
setTrustConfigRoot(t) // isolates the trust store; the workspace stays untrusted
// A live server standing in for the untrusted project's OAuth server. A gated login
// must never reach it, so hits must stay 0 — a direct assertion of no outbound I/O
// (discovery/registration/token exchange) against a cloned repo's configured URL. If
// the gate leaks, mcp.Login contacts this URL and the hit count rises.
var hits atomic.Int64
oauthSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hits.Add(1)
w.WriteHeader(http.StatusNotFound)
}))
defer oauthSrv.Close()
cwd := t.TempDir()
// A real project MCP config on disk so projectMCPConfigExists() is true and the
// notice fires. resolveMCPConfig is faked, but the notice detector reads disk.
if err := os.MkdirAll(filepath.Join(cwd, ".zero"), 0o700); err != nil {
t.Fatal(err)
}
body := `{"mcp":{"servers":{"remote":{"type":"http","url":"` + oauthSrv.URL + `","auth":"oauth"}}}}`
if err := os.WriteFile(filepath.Join(cwd, ".zero", "config.json"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
store, err := mcp.NewTokenStore(mcp.TokenStoreOptions{
FilePath: filepath.Join(t.TempDir(), "mcp-oauth-tokens.json"),
})
if err != nil {
t.Fatalf("NewTokenStore() error = %v", err)
}
// Points every OAuth endpoint at the live server: if the gate leaks and login
// proceeds, the flow contacts oauthSrv and hits.Load() becomes non-zero.
projectServer := config.MCPServerConfig{
Type: "http",
URL: oauthSrv.URL,
Auth: "oauth",
OAuth: &config.MCPOAuthConfig{
ClientID: "client-123",
AuthorizationEndpoint: oauthSrv.URL + "/authorize",
TokenEndpoint: oauthSrv.URL + "/token",
Scopes: []string{"read"},
},
}
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
newMCPTokenStore: func() (*mcp.TokenStore, error) { return store, nil },
resolveMCPConfig: func(_ string, excludeProject bool) (config.MCPConfig, error) {
servers := map[string]config.MCPServerConfig{}
if !excludeProject {
servers["remote"] = projectServer
}
return config.MCPConfig{Servers: servers}, nil
},
now: time.Now,
}
// The gate must short-circuit BEFORE the interactive OAuth flow. Bound the run so a
// regression that drops the gate (login proceeds and blocks on the loopback callback)
// fails here in 10s instead of hanging until the package test timeout.
stdout := &syncBuffer{}
stderr := &syncBuffer{}
done := make(chan int, 1)
go func() {
done <- runWithDeps([]string{"mcp", "oauth", "login", "remote"}, stdout, stderr, deps)
}()
var exitCode int
select {
case exitCode = <-done:
case <-time.After(10 * time.Second):
t.Fatalf("login was not gated; it blocked on the OAuth flow — the untrusted gate is missing. stderr=%q", stderr.String())
}
if exitCode == exitSuccess {
t.Fatalf("login on a project-only OAuth server in an untrusted workspace must fail; stdout=%q stderr=%q", stdout.String(), stderr.String())
}
if !strings.Contains(stderr.String(), "not configured") {
t.Fatalf("stderr must report the server as not configured, got %q", stderr.String())
}
if !strings.Contains(stderr.String(), "MCP servers") || !strings.Contains(stderr.String(), "zero trust") {
t.Fatalf("stderr must surface the workspace-trust notice, got %q", stderr.String())
}
if _, ok, _ := store.Load("remote"); ok {
t.Fatal("no token must be stored when login is gated")
}
if got := hits.Load(); got != 0 {
t.Fatalf("gated login must never contact the project OAuth server URL, got %d hit(s)", got)
}
}
// TestResolveOAuthServerTrustedWorkspaceReturnsServer proves the gate does not
// over-block: in a TRUSTED workspace the project OAuth server resolves normally with a
// clean skip, so login proceeds as before. Calls resolveOAuthServer directly to avoid
// driving the full interactive login flow.
func TestResolveOAuthServerTrustedWorkspaceReturnsServer(t *testing.T) {
setTrustConfigRoot(t)
cwd := t.TempDir()
if err := workspacetrust.Trust(cwd); err != nil {
t.Fatalf("Trust(cwd): %v", err)
}
var gotExclude bool
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(_ string, excludeProject bool) (config.MCPConfig, error) {
gotExclude = excludeProject
servers := map[string]config.MCPServerConfig{}
if !excludeProject {
servers["remote"] = oauthTestServerConfig()
}
return config.MCPConfig{Servers: servers}, nil
},
}
server, skip, err := resolveOAuthServer(deps, "remote")
if err != nil {
t.Fatalf("trusted workspace must resolve the project OAuth server, got err %v", err)
}
if gotExclude {
t.Fatalf("trusted workspace must resolve MCP config with excludeProject=false")
}
if server.Name != "remote" {
t.Fatalf("server.Name = %q, want remote", server.Name)
}
if skip.excludedProjectConfig {
t.Fatalf("trusted workspace must not report an excluded project config")
}
}
// TestResolveOAuthServerGatedInUntrustedWorkspace is the fast load-bearing guard for the
// gate: in an untrusted workspace a project-only OAuth server is dropped, so resolution
// fails with "not configured" and flags the excluded project config for the notice.
// Reverting the gate to resolveMCPConfig(cwd, false) makes the server resolve and this
// test fails immediately (no 10s login-flow timeout needed).
func TestResolveOAuthServerGatedInUntrustedWorkspace(t *testing.T) {
setTrustConfigRoot(t)
cwd := t.TempDir()
if err := os.MkdirAll(filepath.Join(cwd, ".zero"), 0o700); err != nil {
t.Fatal(err)
}
body := `{"mcp":{"servers":{"remote":{"type":"http","url":"https://remote.invalid/mcp","auth":"oauth"}}}}`
if err := os.WriteFile(filepath.Join(cwd, ".zero", "config.json"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(_ string, excludeProject bool) (config.MCPConfig, error) {
servers := map[string]config.MCPServerConfig{}
if !excludeProject {
servers["remote"] = oauthTestServerConfig()
}
return config.MCPConfig{Servers: servers}, nil
},
}
_, skip, err := resolveOAuthServer(deps, "remote")
if err == nil {
t.Fatal("untrusted workspace must not resolve a project-only OAuth server")
}
if !strings.Contains(err.Error(), "not configured") {
t.Fatalf("want not-configured error, got %v", err)
}
if !skip.excludedProjectConfig {
t.Fatal("a dropped project OAuth config must flag excludedProjectConfig for the notice")
}
if skip.trustCheckErrored {
t.Fatal("a clean untrusted verdict is not a store-read error")
}
}
func oauthTestServerConfig() config.MCPServerConfig {
return config.MCPServerConfig{
Type: "http",
URL: "https://remote.invalid/mcp",
Auth: "oauth",
OAuth: &config.MCPOAuthConfig{
ClientID: "client-123",
AuthorizationEndpoint: "https://remote.invalid/authorize",
TokenEndpoint: "https://remote.invalid/token",
Scopes: []string{"read"},
},
}
}
// TestRunMCPOAuthLoginTrustedProjectServerStoresToken proves the gate does not over-block
// end-to-end: in a TRUSTED workspace, a project-only OAuth server (returned by the fake
// ONLY when excludeProject is false) is resolved, the full login flow runs, and the token
// is persisted with exitSuccess. This exercises the trusted-resolve + login-success path
// as one execution; if trust were dropped, excludeProject would be true, the fake would
// drop the server, and login would fail with "not configured".
func TestRunMCPOAuthLoginTrustedProjectServerStoresToken(t *testing.T) {
setTrustConfigRoot(t)
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "access-final",
"refresh_token": "refresh-final",
"token_type": "Bearer",
"expires_in": 3600,
})
}))
defer tokenServer.Close()
cwd := t.TempDir()
if err := workspacetrust.Trust(cwd); err != nil {
t.Fatalf("Trust(cwd): %v", err)
}
store, err := mcp.NewTokenStore(mcp.TokenStoreOptions{
FilePath: filepath.Join(t.TempDir(), "mcp-oauth-tokens.json"),
})
if err != nil {
t.Fatalf("NewTokenStore() error = %v", err)
}
projectServer := config.MCPServerConfig{
Type: "http",
URL: "https://remote.invalid/mcp",
Auth: "oauth",
OAuth: &config.MCPOAuthConfig{
ClientID: "client-123",
AuthorizationEndpoint: "https://remote.invalid/authorize",
TokenEndpoint: tokenServer.URL,
Scopes: []string{"read"},
},
}
var gotExclude bool
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
newMCPTokenStore: func() (*mcp.TokenStore, error) { return store, nil },
resolveMCPConfig: func(_ string, excludeProject bool) (config.MCPConfig, error) {
gotExclude = excludeProject
servers := map[string]config.MCPServerConfig{}
if !excludeProject {
servers["remote"] = projectServer
}
return config.MCPConfig{Servers: servers}, nil
},
now: time.Now,
}
stdout := &syncBuffer{}
stderr := &syncBuffer{}
go func() {
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
callbackURL := extractCallbackURL(stdout.String())
if callbackURL != "" {
_, _ = http.Get(callbackURL)
return
}
time.Sleep(5 * time.Millisecond)
}
}()
done := make(chan int, 1)
go func() {
done <- runWithDeps([]string{"mcp", "oauth", "login", "remote"}, stdout, stderr, deps)
}()
var exitCode int
select {
case exitCode = <-done:
case <-time.After(10 * time.Second):
t.Fatalf("trusted login did not complete within 10s; stderr=%s stdout=%s", stderr.String(), stdout.String())
}
if exitCode != exitSuccess {
t.Fatalf("trusted login must succeed; exit=%d stderr=%s", exitCode, stderr.String())
}
if gotExclude {
t.Fatal("trusted workspace must resolve MCP config with excludeProject=false")
}
token, ok, err := store.Load("remote")
if err != nil || !ok {
t.Fatalf("token must be stored on trusted login; ok=%v err=%v", ok, err)
}
if token.AccessToken != "access-final" {
t.Fatalf("stored token = %#v", token)
}
}
func TestRunMCPOAuthUnknownSubcommand(t *testing.T) {
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"mcp", "oauth", "bogus"}, &stdout, &stderr, appDeps{})
+6 -22
View File
@@ -19,38 +19,22 @@ type mcpToolListItem struct {
Permission string `json:"permission"`
}
// registerMCPToolsForWorkspace resolves and registers the workspace's MCP servers.
// This spawns stdio servers, so it gates the project config layer behind the
// workspace-trust check: trustRoot is the ORIGINAL launch directory (resolved before
// any --worktree reassignment) so a worktree of a trusted repo inherits that trust.
// resolveTrust fails closed, so an empty trustRoot or a store-read error excludes the
// project layer and a cloned repo cannot spawn its ./.zero/config.json MCP servers.
//
// It returns a trustSkip alongside the runtime so the caller can fold the MCP gate
// into the one-line trust notice (mirroring the hooks and plugins chokepoints);
// otherwise a workspace whose only project config is MCP would be gated silently.
func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, registry *tools.Registry, deps appDeps, autonomy mcp.PermissionAutonomy, trustRoot string) (mcpToolRuntime, trustSkip, error) {
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
skip := trustSkip{
excludedProjectConfig: excludeProject && projectMCPConfigExists(workspaceRoot),
trustCheckErrored: trustCheckErrored,
}
cfg, err := deps.resolveMCPConfig(workspaceRoot, excludeProject)
func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, registry *tools.Registry, deps appDeps, autonomy mcp.PermissionAutonomy) (mcpToolRuntime, error) {
cfg, err := deps.resolveMCPConfig(workspaceRoot)
if err != nil {
return nil, skip, err
return nil, err
}
if len(cfg.Servers) == 0 {
return noopMCPRuntime{}, skip, nil
return noopMCPRuntime{}, nil
}
store, err := deps.newMCPStore()
if err != nil {
return nil, skip, err
return nil, err
}
runtime, err := deps.registerMCPTools(ctx, registry, cfg, mcp.RegisterOptions{
return deps.registerMCPTools(ctx, registry, cfg, mcp.RegisterOptions{
PermissionStore: store,
Autonomy: autonomy,
})
return runtime, skip, err
}
func execMCPAutonomy(options execOptions) mcp.PermissionAutonomy {
-221
View File
@@ -1,221 +0,0 @@
package cli
// Edge-case coverage for the MCP trust gate on the oauth-login and check paths:
// the error-return contract of the gated resolveOAuthServer, the trust-store-read-error
// notice variant, and the accepted (advisory) notice over-emission on the not-oauth path.
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
)
// writeProjectMCPConfig drops a ./.zero/config.json under dir declaring one MCP server,
// so projectMCPConfigExists() reads it as present.
func writeProjectMCPConfig(t *testing.T, dir string) {
t.Helper()
if err := os.MkdirAll(filepath.Join(dir, ".zero"), 0o700); err != nil {
t.Fatal(err)
}
body := `{"mcp":{"servers":{"proj":{"type":"stdio","command":"proj-cmd"}}}}`
if err := os.WriteFile(filepath.Join(dir, ".zero", "config.json"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
// breakTrustStore makes the trust store unreadable (creates trust.json as a directory),
// so workspacetrust.IsTrusted returns an error and resolveTrust fails closed with
// trustCheckErrored=true.
func breakTrustStore(t *testing.T) {
t.Helper()
configRoot := setTrustConfigRoot(t)
if err := os.MkdirAll(filepath.Join(configRoot, "zero", "trust.json"), 0o700); err != nil {
t.Fatal(err)
}
}
// TestResolveOAuthServerValidationErrorReturnsZeroSkip locks the new 3-value contract on
// the pre-resolve error path: an invalid server name errors before trust is resolved, so
// the returned skip is zero (nothing was excluded yet).
func TestResolveOAuthServerValidationErrorReturnsZeroSkip(t *testing.T) {
deps := appDeps{getwd: func() (string, error) { return t.TempDir(), nil }}
_, skip, err := resolveOAuthServer(deps, "bad name with spaces")
if err == nil {
t.Fatal("invalid server name must error")
}
if skip.excludedProjectConfig || skip.trustCheckErrored {
t.Fatalf("a validation error must return a zero skip, got %+v", skip)
}
}
// TestResolveOAuthServerGetwdErrorPropagates covers the getwd error path of the gated
// resolver.
func TestResolveOAuthServerGetwdErrorPropagates(t *testing.T) {
deps := appDeps{getwd: func() (string, error) { return "", errors.New("boom") }}
_, _, err := resolveOAuthServer(deps, "remote")
if err == nil || !strings.Contains(err.Error(), "failed to resolve workspace") {
t.Fatalf("getwd error must propagate, got %v", err)
}
}
// TestRunMCPOAuthLoginStoreErrorNotice proves the store-read-error branch: when the trust
// store cannot be read, login fails closed AND the notice names the store error (the
// "could not be read" variant) rather than the plain untrusted wording.
func TestRunMCPOAuthLoginStoreErrorNotice(t *testing.T) {
breakTrustStore(t)
cwd := t.TempDir()
writeProjectMCPConfig(t, cwd)
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(_ string, excludeProject bool) (config.MCPConfig, error) {
servers := map[string]config.MCPServerConfig{}
if !excludeProject {
servers["proj"] = config.MCPServerConfig{Type: "http", URL: "https://x.invalid", Auth: "oauth"}
}
return config.MCPConfig{Servers: servers}, nil
},
}
var out, errBuf bytes.Buffer
if code := runWithDeps([]string{"mcp", "oauth", "login", "proj"}, &out, &errBuf, deps); code == exitSuccess {
t.Fatal("login must fail closed on a trust-store read error")
}
if got := errBuf.String(); !strings.Contains(got, "could not be read") || !strings.Contains(got, "not configured") {
t.Fatalf("store-error login must emit the store-errored notice AND not-configured, got %q", got)
}
}
// TestRunMCPCheckStoreErrorNotice is the mcp-check sibling of the store-error case.
func TestRunMCPCheckStoreErrorNotice(t *testing.T) {
breakTrustStore(t)
cwd := t.TempDir()
writeProjectMCPConfig(t, cwd)
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(_ string, excludeProject bool) (config.MCPConfig, error) {
servers := map[string]config.MCPServerConfig{}
if !excludeProject {
servers["proj"] = config.MCPServerConfig{Type: "stdio", Command: "proj-cmd"}
}
return config.MCPConfig{Servers: servers}, nil
},
}
var out, errBuf bytes.Buffer
if code := runWithDeps([]string{"mcp", "check", "proj"}, &out, &errBuf, deps); code == exitSuccess {
t.Fatal("mcp check must fail closed on a trust-store read error")
}
if got := errBuf.String(); !strings.Contains(got, "could not be read") {
t.Fatalf("mcp check store-error must emit the store-errored notice, got %q", got)
}
}
// TestRunMCPOAuthLoginNoticeCoFiresWithNonOAuthError documents accepted behavior: in an
// untrusted workspace with project MCP config on disk, a login that fails because the
// named USER-config server is not OAuth still co-emits the trust notice (project MCP was
// excluded this run, which is true). The notice is advisory and keyed on "project config
// was dropped", not on "this server was the dropped one" — the same coarse scoping the
// mcp-check path uses. If this behavior changes intentionally, update this test.
func TestRunMCPOAuthLoginNoticeCoFiresWithNonOAuthError(t *testing.T) {
setTrustConfigRoot(t) // untrusted
cwd := t.TempDir()
writeProjectMCPConfig(t, cwd)
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
// "foo" is a user-config server (survives the gate) that does not declare oauth.
resolveMCPConfig: func(_ string, _ bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"foo": {Type: "http", URL: "https://foo.invalid"},
}}, nil
},
}
var out, errBuf bytes.Buffer
if code := runWithDeps([]string{"mcp", "oauth", "login", "foo"}, &out, &errBuf, deps); code == exitSuccess {
t.Fatal("login on a non-oauth server must fail")
}
got := errBuf.String()
if !strings.Contains(got, "oauth") {
t.Fatalf("want the not-oauth error, got %q", got)
}
if !strings.Contains(got, "zero trust") {
t.Fatalf("expected the advisory trust notice to co-fire (accepted behavior), got %q", got)
}
}
// TestResolveOAuthServerResolveConfigErrorPropagates covers the resolveMCPConfig-error
// return of the gated resolver: the error propagates with the computed skip.
func TestResolveOAuthServerResolveConfigErrorPropagates(t *testing.T) {
setTrustConfigRoot(t)
deps := appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
resolveMCPConfig: func(_ string, _ bool) (config.MCPConfig, error) {
return config.MCPConfig{}, errors.New("resolve boom")
},
}
_, _, err := resolveOAuthServer(deps, "remote")
if err == nil || !strings.Contains(err.Error(), "resolve boom") {
t.Fatalf("resolveMCPConfig error must propagate, got %v", err)
}
}
// TestResolveOAuthServerNormalizeErrorPropagates covers the NormalizeConfig-error return:
// a malformed server (stdio with no command) fails normalization.
func TestResolveOAuthServerNormalizeErrorPropagates(t *testing.T) {
setTrustConfigRoot(t)
deps := appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
resolveMCPConfig: func(_ string, _ bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"remote": {Type: "stdio"}, // no command -> NormalizeConfig rejects
}}, nil
},
}
_, _, err := resolveOAuthServer(deps, "remote")
if err == nil || !strings.Contains(err.Error(), "requires command") {
t.Fatalf("NormalizeConfig error must propagate, got %v", err)
}
}
// TestRunMCPCheckDisabledServer covers the found-but-disabled branch of runMCPCheck.
func TestRunMCPCheckDisabledServer(t *testing.T) {
setTrustConfigRoot(t)
deps := appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
resolveMCPConfig: func(_ string, _ bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"proj": {Type: "stdio", Command: "c", Disabled: true},
}}, nil
},
}
var out, errBuf bytes.Buffer
if code := runWithDeps([]string{"mcp", "check", "proj"}, &out, &errBuf, deps); code == exitSuccess {
t.Fatal("mcp check on a disabled server must fail")
}
if got := errBuf.String(); !strings.Contains(got, "is disabled") {
t.Fatalf("want disabled error, got %q", got)
}
}
// TestRunMCPCheckNormalizeError covers the found-but-malformed branch of runMCPCheck: the
// scoped NormalizeConfig fails before any spawn.
func TestRunMCPCheckNormalizeError(t *testing.T) {
setTrustConfigRoot(t)
deps := appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
resolveMCPConfig: func(_ string, _ bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"proj": {Type: "stdio"}, // no command
}}, nil
},
}
var out, errBuf bytes.Buffer
if code := runWithDeps([]string{"mcp", "check", "proj"}, &out, &errBuf, deps); code == exitSuccess {
t.Fatal("mcp check on a malformed server must fail")
}
if got := errBuf.String(); !strings.Contains(got, "requires command") {
t.Fatalf("want normalize error, got %q", got)
}
}
-358
View File
@@ -1,358 +0,0 @@
package cli
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/workspacetrust"
)
// mcpTrustDeps builds an appDeps whose resolveMCPConfig HONORS excludeProject: it
// returns a project stdio server only when excludeProject is false, mirroring the
// real ResolveMCP gate. It records the excludeProject value it was called with and
// whether registerMCPTools (which spawns servers) actually fired.
func mcpTrustDeps(gotExclude *bool, spawned *bool) appDeps {
return appDeps{
resolveMCPConfig: func(_ string, excludeProject bool) (config.MCPConfig, error) {
*gotExclude = excludeProject
servers := map[string]config.MCPServerConfig{}
if !excludeProject {
servers["proj-srv"] = config.MCPServerConfig{Type: "stdio", Command: "proj-cmd"}
}
return config.MCPConfig{Servers: servers}, nil
},
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
registerMCPTools: func(_ context.Context, _ *tools.Registry, _ config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
*spawned = true
return closeFunc(func() error { return nil }), nil
},
}
}
// TestMCPGateUntrustedExcludesProjectServer proves the P0 fix: an untrusted trustRoot
// makes registerMCPToolsForWorkspace resolve MCP config with excludeProject=true and,
// because that drops the project server, it never spawns anything. This test is
// load-bearing: if the gate is removed (a hardcoded excludeProject=false passed to
// resolveMCPConfig), gotExclude is false, the project server survives, spawned flips
// true, and every assertion below fails.
func TestMCPGateUntrustedExcludesProjectServer(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir() // never trusted
var gotExclude, spawned bool
deps := mcpTrustDeps(&gotExclude, &spawned)
registry := tools.NewRegistry()
runtime, skip, err := registerMCPToolsForWorkspace(context.Background(), repo, registry, deps, mcp.AutonomyLow, repo)
if err != nil {
t.Fatalf("registerMCPToolsForWorkspace: %v", err)
}
defer func() { _ = runtime.Close() }()
if !gotExclude {
t.Fatalf("untrusted workspace must resolve MCP config with excludeProject=true")
}
if spawned {
t.Fatalf("untrusted workspace must not spawn the project MCP server")
}
if _, ok := runtime.(noopMCPRuntime); !ok {
t.Fatalf("with the project server dropped, the runtime should be the noop runtime, got %T", runtime)
}
// This repo has no ./.zero/config.json, so there is no project MCP config to
// notice about even though it is untrusted; the skip must stay clean.
if skip.excludedProjectConfig {
t.Fatalf("no project MCP config on disk, so the skip must not flag an excluded config")
}
if skip.trustCheckErrored {
t.Fatalf("a clean untrusted verdict is not a store-read error")
}
}
// TestMCPGateEmptyTrustRootFailsClosed proves fail-closed-by-construction: a caller
// that forgot to resolve trustRoot (empty) still excludes the project layer.
func TestMCPGateEmptyTrustRootFailsClosed(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir()
// Even trusting the repo must not help when the caller passes an empty root.
if err := workspacetrust.Trust(repo); err != nil {
t.Fatalf("Trust(repo): %v", err)
}
var gotExclude, spawned bool
deps := mcpTrustDeps(&gotExclude, &spawned)
registry := tools.NewRegistry()
runtime, skip, err := registerMCPToolsForWorkspace(context.Background(), repo, registry, deps, mcp.AutonomyLow, "")
if err != nil {
t.Fatalf("registerMCPToolsForWorkspace: %v", err)
}
defer func() { _ = runtime.Close() }()
if !gotExclude {
t.Fatalf("empty trustRoot must fail closed (excludeProject=true)")
}
if spawned {
t.Fatalf("empty trustRoot must not spawn the project MCP server")
}
// Empty trustRoot is a clean fail-closed verdict, not a store-read error.
if skip.trustCheckErrored {
t.Fatalf("empty trustRoot is not a store-read error")
}
}
// TestMCPGateTrustedSpawnsProjectServer proves R3 for MCP: after Trust(repo) the
// project layer is included (excludeProject=false) and the project server spawns.
func TestMCPGateTrustedSpawnsProjectServer(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir()
if err := workspacetrust.Trust(repo); err != nil {
t.Fatalf("Trust(repo): %v", err)
}
var gotExclude, spawned bool
deps := mcpTrustDeps(&gotExclude, &spawned)
registry := tools.NewRegistry()
runtime, skip, err := registerMCPToolsForWorkspace(context.Background(), repo, registry, deps, mcp.AutonomyLow, repo)
if err != nil {
t.Fatalf("registerMCPToolsForWorkspace: %v", err)
}
defer func() { _ = runtime.Close() }()
if gotExclude {
t.Fatalf("trusted workspace must resolve MCP config with excludeProject=false")
}
if !spawned {
t.Fatalf("trusted workspace must spawn the project MCP server")
}
if skip.excludedProjectConfig {
t.Fatalf("trusted workspace must not report the project MCP layer excluded")
}
}
// TestMCPToolsListSurfacesTrustNotice proves `zero mcp tools list` no longer drops the
// project MCP layer silently in an untrusted workspace: the gated skip is surfaced on
// stderr (the list stays on stdout), so an empty list is explained rather than read as
// "nothing configured". Trusting the repo silences it.
func TestMCPToolsListSurfacesTrustNotice(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir()
if err := os.MkdirAll(filepath.Join(repo, ".zero"), 0o700); err != nil {
t.Fatal(err)
}
body := `{"mcp":{"servers":{"proj":{"type":"stdio","command":"proj-cmd"}}}}`
if err := os.WriteFile(filepath.Join(repo, ".zero", "config.json"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
run := func() string {
var out, errBuf bytes.Buffer
deps := appDeps{
getwd: func() (string, error) { return repo, nil },
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
}
if code := runWithDeps([]string{"mcp", "tools", "list"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("mcp tools list exit = %d, stderr=%q", code, errBuf.String())
}
return errBuf.String()
}
// Untrusted: the gated project MCP layer must be explained on stderr.
if errUntrusted := run(); !strings.Contains(errUntrusted, "MCP servers") || !strings.Contains(errUntrusted, "zero trust") {
t.Fatalf("untrusted `mcp tools list` must surface the trust notice on stderr, got %q", errUntrusted)
}
// Trusted: nothing is skipped, so no notice.
if err := workspacetrust.Trust(repo); err != nil {
t.Fatal(err)
}
if errTrusted := run(); strings.Contains(errTrusted, "ignoring project") {
t.Fatalf("trusted `mcp tools list` must not emit a trust notice, got %q", errTrusted)
}
}
// TestProjectMCPConfigExists exercises every branch of the notice-gating detector:
// only a ./.zero/config.json that parses AND declares at least one server is true.
func TestProjectMCPConfigExists(t *testing.T) {
writeCfg := func(t *testing.T, dir, body string) {
t.Helper()
if err := os.MkdirAll(filepath.Join(dir, ".zero"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, ".zero", "config.json"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
if projectMCPConfigExists("") {
t.Fatal("empty workspace root must be false")
}
cases := []struct {
name string
body string // "" means write no config.json at all
want bool
}{
{"no file", "", false},
{"declares a server", `{"mcp":{"servers":{"a":{"type":"stdio","command":"x"}}}}`, true},
{"empty servers map", `{"mcp":{"servers":{}}}`, false},
{"config without mcp key", `{"model":"x"}`, false},
{"unparseable json", `{not valid`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
if tc.body != "" {
writeCfg(t, dir, tc.body)
}
if got := projectMCPConfigExists(dir); got != tc.want {
t.Fatalf("projectMCPConfigExists = %v, want %v", got, tc.want)
}
})
}
}
// TestMCPGateFailClosedOnStoreError proves the MCP surface reports a store-read error
// (trust.json created as a directory) as trustCheckErrored, so the caller's notice can
// name the fail-closed reason -- the same error path the hook and plugin gates cover.
func TestMCPGateFailClosedOnStoreError(t *testing.T) {
configRoot := setTrustConfigRoot(t)
trustPath := filepath.Join(configRoot, "zero", "trust.json")
if err := os.MkdirAll(trustPath, 0o700); err != nil {
t.Fatalf("create trust.json as a directory: %v", err)
}
repo := t.TempDir()
if err := os.MkdirAll(filepath.Join(repo, ".zero"), 0o700); err != nil {
t.Fatalf("mkdir project .zero: %v", err)
}
body := `{"mcp":{"servers":{"proj":{"type":"stdio","command":"proj-cmd"}}}}`
if err := os.WriteFile(filepath.Join(repo, ".zero", "config.json"), []byte(body), 0o600); err != nil {
t.Fatalf("write project config.json: %v", err)
}
var gotExclude, spawned bool
deps := mcpTrustDeps(&gotExclude, &spawned)
runtime, skip, err := registerMCPToolsForWorkspace(context.Background(), repo, tools.NewRegistry(), deps, mcp.AutonomyLow, repo)
if err != nil {
t.Fatalf("registerMCPToolsForWorkspace: %v", err)
}
defer func() { _ = runtime.Close() }()
if !gotExclude {
t.Fatalf("a store-read error must fail closed (excludeProject=true)")
}
if spawned {
t.Fatalf("a store-read error must not spawn the project MCP server")
}
if !skip.trustCheckErrored {
t.Fatalf("skip must mark the store-read error so the notice can name it")
}
if !skip.excludedProjectConfig {
t.Fatalf("the project MCP layer must be reported excluded on the error path")
}
}
// TestMCPGateUntrustedNoticesProjectMCPConfig proves the notice-surfacing fix: when an
// untrusted workspace actually declares project MCP servers in ./.zero/config.json,
// registerMCPToolsForWorkspace reports excludedProjectConfig=true so the caller can
// warn (the CodeRabbit finding: project MCP was gated silently). Trusting the repo
// clears the skip.
func TestMCPGateUntrustedNoticesProjectMCPConfig(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir()
// A real project MCP config on disk: projectMCPConfigExists reads this file.
if err := os.MkdirAll(filepath.Join(repo, ".zero"), 0o700); err != nil {
t.Fatalf("mkdir project .zero: %v", err)
}
body := `{"mcp":{"servers":{"proj":{"type":"stdio","command":"proj-cmd"}}}}`
if err := os.WriteFile(filepath.Join(repo, ".zero", "config.json"), []byte(body), 0o600); err != nil {
t.Fatalf("write project config.json: %v", err)
}
var gotExclude, spawned bool
deps := mcpTrustDeps(&gotExclude, &spawned)
registry := tools.NewRegistry()
// Untrusted: the project MCP layer is dropped AND flagged for the notice.
runtime, skip, err := registerMCPToolsForWorkspace(context.Background(), repo, registry, deps, mcp.AutonomyLow, repo)
if err != nil {
t.Fatalf("registerMCPToolsForWorkspace (untrusted): %v", err)
}
defer func() { _ = runtime.Close() }()
if !skip.excludedProjectConfig {
t.Fatalf("untrusted workspace with project MCP config must flag excludedProjectConfig for the notice")
}
if skip.trustCheckErrored {
t.Fatalf("a clean untrusted verdict is not a store-read error")
}
// Trusted: nothing is skipped, so no notice.
if err := workspacetrust.Trust(repo); err != nil {
t.Fatalf("Trust(repo): %v", err)
}
_, trustedSkip, err := registerMCPToolsForWorkspace(context.Background(), repo, tools.NewRegistry(), deps, mcp.AutonomyLow, repo)
if err != nil {
t.Fatalf("registerMCPToolsForWorkspace (trusted): %v", err)
}
if trustedSkip.excludedProjectConfig {
t.Fatalf("trusted workspace must not flag an excluded project MCP layer")
}
}
// TestMCPCheckSurfacesTrustNotice proves the `zero mcp check` notice fix: in an untrusted
// workspace whose only definition of a server lives in ./.zero/config.json, the gate
// drops the server and the command must emit the one-line trust notice before the
// "not configured" error, instead of a bare miss that hides the trust exclusion. The R4
// half proves the notice self-gates: a genuinely-absent server in a workspace with no
// project config on disk prints "not configured" with no notice.
func TestMCPCheckSurfacesTrustNotice(t *testing.T) {
setTrustConfigRoot(t)
// resolveMCPConfig fake that HONORS excludeProject: `proj` survives only when the
// project layer is included, mirroring the real gate.
dropFake := func(_ string, excludeProject bool) (config.MCPConfig, error) {
servers := map[string]config.MCPServerConfig{}
if !excludeProject {
servers["proj"] = config.MCPServerConfig{Type: "stdio", Command: "proj-cmd"}
}
return config.MCPConfig{Servers: servers}, nil
}
// Untrusted workspace whose ONLY definition of `proj` is project config on disk.
repo := t.TempDir()
if err := os.MkdirAll(filepath.Join(repo, ".zero"), 0o700); err != nil {
t.Fatal(err)
}
body := `{"mcp":{"servers":{"proj":{"type":"stdio","command":"proj-cmd"}}}}`
if err := os.WriteFile(filepath.Join(repo, ".zero", "config.json"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
var out, errBuf bytes.Buffer
deps := appDeps{getwd: func() (string, error) { return repo, nil }, resolveMCPConfig: dropFake}
if code := runWithDeps([]string{"mcp", "check", "proj"}, &out, &errBuf, deps); code == exitSuccess {
t.Fatalf("mcp check on a gated project server must fail, got success; stderr=%q", errBuf.String())
}
if got := errBuf.String(); !strings.Contains(got, "not configured") ||
!strings.Contains(got, "MCP servers") || !strings.Contains(got, "zero trust") {
t.Fatalf("untrusted mcp check must report not-configured AND surface the trust notice, got %q", got)
}
// R4: an untrusted workspace with NO project MCP config on disk has nothing to
// notice about, so an absent server prints "not configured" with no trust notice.
bare := t.TempDir()
var out2, errBuf2 bytes.Buffer
deps2 := appDeps{getwd: func() (string, error) { return bare, nil }, resolveMCPConfig: dropFake}
if code := runWithDeps([]string{"mcp", "check", "ghost"}, &out2, &errBuf2, deps2); code == exitSuccess {
t.Fatalf("mcp check on an absent server must fail")
}
if got := errBuf2.String(); !strings.Contains(got, "not configured") {
t.Fatalf("stderr must report not configured, got %q", got)
}
if got := errBuf2.String(); strings.Contains(got, "ignoring project") || strings.Contains(got, "zero trust") {
t.Fatalf("no project config on disk means no trust notice, got %q", got)
}
}
+5 -59
View File
@@ -3,11 +3,7 @@ package cli
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/plugins"
"github.com/Gitlawb/zero/internal/tools"
@@ -17,13 +13,9 @@ import (
// later dispatcher + skill wiring can consume it: the plugin hook definitions and
// the plugin skill search roots. The zero value (no plugins) is inert — the
// dispatcher gets no extra hooks and the skill tool keeps only the default dir.
// The embedded trustSkip reports whether the project plugin layer was dropped for
// an untrusted workspace, so the caller can fold it into the single combined
// trust notice.
type pluginActivation struct {
hooks []hooks.Definition
skillRoots []string
trustSkip
}
// activatePlugins loads the workspace's plugins and makes their declared
@@ -33,26 +25,12 @@ type pluginActivation struct {
//
// It fails OPEN: any load error (or a malformed plugin) is surfaced as a warning
// on stderr and otherwise skipped, so a broken plugin can never wedge startup —
// mirroring how newHookDispatcher and skills.Load tolerate bad input. That
// fail-OPEN handling of MALFORMED plugins is separate from the trust gate below.
//
// The trust check lives here, inside the chokepoint, so no caller can bypass it:
// the project plugin root is dropped (ExcludeProject) whenever trustRoot is empty,
// the trust store cannot be read, or the workspace is not trusted (fail-closed).
// trustRoot is the original launch directory (resolved before any --worktree
// reassignment). The returned activation carries the skip report so the caller can
// emit one combined notice; the notice itself is NOT emitted here.
func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDeps, stderr io.Writer, trustRoot string) pluginActivation {
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
skip := trustSkip{
excludedProjectConfig: excludeProject && projectPluginsDirExists(workspaceRoot),
trustCheckErrored: trustCheckErrored,
}
loaded, err := deps.loadPlugins(plugins.LoadOptions{Cwd: workspaceRoot, ExcludeProject: excludeProject})
// mirroring how newHookDispatcher and skills.Load tolerate bad input.
func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDeps, stderr io.Writer) pluginActivation {
loaded, err := deps.loadPlugins(plugins.LoadOptions{Cwd: workspaceRoot})
if err != nil {
writePluginActivationWarning(stderr, "failed to load plugins: "+err.Error())
return pluginActivation{trustSkip: skip}
return pluginActivation{}
}
// Load fails OPEN per plugin: a malformed manifest is recorded as a diagnostic
@@ -76,39 +54,7 @@ func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDep
registry.Register(plugins.NewSkillTool(deps.skillsDir(), result.SkillRoots))
}
return pluginActivation{hooks: result.Hooks, skillRoots: result.SkillRoots, trustSkip: skip}
}
// projectPluginsDirExists reports whether a ./.zero/plugins directory is present
// under workspaceRoot, so the caller only notices about config it actually
// skipped.
func projectPluginsDirExists(workspaceRoot string) bool {
if workspaceRoot == "" {
return false
}
info, err := os.Stat(filepath.Join(workspaceRoot, ".zero", "plugins"))
return err == nil && info.IsDir()
}
// skillInfos resolves the reusable skills the model can load via the skill tool —
// the default skills dir merged with any plugin skill roots, the same set the
// plugin-aware skill tool resolves against — as plain data for the agent's system
// prompt. It returns nil when no skills are installed, so a skill-less run leaves
// the prompt byte-identical.
func (a pluginActivation) skillInfos(defaultDir string) []agent.SkillInfo {
merged, _ := plugins.MergedSkills(defaultDir, a.skillRoots)
if len(merged) == 0 {
return nil
}
infos := make([]agent.SkillInfo, 0, len(merged))
for _, skill := range merged {
name := strings.TrimSpace(skill.Name)
if name == "" {
continue
}
infos = append(infos, agent.SkillInfo{Name: name, Description: strings.TrimSpace(skill.Description)})
}
return infos
return pluginActivation{hooks: result.Hooks, skillRoots: result.SkillRoots}
}
// formatLoadDiagnostic renders a plugin load diagnostic into a single warning
+6 -11
View File
@@ -51,8 +51,7 @@ func TestActivatePluginsRegistersToolAndCollectsHooks(t *testing.T) {
registry := tools.NewRegistry()
var stderr bytes.Buffer
workspace := t.TempDir()
activation := activatePlugins(workspace, registry, fakePluginDeps(t, loaded), &stderr, workspace)
activation := activatePlugins(t.TempDir(), registry, fakePluginDeps(t, loaded), &stderr)
if _, ok := registry.Get("demo_lookup"); !ok {
t.Fatalf("plugin tool not registered into the bootstrap registry")
@@ -91,8 +90,7 @@ func TestActivatePluginsRegistersPluginSkillTool(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.NewSkillTool(t.TempDir())) // core skill tool, like the real bootstrap
var stderr bytes.Buffer
workspace := t.TempDir()
activation := activatePlugins(workspace, registry, fakePluginDeps(t, loaded), &stderr, workspace)
activation := activatePlugins(t.TempDir(), registry, fakePluginDeps(t, loaded), &stderr)
if len(activation.skillRoots) != 1 {
t.Fatalf("expected one plugin skill root, got %#v", activation.skillRoots)
@@ -126,8 +124,7 @@ func TestActivatePluginsSurfacesLoadDiagnostics(t *testing.T) {
}
registry := tools.NewRegistry()
var stderr bytes.Buffer
workspace := t.TempDir()
activatePlugins(workspace, registry, deps, &stderr, workspace)
activatePlugins(t.TempDir(), registry, deps, &stderr)
if stderr.Len() == 0 {
t.Fatal("a load diagnostic must be surfaced on stderr")
@@ -146,8 +143,7 @@ func TestActivatePluginsFailsOpenOnLoadError(t *testing.T) {
}
registry := tools.NewRegistry()
var stderr bytes.Buffer
workspace := t.TempDir()
activation := activatePlugins(workspace, registry, deps, &stderr, workspace)
activation := activatePlugins(t.TempDir(), registry, deps, &stderr)
if len(activation.hooks) != 0 || len(activation.skillRoots) != 0 {
t.Fatalf("a load error must yield an inert activation, got %#v", activation)
@@ -177,8 +173,7 @@ func TestActivatePluginsWarnsOnMalformedPluginButKeepsGood(t *testing.T) {
registry := tools.NewRegistry()
var stderr bytes.Buffer
workspace := t.TempDir()
activatePlugins(workspace, registry, fakePluginDeps(t, []plugins.LoadedPlugin{bad, good}), &stderr, workspace)
activatePlugins(t.TempDir(), registry, fakePluginDeps(t, []plugins.LoadedPlugin{bad, good}), &stderr)
if _, ok := registry.Get("good_tool"); !ok {
t.Fatal("the good plugin tool must still register despite a malformed sibling")
@@ -203,7 +198,7 @@ func TestNewHookDispatcherWithExtraFoldsPluginHooks(t *testing.T) {
Enabled: true,
}}
dispatcher, _ := newHookDispatcherWithExtra(workspace, extra, workspace)
dispatcher := newHookDispatcherWithExtra(workspace, extra)
if dispatcher == nil {
t.Fatal("dispatcher should never be nil for a clean workspace")
}
@@ -18,7 +18,6 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
}{
{id: "openai", name: "OpenAI", transport: "openai", defaultModel: "gpt-4.1"},
{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: "ollama-cloud", name: "Ollama Cloud", transport: "openai-compatible", defaultModel: "qwen3-coder:480b"},
{id: "ollama", name: "Ollama Local", transport: "openai-compatible", defaultModel: "llama3.1"},
}
@@ -47,21 +46,6 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
}
}
func TestProviderCatalogLongCatRequiresAPIKey(t *testing.T) {
output := runProviderCatalogOnboarding(t)
block := providerCatalogOnboardingBlock(t, output, "longcat")
for _, want := range []string{
"requiresAuth=true",
"authEnvVars=LONGCAT_API_KEY",
"setup: zero providers setup longcat --set-active",
} {
if !strings.Contains(block, want) {
t.Fatalf("expected LongCat catalog block to contain %q, got:\n%s", want, block)
}
}
}
func TestProviderCatalogOllamaCloudRequiresAPIKey(t *testing.T) {
output := runProviderCatalogOnboarding(t)
block := providerCatalogOnboardingBlock(t, output, "ollama-cloud")
-14
View File
@@ -8,7 +8,6 @@ import (
"strings"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providermodelcatalog"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
)
@@ -52,19 +51,6 @@ func runProvidersModels(args []string, stdout io.Writer, stderr io.Writer, deps
return writeAppError(stderr, err.Error(), exitProvider)
}
// Apply provider-specific model filtering for catalog-backed profiles.
// For example, opencode-go-anthropic-compatible only permits Qwen and
// MiniMax model IDs; the raw /zen/go/v1/models endpoint returns many more.
if profile.CatalogID != "" {
filtered := make([]providermodeldiscovery.Model, 0, len(models))
for _, model := range models {
if providermodelcatalog.ModelIDAllowedForProvider(profile.CatalogID, model.ID) {
filtered = append(filtered, model)
}
}
models = filtered
}
if options.json {
items := make([]map[string]any, 0, len(models))
for _, model := range models {
-56
View File
@@ -132,62 +132,6 @@ func TestRunProvidersModelsUnknownProviderFails(t *testing.T) {
}
}
func TestRunProvidersModelsFiltersForCatalogProfile(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
deps := commandCenterDeps(t)
// Override the resolved config to include a catalog-backed profile.
deps.resolveConfig = func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
profile := config.ProviderProfile{
Name: "opencode-go",
ProviderKind: config.ProviderKindAnthropicCompat,
CatalogID: "opencode-go-anthropic-compatible",
BaseURL: "https://api.example.com/zen/go/v1",
APIKey: "sk-test",
Model: "minimax-max",
}
return config.ResolvedConfig{
ActiveProvider: "opencode-go",
Providers: []config.ProviderProfile{profile},
Provider: profile,
}, nil
}
deps.discoverProviderModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return []providermodeldiscovery.Model{
{ID: "deepseek-chat", Description: "DeepSeek Chat"},
{ID: "qwen-max", Description: "Qwen Max"},
{ID: "minimax-max-01", Description: "MiniMax Max"},
{ID: "kimi-k2", Description: "Kimi K2"},
{ID: "glm-4", Description: "GLM-4"},
}, nil
}
exitCode := runWithDeps([]string{"providers", "models"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
out := stdout.String()
// Only Qwen and MiniMax model IDs should survive the filter.
if strings.Contains(out, "deepseek-chat") {
t.Error("output should not contain deepseek-chat (blocked by opencode-go-anthropic filter)")
}
if strings.Contains(out, "kimi-k2") {
t.Error("output should not contain kimi-k2 (blocked by opencode-go-anthropic filter)")
}
if strings.Contains(out, "glm-4") {
t.Error("output should not contain glm-4 (blocked by opencode-go-anthropic filter)")
}
for _, want := range []string{"qwen-max", "minimax-max-01"} {
if !strings.Contains(out, want) {
t.Fatalf("output missing allowed model %q:\n%s", want, out)
}
}
if !strings.Contains(out, "2 models discovered") {
t.Fatalf("output should show 2 models:\n%s", out)
}
}
func TestRunProvidersModelsSurfacesDiscoveryError(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
-141
View File
@@ -3,7 +3,6 @@ package cli
import (
"fmt"
"io"
"path/filepath"
"strconv"
"strings"
"unicode"
@@ -307,143 +306,3 @@ func providerCommandArg(value string) string {
}
return value
}
type providerNamesOptions struct {
names []string
json bool
}
func parseProviderNamesArgs(args []string, want int, usage string) (providerNamesOptions, bool, error) {
options := providerNamesOptions{}
for _, arg := range args {
switch {
case arg == "-h" || arg == "--help" || arg == "help":
return options, true, nil
case arg == "--json":
options.json = true
case strings.HasPrefix(arg, "-"):
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
default:
options.names = append(options.names, arg)
}
}
if len(options.names) != want {
return options, false, execUsageError{usage}
}
return options, false, nil
}
// runProvidersRemove deletes a saved provider profile and its stored API key.
// The OAuth token (if any) is kept — logins outlive profiles so re-adding the
// provider needs no new browser round-trip; `zero auth logout <name>` removes it.
func runProvidersRemove(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseProviderNamesArgs(args, 1, "usage: zero providers remove <name>")
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeProvidersHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
configPath, err := deps.userConfigPath()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
name := options.names[0]
cfg, err := config.RemoveProvider(configPath, name)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
// Delete the key from the store BESIDE the config being edited — the same
// store setup/rename write to — not the default-path store, so a
// non-default config path cannot leave the encrypted key behind.
keyRemoved, keyErr := removeStoredProviderKeyAt(configPath, name)
if options.json {
payload := map[string]any{
"removed": name,
"keyRemoved": keyRemoved,
"activeProvider": cfg.ActiveProvider,
"configPath": configPath,
}
if keyErr != nil {
// A lingering secret must not read as a clean removal.
payload["keyError"] = keyErr.Error()
}
if err := writePrettyJSON(stdout, payload); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintf(stdout, "Removed provider %s\n", name); err != nil {
return exitCrash
}
if keyErr != nil {
if _, err := fmt.Fprintf(stderr, "warning: its stored API key could not be deleted and remains in the credential store: %v\n", keyErr); err != nil {
return exitCrash
}
} else if keyRemoved {
if _, err := fmt.Fprintln(stdout, "Deleted its stored API key."); err != nil {
return exitCrash
}
}
if active := strings.TrimSpace(cfg.ActiveProvider); active != "" {
if _, err := fmt.Fprintf(stdout, "Active provider: %s\n", active); err != nil {
return exitCrash
}
} else {
if _, err := fmt.Fprintln(stdout, "No providers remain — run zero setup to add one."); err != nil {
return exitCrash
}
}
return exitSuccess
}
// removeStoredProviderKeyAt deletes a provider's API key from the credential
// store co-located with configPath (the store SecureProviderProfile captured
// it into and RenameProvider migrates within).
func removeStoredProviderKeyAt(configPath string, provider string) (bool, error) {
store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath))
if err != nil {
return false, err
}
return store.Delete(provider)
}
// runProvidersRename renames a saved provider profile, migrating its stored
// API key and the activeProvider pointer along with it (config.RenameProvider).
func runProvidersRename(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseProviderNamesArgs(args, 2, "usage: zero providers rename <old> <new>")
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeProvidersHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
configPath, err := deps.userConfigPath()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
cfg, err := config.RenameProvider(configPath, options.names[0], options.names[1])
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
if options.json {
if err := writePrettyJSON(stdout, map[string]any{
"renamed": map[string]string{"from": options.names[0], "to": options.names[1]},
"activeProvider": cfg.ActiveProvider,
"configPath": configPath,
}); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintf(stdout, "Renamed provider %s to %s\n", options.names[0], options.names[1]); err != nil {
return exitCrash
}
return exitSuccess
}
-45
View File
@@ -234,48 +234,3 @@ func writeProviderOnboardingConfig(t *testing.T, path string, cfg config.FileCon
t.Fatalf("write config: %v", err)
}
}
// TestRunProvidersRemoveDeletesKeyBesideConfig: the stored key must be deleted
// from the credential store CO-LOCATED with the config being edited (where
// SecureProviderProfile captured it), not the default-path store.
func TestRunProvidersRemoveDeletesKeyBesideConfig(t *testing.T) {
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
seed := `{"activeProvider":"gw","providers":[{"name":"gw","provider_kind":"openai-compatible","baseURL":"https://gw.example.com/v1","apiKeyStored":true,"model":"m1"},{"name":"other","provider_kind":"openai-compatible","baseURL":"https://o.example.com/v1","model":"m2"}]}`
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
t.Fatalf("seed config: %v", err)
}
store, err := config.ProviderKeyStoreAt(dir)
if err != nil {
t.Fatalf("open store: %v", err)
}
if err := store.Set("gw", "sk-secret"); err != nil {
t.Fatalf("seed key: %v", err)
}
var stdout, stderr bytes.Buffer
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
if code := runWithDeps([]string{"providers", "remove", "gw", "--json"}, &stdout, &stderr, deps); code != exitSuccess {
t.Fatalf("remove failed: code=%d stderr=%s", code, stderr.String())
}
var payload struct {
Removed string `json:"removed"`
KeyRemoved bool `json:"keyRemoved"`
KeyError string `json:"keyError"`
ActiveProvider string `json:"activeProvider"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
}
if payload.Removed != "gw" || !payload.KeyRemoved || payload.KeyError != "" {
t.Fatalf("unexpected payload: %+v", payload)
}
if payload.ActiveProvider != "other" {
t.Fatalf("active must hand off, got %q", payload.ActiveProvider)
}
if _, ok, _ := store.Get("gw"); ok {
t.Fatalf("stored key must be deleted from the store beside the config")
}
}
+15 -13
View File
@@ -457,9 +457,7 @@ func selectProviderForCheck(resolved config.ResolvedConfig, name string) (config
}
func validateProviderRuntimeReady(profile config.ProviderProfile) error {
// A stored OAuth login (e.g. `zero auth chatgpt`) is a credential too: a
// keyless token-login profile must pass the readiness check instead of being
// told to set an API key it will never have.
hasCredential := providerProfileHasCredential(profile)
if profile.CatalogID != "" {
descriptor, err := providercatalog.Require(profile.CatalogID)
if err != nil {
@@ -468,19 +466,23 @@ func validateProviderRuntimeReady(profile config.ProviderProfile) error {
if !providercatalog.RuntimeSupported(descriptor) {
return fmt.Errorf("provider %q uses transport %q: %s", descriptor.ID, descriptor.Transport, providercatalog.RuntimeUnsupportedReason(descriptor))
}
}
if providerHasOAuthLogin(profile, oauthLoggedInProviders()) {
if descriptor.RequiresAuth && !hasCredential {
apiKeyEnv := strings.TrimSpace(profile.APIKeyEnv)
if apiKeyEnv == "" && len(descriptor.AuthEnvVars) > 0 {
apiKeyEnv = descriptor.AuthEnvVars[0]
}
if apiKeyEnv != "" {
return fmt.Errorf("provider %s requires API key; set %s", profile.Name, apiKeyEnv)
}
return fmt.Errorf("provider %s requires API key", profile.Name)
}
return nil
}
// Delegates to the single source of truth shared with the TUI and the
// setup wizard, so `providers check` never disagrees with them about
// whether a saved provider (including a no-auth custom endpoint) is
// actually missing a credential.
if envVar, missing := profile.MissingCredentialEnv(); missing {
if envVar != "" {
return fmt.Errorf("provider %s requires API key; set %s", profile.Name, envVar)
switch profile.ProviderKind {
case config.ProviderKindOpenAI, config.ProviderKindAnthropic, config.ProviderKindGoogle:
if !hasCredential {
return fmt.Errorf("provider %s requires API key", profile.Name)
}
return fmt.Errorf("provider %s requires API key", profile.Name)
}
return nil
}
-68
View File
@@ -1,68 +0,0 @@
package cli
import (
"testing"
"github.com/Gitlawb/zero/internal/config"
)
// Regression for issue #555's follow-up: `zero providers check` must not
// error that a no-auth custom endpoint requires an API key, matching what
// /model and /providers already treat as usable.
func TestValidateProviderRuntimeReadyCustomEndpoint(t *testing.T) {
cases := []struct {
name string
profile config.ProviderProfile
wantErr bool
}{
{
name: "custom openai compatible with no credential configured",
profile: config.ProviderProfile{
Name: "local-llama",
CatalogID: "custom-openai-compatible",
BaseURL: "http://192.168.1.50:8080/v1",
Model: "custom-model",
},
wantErr: false,
},
{
name: "custom openai compatible with stale legacy default env",
profile: config.ProviderProfile{
Name: "local-llama",
CatalogID: "custom-openai-compatible",
BaseURL: "http://192.168.1.50:8080/v1",
APIKeyEnv: "OPENAI_API_KEY",
Model: "custom-model",
},
wantErr: false,
},
{
name: "custom openai compatible with explicit non-default env still requires it",
profile: config.ProviderProfile{
Name: "local-llama",
CatalogID: "custom-openai-compatible",
BaseURL: "http://192.168.1.50:8080/v1",
APIKeyEnv: "LLAMA_CPP_API_KEY",
Model: "custom-model",
},
wantErr: true,
},
{
name: "catalog provider missing key still errors",
profile: config.ProviderProfile{
Name: "groq",
CatalogID: "groq",
Model: "llama-3.3-70b-versatile",
},
wantErr: true,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := validateProviderRuntimeReady(c.profile)
if (err != nil) != c.wantErr {
t.Fatalf("validateProviderRuntimeReady() error = %v, wantErr %v", err, c.wantErr)
}
})
}
}
-9
View File
@@ -88,15 +88,6 @@ func formatRepoInfo(info repoinfo.Info) string {
fmt.Fprintf(&b, "Files: %d\n", info.FileCount)
fmt.Fprintf(&b, "Directories: %d (max depth %d)\n", info.DirectoryCount, info.MaxDepth)
fmt.Fprintf(&b, "Est. LOC: %d\n", info.LOCEstimate)
if info.CommitCount != nil {
fmt.Fprintf(&b, "Commits: %d\n", *info.CommitCount)
}
if info.BranchCount != nil {
fmt.Fprintf(&b, "Branches: %d\n", *info.BranchCount)
}
if info.TagCount != nil {
fmt.Fprintf(&b, "Tags: %d\n", *info.TagCount)
}
if info.PrimaryLanguage != "" {
fmt.Fprintf(&b, "Primary lang: %s\n", info.PrimaryLanguage)
}
+1 -5
View File
@@ -33,7 +33,7 @@ func TestFormatRepoInfoText(t *testing.T) {
contrib := 3
info := repoInfoTestData(age, contrib)
out := formatRepoInfo(info)
for _, want := range []string{"Files", "TypeScript", "main", "GitHub Actions", "42", "3", "Commits: 100", "Branches: 5", "Tags: 10"} {
for _, want := range []string{"Files", "TypeScript", "main", "GitHub Actions", "42", "3"} {
if !strings.Contains(out, want) {
t.Fatalf("text output missing %q:\n%s", want, out)
}
@@ -113,16 +113,12 @@ func initTempGitRepo(t *testing.T) string {
}
func repoInfoTestData(age, contrib int) repoinfo.Info {
commits := 100
branches := 5
tags := 10
return repoinfo.Info{
FileCount: 10, DirectoryCount: 3, MaxDepth: 2, LOCEstimate: 500,
Languages: []repoinfo.LangStat{{Name: "TypeScript", LOCEstimate: 400, FileCount: 4}},
PrimaryLanguage: "TypeScript", LanguageCount: 1,
WorkspaceType: "none", CICD: []string{"GitHub Actions"},
HasGit: true, Branch: "main", AgeDays: &age, Contributors90d: &contrib,
CommitCount: &commits, BranchCount: &branches, TagCount: &tags,
}
}
-114
View File
@@ -1,114 +0,0 @@
package cli
import (
"context"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
type scratchFileBaseline map[string]bool
// scratchFileSnapshot records the untracked scratch-like files that already
// existed before a run starts. Completion warnings compare against this baseline
// so Zero only reports files newly left behind by the run, including files made
// by shell commands that bypass the file tools.
func scratchFileSnapshot(workspaceRoot string) scratchFileBaseline {
untracked, ok := gitUntrackedFiles(workspaceRoot)
if !ok {
return nil
}
baseline := make(scratchFileBaseline, len(untracked))
for _, path := range untracked {
if scratchLikePath(path) {
baseline[path] = true
}
}
return baseline
}
// scratchFileWarning builds a completion-time warning listing scratch/debug-like
// files that became untracked during this run. It deliberately does not warn for
// every new untracked file: brand-new deliverables are a normal coding-task
// result, while names such as `_debug.py`, `_fix_test.py`, or `scratch.js` are
// the class of throwaway artifacts called out in issue #551.
//
// Returns "" when there is nothing to report: git is unavailable,
// workspaceRoot isn't a git repo, no baseline was captured, or no new
// scratch-like untracked files remain.
func scratchFileWarning(workspaceRoot string, baseline scratchFileBaseline) string {
if baseline == nil {
return ""
}
untracked, ok := gitUntrackedFiles(workspaceRoot)
if !ok {
return ""
}
var scratchFiles []string
for _, path := range untracked {
if baseline[path] || !scratchLikePath(path) {
continue
}
baseline[path] = true
scratchFiles = append(scratchFiles, path)
}
if len(scratchFiles) == 0 {
return ""
}
displayPaths := make([]string, len(scratchFiles))
for i, path := range scratchFiles {
displayPaths[i] = filepath.ToSlash(path)
}
plural := "s"
if len(displayPaths) == 1 {
plural = ""
}
return "This run left " + strconv.Itoa(len(displayPaths)) + " new scratch/debug-like file" + plural + " untracked in git: " +
strings.Join(displayPaths, ", ") + ". Review before committing/staging."
}
func gitUntrackedFiles(workspaceRoot string) ([]string, bool) {
if _, err := exec.LookPath("git"); err != nil {
return nil, false
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
output, err := exec.CommandContext(ctx, "git", "-C", workspaceRoot, "status", "--porcelain", "-z", "--untracked-files=all").Output()
if err != nil {
// Not a git repo (or git failed for some other reason) — nothing
// reliable to report against, so stay silent rather than guess.
return nil, false
}
var untracked []string
for _, entry := range strings.Split(string(output), "\x00") {
if !strings.HasPrefix(entry, "?? ") {
continue
}
relative := strings.TrimPrefix(entry, "?? ")
untracked = append(untracked, relative)
}
return untracked, true
}
func scratchLikePath(path string) bool {
name := strings.ToLower(filepath.Base(filepath.ToSlash(path)))
name = strings.TrimSpace(name)
if name == "" {
return false
}
if strings.HasPrefix(name, "scratch") || strings.HasPrefix(name, "debug") || strings.HasPrefix(name, "tmp") || strings.HasPrefix(name, "temp") || strings.HasPrefix(name, "repro") {
return true
}
for _, marker := range []string{"scratch", "debug", "tmp", "temp", "repro", "fix_test"} {
if strings.Contains(name, marker) {
return true
}
}
return false
}
-174
View File
@@ -1,174 +0,0 @@
package cli
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func runGitForScratchTest(t *testing.T, dir string, args ...string) {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=test", "[email protected]",
"GIT_COMMITTER_NAME=test", "[email protected]",
)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, output)
}
}
func TestScratchFileWarningFlagsUntrackedCreatedFile(t *testing.T) {
root := t.TempDir()
runGitForScratchTest(t, root, "init")
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGitForScratchTest(t, root, "add", "README.md")
runGitForScratchTest(t, root, "commit", "-m", "init")
baseline := scratchFileSnapshot(root)
scratchPath := filepath.Join(root, "_debug file.py")
if err := os.WriteFile(scratchPath, []byte("print('debug')"), 0o644); err != nil {
t.Fatal(err)
}
warning := scratchFileWarning(root, baseline)
if warning == "" {
t.Fatal("expected a warning about the untracked scratch file")
}
if !strings.Contains(warning, "_debug file.py") {
t.Fatalf("expected warning to mention _debug file.py, got %q", warning)
}
}
func TestScratchFileWarningDetectsShellCreatedScratchFile(t *testing.T) {
root := t.TempDir()
runGitForScratchTest(t, root, "init")
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGitForScratchTest(t, root, "add", "README.md")
runGitForScratchTest(t, root, "commit", "-m", "init")
baseline := scratchFileSnapshot(root)
// Simulate a shell heredoc/redirect creating a scratch file. The warning is
// based on the git before/after state, not only FileTracker-created paths.
if err := os.WriteFile(filepath.Join(root, "_fix_test.py"), []byte("print('debug')"), 0o644); err != nil {
t.Fatal(err)
}
warning := scratchFileWarning(root, baseline)
if !strings.Contains(warning, "_fix_test.py") {
t.Fatalf("expected warning to mention shell-created _fix_test.py, got %q", warning)
}
}
func TestScratchFileWarningSilentForNormalDeliverableFile(t *testing.T) {
root := t.TempDir()
runGitForScratchTest(t, root, "init")
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGitForScratchTest(t, root, "add", "README.md")
runGitForScratchTest(t, root, "commit", "-m", "init")
baseline := scratchFileSnapshot(root)
if err := os.WriteFile(filepath.Join(root, "app.py"), []byte("print('app')"), 0o644); err != nil {
t.Fatal(err)
}
if warning := scratchFileWarning(root, baseline); warning != "" {
t.Fatalf("expected no warning for a normal new deliverable file, got %q", warning)
}
}
func TestScratchFileWarningSilentForUnderscoreDeliverableFile(t *testing.T) {
root := t.TempDir()
runGitForScratchTest(t, root, "init")
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGitForScratchTest(t, root, "add", "README.md")
runGitForScratchTest(t, root, "commit", "-m", "init")
baseline := scratchFileSnapshot(root)
if err := os.WriteFile(filepath.Join(root, "_generated.go"), []byte("package main\n"), 0o644); err != nil {
t.Fatal(err)
}
if warning := scratchFileWarning(root, baseline); warning != "" {
t.Fatalf("expected no warning for an underscore-prefixed deliverable file, got %q", warning)
}
}
func TestScratchFileWarningSilentWhenFileIsTracked(t *testing.T) {
root := t.TempDir()
runGitForScratchTest(t, root, "init")
baseline := scratchFileSnapshot(root)
trackedPath := filepath.Join(root, "_debug.py")
if err := os.WriteFile(trackedPath, []byte("print('app')"), 0o644); err != nil {
t.Fatal(err)
}
runGitForScratchTest(t, root, "add", "_debug.py")
runGitForScratchTest(t, root, "commit", "-m", "init")
// A scratch-like file subsequently staged/committed by the
// model itself is no longer a loose scratch file, so no warning.
if warning := scratchFileWarning(root, baseline); warning != "" {
t.Fatalf("expected no warning for a tracked file, got %q", warning)
}
}
func TestScratchFileWarningSilentWhenNoFilesCreated(t *testing.T) {
root := t.TempDir()
runGitForScratchTest(t, root, "init")
baseline := scratchFileSnapshot(root)
if warning := scratchFileWarning(root, baseline); warning != "" {
t.Fatalf("expected no warning when nothing was created, got %q", warning)
}
}
func TestScratchFileWarningSilentWhenFileWasRemovedAgain(t *testing.T) {
root := t.TempDir()
runGitForScratchTest(t, root, "init")
baseline := scratchFileSnapshot(root)
scratchPath := filepath.Join(root, "_debug.py")
if err := os.WriteFile(scratchPath, []byte("print('debug')"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Remove(scratchPath); err != nil {
t.Fatal(err)
}
// The model created and then cleaned up its own scratch file — nothing to warn about.
if warning := scratchFileWarning(root, baseline); warning != "" {
t.Fatalf("expected no warning for a removed file, got %q", warning)
}
}
func TestScratchFileWarningSilentForPreexistingScratchFile(t *testing.T) {
root := t.TempDir()
runGitForScratchTest(t, root, "init")
if err := os.WriteFile(filepath.Join(root, "_debug.py"), []byte("print('debug')"), 0o644); err != nil {
t.Fatal(err)
}
baseline := scratchFileSnapshot(root)
if warning := scratchFileWarning(root, baseline); warning != "" {
t.Fatalf("expected no warning for a pre-existing scratch file, got %q", warning)
}
}
func TestScratchFileWarningSilentWhenNotAGitRepo(t *testing.T) {
root := t.TempDir()
scratchPath := filepath.Join(root, "_debug.py")
if err := os.WriteFile(scratchPath, []byte("print('debug')"), 0o644); err != nil {
t.Fatal(err)
}
if warning := scratchFileWarning(root, scratchFileSnapshot(root)); warning != "" {
t.Fatalf("expected no warning outside a git repo, got %q", warning)
}
}
+41 -27
View File
@@ -3,7 +3,6 @@ package cli
import (
"fmt"
"io"
"net"
"net/url"
"strconv"
"strings"
@@ -322,9 +321,7 @@ func setupRequired(resolved config.ResolvedConfig) bool {
// the candidate set is permissive (profile name + catalog ID).
func providerHasOAuthLogin(profile config.ProviderProfile, oauthLogins map[string]bool) bool {
for _, name := range profile.OAuthLoginCandidates() {
// Store keys are normalized to lower case (oauth.ProviderKey), so the
// presence check must compare the same way.
if oauthLogins[strings.ToLower(strings.TrimSpace(name))] {
if oauthLogins[name] {
return true
}
}
@@ -346,19 +343,18 @@ func oauthLoggedInProviders() map[string]bool {
}
for _, status := range statuses {
if status.HasToken {
out[strings.ToLower(strings.TrimPrefix(status.Key, oauth.KeyPrefixProvider))] = true
out[strings.TrimPrefix(status.Key, oauth.KeyPrefixProvider)] = true
}
}
return out
}
// firstUsableProvider returns the saved provider best suited to run without
// onboarding: the first usable (inline credential, stored OAuth login, or
// no-auth/local) non-local provider, else the first usable local one. It lets
// the CLI fall back to an already-configured login when the active provider
// happens to lack a credential, instead of re-running onboarding every launch.
// onboarding: the first usable (inline credential present, or no-auth/local)
// non-local provider, else the first usable local one. It lets the CLI fall back
// to an already-configured login when the active provider happens to lack a
// credential, instead of re-running onboarding every launch.
func firstUsableProvider(providers []config.ProviderProfile) (config.ProviderProfile, bool) {
logins := oauthLoggedInProviders()
var localFallback config.ProviderProfile
haveLocal := false
for _, profile := range providers {
@@ -374,11 +370,7 @@ func firstUsableProvider(providers []config.ProviderProfile) (config.ProviderPro
continue
}
}
// A stored OAuth login (e.g. `zero auth login xai`) is a credential too, even
// when the profile has no inline key / env var — mirrors setupRequired and
// usableSavedProviders so this fallback doesn't force onboarding for a
// provider the user is already authenticated with.
if _, missing := setupMissingCredentialEnv(profile); missing && !providerHasOAuthLogin(profile, logins) {
if _, missing := setupMissingCredentialEnv(profile); missing {
continue
}
if providerProfileIsLocal(profile) {
@@ -461,11 +453,6 @@ func providerProfileIsLocal(profile config.ProviderProfile) bool {
case "localhost", "127.0.0.1", "::1":
return true
}
// Also treat explicit private-network IPs (192.168.x.y, 10.x.y.z,
// 172.16-31.x.y) as local — the user is pointing at their own LAN box.
if ip := net.ParseIP(parsed.Hostname()); ip != nil && ip.IsPrivate() {
return true
}
return false
}
@@ -503,14 +490,41 @@ func setupTryThisExample(profile config.ProviderProfile) string {
return example
}
// setupMissingCredentialEnv reports the environment variable a saved profile
// is expected to read an API key from, and whether that expectation is
// unmet. It delegates to config.ProviderProfile.MissingCredentialEnv, the
// single source of truth shared with the TUI's provider status/wizard
// surfaces and the CLI's `providers check` readiness gate, so all three never
// disagree about whether a saved provider is missing a credential.
func setupMissingCredentialEnv(profile config.ProviderProfile) (string, bool) {
return profile.MissingCredentialEnv()
if providerProfileHasCredential(profile) {
return "", false
}
if catalogID := strings.TrimSpace(profile.CatalogID); catalogID != "" {
descriptor, err := providercatalog.Require(catalogID)
if err != nil || !descriptor.RequiresAuth {
return "", false
}
return firstNonEmptyCLI(profile.APIKeyEnv, setupProviderEnvVar(descriptor)), true
}
switch normalizedSetupProviderKind(profile) {
case config.ProviderKindOpenAI, config.ProviderKindOpenAICompatible:
return firstNonEmptyCLI(profile.APIKeyEnv, "OPENAI_API_KEY"), true
case config.ProviderKindAnthropic, config.ProviderKindAnthropicCompat:
return firstNonEmptyCLI(profile.APIKeyEnv, "ANTHROPIC_API_KEY"), true
case config.ProviderKindGoogle:
return firstNonEmptyCLI(profile.APIKeyEnv, "GEMINI_API_KEY"), true
default:
if strings.TrimSpace(profile.APIKeyEnv) != "" {
return strings.TrimSpace(profile.APIKeyEnv), true
}
return "", false
}
}
func normalizedSetupProviderKind(profile config.ProviderProfile) config.ProviderKind {
if kind := strings.TrimSpace(string(profile.ProviderKind)); kind != "" {
return config.ProviderKind(strings.ToLower(kind))
}
if provider := strings.TrimSpace(profile.Provider); provider != "" {
return config.ProviderKind(strings.ToLower(provider))
}
return ""
}
func setupCheckCommand(name string) string {
-62
View File
@@ -2,14 +2,11 @@ package cli
import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
)
func TestFirstUsableProviderPrefersRemoteKeyed(t *testing.T) {
withAuthStore(t)
providers := []config.ProviderProfile{
{Name: "ollama", CatalogID: "ollama", BaseURL: "http://localhost:11434/v1", APIKey: "k"}, // usable but local
{Name: "moonshot", CatalogID: "moonshot", BaseURL: "https://api.moonshot.ai/v1", APIKey: "k"}, // usable, remote
@@ -22,7 +19,6 @@ func TestFirstUsableProviderPrefersRemoteKeyed(t *testing.T) {
}
func TestFirstUsableProviderFallsBackToLocal(t *testing.T) {
withAuthStore(t)
providers := []config.ProviderProfile{
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"}, // not usable
{Name: "ollama", CatalogID: "ollama", BaseURL: "http://localhost:11434/v1", APIKey: "k"}, // local, usable
@@ -34,7 +30,6 @@ func TestFirstUsableProviderFallsBackToLocal(t *testing.T) {
}
func TestFirstUsableProviderNoneUsable(t *testing.T) {
withAuthStore(t)
providers := []config.ProviderProfile{
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"},
{Name: "openai", CatalogID: "openai", APIKeyEnv: "OPENAI_API_KEY"},
@@ -47,10 +42,6 @@ func TestFirstUsableProviderNoneUsable(t *testing.T) {
// A keyless local proxy (chatgpt-proxy, RequiresAuth=false) is usable without a
// credential, so it can serve as a fallback rather than forcing onboarding.
func TestFirstUsableProviderAcceptsKeylessLocalProxy(t *testing.T) {
// Isolate the OAuth token store: on a developer machine with a real xai
// login, the env-only xai profile would count as usable and win over the
// local proxy this test is about.
withAuthStore(t)
providers := []config.ProviderProfile{
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"},
{Name: "chatgpt", CatalogID: "chatgpt-proxy", BaseURL: "http://localhost:10531/v1"},
@@ -65,7 +56,6 @@ func TestFirstUsableProviderAcceptsKeylessLocalProxy(t *testing.T) {
// BaseURL has no endpoint, so it must be skipped rather than selected as a
// fallback that fails at first use. A stale CatalogID with a BaseURL still works.
func TestFirstUsableProviderSkipsUnresolvableCatalogWithoutBaseURL(t *testing.T) {
withAuthStore(t)
providers := []config.ProviderProfile{
{Name: "ghost", CatalogID: "no-such-catalog-entry", APIKey: "k"}, // unusable: no endpoint
{Name: "custom", CatalogID: "no-such-catalog-entry", BaseURL: "https://api.custom.test/v1", APIKey: "k"},
@@ -76,29 +66,6 @@ func TestFirstUsableProviderSkipsUnresolvableCatalogWithoutBaseURL(t *testing.T)
}
}
// An OAuth-only provider (no inline key, no env var) must be selectable as a
// fallback, matching setupRequired/usableSavedProviders — otherwise a fully
// authenticated user gets forced back into onboarding when activeProvider
// goes stale.
func TestFirstUsableProviderRecognizesOAuthLogin(t *testing.T) {
withAuthStore(t)
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
if err := store.Save(oauth.ProviderKey("xai"), oauth.Token{AccessToken: "tok", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
t.Fatalf("seed token: %v", err)
}
providers := []config.ProviderProfile{
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"}, // no inline key/env, but logged in via OAuth
}
got, ok := firstUsableProvider(providers)
if !ok || got.Name != "xai" {
t.Fatalf("want OAuth-logged-in provider (xai), got %q ok=%v", got.Name, ok)
}
}
func TestProviderProfileIsLocal(t *testing.T) {
cases := []struct {
name string
@@ -122,32 +89,3 @@ func TestProviderProfileIsLocal(t *testing.T) {
})
}
}
// TestFirstUsableProviderAcceptsOAuthLoginProfile: a keyless catalog profile
// whose credential is a stored OAuth login (the shape the login flows persist)
// must be usable during active-pointer recovery — the same rule setupRequired
// and usableSavedProviders apply — instead of falling through to onboarding.
func TestFirstUsableProviderAcceptsOAuthLoginProfile(t *testing.T) {
withAuthStore(t)
providers := []config.ProviderProfile{
{Name: "chatgpt", CatalogID: "chatgpt", BaseURL: "https://chatgpt.com/backend-api/codex", Model: "gpt-5.5"},
}
// No login stored: still not usable.
if got, ok := firstUsableProvider(providers); ok {
t.Fatalf("keyless profile without a login must not be usable, got %q", got.Name)
}
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
t.Fatalf("oauth store: %v", err)
}
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "bearer-123"}); err != nil {
t.Fatalf("save token: %v", err)
}
got, ok := firstUsableProvider(providers)
if !ok || got.Name != "chatgpt" {
t.Fatalf("want OAuth-login profile to be usable, got %q ok=%v", got.Name, ok)
}
}
-48
View File
@@ -64,54 +64,6 @@ func TestSetupMissingCredentialEnv(t *testing.T) {
},
want: false,
},
{
// Regression for issue #555: a custom OpenAI-compatible profile saved
// with no credential (e.g. a local llama.cpp server with no auth) must
// not be reported as missing one, or it gets filtered out of /model on
// the next run even though it was configured exactly as intended.
name: "custom openai compatible with no credential configured",
profile: config.ProviderProfile{
Name: "local-llama",
CatalogID: "custom-openai-compatible",
BaseURL: "http://192.168.1.50:8080/v1",
},
want: false,
},
{
name: "custom openai compatible with explicit api key env",
profile: config.ProviderProfile{
Name: "local-llama",
CatalogID: "custom-openai-compatible",
BaseURL: "http://192.168.1.50:8080/v1",
APIKeyEnv: "LLAMA_CPP_API_KEY",
},
wantEnv: "LLAMA_CPP_API_KEY",
want: true,
},
{
// Self-heal regression: profiles saved by the pre-fix wizard were
// stamped with the catalog's own guessed default (OPENAI_API_KEY)
// even when the endpoint needed no auth. That stale value must be
// treated the same as unset so already-broken saved profiles start
// working again without a config.json rewrite.
name: "custom openai compatible with stale legacy default env",
profile: config.ProviderProfile{
Name: "local-llama",
CatalogID: "custom-openai-compatible",
BaseURL: "http://192.168.1.50:8080/v1",
APIKeyEnv: "OPENAI_API_KEY",
},
want: false,
},
{
name: "custom anthropic compatible with no credential configured",
profile: config.ProviderProfile{
Name: "local-proxy",
CatalogID: "custom-anthropic-compatible",
BaseURL: "http://localhost:9000/anthropic",
},
want: false,
},
}
for _, tt := range tests {
-119
View File
@@ -1,119 +0,0 @@
package cli
import (
"fmt"
"io"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/workspacetrust"
)
// runTrust implements `zero trust`, letting the user opt a workspace into running
// its project-scoped executable config (hooks, plugins, MCP servers). Trust is
// keyed on the exact normalized working directory, matching the exact-match trust
// model and cwd-relative project-config discovery.
//
// zero trust trust the current working directory
// zero trust list print the trusted roots, one per line
// zero trust remove [path] untrust the current directory, or a named path
func runTrust(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
if len(args) == 0 {
return trustCurrentDir(stdout, stderr, deps)
}
switch args[0] {
case "list":
return trustList(stdout, stderr)
case "remove", "rm", "untrust":
return trustRemove(args[1:], stdout, stderr, deps)
case "-h", "--help", "help":
// Explicit help is a success path: write usage to stdout and exit 0, matching
// the other subcommands (mcp, sandbox, skills, cron, ...). Only the unknown-
// subcommand error path below writes usage to stderr with a usage exit code.
writeTrustUsage(stdout)
return exitSuccess
default:
if _, err := fmt.Fprintf(stderr, "zero trust: unknown subcommand %q\n\n", args[0]); err != nil {
return exitCrash
}
writeTrustUsage(stderr)
return exitUsage
}
}
// trustCurrentDir trusts the exact current working directory.
func trustCurrentDir(stdout io.Writer, stderr io.Writer, deps appDeps) int {
cwd, err := deps.getwd()
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(fmt.Errorf("resolve workspace: %w", err), redaction.Options{}), exitCrash)
}
if err := workspacetrust.Trust(cwd); err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if _, err := fmt.Fprintf(stdout, "Trusted %s\n", cwd); err != nil {
return exitCrash
}
return exitSuccess
}
// trustList prints each trusted root, one per line, or a friendly line when none
// are trusted.
func trustList(stdout io.Writer, stderr io.Writer) int {
roots, err := workspacetrust.List()
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if len(roots) == 0 {
if _, err := fmt.Fprintln(stdout, "No trusted workspaces."); err != nil {
return exitCrash
}
return exitSuccess
}
for _, root := range roots {
if _, err := fmt.Fprintln(stdout, root); err != nil {
return exitCrash
}
}
return exitSuccess
}
// trustRemove untrusts the current directory, or a named path when one is given.
// The path argument is passed to workspacetrust.Untrust verbatim, which normalizes
// it (filepath.Abs + filepath.EvalSymlinks) the same way the store does, so a
// relative or trailing-slash argument still matches the stored entry.
func trustRemove(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
var target string
switch len(args) {
case 0:
cwd, err := deps.getwd()
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(fmt.Errorf("resolve workspace: %w", err), redaction.Options{}), exitCrash)
}
target = cwd
case 1:
target = args[0]
default:
if _, err := fmt.Fprintln(stderr, "usage: zero trust remove [path]"); err != nil {
return exitCrash
}
return exitUsage
}
if err := workspacetrust.Untrust(target); err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if _, err := fmt.Fprintf(stdout, "Untrusted %s\n", target); err != nil {
return exitCrash
}
return exitSuccess
}
func writeTrustUsage(w io.Writer) {
_, _ = fmt.Fprint(w, `Usage:
zero trust Trust the current working directory
zero trust list List trusted workspace roots
zero trust remove [path] Untrust the current directory, or a named path
Trust lets Zero run a workspace's project-scoped hooks, plugins, and MCP
servers (./.zero/hooks.json, ./.zero/plugins/, project MCP config). Trust is
exact per directory.
`)
}
-363
View File
@@ -1,363 +0,0 @@
package cli
import (
"bytes"
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/workspacetrust"
"github.com/Gitlawb/zero/internal/worktrees"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// These tests close the end-to-end gap the chokepoint unit tests leave open:
// they drive the REAL agent loop (and, for the worktree cases, the real exec
// entry point) with a fake provider that emits a tool call, so a project
// beforeTool hook actually fires (or is gated away) through production code, not
// a direct dispatcher call.
// markerTool is a minimal, always-allowed tool the fake provider "calls" so the
// agent loop reaches dispatchBeforeTool (which only fires for a registered,
// permitted tool). Its own Run is a no-op; the observable effect is the project
// beforeTool hook the gate did or did not load.
type markerTool struct{}
func (markerTool) Name() string { return "marker_tool" }
func (markerTool) Description() string { return "test-only no-op tool" }
func (markerTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (markerTool) Safety() tools.Safety { return tools.Safety{Permission: tools.PermissionAllow} }
func (markerTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
// toolThenTextProvider calls toolName on the first turn, then answers with text so
// the loop terminates. It detects "first turn" by the absence of a prior tool
// result in the message history.
type toolThenTextProvider struct{ toolName string }
func (p toolThenTextProvider) StreamCompletion(_ context.Context, req zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
toolAlreadyCalled := false
for _, m := range req.Messages {
if m.Role == zeroruntime.MessageRoleTool {
toolAlreadyCalled = true
break
}
}
ch := make(chan zeroruntime.StreamEvent, 8)
go func() {
defer close(ch)
if toolAlreadyCalled {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
return
}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: p.toolName}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "c1", ArgumentsFragment: `{"pattern":"*"}`}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
}()
return ch, nil
}
// writeMarkerHook writes a ./.zero/hooks.json under dir whose enabled beforeTool
// hook touches markerPath when it fires. The hook shells out to /bin/sh, so the
// trusted-case assertion (the marker must appear) is meaningless on Windows, where
// that interpreter does not exist; skip there, matching writeMarkerHookScript.
func writeMarkerHook(t *testing.T, dir, markerPath string) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("marker hook is POSIX-shell based (/bin/sh)")
}
if err := os.MkdirAll(filepath.Join(dir, ".zero"), 0o755); err != nil {
t.Fatal(err)
}
body := `{"enabled":true,"hooks":[{"id":"m","event":"beforeTool","command":"/bin/sh","args":["-c","touch '` + markerPath + `'"],"enabled":true}]}`
if err := os.WriteFile(filepath.Join(dir, ".zero", "hooks.json"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
// TestTrustGateBlocksToolHookThroughAgentRun closes gap #2: the gate blocks a
// real tool call's beforeTool hook through the production agent.Run loop, not
// just a direct dispatcher call. Untrusted => the project hook is not in the
// dispatcher, so the tool call fires nothing. Trusted => it fires.
func TestTrustGateBlocksToolHookThroughAgentRun(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir()
marker := filepath.Join(t.TempDir(), "marker")
writeMarkerHook(t, repo, marker)
runOnce := func() {
reg := tools.NewRegistry()
reg.Register(markerTool{})
disp, _ := newHookDispatcherWithExtra(repo, nil, repo)
if _, err := agent.Run(context.Background(), "go", toolThenTextProvider{toolName: "marker_tool"}, agent.Options{
Registry: reg,
Hooks: disp,
PermissionMode: agent.PermissionModeUnsafe,
MaxTurns: 3,
}); err != nil {
t.Fatalf("agent.Run: %v", err)
}
}
// Untrusted: gate excludes the project layer; the beforeTool hook must not run.
_ = os.Remove(marker)
runOnce()
if _, err := os.Stat(marker); err == nil {
t.Fatal("UNTRUSTED: project beforeTool hook ran through agent.Run (marker exists) -- gate failed OPEN")
}
// Trusted: the hook is in the dispatcher; the tool call fires it.
if err := workspacetrust.Trust(repo); err != nil {
t.Fatal(err)
}
_ = os.Remove(marker)
runOnce()
if _, err := os.Stat(marker); err != nil {
t.Fatalf("TRUSTED: project beforeTool hook did NOT run (marker absent): %v", err)
}
}
// TestTrustGateFiresInDefaultAutoMode is the in-scope proof for the security
// report: the beforeTool hook fires in the DEFAULT permission mode (auto), NOT
// only under --skip-permissions-unsafe. marker_tool has PermissionAllow safety,
// so effectivePermission grants it at loop.go without a prompt regardless of
// mode; dispatchBeforeTool then runs the project hook. This means the
// vulnerability is reachable in normal operation with the sandbox ON, so it is
// not the "requires the user to disable the sandbox" out-of-scope case.
//
// Trusted => the hook fires in auto mode without any unsafe flag; untrusted =>
// the gate blocks it, also in auto mode. No OnPermissionRequest is wired: an
// auto-allowed tool needs no approval callback, which is the whole point.
func TestTrustGateFiresInDefaultAutoMode(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir()
marker := filepath.Join(t.TempDir(), "marker")
writeMarkerHook(t, repo, marker)
runOnce := func(mode agent.PermissionMode) {
reg := tools.NewRegistry()
reg.Register(markerTool{})
disp, _ := newHookDispatcherWithExtra(repo, nil, repo)
if _, err := agent.Run(context.Background(), "go", toolThenTextProvider{toolName: "marker_tool"}, agent.Options{
Registry: reg,
Hooks: disp,
PermissionMode: mode,
MaxTurns: 3,
}); err != nil {
t.Fatalf("agent.Run (%s): %v", mode, err)
}
}
// Untrusted, default auto mode: the gate excludes the project layer, so the
// hook must NOT fire even though the tool call is permitted.
_ = os.Remove(marker)
runOnce(agent.PermissionModeAuto)
if _, err := os.Stat(marker); err == nil {
t.Fatal("UNTRUSTED auto mode: project beforeTool hook ran -- gate failed OPEN in the default mode")
}
// Trusted, default auto mode (sandbox on, NO --skip-permissions-unsafe): the
// auto-allowed tool is granted with no prompt and its beforeTool hook fires.
if err := workspacetrust.Trust(repo); err != nil {
t.Fatal(err)
}
_ = os.Remove(marker)
runOnce(agent.PermissionModeAuto)
if _, err := os.Stat(marker); err != nil {
t.Fatalf("TRUSTED auto mode: project beforeTool hook did NOT fire without an unsafe flag (marker absent): %v", err)
}
// And it is not an auto-only quirk: ask mode (also sandboxed, non-unsafe)
// fires the same way for an auto-allowed tool.
_ = os.Remove(marker)
runOnce(agent.PermissionModeAsk)
if _, err := os.Stat(marker); err != nil {
t.Fatalf("TRUSTED ask mode: project beforeTool hook did NOT fire (marker absent): %v", err)
}
}
// TestExecSurfacesMCPTrustNotice proves the exec path actually threads the MCP trust
// skip into the one-line notice (the CodeRabbit finding), not just that the unit
// pieces work in isolation: an untrusted repo whose only project config is MCP prints
// the notice through the real runExec wiring; trusting it silences it. resolveMCPConfig
// is stubbed to return no servers so nothing spawns -- the notice depends only on the
// trust verdict and the real ./.zero/config.json that projectMCPConfigExists reads.
func TestExecSurfacesMCPTrustNotice(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("exec fake-provider harness assumes a POSIX process environment")
}
setTrustConfigRoot(t)
repo := t.TempDir()
if err := os.MkdirAll(filepath.Join(repo, ".zero"), 0o700); err != nil {
t.Fatal(err)
}
body := `{"mcp":{"servers":{"proj":{"type":"stdio","command":"proj-cmd"}}}}`
if err := os.WriteFile(filepath.Join(repo, ".zero", "config.json"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
run := func() string {
var out, errBuf bytes.Buffer
code := runWithDeps([]string{"exec", "--skip-permissions-unsafe", "--max-turns", "3", "go"}, &out, &errBuf, appDeps{
getwd: func() (string, error) { return repo, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) { return execResolvedConfig(), nil },
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return toolThenTextProvider{toolName: "glob"}, nil
},
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
})
if code != exitSuccess {
t.Fatalf("exec exit = %d, stderr=%q", code, errBuf.String())
}
return errBuf.String()
}
// Untrusted: the project MCP layer is dropped, so the notice must name it. The repo
// has no project hooks/plugins, so the MCP skip is the ONLY thing that can fire it.
untrusted := run()
if !strings.Contains(untrusted, "MCP servers") || !strings.Contains(untrusted, "zero trust") {
t.Fatalf("untrusted exec must surface the MCP trust notice, stderr=%q", untrusted)
}
// Trusted: nothing is skipped, so no notice at all.
if err := workspacetrust.Trust(repo); err != nil {
t.Fatal(err)
}
if trusted := run(); strings.Contains(trusted, "ignoring project") {
t.Fatalf("trusted exec must not emit a trust notice, stderr=%q", trusted)
}
}
// TestExecSpecSurfacesMCPTrustNotice is the --use-spec analogue of the test above:
// the spec-draft path (exec_spec.go) must also thread the MCP skip into its notice.
// The fake provider never submits a spec, so the run exits non-zero; that is
// orthogonal to trust, so we assert only the notice, not the exit code.
func TestExecSpecSurfacesMCPTrustNotice(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("exec fake-provider harness assumes a POSIX process environment")
}
setTrustConfigRoot(t)
repo := t.TempDir()
if err := os.MkdirAll(filepath.Join(repo, ".zero"), 0o700); err != nil {
t.Fatal(err)
}
body := `{"mcp":{"servers":{"proj":{"type":"stdio","command":"proj-cmd"}}}}`
if err := os.WriteFile(filepath.Join(repo, ".zero", "config.json"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
var out, errBuf bytes.Buffer
_ = runWithDeps([]string{"exec", "--use-spec", "--skip-permissions-unsafe", "--max-turns", "3", "go"}, &out, &errBuf, appDeps{
getwd: func() (string, error) { return repo, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) { return execResolvedConfig(), nil },
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return toolThenTextProvider{toolName: "glob"}, nil
},
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
})
if !strings.Contains(errBuf.String(), "MCP servers") || !strings.Contains(errBuf.String(), "zero trust") {
t.Fatalf("untrusted --use-spec exec must surface the MCP trust notice, stderr=%q", errBuf.String())
}
}
// runExecTrust drives the full exec entry point with a fake worktree and a
// tool-calling provider, returning the exit code. The provider calls the core
// "glob" tool so dispatchBeforeTool fires inside the real exec-built registry.
func runExecTrust(t *testing.T, extraArgs []string, launchDir, worktreeDir string) int {
t.Helper()
t.Setenv("XDG_DATA_HOME", t.TempDir())
args := append([]string{"exec", "--worktree", "--skip-permissions-unsafe", "--max-turns", "3"}, extraArgs...)
args = append(args, "go")
var out, errBuf bytes.Buffer
return runWithDeps(args, &out, &errBuf, appDeps{
getwd: func() (string, error) { return launchDir, nil },
prepareWorktree: func(context.Context, worktrees.Options) (worktrees.Result, error) {
return worktrees.Result{Path: worktreeDir}, nil
},
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return toolThenTextProvider{toolName: "glob"}, nil
},
})
}
// TestExecWorktreeInheritsTrustEndToEnd closes gap #1 for the exec path: trust is
// keyed on the ORIGINAL launch dir, not the generated worktree path. The worktree
// checkout carries the committed .zero/hooks.json; the gate must load it only when
// the SOURCE repo (launch dir) is trusted, proving exec.go captures trustRoot
// before the --worktree reassignment and threads it into the chokepoint.
func TestExecWorktreeInheritsTrustEndToEnd(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir() // original launch dir -- the trust key
worktree := t.TempDir() // simulated worktree checkout (workspaceRoot after reassignment)
marker := filepath.Join(t.TempDir(), "marker")
writeMarkerHook(t, worktree, marker) // the checkout carries the committed hook
// Untrusted source repo: worktree hooks must NOT run.
_ = os.Remove(marker)
if code := runExecTrust(t, nil, repo, worktree); code != exitSuccess {
t.Fatalf("exec --worktree (untrusted) exit = %d", code)
}
if _, err := os.Stat(marker); err == nil {
t.Fatal("UNTRUSTED worktree: project hook ran -- exec keyed trust on the worktree path or failed open")
}
// Trusted source repo: the worktree inherits its trust, so the hook runs.
if err := workspacetrust.Trust(repo); err != nil {
t.Fatal(err)
}
_ = os.Remove(marker)
if code := runExecTrust(t, nil, repo, worktree); code != exitSuccess {
t.Fatalf("exec --worktree (trusted) exit = %d", code)
}
if _, err := os.Stat(marker); err != nil {
t.Fatalf("TRUSTED worktree: project hook did NOT run (marker absent) -- worktree trust inheritance broken: %v", err)
}
}
// TestExecSpecWorktreeInheritsTrustEndToEnd closes gap #1 for the spec-draft path
// (the exact --use-spec --worktree combination the review fix addressed): the
// spec-draft chokepoint must also key trust on the original launch dir.
func TestExecSpecWorktreeInheritsTrustEndToEnd(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir()
worktree := t.TempDir()
marker := filepath.Join(t.TempDir(), "marker")
writeMarkerHook(t, worktree, marker)
// The spec-draft flow itself exits non-zero here (the fake provider does not
// submit a real spec), which is orthogonal to trust: the hook dispatcher is
// built (keyed on run.trustRoot) before the agent runs, and glob (a read-only
// allow tool) is advertised in spec-draft, so beforeTool still fires. We assert
// only the marker, the trust behavior, not the spec-flow exit code.
// Untrusted: spec-draft in a worktree of an untrusted repo runs no project hook.
_ = os.Remove(marker)
_ = runExecTrust(t, []string{"--use-spec"}, repo, worktree)
if _, err := os.Stat(marker); err == nil {
t.Fatal("UNTRUSTED spec-draft worktree: project hook ran -- spec-draft keyed trust on the worktree path")
}
// Trusted: the spec-draft path inherits the source repo's trust.
if err := workspacetrust.Trust(repo); err != nil {
t.Fatal(err)
}
_ = os.Remove(marker)
_ = runExecTrust(t, []string{"--use-spec"}, repo, worktree)
if _, err := os.Stat(marker); err != nil {
t.Fatalf("TRUSTED spec-draft worktree: project hook did NOT run -- spec-draft trust inheritance broken: %v", err)
}
}
-466
View File
@@ -1,466 +0,0 @@
package cli
import (
"bytes"
"context"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/plugins"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/workspacetrust"
)
// setTrustConfigRoot redirects both the workspace-trust store and the user-level
// hooks/plugins config to a fresh temp dir with a GOOS-aware env switch, mirroring
// setUserConfigRoot in internal/config/paths_test.go, and returns that dir so tests
// can build the exact paths the code resolves to.
//
// XDG_CONFIG_HOME is the lever on macOS as well as Linux: config.UserConfigDir is
// XDG-first there (it only falls back to $HOME/.config when XDG_CONFIG_HOME is
// unset), and the hooks loader resolves its user layer from XDG_CONFIG_HOME too. An
// earlier version set HOME on darwin and returned the raw root; that left the store
// at <root>/.config/zero while the tests wrote to <root>/zero, so the store-error
// and user-hook cases silently missed. Only Windows needs its own variable (APPDATA,
// what os.UserConfigDir consults). XDG_DATA_HOME is redirected so the hook audit
// store never touches the user's real data dir.
func setTrustConfigRoot(t *testing.T) string {
t.Helper()
root := t.TempDir()
switch runtime.GOOS {
case "windows":
t.Setenv("APPDATA", root)
default:
t.Setenv("XDG_CONFIG_HOME", root)
}
t.Setenv("XDG_DATA_HOME", t.TempDir())
return root
}
// writeMarkerHookScript writes an executable script that creates markerPath when
// run, and returns its absolute path. The hook dispatcher runs commands directly
// (no shell), so a real executable is needed to observe execution.
func writeMarkerHookScript(t *testing.T, markerPath string) string {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("marker-hook script is POSIX-shell based")
}
dir := t.TempDir()
script := filepath.Join(dir, "mark.sh")
body := "#!/bin/sh\n: > " + shellQuote(markerPath) + "\n"
if err := os.WriteFile(script, []byte(body), 0o700); err != nil {
t.Fatalf("write marker hook script: %v", err)
}
return script
}
func shellQuote(path string) string {
return "'" + path + "'"
}
// writeProjectHooks writes a project ./.zero/hooks.json under repo with the given
// hooks and top-level enabled flag.
func writeProjectHooks(t *testing.T, repo string, enabled bool, defs ...hooks.Definition) {
t.Helper()
path := filepath.Join(repo, ".zero", "hooks.json")
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatalf("mkdir project .zero: %v", err)
}
if err := hooks.WriteConfig(path, hooks.Config{Enabled: enabled, Hooks: defs}); err != nil {
t.Fatalf("write project hooks.json: %v", err)
}
}
// writeUserHooks writes a user-level hooks.json into the redirected config root.
func writeUserHooks(t *testing.T, configRoot string, enabled bool, defs ...hooks.Definition) {
t.Helper()
path := filepath.Join(configRoot, "zero", "hooks.json")
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatalf("mkdir user config dir: %v", err)
}
if err := hooks.WriteConfig(path, hooks.Config{Enabled: enabled, Hooks: defs}); err != nil {
t.Fatalf("write user hooks.json: %v", err)
}
}
func dispatchBeforeTool(dispatcher *hooks.Dispatcher) hooks.DispatchOutcome {
return dispatcher.Dispatch(context.Background(), hooks.DispatchInput{
Event: hooks.EventBeforeTool,
ToolName: "bash",
})
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// TestHookGateUntrustedExcludesProjectLayer proves R1: an untrusted trustRoot with
// an enabled project beforeTool hook runs nothing and writes no marker.
func TestHookGateUntrustedExcludesProjectLayer(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir()
marker := filepath.Join(t.TempDir(), "ran")
script := writeMarkerHookScript(t, marker)
writeProjectHooks(t, repo, true, hooks.Definition{
ID: "proj.mark", Event: hooks.EventBeforeTool, Command: script, Enabled: true,
})
dispatcher, skip := newHookDispatcherWithExtra(repo, nil, repo)
outcome := dispatchBeforeTool(dispatcher)
if outcome.Ran != 0 {
t.Fatalf("untrusted workspace should run 0 project hooks, Ran=%d", outcome.Ran)
}
if fileExists(marker) {
t.Fatalf("untrusted workspace must not execute the project hook (marker exists)")
}
if !skip.excludedProjectConfig {
t.Fatalf("skip report should flag the excluded project hooks file")
}
if skip.trustCheckErrored {
t.Fatalf("a clean untrusted verdict is not a store-read error")
}
}
// TestHookGateTrustedRunsProjectHook proves R3: after Trust(repo) the project hook
// runs and the marker appears.
func TestHookGateTrustedRunsProjectHook(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir()
marker := filepath.Join(t.TempDir(), "ran")
script := writeMarkerHookScript(t, marker)
writeProjectHooks(t, repo, true, hooks.Definition{
ID: "proj.mark", Event: hooks.EventBeforeTool, Command: script, Enabled: true,
})
if err := workspacetrust.Trust(repo); err != nil {
t.Fatalf("Trust(repo): %v", err)
}
dispatcher, skip := newHookDispatcherWithExtra(repo, nil, repo)
outcome := dispatchBeforeTool(dispatcher)
if outcome.Ran != 1 {
t.Fatalf("trusted workspace should run the project hook, Ran=%d", outcome.Ran)
}
if !fileExists(marker) {
t.Fatalf("trusted workspace must execute the project hook (marker missing)")
}
if skip.excludedProjectConfig {
t.Fatalf("a trusted workspace must not report the project layer as excluded")
}
}
// TestHookGateEmptyTrustRootFailsClosed proves the fail-closed-by-construction
// guard: a caller that forgot to resolve trustRoot (empty) still excludes the
// project layer, so no marker appears.
func TestHookGateEmptyTrustRootFailsClosed(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir()
marker := filepath.Join(t.TempDir(), "ran")
script := writeMarkerHookScript(t, marker)
writeProjectHooks(t, repo, true, hooks.Definition{
ID: "proj.mark", Event: hooks.EventBeforeTool, Command: script, Enabled: true,
})
// Even trusting the repo must not help when the caller passes an empty root.
if err := workspacetrust.Trust(repo); err != nil {
t.Fatalf("Trust(repo): %v", err)
}
dispatcher, skip := newHookDispatcherWithExtra(repo, nil, "")
outcome := dispatchBeforeTool(dispatcher)
if outcome.Ran != 0 {
t.Fatalf("empty trustRoot must fail closed, Ran=%d", outcome.Ran)
}
if fileExists(marker) {
t.Fatalf("empty trustRoot must not execute the project hook (marker exists)")
}
if !skip.excludedProjectConfig {
t.Fatalf("empty trustRoot should report the project layer excluded")
}
}
// TestHookGateFailClosedOnStoreError proves R5: a real IsTrusted error (trust.json
// created as a directory, per U1) is treated as untrusted, the project hook does
// not run, and the skip report marks the store-read error.
func TestHookGateFailClosedOnStoreError(t *testing.T) {
configRoot := setTrustConfigRoot(t)
// Create the trust store path as a DIRECTORY so os.ReadFile fails with a
// non-ErrNotExist error, forcing IsTrusted to return (false, non-nil).
trustPath := filepath.Join(configRoot, "zero", "trust.json")
if err := os.MkdirAll(trustPath, 0o700); err != nil {
t.Fatalf("create trust.json as a directory: %v", err)
}
repo := t.TempDir()
marker := filepath.Join(t.TempDir(), "ran")
script := writeMarkerHookScript(t, marker)
writeProjectHooks(t, repo, true, hooks.Definition{
ID: "proj.mark", Event: hooks.EventBeforeTool, Command: script, Enabled: true,
})
dispatcher, skip := newHookDispatcherWithExtra(repo, nil, repo)
outcome := dispatchBeforeTool(dispatcher)
if outcome.Ran != 0 {
t.Fatalf("a store-read error must fail closed, Ran=%d", outcome.Ran)
}
if fileExists(marker) {
t.Fatalf("a store-read error must not execute the project hook (marker exists)")
}
if !skip.trustCheckErrored {
t.Fatalf("skip report must mark the store-read error")
}
if !skip.excludedProjectConfig {
t.Fatalf("the project layer must be reported excluded on the error path")
}
}
// TestHookGateUserHookStillRunsWhenProjectExcluded proves R4/R10: with the project
// layer excluded, a user-level hook still fires, and a project hooks.json that sets
// global enabled:false or defines a same-ID hook cannot disable or override it.
func TestHookGateUserHookStillRunsWhenProjectExcluded(t *testing.T) {
configRoot := setTrustConfigRoot(t)
repo := t.TempDir()
marker := filepath.Join(t.TempDir(), "ran")
script := writeMarkerHookScript(t, marker)
// User hook that writes the marker.
writeUserHooks(t, configRoot, true, hooks.Definition{
ID: "shared.mark", Event: hooks.EventBeforeTool, Command: script, Enabled: true,
})
// Project config that would, if loaded, disable the whole surface (enabled:false)
// AND override the user hook by ID with a disabled no-op. The gate drops the
// whole project layer, so neither takes effect.
writeProjectHooks(t, repo, false, hooks.Definition{
ID: "shared.mark", Event: hooks.EventBeforeTool, Command: "true", Enabled: false,
})
// Untrusted: the project layer is excluded.
dispatcher, _ := newHookDispatcherWithExtra(repo, nil, repo)
outcome := dispatchBeforeTool(dispatcher)
if outcome.Ran != 1 {
t.Fatalf("user hook must still run when the project layer is excluded, Ran=%d", outcome.Ran)
}
if !fileExists(marker) {
t.Fatalf("user hook must fire (marker missing); project config must not disable/override it")
}
}
// TestHookGateWorktreeInheritsTrust proves the worktree case: with repo trusted,
// calling the chokepoint with a different workspaceRoot (the worktree path) but
// trustRoot=repo loads the project hooks, so trust keys on trustRoot not
// workspaceRoot.
func TestHookGateWorktreeInheritsTrust(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir()
worktree := t.TempDir() // a distinct, never-trusted path
marker := filepath.Join(t.TempDir(), "ran")
script := writeMarkerHookScript(t, marker)
// The project hooks live under the worktree (that is where config is read from).
writeProjectHooks(t, worktree, true, hooks.Definition{
ID: "proj.mark", Event: hooks.EventBeforeTool, Command: script, Enabled: true,
})
if err := workspacetrust.Trust(repo); err != nil {
t.Fatalf("Trust(repo): %v", err)
}
// workspaceRoot is the worktree, but trustRoot is the original repo.
dispatcher, skip := newHookDispatcherWithExtra(worktree, nil, repo)
outcome := dispatchBeforeTool(dispatcher)
if outcome.Ran != 1 {
t.Fatalf("a worktree of a trusted repo should load project hooks, Ran=%d", outcome.Ran)
}
if !fileExists(marker) {
t.Fatalf("worktree run should execute the project hook (marker missing)")
}
if skip.excludedProjectConfig {
t.Fatalf("worktree of a trusted repo must not report the project layer excluded")
}
}
// TestPluginGateUntrustedExcludesProject proves R2/R5: an untrusted trustRoot makes
// activatePlugins load with ExcludeProject=true and report the skip.
func TestPluginGateUntrustedExcludesProject(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir()
// A ./.zero/plugins dir so the skip report flags an excluded project config.
if err := os.MkdirAll(filepath.Join(repo, ".zero", "plugins"), 0o700); err != nil {
t.Fatalf("mkdir project plugins dir: %v", err)
}
var gotExclude bool
deps := appDeps{
loadPlugins: func(opts plugins.LoadOptions) (plugins.LoadResult, error) {
gotExclude = opts.ExcludeProject
return plugins.LoadResult{}, nil
},
skillsDir: func() string { return t.TempDir() },
}
registry := tools.NewRegistry()
var stderr bytes.Buffer
activation := activatePlugins(repo, registry, deps, &stderr, repo)
if !gotExclude {
t.Fatalf("untrusted workspace must pass ExcludeProject=true to loadPlugins")
}
if !activation.excludedProjectConfig {
t.Fatalf("skip report should flag the excluded ./.zero/plugins dir")
}
if activation.trustCheckErrored {
t.Fatalf("a clean untrusted verdict is not a store-read error")
}
}
// TestPluginGateTrustedIncludesProject proves R3: after Trust(repo), activatePlugins
// loads with ExcludeProject=false.
func TestPluginGateTrustedIncludesProject(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir()
if err := workspacetrust.Trust(repo); err != nil {
t.Fatalf("Trust(repo): %v", err)
}
var gotExclude bool
deps := appDeps{
loadPlugins: func(opts plugins.LoadOptions) (plugins.LoadResult, error) {
gotExclude = opts.ExcludeProject
return plugins.LoadResult{}, nil
},
skillsDir: func() string { return t.TempDir() },
}
registry := tools.NewRegistry()
var stderr bytes.Buffer
activation := activatePlugins(repo, registry, deps, &stderr, repo)
if gotExclude {
t.Fatalf("trusted workspace must pass ExcludeProject=false to loadPlugins")
}
if activation.excludedProjectConfig {
t.Fatalf("trusted workspace must not report the project config excluded")
}
}
// TestPluginGateFailClosedOnStoreError proves R5 for plugins: a store-read error
// forces ExcludeProject=true and marks the error.
func TestPluginGateFailClosedOnStoreError(t *testing.T) {
configRoot := setTrustConfigRoot(t)
trustPath := filepath.Join(configRoot, "zero", "trust.json")
if err := os.MkdirAll(trustPath, 0o700); err != nil {
t.Fatalf("create trust.json as a directory: %v", err)
}
repo := t.TempDir()
if err := os.MkdirAll(filepath.Join(repo, ".zero", "plugins"), 0o700); err != nil {
t.Fatalf("mkdir project plugins dir: %v", err)
}
var gotExclude bool
deps := appDeps{
loadPlugins: func(opts plugins.LoadOptions) (plugins.LoadResult, error) {
gotExclude = opts.ExcludeProject
return plugins.LoadResult{}, nil
},
skillsDir: func() string { return t.TempDir() },
}
registry := tools.NewRegistry()
var stderr bytes.Buffer
activation := activatePlugins(repo, registry, deps, &stderr, repo)
if !gotExclude {
t.Fatalf("a store-read error must fail closed (ExcludeProject=true)")
}
if !activation.trustCheckErrored {
t.Fatalf("skip report must mark the store-read error")
}
}
// TestEmitTrustNoticeOneLineWhenSkipped proves R6: exactly one notice when either
// surface skips project config, none when trusted, and distinct error text on a
// store-read failure.
func TestEmitTrustNoticeOneLineWhenSkipped(t *testing.T) {
t.Run("both surfaces skipped yields one line", func(t *testing.T) {
var buf bytes.Buffer
emitTrustNotice(&buf,
trustSkip{excludedProjectConfig: true},
trustSkip{excludedProjectConfig: true})
lines := nonEmptyLines(buf.String())
if len(lines) != 1 {
t.Fatalf("expected exactly one notice line, got %d: %q", len(lines), buf.String())
}
if !bytes.Contains(buf.Bytes(), []byte("zero trust")) {
t.Fatalf("notice should point at 'zero trust', got %q", buf.String())
}
})
t.Run("trusted yields no notice", func(t *testing.T) {
var buf bytes.Buffer
emitTrustNotice(&buf, trustSkip{}, trustSkip{})
if buf.Len() != 0 {
t.Fatalf("a trusted session must emit no notice, got %q", buf.String())
}
})
t.Run("store-read error uses distinct text", func(t *testing.T) {
var buf bytes.Buffer
emitTrustNotice(&buf,
trustSkip{excludedProjectConfig: true, trustCheckErrored: true},
trustSkip{})
lines := nonEmptyLines(buf.String())
if len(lines) != 1 {
t.Fatalf("expected exactly one notice line, got %d: %q", len(lines), buf.String())
}
if !bytes.Contains(buf.Bytes(), []byte("could not be read")) {
t.Fatalf("error-path notice should name the store read failure, got %q", buf.String())
}
})
t.Run("only one surface skipped still yields one line", func(t *testing.T) {
var buf bytes.Buffer
emitTrustNotice(&buf, trustSkip{excludedProjectConfig: true}, trustSkip{})
lines := nonEmptyLines(buf.String())
if len(lines) != 1 {
t.Fatalf("expected exactly one notice line, got %d: %q", len(lines), buf.String())
}
})
t.Run("mcp skip alone yields one line naming mcp", func(t *testing.T) {
var buf bytes.Buffer
// Hooks and plugins clean; only the MCP surface (third arg) dropped project config.
emitTrustNotice(&buf, trustSkip{}, trustSkip{}, trustSkip{excludedProjectConfig: true})
lines := nonEmptyLines(buf.String())
if len(lines) != 1 {
t.Fatalf("expected exactly one notice line, got %d: %q", len(lines), buf.String())
}
if !bytes.Contains(buf.Bytes(), []byte("MCP")) {
t.Fatalf("notice should name MCP when the MCP surface is skipped, got %q", buf.String())
}
})
t.Run("store error with nothing excluded yields no notice", func(t *testing.T) {
// trustCheckErrored but no surface had project config to skip (excludedProjectConfig
// false): there is nothing to warn about, so the excluded-gate wins over the error.
var buf bytes.Buffer
emitTrustNotice(&buf, trustSkip{trustCheckErrored: true}, trustSkip{trustCheckErrored: true})
if buf.Len() != 0 {
t.Fatalf("a store error with no skipped project config must emit nothing, got %q", buf.String())
}
})
}
func nonEmptyLines(s string) []string {
out := []string{}
for _, line := range bytes.Split([]byte(s), []byte("\n")) {
if len(bytes.TrimSpace(line)) > 0 {
out = append(out, string(line))
}
}
return out
}
-227
View File
@@ -1,227 +0,0 @@
package cli
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/workspacetrust"
)
// trustDeps builds a test appDeps whose getwd returns a fixed directory, so the
// trust command can be exercised without touching the real cwd.
func trustDeps(cwd string) appDeps {
return appDeps{getwd: func() (string, error) { return cwd, nil }}
}
// TestRunTrustAddThenList proves `trust` trusts the cwd and `trust list` shows it.
func TestRunTrustAddThenList(t *testing.T) {
setTrustConfigRoot(t)
cwd := t.TempDir()
deps := trustDeps(cwd)
var out, errBuf bytes.Buffer
if code := runTrust(nil, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("trust returned %d, want %d; stderr=%q", code, exitSuccess, errBuf.String())
}
if !strings.Contains(out.String(), "Trusted "+cwd) {
t.Fatalf("trust stdout = %q, want it to contain %q", out.String(), "Trusted "+cwd)
}
trusted, err := workspacetrust.IsTrusted(cwd)
if err != nil {
t.Fatalf("IsTrusted after trust: %v", err)
}
if !trusted {
t.Fatalf("cwd should be trusted after `trust`")
}
// The store canonicalizes with filepath.EvalSymlinks, so `list` prints the
// resolved path. On platforms where the temp dir is a symlink (macOS /var) or a
// Windows 8.3 short name (RUNNER~1 -> runneradmin), that differs from the raw
// cwd; compare against the same canonical form the store records.
wantListed := cwd
if resolved, err := filepath.EvalSymlinks(cwd); err == nil {
wantListed = resolved
}
out.Reset()
errBuf.Reset()
if code := runTrust([]string{"list"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("trust list returned %d, want %d; stderr=%q", code, exitSuccess, errBuf.String())
}
if !strings.Contains(out.String(), wantListed) {
t.Fatalf("trust list stdout = %q, want it to contain the trusted root %q", out.String(), wantListed)
}
}
// TestRunTrustListShowsCanonicalPath executes the canonical-path behavior that broke
// TestRunTrustAddThenList on the Windows runner (an 8.3 short name expanded by
// EvalSymlinks). It uses a symlinked cwd -- the portable, Linux-runnable analogue of
// that divergence -- and proves `trust list` prints the resolved path the store
// records, never the raw pre-resolution path.
func TestRunTrustListShowsCanonicalPath(t *testing.T) {
setTrustConfigRoot(t)
realDir := t.TempDir()
link := filepath.Join(t.TempDir(), "link")
if err := os.Symlink(realDir, link); err != nil {
t.Skipf("symlinks unavailable on this platform: %v", err)
}
resolved, err := filepath.EvalSymlinks(link)
if err != nil {
t.Fatalf("EvalSymlinks(link): %v", err)
}
if resolved == link {
t.Skip("symlink did not change the path on this platform")
}
deps := trustDeps(link)
if code := runTrust(nil, &bytes.Buffer{}, &bytes.Buffer{}, deps); code != exitSuccess {
t.Fatalf("trust returned %d", code)
}
var out, errBuf bytes.Buffer
if code := runTrust([]string{"list"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("trust list returned %d; stderr=%q", code, errBuf.String())
}
if !strings.Contains(out.String(), resolved) {
t.Fatalf("trust list = %q, want the canonical path %q", out.String(), resolved)
}
if strings.Contains(out.String(), link) {
t.Fatalf("trust list = %q, must not contain the raw pre-resolution path %q", out.String(), link)
}
}
// TestRunTrustRemoveCurrentDir proves `trust remove` (no arg) untrusts the cwd and
// `trust list` then reports none.
func TestRunTrustRemoveCurrentDir(t *testing.T) {
setTrustConfigRoot(t)
cwd := t.TempDir()
deps := trustDeps(cwd)
if code := runTrust(nil, &bytes.Buffer{}, &bytes.Buffer{}, deps); code != exitSuccess {
t.Fatalf("trust setup returned %d", code)
}
var out, errBuf bytes.Buffer
if code := runTrust([]string{"remove"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("trust remove returned %d, want %d; stderr=%q", code, exitSuccess, errBuf.String())
}
if !strings.Contains(out.String(), "Untrusted "+cwd) {
t.Fatalf("trust remove stdout = %q, want it to contain %q", out.String(), "Untrusted "+cwd)
}
trusted, err := workspacetrust.IsTrusted(cwd)
if err != nil {
t.Fatalf("IsTrusted after remove: %v", err)
}
if trusted {
t.Fatalf("cwd should not be trusted after `trust remove`")
}
out.Reset()
errBuf.Reset()
if code := runTrust([]string{"list"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("trust list returned %d, want %d", code, exitSuccess)
}
if !strings.Contains(out.String(), "No trusted workspaces.") {
t.Fatalf("trust list stdout = %q, want %q for an empty store", out.String(), "No trusted workspaces.")
}
}
// TestRunTrustRemoveNamedPathNonCanonical proves `trust remove <path>` untrusts a
// specific path, and that a non-canonical argument (a trailing slash) still matches
// the stored normalized entry.
func TestRunTrustRemoveNamedPathNonCanonical(t *testing.T) {
setTrustConfigRoot(t)
// The cwd is a different directory than the one we trust-and-remove, so this
// exercises the named-path branch, not the bare-cwd branch.
cwd := t.TempDir()
target := t.TempDir()
deps := trustDeps(cwd)
if err := workspacetrust.Trust(target); err != nil {
t.Fatalf("Trust(target): %v", err)
}
trusted, err := workspacetrust.IsTrusted(target)
if err != nil || !trusted {
t.Fatalf("target should be trusted before remove (trusted=%v err=%v)", trusted, err)
}
// Pass the target with a trailing slash: normalization must still match the
// stored canonical entry.
var out, errBuf bytes.Buffer
if code := runTrust([]string{"remove", target + "/"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("trust remove <path> returned %d, want %d; stderr=%q", code, exitSuccess, errBuf.String())
}
trusted, err = workspacetrust.IsTrusted(target)
if err != nil {
t.Fatalf("IsTrusted after named remove: %v", err)
}
if trusted {
t.Fatalf("target should be untrusted after `trust remove <path>/` despite the trailing slash")
}
}
// TestRunTrustUnknownSubcommand proves an unknown subcommand returns exit code 2 and
// writes usage to stderr (not stdout).
func TestRunTrustUnknownSubcommand(t *testing.T) {
setTrustConfigRoot(t)
deps := trustDeps(t.TempDir())
var out, errBuf bytes.Buffer
if code := runTrust([]string{"bogus"}, &out, &errBuf, deps); code != exitUsage {
t.Fatalf("unknown subcommand returned %d, want %d", code, exitUsage)
}
if errBuf.Len() == 0 {
t.Fatalf("unknown subcommand should write usage to stderr, stderr was empty")
}
if !strings.Contains(errBuf.String(), "trust") {
t.Fatalf("stderr usage = %q, want it to mention `trust`", errBuf.String())
}
if out.Len() != 0 {
t.Fatalf("unknown subcommand should not write to stdout, got %q", out.String())
}
}
// TestRunTrustRemoveTooManyArgs proves `trust remove a b` (more than one path) is a
// usage error: exit code 2, usage on stderr, nothing on stdout, and no store change.
func TestRunTrustRemoveTooManyArgs(t *testing.T) {
setTrustConfigRoot(t)
deps := trustDeps(t.TempDir())
var out, errBuf bytes.Buffer
if code := runTrust([]string{"remove", "a", "b"}, &out, &errBuf, deps); code != exitUsage {
t.Fatalf("remove with two args returned %d, want %d", code, exitUsage)
}
if errBuf.Len() == 0 {
t.Fatalf("remove with two args should write usage to stderr, stderr was empty")
}
if out.Len() != 0 {
t.Fatalf("remove with two args should not write to stdout, got %q", out.String())
}
}
// TestRunTrustHelp proves the -h / --help / help subcommands print usage to stdout,
// nothing to stderr, and return success, matching the other CLI help entrypoints
// (unknown subcommands, by contrast, write to stderr with the usage exit code).
func TestRunTrustHelp(t *testing.T) {
setTrustConfigRoot(t)
deps := trustDeps(t.TempDir())
for _, flag := range []string{"-h", "--help", "help"} {
var out, errBuf bytes.Buffer
if code := runTrust([]string{flag}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("trust %s returned %d, want %d", flag, code, exitSuccess)
}
if !strings.Contains(out.String(), "Usage") {
t.Fatalf("trust %s stdout = %q, want it to contain usage text", flag, out.String())
}
if errBuf.Len() != 0 {
t.Fatalf("trust %s should not write to stderr, got %q", flag, errBuf.String())
}
}
}
+8 -51
View File
@@ -12,7 +12,6 @@ import (
type updateOptions struct {
check bool
apply bool
json bool
repository string
endpoint string
@@ -21,18 +20,6 @@ type updateOptions struct {
}
func runUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
return runUpdateCommand(args, stdout, stderr, deps, false)
}
func runUpgrade(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
return runUpdateCommand(args, stdout, stderr, deps, true)
}
// runUpdateCommand backs both `zero update` and `zero upgrade`. defaultApply
// makes `--apply` the implicit behavior for `zero upgrade` when neither
// `--check` nor `--apply` is passed explicitly; `zero update` keeps requiring
// one of the two so existing scripts around `zero update --check` don't change.
func runUpdateCommand(args []string, stdout io.Writer, stderr io.Writer, deps appDeps, defaultApply bool) int {
options, help, err := parseUpdateArgs(args)
if err != nil {
return writeUsageError(stderr, err.Error())
@@ -43,19 +30,10 @@ func runUpdateCommand(args []string, stdout io.Writer, stderr io.Writer, deps ap
}
return exitSuccess
}
if options.check && options.apply {
return writeUsageError(stderr, "Pass only one of --check or --apply.")
if !options.check {
return writeUsageError(stderr, "Only `zero update --check` is available right now.")
}
if !options.check && !options.apply {
if !defaultApply {
return writeUsageError(stderr, "Pass --check to check for updates or --apply to install one (or use `zero upgrade`).")
}
options.apply = true
}
if options.apply && options.target != "" {
return writeUsageError(stderr, "--target cannot be combined with --apply; it only verifies release assets for --check.")
}
updateOptions := update.Options{
checkOptions := update.Options{
CurrentVersion: version,
Repository: options.repository,
Endpoint: options.endpoint,
@@ -66,26 +44,10 @@ func runUpdateCommand(args []string, stdout io.Writer, stderr io.Writer, deps ap
if err != nil {
return writeUsageError(stderr, err.Error())
}
updateOptions.GOOS = target.GOOS
updateOptions.GOARCH = target.GOARCH
checkOptions.GOOS = target.GOOS
checkOptions.GOARCH = target.GOARCH
}
if options.apply {
result, err := deps.applyUpdate(context.Background(), updateOptions)
if err != nil {
return writeAppError(stderr, "Could not install update: "+err.Error(), exitCrash)
}
if options.json {
if err := writePrettyJSON(stdout, result); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, update.FormatApply(result)); err != nil {
return exitCrash
}
return exitSuccess
}
result, err := deps.checkUpdate(context.Background(), updateOptions)
result, err := deps.checkUpdate(context.Background(), checkOptions)
if err != nil {
return writeAppError(stderr, "Could not check for updates: "+err.Error(), exitCrash)
}
@@ -110,8 +72,6 @@ func parseUpdateArgs(args []string) (updateOptions, bool, error) {
return options, true, nil
case arg == "--check":
options.check = true
case arg == "--apply":
options.apply = true
case arg == "--json":
options.json = true
case arg == "--repo":
@@ -195,17 +155,14 @@ func parseUpdateTarget(value string) (string, error) {
func writeUpdateHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero update --check [flags]
zero update --apply [flags]
zero upgrade [flags]
Flags:
--check Check the latest GitHub release without installing
--apply Download, verify, and install the latest release
--json Print the update result as JSON
--json Print the update check result as JSON
--repo <owner/repo> Repository to check when no endpoint is provided
--endpoint <url|repo> Release API URL or owner/repo slug to check
--timeout <duration> Release check timeout (default 5s)
--target <platform> Release target to verify with --check (for example windows-x64); not valid with --apply
--target <platform> Release target to verify (for example windows-x64)
-h, --help Show this help
`)
return err
-141
View File
@@ -662,144 +662,3 @@ func TestRunExecRejectsWorktreeDirWithoutWorktree(t *testing.T) {
t.Fatalf("expected empty stdout, got %q", stdout.String())
}
}
func TestParseChangesArgsAuto(t *testing.T) {
// 1. Correct parsing of auto
for _, args := range [][]string{
{"--auto"},
{"-a"},
} {
options, help, err := parseChangesArgs(args, "commit")
if err != nil {
t.Fatalf("parseChangesArgs(%v) error: %v", args, err)
}
if help {
t.Fatalf("parseChangesArgs(%v) returned help", args)
}
if !options.auto {
t.Fatalf("auto = false, want true (args %v)", args)
}
}
// 2. Reject --auto on inspect
_, _, err := parseChangesArgs([]string{"--auto"}, "inspect")
if err == nil || !strings.Contains(err.Error(), "--auto") {
t.Fatalf("expected --auto rejection on inspect, got %v", err)
}
// 3. Reject both --message and --auto on commit
_, _, err = parseChangesArgs([]string{"--message", "foo", "--auto"}, "commit")
if err == nil || !strings.Contains(err.Error(), "cannot specify both --message and --auto") {
t.Fatalf("expected --message and --auto conflict error, got %v", err)
}
// 4. Reject both empty message and --auto on commit
_, _, err = parseChangesArgs([]string{"--message=", "--auto"}, "commit")
if err == nil || !strings.Contains(err.Error(), "cannot specify both --message and --auto") {
t.Fatalf("expected empty --message and --auto conflict error, got %v", err)
}
}
type mockCommitMsgProvider struct {
response string
req zeroruntime.CompletionRequest
}
func (p *mockCommitMsgProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
p.req = request
events := make(chan zeroruntime.StreamEvent, 2)
events <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: p.response}
events <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(events)
return events, nil
}
func TestRunChangesCommitAuto(t *testing.T) {
cwd := t.TempDir()
summary := zerogit.ChangeSummary{
Root: cwd,
Branch: "main",
Files: []zerogit.FileChange{{Path: "README.md", Status: "modified"}},
Diff: "some diff content with ghp_SECRETKEYHERE",
}
mockProv := &mockCommitMsgProvider{
response: "```\nfeat: auto commit message\n```",
}
var stdout bytes.Buffer
var stderr bytes.Buffer
commitCalled := false
exitCode := runWithDeps([]string{"changes", "commit", "--auto"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
inspectChanges: func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
return summary, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
cfg := execResolvedConfig()
cfg.Provider.Name = "openai"
cfg.Provider.Model = "gpt-4o"
return cfg, nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
return mockProv, nil
},
commitChanges: func(ctx context.Context, options zerogit.CommitOptions) (zerogit.CommitResult, error) {
commitCalled = true
if options.Message != "feat: auto commit message" {
t.Fatalf("expected commit message 'feat: auto commit message', got %q", options.Message)
}
return zerogit.CommitResult{
Root: cwd,
Message: options.Message,
Committed: true,
CommitHash: "abc1234",
Before: summary,
}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if !commitCalled {
t.Fatal("expected commitChanges to be called")
}
// Verify that secret in the diff was redacted
promptContent := mockProv.req.Messages[0].Content
if strings.Contains(promptContent, "ghp_SECRETKEYHERE") {
t.Fatal("expected secret in diff to be redacted, but it was found in the prompt")
}
if !strings.Contains(promptContent, "[REDACTED]") && !strings.Contains(promptContent, "REDACTED") {
t.Fatalf("expected redacted diff content in prompt, got: %q", promptContent)
}
if !strings.Contains(stdout.String(), "Generating commit message using LLM...") {
t.Fatalf("expected status message in stdout, got %q", stdout.String())
}
t.Run("EmptyLLMResponse", func(t *testing.T) {
mockProvEmpty := &mockCommitMsgProvider{
response: " \n\n ",
}
var stdout, stderr bytes.Buffer
code := runWithDeps([]string{"changes", "commit", "--auto"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
inspectChanges: func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
return summary, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
return mockProvEmpty, nil
},
})
if code == exitSuccess {
t.Fatal("expected command to fail when LLM returns empty response, got success")
}
if !strings.Contains(stderr.String(), "empty commit message") {
t.Fatalf("expected empty commit message error in stderr, got %q", stderr.String())
}
})
}
Loaded 100 of 381 files, more files were not shown because too many files have changed in this diff. Show more