mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
130
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f009491e0b | ||
|
|
b97cf36bb4 | ||
|
|
6fc1220f6a | ||
|
|
80c39aa599 | ||
|
|
8bd9742fa9 | ||
|
|
91ea6ded75 | ||
|
|
fa3052a142 | ||
|
|
4c986d327b | ||
|
|
a5d2e327c8 | ||
|
|
5c4815a66e | ||
|
|
635c93af51 | ||
|
|
893b7b424c | ||
|
|
da41c3a75b | ||
|
|
5a5b7fd5b8 | ||
|
|
212734adf3 | ||
|
|
2efe6d539e | ||
|
|
5e1405d0b7 | ||
|
|
1af58828eb | ||
|
|
dc06fe72ca | ||
|
|
e4bd703cfb | ||
|
|
aa73a76f1b | ||
|
|
5b4f48d2dc | ||
|
|
f78b36c770 | ||
|
|
2db00ee3d5 | ||
|
|
8ea53841a5 | ||
|
|
8536cc87f7 | ||
|
|
ddc4927aac | ||
|
|
0184581ec2 | ||
|
|
3d96ac7e55 | ||
|
|
fdddb05ba8 | ||
|
|
b6117af86d | ||
|
|
584ef75a46 | ||
|
|
a880ce80a6 | ||
|
|
a7cfb99fed | ||
|
|
1fc9b2d25c | ||
|
|
66a6396414 | ||
|
|
8a05e6486f | ||
|
|
92a92ceb29 | ||
|
|
062328b632 | ||
|
|
f10ed0c893 | ||
|
|
8a52d98cad | ||
|
|
46077c8d24 | ||
|
|
041167a272 | ||
|
|
8e9149f1a2 | ||
|
|
ce9cb91295 | ||
|
|
2a062aa401 | ||
|
|
ba4c00755d | ||
|
|
066681855b | ||
|
|
50d4bef8e5 | ||
|
|
8f156506dc | ||
|
|
6eb4b89f3f | ||
|
|
6ec77d62f5 | ||
|
|
25653f686c | ||
|
|
c7346fbcf0 | ||
|
|
d0c4e62cd4 | ||
|
|
302f58bb5f | ||
|
|
ba99fa8d48 | ||
|
|
1805a8ce09 | ||
|
|
8b248f4e11 | ||
|
|
4f1f94f26a | ||
|
|
6247ee8f9c | ||
|
|
87158a1c90 | ||
|
|
16550569c1 | ||
|
|
008bc9b3f3 | ||
|
|
fd69233e33 | ||
|
|
3f0503bc23 | ||
|
|
f951aee65d | ||
|
|
82ecac5281 | ||
|
|
387fe67ee7 | ||
|
|
d185f3ef0d | ||
|
|
8c6c49ad52 | ||
|
|
809489a6e3 | ||
|
|
56147a708e | ||
|
|
81aad58d97 | ||
|
|
95ccd5bc32 | ||
|
|
284a6c86cb | ||
|
|
8f3b8f0d13 | ||
|
|
0f69d995e9 | ||
|
|
87e7e69afd | ||
|
|
f4c998ac30 | ||
|
|
30bff28efc | ||
|
|
7b10aab74b | ||
|
|
3c81fea228 | ||
|
|
f6c060631e | ||
|
|
563a6dbe91 | ||
|
|
2312abe5dd | ||
|
|
afb5010a46 | ||
|
|
30dd7c3112 | ||
|
|
e7d88b4b51 | ||
|
|
177442cfe4 | ||
|
|
190241bd59 | ||
|
|
ba6c026469 | ||
|
|
daf65e0af9 | ||
|
|
f401c66e81 | ||
|
|
949ee43f71 | ||
|
|
2fcea71778 | ||
|
|
5f36349c18 | ||
|
|
e4f760ee8b | ||
|
|
068c5ad3ad | ||
|
|
407a92739f | ||
|
|
d05e9148a7 | ||
|
|
1d8638466c | ||
|
|
305a62c954 | ||
|
|
9134148f4d | ||
|
|
f653dcac36 | ||
|
|
071ea3a495 | ||
|
|
b0abde7d06 | ||
|
|
5658a36627 | ||
|
|
c60ad8729f | ||
|
|
0562e3bef7 | ||
|
|
9bd93c62f8 | ||
|
|
bdb1b0ecd1 | ||
|
|
4266baf222 | ||
|
|
8d0c5feccb | ||
|
|
b63fe696fd | ||
|
|
3fbbd28e4c | ||
|
|
b9ddd6f421 | ||
|
|
aaad4d2712 | ||
|
|
b4275e3504 | ||
|
|
5b2b4dea1a | ||
|
|
b5a78dca6f | ||
|
|
952788f72d | ||
|
|
f915f70e5a | ||
|
|
cdf9d839ae | ||
|
|
960db9660e | ||
|
|
b1ccb6d9c1 | ||
|
|
0bc8074c97 | ||
|
|
72eed06b4f | ||
|
|
33dc7ae2cc | ||
|
|
292e1edea5 |
No files matched your search
@@ -100,9 +100,10 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
# 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.
|
||||
# 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.
|
||||
- name: Pin toolchain from go.mod
|
||||
run: |
|
||||
toolchain="$(awk '/^toolchain /{print $2}' go.mod)"
|
||||
@@ -120,3 +121,13 @@ 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 ./...
|
||||
@@ -97,16 +97,21 @@ jobs:
|
||||
gh release create "$tag" --repo "$GITHUB_REPOSITORY" --title "$tag" --generate-notes 2>/dev/null || true
|
||||
gh release upload "$tag" dist/release/* --repo "$GITHUB_REPOSITORY" --clobber
|
||||
|
||||
# Publishes the npm wrapper (@gitlawb/zero) once every platform's release
|
||||
# assets are live — the package's postinstall downloads its binary from the
|
||||
# GitHub Release, so publishing before the assets exist would ship a package
|
||||
# that fails to install.
|
||||
# Publishes @gitlawb/zero to npm: first the five platform payloads (versions
|
||||
# of the SAME package at suffixed versions like X.Y.Z-linux-x64, carrying the
|
||||
# native binary + vendored helpers), then the wrapper at X.Y.Z whose
|
||||
# optionalDependencies alias those platform versions. Platform versions MUST
|
||||
# publish first — the wrapper pins them exactly, so the reverse order would
|
||||
# open a window where installs resolve aliases that 404. See
|
||||
# docs/NPM_PACKAGING.md for the full contract.
|
||||
#
|
||||
# Auth is npm OIDC trusted publishing: no NPM_TOKEN secret. The package's
|
||||
# npmjs.com settings must list this repo + workflow file as a trusted
|
||||
# publisher, and provenance is then generated automatically. Note npm only
|
||||
# allows configuring a trusted publisher on an EXISTING package, so the very
|
||||
# first @gitlawb/zero publish is a one-time manual `npm publish` bootstrap.
|
||||
# Because the platform payloads are versions of the same package, they reuse
|
||||
# that single trusted-publisher configuration.
|
||||
publish-npm:
|
||||
name: Publish npm package
|
||||
needs: package
|
||||
@@ -156,10 +161,11 @@ jobs:
|
||||
console.log(`npm ${version} supports trusted publishing.`);
|
||||
'
|
||||
|
||||
# Probe the exact URLs postinstall will build (internal/release/release.go
|
||||
# asset-name scheme) as an anonymous client. This also blocks publishing
|
||||
# while the repo is still private — private release assets 404 without
|
||||
# auth, exactly as they would for an installing user.
|
||||
# Probe the exact URLs the wrapper's first-run downloader builds
|
||||
# (internal/release/release.go asset-name scheme) as an anonymous client
|
||||
# — install.sh/install.ps1 and `zero update` read them too. This also
|
||||
# blocks publishing while the repo is still private: private release
|
||||
# assets 404 without auth, exactly as they would for an installing user.
|
||||
- name: Verify release assets are downloadable
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -173,12 +179,64 @@ jobs:
|
||||
"zero-v${version}-windows-x64.zip"; do
|
||||
for url in "${base}/${asset}" "${base}/${asset}.sha256"; do
|
||||
if ! curl -fsSLI -o /dev/null "$url"; then
|
||||
echo "::error::${url} is not publicly downloadable — npm postinstall would fail. Is the repo public and are all assets uploaded?" >&2
|
||||
echo "::error::${url} is not publicly downloadable — the wrapper's fallback downloader and install.sh would fail. Is the repo public and are all assets uploaded?" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
done
|
||||
echo "all release assets for v${version} are publicly downloadable."
|
||||
|
||||
- name: Publish to npm
|
||||
run: npm publish --access public
|
||||
- name: Download release artifacts
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
pattern: zero-*
|
||||
path: dist/artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Assemble npm packages
|
||||
run: node scripts/npm/build-platform-packages.mjs --artifacts-dir dist/artifacts --out-dir dist/npm
|
||||
|
||||
# Platform versions are semver PRERELEASES of the wrapper version
|
||||
# (X.Y.Z-linux-x64), so they must never publish under the default
|
||||
# `latest` dist-tag — that would make `npm install @gitlawb/zero`
|
||||
# deliver a platform payload as the CLI. Publish them under a dedicated
|
||||
# tag and assert `latest` survived before the wrapper goes out.
|
||||
- name: Publish platform packages to npm
|
||||
shell: bash
|
||||
run: |
|
||||
version="$(node -p "require('./package.json').version")"
|
||||
for dir in dist/npm/platforms/*/; do
|
||||
echo "publishing ${dir}"
|
||||
(cd "$dir" && npm publish --access public --tag platform)
|
||||
done
|
||||
# Tolerate a failed lookup (registry lag, or no latest tag yet on a
|
||||
# bootstrap publish): under set -e a bare failure here would abort
|
||||
# AFTER the platform payloads went out, leaving no wrapper and a
|
||||
# re-run that fails on republishing existing versions. An empty
|
||||
# value falls through the guard below.
|
||||
latest="$(npm view @gitlawb/zero dist-tags.latest || true)"
|
||||
case "$latest" in
|
||||
"${version}-"*)
|
||||
echo "::error::dist-tag latest points at platform version ${latest} — a platform publish clobbered it. Fix the dist-tag before the wrapper publish." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "dist-tag latest is ${latest} (unchanged by platform publishes)."
|
||||
|
||||
- name: Publish wrapper to npm
|
||||
shell: bash
|
||||
run: |
|
||||
version="$(node -p "require('./package.json').version")"
|
||||
(cd dist/npm/wrapper && npm publish --access public)
|
||||
# Confirm the wrapper owns `latest` (retry: registry metadata can lag
|
||||
# a publish by a few seconds).
|
||||
for attempt in 1 2 3 4 5; do
|
||||
latest="$(npm view @gitlawb/zero dist-tags.latest || true)"
|
||||
if [ "$latest" = "$version" ]; then
|
||||
echo "dist-tag latest is ${version}."
|
||||
exit 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
echo "::error::dist-tag latest is ${latest}, expected ${version} after the wrapper publish." >&2
|
||||
exit 1
|
||||
@@ -4,12 +4,14 @@ 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:
|
||||
@@ -29,7 +31,8 @@ jobs:
|
||||
import sys, yaml
|
||||
|
||||
with open("action.yml", "r", encoding="utf-8") as handle:
|
||||
doc = yaml.safe_load(handle)
|
||||
action_raw = handle.read()
|
||||
doc = yaml.safe_load(action_raw)
|
||||
|
||||
assert isinstance(doc, dict), "action.yml must be a mapping"
|
||||
assert doc.get("name"), "action must declare a name"
|
||||
@@ -65,6 +68,19 @@ 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
@@ -27,6 +27,10 @@ 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
|
||||
|
||||
@@ -37,8 +41,17 @@ 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 @@
|
||||
{".":"0.1.0"}
|
||||
{".":"0.3.0"}
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
description: Project guidelines for the open-source CLI coding agent (zero) repository.
|
||||
globs: "*.go, *.js, *.md, *.json, *.toml, *.yaml, *.yml"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Repository Conventions for Zero
|
||||
|
||||
This file outlines the project conventions and repository guidelines for coding agents when working on the `zero` repository. For the general guide on how to extend Zero (write specialist sub-agents, hooks, plugins, MCP, skills), see [docs/EXTENDING.md](docs/EXTENDING.md).
|
||||
|
||||
## 1. Project Conventions
|
||||
|
||||
- Build with `make`, not `go build` directly.
|
||||
- Tests live next to the source file (`foo_test.go` next to `foo.go`).
|
||||
- Run `make lint` before opening a PR.
|
||||
- Never edit files under `third_party/` — those are vendored.
|
||||
- Unify functions/methods where possible to prevent codebase inflation. Prefer a single cross-platform function with conditional checks over duplicating helpers per platform.
|
||||
|
||||
## 2. Guidelines for Coding Agents
|
||||
|
||||
If you are an AI coding agent executing tasks in this repository, you **MUST** run all Go code quality and security checks before committing code or completing your task:
|
||||
|
||||
1. **Format & Vet**: Run `go fmt ./...` and `go vet ./...`.
|
||||
2. **Lint**: Run `go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...`.
|
||||
3. **Security**: Run `go run golang.org/x/vuln/cmd/[email protected] ./...`.
|
||||
|
||||
If any check fails or cannot be run, do not ignore it. Prompt the user for instructions or setup assistance before proceeding.
|
||||
+105
@@ -6,6 +6,111 @@ 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,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
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
|
||||
<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="Go 1.26.5+" src="https://img.shields.io/badge/Go-1.26.5+-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,
|
||||
@@ -47,9 +49,15 @@ npm install -g @gitlawb/zero
|
||||
zero
|
||||
```
|
||||
|
||||
The npm package installs a small wrapper plus the matching Zero binary for your
|
||||
platform from GitHub Releases. It supports Linux, macOS, and Windows on x64 and
|
||||
arm64.
|
||||
The npm package is a small wrapper whose platform build (Linux and macOS on
|
||||
x64/arm64, Windows on x64 — including the browser/terminal control helpers)
|
||||
installs as an optional dependency straight from the npm registry — no install
|
||||
scripts, no downloads outside npm. Bun, pnpm, and yarn work the same way with
|
||||
no trust or approval steps. Installs that skip optional dependencies
|
||||
(`--omit=optional`) still work: the wrapper fetches the binary from the
|
||||
matching GitHub Release whenever it is missing. Windows on ARM runs the x64
|
||||
build under emulation. See [docs/NPM_PACKAGING.md](docs/NPM_PACKAGING.md) for
|
||||
how the package is put together.
|
||||
|
||||
### Install scripts
|
||||
|
||||
@@ -67,7 +75,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
|
||||
|
||||
### From source
|
||||
|
||||
Source builds require Go 1.25+.
|
||||
Source builds require Go 1.26.5+.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
@@ -121,6 +129,42 @@ the key in the wizard:
|
||||
export OPENAI_API_KEY=sk-...
|
||||
export ANTHROPIC_API_KEY=...
|
||||
export GEMINI_API_KEY=...
|
||||
export LONGCAT_API_KEY=...
|
||||
export MINIMAX_API_KEY=...
|
||||
export MINIMAXI_API_KEY=...
|
||||
```
|
||||
|
||||
To configure Meituan LongCat (LongCat-2.0) directly, run:
|
||||
|
||||
```bash
|
||||
zero providers setup longcat --set-active
|
||||
```
|
||||
|
||||
MiniMax presets use the Anthropic-compatible endpoints for the global and China
|
||||
regions:
|
||||
|
||||
```bash
|
||||
zero providers add minimax --set-active
|
||||
zero providers add minimaxi-cn --set-active
|
||||
```
|
||||
|
||||
To use the OpenAI-compatible endpoints instead, add a custom compatible profile
|
||||
for the required region:
|
||||
|
||||
```bash
|
||||
zero providers add custom-openai-compatible \
|
||||
--name minimax-openai \
|
||||
--model MiniMax-M3 \
|
||||
--base-url https://api.minimax.io/v1 \
|
||||
--api-key-env MINIMAX_API_KEY \
|
||||
--set-active
|
||||
|
||||
zero providers add custom-openai-compatible \
|
||||
--name minimax-cn-openai \
|
||||
--model MiniMax-M3 \
|
||||
--base-url https://api.minimaxi.com/v1 \
|
||||
--api-key-env MINIMAXI_API_KEY \
|
||||
--set-active
|
||||
```
|
||||
|
||||
For local models, run Ollama or LM Studio and then use `zero setup` or
|
||||
@@ -152,6 +196,7 @@ 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 |
|
||||
@@ -249,6 +294,39 @@ 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 |
|
||||
@@ -271,7 +349,26 @@ go run ./cmd/zero-release smoke
|
||||
go run ./cmd/zero-perf-bench
|
||||
```
|
||||
|
||||
Cross-compile examples:
|
||||
### Code Quality and Security Checks
|
||||
|
||||
Before committing any changes, ensure all Go code quality and security checks pass. Pinned `go run` commands matching CI constraints can be used directly without prior installation:
|
||||
|
||||
1. **Formatting**: Run `go fmt ./...` (or `make fmt`).
|
||||
2. **Vetting**: Run `go vet ./...` (or `make vet`).
|
||||
3. **Linting**: Run `go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...`.
|
||||
4. **Vulnerability Scan**: Run `go run golang.org/x/vuln/cmd/[email protected] ./...`.
|
||||
|
||||
If you prefer to install these tools globally on your path, you can run:
|
||||
|
||||
```bash
|
||||
# Install golangci-lint
|
||||
go install github.com/golangci/golangci-lint/v2/cmd/[email protected]
|
||||
|
||||
# Install govulncheck
|
||||
go install golang.org/x/vuln/cmd/[email protected]
|
||||
```
|
||||
|
||||
### Cross-Compile Examples
|
||||
|
||||
```bash
|
||||
go run ./cmd/zero-release build --goos linux --goarch amd64
|
||||
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
<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.26.5+" src="https://img.shields.io/badge/Go-1.26.5+-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 包是一个小型包装器,其平台构建(Linux 和 macOS 的 x64/arm64、Windows 的 x64,包含浏览器/终端控制辅助工具)作为可选依赖直接从 npm registry 安装——没有安装脚本,也不会从 npm 之外下载任何内容。Bun、pnpm 和 yarn 的行为完全一致,无需任何信任或批准步骤。跳过可选依赖的安装(`--omit=optional`)也能工作:只要二进制文件缺失,包装器就会从对应的 GitHub Release 下载。Windows on ARM 通过模拟运行 x64 构建。详见 [docs/NPM_PACKAGING.md](docs/NPM_PACKAGING.md)。
|
||||
|
||||
### 安装脚本
|
||||
|
||||
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.26.5+。
|
||||
|
||||
```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) 发布。
|
||||
+8
-9
@@ -27,14 +27,14 @@ inputs:
|
||||
api-key:
|
||||
description: >-
|
||||
The provider API key. Pass it from a repository or organization secret;
|
||||
it is exported into the step environment and never written to the log.
|
||||
it is exported only for the ZERO step 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 for the run. Leave the action
|
||||
api-key, that variable is exported only for the ZERO step. Leave the action
|
||||
provider-agnostic by passing the env name your provider expects rather
|
||||
than hardcoding one here.
|
||||
required: false
|
||||
@@ -180,6 +180,7 @@ 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
|
||||
@@ -192,10 +193,9 @@ runs:
|
||||
echo "::error::api-key-env must be a valid environment variable name." >&2
|
||||
exit 2
|
||||
fi
|
||||
# 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 in-process for this Run ZERO step so zero can read it; do
|
||||
# not persist provider credentials to later job steps.
|
||||
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,10 +248,9 @@ runs:
|
||||
echo "exit-code=${code}" >> "${GITHUB_OUTPUT}"
|
||||
echo "output-file=${output_file}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
# 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)"
|
||||
# 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)"
|
||||
{
|
||||
echo "summary<<ZERO_EOF"
|
||||
printf '%s\n' "${summary}"
|
||||
|
||||
+78
-5
@@ -2,6 +2,7 @@
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -39,6 +40,11 @@ function resolveHelper(packageRoot, name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Legacy path: helpers installed as npm dependencies of the wrapper. Current
|
||||
// installs vendor the helpers inside the platform package instead (a helpers/
|
||||
// directory next to the binary, found by the Go side on its own), so this
|
||||
// manifest is usually empty — it only matters for a downloaded-binary install
|
||||
// whose wrapper still carries helper dependencies.
|
||||
function localControlHelperManifest(packageRoot) {
|
||||
const helpers = {};
|
||||
for (const name of ['agent-browser', 'tuistory']) {
|
||||
@@ -49,18 +55,85 @@ function localControlHelperManifest(packageRoot) {
|
||||
return JSON.stringify({ version: 1, helpers });
|
||||
}
|
||||
|
||||
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const nativePath = join(packageRoot, zeroBinaryName());
|
||||
const localControlHelpers = localControlHelperManifest(packageRoot);
|
||||
// The platform payload is a version of @gitlawb/zero itself, installed under
|
||||
// an npm: alias (see docs/NPM_PACKAGING.md). The alias name is derived from
|
||||
// process.platform/process.arch, so an unsupported platform simply fails to
|
||||
// resolve and we fall through to the downloader.
|
||||
function platformPackageBinary() {
|
||||
const aliasedName = `@gitlawb/zero-${process.platform}-${process.arch}`;
|
||||
let manifestPath;
|
||||
try {
|
||||
manifestPath = createRequire(import.meta.url).resolve(`${aliasedName}/package.json`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const candidate = join(dirname(manifestPath), zeroBinaryName());
|
||||
return existsSync(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
if (!existsSync(nativePath)) {
|
||||
// Mirrors the release matrix (internal/release/release.go): the platforms a
|
||||
// GitHub Release asset exists for. android maps to the linux asset; there is
|
||||
// no windows-arm64 asset (Windows on ARM runs the x64 build under emulation).
|
||||
function releaseAssetAvailable() {
|
||||
const platform = { linux: 'linux', android: 'linux', darwin: 'macos', win32: 'windows' }[
|
||||
process.platform
|
||||
];
|
||||
const arch = process.arch === 'x64' || process.arch === 'arm64' ? process.arch : null;
|
||||
if (!platform || !arch) return false;
|
||||
return !(platform === 'windows' && arch === 'arm64');
|
||||
}
|
||||
|
||||
// Fallback for installs without a platform package (--omit=optional, package
|
||||
// managers that skip optionalDependencies): fetch the binary next to the
|
||||
// wrapper with the same checksum-verified downloader that used to run as
|
||||
// postinstall. Deliberately NOT cached on failure — a transient network error
|
||||
// self-heals on the next run — so this retries on every invocation until a
|
||||
// binary is in place. Platforms with no release asset skip the attempt.
|
||||
function downloadMissingBinary(packageRoot) {
|
||||
const downloadScript = join(packageRoot, 'scripts', 'postinstall.mjs');
|
||||
if (!existsSync(downloadScript) || !releaseAssetAvailable()) return;
|
||||
console.error(
|
||||
'[zero] No native binary found next to the npm wrapper. Reinstall the zero package or run `go run ./cmd/zero-release build` from the repository.'
|
||||
'[zero] no platform package installed — fetching the native binary from the GitHub Release (retried on each run until it succeeds).',
|
||||
);
|
||||
spawnSync(process.execPath, [downloadScript], {
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
});
|
||||
}
|
||||
|
||||
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
function resolveNativeBinary() {
|
||||
const fromPlatformPackage = platformPackageBinary();
|
||||
if (fromPlatformPackage) return fromPlatformPackage;
|
||||
const downloaded = join(packageRoot, zeroBinaryName());
|
||||
if (existsSync(downloaded)) return downloaded;
|
||||
downloadMissingBinary(packageRoot);
|
||||
return existsSync(downloaded) ? downloaded : null;
|
||||
}
|
||||
|
||||
const nativePath = resolveNativeBinary();
|
||||
|
||||
if (!nativePath) {
|
||||
console.error(
|
||||
'[zero] No native binary is available for this install.\n' +
|
||||
'Normally npm installs it as an optional dependency of @gitlawb/zero\n' +
|
||||
`(@gitlawb/zero-${process.platform}-${process.arch}), and the wrapper can\n` +
|
||||
'also download it from the GitHub Release when it is missing.\n' +
|
||||
'\n' +
|
||||
'Things to try:\n' +
|
||||
' - reinstall without omitting optional dependencies:\n' +
|
||||
' npm install -g @gitlawb/zero\n' +
|
||||
' - run the downloader manually (needs write access to the package dir):\n' +
|
||||
` node "${join(packageRoot, 'scripts', 'postinstall.mjs')}"\n` +
|
||||
'\n' +
|
||||
'If this platform has no prebuilt binary, build from source:\n' +
|
||||
'https://github.com/Gitlawb/zero (go run ./cmd/zero, requires Go 1.26+).',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const env = { ...process.env };
|
||||
const localControlHelpers = localControlHelperManifest(packageRoot);
|
||||
if (localControlHelpers) {
|
||||
env.ZERO_LOCAL_CONTROL_HELPERS = localControlHelpers;
|
||||
} else {
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
---
|
||||
description: Extending Zero — how to write AGENTS.md files, custom specialists, skills, hooks, MCP servers, and plugins for the open-source CLI coding agent.
|
||||
globs: "*.go, *.js, *.md, *.json, *.toml, *.yaml, *.yml"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Extending Zero
|
||||
|
||||
Zero is an open-source terminal coding agent. Out of the box it does the obvious things — read, edit, run, search — but the design point of the project is that **every surface is configurable**. This document is the user-facing guide for that configuration.
|
||||
|
||||
If you only want to *use* Zero, the [README](README.md) is enough. This page is for the other three jobs:
|
||||
If you only want to *use* Zero, the [README](../README.md) is enough. This page is for the other three jobs:
|
||||
|
||||
1. Tell the agent about *your* project (drop an `AGENTS.md` in your repo).
|
||||
2. Add new specialist sub-agents.
|
||||
@@ -26,7 +20,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` (uppercase `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` — 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,6 +41,12 @@ 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%\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:
|
||||
@@ -94,17 +94,17 @@ zero specialist delete api-reviewer --project
|
||||
zero specialist path # prints the resolved specialists directory
|
||||
```
|
||||
|
||||
The full format spec (frontmatter fields, tool scopes, prompt conventions) is in [`docs/SPECIALISTS.md`](docs/SPECIALISTS.md).
|
||||
The full format spec (frontmatter fields, tool scopes, prompt conventions) is in [`docs/SPECIALISTS.md`](SPECIALISTS.md).
|
||||
|
||||
> **Roadmap.** An in-UI specialist manager (create / edit / delete / preview) is on the backlog. Today you use the `zero specialist` CLI subcommands above.
|
||||
|
||||
## 3. Skills
|
||||
|
||||
Skills are markdown instruction packs the agent can pull in on demand. Each skill is a directory containing a `SKILL.md`. Skills are **user-level only** in this version — there's no project-scoped skill directory yet, so anything you want shared with the team goes in `AGENTS.md` (section 1) or as a hook (section 4).
|
||||
Skills are markdown instruction packs the agent can pull in on demand. Each skill is a directory containing a `SKILL.md`. Standalone project skill directories are not supported in this version (shared project-wide skills must go in `AGENTS.md` or as a hook). However, project plugins (section 6) may bundle skills, which are merged into the active run.
|
||||
|
||||
Discovery root: `$ZERO_SKILLS_DIR` → `$XDG_DATA_HOME/zero/skills` → `~/.local/share/zero/skills/`. A missing directory is fine — Zero just reports "no skills".
|
||||
|
||||
```
|
||||
```text
|
||||
~/.local/share/zero/skills/
|
||||
run-benchmarks/
|
||||
SKILL.md
|
||||
@@ -126,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. 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.
|
||||
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.
|
||||
|
||||
The `skill` core tool lets the agent load any discovered skill by name.
|
||||
|
||||
@@ -229,6 +229,13 @@ Servers are merged from user and project configs (project wins on conflicts). To
|
||||
- A `--header` value produced by command substitution (`"Authorization=Bearer $(print-token)"`) in a private shell config that you keep out of git.
|
||||
- A secret manager that injects the env var your MCP server reads on its own (the `command` and `args` then run inside that environment).
|
||||
|
||||
Project MCP config remains supported for shared team servers, but a project layer
|
||||
that changes an existing server's target (`url`, `command`, or `args`) does not
|
||||
inherit user credentials. Clear or replace `headers`, `env`, or `oauth` explicitly,
|
||||
or use a new server name. OAuth tokens are bound to the resolved server identity,
|
||||
so changing a server target can require `zero mcp oauth login <server>` again.
|
||||
OAuth server names ending in `.<32 hex chars>` are reserved for token storage.
|
||||
|
||||
### As a server — expose Zero's tools to another agent
|
||||
|
||||
```bash
|
||||
@@ -276,19 +283,19 @@ 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. Skills declared inside a plugin's `plugin.json` are not yet merged into the `skill` tool's discovery (see section 3).
|
||||
> **Roadmap.** An in-UI plugins manager (browse, install, enable / disable) is on the backlog. Today you use the `zero plugins` CLI subcommands above.
|
||||
|
||||
## 7. Configuration locations
|
||||
|
||||
Three layers, applied in order (later layers override earlier ones):
|
||||
Five configuration sources, in precedence order (later sources override earlier ones):
|
||||
|
||||
| Layer | Path | Notes |
|
||||
| Source | Path / Key | Notes |
|
||||
| --- | --- | --- |
|
||||
| Built-in defaults | compiled in | Lowest priority. |
|
||||
| User config | `~/.config/zero/config.json` | Your machine. Never committed. |
|
||||
| Project config | `./.zero/config.json` | The repo. Committed (or not, your call). |
|
||||
| CLI flags | `--model`, `--mode`, ... | Highest priority, per-invocation. |
|
||||
| Environment | `ZERO_*` | Provider commands, secrets, skills dir override. |
|
||||
| CLI flags | `--model`, `--mode`, ... | Highest priority, per-invocation. |
|
||||
|
||||
The user config holds things that should follow the user across projects (default provider, default model, theme). The project config holds things the team agreed on (provider catalog, sandbox policies, model restrictions).
|
||||
|
||||
@@ -301,19 +308,20 @@ A team that wants every contributor's Zero to behave the same way commits:
|
||||
- `AGENTS.md` — project conventions, build commands, do-not-edit lists.
|
||||
- `.zero/config.json` — provider catalog, default model, allowed tools.
|
||||
- `.zero/specialists/api-reviewer.md` — the team's PR-review specialist.
|
||||
- `.zero/hooks.json` — block `rm -rf` and `git push --force` on `beforeTool`.
|
||||
- `.zero/hooks.json` — block `rm -rf` on `beforeTool`.
|
||||
- `.zero/plugins/internal-tooling/` — a plugin that adds the team's internal CLI tools to the agent's toolset.
|
||||
|
||||
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.
|
||||
|
||||
## 9. Reference
|
||||
|
||||
- [README](README.md) — install, quickstart, command reference.
|
||||
- [docs/SPECIALISTS.md](docs/SPECIALISTS.md) — full specialist manifest spec.
|
||||
- [docs/STREAM_JSON_PROTOCOL.md](docs/STREAM_JSON_PROTOCOL.md) — `zero exec` I/O contract.
|
||||
- [docs/INSTALL.md](docs/INSTALL.md) — install from source or release.
|
||||
- [README](../README.md) — install, quickstart, command reference.
|
||||
- [docs/SPECIALISTS.md](SPECIALISTS.md) — full specialist manifest spec.
|
||||
- [docs/STREAM_JSON_PROTOCOL.md](STREAM_JSON_PROTOCOL.md) — `zero exec` I/O contract.
|
||||
- [docs/INSTALL.md](INSTALL.md) — install from source or release.
|
||||
@@ -41,8 +41,9 @@ 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 for the run and never prints it. If your repository already
|
||||
> commits a `.zero/config.json` with an active provider, you can omit `provider`.
|
||||
> 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`.
|
||||
|
||||
## Inputs
|
||||
|
||||
@@ -51,8 +52,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; never logged. |
|
||||
| `api-key-env` | no | `""` | Env var name the provider reads its key from (e.g. `OPENAI_API_KEY`). Exported with `api-key`. |
|
||||
| `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`. |
|
||||
| `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. |
|
||||
@@ -162,9 +163,10 @@ jobs:
|
||||
## Security notes
|
||||
|
||||
- **Secrets are passed as Action secrets and never logged.** The action exports
|
||||
`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.
|
||||
`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.
|
||||
- **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
|
||||
|
||||
@@ -0,0 +1,787 @@
|
||||
# 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.
|
||||
+82
-9
@@ -4,10 +4,11 @@ Zero is distributed as:
|
||||
|
||||
- an npm package, `@gitlawb/zero`
|
||||
- release archives on GitHub Releases
|
||||
- source builds with Go 1.25+
|
||||
- source builds with Go 1.26.5+
|
||||
|
||||
The npm package and install scripts download a platform-specific release archive.
|
||||
They require a published GitHub Release for the requested version.
|
||||
The install scripts download a platform-specific release archive and require a
|
||||
published GitHub Release for the requested version. The npm package is
|
||||
self-contained: the platform binary installs from the npm registry.
|
||||
|
||||
## npm
|
||||
|
||||
@@ -16,13 +17,31 @@ npm install -g @gitlawb/zero
|
||||
zero
|
||||
```
|
||||
|
||||
The package supports Linux, macOS, and Windows on x64 and arm64. It installs the
|
||||
`zero` command and downloads the matching release binary during `postinstall`.
|
||||
The package supports Linux and macOS on x64 and arm64, and Windows on x64
|
||||
(Windows on ARM runs the x64 build under emulation). It installs a small
|
||||
`zero` wrapper plus, as an optional dependency, a platform payload with the
|
||||
native binary and the bundled browser/terminal control helpers. There are no
|
||||
install scripts and nothing is downloaded from outside the npm registry, so
|
||||
the install is silent and works identically under npm, Bun, pnpm, and yarn —
|
||||
no trust or approval steps. See [NPM_PACKAGING.md](NPM_PACKAGING.md) for the
|
||||
package architecture.
|
||||
|
||||
Requirements:
|
||||
|
||||
- Node.js 18+
|
||||
- network access to npm and GitHub Releases
|
||||
- network access to npm
|
||||
|
||||
If the install skipped optional dependencies (`npm install --omit=optional`,
|
||||
or a package manager configured to do so), the wrapper falls back to
|
||||
downloading the binary from the matching GitHub Release, with checksum
|
||||
verification. The fetch is retried on each run until a binary is in place (a
|
||||
transient network failure heals itself; an install directory the current user
|
||||
cannot write to keeps failing — rerun the install or the command below with
|
||||
sufficient permissions). To trigger the fetch manually:
|
||||
|
||||
```bash
|
||||
node "$(npm root -g)/@gitlawb/zero/scripts/postinstall.mjs"
|
||||
```
|
||||
|
||||
## Linux And macOS Script
|
||||
|
||||
@@ -106,7 +125,7 @@ Build a local binary:
|
||||
go build -o zero ./cmd/zero
|
||||
```
|
||||
|
||||
Source builds require Go 1.25+.
|
||||
Source builds require Go 1.26.5+.
|
||||
|
||||
### Sandbox Helpers For Source Builds
|
||||
|
||||
@@ -128,6 +147,61 @@ 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`:
|
||||
@@ -152,8 +226,7 @@ Supported targets:
|
||||
- `linux-arm64`
|
||||
- `macos-x64`
|
||||
- `macos-arm64`
|
||||
- `windows-x64`
|
||||
- `windows-arm64`
|
||||
- `windows-x64` (Windows on ARM runs this build under emulation)
|
||||
|
||||
Each archive must have a matching `.sha256` file. The install scripts download
|
||||
both files, verify the checksum, and then copy the binary into the install
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
# npm packaging
|
||||
|
||||
How `@gitlawb/zero` is put together on npm, why it is shaped this way, and the
|
||||
rules the release pipeline must follow. Read this before touching
|
||||
`package.json`, `bin/zero.js`, `scripts/postinstall.mjs`,
|
||||
`scripts/npm/build-platform-packages.mjs`, or the npm-publish steps of
|
||||
`release-artifacts.yml`.
|
||||
|
||||
## Goals
|
||||
|
||||
A `npm install -g @gitlawb/zero` must be **silent and self-contained**:
|
||||
|
||||
- No `EBADENGINE` warnings — ours or from any package in the dependency tree.
|
||||
- No install scripts anywhere in the tree, so npm's `allow-scripts` gating,
|
||||
Bun's blocked-by-default lifecycle scripts, and pnpm's strict mode all
|
||||
install zero without prompts, trust ceremonies, or broken binaries.
|
||||
- No network fetches outside the npm registry at install time. GitHub being
|
||||
down or rate-limited must not break `npm install`.
|
||||
- Browser control (`agent-browser`) and terminal control (`tuistory`) work out
|
||||
of the box.
|
||||
|
||||
## Architecture
|
||||
|
||||
The model is the one used by Codex, esbuild, and Biome: a tiny wrapper package
|
||||
plus per-platform payloads carrying the native binaries.
|
||||
|
||||
```
|
||||
@gitlawb/zero <- wrapper: bin/zero.js + optionalDependencies
|
||||
├─ @gitlawb/zero-darwin-arm64 -> npm:@gitlawb/zero@{version}-darwin-arm64
|
||||
├─ @gitlawb/zero-darwin-x64 -> npm:@gitlawb/zero@{version}-darwin-x64
|
||||
├─ @gitlawb/zero-linux-arm64 -> npm:@gitlawb/zero@{version}-linux-arm64
|
||||
├─ @gitlawb/zero-linux-x64 -> npm:@gitlawb/zero@{version}-linux-x64
|
||||
└─ @gitlawb/zero-win32-x64 -> npm:@gitlawb/zero@{version}-win32-x64
|
||||
```
|
||||
|
||||
- The platform "packages" are **versions of the same `@gitlawb/zero` package**,
|
||||
published at suffixed versions (`0.4.0-linux-x64`) and referenced through
|
||||
`npm:` aliases in `optionalDependencies`. One package name means one npm
|
||||
trusted-publisher configuration — no new publish credentials per platform.
|
||||
The alias suffixes use Node's `process.platform`/`process.arch` names so
|
||||
`bin/zero.js` can derive its platform package directly.
|
||||
- Each platform version sets `os` and `cpu`, so npm installs exactly one of
|
||||
them and skips the rest.
|
||||
- A platform payload is assembled from the platform's release archive by
|
||||
`scripts/npm/build-platform-packages.mjs` and contains the `zero` binary,
|
||||
the platform's sandbox helpers, and the vendored `helpers/` tree (see
|
||||
below). It has no `bin`, no scripts, and no dependencies — it is inert on
|
||||
its own; the wrapper execs the binary out of it.
|
||||
- The published wrapper has **no scripts and no dependencies** — the
|
||||
assembly script strips both from the repo `package.json` and injects the
|
||||
exact-version `optionalDependencies`. (The repo `package.json` keeps
|
||||
`agent-browser`/`tuistory` in `dependencies` purely as the version pins for
|
||||
the vendored helpers tree; they are never installed by consumers.)
|
||||
- There is **no windows-arm64 build** (matches the release matrix); Windows on
|
||||
ARM runs the x64 build under emulation via the first-run fallback.
|
||||
|
||||
### Binary resolution in `bin/zero.js`
|
||||
|
||||
1. Resolve `@gitlawb/zero-<platform>-<arch>` and exec the `zero` binary from
|
||||
it. This wins over any previously downloaded copy — the platform version is
|
||||
pinned to the wrapper release.
|
||||
2. Fall back to a binary previously downloaded next to the wrapper.
|
||||
3. If neither exists (`--omit=optional`, package managers that skip optional
|
||||
dependencies), run the **fallback downloader**: `scripts/postinstall.mjs`,
|
||||
the exact logic that used to run as a postinstall hook (HTTPS-only, SHA-256
|
||||
verified against the release's own checksum file, no zip-slip), invoked by
|
||||
the wrapper itself. Failures are deliberately not cached — the fetch
|
||||
retries on every run until a binary is in place, so a transient network
|
||||
error self-heals. Platforms with no release asset (anything outside the
|
||||
matrix above, including windows-arm64) skip the attempt entirely rather
|
||||
than probing the network each run.
|
||||
4. If the download is impossible too, print build-from-source guidance.
|
||||
|
||||
There is deliberately **no `scripts.postinstall`** in any published
|
||||
`package.json`. The downloader exists only as a first-run fallback.
|
||||
|
||||
### Vendored helpers (`helpers/`)
|
||||
|
||||
`agent-browser` (Apache-2.0, vercel-labs/agent-browser) and `tuistory` are
|
||||
**vendored binaries/packages inside the platform payload**, not npm
|
||||
dependencies of the wrapper:
|
||||
|
||||
- As a dependency, agent-browser's `engines: { node: ">=24", pnpm: ">=11" }`
|
||||
and postinstall script produce `EBADENGINE` and `allow-scripts` warnings for
|
||||
every installer. As a vendored tree it produces none, because npm never
|
||||
resolves it.
|
||||
- `zero-release package` already stages the helpers tree into every release
|
||||
archive (`stageLocalControlHelpers` runs `npm ci` from the repo's
|
||||
`package.json` pins + lockfile on the native builder). The assembly script
|
||||
reuses that staged tree, so npm installs and `install.sh` installs get
|
||||
identical helpers.
|
||||
- The Go binary resolves helpers from `<binary dir>/helpers/node_modules/.bin`
|
||||
on its own (`internal/localcontrol/browser.go`, `adjacentHelper`) — no
|
||||
wrapper involvement, no configuration.
|
||||
- **Symlink materialization:** `npm pack` silently drops symlinks, and npm's
|
||||
own `.bin` shims are symlinks on POSIX. The assembly script rewrites every
|
||||
`.bin` symlink into a relocatable `#!/bin/sh` exec shim and dereferences any
|
||||
other symlink, so the vendored tree survives publishing. (Verified
|
||||
empirically; nested `node_modules` under `helpers/` IS packed — only the
|
||||
package-root `node_modules` is always ignored.)
|
||||
- **Binary pruning:** agent-browser ships one ~11 MB native binary per
|
||||
platform (7 total). The assembly script keeps only the payload's own
|
||||
platform binary (plus the musl variant on linux, which its launcher detects
|
||||
at runtime) — about 65 MB saved per platform package.
|
||||
|
||||
## Publishing rules (release pipeline)
|
||||
|
||||
These are the invariants `release-artifacts.yml` must hold. Breaking the first
|
||||
one is user-visible immediately.
|
||||
|
||||
1. **Platform versions must never become `latest`.** `0.4.0-linux-x64` is a
|
||||
semver *prerelease* of `0.4.0`; publishing it without an explicit
|
||||
non-`latest` dist-tag would clobber `latest` and users would install a
|
||||
platform payload as the CLI. The workflow publishes platform versions with
|
||||
`--tag platform` and asserts `latest` survived before the wrapper publish,
|
||||
then asserts `latest` equals the wrapper version after it. Both lookups
|
||||
tolerate a failed `npm view` (registry lag, or no `latest` tag yet on a
|
||||
bootstrap publish) — a missing value must not abort the job between the
|
||||
platform and wrapper publishes, which would strand a half-published
|
||||
release.
|
||||
2. **Platform versions publish before the wrapper.** The wrapper's
|
||||
`optionalDependencies` pin exact suffixed versions; publishing the wrapper
|
||||
first would create a window where installs resolve aliases that 404.
|
||||
3. **Exact-version pinning.** The wrapper at `X.Y.Z` references platform
|
||||
versions `X.Y.Z-<platform>-<arch>` exactly — never ranges — so a wrapper
|
||||
and its binaries can never skew.
|
||||
4. All publishes go through the existing npm OIDC trusted-publishing flow and
|
||||
the `npm-publish` environment gate; no tokens. Because the platform
|
||||
payloads are versions of the same package, they reuse the single
|
||||
trusted-publisher configuration.
|
||||
|
||||
`zero update` keeps working unchanged: it detects an npm install by finding a
|
||||
`package.json` named `@gitlawb/zero` next to the running binary — true inside
|
||||
a platform payload too — and updates via `npm install -g @gitlawb/zero@latest`.
|
||||
|
||||
## Runbook: bumping the vendored helpers
|
||||
|
||||
The vendored helper versions are pinned by the repo `package.json`
|
||||
`dependencies` + `package-lock.json`; the release build vendors exactly what
|
||||
the lockfile resolves. No `^range` surprises ship: a release carries what its
|
||||
lockfile pinned at build time.
|
||||
|
||||
- Routine bump: `npm install agent-browser@<version>` (or `tuistory@…`) in the
|
||||
repo to update pin + lockfile, verify the browser/terminal tools end-to-end,
|
||||
ship with the next release.
|
||||
- Security response: if a vendored helper publishes a security fix, the bump
|
||||
is release-worthy on its own — cut a patch release of zero carrying only the
|
||||
pin change.
|
||||
- Attribution: agent-browser is Apache-2.0; its LICENSE ships inside the
|
||||
vendored package directory (`helpers/node_modules/agent-browser/`), which
|
||||
satisfies redistribution attribution.
|
||||
|
||||
## History / rationale
|
||||
|
||||
Until v0.3.x the npm package was a wrapper with a `postinstall` downloader and
|
||||
`agent-browser`/`tuistory` as regular dependencies. That produced three
|
||||
warnings on every install (`EBADENGINE` from agent-browser's `node >=24`
|
||||
engines pin, and `allow-scripts` warnings for both postinstall scripts), broke
|
||||
silently under Bun's default script blocking, and coupled `npm install` to
|
||||
GitHub Releases availability. The platform-package model removes the warnings
|
||||
structurally — there is nothing left in the tree for a package manager to warn
|
||||
about — rather than asking users to approve or suppress them.
|
||||
+45
-10
@@ -1,8 +1,8 @@
|
||||
# npm Wrapper Smoke Checklist
|
||||
|
||||
Run this checklist when a PR changes npm distribution files such as
|
||||
`package.json`, `bin/zero.js`, Go build or release tooling, or the npm `bin`
|
||||
wrapper.
|
||||
`package.json`, `bin/zero.js`, `scripts/npm/build-platform-packages.mjs`,
|
||||
Go build or release tooling, or the npm `bin` wrapper.
|
||||
|
||||
## Required Checks
|
||||
|
||||
@@ -21,17 +21,52 @@ go run ./cmd/zero-release build
|
||||
go run ./cmd/zero-release smoke
|
||||
```
|
||||
|
||||
## End-To-End Assembly Smoke
|
||||
|
||||
Build a real archive for the host platform and assemble the npm payloads from
|
||||
it (this is exactly what the publish job does):
|
||||
|
||||
```bash
|
||||
go run ./cmd/zero-release package
|
||||
node scripts/npm/build-platform-packages.mjs \
|
||||
--artifacts-dir dist/release --out-dir dist/npm --only <platform>-<arch>
|
||||
```
|
||||
|
||||
Then simulate an installed layout and run the wrapper through each resolution
|
||||
path:
|
||||
|
||||
```bash
|
||||
# platform-package path
|
||||
mkdir -p /tmp/zero-sim/node_modules/@gitlawb
|
||||
cp -R dist/npm/wrapper /tmp/zero-sim/node_modules/@gitlawb/zero
|
||||
cp -R dist/npm/platforms/zero-<platform>-<arch> \
|
||||
"/tmp/zero-sim/node_modules/@gitlawb/zero-<platform>-<arch>"
|
||||
node /tmp/zero-sim/node_modules/@gitlawb/zero/bin/zero.js --version
|
||||
|
||||
# first-run download fallback (delete the platform package first)
|
||||
rm -rf "/tmp/zero-sim/node_modules/@gitlawb/zero-<platform>-<arch>"
|
||||
node /tmp/zero-sim/node_modules/@gitlawb/zero/bin/zero.js --version
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
- `package.json` has the expected package name (`@gitlawb/zero`), version,
|
||||
`bin.zero` entry, and exactly one `scripts` entry — the `postinstall` hook.
|
||||
- `scripts/postinstall.mjs` resolves the correct release asset name/URL per
|
||||
platform (`ZERO_INSTALL_DRY_RUN=1` prints the plan), verifies the downloaded
|
||||
archive's SHA-256, and extracts only the known binary basenames into place.
|
||||
`ZERO_SKIP_DOWNLOAD=1` opts out cleanly (exit 0) and an unsupported
|
||||
platform/arch is a non-fatal skip.
|
||||
- The wrapper binary resolves through the package `bin` entry and
|
||||
`node_modules/.bin` in a package-install smoke test.
|
||||
`bin.zero` entry, and NO `scripts` entries — the published package must be
|
||||
free of lifecycle scripts (see NPM_PACKAGING.md).
|
||||
- `scripts/npm/build-platform-packages.mjs` emits platform payloads whose
|
||||
`package.json` is `@gitlawb/zero@<version>-<platform>-<arch>` with matching
|
||||
`os`/`cpu`, contains the executable binary and the vendored `helpers/` tree
|
||||
with regular-file (non-symlink) `.bin` shims, and a wrapper payload with no
|
||||
scripts, no dependencies, and the full five-alias `optionalDependencies`
|
||||
matrix.
|
||||
- `scripts/postinstall.mjs` (the first-run fallback) resolves the correct
|
||||
release asset name/URL per platform (`ZERO_INSTALL_DRY_RUN=1` prints the
|
||||
plan), verifies the downloaded archive's SHA-256, and extracts only the
|
||||
known binary basenames into place. `ZERO_SKIP_DOWNLOAD=1` opts out cleanly
|
||||
(exit 0) and an unsupported platform/arch is a non-fatal skip.
|
||||
- The wrapper prefers the platform package binary over a previously downloaded
|
||||
one, and `agent-browser`/`tuistory` run from the platform package's
|
||||
`helpers/node_modules/.bin` shims.
|
||||
- The built binary exits 0 for `zero --version` or `zero --help`.
|
||||
- `zero --version` reports `zero <package.json version>`.
|
||||
- Release packaging still emits the expected archive and checksum names when
|
||||
|
||||
@@ -17,6 +17,7 @@ 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)
|
||||
|
||||
@@ -41,6 +41,7 @@ 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." }
|
||||
@@ -51,6 +52,10 @@ 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
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
module github.com/Gitlawb/zero
|
||||
|
||||
go 1.25.0
|
||||
|
||||
toolchain go1.26.4
|
||||
go 1.26.5
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.1.0
|
||||
@@ -14,6 +12,7 @@ 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
|
||||
|
||||
@@ -32,6 +32,8 @@ 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=
|
||||
|
||||
+54
-21
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -159,8 +160,14 @@ 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 := a.loadHistory(meta.SessionID)
|
||||
history, historyErr := a.loadHistory(meta.SessionID)
|
||||
sess := a.registerSession(meta.SessionID, root, history)
|
||||
a.warnPersistence(
|
||||
¬ifier{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
|
||||
@@ -253,7 +260,14 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
|
||||
if stopErr != nil {
|
||||
return "", RPCError(codeInternalError, stopErr.Error())
|
||||
}
|
||||
a.persistTurn(sess, userText, result.FinalAnswer)
|
||||
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,
|
||||
)
|
||||
}
|
||||
return reason, nil
|
||||
}
|
||||
|
||||
@@ -386,29 +400,34 @@ func (a *Agent) modeState(s *acpSession) *SessionModeState {
|
||||
|
||||
// ---- persistence + continuity ----
|
||||
|
||||
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 {
|
||||
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) error {
|
||||
defer sess.appendHistory(turnRecord{user: user, assistant: assistant})
|
||||
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
|
||||
return nil, err
|
||||
}
|
||||
var records []turnRecord
|
||||
var pendingUser string
|
||||
@@ -444,7 +463,21 @@ func (a *Agent) loadHistory(sessionID string) []turnRecord {
|
||||
if havePending {
|
||||
records = append(records, turnRecord{user: pendingUser})
|
||||
}
|
||||
return records
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// buildPrompt prepends prior conversation as context, since agent.Run drives a
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -242,8 +244,77 @@ 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)
|
||||
@@ -251,7 +322,7 @@ func drainText(t *testing.T, ch <-chan string) string {
|
||||
select {
|
||||
case s := <-ch:
|
||||
b.WriteString(s)
|
||||
if strings.Contains(b.String(), "Hello from ZERO") {
|
||||
if done(b.String()) {
|
||||
return b.String()
|
||||
}
|
||||
case <-deadline:
|
||||
|
||||
@@ -3,6 +3,7 @@ package acp
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
@@ -70,8 +71,8 @@ func primaryArgHint(rawArgs string) string {
|
||||
func truncateHint(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
const max = 60
|
||||
if len(s) > max {
|
||||
return s[:max] + "…"
|
||||
if utf8.RuneCountInString(s) > max {
|
||||
return string([]rune(s)[:max]) + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
@@ -126,3 +128,35 @@ func TestPromptText(t *testing.T) {
|
||||
t.Fatalf("promptText = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolTitleTruncateHintRuneSafe(t *testing.T) {
|
||||
// A 61-character string containing multi-byte UTF-8 runes (emojis / CJK characters).
|
||||
// We want to verify that it is truncated without cutting any runes or producing invalid UTF-8.
|
||||
longPath := "📁/项目/非常长的路径名称/测试/🚀/emoji-and-cjk-characters-which-are-very-long-and-exceed-sixty-characters"
|
||||
|
||||
// Create JSON args for read_file
|
||||
rawArgs := `{"path":"` + longPath + `"}`
|
||||
got := toolTitle("read_file", rawArgs)
|
||||
|
||||
expectedPrefix := "read_file "
|
||||
if !strings.HasPrefix(got, expectedPrefix) {
|
||||
t.Fatalf("expected title to start with %q, got %q", expectedPrefix, got)
|
||||
}
|
||||
|
||||
hint := strings.TrimPrefix(got, expectedPrefix)
|
||||
// Hint should end with the ellipsis character
|
||||
if !strings.HasSuffix(hint, "…") {
|
||||
t.Fatalf("expected truncated hint to end with ellipsis, got %q", hint)
|
||||
}
|
||||
|
||||
// Check that we don't have invalid UTF-8 runes
|
||||
if !utf8.ValidString(hint) {
|
||||
t.Fatalf("truncated hint is not a valid UTF-8 string: %q", hint)
|
||||
}
|
||||
|
||||
// The rune count of the hint (excluding ellipsis) should be exactly 60
|
||||
runes := []rune(strings.TrimSuffix(hint, "…"))
|
||||
if len(runes) != 60 {
|
||||
t.Fatalf("expected exactly 60 runes before ellipsis, got %d (hint: %q)", len(runes), hint)
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,26 @@ func TestSelfReportedIncompletionMatching(t *testing.T) {
|
||||
{"I replaced the placeholder value with the computed key.", false},
|
||||
{"The retry uses exponential backoff as a fallback.", false},
|
||||
{"Without proper error handling the function would crash, so I added it.", false},
|
||||
// real false positive (conversational recap): retelling a PAST exchange is
|
||||
// not an admission about the current objective — the sentence carries its
|
||||
// own narrative context ("You asked …", "at the time"):
|
||||
{"You asked if I could work autonomously on zero if you gave me a GitHub account — I was honest that my sandbox had no repo, no build system, and no network/integration wired up yet, so I couldn't actually do it at the time.", false},
|
||||
{"When we last spoke, I couldn't reach the GitHub API from the sandbox.", false},
|
||||
{"Last session I was unable to build the project; that is what we should retry now.", false},
|
||||
// quoted admissions (the model citing its own earlier message or a log
|
||||
// line) must NOT be flagged:
|
||||
{`My earlier reply said "I couldn't do it" but the tooling is now installed.`, false},
|
||||
{"The run log contains `I cannot access the network`, which is expected inside the sandbox.", false},
|
||||
// current-objective admissions must STILL be flagged, including past tense
|
||||
// and multi-sentence finals whose other sentences are narrative:
|
||||
{"I couldn't complete the refactor.", true},
|
||||
{"I hit an error I could not resolve, so the migration is unfinished.", true},
|
||||
{"You asked me to port the parser. I couldn't finish the second half.", true},
|
||||
// unbalanced delimiters must not swallow the tail (only BALANCED quoted
|
||||
// spans are stripped), so an admission after a stray quote still fires:
|
||||
{"The log ends with a stray \" and I couldn't finish the migration.", true},
|
||||
{"There is an unterminated `code span and I cannot proceed with the fix.", true},
|
||||
{"The doc opens with “a curly quote and I was unable to complete the task.", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := selfReportedIncompletion(c.text) != ""
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
@@ -27,10 +28,20 @@ func proposedCommandPrefix(toolName string, args map[string]any) []string {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, tokens := range segments {
|
||||
if !knownSafeCommandSegment(tokens) && sandbox.ValidCommandPrefix(tokens) {
|
||||
return append([]string(nil), tokens...)
|
||||
// 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
|
||||
}
|
||||
if !sandbox.ValidCommandPrefix(tokens) || !otherSegmentsKnownSafe(segments, index) {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), tokens...)
|
||||
}
|
||||
if len(segments) == 0 || !sandbox.ValidCommandPrefix(segments[0]) {
|
||||
return nil
|
||||
@@ -38,6 +49,20 @@ 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
|
||||
@@ -168,10 +193,6 @@ 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
|
||||
@@ -196,14 +217,6 @@ 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 == "" {
|
||||
@@ -275,7 +288,11 @@ func knownSafeCommandSegment(command []string) bool {
|
||||
if len(command) == 0 {
|
||||
return false
|
||||
}
|
||||
switch commandName(command[0]) {
|
||||
name := commandName(command[0])
|
||||
if runtime.GOOS == "windows" && tools.MsysProneCommandName(name) {
|
||||
return false
|
||||
}
|
||||
switch name {
|
||||
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,9 +1,11 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
|
||||
@@ -35,12 +37,37 @@ 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",
|
||||
@@ -120,13 +147,35 @@ 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": "ps aux | head -5"}, Options{Sandbox: engine})
|
||||
grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": command}, 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"})
|
||||
|
||||
@@ -21,12 +21,17 @@ 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.
|
||||
const defaultCompactionPreserveLast = 8
|
||||
// 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
|
||||
|
||||
// compactionTriggerRatio is the fraction of the context window at which
|
||||
// proactive compaction kicks in (top of each turn).
|
||||
const compactionTriggerRatio = 0.8
|
||||
// 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
|
||||
|
||||
// summaryLabel prefixes the injected summary so it is unmistakable in the
|
||||
// transcript (and so tests can assert on it).
|
||||
@@ -320,6 +325,43 @@ 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 {
|
||||
@@ -348,7 +390,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 := estimateTokens(messages) + toolTokens
|
||||
size := state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
||||
if size <= state.threshold {
|
||||
return messages
|
||||
}
|
||||
@@ -365,7 +407,7 @@ func (state *compactionState) maybeCompact(
|
||||
// entirely and preserve recent turns verbatim.
|
||||
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
|
||||
messages = pruned
|
||||
size = estimateTokens(messages) + toolTokens
|
||||
size = state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
||||
if size <= state.threshold {
|
||||
state.lowWaterMark = size
|
||||
return messages
|
||||
@@ -381,7 +423,7 @@ func (state *compactionState) maybeCompact(
|
||||
// later turn) can try again; we never drop messages on failure here.
|
||||
return messages
|
||||
}
|
||||
newSize := estimateTokens(compacted) + toolTokens
|
||||
newSize := state.calibratedTokens(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.
|
||||
@@ -439,7 +481,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 = estimateTokens(result) + estimateToolDefTokens(tools)
|
||||
state.lowWaterMark = state.calibratedTokens(estimateTokens(result) + estimateToolDefTokens(tools))
|
||||
return result, true, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,16 @@ 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 (
|
||||
@@ -100,12 +110,123 @@ func formatPlanArguments(arguments string) string {
|
||||
}
|
||||
|
||||
// skillEntry is a named preserved body. It began as loaded-skill state and is
|
||||
// reused for loaded tools and project instruction blocks.
|
||||
// reused for loaded tools, project instruction blocks, and recent file edits.
|
||||
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.
|
||||
@@ -310,11 +431,17 @@ 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"`
|
||||
@@ -347,6 +474,12 @@ 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))
|
||||
@@ -360,19 +493,76 @@ func appendPreservedState(summary string, middle []zeroruntime.Message) string {
|
||||
projectInstructionEntries(middle),
|
||||
)
|
||||
|
||||
if block := formatPreservedState(plan, tools, skills, instructions); block != "" {
|
||||
if block := formatPreservedState(plan, edits, 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, tools, skills, instructions []skillEntry) string {
|
||||
if plan == "" && len(tools) == 0 && len(skills) == 0 && len(instructions) == 0 {
|
||||
func formatPreservedState(plan string, edits, tools, skills, instructions []skillEntry) string {
|
||||
if plan == "" && len(edits) == 0 && 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})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -126,9 +127,107 @@ func TestCompletionGateContinuesOnCueThenSucceeds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// With the gate OFF (the interactive/TUI default), a continuation-cue turn is
|
||||
// accepted as the final answer exactly as before — guaranteeing no behavior
|
||||
// change for non-headless callers.
|
||||
// Issue #666: the plan-aware gate applies even when RequireCompletionSignal is
|
||||
// off (interactive/TUI). A model that creates a plan then stalls on a
|
||||
// continuation-cue text turn must be re-prompted and eventually INCOMPLETE, not
|
||||
// accepted as success while the plan panel stays on step one.
|
||||
func TestPlanPendingGateAppliesWithoutRequireCompletionSignal(t *testing.T) {
|
||||
cue := "Now I need to configure the SSH server. Let me check the current SSH configuration:"
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
planTurn("in_progress", "pending", "pending"),
|
||||
textTurn(cue), textTurn(cue), textTurn(cue), textTurn(cue), textTurn(cue),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "set up a git server", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 10,
|
||||
// RequireCompletionSignal deliberately off — interactive/TUI default.
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Incomplete {
|
||||
t.Fatalf("expected Incomplete=true (plan stalled without headless gate), got false; final=%q turns=%d", result.FinalAnswer, result.Turns)
|
||||
}
|
||||
if len(provider.requests) != 1+maxContinueNudges+1 {
|
||||
t.Fatalf("expected %d provider turns (1 plan + %d nudges + 1 final), got %d",
|
||||
1+maxContinueNudges+1, maxContinueNudges, len(provider.requests))
|
||||
}
|
||||
if !someRequestContains(provider.requests, continueNudgeMarker) {
|
||||
t.Fatalf("expected a continue nudge (%q) to be injected", continueNudgeMarker)
|
||||
}
|
||||
}
|
||||
|
||||
// A pending plan must nudge non-colon continuation announcements too — not only
|
||||
// colon-terminated cues — so the first mid-step text turn cannot finalize while
|
||||
// steps remain. A later clean final answer is accepted after the bounded nudges.
|
||||
func TestPlanPendingNudgesNonColonContinuationText(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
midStep := "Let me inspect the configuration."
|
||||
final := "Provider profiles are loaded from config and merged with defaults."
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
planTurn("in_progress", "pending", "pending"),
|
||||
textTurn(midStep),
|
||||
textTurn(final), textTurn(final), textTurn(final), textTurn(final),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "inspect and configure", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("expected success after nudge + final answer, got Incomplete (%q)", result.IncompleteReason)
|
||||
}
|
||||
if result.FinalAnswer != final {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, final)
|
||||
}
|
||||
if len(provider.requests) != 1+1+maxContinueNudges {
|
||||
t.Fatalf("expected %d turns (plan + mid-step + %d nudged finals), got %d",
|
||||
1+1+maxContinueNudges, maxContinueNudges, len(provider.requests))
|
||||
}
|
||||
if !someRequestContains(provider.requests, continueNudgeMarker) {
|
||||
t.Fatalf("expected a continue nudge after the mid-step text with pending plan")
|
||||
}
|
||||
}
|
||||
|
||||
// After bounded nudges, a confident final answer with stale plan bookkeeping must
|
||||
// still succeed on the interactive/TUI path (no infinite loop, no false incomplete).
|
||||
func TestPlanPendingAcceptsFinalAnswerAfterBoundedNudgesWithoutHeadlessGate(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
done := "All provider profiles are documented above."
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
planTurn("in_progress", "pending"),
|
||||
textTurn(done), textTurn(done), textTurn(done), textTurn(done),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "explain provider loading", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("stale plan alone must not force Incomplete on TUI path; reason=%q", result.IncompleteReason)
|
||||
}
|
||||
if result.FinalAnswer != done {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, done)
|
||||
}
|
||||
if !someRequestContains(provider.requests, continueNudgeMarker) {
|
||||
t.Fatalf("expected at least one continue nudge before accepting completion")
|
||||
}
|
||||
}
|
||||
|
||||
// With the gate OFF and NO pending plan, a continuation-cue turn is still
|
||||
// accepted as the final answer — no behavior change for short single-step tasks.
|
||||
func TestCompletionGateOffPreservesLegacyBehavior(t *testing.T) {
|
||||
cue := "Let me check the config:"
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
@@ -177,6 +276,44 @@ func TestContinuationCueMatching(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Cancellation during a plan-pending stall must return immediately without extra
|
||||
// nudges or incomplete synthesis.
|
||||
func TestRunCancellationDuringPlanPendingStopsImmediately(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
_, err := Run(ctx, "do work", cancelMidStreamProvider{cancel: cancel}, Options{
|
||||
Registry: registry,
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("expected context.Canceled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Repeated identical update_plan calls must still hit the max-turns ceiling rather
|
||||
// than looping silently forever.
|
||||
func TestRunRepeatedIdenticalUpdatePlanHitsMaxTurns(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
same := toolTurn("plan", "update_plan", `{"plan":[{"content":"step","status":"in_progress"}]}`)
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{same, same, same, textTurn("halted summary")}}
|
||||
|
||||
result, err := Run(context.Background(), "keep planning", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// MaxTurns=3 tool turns, then finalAnswerAfterMaxTurns issues one more request.
|
||||
if len(provider.requests) != 4 {
|
||||
t.Fatalf("expected 3 tool turns + 1 max-turns final-answer call, got %d", len(provider.requests))
|
||||
}
|
||||
if result.FinalAnswer == "" {
|
||||
t.Fatal("expected a max-turns final answer")
|
||||
}
|
||||
}
|
||||
|
||||
// review #4: a run that loops to the MaxTurns ceiling (always calling a tool, so it
|
||||
// never reaches the no-tool-call gate) was reported as success. Under the headless
|
||||
// gate, a max-turns cutoff is INCOMPLETE — the agent was stopped mid-run, not done.
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+140
-30
@@ -22,6 +22,12 @@ 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
|
||||
@@ -43,8 +49,12 @@ 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.
|
||||
toolFailureStopAt = 4
|
||||
// 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
|
||||
|
||||
// maxContinueNudges bounds how many times the headless completion gate
|
||||
// (Options.RequireCompletionSignal) re-prompts a model that stopped without a
|
||||
@@ -173,35 +183,112 @@ var successNegationTails = []string{
|
||||
"reproduce", "spot any", "locate any",
|
||||
}
|
||||
|
||||
// selfReportedIncompletion returns a short reason when the model's final text
|
||||
// admits it guessed or could not meet the objective, else "". Case-insensitive.
|
||||
func selfReportedIncompletion(text string) string {
|
||||
lower := strings.ToLower(text)
|
||||
for _, phrase := range selfReportPhrases {
|
||||
if strings.Contains(lower, phrase) {
|
||||
return selfReportReason(phrase)
|
||||
// narrativeMarkers flag a sentence as RETELLING a past exchange rather than
|
||||
// reporting the outcome of the current objective, so an inability admission in
|
||||
// that sentence is about THEN, not NOW. Grounded in a real false positive: a
|
||||
// conversational recap ("You asked if I could work autonomously … I was honest
|
||||
// that my sandbox had no repo … so I couldn't actually do it at the time") was
|
||||
// downgraded to INCOMPLETE on the quoted "i couldn't " stem even though the
|
||||
// current turn's objective (summarize where we left off) was fully met. Every
|
||||
// marker is second-person or explicitly past-referential, so a first-person
|
||||
// admission about the current task ("I couldn't complete the refactor") is
|
||||
// never masked.
|
||||
var narrativeMarkers = []string{
|
||||
"you asked", "you said", "you wanted", "you mentioned",
|
||||
"we discussed", "we talked", "we covered",
|
||||
"when we last", "last time", "last session", "previous session",
|
||||
"previous conversation", "earlier session", "earlier conversation",
|
||||
"at the time", "back then",
|
||||
}
|
||||
|
||||
// stripQuoted removes spans enclosed in double quotes (straight or curly) or
|
||||
// backticks, so an admission the model merely QUOTES — its own earlier message,
|
||||
// a log line, an error string — cannot fire the detector. Only BALANCED spans
|
||||
// are removed: an opening delimiter that never closes is kept as literal text,
|
||||
// so a stray quote cannot swallow the rest of the message (and with it a
|
||||
// genuine admission the detector must see). Single quotes are left alone: they
|
||||
// are overwhelmingly apostrophes ("couldn't"), and treating them as quote
|
||||
// delimiters would swallow the text between two contractions.
|
||||
func stripQuoted(s string) string {
|
||||
var b strings.Builder
|
||||
var span strings.Builder // pending text since the open delimiter, kept if it never closes
|
||||
open := rune(0)
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case open != 0:
|
||||
if (open == '"' && r == '"') || (open == '“' && r == '”') || (open == '`' && r == '`') {
|
||||
open = 0
|
||||
span.Reset()
|
||||
continue
|
||||
}
|
||||
span.WriteRune(r)
|
||||
case r == '"' || r == '“' || r == '`':
|
||||
open = r
|
||||
span.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
for _, stem := range inabilityStems {
|
||||
// Scan EVERY occurrence of the stem, not just the first: an earlier
|
||||
// success-negation use ("I could not find any examples, so I could not
|
||||
// implement it") must not mask a later genuine admission with the same stem.
|
||||
for start := 0; ; {
|
||||
rel := strings.Index(lower[start:], stem)
|
||||
if rel < 0 {
|
||||
break
|
||||
b.WriteString(span.String()) // dangling delimiter: restore the span verbatim
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// admissionSentences splits lowered text into sentence-ish fragments so the
|
||||
// detector judges each claim in its own context. Newlines split too (markdown
|
||||
// bullets are separate claims); the exact boundaries only need to keep an
|
||||
// admission next to its own narrative/negation context, not be grammatical.
|
||||
func admissionSentences(lower string) []string {
|
||||
return strings.FieldsFunc(lower, func(r rune) bool {
|
||||
return r == '.' || r == '!' || r == '?' || r == '\n'
|
||||
})
|
||||
}
|
||||
|
||||
// selfReportedIncompletion returns a short reason when the model's final text
|
||||
// admits it guessed or could not meet the objective, else "". Case-insensitive.
|
||||
// Matching is per sentence, after dropping quoted spans, and a sentence that
|
||||
// retells a past exchange (narrativeMarkers) is skipped entirely — an admission
|
||||
// must be the model's own report about the CURRENT objective, not general
|
||||
// language that merely resembles one.
|
||||
func selfReportedIncompletion(text string) string {
|
||||
for _, sentence := range admissionSentences(strings.ToLower(stripQuoted(text))) {
|
||||
if containsAny(sentence, narrativeMarkers) {
|
||||
continue
|
||||
}
|
||||
for _, phrase := range selfReportPhrases {
|
||||
if strings.Contains(sentence, phrase) {
|
||||
return selfReportReason(phrase)
|
||||
}
|
||||
abs := start + rel
|
||||
tail := strings.TrimSpace(lower[abs+len(stem):])
|
||||
if !hasAnyPrefix(tail, successNegationTails) {
|
||||
return selfReportReason(strings.TrimSpace(stem) + " …")
|
||||
}
|
||||
for _, stem := range inabilityStems {
|
||||
// Scan EVERY occurrence of the stem, not just the first: an earlier
|
||||
// success-negation use ("I could not find any examples, so I could not
|
||||
// implement it") must not mask a later genuine admission with the same stem.
|
||||
for start := 0; ; {
|
||||
rel := strings.Index(sentence[start:], stem)
|
||||
if rel < 0 {
|
||||
break
|
||||
}
|
||||
abs := start + rel
|
||||
tail := strings.TrimSpace(sentence[abs+len(stem):])
|
||||
if !hasAnyPrefix(tail, successNegationTails) {
|
||||
return selfReportReason(strings.TrimSpace(stem) + " …")
|
||||
}
|
||||
start = abs + len(stem)
|
||||
}
|
||||
start = abs + len(stem)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func containsAny(s string, subs []string) bool {
|
||||
for _, sub := range subs {
|
||||
if strings.Contains(s, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func selfReportReason(marker string) string {
|
||||
return `the final message admits the objective was not met ("` + marker + `")`
|
||||
}
|
||||
@@ -354,8 +441,13 @@ type guardState struct {
|
||||
emptyTurns int
|
||||
totalToolCalls int
|
||||
toolCallsSincePlanUpdate int
|
||||
planEverCalled bool
|
||||
notCalledReminderSent bool
|
||||
// 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
|
||||
// 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.
|
||||
@@ -414,10 +506,21 @@ 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 {
|
||||
productive := hasToolCalls || hasVisibleText
|
||||
switch {
|
||||
case productive:
|
||||
state.emptyTurns = 0
|
||||
} else {
|
||||
case hasReasoning && state.planItemsPending > 0:
|
||||
// Reasoning alone does not prove progress while plan steps remain — weaker
|
||||
// models (e.g. HY3 via OpenAI-compatible gateways) can stream long
|
||||
// reasoning-only turns without tools or visible text, burning the turn
|
||||
// budget while the plan panel stays on the first in_progress step.
|
||||
state.emptyTurns++
|
||||
case hasReasoning:
|
||||
state.emptyTurns = 0
|
||||
default:
|
||||
state.emptyTurns++
|
||||
}
|
||||
if hasToolCalls && !hasVisibleText {
|
||||
@@ -427,11 +530,16 @@ 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
|
||||
@@ -485,10 +593,12 @@ 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. One-shot per stale interval.
|
||||
if state.planEverCalled &&
|
||||
!state.staleReminderSent &&
|
||||
state.toolCallsSincePlanUpdate >= staleToolCallThreshold {
|
||||
// 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)) {
|
||||
state.staleReminderSent = true
|
||||
return planStaleReminder(state.toolCallsSincePlanUpdate)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,24 @@ 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},
|
||||
}
|
||||
}
|
||||
|
||||
// multiReasoningTurn streams several reasoning chunks inside ONE provider turn.
|
||||
func multiReasoningTurn(chunks ...string) []zeroruntime.StreamEvent {
|
||||
events := make([]zeroruntime.StreamEvent, 0, len(chunks)+1)
|
||||
for _, chunk := range chunks {
|
||||
events = append(events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventReasoning, Content: chunk})
|
||||
}
|
||||
events = append(events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
|
||||
return events
|
||||
}
|
||||
|
||||
// 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{
|
||||
@@ -101,6 +119,120 @@ func TestRunResetsEmptyTurnCounterOnVisibleOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStopsReasoningOnlyTurnsWhilePlanPending(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
toolTurn("plan", "update_plan", `{"plan":[{"content":"step 1","status":"in_progress"},{"content":"step 2","status":"pending"}]}`),
|
||||
reasoningTurn("thinking 1"),
|
||||
reasoningTurn("thinking 2"),
|
||||
reasoningTurn("thinking 3"),
|
||||
textTurn("should never reach here"),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "multi-step task", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 12,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(provider.requests) != maxEmptyTurns+1 {
|
||||
t.Fatalf("expected %d turns (1 plan + %d reasoning-only stalls), got %d", maxEmptyTurns+1, maxEmptyTurns, len(provider.requests))
|
||||
}
|
||||
if !result.Incomplete {
|
||||
t.Fatalf("expected Incomplete=true after reasoning-only stall with pending plan, got false")
|
||||
}
|
||||
if !strings.Contains(result.FinalAnswer, noOutputStopMarker) {
|
||||
t.Fatalf("expected no-output stop answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMultiChunkReasoningCountsAsOneNonProductiveTurnWhilePlanPending(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
toolTurn("plan", "update_plan", `{"plan":[{"content":"step 1","status":"in_progress"},{"content":"step 2","status":"pending"}]}`),
|
||||
multiReasoningTurn("chunk-1 ", "chunk-2 ", "chunk-3 ", "chunk-4 ", "chunk-5 "),
|
||||
multiReasoningTurn("more-1 ", "more-2 ", "more-3 "),
|
||||
multiReasoningTurn("final-1 ", "final-2 "),
|
||||
textTurn("should never reach here"),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "multi-step task", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 12,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(provider.requests) != maxEmptyTurns+1 {
|
||||
t.Fatalf("expected %d turns (1 plan + %d multi-chunk reasoning stalls), got %d",
|
||||
maxEmptyTurns+1, maxEmptyTurns, len(provider.requests))
|
||||
}
|
||||
if !result.Incomplete {
|
||||
t.Fatalf("expected Incomplete=true, got false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReasoningThenToolCallSucceedsWithPendingPlan(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
toolTurn("plan", "update_plan", `{"plan":[{"content":"step 1","status":"in_progress"},{"content":"step 2","status":"pending"}]}`),
|
||||
multiReasoningTurn("thinking ", "more thinking "),
|
||||
toolTurn("read", "read_file", `{"path":"notes.txt"}`),
|
||||
toolTurn("plan2", "update_plan", `{"plan":[{"content":"step 1","status":"completed"},{"content":"step 2","status":"completed"}]}`),
|
||||
textTurn("Both steps are done."),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "multi-step task", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 12,
|
||||
Cwd: root,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("run should succeed after productive tool call, got Incomplete (%q)", result.IncompleteReason)
|
||||
}
|
||||
if result.FinalAnswer != "Both steps are done." {
|
||||
t.Fatalf("final answer = %q", result.FinalAnswer)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -260,7 +392,7 @@ func TestRunDoesNotInjectNotCalledReminderWhenPlanUsed(t *testing.T) {
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
toolTurn("call-1", "update_plan", `{"plan":[{"content":"step one"}]}`),
|
||||
toolTurn("call-1", "update_plan", `{"plan":[{"content":"step one","status":"completed"}]}`),
|
||||
toolTurn("call-2", "read_file", `{"path":"notes.txt"}`),
|
||||
textTurn("done"),
|
||||
},
|
||||
@@ -296,6 +428,12 @@ func TestRunInjectsStalePlanReminderAfterManyToolCalls(t *testing.T) {
|
||||
for i := 0; i < staleToolCallThreshold+2; i++ {
|
||||
turns = append(turns, toolTurn("call", "read_file", `{"path":"notes.txt"}`))
|
||||
}
|
||||
// The plan-aware completion gate re-prompts up to maxContinueNudges when the
|
||||
// model stops with text while plan items remain; supply those turns before
|
||||
// the final answer.
|
||||
for i := 0; i < maxContinueNudges; i++ {
|
||||
turns = append(turns, textTurn("done."))
|
||||
}
|
||||
turns = append(turns, textTurn("done"))
|
||||
|
||||
provider := &mockProvider{turns: turns}
|
||||
@@ -330,6 +468,9 @@ func TestRunStalePlanReminderIsOneShotPerInterval(t *testing.T) {
|
||||
for i := 0; i < staleToolCallThreshold*2; i++ {
|
||||
turns = append(turns, toolTurn("call", "read_file", `{"path":"notes.txt"}`))
|
||||
}
|
||||
for i := 0; i < maxContinueNudges; i++ {
|
||||
turns = append(turns, textTurn("done."))
|
||||
}
|
||||
turns = append(turns, textTurn("done"))
|
||||
|
||||
provider := &mockProvider{turns: turns}
|
||||
|
||||
+323
-76
@@ -25,7 +25,12 @@ 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.
|
||||
const maxStreamStallRetries = 2
|
||||
//
|
||||
// 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 (
|
||||
toolResultMetaControl = "control"
|
||||
@@ -104,7 +109,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, error) {
|
||||
func Run(ctx context.Context, prompt string, provider Provider, options Options) (result Result, err error) {
|
||||
if provider == nil {
|
||||
return Result{}, errors.New("agent provider is required")
|
||||
}
|
||||
@@ -132,6 +137,12 @@ 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).
|
||||
@@ -145,15 +156,35 @@ 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
|
||||
|
||||
result := Result{Messages: copyMessages(messages)}
|
||||
// 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)
|
||||
}()
|
||||
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, _ := partitionTools(registry, permissionMode, options, loaded)
|
||||
exposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
|
||||
|
||||
// PROACTIVE compaction: if the history is approaching the model's
|
||||
// context window, summarize the oldest middle before building the
|
||||
@@ -163,6 +194,7 @@ 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
|
||||
@@ -198,6 +230,7 @@ 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
|
||||
@@ -258,6 +291,7 @@ 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 {
|
||||
@@ -316,6 +350,7 @@ 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 {
|
||||
@@ -340,6 +375,16 @@ 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.
|
||||
@@ -371,6 +416,10 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// counted toward the runaway cap so we stop before burning maxTurns.
|
||||
if guards.observeTurn(collected) {
|
||||
result.FinalAnswer = noOutputStopAnswer(result.Turns)
|
||||
if guards.pendingPlanItems() {
|
||||
result.Incomplete = true
|
||||
result.IncompleteReason = "stopped after non-productive turns while plan items remained"
|
||||
}
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, nil
|
||||
}
|
||||
@@ -384,10 +433,13 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
})
|
||||
continue
|
||||
}
|
||||
// Completion gate (headless): a turn with text but no tool call is the
|
||||
// model's final answer ONLY when the work is actually done. Default off
|
||||
// (RequireCompletionSignal), so interactive runs stay byte-identical.
|
||||
if options.RequireCompletionSignal {
|
||||
// Completion gate: a turn with text but no tool call is the model's
|
||||
// final answer ONLY when the work is actually done. Headless exec opts in
|
||||
// via RequireCompletionSignal; interactive runs also apply the gate while
|
||||
// update_plan still has pending/in_progress items so a plan cannot stall
|
||||
// silently after the first step is marked active.
|
||||
planPending := guards.pendingPlanItems()
|
||||
if options.RequireCompletionSignal || planPending {
|
||||
// (1) Self-report downgrade (strongest, unambiguous): the model's own
|
||||
// final message admits it guessed / could not meet the objective. Checked
|
||||
// FIRST so an admitted-impossible task is downgraded immediately (no wasted
|
||||
@@ -410,7 +462,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// false-fail a completed run with stale bookkeeping) — fall through to the
|
||||
// acceptance check / success.
|
||||
cue := endsWithContinuationCue(collected.Text)
|
||||
planPending := guards.pendingPlanItems()
|
||||
if cue || planPending {
|
||||
if continueNudges < maxContinueNudges {
|
||||
continueNudges++
|
||||
@@ -450,6 +501,19 @@ 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
|
||||
@@ -475,11 +539,35 @@ 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)
|
||||
}
|
||||
toolResult, abortErr := executeToolCall(ctx, registry, call, permissionMode, options)
|
||||
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)
|
||||
}
|
||||
if options.OnToolResult != nil {
|
||||
options.OnToolResult(toolResult)
|
||||
}
|
||||
@@ -542,11 +630,14 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
failureHint = toolFailureHint(call.Name, toolSchemaJSON(registry, call.Name), toolResult.Output)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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 {
|
||||
changedFilesThisBatch = append(changedFilesThisBatch, toolResult.ChangedFiles...)
|
||||
postEditDiagnostics.enqueue(ctx, toolResult.ChangedFiles)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -629,7 +720,19 @@ 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.
|
||||
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, options); strings.TrimSpace(answer) != "" {
|
||||
//
|
||||
// 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) != "" {
|
||||
result.FinalAnswer = answer
|
||||
result.FinishReason = finishReason
|
||||
result.Messages = copyMessages(finalMessages)
|
||||
@@ -649,7 +752,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, options Options) (string, []zeroruntime.Message, string) {
|
||||
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, options Options) (string, []zeroruntime.Message, string) {
|
||||
finalMessages := copyMessages(messages)
|
||||
finalMessages = append(finalMessages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
@@ -660,7 +763,9 @@ 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, ""
|
||||
@@ -816,6 +921,25 @@ 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().
|
||||
@@ -1032,6 +1156,12 @@ 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,
|
||||
@@ -1450,6 +1580,54 @@ 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
|
||||
@@ -2159,6 +2337,18 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
|
||||
}
|
||||
}
|
||||
|
||||
// A command that explicitly requests additional sandbox permissions
|
||||
// (sandbox_permissions: with_additional_permissions) is an ELEVATION the user
|
||||
// must consent to, even when the base command's sandbox decision was Allow.
|
||||
// shouldRequestPermission blocks on OnPermissionRequest for exactly this case,
|
||||
// so the event MUST be a prompt — otherwise it carried Action=allow while the
|
||||
// loop waited on a decision, and a UI that renders only prompts (the TUI)
|
||||
// dropped it, deadlocking the run. Keep this consistent with
|
||||
// shouldRequestPermission's own condition.
|
||||
if action == PermissionActionAllow && !permissionGranted && shellCommandAdditionalPermissionsRequested(args) {
|
||||
action = PermissionActionPrompt
|
||||
}
|
||||
|
||||
if safety.Permission == tools.PermissionAllow && action == PermissionActionAllow && !grantMatched && block == nil {
|
||||
return PermissionEvent{}, false
|
||||
}
|
||||
@@ -2310,7 +2500,12 @@ func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (s
|
||||
}
|
||||
raw, ok := args["additional_permissions"]
|
||||
if !ok || raw == nil {
|
||||
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf("missing additional_permissions; provide at least one of network or file_system")
|
||||
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)
|
||||
}
|
||||
data, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
@@ -2325,7 +2520,9 @@ 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 requested permission in network or file_system")
|
||||
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"]}}`)
|
||||
}
|
||||
grantProfile, err := sandbox.RequestPermissionGrantProfile(normalized)
|
||||
if err != nil {
|
||||
@@ -2424,6 +2621,17 @@ 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))
|
||||
@@ -2458,76 +2666,115 @@ func partitionTools(registry *tools.Registry, permissionMode PermissionMode, opt
|
||||
|
||||
active := options.DeferThreshold > 0 && eligible >= options.DeferThreshold && loaderUsable
|
||||
|
||||
definitions := make([]zeroruntime.ToolDefinition, 0, len(visible))
|
||||
exposedNames := make(map[string]bool, len(visible))
|
||||
// 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)
|
||||
var hiddenTools []tools.Tool
|
||||
for _, tool := range visible {
|
||||
name := tool.Name()
|
||||
deferred := tools.IsDeferred(tool)
|
||||
|
||||
if !active {
|
||||
// Inactive: byte-identical to legacy, but tool_search is never advertised.
|
||||
if name == tools.ToolSearchToolName {
|
||||
continue
|
||||
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)
|
||||
}
|
||||
definitions = append(definitions, zeroruntime.ToolDefinition{
|
||||
Name: name,
|
||||
Description: tool.Description(),
|
||||
Parameters: schemaToRuntimeMap(tool.Parameters()),
|
||||
})
|
||||
exposedNames[name] = true
|
||||
continue
|
||||
}
|
||||
|
||||
// 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
|
||||
eager = append(eager, cachedRuntimeToolDefinition(defCache, tool))
|
||||
}
|
||||
|
||||
// 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 active && len(hiddenTools) > 0 {
|
||||
discovery = tools.BuildToolSearchDescription(hiddenTools)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(definitions, func(left int, right int) bool {
|
||||
return definitions[left].Name < definitions[right].Name
|
||||
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
|
||||
})
|
||||
|
||||
discovery := ""
|
||||
if 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
|
||||
}
|
||||
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()),
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
+173
-7
@@ -4,12 +4,14 @@ 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"
|
||||
@@ -37,6 +39,88 @@ 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
|
||||
}
|
||||
|
||||
func (tool *recordingWebSearchTool) Name() string { return "web_search" }
|
||||
func (tool *recordingWebSearchTool) Description() string { return "test web search tool" }
|
||||
func (tool *recordingWebSearchTool) Parameters() tools.Schema {
|
||||
return tools.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]tools.PropertySchema{
|
||||
"query": {Type: "string"},
|
||||
},
|
||||
Required: []string{"query"},
|
||||
AdditionalProperties: false,
|
||||
}
|
||||
}
|
||||
func (tool *recordingWebSearchTool) Safety() tools.Safety {
|
||||
return tools.Safety{
|
||||
SideEffect: tools.SideEffectNetwork,
|
||||
Permission: tools.PermissionPrompt,
|
||||
Reason: "Sends model-provided search query text to the configured web search backend.",
|
||||
AdvertiseInAuto: true,
|
||||
}
|
||||
}
|
||||
func (tool *recordingWebSearchTool) Run(_ context.Context, args map[string]any) tools.Result {
|
||||
tool.calls = append(tool.calls, cloneArgs(args))
|
||||
return tools.Result{Status: tools.StatusOK, Output: "1. T — https://x.test"}
|
||||
}
|
||||
|
||||
type sandboxDeniedRetryTool struct {
|
||||
calls []map[string]any
|
||||
}
|
||||
@@ -865,7 +949,7 @@ func TestRunRejectsLocalWebFetchBeforePermissionRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAdvertisesAllowedWebSearchInAutoMode(t *testing.T) {
|
||||
func TestRunAdvertisesPromptedWebSearchInAutoMode(t *testing.T) {
|
||||
t.Setenv("ZERO_WEBSEARCH_BASE_URL", "https://search.example/api")
|
||||
registry := tools.NewRegistry()
|
||||
for _, tool := range tools.CoreNetworkTools() {
|
||||
@@ -896,6 +980,52 @@ func TestRunAdvertisesAllowedWebSearchInAutoMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRequestsPermissionBeforeWebSearchExecution(t *testing.T) {
|
||||
search := &recordingWebSearchTool{}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(search)
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "web_search"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"query":"private workspace detail"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var requests []PermissionRequest
|
||||
|
||||
result, err := Run(context.Background(), "search", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
requests = append(requests, request)
|
||||
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "network not approved"}, nil
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected final answer after denied tool call, got %q", result.FinalAnswer)
|
||||
}
|
||||
if len(requests) != 1 {
|
||||
t.Fatalf("expected one permission request, got %#v", requests)
|
||||
}
|
||||
if requests[0].ToolName != "web_search" || requests[0].Permission != string(tools.PermissionPrompt) || requests[0].SideEffect != string(tools.SideEffectNetwork) {
|
||||
t.Fatalf("unexpected permission request: %#v", requests[0])
|
||||
}
|
||||
if len(search.calls) != 0 {
|
||||
t.Fatalf("web_search backend must not run when permission is denied, got calls %#v", search.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunFiltersAdvertisedTools(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
@@ -1870,6 +2000,11 @@ 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)
|
||||
@@ -1883,7 +2018,7 @@ func TestRunCommandPrefixApprovalCoversSegmentedShellWithSafeTail(t *testing.T)
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"command":"ps aux | head -5"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: fmt.Sprintf(`{"command":%q}`, segmentedCommand)},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
@@ -2687,11 +2822,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 final no-tools request after max turns, got %d requests", len(provider.requests))
|
||||
t.Fatalf("expected finalization request after max turns, got %d requests", len(provider.requests))
|
||||
}
|
||||
finalRequest := provider.requests[1]
|
||||
if len(finalRequest.Tools) != 0 {
|
||||
t.Fatalf("finalization request must not advertise tools, got %#v", finalRequest.Tools)
|
||||
if toolDefinitionByName(finalRequest.Tools, "read_file") == nil {
|
||||
t.Fatalf("finalization request must keep prior tools exposed for strict provider replay, got %#v", finalRequest.Tools)
|
||||
}
|
||||
lastMessage := finalRequest.Messages[len(finalRequest.Messages)-1]
|
||||
if lastMessage.Role != zeroruntime.MessageRoleUser || !strings.Contains(lastMessage.Content, "tool-turn limit") {
|
||||
@@ -2812,6 +2947,24 @@ func TestGitBranchForPromptResolvesRelativeWorktreeGitdir(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitBranchForPromptInSubdirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
gitdir := filepath.Join(root, ".git")
|
||||
if err := os.MkdirAll(gitdir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(gitdir, "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subdir := filepath.Join(root, "sub", "dir")
|
||||
if err := os.MkdirAll(subdir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := gitBranchForPrompt(subdir); got != "main" {
|
||||
t.Fatalf("gitBranchForPrompt in subdirectory = %q, want main", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptInjectsWorkspaceContext(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.md"), []byte("Always run `make lint` before committing."), 0o644); err != nil {
|
||||
@@ -2832,8 +2985,21 @@ func TestBuildSystemPromptInjectsHostShellContext(t *testing.T) {
|
||||
t.Fatalf("expected operating system in environment block, got %q", prompt)
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
} else if !strings.Contains(prompt, "/bin/sh syntax") {
|
||||
t.Fatalf("expected POSIX shell guidance in prompt, got %q", prompt)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// promptShellTool is a minimal shell-style tool whose Safety requires a prompt,
|
||||
// standing in for bash so buildPermissionEvent can be exercised directly.
|
||||
type promptShellTool struct{}
|
||||
|
||||
func (promptShellTool) Name() string { return "bash" }
|
||||
func (promptShellTool) Description() string { return "run a shell command" }
|
||||
func (promptShellTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", AdditionalProperties: false}
|
||||
}
|
||||
func (promptShellTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs a shell command"}
|
||||
}
|
||||
func (promptShellTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK}
|
||||
}
|
||||
|
||||
// A command whose sandbox decision is Allow but that explicitly requests
|
||||
// additional sandbox permissions is an ELEVATION: it must be surfaced as a
|
||||
// prompt, never Action=allow. Previously it carried allow while the loop still
|
||||
// blocked on OnPermissionRequest, and the TUI (which renders only prompts)
|
||||
// dropped it → the run deadlocked forever. This is the exact gpt-5.5
|
||||
// folder-creation shape captured in the reported hang.
|
||||
func TestBuildPermissionEventPromptsForAdditionalPermissions(t *testing.T) {
|
||||
call := ToolCall{ID: "call_1", Name: "bash", Arguments: `{}`}
|
||||
args := map[string]any{
|
||||
"command": "mkdir foo && cd foo && go mod init foo",
|
||||
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
|
||||
}
|
||||
// Base command allowed by the sandbox…
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionAllow}
|
||||
|
||||
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
|
||||
if !ok {
|
||||
t.Fatal("buildPermissionEvent returned ok=false; the elevation request must produce an event")
|
||||
}
|
||||
if event.Action != PermissionActionPrompt {
|
||||
t.Fatalf("Action = %q, want prompt — an additional-permissions elevation must ask the user, not auto-allow", event.Action)
|
||||
}
|
||||
// Sanity: shouldRequestPermission agrees the loop blocks on this.
|
||||
if !shouldRequestPermission(promptShellTool{}, args, false, decision) {
|
||||
t.Fatal("shouldRequestPermission must be true for an additional-permissions request")
|
||||
}
|
||||
}
|
||||
|
||||
// Without the additional-permissions flag, a sandbox-allowed command is NOT a
|
||||
// prompt (shouldRequestPermission is false; it just runs) — the override must
|
||||
// not over-reach and prompt for ordinary allowed commands.
|
||||
func TestBuildPermissionEventKeepsAllowForOrdinaryAllowedCommand(t *testing.T) {
|
||||
call := ToolCall{ID: "call_2", Name: "bash", Arguments: `{}`}
|
||||
args := map[string]any{"command": "ls"}
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionAllow}
|
||||
|
||||
event, _ := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
|
||||
if event.Action == PermissionActionPrompt {
|
||||
t.Fatalf("Action = prompt for an ordinary allowed command; want allow (no spurious prompt)")
|
||||
}
|
||||
if shouldRequestPermission(promptShellTool{}, args, false, decision) {
|
||||
t.Fatal("shouldRequestPermission must be false for an ordinary sandbox-allowed command")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,11 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/errhint"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
@@ -17,10 +19,20 @@ import (
|
||||
// times. We retry ONLY the connect (not a partially-consumed stream), so no
|
||||
// already-forwarded OnText is ever duplicated.
|
||||
const (
|
||||
maxStreamReconnects = 2
|
||||
streamReconnectBase = 500 * time.Millisecond
|
||||
// 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
|
||||
)
|
||||
|
||||
// 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.
|
||||
@@ -73,7 +85,7 @@ func streamWithReconnect(ctx context.Context, provider Provider, request zerorun
|
||||
if notify != nil {
|
||||
notify(attempt, maxStreamReconnects)
|
||||
}
|
||||
if waitErr := sleepWithContext(ctx, backoffFor(attempt)); waitErr != nil {
|
||||
if waitErr := sleepWithContext(ctx, jitteredBackoff(attempt)); waitErr != nil {
|
||||
return nil, err // ctx cancelled while waiting; surface the original error
|
||||
}
|
||||
stream, err = provider.StreamCompletion(ctx, request)
|
||||
@@ -96,6 +108,19 @@ 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",
|
||||
@@ -106,8 +131,6 @@ func shouldReconnect(ctx context.Context, err error) bool {
|
||||
"timed out",
|
||||
"temporarily unavailable",
|
||||
"i/o timeout",
|
||||
"503",
|
||||
"502",
|
||||
"server closed",
|
||||
"unexpected end",
|
||||
} {
|
||||
@@ -118,14 +141,31 @@ 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()
|
||||
|
||||
@@ -44,6 +44,10 @@ 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)
|
||||
@@ -87,7 +91,7 @@ func TestShouldReconnectClassification(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
disconnects := []string{
|
||||
"unexpected EOF", "connection reset by peer", "broken pipe",
|
||||
"i/o timeout", "503 Service Unavailable", "server closed the connection",
|
||||
"i/o timeout", "server closed the connection", "connection refused",
|
||||
}
|
||||
for _, m := range disconnects {
|
||||
if !shouldReconnect(ctx, errors.New(m)) {
|
||||
@@ -97,6 +101,13 @@ 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)) {
|
||||
@@ -112,6 +123,24 @@ 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) {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
+197
-31
@@ -5,9 +5,11 @@ 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"
|
||||
)
|
||||
@@ -37,6 +39,14 @@ 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
|
||||
@@ -80,12 +90,22 @@ 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)
|
||||
}
|
||||
@@ -164,6 +184,75 @@ 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)
|
||||
@@ -197,7 +286,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; prefer the workdir/cwd argument instead of cd when changing directories.\n")
|
||||
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")
|
||||
} 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")
|
||||
}
|
||||
@@ -206,7 +295,7 @@ func workspaceContext(cwd string) string {
|
||||
}
|
||||
b.WriteString("</environment>")
|
||||
|
||||
b.WriteString(projectGuidelines(cwd, findProjectGitRoot(cwd)))
|
||||
b.WriteString(projectGuidelines(cwd, FindProjectGitRoot(cwd)))
|
||||
if repoMap := repoMapContext(cwd); repoMap != "" {
|
||||
b.WriteString("\n\n## Repo map\n\n" + repoMap)
|
||||
}
|
||||
@@ -250,13 +339,7 @@ func projectGuidelines(cwd, gitRoot string) string {
|
||||
if remaining := maxProjectContextTotalBytes - totalUsed; remaining < limit {
|
||||
limit = remaining
|
||||
}
|
||||
if len(content) > limit {
|
||||
cut := limit
|
||||
for cut > 0 && !utf8.RuneStart(content[cut]) {
|
||||
cut--
|
||||
}
|
||||
content = content[:cut] + "\n… (truncated)"
|
||||
}
|
||||
content = truncateGuidelineContent(content, limit)
|
||||
label := projectGuidelineLabel(match, gitRoot)
|
||||
b.WriteString("\n\n## Project guidelines (" + label + ")\n\n" + content)
|
||||
totalUsed += len(content)
|
||||
@@ -264,6 +347,92 @@ 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].
|
||||
@@ -310,29 +479,22 @@ 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 (uppercase 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 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 lowercased
|
||||
// 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 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
|
||||
}
|
||||
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())
|
||||
}
|
||||
if match := findCaseInsensitiveFile(parent, filepath.Base(name)); match != "" {
|
||||
return match
|
||||
}
|
||||
}
|
||||
return ""
|
||||
@@ -380,17 +542,17 @@ func resolveDirCaseInsensitive(target, anchor string) (string, bool) {
|
||||
return cur, true
|
||||
}
|
||||
|
||||
// findProjectGitRoot returns the nearest ancestor of cwd that contains a
|
||||
// FindProjectGitRoot returns the nearest ancestor of cwd that contains a
|
||||
// .git entry (file or directory). Returns "" when no git root is found, so
|
||||
// the caller can fall back to cwd-only lookup.
|
||||
func findProjectGitRoot(cwd string) string {
|
||||
func FindProjectGitRoot(cwd string) string {
|
||||
cwd = strings.TrimSpace(cwd)
|
||||
if cwd == "" {
|
||||
return ""
|
||||
}
|
||||
cur := cwd
|
||||
for {
|
||||
if hasGitMetadata(cur) {
|
||||
if HasGitMetadata(cur) {
|
||||
return cur
|
||||
}
|
||||
parent := filepath.Dir(cur)
|
||||
@@ -401,7 +563,7 @@ func findProjectGitRoot(cwd string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func hasGitMetadata(dir string) bool {
|
||||
func HasGitMetadata(dir string) bool {
|
||||
gitPath := filepath.Join(dir, ".git")
|
||||
info, err := os.Stat(gitPath)
|
||||
if err != nil {
|
||||
@@ -457,7 +619,11 @@ func repoMapContext(cwd string) string {
|
||||
// cwd, handling both a regular checkout (.git dir) and a worktree (.git file).
|
||||
// Returns "" on any problem — the prompt simply omits the branch segment.
|
||||
func gitBranchForPrompt(cwd string) string {
|
||||
gitPath := filepath.Join(cwd, ".git")
|
||||
gitRoot := FindProjectGitRoot(cwd)
|
||||
if gitRoot == "" {
|
||||
return ""
|
||||
}
|
||||
gitPath := filepath.Join(gitRoot, ".git")
|
||||
info, err := os.Stat(gitPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
@@ -473,10 +639,10 @@ func gitBranchForPrompt(cwd string) string {
|
||||
return ""
|
||||
}
|
||||
// In worktree mode the gitdir is often RELATIVE (e.g.
|
||||
// "gitdir: ../.git/worktrees/<name>") — resolve it against cwd, not the
|
||||
// "gitdir: ../.git/worktrees/<name>") — resolve it against the worktree root (gitRoot), not the
|
||||
// process working directory, or HEAD lookup fails and we drop the branch.
|
||||
if !filepath.IsAbs(dir) {
|
||||
dir = filepath.Join(cwd, dir)
|
||||
dir = filepath.Join(gitRoot, dir)
|
||||
}
|
||||
headPath = filepath.Join(dir, "HEAD")
|
||||
}
|
||||
|
||||
@@ -46,14 +46,10 @@ 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.** 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.
|
||||
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.
|
||||
|
||||
## Editing discipline
|
||||
|
||||
@@ -64,7 +60,13 @@ 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.
|
||||
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.
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -6,11 +6,13 @@ 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 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).
|
||||
// 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).
|
||||
|
||||
const (
|
||||
familyOpenAI = "openai"
|
||||
@@ -45,9 +47,10 @@ 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 ""
|
||||
}
|
||||
}
|
||||
@@ -70,8 +73,3 @@ 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>`
|
||||
@@ -29,9 +29,11 @@ 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, anthropicPromptAddendum) {
|
||||
t.Fatalf("expected the Anthropic addendum in the claude prompt")
|
||||
if strings.Contains(claude, "<model_guidance>") {
|
||||
t.Fatalf("expected no model_guidance block for Claude (aligned with core prompt)")
|
||||
}
|
||||
if strings.Contains(claude, openAIPromptAddendum) {
|
||||
t.Fatalf("the claude prompt must not contain the OpenAI addendum")
|
||||
|
||||
@@ -9,6 +9,25 @@ 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{}))
|
||||
|
||||
@@ -23,7 +42,8 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
"avoid broad refactors",
|
||||
"search the web before answering",
|
||||
"do not recognize",
|
||||
"how each requirement is met",
|
||||
"scaled to the work",
|
||||
"comment density",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected core system prompt to include %q, got:\n%s", want, buildSystemPrompt(Options{}))
|
||||
@@ -127,8 +147,90 @@ 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 (uppercase MD) on a case-sensitive filesystem; the
|
||||
// Git tracks AGENTS.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 {
|
||||
@@ -256,6 +358,30 @@ 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")
|
||||
@@ -298,7 +424,7 @@ func TestFindProjectGitRootIgnoresEmptyGitDirectory(t *testing.T) {
|
||||
if err := os.MkdirAll(leaf, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := findProjectGitRoot(leaf); got != "" {
|
||||
t.Fatalf("findProjectGitRoot = %q, want empty for invalid .git directory", got)
|
||||
if got := FindProjectGitRoot(leaf); got != "" {
|
||||
t.Fatalf("FindProjectGitRoot = %q, want empty for invalid .git directory", got)
|
||||
}
|
||||
}
|
||||
+31
-7
@@ -174,6 +174,16 @@ 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
|
||||
@@ -189,6 +199,12 @@ 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
|
||||
@@ -269,16 +285,24 @@ 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
|
||||
// final answer. With it, a no-tool-call turn is NOT treated as "done" while
|
||||
// work clearly remains — pending update_plan items, or a message that ends on a
|
||||
// continuation cue ("…Let me check the config:"). The loop then nudges the
|
||||
// model to continue instead, bounded by maxContinueNudges (and still by
|
||||
// MaxTurns and the run deadline); if the model keeps stalling, the run
|
||||
// finalizes as INCOMPLETE (Result.Incomplete) rather than success. Default
|
||||
// false leaves the loop byte-identical, so the interactive TUI is unaffected.
|
||||
// final answer — except while update_plan still has pending/in_progress items,
|
||||
// when the plan-aware completion gate always applies (interactive and headless).
|
||||
// With RequireCompletionSignal, a no-tool-call turn is also gated on
|
||||
// continuation cues ("…Let me check the config:"). The loop nudges the model to
|
||||
// continue instead, bounded by maxContinueNudges (and still by MaxTurns and the
|
||||
// run deadline); if the model keeps stalling, the run finalizes as INCOMPLETE
|
||||
// (Result.Incomplete) rather than success.
|
||||
RequireCompletionSignal bool
|
||||
|
||||
runPermissions *permissionRunState
|
||||
|
||||
@@ -5,6 +5,7 @@ package background
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
@@ -14,7 +15,8 @@ import (
|
||||
func ConfigureChildProcessGroup(cmd *exec.Cmd) {}
|
||||
|
||||
func terminateProcess(pid int) error {
|
||||
if err := exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(pid)).Run(); err == nil {
|
||||
taskkill := taskkillPath()
|
||||
if err := exec.Command(taskkill, "/T", "/F", "/PID", strconv.Itoa(pid)).Run(); err == nil {
|
||||
return nil
|
||||
}
|
||||
process, err := os.FindProcess(pid)
|
||||
@@ -23,3 +25,14 @@ func terminateProcess(pid int) error {
|
||||
}
|
||||
return process.Kill()
|
||||
}
|
||||
|
||||
func taskkillPath() string {
|
||||
systemRoot := os.Getenv("SystemRoot")
|
||||
if systemRoot == "" {
|
||||
systemRoot = os.Getenv("windir")
|
||||
}
|
||||
if systemRoot == "" {
|
||||
systemRoot = `C:\Windows`
|
||||
}
|
||||
return filepath.Join(systemRoot, "System32", "taskkill.exe")
|
||||
}
|
||||
+214
-53
@@ -12,6 +12,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
@@ -19,9 +20,12 @@ 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"
|
||||
@@ -47,38 +51,43 @@ type appDeps struct {
|
||||
stdin io.Reader
|
||||
userConfigPath func() (string, error)
|
||||
resolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
|
||||
resolveMCPConfig func(workspaceRoot string) (config.MCPConfig, error)
|
||||
resolveMCPConfig func(workspaceRoot string, excludeProject bool) (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
|
||||
// by fillAppDeps, so tests never mutate the process environment unless they
|
||||
// inject it). nil ⇒ no export.
|
||||
exportActiveProvider func(providerName string)
|
||||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
|
||||
newSessionStore func() *sessions.Store
|
||||
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
|
||||
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
|
||||
skillsDir func() string
|
||||
pluginsDir func() string
|
||||
toolsDir func() string
|
||||
newMCPStore func() (*mcp.PermissionStore, error)
|
||||
newMCPTokenStore func() (*mcp.TokenStore, error)
|
||||
newSandboxStore func() (*sandbox.GrantStore, error)
|
||||
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
|
||||
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
|
||||
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
|
||||
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
|
||||
detectVerifyPlan func(string) (verify.Plan, error)
|
||||
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
|
||||
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
|
||||
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)
|
||||
runTUI func(context.Context, tui.Options) int
|
||||
runEditor func(string) error
|
||||
checkUpdate func(context.Context, update.Options) (update.Result, error)
|
||||
now func() time.Time
|
||||
exportActiveProvider func(providerName string)
|
||||
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)
|
||||
skillsDir func() string
|
||||
pluginsDir func() string
|
||||
toolsDir func() string
|
||||
newMCPStore func() (*mcp.PermissionStore, error)
|
||||
newMCPTokenStore func() (*mcp.TokenStore, error)
|
||||
newSandboxStore func() (*sandbox.GrantStore, error)
|
||||
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
|
||||
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
|
||||
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
|
||||
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
|
||||
detectVerifyPlan func(string) (verify.Plan, error)
|
||||
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
|
||||
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
|
||||
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
|
||||
}
|
||||
|
||||
type mcpToolRuntime interface {
|
||||
@@ -116,11 +125,12 @@ func defaultAppDeps() appDeps {
|
||||
options.Overrides = overrides
|
||||
return config.Resolve(options)
|
||||
},
|
||||
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(workspaceRoot string, excludeProject bool) (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) {
|
||||
@@ -134,8 +144,10 @@ func defaultAppDeps() appDeps {
|
||||
OAuthLoginKey: loginKey,
|
||||
})
|
||||
},
|
||||
probeProviderHealth: providerhealth.Probe,
|
||||
detectLocalRuntimes: provideronboarding.DetectLocalRuntimes,
|
||||
probeProviderHealth: providerhealth.Probe,
|
||||
discoverProviderModels: defaultDiscoverProviderModels,
|
||||
detectLocalRuntimes: provideronboarding.DetectLocalRuntimes,
|
||||
openRouterLogin: provideroauth.OpenRouterLogin,
|
||||
newSessionStore: func() *sessions.Store {
|
||||
return sessions.NewStore(sessions.StoreOptions{})
|
||||
},
|
||||
@@ -174,9 +186,12 @@ 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,
|
||||
}
|
||||
}
|
||||
@@ -224,6 +239,12 @@ 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)
|
||||
@@ -380,10 +401,14 @@ 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":
|
||||
@@ -402,6 +427,14 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
if _, err := fmt.Fprintf(stderr, "unknown command %q\n", args[0]); err != nil {
|
||||
return 1
|
||||
}
|
||||
// First-run users reach for `zero login`/`zero logout` (reported in the
|
||||
// wild); point them at the real command instead of a bare usage pointer.
|
||||
switch strings.ToLower(args[0]) {
|
||||
case "login", "logout":
|
||||
if _, err := fmt.Fprintf(stderr, "did you mean %q?\n", "zero auth "+strings.ToLower(args[0])); err != nil {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintln(stderr, "Run zero --help for usage."); err != nil {
|
||||
return 1
|
||||
}
|
||||
@@ -432,9 +465,15 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
if deps.probeProviderHealth == nil {
|
||||
deps.probeProviderHealth = defaults.probeProviderHealth
|
||||
}
|
||||
if deps.discoverProviderModels == nil {
|
||||
deps.discoverProviderModels = defaults.discoverProviderModels
|
||||
}
|
||||
if deps.detectLocalRuntimes == nil {
|
||||
deps.detectLocalRuntimes = defaults.detectLocalRuntimes
|
||||
}
|
||||
if deps.openRouterLogin == nil {
|
||||
deps.openRouterLogin = defaults.openRouterLogin
|
||||
}
|
||||
if deps.newSessionStore == nil {
|
||||
deps.newSessionStore = defaults.newSessionStore
|
||||
}
|
||||
@@ -492,6 +531,12 @@ 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
|
||||
}
|
||||
@@ -501,6 +546,9 @@ 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
|
||||
}
|
||||
@@ -515,6 +563,11 @@ 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
|
||||
}
|
||||
|
||||
@@ -523,6 +576,11 @@ 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)
|
||||
@@ -530,15 +588,32 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
|
||||
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
|
||||
if err != nil {
|
||||
// A resolve failure caused solely by a missing/unresolvable active provider
|
||||
// is not fatal for the interactive TUI: drop into the setup wizard with an
|
||||
// empty config so the user can onboard, instead of exiting with an error.
|
||||
// Any other error (malformed JSON, directory conflict, etc.) is still fatal.
|
||||
if !errors.Is(err, config.ErrNoActiveProvider) {
|
||||
// A resolve failure the setup wizard can FIX is not fatal for the
|
||||
// interactive TUI: drop into the wizard with an empty config so the user
|
||||
// can onboard/repair, instead of exiting with an error they can only fix
|
||||
// by hand-editing config.json. That covers a missing/unresolvable active
|
||||
// provider and an active provider without a model (custom endpoints have
|
||||
// no catalog default) — previously the second shape bricked bare `zero`
|
||||
// and `zero setup`, the exact commands that could have fixed it. Any
|
||||
// other error (malformed JSON, directory conflict, etc.) is still fatal,
|
||||
// and headless commands (zero config / zero exec) still fail with the
|
||||
// actionable message.
|
||||
if !errors.Is(err, config.ErrNoActiveProvider) && !errors.Is(err, config.ErrProviderRequiresModel) {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
resolved = config.ResolvedConfig{}
|
||||
forceSetup = true
|
||||
// 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
|
||||
}
|
||||
}
|
||||
userConfigPath, err := deps.userConfigPath()
|
||||
if err != nil {
|
||||
@@ -597,7 +672,17 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1)
|
||||
}
|
||||
defer closeSpecialistRuntime(stderr, specialistRuntime)
|
||||
mcpConfig, err := deps.resolveMCPConfig(workspaceRoot)
|
||||
// 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)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
@@ -625,15 +710,24 @@ 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.
|
||||
// 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).
|
||||
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.
|
||||
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr)
|
||||
// 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)
|
||||
// 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
|
||||
@@ -668,8 +762,26 @@ 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,
|
||||
@@ -680,17 +792,24 @@ 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(),
|
||||
Registry: registry,
|
||||
SessionStore: deps.newSessionStore(),
|
||||
SandboxStore: sandboxStore,
|
||||
MCPConfig: mcpConfig,
|
||||
MCPPermissionStore: mcpPermissionStore,
|
||||
MCPTokenStore: mcpTokenStore,
|
||||
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,
|
||||
MCPCommand: func(ctx context.Context, args []string) tui.MCPCommandResult {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
@@ -698,7 +817,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); err == nil {
|
||||
if refreshed, err := deps.resolveMCPConfig(workspaceRoot, mcpExcludeProject); err == nil {
|
||||
lastKnownMCPConfig = refreshed
|
||||
nextConfig = refreshed
|
||||
}
|
||||
@@ -716,13 +835,29 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
PermissionMode: permissionMode,
|
||||
Autonomy: "low",
|
||||
Sandbox: sandboxEngine,
|
||||
FileTracker: tools.NewFileTracker(),
|
||||
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
|
||||
FileTracker: fileTracker,
|
||||
Hooks: hookDispatcher,
|
||||
DeferThreshold: resolved.Tools.DeferThreshold,
|
||||
Specialists: specialistRuntime.specialists,
|
||||
Skills: pluginActivation.skillInfos(deps.skillsDir()),
|
||||
},
|
||||
PermissionMode: permissionMode,
|
||||
Notify: resolved.Notify,
|
||||
// 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),
|
||||
Setup: tui.SetupOptions{
|
||||
Visible: setupVisible,
|
||||
Required: needsSetup,
|
||||
@@ -1138,7 +1273,8 @@ func profileHasCredential(profile config.ProviderProfile) bool {
|
||||
}
|
||||
|
||||
// baseURLIsLoopback reports whether a provider base_url points at a loopback host
|
||||
// (a keyless local provider like Ollama/LM Studio), which needs no API key.
|
||||
// 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.
|
||||
func baseURLIsLoopback(baseURL string) bool {
|
||||
u := strings.TrimSpace(baseURL)
|
||||
if u == "" {
|
||||
@@ -1153,7 +1289,7 @@ func baseURLIsLoopback(baseURL string) bool {
|
||||
return true
|
||||
}
|
||||
if addr, err := netip.ParseAddr(host); err == nil {
|
||||
return addr.IsLoopback()
|
||||
return addr.IsLoopback() || addr.IsPrivate()
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1211,6 +1347,31 @@ Flags:
|
||||
--notify <off|bell|notify|both>
|
||||
Override notification mode for this run
|
||||
--no-notify Disable notifications for this run
|
||||
--no-completion-gate Accept a no-tool-call reply as final without the INCOMPLETE
|
||||
downgrade (for conversational callers with an operator present)
|
||||
`)
|
||||
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
|
||||
}
|
||||
}
|
||||
+411
-4
@@ -20,6 +20,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -158,6 +159,86 @@ 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
|
||||
@@ -205,6 +286,67 @@ 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
|
||||
@@ -263,7 +405,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) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
||||
if workspaceRoot != cwd {
|
||||
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
|
||||
}
|
||||
@@ -338,7 +480,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) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
||||
resolveCalls++
|
||||
switch resolveCalls {
|
||||
case 1:
|
||||
@@ -405,7 +547,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) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"docs": {Type: "stdio", Command: "docs-mcp"},
|
||||
}}, nil
|
||||
@@ -460,7 +602,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) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{}, nil
|
||||
},
|
||||
newMCPStore: func() (*mcp.PermissionStore, error) {
|
||||
@@ -599,6 +741,52 @@ 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
|
||||
@@ -1267,6 +1455,153 @@ 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()
|
||||
|
||||
@@ -1476,3 +1811,75 @@ func TestRunThemeFlagRejectsBadValue(t *testing.T) {
|
||||
t.Fatalf("expected a clear --theme error, got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A custom endpoint saved without a model previously bricked bare `zero` and
|
||||
// `zero setup` — the exact commands that could have fixed it (the resolve
|
||||
// error escaped before the wizard could open). The interactive TUI now treats
|
||||
// the requires-model failure as "needs onboarding", same as a missing active
|
||||
// provider. The error comes from a REAL Resolve over a real config file, so
|
||||
// this exercises the production sentinel wrapping, not a hand-built error.
|
||||
func TestRunNoArgsEntersSetupWhenActiveProviderMissesModel(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
setCLIUserConfigRoot(t)
|
||||
brokenConfig := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(brokenConfig, []byte(`{
|
||||
"activeProvider": "gw",
|
||||
"providers": [{"name": "gw", "provider_kind": "openai-compatible", "baseURL": "https://gw.example/v1", "apiKey": "sk-x"}]
|
||||
}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
launched := false
|
||||
var launchedOptions tui.Options
|
||||
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: brokenConfig, Env: map[string]string{}})
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
t.Fatal("newProvider must not be called without a resolved provider")
|
||||
return nil, nil
|
||||
},
|
||||
userConfigPath: func() (string, error) { return filepath.Join(t.TempDir(), "zero", "config.json"), nil },
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
runTUI: func(_ context.Context, options tui.Options) int {
|
||||
launched = true
|
||||
launchedOptions = options
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exit code = %d, want 0 (setup TUI launched), stderr=%q", exitCode, stderr.String())
|
||||
}
|
||||
if !launched {
|
||||
t.Fatal("TUI was not launched; a fixable requires-model config must fall into setup, not exit fatally")
|
||||
}
|
||||
if !launchedOptions.Setup.Visible || !launchedOptions.Setup.Required {
|
||||
t.Fatalf("Setup = %#v, want visible required setup", launchedOptions.Setup)
|
||||
}
|
||||
}
|
||||
|
||||
// `zero login`/`zero logout` don't exist (it's `zero auth login`); first-run
|
||||
// users try them (reported in the wild), so the unknown-command error points at
|
||||
// the real command.
|
||||
func TestRunUnknownLoginSuggestsAuthLogin(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"login"}, &stdout, &stderr, appDeps{})
|
||||
if exitCode != 2 {
|
||||
t.Fatalf("exit = %d, want 2 (unknown command)", exitCode)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), `"zero auth login"`) {
|
||||
t.Fatalf("stderr = %q, want a zero auth login suggestion", stderr.String())
|
||||
}
|
||||
// An unrelated unknown command gets no misleading suggestion.
|
||||
stderr.Reset()
|
||||
runWithDeps([]string{"frobnicate"}, &stdout, &stderr, appDeps{})
|
||||
if strings.Contains(stderr.String(), "did you mean") {
|
||||
t.Fatalf("stderr = %q, unrelated commands must not get the auth hint", stderr.String())
|
||||
}
|
||||
}
|
||||
+119
-12
@@ -6,15 +6,52 @@ 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.
|
||||
@@ -46,11 +83,11 @@ func runAuth(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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 {
|
||||
for _, a := range args {
|
||||
if a == "-h" || a == "--help" || a == "help" {
|
||||
_ = writeAuthHelp(stdout)
|
||||
@@ -62,7 +99,7 @@ func runAuthOpenRouter(args []string, stdout io.Writer, stderr io.Writer, _ appD
|
||||
if len(args) > 0 {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("zero auth openrouter takes no arguments (got %q)", args[0]))
|
||||
}
|
||||
key, err := provideroauth.OpenRouterLogin(context.Background(), provideroauth.OpenRouterOptions{
|
||||
key, err := deps.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).
|
||||
@@ -71,12 +108,59 @@ func runAuthOpenRouter(args []string, stdout io.Writer, stderr io.Writer, _ appD
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
@@ -138,8 +222,10 @@ func runAuthChatGPT(args []string, stdout io.Writer, stderr io.Writer, deps appD
|
||||
if _, err := fmt.Fprint(stdout, statuses); 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
|
||||
if line := ensureLoginProviderProfile(deps, "chatgpt"); line != "" {
|
||||
if _, err := fmt.Fprintf(stdout, "\n%s\n", line); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
@@ -301,12 +387,27 @@ 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: parsed.positional[0],
|
||||
Provider: provider,
|
||||
Device: parsed.device,
|
||||
ExtraScopes: parsed.scopes,
|
||||
})
|
||||
@@ -316,6 +417,11 @@ 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
|
||||
}
|
||||
|
||||
@@ -489,8 +595,9 @@ 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. Any preset field is overridable via the env vars
|
||||
below. For a custom provider named <name>, set:
|
||||
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_<NAME>_CLIENT_ID (required)
|
||||
ZERO_OAUTH_<NAME>_CLIENT_SECRET (optional)
|
||||
ZERO_OAUTH_<NAME>_AUTHORIZE_URL ZERO_OAUTH_<NAME>_TOKEN_URL
|
||||
|
||||
@@ -2,12 +2,14 @@ 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"
|
||||
)
|
||||
|
||||
@@ -72,6 +74,32 @@ 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
|
||||
|
||||
+166
-1
@@ -2,18 +2,26 @@ 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.
|
||||
// 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.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -145,6 +153,44 @@ 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 {
|
||||
@@ -156,3 +202,122 @@ 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
|
||||
}
|
||||
@@ -125,7 +125,9 @@ func backendLifecycleSnapshot(deps appDeps) (zerocommands.BackendLifecycleSnapsh
|
||||
return zerocommands.BackendLifecycleSnapshot{}, fmt.Errorf("failed to resolve workspace: %w", err)
|
||||
}
|
||||
|
||||
cfg, err := deps.resolveMCPConfig(cwd)
|
||||
// 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)
|
||||
if err != nil {
|
||||
return zerocommands.BackendLifecycleSnapshot{}, err
|
||||
}
|
||||
@@ -152,7 +154,9 @@ func backendDoctorReport(deps appDeps) (zerocommands.BackendDoctorReport, error)
|
||||
return zerocommands.BackendDoctorReport{}, fmt.Errorf("failed to resolve workspace: %w", err)
|
||||
}
|
||||
|
||||
cfg, err := deps.resolveMCPConfig(cwd)
|
||||
// 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)
|
||||
if err != nil {
|
||||
return zerocommands.BackendDoctorReport{}, err
|
||||
}
|
||||
|
||||
@@ -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) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (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) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(string, bool) (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) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (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) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"remote": {Type: "http", URL: server.URL + "/mcp"},
|
||||
}}, nil
|
||||
|
||||
@@ -75,12 +75,21 @@ 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)
|
||||
}
|
||||
if command == "detect" {
|
||||
return runProvidersDetect(args, stdout, stderr, deps)
|
||||
}
|
||||
if command == "models" {
|
||||
return runProvidersModels(args, stdout, stderr, deps)
|
||||
}
|
||||
if command != "list" && command != "current" && command != "catalog" {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unknown providers command %q", command))
|
||||
}
|
||||
@@ -243,6 +252,20 @@ 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
|
||||
@@ -320,7 +343,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: "+apiKeyState(provider.APIKeySet),
|
||||
"api key: "+providerCredentialState(provider),
|
||||
)
|
||||
if provider.Message != "" {
|
||||
lines = append(lines, "status: "+provider.Status+" - "+provider.Message)
|
||||
@@ -337,7 +360,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"), apiKeyState(provider.APIKeySet))
|
||||
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))
|
||||
if provider.Message != "" {
|
||||
line += " (" + provider.Status + ": " + provider.Message + ")"
|
||||
}
|
||||
@@ -410,6 +433,15 @@ 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
|
||||
@@ -463,11 +495,15 @@ 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]
|
||||
|
||||
Inspects resolved provider profiles and provider catalog descriptors without printing secrets.
|
||||
Detect probes for running local runtimes (Ollama, LM Studio) and prints adopt commands plus per-provider next steps.
|
||||
Models probes a provider's live model-listing endpoint (e.g. an OpenAI-compatible /v1/models) and lists the models it serves — including custom OpenAI-/Anthropic-compatible endpoints — so a self-hosted provider needs no per-model config.
|
||||
|
||||
Flags:
|
||||
--json Print JSON summary
|
||||
|
||||
@@ -11,6 +11,7 @@ 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"
|
||||
@@ -835,3 +836,57 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -211,11 +211,12 @@ func fireJob(store *cron.Store, now func() time.Time, job cron.Job, stdout io.Wr
|
||||
fmt.Fprintf(stderr, "warning: could not re-read job %s before persist: %v\n", job.ID, readErr)
|
||||
return job, nil
|
||||
}
|
||||
if current.Status == cron.StatusPaused {
|
||||
// Honor an external pause that landed while the job ran.
|
||||
job.Status = cron.StatusPaused
|
||||
current.FireCount = job.FireCount
|
||||
current.NextRunAt = job.NextRunAt
|
||||
if current.Status != cron.StatusPaused {
|
||||
current.Status = job.Status
|
||||
}
|
||||
return job, nil
|
||||
return current, nil
|
||||
})
|
||||
if errors.Is(err, cron.ErrJobNotFound) {
|
||||
// Genuinely removed mid-run: don't recreate it (no run record, no persist).
|
||||
|
||||
@@ -142,7 +142,7 @@ func TestRunExecListToolsAdvertisesMCPToolsWithoutToolSearch(t *testing.T) {
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return execResolvedConfig(), nil
|
||||
},
|
||||
resolveMCPConfig: func(string) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(string, bool) (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) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
|
||||
resolveMCPConfig: func(string, bool) (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) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"docs": {Type: "stdio", Command: "docs-mcp"},
|
||||
}}, nil
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+80
-19
@@ -12,6 +12,7 @@ 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"
|
||||
@@ -109,6 +110,16 @@ type execOptions struct {
|
||||
notifyMode string
|
||||
// noNotify forces ModeOff for this run. Mutually exclusive with notifyMode.
|
||||
noNotify bool
|
||||
// noCompletionGate turns off the headless completion gate
|
||||
// (agent.Options.RequireCompletionSignal) for this run. It exists for
|
||||
// CONVERSATIONAL exec callers — a chat frontend driving exec with an operator
|
||||
// present to reply (e.g. zeroclaw chat). There, a final message that hands
|
||||
// the turn back ("the sandbox blocks network egress, approve it and I'll
|
||||
// continue") is a COMPLETE answer, not an unfinished task, so the gate's
|
||||
// self-report admission check and INCOMPLETE downgrade (exit 4) would only
|
||||
// mislabel honest blocker reports. Default off: plain `zero exec` keeps the
|
||||
// gate, preserving CI/cron semantics.
|
||||
noCompletionGate bool
|
||||
// addDirs holds directories passed via --add-dir that should be allowed as
|
||||
// additional write roots for this run. Unioned with
|
||||
// config.SandboxConfig.AdditionalWriteRoots at scope construction time.
|
||||
@@ -135,6 +146,11 @@ 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
|
||||
@@ -149,6 +165,10 @@ 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,
|
||||
@@ -195,7 +215,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
if options.useSpec {
|
||||
permissionMode = agent.PermissionModeSpecDraft
|
||||
}
|
||||
mcpRuntime, err := registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options))
|
||||
mcpRuntime, mcpSkip, err := registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options), trustRoot)
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error())
|
||||
}
|
||||
@@ -204,7 +224,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)
|
||||
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot)
|
||||
if options.useSpec {
|
||||
specmode.RegisterDraftTools(registry, workspaceRoot, deps.now)
|
||||
}
|
||||
@@ -409,6 +429,8 @@ 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,
|
||||
@@ -499,15 +521,31 @@ 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. 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)
|
||||
// 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)
|
||||
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,
|
||||
@@ -524,17 +562,21 @@ 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,
|
||||
// if the model keeps stalling. The interactive TUI leaves this off, and
|
||||
// --no-completion-gate lets conversational exec callers (a chat frontend
|
||||
// with an operator present) opt out the same way.
|
||||
RequireCompletionSignal: !options.noCompletionGate,
|
||||
Sandbox: sandboxEngine,
|
||||
FileTracker: tools.NewFileTracker(),
|
||||
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
|
||||
FileTracker: fileTracker,
|
||||
Hooks: hookDispatcher,
|
||||
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{
|
||||
@@ -588,6 +630,7 @@ 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"})
|
||||
@@ -638,6 +681,10 @@ 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
|
||||
@@ -699,23 +746,29 @@ 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()) {
|
||||
if !enabled {
|
||||
return nil, func() {}
|
||||
}
|
||||
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)
|
||||
if !enabled {
|
||||
return nil, fileDiagnostics, cleanup
|
||||
}
|
||||
corrector := agent.NewSelfCorrector(workspaceRoot, agent.NewLSPDiagnosticsChecker(manager), agent.NewProjectVerifier(workspaceRoot), agent.SelfCorrectConfig{
|
||||
Enabled: true,
|
||||
IncludeTests: true,
|
||||
IncludeLSP: true,
|
||||
Autonomy: autonomy,
|
||||
})
|
||||
cleanup := func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = manager.Shutdown(ctx)
|
||||
}
|
||||
return corrector, cleanup
|
||||
return corrector, fileDiagnostics, cleanup
|
||||
}
|
||||
|
||||
func deferredEligibleCount(registry *tools.Registry, permissionMode agent.PermissionMode, enabledTools []string, disabledTools []string) int {
|
||||
@@ -948,6 +1001,14 @@ 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
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
|
||||
options.selfCorrect = true
|
||||
case arg == "--no-notify":
|
||||
options.noNotify = true
|
||||
case arg == "--no-completion-gate":
|
||||
options.noCompletionGate = true
|
||||
case arg == "--notify":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
@@ -402,6 +404,11 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
|
||||
// rather than pretend it took effect.
|
||||
return options, false, execUsageError{"--self-correct cannot be combined with --use-spec."}
|
||||
}
|
||||
if options.useSpec && options.noCompletionGate {
|
||||
// Same reasoning as --self-correct above: the spec-draft path never consults
|
||||
// the completion gate, so the flag would be silently ignored.
|
||||
return options, false, execUsageError{"--no-completion-gate cannot be combined with --use-spec."}
|
||||
}
|
||||
if !options.useSpec && options.specModel != "" {
|
||||
return options, false, execUsageError{"--spec-model requires --use-spec."}
|
||||
}
|
||||
@@ -461,6 +468,49 @@ func nextFlagValue(args []string, index int, flag string) (string, int, error) {
|
||||
if next == "" || flagValueLooksLikeOption(next) {
|
||||
return "", index, execUsageError{fmt.Sprintf("%s requires a value", flag)}
|
||||
}
|
||||
|
||||
// Validate specific flag values to prevent consuming positional prompt text
|
||||
switch flag {
|
||||
case "--auto":
|
||||
switch strings.ToLower(next) {
|
||||
case "low", "medium", "high", "member":
|
||||
default:
|
||||
return "", index, execUsageError{fmt.Sprintf("Invalid autonomy level %q. Expected low, medium, or high.", next)}
|
||||
}
|
||||
case "--notify":
|
||||
switch strings.ToLower(next) {
|
||||
case "off", "bell", "notify", "both":
|
||||
default:
|
||||
return "", index, execUsageError{fmt.Sprintf("invalid --notify %q. Expected off, bell, notify, or both.", next)}
|
||||
}
|
||||
case "-o", "--output":
|
||||
switch strings.ToLower(next) {
|
||||
case "text", "json", "stream-json", "debug":
|
||||
default:
|
||||
return "", index, execUsageError{fmt.Sprintf("invalid output format %q. Expected text, json, stream-json, or debug.", next)}
|
||||
}
|
||||
case "-i", "--input":
|
||||
switch strings.ToLower(next) {
|
||||
case "text", "stream-json":
|
||||
default:
|
||||
return "", index, execUsageError{fmt.Sprintf("Invalid input format %q. Expected text or stream-json.", next)}
|
||||
}
|
||||
case "--reasoning-effort", "--spec-reasoning-effort":
|
||||
switch strings.ToLower(next) {
|
||||
case "low", "medium", "high":
|
||||
default:
|
||||
return "", index, execUsageError{fmt.Sprintf("invalid %s %q. Expected low, medium, or high.", flag, next)}
|
||||
}
|
||||
case "--depth":
|
||||
if _, err := strconv.Atoi(next); err != nil {
|
||||
return "", index, execUsageError{fmt.Sprintf("invalid --depth %q. Expected a non-negative integer.", next)}
|
||||
}
|
||||
case "--max-turns":
|
||||
if _, err := strconv.Atoi(next); err != nil {
|
||||
return "", index, execUsageError{fmt.Sprintf("invalid --max-turns %q. Expected a positive integer.", next)}
|
||||
}
|
||||
}
|
||||
|
||||
return next, index + 1, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
// --no-completion-gate exists for conversational exec callers (a chat frontend
|
||||
// with an operator present): an honest blocker report that hands the turn back
|
||||
// must not be downgraded to INCOMPLETE there. The flag maps to
|
||||
// agent.Options.RequireCompletionSignal = false; default keeps the gate on.
|
||||
func TestParseExecArgsNoCompletionGate(t *testing.T) {
|
||||
options, _, err := parseExecArgs([]string{"--prompt", "hi", "--no-completion-gate"})
|
||||
if err != nil {
|
||||
t.Fatalf("parseExecArgs: %v", err)
|
||||
}
|
||||
if !options.noCompletionGate {
|
||||
t.Fatal("--no-completion-gate must set noCompletionGate")
|
||||
}
|
||||
|
||||
options, _, err = parseExecArgs([]string{"--prompt", "hi"})
|
||||
if err != nil {
|
||||
t.Fatalf("parseExecArgs: %v", err)
|
||||
}
|
||||
if options.noCompletionGate {
|
||||
t.Fatal("noCompletionGate must default to false (gate on)")
|
||||
}
|
||||
}
|
||||
|
||||
// The spec-draft path never consults the completion gate, so the combination
|
||||
// must be rejected up front instead of silently ignoring the flag (mirrors the
|
||||
// existing --self-correct + --use-spec check).
|
||||
func TestParseExecArgsNoCompletionGateRejectsUseSpec(t *testing.T) {
|
||||
if _, _, err := parseExecArgs([]string{"--prompt", "hi", "--use-spec", "--no-completion-gate"}); err == nil {
|
||||
t.Fatal("--no-completion-gate with --use-spec must error")
|
||||
}
|
||||
}
|
||||
@@ -21,3 +21,20 @@ func TestParseExecArgsInlinePromptKeepsLeadingDash(t *testing.T) {
|
||||
t.Fatalf("inline prompt = %q, want %q", got, "-foo bar")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExecArgsPreventsPositionalFromBeingConsumedAsFlagValue(t *testing.T) {
|
||||
_, _, err := parseExecArgs([]string{"--auto", "do a build"})
|
||||
if err == nil || !strings.Contains(err.Error(), "Invalid autonomy level") {
|
||||
t.Fatalf("expected error that --auto has invalid autonomy level, got: %v", err)
|
||||
}
|
||||
|
||||
_, _, err = parseExecArgs([]string{"--notify", "do a build"})
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid --notify") {
|
||||
t.Fatalf("expected error that --notify is invalid, got: %v", err)
|
||||
}
|
||||
|
||||
_, _, err = parseExecArgs([]string{"--max-turns", "do a build"})
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid --max-turns") {
|
||||
t.Fatalf("expected error that --max-turns is invalid, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -149,7 +150,7 @@ func TestRunExecListsMCPToolsWithoutProviderConstruction(t *testing.T) {
|
||||
providerBuilt = true
|
||||
return nil, errors.New("provider should not be constructed for --list-tools")
|
||||
},
|
||||
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
||||
if workspaceRoot != cwd {
|
||||
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
|
||||
}
|
||||
@@ -192,7 +193,7 @@ func TestRunExecLogsMCPRuntimeCloseError(t *testing.T) {
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
||||
if workspaceRoot != cwd {
|
||||
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
|
||||
}
|
||||
@@ -529,6 +530,45 @@ 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{
|
||||
@@ -673,6 +713,48 @@ 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()
|
||||
@@ -751,6 +833,24 @@ 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 {
|
||||
@@ -775,15 +875,41 @@ 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()
|
||||
for _, event := range events {
|
||||
_, 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 {
|
||||
if event["type"] == eventType {
|
||||
return event
|
||||
return idx, event
|
||||
}
|
||||
}
|
||||
t.Fatalf("event %q not found in %#v", eventType, events)
|
||||
return nil
|
||||
return -1, nil
|
||||
}
|
||||
|
||||
func findSessionEvent(t *testing.T, events []sessions.Event, eventType sessions.EventType) sessions.Event {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -21,11 +21,19 @@ import (
|
||||
)
|
||||
|
||||
type execSpecDraftRun struct {
|
||||
options execOptions
|
||||
stdout io.Writer
|
||||
stderr io.Writer
|
||||
deps appDeps
|
||||
workspaceRoot string
|
||||
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
|
||||
registry *tools.Registry
|
||||
modelRegistry modelregistry.Registry
|
||||
resolved config.ResolvedConfig
|
||||
@@ -96,6 +104,13 @@ 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),
|
||||
@@ -112,7 +127,7 @@ func runExecSpecDraft(run execSpecDraftRun) int {
|
||||
Autonomy: "low",
|
||||
Sandbox: run.sandboxEngine,
|
||||
FileTracker: tools.NewFileTracker(),
|
||||
Hooks: newHookDispatcher(run.workspaceRoot),
|
||||
Hooks: hookDispatcher,
|
||||
EnabledTools: run.options.enabledTools,
|
||||
DisabledTools: run.options.disabledTools,
|
||||
OnText: writer.text,
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestRunExecUseSpecCreatesDraftSession(t *testing.T) {
|
||||
MaxTurns: 3,
|
||||
}, nil
|
||||
},
|
||||
resolveMCPConfig: func(string) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{}, nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
|
||||
@@ -345,8 +345,8 @@ func TestRunExecModeSeedsModelAndTurnOverrides(t *testing.T) {
|
||||
if gotModel != "claude-opus-4.1" {
|
||||
t.Fatalf("overrides.Provider.Model = %q, want claude-opus-4.1", gotModel)
|
||||
}
|
||||
if gotMaxTurns != 50 {
|
||||
t.Fatalf("overrides.MaxTurns = %d, want 50", gotMaxTurns)
|
||||
if gotMaxTurns != 160 {
|
||||
t.Fatalf("overrides.MaxTurns = %d, want 160 (deep mode)", gotMaxTurns)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,16 @@ 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{
|
||||
|
||||
@@ -205,7 +205,9 @@ 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)
|
||||
}
|
||||
cfg, err := deps.resolveMCPConfig(cwd)
|
||||
// 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)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
@@ -286,11 +288,21 @@ 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()
|
||||
mcpRuntime, err := registerMCPToolsForWorkspace(ctx, cwd, registry, deps, mcp.AutonomyLow)
|
||||
// `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)
|
||||
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 {
|
||||
|
||||
@@ -204,7 +204,7 @@ func TestRunMCPToolsListJSONAndText(t *testing.T) {
|
||||
closeCalls := 0
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (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) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
||||
if workspaceRoot != cwd {
|
||||
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,63 @@
|
||||
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.
|
||||
func newHookDispatcher(workspaceRoot string) *hooks.Dispatcher {
|
||||
return newHookDispatcherWithExtra(workspaceRoot, nil)
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
|
||||
// newHookDispatcherWithExtra builds the dispatcher like newHookDispatcher but also
|
||||
@@ -20,10 +66,22 @@ func newHookDispatcher(workspaceRoot string) *hooks.Dispatcher {
|
||||
// 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.
|
||||
func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition) *hooks.Dispatcher {
|
||||
loaded, err := hooks.LoadConfig(hooks.LoadOptions{Cwd: workspaceRoot})
|
||||
//
|
||||
// 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})
|
||||
if err != nil {
|
||||
return nil
|
||||
return nil, skip
|
||||
}
|
||||
var audit *hooks.AuditStore
|
||||
if store, err := hooks.NewAuditStore(hooks.AuditStoreOptions{}); err == nil {
|
||||
@@ -52,5 +110,42 @@ 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.")
|
||||
}
|
||||
@@ -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) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (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) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (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) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"docs": {Type: "stdio", Command: "docs-mcp"},
|
||||
}}, nil
|
||||
|
||||
@@ -29,6 +29,27 @@ 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 {
|
||||
@@ -243,12 +264,25 @@ 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)
|
||||
}
|
||||
cfg, err := deps.resolveMCPConfig(cwd)
|
||||
// `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)
|
||||
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 {
|
||||
|
||||
+31
-13
@@ -55,8 +55,12 @@ func runMCPOAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps ap
|
||||
}
|
||||
serverName := positional[0]
|
||||
|
||||
server, err := resolveOAuthServer(deps, serverName)
|
||||
server, skip, 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)
|
||||
}
|
||||
|
||||
@@ -82,7 +86,7 @@ func runMCPOAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps ap
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
if err := store.Save(serverName, token); err != nil {
|
||||
if err := store.SaveForServer(server, token); err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
|
||||
@@ -112,7 +116,7 @@ func runMCPOAuthLogout(args []string, stdout io.Writer, stderr io.Writer, deps a
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
removed, err := store.Delete(serverName)
|
||||
removed, err := store.DeleteForServerName(serverName)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
@@ -193,33 +197,47 @@ 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.
|
||||
func resolveOAuthServer(deps appDeps, serverName string) (mcp.Server, error) {
|
||||
// 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) {
|
||||
if err := mcp.ValidateServerName(serverName); err != nil {
|
||||
return mcp.Server{}, err
|
||||
return mcp.Server{}, trustSkip{}, err
|
||||
}
|
||||
cwd, err := deps.getwd()
|
||||
if err != nil {
|
||||
return mcp.Server{}, fmt.Errorf("failed to resolve workspace: %w", err)
|
||||
return mcp.Server{}, trustSkip{}, fmt.Errorf("failed to resolve workspace: %w", err)
|
||||
}
|
||||
cfg, err := deps.resolveMCPConfig(cwd)
|
||||
excludeProject, trustCheckErrored := resolveTrust(cwd)
|
||||
skip := trustSkip{
|
||||
excludedProjectConfig: excludeProject && projectMCPConfigExists(cwd),
|
||||
trustCheckErrored: trustCheckErrored,
|
||||
}
|
||||
cfg, err := deps.resolveMCPConfig(cwd, excludeProject)
|
||||
if err != nil {
|
||||
return mcp.Server{}, err
|
||||
return mcp.Server{}, skip, err
|
||||
}
|
||||
servers, err := mcp.NormalizeConfig(cfg)
|
||||
if err != nil {
|
||||
return mcp.Server{}, err
|
||||
return mcp.Server{}, skip, err
|
||||
}
|
||||
for _, server := range servers {
|
||||
if server.Name != serverName {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(server.Auth, mcp.ServerAuthOAuth) {
|
||||
return mcp.Server{}, fmt.Errorf("MCP server %q does not declare auth: \"oauth\"", serverName)
|
||||
return mcp.Server{}, skip, fmt.Errorf("MCP server %q does not declare auth: \"oauth\"", serverName)
|
||||
}
|
||||
return server, nil
|
||||
return server, skip, nil
|
||||
}
|
||||
return mcp.Server{}, fmt.Errorf("MCP server %q is not configured", serverName)
|
||||
return mcp.Server{}, skip, fmt.Errorf("MCP server %q is not configured", serverName)
|
||||
}
|
||||
|
||||
func oauthConfigForServer(server mcp.Server) mcp.OAuthConfig {
|
||||
|
||||
+316
-17
@@ -6,14 +6,17 @@ 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
|
||||
@@ -85,6 +88,9 @@ func TestRunMCPOAuthLogout(t *testing.T) {
|
||||
if err := store.Save("remote", mcp.StoredToken{AccessToken: "a", RefreshToken: "r"}); err != nil {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
if err := store.SaveForServer(mcp.Server{Name: "remote", Identity: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, mcp.StoredToken{AccessToken: "identity", RefreshToken: "ir"}); err != nil {
|
||||
t.Fatalf("SaveForServer() error = %v", err)
|
||||
}
|
||||
deps := appDeps{newMCPTokenStore: func() (*mcp.TokenStore, error) { return store, nil }}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
@@ -98,6 +104,9 @@ func TestRunMCPOAuthLogout(t *testing.T) {
|
||||
if _, ok, _ := store.Load("remote"); ok {
|
||||
t.Fatal("token still present after logout")
|
||||
}
|
||||
if statuses, err := store.Status(); err != nil || len(statuses) != 0 {
|
||||
t.Fatalf("status after logout = %#v err=%v, want no tokens", statuses, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMCPOAuthLoginStoresTokens(t *testing.T) {
|
||||
@@ -119,23 +128,24 @@ func TestRunMCPOAuthLoginStoresTokens(t *testing.T) {
|
||||
}
|
||||
|
||||
cwd := t.TempDir()
|
||||
mcpConfig := config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"remote": {
|
||||
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"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
newMCPTokenStore: func() (*mcp.TokenStore, error) { return store, nil },
|
||||
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"remote": {
|
||||
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"},
|
||||
},
|
||||
},
|
||||
}}, nil
|
||||
resolveMCPConfig: func(_ string, _ bool) (config.MCPConfig, error) {
|
||||
return mcpConfig, nil
|
||||
},
|
||||
now: time.Now,
|
||||
}
|
||||
@@ -170,9 +180,13 @@ func TestRunMCPOAuthLoginStoresTokens(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exitCode = %d stderr=%s stdout=%s", exitCode, stderr.String(), stdout.String())
|
||||
}
|
||||
token, ok, err := store.Load("remote")
|
||||
servers, err := mcp.NormalizeConfig(mcpConfig)
|
||||
if err != nil || len(servers) != 1 {
|
||||
t.Fatalf("NormalizeConfig() servers=%#v err=%v", servers, err)
|
||||
}
|
||||
token, ok, err := store.LoadForServer(servers[0])
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Load() ok=%v err=%v", ok, err)
|
||||
t.Fatalf("LoadForServer() ok=%v err=%v", ok, err)
|
||||
}
|
||||
if token.AccessToken != "access-final" || token.RefreshToken != "refresh-final" {
|
||||
t.Fatalf("stored token = %#v", token)
|
||||
@@ -194,7 +208,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) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"plain": {Type: "http", URL: "https://plain.invalid/mcp"},
|
||||
}}, nil
|
||||
@@ -210,6 +224,291 @@ 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")
|
||||
}
|
||||
servers, err := mcp.NormalizeConfig(config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"remote": projectServer,
|
||||
}})
|
||||
if err != nil || len(servers) != 1 {
|
||||
t.Fatalf("NormalizeConfig() servers=%#v err=%v", servers, err)
|
||||
}
|
||||
token, ok, err := store.LoadForServer(servers[0])
|
||||
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{})
|
||||
|
||||
@@ -19,22 +19,38 @@ type mcpToolListItem struct {
|
||||
Permission string `json:"permission"`
|
||||
}
|
||||
|
||||
func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, registry *tools.Registry, deps appDeps, autonomy mcp.PermissionAutonomy) (mcpToolRuntime, error) {
|
||||
cfg, err := deps.resolveMCPConfig(workspaceRoot)
|
||||
// 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, skip, err
|
||||
}
|
||||
if len(cfg.Servers) == 0 {
|
||||
return noopMCPRuntime{}, nil
|
||||
return noopMCPRuntime{}, skip, nil
|
||||
}
|
||||
store, err := deps.newMCPStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, skip, err
|
||||
}
|
||||
return deps.registerMCPTools(ctx, registry, cfg, mcp.RegisterOptions{
|
||||
runtime, err := deps.registerMCPTools(ctx, registry, cfg, mcp.RegisterOptions{
|
||||
PermissionStore: store,
|
||||
Autonomy: autonomy,
|
||||
})
|
||||
return runtime, skip, err
|
||||
}
|
||||
|
||||
func execMCPAutonomy(options execOptions) mcp.PermissionAutonomy {
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,11 @@ 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"
|
||||
@@ -13,9 +17,13 @@ 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
|
||||
@@ -25,12 +33,26 @@ 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.
|
||||
func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDeps, stderr io.Writer) pluginActivation {
|
||||
loaded, err := deps.loadPlugins(plugins.LoadOptions{Cwd: workspaceRoot})
|
||||
// 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})
|
||||
if err != nil {
|
||||
writePluginActivationWarning(stderr, "failed to load plugins: "+err.Error())
|
||||
return pluginActivation{}
|
||||
return pluginActivation{trustSkip: skip}
|
||||
}
|
||||
|
||||
// Load fails OPEN per plugin: a malformed manifest is recorded as a diagnostic
|
||||
@@ -54,7 +76,39 @@ 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}
|
||||
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
|
||||
}
|
||||
|
||||
// formatLoadDiagnostic renders a plugin load diagnostic into a single warning
|
||||
|
||||
@@ -51,7 +51,8 @@ func TestActivatePluginsRegistersToolAndCollectsHooks(t *testing.T) {
|
||||
|
||||
registry := tools.NewRegistry()
|
||||
var stderr bytes.Buffer
|
||||
activation := activatePlugins(t.TempDir(), registry, fakePluginDeps(t, loaded), &stderr)
|
||||
workspace := t.TempDir()
|
||||
activation := activatePlugins(workspace, registry, fakePluginDeps(t, loaded), &stderr, workspace)
|
||||
|
||||
if _, ok := registry.Get("demo_lookup"); !ok {
|
||||
t.Fatalf("plugin tool not registered into the bootstrap registry")
|
||||
@@ -90,7 +91,8 @@ 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
|
||||
activation := activatePlugins(t.TempDir(), registry, fakePluginDeps(t, loaded), &stderr)
|
||||
workspace := t.TempDir()
|
||||
activation := activatePlugins(workspace, registry, fakePluginDeps(t, loaded), &stderr, workspace)
|
||||
|
||||
if len(activation.skillRoots) != 1 {
|
||||
t.Fatalf("expected one plugin skill root, got %#v", activation.skillRoots)
|
||||
@@ -124,7 +126,8 @@ func TestActivatePluginsSurfacesLoadDiagnostics(t *testing.T) {
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
var stderr bytes.Buffer
|
||||
activatePlugins(t.TempDir(), registry, deps, &stderr)
|
||||
workspace := t.TempDir()
|
||||
activatePlugins(workspace, registry, deps, &stderr, workspace)
|
||||
|
||||
if stderr.Len() == 0 {
|
||||
t.Fatal("a load diagnostic must be surfaced on stderr")
|
||||
@@ -143,7 +146,8 @@ func TestActivatePluginsFailsOpenOnLoadError(t *testing.T) {
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
var stderr bytes.Buffer
|
||||
activation := activatePlugins(t.TempDir(), registry, deps, &stderr)
|
||||
workspace := t.TempDir()
|
||||
activation := activatePlugins(workspace, registry, deps, &stderr, workspace)
|
||||
|
||||
if len(activation.hooks) != 0 || len(activation.skillRoots) != 0 {
|
||||
t.Fatalf("a load error must yield an inert activation, got %#v", activation)
|
||||
@@ -173,7 +177,8 @@ func TestActivatePluginsWarnsOnMalformedPluginButKeepsGood(t *testing.T) {
|
||||
|
||||
registry := tools.NewRegistry()
|
||||
var stderr bytes.Buffer
|
||||
activatePlugins(t.TempDir(), registry, fakePluginDeps(t, []plugins.LoadedPlugin{bad, good}), &stderr)
|
||||
workspace := t.TempDir()
|
||||
activatePlugins(workspace, registry, fakePluginDeps(t, []plugins.LoadedPlugin{bad, good}), &stderr, workspace)
|
||||
|
||||
if _, ok := registry.Get("good_tool"); !ok {
|
||||
t.Fatal("the good plugin tool must still register despite a malformed sibling")
|
||||
@@ -198,7 +203,7 @@ func TestNewHookDispatcherWithExtraFoldsPluginHooks(t *testing.T) {
|
||||
Enabled: true,
|
||||
}}
|
||||
|
||||
dispatcher := newHookDispatcherWithExtra(workspace, extra)
|
||||
dispatcher, _ := newHookDispatcherWithExtra(workspace, extra, workspace)
|
||||
if dispatcher == nil {
|
||||
t.Fatal("dispatcher should never be nil for a clean workspace")
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ 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"},
|
||||
}
|
||||
@@ -46,6 +47,21 @@ 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")
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providermodelcatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
)
|
||||
|
||||
type providerModelsOptions struct {
|
||||
name string
|
||||
json bool
|
||||
}
|
||||
|
||||
// runProvidersModels lists the models a saved provider actually serves by probing
|
||||
// its live model-discovery endpoint (e.g. an OpenAI-compatible `/v1/models`). It
|
||||
// works for custom OpenAI-/Anthropic-compatible providers too: discovery runs off
|
||||
// the profile's base URL + credentials, so a self-hosted endpoint serving a dozen
|
||||
// models no longer needs a config object per model — configure the provider once,
|
||||
// then run any listed model with `zero exec --model <id>` (Zero passes unknown
|
||||
// model ids through to the provider).
|
||||
func runProvidersModels(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseProviderModelsArgs(args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeProvidersHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
return exitCode
|
||||
}
|
||||
profile, err := selectProviderForCheck(resolved, options.name)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
|
||||
ctx, stop := signalContext()
|
||||
defer stop()
|
||||
models, err := deps.discoverProviderModels(ctx, discoveryCredentialProfile(profile))
|
||||
if err != nil {
|
||||
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 {
|
||||
entry := map[string]any{"id": model.ID}
|
||||
if description := strings.TrimSpace(model.Description); description != "" {
|
||||
entry["description"] = description
|
||||
}
|
||||
items = append(items, entry)
|
||||
}
|
||||
payload := map[string]any{
|
||||
"provider": profile.Name,
|
||||
"count": len(items),
|
||||
"models": items,
|
||||
}
|
||||
if err := writePrettyJSON(stdout, payload); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(profile.Name)
|
||||
if name == "" {
|
||||
name = "provider"
|
||||
}
|
||||
if _, err := fmt.Fprintf(stdout, "Provider models (%s)\n", name); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
for _, model := range models {
|
||||
line := strings.TrimSpace(model.ID)
|
||||
if description := strings.TrimSpace(model.Description); description != "" {
|
||||
line += " — " + description
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, line); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
suffix := "s"
|
||||
if len(models) == 1 {
|
||||
suffix = ""
|
||||
}
|
||||
if _, err := fmt.Fprintf(stdout, "%d model%s discovered\n", len(models), suffix); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if len(models) > 0 {
|
||||
if _, err := fmt.Fprintf(stdout, "next: zero exec %q --model %s\n", "hello", setupCommandArg(models[0].ID)); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// discoveryCredentialProfile resolves the profile's API key the same way the
|
||||
// runtime does — inline, then the stored credential, then the configured env var —
|
||||
// so a `providers models` probe authenticates exactly like a real request. Mirrors
|
||||
// discoveredModelContextWindow's credential resolution.
|
||||
func discoveryCredentialProfile(profile config.ProviderProfile) config.ProviderProfile {
|
||||
authed := profile
|
||||
if strings.TrimSpace(authed.APIKey) == "" {
|
||||
if store, err := config.ProviderKeyStore(); err == nil {
|
||||
authed = config.ApplyStoredAPIKey(authed, store)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(authed.APIKey) == "" && strings.TrimSpace(authed.APIKeyEnv) != "" {
|
||||
authed.APIKey = strings.TrimSpace(os.Getenv(authed.APIKeyEnv))
|
||||
}
|
||||
return authed
|
||||
}
|
||||
|
||||
// defaultDiscoverProviderModels is the production discovery hook: a live probe of
|
||||
// the provider's model-listing endpoint with no curated-catalog merge or
|
||||
// coding-model filtering, so a custom provider's full model list is returned.
|
||||
func defaultDiscoverProviderModels(ctx context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return providermodeldiscovery.Discover(ctx, profile, providermodeldiscovery.Options{})
|
||||
}
|
||||
|
||||
func parseProviderModelsArgs(args []string) (providerModelsOptions, bool, error) {
|
||||
options := providerModelsOptions{}
|
||||
for index := 0; index < len(args); index++ {
|
||||
arg := args[index]
|
||||
switch {
|
||||
case arg == "-h" || arg == "--help" || arg == "help":
|
||||
return options, true, nil
|
||||
case arg == "--json":
|
||||
options.json = true
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
|
||||
default:
|
||||
if options.name != "" {
|
||||
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
|
||||
}
|
||||
options.name = arg
|
||||
}
|
||||
}
|
||||
return options, false, nil
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
)
|
||||
|
||||
func TestRunProvidersModelsListsDiscoveredModels(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
deps := commandCenterDeps(t)
|
||||
var probed config.ProviderProfile
|
||||
deps.discoverProviderModels = func(_ context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
probed = profile
|
||||
return []providermodeldiscovery.Model{
|
||||
{ID: "team/model-a", Description: "first"},
|
||||
{ID: "team/model-b"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "models"}, &stdout, &stderr, deps)
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
// No name given → the active provider is probed, with its resolved credential.
|
||||
if probed.Name != "work" {
|
||||
t.Fatalf("probed provider = %q, want active provider work", probed.Name)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"Provider models (work)", "team/model-a — first", "team/model-b", "2 models discovered", "--model team/model-a"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersModelsJSON(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
deps := commandCenterDeps(t)
|
||||
deps.discoverProviderModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return []providermodeldiscovery.Model{{ID: "m1", Description: "d1"}, {ID: "m2"}}, nil
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "models", "--json"}, &stdout, &stderr, deps)
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Provider string `json:"provider"`
|
||||
Count int `json:"count"`
|
||||
Models []struct {
|
||||
ID string `json:"id"`
|
||||
Description string `json:"description"`
|
||||
} `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode json: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.Provider != "work" || payload.Count != 2 || len(payload.Models) != 2 {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
if payload.Models[0].ID != "m1" || payload.Models[0].Description != "d1" {
|
||||
t.Fatalf("model[0] = %#v", payload.Models[0])
|
||||
}
|
||||
// A model with no description omits the field rather than emitting an empty string.
|
||||
if payload.Models[1].ID != "m2" || payload.Models[1].Description != "" {
|
||||
t.Fatalf("model[1] = %#v", payload.Models[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersModelsSelectsNamedProvider(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
deps := commandCenterDeps(t)
|
||||
custom := config.ProviderProfile{
|
||||
Name: "self-hosted",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://models.internal/v1",
|
||||
APIKey: "sk-internal",
|
||||
Model: "house-model",
|
||||
}
|
||||
deps.resolveConfig = func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
work := config.ProviderProfile{Name: "work", ProviderKind: config.ProviderKindOpenAI, BaseURL: config.OpenAIBaseURL, APIKey: "sk", Model: "gpt-4.1"}
|
||||
return config.ResolvedConfig{ActiveProvider: "work", Provider: work, Providers: []config.ProviderProfile{work, custom}}, nil
|
||||
}
|
||||
var probed config.ProviderProfile
|
||||
deps.discoverProviderModels = func(_ context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
probed = profile
|
||||
return []providermodeldiscovery.Model{{ID: "house-model"}}, nil
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "models", "self-hosted"}, &stdout, &stderr, deps)
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
if probed.Name != "self-hosted" || probed.BaseURL != "https://models.internal/v1" {
|
||||
t.Fatalf("probed = %#v, want the named custom provider", probed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersModelsUnknownProviderFails(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
deps := commandCenterDeps(t)
|
||||
called := false
|
||||
deps.discoverProviderModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
called = true
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "models", "ghost"}, &stdout, &stderr, deps)
|
||||
|
||||
if exitCode == exitSuccess {
|
||||
t.Fatalf("exit = %d, want non-zero for unknown provider", exitCode)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("discovery must not run for an unknown provider")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), `provider "ghost" not found`) {
|
||||
t.Fatalf("stderr = %q, want provider-not-found", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
deps := commandCenterDeps(t)
|
||||
deps.discoverProviderModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return nil, errors.New("models endpoint returned 404")
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "models"}, &stdout, &stderr, deps)
|
||||
|
||||
if exitCode != exitProvider {
|
||||
t.Fatalf("exit = %d, want exitProvider %d", exitCode, exitProvider)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "models endpoint returned 404") {
|
||||
t.Fatalf("stderr = %q, want the discovery error", stderr.String())
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package cli
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
@@ -306,3 +307,143 @@ 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
|
||||
}
|
||||
@@ -234,3 +234,48 @@ 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")
|
||||
}
|
||||
}
|
||||
Loaded 100 of 463 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user