Compare commits

..
Author SHA1 Message Date
Gnanam c427052f9b chore: ignore zero-skills local build artifact
A locally built test binary under this name was accidentally committed
(and has been scrubbed from the branch history); ignore the name so it
cannot recur.
2026-07-05 20:49:24 +05:30
Gnanam fce9569362 fix(tui): review follow-ups — restore typed invocation on compaction guard, cap merged suggestions
- The compaction-in-flight guard for skill/user-command invocations fired
  after the dispatch path had already cleared the composer, so the typed
  "/name args" was lost and had to be retyped; thread the raw invocation
  through and restore it into the composer (the plain-prompt guard keeps
  the composer intact by firing before the clear). The picker path has no
  typed form and restores nothing.
- Cap the merged autocomplete list at the shared bound: each source
  self-caps, but three sources could stack to ~3x the palette limit.
- Tighten the shadowed-skill palette test to assert on the advertised
  Name rather than the fallback Desc wording.
2026-07-05 19:46:03 +05:30
Gnanam 3259ea3f6a feat(tui): ask-first guard for bare skill invocations
A skill invoked with no request submits only its body — instructions
with no target ("review the PR" — which PR?) — and the model improvises
a target instead of asking, which is especially wrong in a fresh
session. Bare invocations (slash and picker) now append a conditional
note: if the instructions need a target or details not already clear
from the conversation, ask first; self-contained skills still just run.
Invocations that carry a request are unchanged.
2026-07-05 19:26:10 +05:30
Gnanam f6e298ad20 feat(tui): /skills opens a searchable skill picker
Replace the static /skills card with the same picker UX /model uses:
type-to-filter (ranked search), arrow-key selection, Enter runs the
chosen skill immediately. Selection is by the skill's exact name, so
skills the slash path cannot reach — names shadowed by a builtin or
user command, or names that don't fit a slash token — are still
runnable from the picker, where the choice is unambiguous. The text
card remains only as the no-skills install hint, and the picker path
applies the same run-state guards as slash invocation.
2026-07-05 19:26:10 +05:30
Gnanam 15ddc79e75 feat(skills): make skill invocation deterministic and discoverable
Skills were model-pulled only: the model had to notice a skill in a
640-byte system-prompt list (~6 skills visible; the rest collapsed into
'…and N more') and call the skill tool on its own. Users could not run a
skill at all — typing /<skill-name> reported 'unknown command'. The two
halves of the fix:

User invocation
- /<skill-name> [args] now runs an installed skill directly: resolved
  after builtins and user commands, the skill body is inlined as the
  prompt with the typed args appended, and submitted through the same
  launch path user commands use.
- /skills lists installed skills with their slash form, live from disk.
  Labels are honest: a skill shadowed by a builtin, alias, user command,
  or a case-colliding sibling is listed bare with a '(via skill tool)'
  note instead of advertising a dead /name.
- The slash palette suggests skills (labeled '(skill)') read through the
  same loader dispatch uses, so mid-session installs appear and shadowed
  or case-colliding names are never advertised.
- Skill and user-command invocations now apply the plain-prompt run-state
  guards: mid-run submissions queue the EXPANDED prompt (the queue flush
  submits literal text), compaction-in-flight warns, exiting drops.
- Skills resolve through a lazy plugin-aware loader (default dir merged
  with plugin skill roots — the same set the skill tool and the system
  prompt see), TTL-cached so palette keystrokes don't re-read SKILL.md
  bodies.

Model triggering
- Raise the skills-list budget 640 -> 4096 bytes: the list is the model's
  only discovery surface, and the old cap made skills past ~6 invisible —
  invocation reliability collapsed exactly for users invested in skills.
- Raise description truncation 100 -> 200 runes: the description carries
  the trigger conditions ('use when…'); cutting them is what stopped the
  model from firing the skill.
- Strengthen the instruction line: scan the list before acting, call the
  matching skill FIRST, don't substitute your own approach.
2026-07-05 19:26:10 +05:30
370 changed files with 1715 additions and 35542 deletions

No files matched your search

+3 -14
View File
@@ -100,10 +100,9 @@ jobs:
go-version-file: go.mod
cache: true
# govulncheck, deadcode, and golangci-lint each resolve a toolchain from
# their own modules, which can drop below the version go.mod requires and
# then fail to load our packages. Pin GOTOOLCHAIN to the go.mod toolchain
# so all three run under it.
# govulncheck and deadcode resolve a toolchain from their own modules, which
# can drop below the version go.mod requires and then fail to load our
# packages. Pin GOTOOLCHAIN to the go.mod toolchain so both run under it.
- name: Pin toolchain from go.mod
run: |
toolchain="$(awk '/^toolchain /{print $2}' go.mod)"
@@ -121,13 +120,3 @@ jobs:
- name: deadcode (advisory)
continue-on-error: true
run: go run golang.org/x/tools/cmd/[email protected] -test=false ./...
# Advisory: catches what deadcode's whole-program reachability analysis
# doesn't, unused private functions/assignments reachable within a
# package but never actually called, plus staticcheck-style correctness
# and readability issues. Scoped to a few linters rather than the full
# default battery, and non-blocking, while the existing findings across
# the repo are cleaned up incrementally (see #527).
- name: golangci-lint (advisory)
continue-on-error: true
run: go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...
+11 -69
View File
@@ -97,21 +97,16 @@ 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 @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.
# 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.
#
# 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
@@ -161,11 +156,10 @@ jobs:
console.log(`npm ${version} supports trusted publishing.`);
'
# 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.
# 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.
- name: Verify release assets are downloadable
shell: bash
run: |
@@ -179,64 +173,12 @@ 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 — the wrapper's fallback downloader and install.sh would fail. Is the repo public and are all assets uploaded?" >&2
echo "::error::${url} is not publicly downloadable — npm postinstall 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: 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
- name: Publish to npm
run: npm publish --access public
-8
View File
@@ -27,10 +27,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
.cache
*.tsbuildinfo
# Go build/module caches (GOCACHE/GOMODCACHE pointed here for local runs)
.gocache/
.gomodcache/
# IntelliJ based IDEs
.idea
@@ -41,10 +37,7 @@ 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
@@ -54,4 +47,3 @@ docs/superpowers/
# Termux/Android build artifacts
zero-termux-arm64
zero-linux-sandbox
zero-seccomp
+1 -1
View File
@@ -1 +1 @@
{".":"0.3.0"}
{".":"0.1.0"}
+310 -11
View File
@@ -1,27 +1,326 @@
---
description: Project guidelines for the open-source CLI coding agent (zero) repository.
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
---
# Repository Conventions for Zero
# Extending 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).
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.
## 1. Project Conventions
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.
3. Wire Zero into the rest of your toolchain (MCP, skills, hooks, plugins).
## 1. Drop a project `AGENTS.md`
When Zero starts in a directory, it looks for project-level instructions and injects them into the system prompt. The lookup walks from your current working directory **up to the nearest git root** and reads the first matching file at each level — general rules at the repo root, more specific rules in sub-trees. Files are labeled with their directory in the prompt (e.g. `## Project guidelines (services/api/AGENTS.md)`).
Accepted file names, in priority order at each level:
| Path | Notes |
| --- | --- |
| `./AGENTS.md` | The classic spot — committed to your repo, shared with the team. |
| `./ZERO.md` | Brand-specific alias. Same format, lower priority. |
| `./.zero/AGENTS.md` | Project-local, hidden, gitignored. Personal notes that stay out of git. |
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.md` — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
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.
```markdown
# Project conventions for <your project>
- 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
Tips:
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:
- Keep each file under ~8 KiB. Zero caps the **total** across all matched files at 32 KiB; everything past the cap is dropped.
- Re-state rules in the imperative voice: "Run `make lint`", not "you should consider running the linter".
- Don't put secrets, model IDs, or environment-specific paths in `AGENTS.md`. Use `config.json` for those.
- 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.
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] ./...`.
### Personal guidelines, across every project
If any check fails or cannot be run, do not ignore it. Prompt the user for instructions or setup assistance before proceeding.
For preferences that follow *you*, not a specific repo (tone, tooling habits, workflow), drop a `ZERO.md` in your user config directory: `~/.config/zero/ZERO.md` on Linux/macOS, `%AppData%\Roaming\zero\ZERO.md` on Windows — the same directory as `config.json` and your personal specialists. Same format and 8 KiB cap as the project files above, and the same case-insensitive basename match.
This file is injected as its own `## User guidelines` section, before the project's `AGENTS.md`/`ZERO.md`, and is labeled as personal preference in the prompt: project guidelines are the later, more specific instruction and take precedence over it when the two conflict.
## 2. Custom specialists
Specialists are Zero's sub-agents. Three scopes, in priority order:
| Scope | Path | Shared? |
| --- | --- | --- |
| Built-in | compiled into Zero | yes — `worker`, `explorer`, `code-review` |
| User | `~/.config/zero/specialists/*.md` | no — your machine only |
| Project | `./.zero/specialists/*.md` | yes — the repo team |
Project overrides user overrides built-in when names collide.
A specialist is a markdown manifest with frontmatter and a system prompt:
```markdown
---
description: Reviews API changes for breaking-change risk and missing tests.
tools: read-only,plan
---
You review API changes. For every changed hunk in `internal/api/` or any file
that ends in `_api.go`:
1. Confirm the public signature is backward-compatible, or note the breaking
change explicitly with the migration path.
2. Confirm a corresponding test exists in `internal/api/*_test.go` and that
the new behaviour is exercised.
3. Flag any new exported symbol without a doc comment.
Reply with one JSON object per finding: `{"file", "line", "severity", "message", "fix"}`.
```
CLI management (the prompt is passed inline via `--prompt`):
```bash
zero specialist list
zero specialist show api-reviewer
zero specialist create api-reviewer \
--project \
--description "Reviews API changes" \
--tools read-only,plan \
--prompt "$(cat api-reviewer.md)"
zero specialist edit api-reviewer --project
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).
> **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).
Discovery root: `$ZERO_SKILLS_DIR` → `$XDG_DATA_HOME/zero/skills` → `~/.local/share/zero/skills/`. A missing directory is fine — Zero just reports "no skills".
```
~/.local/share/zero/skills/
run-benchmarks/
SKILL.md
write-changelog/
SKILL.md
```
`SKILL.md` format:
```markdown
---
description: Run the project's benchmark suite and summarize the deltas.
---
# Run benchmarks
1. `make bench` — captures the wall-clock and RSS before and after.
2. `benchstat before.txt after.txt` — diffs the two.
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.
The `skill` core tool lets the agent load any discovered skill by name.
## 4. Hooks
Hooks fire shell commands on lifecycle events. Configure them in JSON:
- User: `~/.config/zero/hooks.json`
- Project: `./.zero/hooks.json`
```json
{
"enabled": true,
"hooks": [
{
"id": "block-rm-rf",
"event": "beforeTool",
"matcher": "bash",
"command": "/usr/local/bin/zero-hook-block-rmrf.sh",
"enabled": true
},
{
"id": "log-session",
"event": "sessionStart",
"command": "/usr/local/bin/zero-hook-log.sh",
"enabled": true
}
]
}
```
The `args` array (when present) is passed verbatim to `exec.CommandContext`. The actual hook payload — event name, matcher, tool call id, tool name, tool input, tool output, status — is delivered to the command as **JSON on stdin**, not via `${...}` substitution. A typical handler reads stdin and decides what to do:
```bash
#!/usr/bin/env bash
# /usr/local/bin/zero-hook-block-rmrf.sh
set -euo pipefail
payload="$(cat)"
if printf '%s' "$payload" | grep -q '"input":"[^"]*rm[[:space:]]+-rf'; then
echo "refusing rm -rf" >&2
exit 1
fi
```
Events the agent emits (in dispatch order):
| Event | Fires when | Matcher allowed? |
| --- | --- | --- |
| `beforeTool` | A tool is about to run | yes (tool name) |
| `afterTool` | A tool just returned | yes (tool name) |
| `sessionStart` | A session begins | no |
| `sessionEnd` | A session ends | no |
| `specialistStart` | A sub-agent is spawned | yes (specialist name) |
| `specialistStop` | A sub-agent ends | yes (specialist name) |
A hook's exit code decides what happens next: `0` continues, non-zero blocks the tool call (`beforeTool`) or surfaces an error (`afterTool`). Hook execution is recorded in the audit log; the audit is reachable from the agent's view of past actions, not from a dedicated `zero doctor` check.
> **Roadmap.** An in-UI hooks manager is on the backlog. Today you edit the JSON directly.
## 5. MCP — Model Context Protocol
Zero is both an **MCP client** (it can call external MCP servers) and an **MCP server** (other agents can call its tools).
### As a client — configure MCP servers in `config.json`
```json
{
"mcp": {
"servers": {
"docs": {
"type": "stdio",
"command": "docs-mcp",
"args": ["--port", "7777"]
},
"github": {
"type": "http",
"url": "https://api.example.com/mcp",
"headers": { "Authorization": "Bearer YOUR_TOKEN_HERE" }
}
}
}
}
```
Manage via CLI:
```bash
zero mcp add docs --type stdio -- docs-mcp --port 7777
zero mcp add github --type http --url https://api.example.com/mcp \
--header "Authorization=Bearer YOUR_TOKEN_HERE"
zero mcp list
zero mcp check docs
zero mcp remove github
zero mcp oauth login github
```
Servers are merged from user and project configs (project wins on conflicts). Token-bearing values in `config.json` are sent verbatim — there is no `${env:...}` expansion — so prefer one of:
- A wrapper script that sources the secret and execs the real command.
- 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).
### As a server — expose Zero's tools to another agent
```bash
zero serve --mcp
```
The server speaks MCP over stdio. Configure it from the receiving side as a `stdio` server whose command is `zero serve --mcp`.
## 6. Plugins
A plugin is a self-contained directory that bundles tools, hooks, and skills for one capability. Plugins live at:
- User: `~/.config/zero/plugins/<id>/`
- Project: `./.zero/plugins/<id>/`
Each plugin has a `plugin.json` manifest:
```json
{
"id": "github-pr-review",
"name": "GitHub PR Review",
"description": "Adds review tools for GitHub PRs.",
"version": "1.0.0",
"tools": [
{ "name": "list_prs", "command": "./tools/list_prs.sh" }
],
"hooks": [
{ "name": "pre-merge-check", "event": "beforeTool", "command": "./hooks/pre-merge.sh" }
],
"skills": [
{ "path": "./skills/review-checklist/SKILL.md" }
]
}
```
Install and manage:
```bash
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
zero plugins list
zero plugins remove github-pr-review # alias: rm
```
A plugin is enabled by being present in the plugins directory and disabled by removing it (or by the user setting `"enabled": false` in its `plugin.json`). Plugins are not enabled or disabled by a CLI subcommand today.
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).
## 7. Configuration locations
Three layers, applied in order (later layers override earlier ones):
| Layer | Path | 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. |
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).
The sandbox `additionalWriteRoots` key is **ignored in project config** by design — a checked-out repo cannot widen its own sandbox. Set it in the user config or pass `--add-dir` per-invocation.
## 8. End-to-end example
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/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.
-105
View File
@@ -6,111 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
tagged. Until then, source builds report the version `dev`.
## [0.3.0](https://github.com/Gitlawb/zero/compare/v0.2.0...v0.3.0) (2026-07-09)
### Features
* gate project-scoped hooks, plugins, and MCP servers behind workspace trust ([#529](https://github.com/Gitlawb/zero/issues/529)) ([a880ce8](https://github.com/Gitlawb/zero/commit/a880ce80a6ec72da511fb9bdf6dd69291c72a64b))
* **modelregistry:** infer reasoning efforts for Hunyuan and vendor-prefixed model ids ([#599](https://github.com/Gitlawb/zero/issues/599)) ([92a92ce](https://github.com/Gitlawb/zero/commit/92a92ceb29dc63f5216ab140ef9a6dd9afe17df8))
* **providers:** OAuth login profiles and list-first /provider manager ([#560](https://github.com/Gitlawb/zero/issues/560)) ([1655056](https://github.com/Gitlawb/zero/commit/16550569c1be615cfaf244dab25909ec37f6dee6))
* **tui:** remember recent provider+model selections in /model picker ([#568](https://github.com/Gitlawb/zero/issues/568)) ([d0c4e62](https://github.com/Gitlawb/zero/commit/d0c4e62cd429a0614c15296934c740a08bc0e07b))
* **tui:** show CLI version on the startup home screen ([#538](https://github.com/Gitlawb/zero/issues/538)) ([fd69233](https://github.com/Gitlawb/zero/commit/fd69233e334f1823a06b5794085a9255b3abdfa8))
* voice dictation (speech-to-text) ([#557](https://github.com/Gitlawb/zero/issues/557)) ([87158a1](https://github.com/Gitlawb/zero/commit/87158a1c90b4f91fc5f2bb8178ebaf46d7654680))
### Bug Fixes
* address bugs found in a multi-agent codebase audit ([#481](https://github.com/Gitlawb/zero/issues/481)) ([008bc9b](https://github.com/Gitlawb/zero/commit/008bc9b3f3ba13c7d4822b9559b020f381ff555b))
* **agent:** keep tools exposed for max-turn finalization ([#533](https://github.com/Gitlawb/zero/issues/533)) ([3f0503b](https://github.com/Gitlawb/zero/commit/3f0503bc2312ae29d5ade784d8824dc9a3524958))
* **auth:** persist OpenRouter API key after login ([#595](https://github.com/Gitlawb/zero/issues/595)) ([2a062aa](https://github.com/Gitlawb/zero/commit/2a062aa4014c6cd5e20a57dad4a685f88966f109))
* bump Go to 1.26.5 for crypto/tls fix (GO-2026-5856) ([#607](https://github.com/Gitlawb/zero/issues/607)) ([a7cfb99](https://github.com/Gitlawb/zero/commit/a7cfb99fed7b88ebc09a2f251cb82864d3c2cade))
* gitignore Windows sandbox helpers and npm version marker ([#578](https://github.com/Gitlawb/zero/issues/578)) ([25653f6](https://github.com/Gitlawb/zero/commit/25653f686c016f95b81ceb7ff5d5452d37c4d4f3))
* **mcp:** silence startup warning for unconfigured default servers ([#563](https://github.com/Gitlawb/zero/issues/563)) ([302f58b](https://github.com/Gitlawb/zero/commit/302f58bb5f2a03ec7230354ed4747e4e55c16c50))
* **mcp:** skip RFC 8414 discovery when OAuth endpoints are preconfigured ([#586](https://github.com/Gitlawb/zero/issues/586)) ([8a52d98](https://github.com/Gitlawb/zero/commit/8a52d98cad7cd0086dee9aede4ce477e432bd385))
* **modelregistry:** reject oversized models.dev cache responses ([#602](https://github.com/Gitlawb/zero/issues/602)) ([66a6396](https://github.com/Gitlawb/zero/commit/66a63964149fb2f07e646e5f1987627c5cd9ac28))
* **provider:** stop dropping custom no-auth providers on restart ([#558](https://github.com/Gitlawb/zero/issues/558)) ([ba99fa8](https://github.com/Gitlawb/zero/commit/ba99fa8d487fb28f2700e0ff10b2a25c75303cf7))
* **sandbox:** Windows-appropriate suggestions when blocking interactive commands ([#414](https://github.com/Gitlawb/zero/issues/414)) ([ba4c007](https://github.com/Gitlawb/zero/commit/ba4c00755dc1c31b3dcca18e50b20f62c6bf5d1f))
* **tools:** block MSYS and WSL shells under the Windows sandbox ([#587](https://github.com/Gitlawb/zero/issues/587)) ([0666818](https://github.com/Gitlawb/zero/commit/066681855b80e3baf5d07d7397610b25f724e353))
* **tools:** platform-specific pager suggestions, quote/caret-safe cd detection ([#543](https://github.com/Gitlawb/zero/issues/543)) ([8b248f4](https://github.com/Gitlawb/zero/commit/8b248f4e1198dc86ab332a697fba4cf520823cbd))
* **tools:** Windows cmd.exe quoting guidance and clipboard escaping fix ([#468](https://github.com/Gitlawb/zero/issues/468)) ([f10ed0c](https://github.com/Gitlawb/zero/commit/f10ed0c893ce6de08923f143d681ba96f0fcfe3a))
* **tui:** bypass toggleSidebar and toggleMouse global shortcuts when composer is non-empty ([#576](https://github.com/Gitlawb/zero/issues/576)) ([c7346fb](https://github.com/Gitlawb/zero/commit/c7346fbcf01fe7e70ed2fcfccbf9965e5985727b))
* **tui:** paste protection for Termux char-by-char paste ([#573](https://github.com/Gitlawb/zero/issues/573)) ([8e9149f](https://github.com/Gitlawb/zero/commit/8e9149f1a296bebebf95cae9dd2a7c5156c9dbb6))
* **tui:** update picker_test for switchProviderModel 4-value return ([#589](https://github.com/Gitlawb/zero/issues/589)) ([8f15650](https://github.com/Gitlawb/zero/commit/8f156506dc92449bd24caa14e36f28477ba00fff))
* **update:** clearer error on unsupported release platform (android/termux) ([#603](https://github.com/Gitlawb/zero/issues/603)) ([1fc9b2d](https://github.com/Gitlawb/zero/commit/1fc9b2d25c79e089f34cb7b5b6a7f7c7b8233123))
* **update:** support safe symlink extraction during updates ([#575](https://github.com/Gitlawb/zero/issues/575)) ([ce9cb91](https://github.com/Gitlawb/zero/commit/ce9cb912958a3d9ae0b052bfebb849c28e0e719b))
* warn about untracked scratch files left behind after a run ([#571](https://github.com/Gitlawb/zero/issues/571)) ([062328b](https://github.com/Gitlawb/zero/commit/062328b632a2d27353a6d47c522bbd22d7282539))
### Performance Improvements
* **grep:** stop content scan after head limit ([#601](https://github.com/Gitlawb/zero/issues/601)) ([8a05e64](https://github.com/Gitlawb/zero/commit/8a05e6486f7a63281b869064db03dfc5531e6a04))
## [0.2.0](https://github.com/Gitlawb/zero/compare/v0.1.0...v0.2.0) (2026-07-06)
### Features
* add --auto flag for LLM-generated commit messages ([#423](https://github.com/Gitlawb/zero/issues/423)) ([b0abde7](https://github.com/Gitlawb/zero/commit/b0abde7d0697e808480cd59d69a6f4d0c6320475))
* add zero changes push and pr subcommands, and extra repo-info metrics ([#391](https://github.com/Gitlawb/zero/issues/391)) ([2312abe](https://github.com/Gitlawb/zero/commit/2312abe5ddd95f4c6ef373cfb61cc03092f48cdd))
* agent quality, caching, retry, and tooling upgrades ([#506](https://github.com/Gitlawb/zero/issues/506)) ([3c81fea](https://github.com/Gitlawb/zero/commit/3c81fea22873ee3df7fc97b10cb4f77792706c4b))
* **agent:** curb over-engineering the solution in the editing discipline ([#517](https://github.com/Gitlawb/zero/issues/517)) ([f4c998a](https://github.com/Gitlawb/zero/commit/f4c998ac30c4f07ff313a2d706791e857293be49)), closes [#516](https://github.com/Gitlawb/zero/issues/516)
* **agent:** inject per-user config.UserConfigDir()/zero/ZERO.md guidelines into system prompt ([#475](https://github.com/Gitlawb/zero/issues/475)) ([7b10aab](https://github.com/Gitlawb/zero/commit/7b10aab74bf14a01166b2cea22deab79bba9850b))
* **openai:** forward prompt_cache_key for server-side prefix cache routing ([#515](https://github.com/Gitlawb/zero/issues/515)) ([87e7e69](https://github.com/Gitlawb/zero/commit/87e7e69afd18b5539579856f3a61c6a95bc445ae))
* **providers:** add `zero providers models` to discover a provider's models ([#386](https://github.com/Gitlawb/zero/issues/386)) ([0bc8074](https://github.com/Gitlawb/zero/commit/0bc8074c97b0310e4a9d70c3f967003ee5e8a59f))
* **providers:** add KiloCode and OpenCode provider support ([#388](https://github.com/Gitlawb/zero/issues/388)) ([b1ccb6d](https://github.com/Gitlawb/zero/commit/b1ccb6d9c1875377f5e5ea81a1304edd1e41ab4f))
* **providers:** add Meituan LongCat catalog preset ([#424](https://github.com/Gitlawb/zero/issues/424)) ([b4275e3](https://github.com/Gitlawb/zero/commit/b4275e350472b2490212bf814709819d354c1216))
* **providers:** split minimax zai into intl cn ([#398](https://github.com/Gitlawb/zero/issues/398)) ([aaad4d2](https://github.com/Gitlawb/zero/commit/aaad4d271270f41af837b6f3b60ae80beba0c645))
* require manual approval before npm publish + drop release-as pin ([#369](https://github.com/Gitlawb/zero/issues/369)) ([bd89a1f](https://github.com/Gitlawb/zero/commit/bd89a1f451643c1b65ec803070abc7b116631ebe))
* **sandbox:** unelevated Windows fallback tier instead of prompts-only degrade ([#427](https://github.com/Gitlawb/zero/issues/427)) ([b9ddd6f](https://github.com/Gitlawb/zero/commit/b9ddd6f42138312a1fee8d8bb67c46c8eb1dea2f))
* support shift enter for composer newlines ([#462](https://github.com/Gitlawb/zero/issues/462)) ([daf65e0](https://github.com/Gitlawb/zero/commit/daf65e0af9a040314d4ab337b0ad59c55416b7bc))
* **tui:** /loop — repeat a prompt or command on an interval or self-paced ([#502](https://github.com/Gitlawb/zero/issues/502)) ([387fe67](https://github.com/Gitlawb/zero/commit/387fe67ee7cd81317c9c969f5906a4437080fea3))
* **tui:** add search/filter to provider picker in setup wizard ([#400](https://github.com/Gitlawb/zero/issues/400)) ([2fcea71](https://github.com/Gitlawb/zero/commit/2fcea71778d23e050c93409c471aef45b68c1621))
* **update:** add zero upgrade command to apply self-updates ([#461](https://github.com/Gitlawb/zero/issues/461)) ([5f36349](https://github.com/Gitlawb/zero/commit/5f36349c1884e81fa9bc66bb5fe813b627e897b7))
### Bug Fixes
* **action:** keep provider key scoped to zero step ([#448](https://github.com/Gitlawb/zero/issues/448)) ([407a927](https://github.com/Gitlawb/zero/commit/407a92739ff508cba32d2c12b3f36f0efcdd54c3))
* add android platform support for Termux npm install ([#455](https://github.com/Gitlawb/zero/issues/455)) ([9bd93c6](https://github.com/Gitlawb/zero/commit/9bd93c62f8d57fb74057284aa66a1b6e1429dcdd)), closes [#449](https://github.com/Gitlawb/zero/issues/449)
* **agent:** reject a malformed additional_permissions payload before prompting ([#453](https://github.com/Gitlawb/zero/issues/453)) ([e4f760e](https://github.com/Gitlawb/zero/commit/e4f760ee8bd57299cd2fcb37e8e23130037c2607))
* allow non-TLS connections to private-network provider endpoints ([#444](https://github.com/Gitlawb/zero/issues/444)) ([1d86384](https://github.com/Gitlawb/zero/commit/1d8638466ca31517eb9db2b9353d3dce1cbeeabc))
* **auth:** route zero auth login chatgpt to the dedicated ChatGPT flow ([#443](https://github.com/Gitlawb/zero/issues/443)) ([305a62c](https://github.com/Gitlawb/zero/commit/305a62c954ca6cec00bc58d5398f933415156aff))
* **config:** fall back to a usable saved provider instead of forcing full re-onboarding ([#410](https://github.com/Gitlawb/zero/issues/410)) ([c60ad87](https://github.com/Gitlawb/zero/commit/c60ad8729f79bb841114d352ee2d2fe29d5d0e41))
* **config:** let a gateway ANTHROPIC_BASE_URL resolve as anthropic-compatible ([#497](https://github.com/Gitlawb/zero/issues/497)) ([30dd7c3](https://github.com/Gitlawb/zero/commit/30dd7c3112ad22d42fa12b5addd4e38f4beda42a)), closes [#479](https://github.com/Gitlawb/zero/issues/479)
* **config:** unbrick first-run setup — default google/anthropic models, enter setup on fixable config errors ([#385](https://github.com/Gitlawb/zero/issues/385)) ([72eed06](https://github.com/Gitlawb/zero/commit/72eed06b4f94c43d75d31fe54a58d2f566de059e))
* **config:** use ~/.config on macOS and enter setup when no provider ([#371](https://github.com/Gitlawb/zero/issues/371)) ([#372](https://github.com/Gitlawb/zero/issues/372)) ([027a8f2](https://github.com/Gitlawb/zero/commit/027a8f2768b17b89f5c8270887f156e2ccda69ea))
* **docs:** rename AGENTS.MD &gt; 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)
+2 -16
View File
@@ -1,7 +1,7 @@
# 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 baseline help
.PHONY: build build-all test test-race vet fmt fmt-check lint tidy clean help
# Build the main CLI binary into ./zero.
build:
@@ -40,19 +40,5 @@ clean:
rm -f zero
go clean ./...
# Run the per-turn benchmark harness over the checked-in baseline manifest and
# write the JSON result to internal/perfbench/reports/baseline.json. Requires a
# built `zero` binary and a model; set ZERO_BENCH_MODEL (required) and
# ZERO_BENCH_BINARY (defaults to ./zero) to configure the run. The report is
# machine-specific and regenerated, not hand-edited.
baseline: build
@if [ -z "$(ZERO_BENCH_MODEL)" ]; then echo "Set ZERO_BENCH_MODEL (and optionally ZERO_BENCH_BINARY) before running 'make baseline'"; exit 2; fi
@ZERO_BIN="$${ZERO_BENCH_BINARY:-./zero}"; \
go run ./cmd/zero-perf-bench turn \
--suite internal/perfbench/manifests/baseline.json \
--model $(ZERO_BENCH_MODEL) \
--binary "$$ZERO_BIN" \
--output internal/perfbench/reports/baseline.json
help:
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, tidy, clean, baseline"
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, tidy, clean"
+31 -94
View File
@@ -6,7 +6,7 @@
<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="Go 1.25+" src="https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<br>
<strong>English</strong> | <a href="README_ZH.md">中文</a>
@@ -49,15 +49,34 @@ npm install -g @gitlawb/zero
zero
```
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.
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.
### Bun
Bun does not run dependency lifecycle scripts by default, so the `postinstall`
that fetches the Zero binary is skipped and the first run fails with
`No native binary found next to the npm wrapper`.
The simplest fix is to trust the package after installing, which runs the
blocked postinstall. This works for project and global installs:
```bash
# project install
bun add @gitlawb/zero
bun pm trust @gitlawb/zero
# global install
bun add -g @gitlawb/zero
bun pm -g trust @gitlawb/zero
```
Alternatives: allow the postinstall up front by adding
`"trustedDependencies": ["@gitlawb/zero"]` to your project's package.json
before `bun add`, or run the installer manually
(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`) on Bun versions
that do not have `bun pm trust`.
### Install scripts
@@ -75,7 +94,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
### From source
Source builds require Go 1.26.5+.
Source builds require Go 1.25+.
```bash
git clone https://github.com/Gitlawb/zero.git
@@ -130,8 +149,6 @@ 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:
@@ -140,33 +157,6 @@ To configure Meituan LongCat (LongCat-2.0) directly, run:
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
`zero providers detect`.
@@ -196,7 +186,6 @@ Common slash commands:
| `/spec`, `/plan` | draft and review a plan before building |
| `/image` | attach an image for vision-capable models |
| `/resume`, `/rewind` | continue or roll back local sessions |
| `/loop` | repeat a prompt or custom `/command` on an interval (`/loop 5m /babysit-prs`) or self-paced |
| `/compact`, `/context` | manage context usage |
| `/permissions`, `/tools` | inspect available tools and policy |
| `/add-dir` | allow an extra write directory for this session |
@@ -294,39 +283,6 @@ zero cron scheduled agent jobs
zero update check for newer releases
```
## Extending Zero
### Project and personal instructions
Zero appends project-specific guidance to the system prompt from the first
`AGENTS.md`, `ZERO.md`, or `.zero/AGENTS.md` file found in each directory from
the git root down to your current working directory (checked in that order
per directory). Files are injected general-to-specific, capped at 8 KiB per
file and 32 KiB total.
A personal `ZERO.md` under `config.UserConfigDir()/zero/ZERO.md`
(`$XDG_CONFIG_HOME/zero/ZERO.md` or `~/.config/zero/ZERO.md` on Linux/macOS,
`%AppData%\Roaming\zero\ZERO.md` on Windows) applies across every workspace, ahead of any project guidelines.
### Plugins
Plugins are discovered from `~/.config/zero/plugins/<name>/plugin.json` (user
scope — `$XDG_CONFIG_HOME` or `~/.config` on every OS, independent of the
`config.UserConfigDir()` path used above) and `<cwd>/.zero/plugins/<name>/plugin.json`
(project scope — resolved from the current working directory, not the repo
root), and managed with `zero plugins`. A manifest can declare:
- `tools` — custom tools (`command`, `args`, `inputSchema`, and a
`permission` of `prompt` or `deny`; `allow` is honored only when manifest tool
auto-approval is enabled)
- `hooks` — commands run on `beforeTool`, `afterTool`, `sessionStart`, or
`sessionEnd`
- `prompts` and `skills` — additional prompt/skill files
MCP servers (`zero mcp`) and standalone markdown skills (`zero skills`) use
the same extension points and can also be wired up outside of a plugin
manifest.
## Appearance And Accessibility
| Control | Effect |
@@ -349,26 +305,7 @@ go run ./cmd/zero-release smoke
go run ./cmd/zero-perf-bench
```
### 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
Cross-compile examples:
```bash
go run ./cmd/zero-release build --goos linux --goarch amd64
+21 -3
View File
@@ -6,7 +6,7 @@
<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="Go 1.25+" src="https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<br>
<a href="README.md">English</a> | <strong>中文</strong>
@@ -38,7 +38,25 @@ 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)。
npm 包安装一个小型包装器以及与你平台匹配的 Zero 二进制文件(从 GitHub Releases 获取)。支持 Linux、macOS 和 Windows 的 x64 和 arm64。
### Bun
Bun 默认不运行依赖的生命周期脚本,因此获取 Zero 二进制文件的 `postinstall` 会被跳过,首次运行会失败并显示 `No native binary found next to the npm wrapper`。
最简单的解决方法是安装后信任该包,这会运行被阻止的 postinstall。项目安装和全局安装均适用:
```bash
# 项目安装
bun add @gitlawb/zero
bun pm trust @gitlawb/zero
# 全局安装
bun add -g @gitlawb/zero
bun pm -g trust @gitlawb/zero
```
其他方式:在 `bun add` 之前将 `"trustedDependencies": ["@gitlawb/zero"]` 添加到项目的 package.json 以提前允许 postinstall;或在不支持 `bun pm trust` 的旧版 Bun 上手动运行安装程序(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`)。
### 安装脚本
@@ -56,7 +74,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
### 从源码构建
源码构建需要 Go 1.26.5+。
源码构建需要 Go 1.25+。
```bash
git clone https://github.com/Gitlawb/zero.git
+4 -4
View File
@@ -180,7 +180,6 @@ runs:
ZERO_INPUT_ADD_DIR: ${{ inputs.add-dir }}
ZERO_INPUT_WORKTREE: ${{ inputs.worktree }}
ZERO_INPUT_OUTPUT_FORMAT: ${{ inputs.output-format }}
ACTION_PATH: ${{ github.action_path }}
RUNNER_TEMP: ${{ runner.temp }}
run: |
set -euo pipefail
@@ -248,9 +247,10 @@ runs:
echo "exit-code=${code}" >> "${GITHUB_OUTPUT}"
echo "output-file=${output_file}" >> "${GITHUB_OUTPUT}"
# Best-effort one-line summary. Structured output ends with lifecycle
# metadata, so parse final/error events instead of tailing stdout.
summary="$(node "${ACTION_PATH}/scripts/action-summary.mjs" "${ZERO_INPUT_OUTPUT_FORMAT}" "${output_file}" || true)"
# Best-effort one-line summary: last non-empty line of the captured output.
summary="$(grep -v '^[[:space:]]*$' "${output_file}" | tail -n 1 || true)"
# Keep the summary single-line and bounded for the step output.
summary="$(printf '%s' "${summary}" | cut -c1-280)"
{
echo "summary<<ZERO_EOF"
printf '%s\n' "${summary}"
+22 -75
View File
@@ -2,7 +2,6 @@
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';
@@ -40,11 +39,6 @@ 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']) {
@@ -55,85 +49,38 @@ function localControlHelperManifest(packageRoot) {
return JSON.stringify({ version: 1, helpers });
}
// 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;
}
// 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 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)));
const nativePath = join(packageRoot, zeroBinaryName());
const localControlHelpers = localControlHelperManifest(packageRoot);
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) {
if (!existsSync(nativePath)) {
const postinstallScript = join(packageRoot, 'scripts', 'postinstall.mjs');
const ranByBun = process.execPath.includes('bun') || !!process.versions?.bun;
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' +
'[zero] No native binary found next to the npm wrapper.\n' +
'The platform binary is fetched at install time by a postinstall script,\n' +
'which did not run (or was skipped) for this install.\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` +
'Fix it now by running the installer manually:\n' +
` node "${postinstallScript}"\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+).',
(ranByBun
? 'You installed with Bun, which does not run dependency lifecycle scripts\n' +
'by default. Trust the package to run the blocked postinstall:\n' +
' bun pm trust @gitlawb/zero (project install)\n' +
' bun pm -g trust @gitlawb/zero (global install)\n' +
'On Bun versions without `bun pm trust`, add\n' +
' "trustedDependencies": ["@gitlawb/zero"]\n' +
'to your project package.json and reinstall.\n' +
'\n'
: '') +
'If that fails, build from source: https://github.com/Gitlawb/zero\n' +
'(go run ./cmd/zero, requires Go 1.25+).',
);
process.exit(1);
}
const env = { ...process.env };
const localControlHelpers = localControlHelperManifest(packageRoot);
if (localControlHelpers) {
env.ZERO_LOCAL_CONTROL_HELPERS = localControlHelpers;
} else {
-4
View File
@@ -39,9 +39,6 @@ func run(args []string, getenv func(string) string, stdout io.Writer, stderr io.
if len(args) > 0 && args[0] == "tasks" {
return runTasksCommand(args[1:], getenv, stdout, stderr)
}
if len(args) > 0 && args[0] == "turn" {
return runTurnCommand(args[1:], getenv, stdout, stderr)
}
options, err := parseArgs(args, getenv)
if err != nil {
_, _ = fmt.Fprintln(stderr, err.Error())
@@ -209,7 +206,6 @@ func helpText() string {
return strings.Join([]string{
"Usage: zero-perf-bench [options]",
" zero-perf-bench tasks [options] (Terminal-Bench-style task harness; see `tasks --help`)",
" zero-perf-bench turn [options] (per-turn tracing benchmark; see `turn --help`)",
"",
"Options:",
" --iterations <n> Measured samples to collect (default: 5)",
-238
View File
@@ -1,238 +0,0 @@
package main
import (
"bytes"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/Gitlawb/zero/internal/perfbench"
)
// turnOptions configures the `zero-perf-bench turn` subcommand: the per-turn
// benchmark harness that runs ZERO headlessly with --trace, parses each turn's
// NDJSON trace, and records per-span latency plus the top controllable latency
// sources — the Phase 0 baseline's "do not proceed until" criterion.
type turnOptions struct {
SuitePath string
Model string
Mode string
SelfCorrect bool
Binary string
Iterations int
Version string
Commit string
Output string
JSON bool
DryRun bool
Help bool
}
func runTurnCommand(args []string, getenv func(string) string, stdout io.Writer, stderr io.Writer) int {
options, err := parseTurnArgs(args, getenv)
if err != nil {
_, _ = fmt.Fprintln(stderr, err.Error())
return 2
}
if options.Help {
_, _ = fmt.Fprint(stdout, turnHelpText())
return 0
}
set, err := perfbench.LoadTaskSet(options.SuitePath)
if err != nil {
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: "+err.Error())
return 1
}
// The dry-run path records a zero-iteration run without a binary, so the
// manifest loads and the report path is exercised in CI without a model.
if options.DryRun {
_, _ = fmt.Fprintln(stdout, "[zero] turn benchmark: dry run (no agent invoked)")
return 0
}
binary, err := perfbench.ResolveBinary(options.Binary)
if err != nil {
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: "+err.Error())
return 2
}
result, err := perfbench.RunTurnBench(context.Background(), set, perfbench.TurnBenchConfig{
Model: options.Model,
Mode: options.Mode,
SelfCorrect: options.SelfCorrect,
Version: options.Version,
Commit: options.Commit,
Iterations: options.Iterations,
Runner: perfbench.NewTurnExecRunner(binary),
})
if err != nil {
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: "+err.Error())
return 1
}
if options.Output != "" {
if err := writeTurnReport(options.Output, result); err != nil {
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: "+err.Error())
return 1
}
}
if options.JSON {
if err := perfbench.WriteTurnBenchJSON(stdout, result); err != nil {
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: "+err.Error())
return 1
}
return 0
}
_, _ = fmt.Fprintln(stdout, perfbench.FormatTurnBenchSummary(result))
return 0
}
func parseTurnArgs(args []string, getenv func(string) string) (turnOptions, error) {
options := turnOptions{
Iterations: 1,
Version: strings.TrimSpace(getenv("ZERO_BENCH_VERSION")),
Commit: strings.TrimSpace(getenv("ZERO_BENCH_COMMIT")),
}
for index := 0; index < len(args); index++ {
arg := args[index]
flag, inlineValue := splitFlagValue(arg)
switch flag {
case "--suite":
value, next, err := readOptionValue(args, inlineValue, index, flag)
if err != nil {
return options, err
}
options.SuitePath = value
index = next
case "--model":
value, next, err := readOptionValue(args, inlineValue, index, flag)
if err != nil {
return options, err
}
options.Model = value
index = next
case "--mode":
value, next, err := readOptionValue(args, inlineValue, index, flag)
if err != nil {
return options, err
}
options.Mode = value
index = next
case "--binary":
value, next, err := readOptionValue(args, inlineValue, index, flag)
if err != nil {
return options, err
}
options.Binary = value
index = next
case "--iterations":
value, next, err := readOptionValue(args, inlineValue, index, flag)
if err != nil {
return options, err
}
parsed, err := parsePositiveInteger(flag, value)
if err != nil {
return options, err
}
options.Iterations = parsed
index = next
case "--version":
value, next, err := readOptionValue(args, inlineValue, index, flag)
if err != nil {
return options, err
}
options.Version = value
index = next
case "--commit":
value, next, err := readOptionValue(args, inlineValue, index, flag)
if err != nil {
return options, err
}
options.Commit = value
index = next
case "--output":
value, next, err := readOptionValue(args, inlineValue, index, flag)
if err != nil {
return options, err
}
options.Output = value
index = next
case "--self-correct":
if strings.Contains(arg, "=") {
return options, fmt.Errorf("%s does not accept a value", flag)
}
options.SelfCorrect = true
case "--json":
if strings.Contains(arg, "=") {
return options, fmt.Errorf("%s does not accept a value", flag)
}
options.JSON = true
case "--dry-run":
if strings.Contains(arg, "=") {
return options, fmt.Errorf("%s does not accept a value", flag)
}
options.DryRun = true
case "-h", "--help":
if strings.Contains(arg, "=") {
return options, fmt.Errorf("%s does not accept a value", flag)
}
options.Help = true
default:
return options, fmt.Errorf("unknown option: %s", arg)
}
}
if options.Help {
return options, nil
}
if strings.TrimSpace(options.SuitePath) == "" {
return options, fmt.Errorf("--suite is required")
}
if strings.TrimSpace(options.Model) == "" && !options.DryRun {
return options, fmt.Errorf("--model is required (or pass --dry-run)")
}
return options, nil
}
func writeTurnReport(path string, result perfbench.TurnBenchResult) error {
if dir := filepath.Dir(path); dir != "" {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
}
var buffer bytes.Buffer
if err := perfbench.WriteTurnBenchJSON(&buffer, result); err != nil {
return err
}
return os.WriteFile(path, buffer.Bytes(), 0o644)
}
func turnHelpText() string {
return strings.Join([]string{
"Usage: zero-perf-bench turn [options]",
"",
"Runs ZERO headlessly with --trace against a per-turn benchmark task set and",
"records per-span latency plus the top controllable latency sources (the",
"Phase 0 baseline's \"do not proceed until\" criterion). Each task is a fresh",
"`zero exec` process, so iterations are cold-start samples; a warm path needs",
"an in-process runner (future work).",
"",
"Options:",
" --suite <path> Task set JSON file (required)",
" --model <model> Model to run (required unless --dry-run)",
" --mode <name> Exec mode preset to apply",
" --self-correct Enable the post-edit verify-and-correct loop",
" --binary <path> Path to the `zero` binary (default: zero on PATH / repo root)",
" --iterations <n> Times to run each task (default: 1)",
" --version <v> Record the ZERO version (default: $ZERO_BENCH_VERSION)",
" --commit <sha> Record the ZERO commit (default: $ZERO_BENCH_COMMIT)",
" --output <path> Write the JSON result to path",
" --json Print only the JSON result",
" --dry-run Load the manifest and exit without invoking the agent",
" -h, --help Show this help",
}, "\n") + "\n"
}
-330
View File
@@ -1,330 +0,0 @@
# 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:
1. Tell the agent about *your* project (drop an `AGENTS.md` in your repo).
2. Add new specialist sub-agents.
3. Wire Zero into the rest of your toolchain (MCP, skills, hooks, plugins).
## 1. Drop a project `AGENTS.md`
When Zero starts in a directory, it looks for project-level instructions and injects them into the system prompt. The lookup walks from your current working directory **up to the nearest git root** and reads the first matching file at each level — general rules at the repo root, more specific rules in sub-trees. Files are labeled with their directory in the prompt (e.g. `## Project guidelines (services/api/AGENTS.md)`).
Accepted file names, in priority order at each level:
| Path | Notes |
| --- | --- |
| `./AGENTS.md` | The classic spot — committed to your repo, shared with the team. |
| `./ZERO.md` | Brand-specific alias. Same format, lower priority. |
| `./.zero/AGENTS.md` | Project-local, hidden, gitignored. Personal notes that stay out of git. |
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.md` — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
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.
```markdown
# Project conventions for <your project>
- 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.
```
Tips:
- Keep each file under ~8 KiB. Zero caps the **total** across all matched files at 32 KiB; everything past the cap is dropped.
- Re-state rules in the imperative voice: "Run `make lint`", not "you should consider running the linter".
- Don't put secrets, model IDs, or environment-specific paths in `AGENTS.md`. Use `config.json` for those.
- 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:
| Scope | Path | Shared? |
| --- | --- | --- |
| Built-in | compiled into Zero | yes — `worker`, `explorer`, `code-review` |
| User | `~/.config/zero/specialists/*.md` | no — your machine only |
| Project | `./.zero/specialists/*.md` | yes — the repo team |
Project overrides user overrides built-in when names collide.
A specialist is a markdown manifest with frontmatter and a system prompt:
```markdown
---
description: Reviews API changes for breaking-change risk and missing tests.
tools: read-only,plan
---
You review API changes. For every changed hunk in `internal/api/` or any file
that ends in `_api.go`:
1. Confirm the public signature is backward-compatible, or note the breaking
change explicitly with the migration path.
2. Confirm a corresponding test exists in `internal/api/*_test.go` and that
the new behaviour is exercised.
3. Flag any new exported symbol without a doc comment.
Reply with one JSON object per finding: `{"file", "line", "severity", "message", "fix"}`.
```
CLI management (the prompt is passed inline via `--prompt`):
```bash
zero specialist list
zero specialist show api-reviewer
zero specialist create api-reviewer \
--project \
--description "Reviews API changes" \
--tools read-only,plan \
--prompt "$(cat api-reviewer.md)"
zero specialist edit api-reviewer --project
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`](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`. 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
write-changelog/
SKILL.md
```
`SKILL.md` format:
```markdown
---
description: Run the project's benchmark suite and summarize the deltas.
---
# Run benchmarks
1. `make bench` — captures the wall-clock and RSS before and after.
2. `benchstat before.txt after.txt` — diffs the two.
3. Report any regression > 5% with the function name and the previous value.
```
Only `name` and `description` are recognized in the frontmatter today. The `name` defaults to the directory name. Within a single skills root, duplicate names are resolved by lexicographic directory order. During an agent run, Zero loads the default skills directory before plugin skill roots; earlier roots win name collisions silently. Plugin-declared skills (section 6) are merged into the active agent run at plugin activation time, so bundled skills appear in the available skills list and can be loaded with the `skill` tool.
The `skill` core tool lets the agent load any discovered skill by name.
## 4. Hooks
Hooks fire shell commands on lifecycle events. Configure them in JSON:
- User: `~/.config/zero/hooks.json`
- Project: `./.zero/hooks.json`
```json
{
"enabled": true,
"hooks": [
{
"id": "block-rm-rf",
"event": "beforeTool",
"matcher": "bash",
"command": "/usr/local/bin/zero-hook-block-rmrf.sh",
"enabled": true
},
{
"id": "log-session",
"event": "sessionStart",
"command": "/usr/local/bin/zero-hook-log.sh",
"enabled": true
}
]
}
```
The `args` array (when present) is passed verbatim to `exec.CommandContext`. The actual hook payload — event name, matcher, tool call id, tool name, tool input, tool output, status — is delivered to the command as **JSON on stdin**, not via `${...}` substitution. A typical handler reads stdin and decides what to do:
```bash
#!/usr/bin/env bash
# /usr/local/bin/zero-hook-block-rmrf.sh
set -euo pipefail
payload="$(cat)"
if printf '%s' "$payload" | grep -q '"input":"[^"]*rm[[:space:]]+-rf'; then
echo "refusing rm -rf" >&2
exit 1
fi
```
Events the agent emits (in dispatch order):
| Event | Fires when | Matcher allowed? |
| --- | --- | --- |
| `beforeTool` | A tool is about to run | yes (tool name) |
| `afterTool` | A tool just returned | yes (tool name) |
| `sessionStart` | A session begins | no |
| `sessionEnd` | A session ends | no |
| `specialistStart` | A sub-agent is spawned | yes (specialist name) |
| `specialistStop` | A sub-agent ends | yes (specialist name) |
A hook's exit code decides what happens next: `0` continues, non-zero blocks the tool call (`beforeTool`) or surfaces an error (`afterTool`). Hook execution is recorded in the audit log; the audit is reachable from the agent's view of past actions, not from a dedicated `zero doctor` check.
> **Roadmap.** An in-UI hooks manager is on the backlog. Today you edit the JSON directly.
## 5. MCP — Model Context Protocol
Zero is both an **MCP client** (it can call external MCP servers) and an **MCP server** (other agents can call its tools).
### As a client — configure MCP servers in `config.json`
```json
{
"mcp": {
"servers": {
"docs": {
"type": "stdio",
"command": "docs-mcp",
"args": ["--port", "7777"]
},
"github": {
"type": "http",
"url": "https://api.example.com/mcp",
"headers": { "Authorization": "Bearer YOUR_TOKEN_HERE" }
}
}
}
}
```
Manage via CLI:
```bash
zero mcp add docs --type stdio -- docs-mcp --port 7777
zero mcp add github --type http --url https://api.example.com/mcp \
--header "Authorization=Bearer YOUR_TOKEN_HERE"
zero mcp list
zero mcp check docs
zero mcp remove github
zero mcp oauth login github
```
Servers are merged from user and project configs (project wins on conflicts). Token-bearing values in `config.json` are sent verbatim — there is no `${env:...}` expansion — so prefer one of:
- A wrapper script that sources the secret and execs the real command.
- 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
zero serve --mcp
zero serve --mcp -C /path/to/workspace
zero serve --mcp --add-dir /path/to/extra
```
The server speaks MCP over stdio. Configure it from the receiving side as a `stdio` server whose command is `zero serve --mcp`.
`-C/--cwd` sets the workspace root exposed as MCP resources and used by core tools. `--add-dir` (repeatable) widens that resource/tool scope beyond the workspace without granting the sandbox temp roots used by interactive runs.
## 6. Plugins
A plugin is a self-contained directory that bundles tools, hooks, and skills for one capability. Plugins live at:
- User: `~/.config/zero/plugins/<id>/`
- Project: `./.zero/plugins/<id>/`
Each plugin has a `plugin.json` manifest:
```json
{
"id": "github-pr-review",
"name": "GitHub PR Review",
"description": "Adds review tools for GitHub PRs.",
"version": "1.0.0",
"tools": [
{ "name": "list_prs", "command": "./tools/list_prs.sh" }
],
"hooks": [
{ "name": "pre-merge-check", "event": "beforeTool", "command": "./hooks/pre-merge.sh" }
],
"skills": [
{ "path": "./skills/review-checklist/SKILL.md" }
]
}
```
Install and manage:
```bash
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
zero plugins list
zero plugins remove github-pr-review # alias: rm
```
A plugin is enabled by being present in the plugins directory and disabled by removing it (or by the user setting `"enabled": false` in its `plugin.json`). Plugins are not enabled or disabled by a CLI subcommand today.
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.
## 7. Configuration locations
Five configuration sources, in precedence order (later sources override earlier ones):
| 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). |
| 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).
The sandbox `additionalWriteRoots` key is **ignored in project config** by design — a checked-out repo cannot widen its own sandbox. Set it in the user config or pass `--add-dir` per-invocation.
## 8. End-to-end example
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` 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](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.
-787
View File
@@ -1,787 +0,0 @@
# How Zero Works
This document explains Zero's architecture at a high level: the layers, module
boundaries, data flow, and runtime concepts behind the interactive TUI and the
headless `zero exec` mode.
It is written as a progressive map: each high-level section introduces the
concepts you need for orientation, then points to the later section that explains
the mechanics in more detail.
## Big Picture
Zero is a Go CLI application with two main user-facing surfaces:
- **Interactive TUI** (`zero`): a Bubble Tea application that renders chat,
tool cards, permission prompts, model/provider pickers, and session controls.
- **Headless exec** (`zero exec ...`): a scriptable command runner that uses the
same agent loop and tools, but writes text, JSON, or stream-JSON events.
Both surfaces converge on the same core agent runtime:
```mermaid
flowchart TD
User[User / automation] --> CLI[cmd/zero -> internal/cli]
CLI --> TUI[Interactive TUI<br/>internal/tui]
CLI --> Exec[Headless exec<br/>internal/cli/exec.go]
CLI --> ACP[ACP / editor bridge<br/>internal/acp]
TUI --> Agent[Agent loop<br/>internal/agent]
Exec --> Agent
ACP --> Agent
Agent --> Provider[Provider runtime<br/>internal/zeroruntime.Provider]
Provider --> APIs[OpenAI / Anthropic / Gemini / compatible APIs]
Agent --> Registry[Tool registry<br/>internal/tools.Registry]
Registry --> CoreTools[Core tools<br/>read/edit/shell/search/plan]
Registry --> MCP[MCP tools<br/>internal/mcp]
Registry --> Specialists[Specialist tools<br/>internal/specialist]
Registry --> Plugins[Plugin tools<br/>internal/plugins]
Registry --> LocalControl[Browser / terminal / desktop helpers]
Agent --> Sandbox[Permission + sandbox engine<br/>internal/sandbox]
Agent --> Hooks[Hooks dispatcher<br/>internal/hooks]
Agent --> Sessions[Session event store<br/>internal/sessions]
```
The important split is:
- `internal/tui` is the **presentation layer**. For the mechanics, see
[Presentation Layer: Bubble Tea TUI](#presentation-layer-bubble-tea-tui) and
[How the TUI and Agent Loop Connect](#how-the-tui-and-agent-loop-connect).
- `internal/agent` is the **reasoning/tool loop**. For the per-turn mechanics,
see [Agent Loop](#agent-loop), [Tool Execution Lifecycle](#tool-execution-lifecycle),
and [Context Budget and Compaction](#context-budget-and-compaction).
- `internal/tools`, `internal/sandbox`, and provider packages are the **runtime
capability layer**. For the details, see [Runtime Provider Layer](#runtime-provider-layer),
[Tools and Capabilities](#tools-and-capabilities), and
[Permission and Sandbox Model](#permission-and-sandbox-model).
- `internal/sessions` is the **durable event log** used for resume, fork,
rewind, sub-agents, and history. See [Session Storage and History](#session-storage-and-history).
## Architecture Spine
The easiest way to understand Zero is not as a set of independent packages, but
as one spine with several mechanisms attached to it:
```mermaid
flowchart LR
Surface[Surface<br/>TUI / exec / ACP] --> Run[Run request<br/>prompt + runtime options]
Run --> Agent[Agent loop<br/>turns, tools, recovery]
Agent --> Capabilities[Capabilities<br/>providers + tools + sandbox]
Capabilities --> Events[Runtime events<br/>text, tool calls, permissions, usage]
Events --> Surface
Events --> Sessions[Session log<br/>resume, fork, rewind, history]
Config[Config / guidelines / skills] --> Run
Extensions[MCP / specialists / plugins / hooks] --> Capabilities
```
The **TUI + agent loop** is the main story because it explains the normal user
experience: type a prompt, watch the model stream, approve tool use, see file
changes, and receive a final answer. Other mechanisms are still important, but
most of them attach to the same spine:
| Mechanism | Where it attaches | What it changes |
| --- | --- | --- |
| Config, provider profiles, model registry | Before a run starts | Which provider/model is used and what defaults apply. |
| Project/user guidelines, skills, specialists context | Prompt construction | What instructions and available capabilities the model sees. |
| Tool registry, MCP, plugins, local-control helpers | Capability layer | Which actions the model may request. |
| Permission policy, sandbox, command-prefix grants | Tool execution | Whether a requested action can run, needs approval, or is denied. |
| Hooks | Tool lifecycle | Optional before/after observers or blockers around tool calls. |
| Sessions, checkpoints, compaction metadata | Event persistence/context | What can be resumed, forked, rewound, or summarized for later turns. |
| Stream-JSON | Surface layer | How the same runtime events are emitted for automation instead of rendered in the TUI. |
So this document focuses first on the TUI/agent-loop bridge, then explains the
supporting mechanisms in terms of where they connect to that bridge.
Use this as the reading path:
| If you see this concept in a high-level flow | Read this detail section |
| --- | --- |
| Surface submits a run | [Startup Flow](#startup-flow), then [How the TUI and Agent Loop Connect](#how-the-tui-and-agent-loop-connect) |
| Agent builds context and advances turns | [Agent Loop](#agent-loop) |
| “Maybe compact context” | [Context Budget and Compaction](#context-budget-and-compaction) |
| Model requests a tool | [Tool Execution Lifecycle](#tool-execution-lifecycle), then [Tools and Capabilities](#tools-and-capabilities) |
| Tool needs approval or sandbox access | [Permission and Sandbox Model](#permission-and-sandbox-model) |
| Events appear in the UI or stream-JSON | [How the TUI and Agent Loop Connect](#how-the-tui-and-agent-loop-connect), [Stream-JSON Protocol](#stream-json-protocol) |
| Resume, fork, rewind, or sub-agent history | [Session Storage and History](#session-storage-and-history) |
## Main Packages
The sections above describe the architecture in concepts. If you want to connect
those concepts to implementation details, use this table as the code map before
reading the deeper flows below.
| Package | Role |
| --- | --- |
| `cmd/zero` | Tiny binary entrypoint; calls `internal/cli.Run`. |
| `internal/cli` | Command parsing, config resolution, provider creation, registry setup, TUI/exec/ACP launch paths. |
| `internal/tui` | Bubble Tea model/update/view code, transcript rendering, composer, prompts, slash commands, setup wizard. |
| `internal/agent` | Core loop that sends messages to the model, streams output, executes tools, handles permissions, compaction, retries, and final answers. |
| `internal/zeroruntime` | Provider-neutral message, tool-call, stream, usage, and image types. Providers implement `Provider.StreamCompletion`. |
| `internal/providers` | Factory that turns a resolved provider profile into an OpenAI, Anthropic, Gemini, Codex, or compatible provider adapter. |
| `internal/tools` | Tool interface, registry, core read/write/shell/network tools, local-control wrappers, tool safety metadata, redaction, output ceilings. |
| `internal/sandbox` | Permission policy, path/network checks, grant store, command-prefix approvals, and platform sandbox backends. |
| `internal/sessions` | Local append-only session storage: `metadata.json` + `events.jsonl`. |
| `internal/config` | User/project config resolution, active provider, preferences, sandbox/tool settings. |
| `internal/mcp` | MCP server config, client runtime, permission store, and MCP tool registration. |
| `internal/specialist` / `internal/swarm` | Sub-agent manifests and team/member orchestration exposed as tools. |
| `internal/localcontrol` / `internal/browser` | Runtime helpers used by config-gated local-control tool wrappers. |
| `internal/plugins` / `internal/skills` / `internal/hooks` | Extension surfaces loaded into the agent context, tool registry, or tool lifecycle. |
| `internal/streamjson` | Machine-readable protocol for `zero exec --input-format/--output-format stream-json`. |
## Startup Flow
### Interactive TUI (`zero`)
```mermaid
sequenceDiagram
participant Main as cmd/zero
participant CLI as internal/cli
participant Config as internal/config
participant TUI as internal/tui
participant Agent as internal/agent
Main->>CLI: Run(args)
CLI->>Config: resolve config for workspace
CLI->>CLI: build provider
CLI->>CLI: build tool registry
CLI->>CLI: register core, config-gated local-control, specialists, MCP, plugins
CLI->>CLI: create sandbox engine + session store
CLI->>TUI: run Bubble Tea with Options
TUI->>TUI: Init / Update / View event loop
TUI->>Agent: agent.Run on submitted prompt
```
The TUI owns terminal interaction. It tracks composer state, transcript rows,
scrolling, permission dialogs, model/provider pickers, setup wizard state, and
visual updates. When the user submits a prompt, the TUI builds `agent.Options`
with callbacks like `OnText`, `OnToolCall`, `OnToolResult`, `OnPermission`,
`OnAskUser`, and `OnUsage`. Those callbacks convert runtime events into visible
transcript rows and session events.
### Headless exec (`zero exec`)
```mermaid
flowchart TD
A[Parse exec flags] --> B[Resolve config + provider]
B --> C[Build tool registry]
C --> D[Resolve prompt / stream-json input / images]
D --> E[Prepare or resume session]
E --> F[Create stream-json/text/json writer]
F --> G[agent.Run]
G --> H[Write final output and exit code]
```
`zero exec` uses the same `agent.Run` loop as the TUI, but without interactive
terminal state. It can persist sessions, resume/fork existing sessions, run in
stream-JSON mode for automation, and always requires a stronger completion
signal so a no-tool assistant response is not treated as success while work is
still pending.
## Agent Loop
The core loop lives in `internal/agent.Run`. Conceptually, every turn follows
this cycle:
```mermaid
flowchart TD
Start[Build system prompt + user prompt] --> Expose[Partition visible tools]
Expose --> Compact[Maybe compact context]
Compact --> Request[Send CompletionRequest to provider]
Request --> Stream[Collect streamed text, reasoning, usage, tool calls]
Stream --> HasTools{Tool calls?}
HasTools -- No --> FinalCheck{Final answer acceptable?}
FinalCheck -- Yes --> Done[Return Result]
FinalCheck -- No --> Nudge[Append user nudge and continue]
HasTools -- Yes --> Execute[Execute tool calls]
Execute --> Permission[Permission / sandbox / hooks]
Permission --> ToolResult[Append tool result messages]
ToolResult --> Diagnostics[Post-edit diagnostics / self-correct]
Diagnostics --> Expose
```
Key responsibilities of the loop:
1. **Build prompt state** — combines the system prompt, project/user guidelines,
loaded skills/specialist instructions, the current user prompt, and images. The
resulting message list is the model-facing context that later tool results are
appended to. Resumed session history is replayed by the caller before a new run,
not passed as a separate `agent.Run` history field.
2. **Expose tools** — starts from the registry built by the CLI, then filters
tool definitions by permission mode, enabled or disabled filters, and
deferred-loading rules. Tool schemas also count toward context budget, so they
matter for compaction decisions.
3. **Call provider** — sends a provider-neutral `zeroruntime.CompletionRequest`.
4. **Stream events** — forwards text, reasoning, tool-call deltas, and usage to
the caller via callbacks.
5. **Execute tools** — decodes tool arguments, checks filters, applies
permissions and sandbox policy, dispatches hooks, runs the registered tool,
redacts output, and appends a tool message. See
[Tool Execution Lifecycle](#tool-execution-lifecycle).
6. **Recover and guard** — handles malformed tool calls, repeated failures,
empty turns, context pressure, stream reconnect/retry, and max-turn fallback.
Context pressure is handled by [Context Budget and Compaction](#context-budget-and-compaction).
7. **Finalize** — returns a final answer or a structured incomplete/stop reason.
The model never directly mutates the workspace. It asks for tool calls; Zero
validates and executes those calls through the registry, permission policy, and
sandbox engine.
## Tool Execution Lifecycle
A tool call is part of the model conversation, not a side channel that bypasses
it. The model proposes an action, Zero decides whether that action is allowed to
run, the tool returns a structured result, and that result becomes context for the
next provider request.
```mermaid
sequenceDiagram
participant Model as LLM / provider
participant Agent as agent.Run
participant Policy as filters + permissions + sandbox
participant Hooks as hooks
participant Tool as tool implementation
participant Surface as TUI / exec callbacks
participant Transcript as model conversation + session events
Agent->>Model: request with messages + available tool schemas
Model-->>Agent: assistant text and/or tool_call(name, args)
Agent-->>Surface: stream text / tool-call preview
Agent->>Agent: collect and decode tool arguments
Agent->>Policy: check tool filters, permission mode, sandbox policy
alt denied before execution
Policy-->>Agent: denied result
Agent-->>Surface: tool failed/denied event
Agent->>Transcript: append tool result explaining denial
else needs user approval
Policy-->>Surface: permission request
Surface-->>Policy: allow / deny / remember decision
Policy-->>Agent: decision
end
alt approved to execute
Agent->>Hooks: beforeTool hooks
Hooks-->>Agent: allow / block / annotate
Agent->>Tool: execute(ctx, args)
Tool-->>Agent: output, error, metadata
Agent->>Hooks: afterTool hooks
Agent->>Agent: redact, truncate, classify success/failure
Agent-->>Surface: OnToolResult callback
Agent->>Transcript: append tool result message
end
Agent->>Model: next request includes prior assistant tool_call + tool result
Model-->>Agent: continue with more tools or final answer
```
Conceptually, one requested tool goes through these phases:
1. **Proposal** — the provider streams assistant content and may emit one or more
tool calls. Zero can show the partial tool arguments in the TUI while they are
still streaming, but nothing has executed yet.
2. **Decode and gate** — the agent validates JSON arguments, checks whether the
tool is enabled, applies permission mode, asks the sandbox engine about path,
network, shell, or command-prefix constraints, and may invoke permission
callbacks.
3. **Execute** — the selected registry entry runs locally, through MCP, as a
specialist/sub-agent, or through a plugin/local-control helper. Hooks can run
before or after this step.
4. **Normalize result** — Zero turns the outcome into an agent `ToolResult`: a
success, failure, denial, cancellation, timeout, or validation error with
output/metadata that may be redacted or truncated.
5. **Append to context** — the result is appended as a tool-result message paired
with the assistant tool call. The next model request includes that result, so
the model can inspect it and decide what to do next.
6. **Render and persist** — the result is surfaced to the TUI, stream-JSON
writer, or ACP client. TUI and exec record rich tool/session events for
resume/history; ACP currently persists conversational messages and streams
tool/permission updates to the client.
Tool success and failure are both informative context. A successful `read_file`
result gives the model file contents; a failed `edit_file` result gives the model
an error such as “old string not found”; a denied shell command tells the model
that the action is not currently permitted. The model is expected to use that
feedback to continue, choose another tool, ask the user, or explain the blocker.
The judgement that “information is sufficient” is made by the model, not by the
TUI. Zero controls the loop mechanics: it keeps appending messages, enforces
safety, retries or nudges around malformed/empty/incomplete turns, and stops on
hard limits. But the semantic decision to call another tool versus produce a
final answer comes from the next provider response. In other words:
```mermaid
flowchart TD
Result[Tool result appended to conversation] --> Next[Next provider request]
Next --> Decide{Model response}
Decide -- More information/action needed --> MoreTools[Request another tool]
MoreTools --> Result
Decide -- User input needed --> AskUser[Call ask_user or explain question]
AskUser --> Result
Decide -- Enough information --> Final[Final assistant answer]
Decide -- Invalid / empty / unsafe --> Guard[Agent guardrail, retry, denial, or stop reason]
Guard --> Next
```
This is why tool output is usually more than UI decoration. It is part of the
working memory for the next model turn, subject to context-window pressure and
compaction. The TUI may summarize or prettify it for the user, but the agent loop
also stores the machine-facing result that the model sees.
## Context Budget and Compaction
The `Maybe compact context` step in the agent-loop diagram is Zero's context-window
pressure valve. It is not a separate memory engine; it rewrites the current
model-facing conversation so the next provider request fits within the configured
context window.
Compaction is enabled only when the run knows a positive `ContextWindow`. When it
is enabled, the loop does this near the top of each turn:
```mermaid
flowchart TD
Tools[Partition visible tools] --> Estimate[Estimate messages + tool-definition tokens]
Estimate --> Under{Under threshold?}
Under -- Yes --> Keep[Keep context as-is]
Under -- No --> Grown{Grown past low-water mark?}
Grown -- No --> Keep
Grown -- Yes --> Prune[Prune stale large tool outputs]
Prune --> Recheck{Back under threshold?}
Recheck -- Yes --> KeepPruned[Use pruned context]
Recheck -- No --> Summarize[Summarize oldest middle]
Summarize --> Replace[system messages + summary + recent suffix]
```
The trigger is intentionally approximate:
- Zero estimates request size using message text, images, tool-call arguments, and
the tool definitions advertised on that turn.
- The proactive threshold is roughly **two-thirds of the context window**.
- Provider usage from completed turns is used to calibrate the rough estimate over
time, so the budget gauge and compaction decision track the provider more
closely after a few turns.
- A low-water mark prevents the loop from re-summarizing the same already-compacted
history on every turn.
When compaction actually runs, it tries to preserve the parts most likely to be
needed by the model:
- leading system messages stay verbatim;
- the most recent turns stay verbatim (`CompactionPreserveLast`, defaulting to the
agent's normal preserve count);
- the preserved suffix is widened to a provider-valid turn boundary so tool-result
messages are not replayed without their corresponding assistant tool call;
- the older middle is summarized into one user-role message labeled
`[Summary of earlier conversation]`;
- structured state that should not be paraphrased away, such as active plan/loaded
skill state, is preserved alongside the prose summary.
There are two compaction paths:
1. **Proactive compaction** runs before the provider call when the estimated
request is over threshold.
2. **Reactive compaction** runs after a provider returns a context-limit style
error. Zero compacts once and retries that same turn; if the provider still
rejects the request, the error is surfaced rather than looping forever.
Compaction failures are conservative. If the summarizer fails during proactive
compaction, Zero keeps the original messages and tries to continue. It does not
drop conversation history just to make room.
## Presentation Layer: Bubble Tea TUI
`internal/tui` is a Bubble Tea application. The usual Bubble Tea shape applies:
```mermaid
flowchart LR
Init[model.Init] --> Update[model.Update]
Update --> View[model.View]
View --> Terminal[Terminal frame]
Terminal --> Msg[tea.Msg: key, mouse, window, agent event]
Msg --> Update
```
The TUI is intentionally not the agent brain. It is responsible for:
- collecting user input from the composer;
- rendering assistant text, reasoning, tool cards, permissions, plans, usage,
files, and sidebars;
- handling slash commands and modal surfaces;
- converting agent callbacks into Bubble Tea messages;
- persisting visible session events;
- allowing live provider/model/permission changes.
When a prompt is submitted, the TUI starts a background command that invokes
`agent.Run`. Streaming callbacks send messages back into the Bubble Tea update
loop, so rendering remains responsive while the agent is working.
## How the TUI and Agent Loop Connect
A common source of confusion is that Zero has two event loops running at once:
1. **Bubble Tea loop** — owns terminal input, in-memory UI state, and frames.
2. **Agent loop** — owns model turns, tool execution, permissions, retries, and
stop conditions.
They are connected by `tea.Cmd` and `agent.Options` callbacks. The TUI never
runs provider/tool logic directly inside `Update`; instead, submit handling gates
and dispatches a prompt, the TUI starts a background command, and that command
calls `agent.Run` with callbacks that send runtime events back to the Bubble Tea
program. Function names in this section are illustrative code anchors, not API
boundaries.
```mermaid
sequenceDiagram
participant User
participant TUI as Bubble Tea model<br/>internal/tui
participant Cmd as tea.Cmd goroutine<br/>runAgentWithOptions
participant Agent as agent.Run<br/>internal/agent
participant Provider as Provider.StreamCompletion
participant Tools as Tool registry + sandbox
participant Store as Session store
User->>TUI: type prompt / press Enter
TUI->>TUI: validate/gate submit and append visible user row
TUI->>Cmd: launch tea.Cmd with runID + cancellable context
Cmd->>Agent: agent.Run(prompt, provider, options)
Agent->>Provider: stream completion request
Provider-->>Agent: text / reasoning / tool-call deltas
Agent-->>Cmd: OnText / OnReasoning / OnToolCallStart callbacks
Cmd-->>TUI: runtimeMessageSink(tea.Msg)
TUI->>TUI: Update appends/updates transcript rows
TUI-->>User: View renders new frame
Agent->>Tools: execute collected tool call
Tools->>Tools: permission, sandbox, hooks, redaction
Tools-->>Agent: tool result
Agent-->>Cmd: OnToolCall / OnToolResult / OnPermission / OnUsage
Cmd->>Store: batch session events for resume/history
Cmd-->>TUI: final agentResponseMsg
TUI->>TUI: mark run complete, flush rows/events, unlock composer
```
In the TUI path, the same event can have three representations:
| Runtime thing | Agent representation | TUI representation | Session representation |
| --- | --- | --- | --- |
| Assistant text | streamed provider text forwarded through `OnText` | incremental assistant row text | assistant message event |
| Reasoning preview | `OnReasoning` callback | collapsible/preview reasoning row | usually not the same as final assistant text |
| Tool call | `agent.ToolCall` from collected provider stream | tool card, specialist card, or plan panel update | `EventToolCall` |
| Tool result | `agent.ToolResult` | result card/status, changed files, plan/specialist updates | `EventToolResult` |
| Permission prompt/audit | `PermissionRequest` / `PermissionEvent` | modal prompt or permission row | permission request/decision events |
| Usage/context | provider usage + measured context | context gauge / usage metadata | usage event |
### One interactive turn, end to end
For a normal prompt in the interactive TUI:
1. Submit handling validates composer state and command gates, then dispatches
the prompt to the launch path.
2. The launch path appends immediate UI rows, creates a cancellable run context,
and starts `runAgent` as a `tea.Cmd`; run bookkeeping assigns the `runID` used
to correlate later events.
3. `runAgentWithOptions` copies the current UI-controlled runtime choices into
`agent.Options`: provider/model labels, permission mode, cwd, session ID,
images, registry, response style, context window, and self-correction hooks.
4. The command wraps agent callbacks. For example:
- `OnText` sends incremental assistant text into the TUI message sink.
- `OnToolCallStart` / `OnToolCallDelta` show live tool-argument streaming.
- `OnToolCall` creates visible tool rows and appends session events.
- `OnToolResult` creates result rows, updates plan/specialist side panels, and
records changed files.
- `OnPermissionRequest` sends a prompt to the TUI and blocks the agent loop
until the user answers or the run context is cancelled.
- `OnAskUser` does the same for model-initiated clarification questions.
5. `agent.Run` builds provider-neutral messages and tool definitions, streams a
provider response, executes any requested tools through the registry/sandbox,
appends tool results back into the model conversation, and repeats until it has
a final answer or a stop condition.
6. When `agent.Run` returns, the command emits `agentResponseMsg`. The Bubble Tea
loop marks the run complete, flushes queued rows/session events, updates usage
and file state, and re-enables the composer.
The important architectural point: **the TUI is stateful and reactive, but the
agent loop is the source of runtime truth for model/tool progress**. Presentation
state is derived from callback events and persisted session events; workspace
changes happen only through tools executed by the agent runtime.
### Feature flows use the same bridge
For one interactive turn, the supporting systems do not run beside the TUI/agent
loop; they run inside or around it:
```mermaid
flowchart TD
Submit[TUI submit] --> Options[Build agent.Options]
Options --> Context[Assemble context<br/>config, guidelines, history, skills]
Context --> ProviderCall[Provider stream]
ProviderCall --> StreamEvents[Text/reasoning/tool-call events]
StreamEvents --> Render[TUI render callbacks]
ProviderCall --> ToolCall{Tool call?}
ToolCall -- No --> Final[Final response]
ToolCall -- Yes --> ToolGate[Tool gate<br/>filters, permissions, sandbox]
ToolGate --> HooksBefore[beforeTool hooks]
HooksBefore --> Execute[Tool / MCP / specialist / plugin]
Execute --> HooksAfter[afterTool hooks]
HooksAfter --> ToolResult[Tool result message]
ToolResult --> Persist[Session events]
ToolResult --> Context
Final --> Persist
Persist --> Resume[Resume / fork / rewind / parent metadata]
```
Most interactive features are variations of that same bridge rather than separate
architectures:
- **Slash commands** either mutate TUI state directly (`/model`, `/permission`,
`/help`) or start short `tea.Cmd` jobs that later return a message to `Update`
(`/doctor`, `/search`, `/compact`, session commands).
- **Permissions** sit between a model-requested tool call and actual execution:
the agent blocks on `OnPermissionRequest`, the TUI surfaces a modal and sends
the decision back through a channel, and the session log records the
request/decision events.
- **Plans, specialists/sub-agents, and ask-user prompts** all follow the same
runtime pattern: the model requests a capability, the agent records the
call/result in its conversation, and the TUI mirrors the visible state as plan
panels, specialist cards, or question forms. Specialist session metadata can
carry parent/root and sub-agent/spec fields, with richer linkage depending on
the specialist or swarm path.
- **Streaming tool writes** start as provider tool-call deltas before execution;
the TUI shows the arguments live, then replaces/augments them with the actual
`ToolResult` after the tool runs.
- **Compaction** is an agent-loop context-management step; the TUI may show that
it happened, but the purpose is to keep the next provider request within budget.
- **Stream-JSON** is another surface over the same callbacks: `zero exec`
serializes the events as JSON protocol events instead of Bubble Tea views.
So a feature-specific deep dive should usually explain: what user event starts it,
which `tea.Msg`/callback carries it, which agent/tool event is authoritative, and
which session events make it resumable.
## Runtime Provider Layer
Providers are hidden behind this interface in `internal/zeroruntime`:
```go
type Provider interface {
StreamCompletion(ctx context.Context, request CompletionRequest) (<-chan StreamEvent, error)
}
```
The agent loop only knows about provider-neutral messages, tool definitions,
stream events, reasoning blocks, images, and token usage. Provider adapters in
`internal/providers/*` translate those neutral types to provider-specific APIs:
- OpenAI and OpenAI-compatible APIs;
- Anthropic and Anthropic-compatible APIs;
- Gemini;
- Codex / ChatGPT OAuth-backed runtime.
`internal/providers.New` resolves the configured model through the model
registry/catalog, normalizes API model IDs and base URLs, attaches API keys or
OAuth resolvers, and returns the concrete adapter.
## Tools and Capabilities
Tools implement a common interface and are registered by name in
`internal/tools.Registry`.
```mermaid
flowchart TD
AgentToolCall[Tool call from model] --> Decode[Decode JSON args]
Decode --> Filter[Enabled/disabled + permission-mode filter]
Filter --> Precheck[Pre-permission rejecters]
Precheck --> SandboxEval[Sandbox Evaluate]
SandboxEval --> Prompt{Needs user approval?}
Prompt -- Yes --> Decision[Permission callback]
Prompt -- No --> Run[Registry.RunWithOptions]
Decision --> Run
Run --> Redact[Redact secrets + enforce output ceiling]
Redact --> Message[Return tool result to model]
```
Core tool groups include:
- **Read-only tools**: file reads, directory listing, glob, grep, LSP navigation,
skills, `ask_user`, permission requests.
- **Write tools**: `write_file`, `edit_file`, `apply_patch`, `update_plan`.
- **Shell tools**: `exec_command`, `write_stdin`, legacy `bash`.
- **Network tools**: `web_fetch` and optional web search backend.
- **Local-control tools**: config-gated wrappers for browser, terminal, desktop,
and artifact helpers backed by packages such as `internal/localcontrol` and
`internal/browser`.
- **Extension tools**: MCP tools, specialists/sub-agents, swarm tools, plugins.
Tool definitions include safety metadata: side-effect class, permission level,
reason, and whether prompt-gated tools are advertised in auto-like modes. The
agent uses this metadata plus per-turn filters and deferred-loading state to
choose which registered tools the model can see and whether a call can run
immediately.
## Permission and Sandbox Model
Zero has two related but separate checks:
1. **Tool permission metadata** says whether a tool is allowed, denied, or needs
approval.
2. **Sandbox policy** evaluates the actual request: path scope, network access,
destructive shell patterns, explicit escalation, persistent/session grants,
and platform isolation availability.
```mermaid
flowchart TD
Call[Tool call] --> Safety[Tool Safety metadata]
Call --> Scope[Workspace/additional roots scope]
Safety --> Engine[Sandbox Engine Evaluate]
Scope --> Engine
Engine --> Allow[Allow]
Engine --> Prompt[Prompt user]
Engine --> Deny[Deny]
Prompt --> Grant[Turn/session/persistent grant]
Grant --> Execute[Execute tool]
Allow --> Execute
Deny --> Result[Return denied tool result]
```
The sandbox engine can:
- auto-allow workspace writes for safe file-edit tools;
- block reads/writes outside the allowed scope;
- prompt for recoverable outside-workspace access;
- deny or prompt for network depending on policy;
- detect destructive shell commands;
- run shell commands through a platform backend when available;
- remember session or persistent grants;
- support narrow command-prefix approvals.
The user-facing TUI and `zero exec` mode both route permission requests through
callbacks, but the TUI can show interactive modals while headless mode generally
returns non-interactive fallback behavior unless a protocol/client handles it.
## Session Storage and History
Sessions live locally under Zero's data directory. A session directory contains:
- `metadata.json` — session ID, title, cwd, provider/model, parent/root IDs,
spec/sub-agent fields, timestamps, and event count.
- `events.jsonl` — append-only event log with messages, tool calls/results,
permissions, usage, checkpoints, forks, compaction, and specs. TUI and exec use
the richer event stream; ACP currently records conversational message history
while streaming tool and permission updates to the client.
```mermaid
flowchart LR
TUI[TUI callbacks] --> Store[internal/sessions.Store]
Exec[exec recorder] --> Store
Store --> Metadata[metadata.json]
Store --> Events[events.jsonl]
Events --> Resume[resume]
Events --> Fork[fork]
Events --> Rewind[rewind/checkpoints]
Events --> Children[parent/root metadata]
```
This is why sessions can be resumed, forked, rewound, and inspected without a
remote service. Zero stores the local event history; providers only receive the
messages needed for the current completion request.
## Stream-JSON Protocol
`zero exec --output-format stream-json` emits newline-delimited JSON events for
integrations and CI. The protocol is implemented in `internal/streamjson` and is
specified in [STREAM_JSON_PROTOCOL.md](STREAM_JSON_PROTOCOL.md).
Common output event types include:
- `run_start`, `text`, `reasoning`, `tool_call`, `tool_result`;
- `permission`, `permission_request`, `permission_decision`;
- `checkpoint`, `restore`, `usage`, `final`, `warning`, `error`, `run_end`.
Input stream-JSON can provide `prompt` or `message` events and image payloads.
The CLI parses those events, resolves a single prompt, validates/decodes images,
and passes the result into the same agent loop used everywhere else.
## Extensibility Flow
Zero's extension surfaces are loaded before the agent loop starts:
```mermaid
flowchart TD
Config[Config resolution] --> Registry[Tool registry]
Registry --> Core[Core tools]
Registry --> MCP[MCP server tools]
Registry --> Specialist[Specialist / Task tools]
Registry --> Plugin[Plugin tools]
Config --> Prompt[System prompt context]
Prompt --> Guidelines[AGENTS.md / ZERO.md]
Prompt --> Skills[Skills list]
Prompt --> SpecialistContext[Specialist descriptions]
ToolLifecycle[Tool lifecycle] --> Hooks[beforeTool / afterTool hooks]
```
- **Project/user guidelines** become prompt context.
- **Skills** are instruction packs the model can load on demand.
- **Specialists** are sub-agents callable through the `Task` tool.
- **MCP servers** contribute external tools.
- **Plugins** can add tools, hooks, and skill roots. Bootstrap merges plugin skill
roots into the runtime skill tool and prompt-visible skill list; the base
`internal/skills` scanner still scans one root at a time.
- **Hooks** can observe or block tool lifecycle events.
## End-to-End Data Flow
A typical interactive edit request looks like this:
```mermaid
sequenceDiagram
participant U as User
participant T as TUI
participant A as Agent loop
participant P as Provider
participant R as Tool registry
participant S as Sandbox
participant FS as Workspace/session files
U->>T: Type prompt and press Enter
T->>A: agent.Run(prompt, provider, options)
A->>P: CompletionRequest(messages, tool definitions)
P-->>A: Stream text + tool call edit_file
A->>S: Evaluate edit_file args
S-->>A: allow/prompt/deny decision
A->>T: permission callback if prompt needed
T-->>A: user decision
A->>R: Run edit_file
R->>FS: Write workspace file
R-->>A: Tool result + changed files
A->>P: Next CompletionRequest with tool result
P-->>A: Final answer
A-->>T: Result + callbacks
T->>FS: Append session events
T-->>U: Render final transcript
```
The same request in `zero exec` follows the same provider/tool/sandbox path, but
streaming events are written to stdout/stderr instead of rendered in Bubble Tea.
## Mental Model
When you work on Zero, start by locating the change on the four-layer spine from
[Architecture Spine](#architecture-spine) — Surface, Agent loop, Capabilities,
Persistence — then jump to the package that owns that boundary:
- UI behavior? Start in `internal/tui`.
- Command-line behavior? Start in `internal/cli`.
- Model/tool-turn behavior? Start in `internal/agent`.
- A new capability? Register a tool in `internal/tools` or an extension surface.
- Safety policy? Start in `internal/sandbox` and agent permission wiring.
- Resume/history behavior? Start in `internal/sessions` and session recorders.
+50 -23
View File
@@ -4,11 +4,10 @@ Zero is distributed as:
- an npm package, `@gitlawb/zero`
- release archives on GitHub Releases
- source builds with Go 1.26.5+
- source builds with Go 1.25+
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.
The npm package and install scripts download a platform-specific release archive.
They require a published GitHub Release for the requested version.
## npm
@@ -17,32 +16,59 @@ npm install -g @gitlawb/zero
zero
```
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.
The package supports Linux, macOS, and Windows on x64 and arm64. It installs the
`zero` command and downloads the matching release binary during `postinstall`.
Requirements:
- Node.js 18+
- network access to npm
- network access to npm and GitHub Releases
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:
## Bun
Bun is "default-secure" and does not run lifecycle scripts of installed
dependencies (only the installing project's own scripts), so the `postinstall`
that fetches the Zero binary is silently skipped. The first run then fails with
`No native binary found next to the npm wrapper`.
The simplest fix is to trust the package after installing, which runs the
blocked postinstall. This works for project and global installs:
```bash
node "$(npm root -g)/@gitlawb/zero/scripts/postinstall.mjs"
# project install
bun add @gitlawb/zero
bun pm trust @gitlawb/zero
# global install
bun add -g @gitlawb/zero
bun pm -g trust @gitlawb/zero
```
`bun pm untrusted` (or `bun pm -g untrusted`) lists the blocked postinstalls if
you want to inspect before trusting.
Alternatively, allow the postinstall to run at install time by adding the
package to your project's `trustedDependencies` before installing:
```json
{
"trustedDependencies": ["@gitlawb/zero"]
}
```
```bash
bun add @gitlawb/zero
```
On Bun versions that do not have `bun pm trust`, run the installer manually
after installing:
```bash
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
```
Reference: <https://bun.sh/docs/pm/lifecycle>
## Linux And macOS Script
Install the latest release:
@@ -125,7 +151,7 @@ Build a local binary:
go build -o zero ./cmd/zero
```
Source builds require Go 1.26.5+.
Source builds require Go 1.25+.
### Sandbox Helpers For Source Builds
@@ -226,7 +252,8 @@ Supported targets:
- `linux-arm64`
- `macos-x64`
- `macos-arm64`
- `windows-x64` (Windows on ARM runs this build under emulation)
- `windows-x64`
- `windows-arm64`
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
-162
View File
@@ -1,162 +0,0 @@
# 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.
+10 -45
View File
@@ -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`, `scripts/npm/build-platform-packages.mjs`,
Go build or release tooling, or the npm `bin` wrapper.
`package.json`, `bin/zero.js`, Go build or release tooling, or the npm `bin`
wrapper.
## Required Checks
@@ -21,52 +21,17 @@ 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 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.
`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.
- 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
-1
View File
@@ -17,7 +17,6 @@ Zero.
## Maintainer Docs
- [How Zero Works](HOW_ZERO_WORKS.md)
- [npm wrapper smoke checklist](NPM_WRAPPER_SMOKE.md)
- [Benchmarks](BENCHMARK.md)
- [Performance benchmarks](PERFORMANCE.md)
+10 -9
View File
@@ -1,31 +1,32 @@
module github.com/Gitlawb/zero
go 1.26.5
go 1.25.0
toolchain go1.26.4
require (
charm.land/bubbles/v2 v2.1.1
charm.land/bubbletea/v2 v2.0.8
charm.land/lipgloss/v2 v2.0.5
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.7
charm.land/lipgloss/v2 v2.0.4
github.com/alecthomas/chroma/v2 v2.27.0
github.com/atotto/clipboard v0.1.4
github.com/aymanbagabas/go-udiff v0.4.1
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.47.0
golang.org/x/sys v0.46.0
mvdan.cc/sh/v3 v3.13.1
)
require (
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 // indirect
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dlclark/regexp2/v2 v2.5.0 // indirect
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
github.com/go-quicktest/qt v1.102.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
@@ -34,5 +35,5 @@ require (
github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sync v0.21.0 // indirect
)
+14 -16
View File
@@ -1,9 +1,9 @@
charm.land/bubbles/v2 v2.1.1 h1:7r55WzBxpo/R3z98hGmY7KKPd3ET6vsf0Fb9sDHOV60=
charm.land/bubbles/v2 v2.1.1/go.mod h1:GE6M31gaWZVXzGw73OeuTTgy4lX+OtkH0E5ymnNsHxo=
charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY=
charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY=
charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc=
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs=
charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q=
charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
@@ -16,8 +16,8 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 h1:3FmWoGNWK4STvqg0O0Aeav2T7rodWJAPeF0QpH+8gFw=
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo=
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 h1:4+r3uOJ69ueRBt4okgEfWZeXs3BD36HcDBmOIAUlETk=
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb h1:m1Uub9uM7meZ8ofVyeRfK7M/knCi3sF2gKn9kldVW0I=
@@ -32,10 +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.5.0 h1:liNiWIPCvCh5HBcYfsjd+P16AG79fwd6T1Toy2gOtEA=
github.com/dlclark/regexp2/v2 v2.5.0/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
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=
github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -62,9 +60,9 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk=
mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
+21 -54
View File
@@ -5,7 +5,6 @@ import (
"encoding/base64"
"encoding/json"
"errors"
"log"
"strings"
"sync"
@@ -160,14 +159,8 @@ func (a *Agent) handleSessionLoad(_ context.Context, params json.RawMessage) (an
// Load history BEFORE publishing the session so no concurrent prompt observes
// a half-initialized session (registerSession sets history under the lock and
// reuses an already-live session rather than orphaning its in-flight turn).
history, historyErr := a.loadHistory(meta.SessionID)
history := a.loadHistory(meta.SessionID)
sess := a.registerSession(meta.SessionID, root, history)
a.warnPersistence(
&notifier{conn: a.conn, sessionID: sess.id},
"load session history",
"Could not load session history. The session is open, but earlier turns may be missing until storage recovers.",
historyErr,
)
return LoadSessionResult{
Modes: a.modeState(sess),
}, nil
@@ -260,14 +253,7 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
if stopErr != nil {
return "", RPCError(codeInternalError, stopErr.Error())
}
if err := a.persistTurn(sess, userText, result.FinalAnswer); err != nil {
a.warnPersistence(
note,
"save session history",
"Could not save session history. This turn is available in memory, but future resume may miss it until storage recovers.",
err,
)
}
a.persistTurn(sess, userText, result.FinalAnswer)
return reason, nil
}
@@ -400,34 +386,29 @@ func (a *Agent) modeState(s *acpSession) *SessionModeState {
// ---- persistence + continuity ----
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) error {
defer sess.appendHistory(turnRecord{user: user, assistant: assistant})
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) {
if a.deps.Store != nil {
_, _ = a.deps.Store.AppendEvent(sess.id, sessions.AppendEventInput{
Type: sessions.EventMessage,
Payload: map[string]any{"role": "user", "content": user},
})
if assistant != "" {
_, _ = a.deps.Store.AppendEvent(sess.id, sessions.AppendEventInput{
Type: sessions.EventMessage,
Payload: map[string]any{"role": "assistant", "content": assistant},
})
}
}
sess.appendHistory(turnRecord{user: user, assistant: assistant})
}
func (a *Agent) loadHistory(sessionID string) []turnRecord {
if a.deps.Store == nil {
return nil
}
events := []sessions.AppendEventInput{
{
Type: sessions.EventMessage,
Payload: map[string]any{"role": "user", "content": user},
},
}
if assistant != "" {
events = append(events, sessions.AppendEventInput{
Type: sessions.EventMessage,
Payload: map[string]any{"role": "assistant", "content": assistant},
})
}
_, err := a.deps.Store.AppendEvents(sess.id, events)
return err
}
func (a *Agent) loadHistory(sessionID string) ([]turnRecord, error) {
if a.deps.Store == nil {
return nil, nil
}
events, err := a.deps.Store.ReadEvents(sessionID)
if err != nil {
return nil, err
return nil
}
var records []turnRecord
var pendingUser string
@@ -463,21 +444,7 @@ func (a *Agent) loadHistory(sessionID string) ([]turnRecord, error) {
if havePending {
records = append(records, turnRecord{user: pendingUser})
}
return records, nil
}
func (a *Agent) warnPersistence(note *notifier, action string, message string, err error) {
if err == nil {
return
}
sessionID := ""
if note != nil {
sessionID = note.sessionID
}
log.Printf("zero acp: failed to %s for session %s: %v", action, sessionID, err)
if note != nil {
note.text("\n\n[zero warning] " + message + "\n")
}
return records
}
// buildPrompt prepends prior conversation as context, since agent.Run drives a
+1 -72
View File
@@ -5,8 +5,6 @@ import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -244,77 +242,8 @@ func TestACPRejectsInvalidCwd(t *testing.T) {
}
}
func TestACPPromptWarnsWhenTurnPersistenceFails(t *testing.T) {
deps := testDeps(t)
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var newRes NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
t.Fatalf("session/new: %v", err)
}
metadataPath := filepath.Join(deps.Store.RootDir, newRes.SessionID, sessions.MetadataFile)
if err := os.Remove(metadataPath); err != nil {
t.Fatalf("remove metadata: %v", err)
}
var promptRes PromptResult
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: newRes.SessionID,
Prompt: []ContentBlock{TextBlock("hi")},
}, &promptRes); err != nil {
t.Fatalf("session/prompt: %v", err)
}
if promptRes.StopReason != StopEndTurn {
t.Fatalf("stopReason = %q, want %q", promptRes.StopReason, StopEndTurn)
}
got := drainTextUntil(t, h.updates, func(text string) bool {
return strings.Contains(text, "Hello from ZERO") &&
strings.Contains(text, "Could not save session history")
})
if !strings.Contains(got, "Could not save session history") {
t.Fatalf("streamed text = %q, want persistence warning", got)
}
}
func TestACPLoadWarnsWhenHistoryReadFails(t *testing.T) {
deps := testDeps(t)
cwd := t.TempDir()
meta, err := deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: cwd})
if err != nil {
t.Fatalf("create session: %v", err)
}
eventsPath := filepath.Join(deps.Store.RootDir, meta.SessionID, sessions.EventsFile)
if err := os.WriteFile(eventsPath, []byte("{bad json}\n"), 0o600); err != nil {
t.Fatalf("write corrupt events: %v", err)
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: meta.SessionID, Cwd: cwd, McpServers: []McpServer{}}, &LoadSessionResult{}); err != nil {
t.Fatalf("session/load: %v", err)
}
got := drainTextUntil(t, h.updates, func(text string) bool {
return strings.Contains(text, "Could not load session history")
})
if !strings.Contains(got, "Could not load session history") {
t.Fatalf("streamed text = %q, want load warning", got)
}
}
// drainText collects streamed chunks for a short window and concatenates them.
func drainText(t *testing.T, ch <-chan string) string {
t.Helper()
return drainTextUntil(t, ch, func(text string) bool {
return strings.Contains(text, "Hello from ZERO")
})
}
func drainTextUntil(t *testing.T, ch <-chan string, done func(string) bool) string {
t.Helper()
var b strings.Builder
deadline := time.After(2 * time.Second)
@@ -322,7 +251,7 @@ func drainTextUntil(t *testing.T, ch <-chan string, done func(string) bool) stri
select {
case s := <-ch:
b.WriteString(s)
if done(b.String()) {
if strings.Contains(b.String(), "Hello from ZERO") {
return b.String()
}
case <-deadline:
+2 -3
View File
@@ -3,7 +3,6 @@ package acp
import (
"encoding/json"
"strings"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
@@ -71,8 +70,8 @@ func primaryArgHint(rawArgs string) string {
func truncateHint(s string) string {
s = strings.TrimSpace(s)
const max = 60
if utf8.RuneCountInString(s) > max {
return string([]rune(s)[:max]) + "…"
if len(s) > max {
return s[:max] + "…"
}
return s
}
-34
View File
@@ -1,9 +1,7 @@
package acp
import (
"strings"
"testing"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
@@ -128,35 +126,3 @@ 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)
}
}
-20
View File
@@ -72,26 +72,6 @@ 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) != ""
-185
View File
@@ -1,185 +0,0 @@
package agent
import (
"context"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// Post-edit LSP diagnostics used to run synchronously inside edit_file /
// write_file: every edit blocked its tool result on a fresh-publish wait with
// a quiet-period debounce (≥300ms floor that reset on each publish, 10s cap) —
// the largest recurring latency tax in an edit-heavy session. Collection is
// now asynchronous: a file is enqueued the moment a mutating tool finishes, a
// single worker checks it while the rest of the tool batch (and any
// self-correct verification) runs, and the loop drains completed results just
// before building the NEXT provider request, appending errors as a user-role
// nudge. The model always issues another request after an edit turn — it has
// to read its tool results — so it still sees introduced errors at the same
// decision point as before, without any tool call stalling on the language
// server.
//
// A file re-edited before its check runs is checked once in its final state
// (the checker re-reads the file); a file re-edited after its check completed
// is re-enqueued and the newer result wins. When the worker is still busy at
// drain time, the drain gives up after a short wait and the results are
// delivered on the following turn instead — they stay accurate because any
// further edit re-enqueues the file.
// asyncDiagnosticsDrainTimeout bounds how long a turn waits on the in-flight
// check before deferring delivery to the next turn. Most checks finish well
// under it (debounce floor + analysis); a cold language server on a large
// repo does not get to stall the loop the way the old inline 10s cap could.
// A var so tests can shorten the wait.
var asyncDiagnosticsDrainTimeout = 3 * time.Second
// asyncDiagnosticsFinalDrainTimeout is the wait used when the run is about to
// FINALIZE (natural completion or the max-turns summary): there is no later
// turn to defer to, so the gate waits out the old inline-era budget rather
// than silently dropping an error the last edit introduced. A var so tests
// can shorten the wait.
var asyncDiagnosticsFinalDrainTimeout = 10 * time.Second
// asyncDiagnosticsNudge prefixes the drained diagnostics blocks; phrased like
// the old inline block so the model reacts the same way.
const asyncDiagnosticsNudge = "Diagnostics after your recent edits (fix any errors you introduced):\n"
type asyncDiagnostics struct {
check func(context.Context, string) string
workspaceRoot string
mu sync.Mutex
queue []string
queued map[string]bool
results map[string]string
// working is non-nil while the worker goroutine runs and is closed when it
// exits, so drain can wait for quiescence without polling.
working chan struct{}
}
// newAsyncDiagnostics returns nil when check is nil, and every method on a nil
// receiver is a no-op — callers wire it unconditionally.
func newAsyncDiagnostics(check func(context.Context, string) string, workspaceRoot string) *asyncDiagnostics {
if check == nil {
return nil
}
return &asyncDiagnostics{
check: check,
workspaceRoot: workspaceRoot,
queued: map[string]bool{},
results: map[string]string{},
}
}
// enqueue schedules changed files for a background check. Paths are
// workspace-relative from Result.ChangedFiles (absolute when under a granted
// extra write root), mirroring SelfCorrector's resolution.
func (diagnostics *asyncDiagnostics) enqueue(ctx context.Context, files []string) {
if diagnostics == nil || len(files) == 0 {
return
}
diagnostics.mu.Lock()
defer diagnostics.mu.Unlock()
for _, file := range files {
path := diagnostics.absPath(file)
if diagnostics.queued[path] {
continue
}
diagnostics.queued[path] = true
// A completed result for this file describes a pre-edit state now.
delete(diagnostics.results, path)
diagnostics.queue = append(diagnostics.queue, path)
}
if diagnostics.working == nil && len(diagnostics.queue) > 0 {
done := make(chan struct{})
diagnostics.working = done
go diagnostics.work(ctx, done)
}
}
func (diagnostics *asyncDiagnostics) work(ctx context.Context, done chan struct{}) {
defer close(done)
for {
diagnostics.mu.Lock()
if len(diagnostics.queue) == 0 || ctx.Err() != nil {
diagnostics.working = nil
diagnostics.mu.Unlock()
return
}
path := diagnostics.queue[0]
diagnostics.queue = diagnostics.queue[1:]
// Un-mark before checking so an edit that lands DURING the check
// re-enqueues the file and it is checked again in its final state.
delete(diagnostics.queued, path)
diagnostics.mu.Unlock()
block := diagnostics.check(ctx, path)
diagnostics.mu.Lock()
if block != "" {
diagnostics.results[path] = block
} else {
delete(diagnostics.results, path)
}
diagnostics.mu.Unlock()
}
}
// drain waits briefly for the worker to go quiet, then formats and consumes
// all completed error blocks as one nudge. Returns "" when there is nothing
// to report or the worker is still busy (results then surface next turn).
func (diagnostics *asyncDiagnostics) drain(ctx context.Context) string {
return diagnostics.drainWithin(ctx, asyncDiagnosticsDrainTimeout)
}
// drainFinal is drain with the finalization budget: called before the run's
// last model request, where an undelivered result would otherwise be lost.
func (diagnostics *asyncDiagnostics) drainFinal(ctx context.Context) string {
return diagnostics.drainWithin(ctx, asyncDiagnosticsFinalDrainTimeout)
}
func (diagnostics *asyncDiagnostics) drainWithin(ctx context.Context, timeout time.Duration) string {
if diagnostics == nil {
return ""
}
diagnostics.mu.Lock()
busy := diagnostics.working
diagnostics.mu.Unlock()
if busy != nil {
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-busy:
case <-timer.C:
return ""
case <-ctx.Done():
return ""
}
}
diagnostics.mu.Lock()
defer diagnostics.mu.Unlock()
if len(diagnostics.results) == 0 {
return ""
}
paths := make([]string, 0, len(diagnostics.results))
for path := range diagnostics.results {
paths = append(paths, path)
}
sort.Strings(paths)
blocks := make([]string, 0, len(paths))
for _, path := range paths {
blocks = append(blocks, diagnostics.results[path])
}
diagnostics.results = map[string]string{}
return asyncDiagnosticsNudge + strings.Join(blocks, "\n")
}
func (diagnostics *asyncDiagnostics) absPath(path string) string {
if filepath.IsAbs(path) {
return path
}
return filepath.Join(diagnostics.workspaceRoot, path)
}
-338
View File
@@ -1,338 +0,0 @@
package agent
import (
"context"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestAsyncDiagnosticsNilCollectorNoOps(t *testing.T) {
var diagnostics *asyncDiagnostics
if diagnostics != newAsyncDiagnostics(nil, "/ws") {
t.Fatal("nil check must yield a nil collector")
}
diagnostics.enqueue(context.Background(), []string{"a.go"})
if nudge := diagnostics.drain(context.Background()); nudge != "" {
t.Fatalf("nil collector drain = %q, want empty", nudge)
}
}
func TestAsyncDiagnosticsCollectsAndDrainsOnce(t *testing.T) {
// A real absolute root: a literal "/ws" is not filepath.IsAbs on Windows
// (no drive letter), which is exactly what the assertions below check.
root := t.TempDir()
var mu sync.Mutex
var checked []string
check := func(_ context.Context, absPath string) string {
mu.Lock()
checked = append(checked, absPath)
mu.Unlock()
if filepath.Base(absPath) == "clean.go" {
return ""
}
return "ERR " + filepath.Base(absPath)
}
diagnostics := newAsyncDiagnostics(check, root)
diagnostics.enqueue(context.Background(), []string{"broken.go", "clean.go"})
nudge := diagnostics.drain(context.Background())
if !strings.HasPrefix(nudge, asyncDiagnosticsNudge) {
t.Fatalf("nudge missing prefix: %q", nudge)
}
if !strings.Contains(nudge, "ERR broken.go") || strings.Contains(nudge, "clean.go") {
t.Fatalf("nudge = %q, want broken.go errors only", nudge)
}
mu.Lock()
for _, path := range checked {
if !filepath.IsAbs(path) || !strings.HasPrefix(path, root+string(filepath.Separator)) {
t.Fatalf("check received %q, want path under workspace root %q", path, root)
}
}
mu.Unlock()
if second := diagnostics.drain(context.Background()); second != "" {
t.Fatalf("second drain = %q, want empty (results are consumed)", second)
}
}
// A check still running at drain time defers delivery to the next drain
// instead of blocking the turn.
func TestAsyncDiagnosticsSlowCheckDefersToNextDrain(t *testing.T) {
previous := asyncDiagnosticsDrainTimeout
asyncDiagnosticsDrainTimeout = 20 * time.Millisecond
defer func() { asyncDiagnosticsDrainTimeout = previous }()
release := make(chan struct{})
check := func(_ context.Context, absPath string) string {
<-release
return "ERR " + filepath.Base(absPath)
}
diagnostics := newAsyncDiagnostics(check, "/ws")
diagnostics.enqueue(context.Background(), []string{"slow.go"})
start := time.Now()
if nudge := diagnostics.drain(context.Background()); nudge != "" {
t.Fatalf("drain during in-flight check = %q, want empty", nudge)
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Fatalf("drain blocked %v, want bounded by the (shortened) timeout", elapsed)
}
close(release)
deadline := time.Now().Add(2 * time.Second)
for {
if nudge := diagnostics.drain(context.Background()); nudge != "" {
if !strings.Contains(nudge, "ERR slow.go") {
t.Fatalf("deferred nudge = %q", nudge)
}
return
}
if time.Now().After(deadline) {
t.Fatal("deferred diagnostics never delivered")
}
time.Sleep(5 * time.Millisecond)
}
}
// Re-enqueueing a file after its check completed replaces the stale result.
func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
var mu sync.Mutex
response := "ERR old"
check := func(context.Context, string) string {
mu.Lock()
defer mu.Unlock()
return response
}
diagnostics := newAsyncDiagnostics(check, "/ws")
diagnostics.enqueue(context.Background(), []string{"a.go"})
waitForIdle(t, diagnostics)
mu.Lock()
response = "ERR new"
mu.Unlock()
diagnostics.enqueue(context.Background(), []string{"a.go"})
nudge := diagnostics.drain(context.Background())
if !strings.Contains(nudge, "ERR new") || strings.Contains(nudge, "ERR old") {
t.Fatalf("nudge = %q, want only the re-check result", nudge)
}
}
func waitForIdle(t *testing.T, diagnostics *asyncDiagnostics) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
diagnostics.mu.Lock()
busy := diagnostics.working
diagnostics.mu.Unlock()
if busy == nil {
return
}
if time.Now().After(deadline) {
t.Fatal("worker never went idle")
}
time.Sleep(2 * time.Millisecond)
}
}
// changedFilesTool is a fake mutating tool reporting a changed file without
// touching the filesystem.
type changedFilesTool struct{}
func (changedFilesTool) Name() string { return "fake_edit" }
func (changedFilesTool) Description() string { return "test mutating tool" }
func (changedFilesTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
}
func (changedFilesTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionAllow, Reason: "test"}
}
func (changedFilesTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "edited", ChangedFiles: []string{"main.go"}}
}
// End-to-end through Run: an edit turn does NOT carry diagnostics in the tool
// result (the old inline path), but the next request contains the background
// diagnostics nudge, before the model finalizes.
func TestRunDeliversAsyncDiagnosticsNudgeNextTurn(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(changedFilesTool{})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
root := t.TempDir()
var checkedPath string
var mu sync.Mutex
result, err := Run(context.Background(), "fix main.go", provider, Options{
Registry: registry,
Cwd: root,
FileDiagnostics: func(_ context.Context, absPath string) string {
mu.Lock()
checkedPath = absPath
mu.Unlock()
return "main.go:1:1 error: boom"
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
if len(provider.requests) != 2 {
t.Fatalf("expected 2 provider turns, got %d", len(provider.requests))
}
// The tool result itself must NOT block on / embed diagnostics.
for _, message := range provider.requests[1].Messages {
if message.Role == zeroruntime.MessageRoleTool && strings.Contains(message.Content, "boom") {
t.Fatalf("diagnostics leaked into the tool result: %q", message.Content)
}
}
// The nudge must be present in the second request as a user message.
found := false
for _, message := range provider.requests[1].Messages {
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
if !strings.Contains(message.Content, "boom") {
t.Fatalf("nudge missing diagnostics: %q", message.Content)
}
found = true
}
}
if !found {
t.Fatal("no async diagnostics nudge in the follow-up request")
}
mu.Lock()
defer mu.Unlock()
if want := filepath.Join(root, "main.go"); checkedPath != want {
t.Fatalf("checked path = %q, want %q", checkedPath, want)
}
}
// When the run hits the maxTurns ceiling right after an edit turn, the
// final-answer request must still carry the diagnostics nudge — otherwise an
// error introduced by the last edit would go unreported in the summary.
func TestRunDrainsAsyncDiagnosticsBeforeMaxTurnsFinalAnswer(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(changedFilesTool{})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "summary"},
{Type: zeroruntime.StreamEventDone},
},
},
}
result, err := Run(context.Background(), "fix main.go", provider, Options{
Registry: registry,
Cwd: t.TempDir(),
MaxTurns: 1,
FileDiagnostics: func(context.Context, string) string {
return "main.go:1:1 error: boom"
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "summary" {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
if len(provider.requests) != 2 {
t.Fatalf("expected the max-turns final-answer request, got %d requests", len(provider.requests))
}
found := false
for _, message := range provider.requests[1].Messages {
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
found = true
}
}
if !found {
t.Fatal("max-turns final-answer request missing the diagnostics nudge")
}
}
// When the model finalizes while a slow check is still in flight (the
// per-turn drain missed it), the finalization gate must wait it out and give
// the model one more turn with the nudge instead of dropping the errors.
func TestRunFinalizationGateDeliversLateDiagnostics(t *testing.T) {
previous := asyncDiagnosticsDrainTimeout
asyncDiagnosticsDrainTimeout = time.Millisecond
defer func() { asyncDiagnosticsDrainTimeout = previous }()
registry := tools.NewRegistry()
registry.Register(changedFilesTool{})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done after fix"},
{Type: zeroruntime.StreamEventDone},
},
},
}
result, err := Run(context.Background(), "fix main.go", provider, Options{
Registry: registry,
Cwd: t.TempDir(),
FileDiagnostics: func(context.Context, string) string {
// Slower than the (shortened) per-turn drain, well under the
// finalization budget.
time.Sleep(100 * time.Millisecond)
return "main.go:1:1 error: boom"
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done after fix" {
t.Fatalf("final answer = %q, want the post-nudge answer", result.FinalAnswer)
}
if len(provider.requests) != 3 {
t.Fatalf("expected a third request carrying the late nudge, got %d requests", len(provider.requests))
}
found := false
for _, message := range provider.requests[2].Messages {
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
if !strings.Contains(message.Content, "boom") {
t.Fatalf("nudge missing diagnostics: %q", message.Content)
}
found = true
}
}
if !found {
t.Fatal("finalization gate did not deliver the late diagnostics nudge")
}
}
+16 -33
View File
@@ -2,7 +2,6 @@ package agent
import (
"fmt"
"runtime"
"strings"
"github.com/Gitlawb/zero/internal/sandbox"
@@ -28,20 +27,10 @@ func proposedCommandPrefix(toolName string, args map[string]any) []string {
}
return nil
}
// Only propose approving a prefix of one segment when every other segment
// in the command is independently known-safe. Once a prefix is approved,
// shellExecutionArgsForApproval escalates the whole command (every
// segment, not just the approved one) to bypass the sandbox, so offering a
// prefix that leaves an MSYS-prone (or otherwise unsafe) segment uncovered
// would let that segment run unsandboxed without ever being reviewed.
for index, tokens := range segments {
if knownSafeCommandSegment(tokens) {
continue
for _, tokens := range segments {
if !knownSafeCommandSegment(tokens) && sandbox.ValidCommandPrefix(tokens) {
return append([]string(nil), tokens...)
}
if !sandbox.ValidCommandPrefix(tokens) || !otherSegmentsKnownSafe(segments, index) {
return nil
}
return append([]string(nil), tokens...)
}
if len(segments) == 0 || !sandbox.ValidCommandPrefix(segments[0]) {
return nil
@@ -49,20 +38,6 @@ func proposedCommandPrefix(toolName string, args map[string]any) []string {
return append([]string(nil), segments[0]...)
}
// otherSegmentsKnownSafe reports whether every segment other than the one at
// skip is known-safe on its own.
func otherSegmentsKnownSafe(segments [][]string, skip int) bool {
for index, tokens := range segments {
if index == skip {
continue
}
if !knownSafeCommandSegment(tokens) {
return false
}
}
return true
}
func matchCommandPrefix(toolName string, args map[string]any, options Options) (sandbox.CommandPrefixGrant, bool, bool) {
if !isShellCommandTool(toolName) || options.Sandbox == nil {
return sandbox.CommandPrefixGrant{}, false, false
@@ -193,6 +168,10 @@ func cleanPrefixRule(prefix []string) []string {
return cleaned
}
func safeRequestedPrefix(prefix []string, command []string) bool {
return safeRequestedPrefixForSegments(prefix, [][]string{command})
}
func safeRequestedPrefixForSegments(prefix []string, segments [][]string) bool {
if len(prefix) == 0 || !sandbox.ValidCommandPrefix(prefix) {
return false
@@ -217,6 +196,14 @@ func safeRequestedPrefixForSegments(prefix []string, segments [][]string) bool {
return matched
}
func safeShellCommandTokens(command string) ([]string, bool) {
segments, ok := safeShellCommandSegments(command)
if !ok || len(segments) != 1 {
return nil, false
}
return segments[0], true
}
func safeShellCommandSegments(command string) ([][]string, bool) {
command = strings.TrimSpace(command)
if command == "" {
@@ -288,11 +275,7 @@ func knownSafeCommandSegment(command []string) bool {
if len(command) == 0 {
return false
}
name := commandName(command[0])
if runtime.GOOS == "windows" && tools.MsysProneCommandName(name) {
return false
}
switch name {
switch commandName(command[0]) {
case "cat", "cd", "cut", "echo", "expr", "false", "grep", "head", "id",
"ls", "nl", "paste", "pwd", "rev", "seq", "stat", "tail", "tr",
"true", "uname", "uniq", "wc", "which", "whoami":
+1 -50
View File
@@ -1,11 +1,9 @@
package agent
import (
"runtime"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
@@ -37,37 +35,12 @@ func TestProposedCommandPrefixHonorsValidatedRequestedPrefix(t *testing.T) {
func TestProposedCommandPrefixSupportsSegmentedCommands(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"})
if runtime.GOOS == "windows" {
// head is MSYS-prone on Windows (#458), so proposedCommandPrefix must
// not offer "ps aux" as a reusable prefix here: approving it would
// escalate the whole command, including the uncovered head segment,
// to bypass the sandbox unreviewed. See
// TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered for
// the platform-independent regression coverage of this behavior.
if got != nil {
t.Fatalf("expected no prefix on Windows because head is MSYS-prone, got %#v", got)
}
return
}
want := []string{"ps", "aux"}
if !equalStringSlices(got, want) {
t.Fatalf("prefix = %#v, want %#v", got, want)
}
}
// TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered guards
// against proposedCommandPrefix offering to approve one segment of a
// multi-segment command (e.g. "ps aux") while a different segment (e.g. "npm
// install") is not known-safe. shellExecutionArgsForApproval escalates the
// entire command once any prefix is approved, so an uncovered unsafe segment
// would bypass the sandbox unreviewed. Uses npm, which is never known-safe on
// any platform, so the assertion does not depend on runtime.GOOS.
func TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered(t *testing.T) {
if got := proposedCommandPrefix("bash", map[string]any{"command": "ps aux && npm install"}); got != nil {
t.Fatalf("expected no prefix because npm segment is not known-safe, got %#v", got)
}
}
func TestProposedCommandPrefixHonorsRequestedPrefixAcrossSegments(t *testing.T) {
got := proposedCommandPrefix("bash", map[string]any{
"command": "git status --short && git status --branch",
@@ -147,35 +120,13 @@ func TestProposedCommandPrefixRejectsUnsafeLaunchers(t *testing.T) {
func TestMatchCommandPrefixCoversSegmentedCommandWithSafeTail(t *testing.T) {
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
// head is MSYS-prone on Windows (#458) and must not count as a known-safe tail.
command := "ps aux | head -5"
if runtime.GOOS == "windows" {
command = "ps aux | echo ok"
}
grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": command}, Options{Sandbox: engine})
grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"}, Options{Sandbox: engine})
if !ok || !session || !equalStringSlices(grant.Prefix, []string{"ps", "aux"}) {
t.Fatalf("match = %#v ok=%v session=%v, want session ps aux prefix", grant, ok, session)
}
}
func TestKnownSafeCommandSegmentRejectsMsysProneOnWindows(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("windows-only known-safe MSYS guard")
}
for _, command := range [][]string{{"head", "-5"}, {"cat", "file.txt"}, {"grep", "pat"}} {
if knownSafeCommandSegment(command) {
t.Fatalf("expected %q to be unsafe on Windows, got known-safe", command)
}
}
if !knownSafeCommandSegment([]string{"echo", "ok"}) {
t.Fatal("expected echo to remain known-safe on Windows")
}
if !tools.MsysProneCommandName("head") {
t.Fatal("expected head to be MSYS-prone")
}
}
func TestMatchCommandPrefixRejectsUncoveredSegment(t *testing.T) {
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
+1 -12
View File
@@ -6,7 +6,6 @@ import (
"errors"
"strings"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -431,12 +430,6 @@ func (state *compactionState) maybeCompact(
state.lowWaterMark = size
return messages
}
// Only count a compaction when it actually shrank the history, so the
// compaction counter reflects real context reductions rather than paid
// no-ops that left the token budget untouched.
if r := trace.FromContext(ctx); r != nil {
r.Counter(trace.CounterCompactionCount, 1)
}
state.lowWaterMark = newSize
return compacted
}
@@ -486,11 +479,7 @@ func (state *compactionState) recover(
// one-shot budget now so a provider that keeps returning context-limit errors
// after a successful compaction can't loop forever. Store the low-water mark in
// the SAME combined (messages + tool-defs) domain maybeCompact uses, so the
// proactive shrink-guard compares like with like. Count it now that the shrink
// is confirmed, so the counter mirrors maybeCompact's real-reduction policy.
if r := trace.FromContext(ctx); r != nil {
r.Counter(trace.CounterCompactionCount, 1)
}
// proactive shrink-guard compares like with like.
state.reactiveAttempted = true
state.lowWaterMark = state.calibratedTokens(estimateTokens(result) + estimateToolDefTokens(tools))
return result, true, nil
+18 -95
View File
@@ -183,112 +183,35 @@ var successNegationTails = []string{
"reproduce", "spot any", "locate any",
}
// 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)
}
}
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
lower := strings.ToLower(text)
for _, phrase := range selfReportPhrases {
if strings.Contains(lower, phrase) {
return selfReportReason(phrase)
}
for _, phrase := range selfReportPhrases {
if strings.Contains(sentence, phrase) {
return selfReportReason(phrase)
}
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
}
}
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)
abs := start + rel
tail := strings.TrimSpace(lower[abs+len(stem):])
if !hasAnyPrefix(tail, successNegationTails) {
return selfReportReason(strings.TrimSpace(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 + `")`
}
+15 -194
View File
@@ -15,7 +15,6 @@ import (
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/streamjson"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -110,31 +109,11 @@ const escalationFailedNoticePrefix = "Note: could not switch to the requested mo
// The system prompt (core coding-craft instructions + workspace context + safety
// confirmation policy) is assembled in system_prompt.go via buildSystemPrompt.
func Run(ctx context.Context, prompt string, provider Provider, options Options) (result Result, err error) {
func Run(ctx context.Context, prompt string, provider Provider, options Options) (Result, error) {
if provider == nil {
return Result{}, errors.New("agent provider is required")
}
// Tracing is opt-in. When a recorder is wired, thread it into ctx so the
// providerio seam and reconnect helper can reach it via trace.FromContext,
// mark the run's start, and wrap OnUsage so token counters accumulate for
// free alongside the existing per-request plumbing. nil recorder leaves the
// loop byte-identical: FromContext returns nil, the no-op stamps cost
// nothing, and the OnUsage wrapper is not installed.
if options.Trace != nil {
ctx = trace.WithContext(ctx, options.Trace)
options.Trace.Start()
originalOnUsage := options.OnUsage
options.OnUsage = func(usage Usage) {
if originalOnUsage != nil {
originalOnUsage(usage)
}
options.Trace.Counter(trace.CounterInputTokens, int64(usage.InputTokens))
options.Trace.Counter(trace.CounterCachedInputTokens, int64(usage.CachedInputTokens))
options.Trace.Counter(trace.CounterOutputTokens, int64(usage.OutputTokens))
}
}
maxTurns := options.MaxTurns
if maxTurns <= 0 {
maxTurns = 12
@@ -158,12 +137,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
guards := newGuardState()
compactor := newCompactionState(options)
// Background post-edit diagnostics: files changed by mutating tools are
// checked off the tool-call critical path and any errors are appended as a
// nudge before the NEXT request (see async_diagnostics.go). nil when no
// FileDiagnostics callback is wired; every method no-ops on nil.
postEditDiagnostics := newAsyncDiagnostics(options.FileDiagnostics, options.Cwd)
// loaded tracks deferred-eligible tools the model has pulled via tool_search
// during THIS run. It is consulted by partitionTools each turn to expose a
// loaded tool's full schema; it lives only for the run (v1 within-run scope).
@@ -182,43 +155,20 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// doesn't re-run the recursive schema→map conversion for every tool every turn.
toolDefCache := map[string]zeroruntime.ToolDefinition{}
result = Result{Messages: copyMessages(messages)}
dispatchSessionStart(ctx, options)
defer func() {
dispatchSessionEnd(ctx, options, result, err)
}()
result := Result{Messages: copyMessages(messages)}
for turn := 0; turn < maxTurns; turn++ {
result.Turns = turn + 1
// Deliver background post-edit diagnostics from the previous turn's edits
// BEFORE compaction so the nudge is part of the request being budgeted.
// A brief wait at most (asyncDiagnosticsDrainTimeout); an unfinished check
// simply delivers on a later turn.
if nudge := postEditDiagnostics.drain(ctx); nudge != "" {
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: nudge,
})
}
// Build the per-turn tool list first so proactive compaction can include
// the tool-definition tokens (they ride on every request) in its estimate.
// partitionTools depends only on registry/permissions/options/loaded, not on
// the messages, so computing it before compaction is safe.
// 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.
toolPartitionSpan := options.Trace.Span(trace.SpanToolPartition)
exposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
toolPartitionSpan.End()
// PROACTIVE compaction: if the history is approaching the model's
// context window, summarize the oldest middle before building the
// request. A no-op when ContextWindow == 0 (compaction disabled).
compactionSpan := options.Trace.Span(trace.SpanCompaction)
messages = compactor.maybeCompact(ctx, provider, messages, exposed)
compactionSpan.End()
request := zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: exposed,
@@ -284,39 +234,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// conversation-state duplication.
forwardedVisibleText := false
forwardingOpts := zeroruntime.CollectOptions{OnUsage: options.OnUsage}
// Install text/reasoning forwarding handlers whenever EITHER a user
// callback OR a trace recorder is set. A headless traced run (e.g. `zero
// exec --trace`) sets Trace but no OnText/OnReasoning; without these
// handlers the stream is still collected, but FirstTokenAt would never
// stamp and the trace would lose its TTFT signal. The trace recorder's
// stamp methods are nil-safe, but we guard on `trace != nil` so a run with
// a user callback but no recorder still works. forwardedVisibleText stays
// tied to the USER callback only, preserving the stall-retry semantics
// (a trace-only handler forwards nothing the user would see duplicated).
rec := options.Trace
onText := options.OnText
if onText != nil || rec != nil {
forwardingOpts.OnText = func(s string) {
if rec != nil {
rec.StampFirstVisibleEvent()
rec.StampFirstToken()
}
if onText != nil {
forwardedVisibleText = true
onText(s)
}
}
if options.OnText != nil {
forwardingOpts.OnText = func(s string) { forwardedVisibleText = true; options.OnText(s) }
}
onReasoning := options.OnReasoning
if onReasoning != nil || rec != nil {
forwardingOpts.OnReasoning = func(s string) {
if rec != nil {
rec.StampFirstToken()
}
if onReasoning != nil {
onReasoning(s)
}
}
if options.OnReasoning != nil {
forwardingOpts.OnReasoning = func(s string) { options.OnReasoning(s) }
}
if options.OnToolCallStart != nil {
forwardingOpts.OnToolCallStart = func(id, name string) { options.OnToolCallStart(id, name) }
@@ -354,18 +276,14 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
if retryStreamErr != nil {
return collected, retryStreamErr
}
genSpan := options.Trace.Span(trace.SpanGeneration)
collected = zeroruntime.CollectStreamWithOptions(ctx, retryStream, zeroruntime.CollectOptions{
OnUsage: options.OnUsage,
})
genSpan.End()
}
return collected, nil
}
generationSpan := options.Trace.Span(trace.SpanGeneration)
collected := zeroruntime.CollectStreamWithOptions(ctx, stream, forwardingOpts)
generationSpan.End()
if collected.Error != "" {
updated, stop := recoverStreamError(collected)
collected = updated
@@ -418,9 +336,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
result.Messages = copyMessages(messages)
return result, retryErr
}
stallGenSpan := options.Trace.Span(trace.SpanGeneration)
collected = zeroruntime.CollectStreamWithOptions(ctx, retryStream, forwardingOpts)
stallGenSpan.End()
}
if collected.Error != "" {
// Route a reissued stream's non-stall error through the SAME recovery as
@@ -522,7 +438,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
if cue || planPending {
if continueNudges < maxContinueNudges {
continueNudges++
options.Trace.Counter(trace.CounterCompletionNudges, 1)
reason := "your message ended mid-step"
if !cue {
reason = "pending plan items remain — finish them, or mark them complete with update_plan if you are done"
@@ -552,7 +467,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// (no admission, no cue) then finalizes as success on the next turn.
if options.SelfCorrect != nil && !acceptanceRequested {
acceptanceRequested = true
options.Trace.Counter(trace.CounterAcceptanceChecks, 1)
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: acceptanceVerificationNudge(),
@@ -560,19 +474,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
continue
}
}
// Finalization diagnostics gate: edits from this run may still have
// checks in flight — the per-turn drain waits only briefly and defers
// to "a later turn", but a final answer means there is no later turn.
// Wait out the full inline-era budget once; an introduced error gives
// the model one more turn to see (and fix) it instead of being lost.
// Free for runs that never edited: an idle collector returns "".
if nudge := postEditDiagnostics.drainFinal(ctx); nudge != "" {
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: nudge,
})
continue
}
result.FinalAnswer = collected.Text
result.Messages = copyMessages(messages)
return result, nil
@@ -613,26 +514,20 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
runEnd++
}
if runEnd-index >= 2 {
batchSpan := options.Trace.Span(trace.SpanToolExecution)
precomputed = executeParallelReadBatch(ctx, registry, collected.ToolCalls, index, runEnd, permissionMode, options)
batchSpan.End()
precomputedStart, precomputedEnd = index, runEnd
}
}
if options.OnToolCall != nil {
options.OnToolCall(call)
}
options.Trace.StampFirstUsefulAction()
var toolResult ToolResult
var abortErr error
if index >= precomputedStart && index < precomputedEnd {
toolResult, abortErr = precomputed[index-precomputedStart].result, precomputed[index-precomputedStart].abortErr
} else {
toolSpan := options.Trace.Span(trace.SpanToolExecution)
toolResult, abortErr = executeToolCall(ctx, registry, call, permissionMode, options)
toolSpan.End()
}
options.Trace.Counter(trace.CounterToolCalls, 1)
if options.OnToolResult != nil {
options.OnToolResult(toolResult)
}
@@ -695,14 +590,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
failureHint = toolFailureHint(call.Name, toolSchemaJSON(registry, call.Name), toolResult.Output)
}
// Collect the files this successful mutating tool changed. Self-correct
// verification runs once over the union after the batch; background
// diagnostics start NOW so the language server analyzes while the rest
// of the batch executes. A read-only tool (no ChangedFiles) never
// contributes.
if toolResult.Status == tools.StatusOK && len(toolResult.ChangedFiles) > 0 {
// Post-edit self-correction: collect the files this successful mutating
// tool changed; verification runs once over the union after the batch.
// A read-only tool (no ChangedFiles) never contributes.
if options.SelfCorrect != nil && toolResult.Status == tools.StatusOK && len(toolResult.ChangedFiles) > 0 {
changedFilesThisBatch = append(changedFilesThisBatch, toolResult.ChangedFiles...)
postEditDiagnostics.enqueue(ctx, toolResult.ChangedFiles)
}
}
@@ -737,7 +629,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// context-window sizing, and usage attribution follow the new model.
provider = newProvider
options.Model = turnRequestedModel
options.Trace.Counter(trace.CounterModelSwitches, 1)
// KNOWN LIMITATION (deferred): the compactor's context-window budget
// is fixed at run start from options.ContextWindow and is NOT updated
// here, so a switch to a model with a different window keeps compacting
@@ -786,19 +677,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// mid-run, not a natural completion (a finished run returns via the no-tool-call
// success path before this). Under the headless completion gate that is INCOMPLETE,
// not success, so a run that loops to the turn limit isn't reported as done.
//
// The final-answer call is one more model request, so diagnostics from the
// LAST turn's edits get a drain too — with the finalization budget, since
// there is no later turn to defer to — otherwise an error introduced by
// the final edit would go unreported in the summary.
if nudge := postEditDiagnostics.drainFinal(ctx); nudge != "" {
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: nudge,
})
}
finalExposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, finalExposed, options); strings.TrimSpace(answer) != "" {
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, options); strings.TrimSpace(answer) != "" {
result.FinalAnswer = answer
result.FinishReason = finishReason
result.Messages = copyMessages(finalMessages)
@@ -818,7 +697,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
return result, nil
}
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, options Options) (string, []zeroruntime.Message, string) {
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages []zeroruntime.Message, options Options) (string, []zeroruntime.Message, string) {
finalMessages := copyMessages(messages)
finalMessages = append(finalMessages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
@@ -829,14 +708,12 @@ 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, ""
}
finalGenSpan := options.Trace.Span(trace.SpanGeneration)
collected := zeroruntime.CollectStreamWithOptions(ctx, stream, zeroruntime.CollectOptions{
OnText: options.OnText,
OnReasoning: options.OnReasoning,
@@ -844,7 +721,6 @@ func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages [
OnToolCallStart: options.OnToolCallStart,
OnToolCallDelta: options.OnToolCallDelta,
})
finalGenSpan.End()
if ctx.Err() != nil || collected.Error != "" || strings.TrimSpace(collected.Text) == "" {
return "", messages, ""
}
@@ -1224,12 +1100,8 @@ 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.
// Inline post-edit diagnostics for mutating tools (nil = disabled).
Diagnostics: options.FileDiagnostics,
// 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,
@@ -1648,54 +1520,6 @@ func dispatchAfterTool(ctx context.Context, options Options, call ToolCall, args
return strings.TrimSpace(strings.Join(outcome.Messages, "\n"))
}
// dispatchSessionStart runs configured sessionStart hooks once before the first
// model turn. Lifecycle hooks are advisory: dispatcher failures are audited but
// never block the run.
func dispatchSessionStart(ctx context.Context, options Options) {
if options.Hooks == nil {
return
}
options.Hooks.Dispatch(ctx, hooks.DispatchInput{
Event: hooks.EventSessionStart,
Payload: map[string]any{
"event": string(hooks.EventSessionStart),
"sessionId": options.SessionID,
"cwd": options.Cwd,
"provider": options.ProviderName,
"model": options.Model,
"depth": options.Depth,
"tag": options.Tag,
"sessionTitle": options.SessionTitle,
},
})
}
// dispatchSessionEnd runs configured sessionEnd hooks once when the agent run
// exits, including early error returns. Lifecycle hooks are advisory.
func dispatchSessionEnd(ctx context.Context, options Options, result Result, runErr error) {
if options.Hooks == nil {
return
}
payload := map[string]any{
"event": string(hooks.EventSessionEnd),
"sessionId": options.SessionID,
"cwd": options.Cwd,
"provider": options.ProviderName,
"model": options.Model,
"turns": result.Turns,
"stopReason": string(result.StopReason),
"finishReason": result.FinishReason,
"incomplete": result.Incomplete,
}
if runErr != nil {
payload["error"] = runErr.Error()
}
options.Hooks.Dispatch(ctx, hooks.DispatchInput{
Event: hooks.EventSessionEnd,
Payload: payload,
})
}
// blockedByHookResult is the tool result for a call vetoed by a beforeTool hook.
func blockedByHookResult(call ToolCall, outcome hooks.DispatchOutcome) ToolResult {
// The hook reason (its stdout/stderr) is model-visible here, an intercepted
@@ -1948,10 +1772,7 @@ func requestPermission(ctx context.Context, request PermissionRequest, options O
if options.OnPermissionRequest == nil {
return PermissionDecision{Action: PermissionDecisionDeny, Reason: request.Reason}, nil
}
permSpan := options.Trace.Span(trace.SpanPermissionWait)
decision, err := options.OnPermissionRequest(ctx, request)
permSpan.End()
return decision, err
return options.OnPermissionRequest(ctx, request)
}
func normalizePermissionDecisionAction(action PermissionDecisionAction) PermissionDecisionAction {
+6 -168
View File
@@ -4,18 +4,15 @@ 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"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -40,59 +37,6 @@ func (provider *mockProvider) StreamCompletion(ctx context.Context, request zero
return ch, nil
}
func TestRunDispatchesSessionLifecycleHooks(t *testing.T) {
audit, err := hooks.NewAuditStore(hooks.AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
if err != nil {
t.Fatalf("NewAuditStore: %v", err)
}
dispatcher := hooks.NewDispatcher(hooks.DispatcherOptions{
Config: hooks.Config{
Enabled: true,
Hooks: []hooks.Definition{
{ID: "zero.session-start", Event: hooks.EventSessionStart, Command: "zero-missing-hook-command", Enabled: true},
{ID: "zero.session-end", Event: hooks.EventSessionEnd, Command: "zero-missing-hook-command", Enabled: true},
},
},
Audit: audit,
})
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
}}}
_, err = Run(context.Background(), "hi", provider, Options{
SessionID: "session-123",
Cwd: t.TempDir(),
ProviderName: "test-provider",
Model: "test-model",
Hooks: dispatcher,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
events, err := audit.ReadEvents()
if err != nil {
t.Fatalf("ReadEvents: %v", err)
}
started := map[hooks.Event]int{}
completed := map[hooks.Event]int{}
for _, event := range events {
switch event.Type {
case "hook_execution_started":
started[event.Event]++
case "hook_execution_completed":
completed[event.Event]++
}
}
if started[hooks.EventSessionStart] != 1 || completed[hooks.EventSessionStart] != 1 {
t.Fatalf("sessionStart audit counts started/completed = %d/%d, events=%#v", started[hooks.EventSessionStart], completed[hooks.EventSessionStart], events)
}
if started[hooks.EventSessionEnd] != 1 || completed[hooks.EventSessionEnd] != 1 {
t.Fatalf("sessionEnd audit counts started/completed = %d/%d, events=%#v", started[hooks.EventSessionEnd], completed[hooks.EventSessionEnd], events)
}
}
type recordingWebSearchTool struct {
calls []map[string]any
}
@@ -2001,11 +1945,6 @@ func TestRunCommandPrefixApprovalBypassesSandboxForMatchingShellCalls(t *testing
func TestRunCommandPrefixApprovalCoversSegmentedShellWithSafeTail(t *testing.T) {
root := t.TempDir()
segmentedCommand := `ps aux | head -5`
if runtime.GOOS == "windows" {
// head is MSYS-prone on Windows (#458) and no longer counts as a known-safe tail.
segmentedCommand = `ps aux | echo ok`
}
retryTool := &sandboxDeniedRetryTool{}
registry := tools.NewRegistry()
registry.Register(retryTool)
@@ -2019,7 +1958,7 @@ func TestRunCommandPrefixApprovalCoversSegmentedShellWithSafeTail(t *testing.T)
},
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "bash"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: fmt.Sprintf(`{"command":%q}`, segmentedCommand)},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"command":"ps aux | head -5"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
{Type: zeroruntime.StreamEventDone},
},
@@ -2823,11 +2762,11 @@ func TestRunRequestsFinalAnswerAfterMaxTurns(t *testing.T) {
t.Fatalf("expected final answer from finalization turn, got %q", result.FinalAnswer)
}
if len(provider.requests) != 2 {
t.Fatalf("expected finalization request after max turns, got %d requests", len(provider.requests))
t.Fatalf("expected final no-tools request after max turns, got %d requests", len(provider.requests))
}
finalRequest := provider.requests[1]
if toolDefinitionByName(finalRequest.Tools, "read_file") == nil {
t.Fatalf("finalization request must keep prior tools exposed for strict provider replay, got %#v", finalRequest.Tools)
if len(finalRequest.Tools) != 0 {
t.Fatalf("finalization request must not advertise tools, got %#v", finalRequest.Tools)
}
lastMessage := finalRequest.Messages[len(finalRequest.Messages)-1]
if lastMessage.Role != zeroruntime.MessageRoleUser || !strings.Contains(lastMessage.Content, "tool-turn limit") {
@@ -2948,24 +2887,6 @@ 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 {
@@ -2986,21 +2907,8 @@ func TestBuildSystemPromptInjectsHostShellContext(t *testing.T) {
t.Fatalf("expected operating system in environment block, got %q", prompt)
}
if runtime.GOOS == "windows" {
for _, want := range []string{"Windows cmd.exe syntax", "cwd argument", "MSYS binaries", "grep", "require_escalated", "use double quotes around the value"} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected Windows shell guidance to mention %q, got %q", want, prompt)
}
}
// The examples must themselves use the safe (double-quoted) form; a
// single-quoted example here would teach the model the exact syntax
// that fails under cmd.exe.
for _, want := range []string{`--jq ".a | b"`, `-run "A|B"`} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected double-quoted example %q in Windows shell guidance, got %q", want, prompt)
}
}
if strings.Contains(prompt, `'.a | b'`) || strings.Contains(prompt, `'A|B'`) {
t.Fatalf("Windows shell guidance must not show the unsafe single-quoted form, got %q", prompt)
if !strings.Contains(prompt, "Windows cmd.exe syntax") || !strings.Contains(prompt, "cwd argument") {
t.Fatalf("expected Windows cmd.exe shell guidance in prompt, got %q", prompt)
}
} else if !strings.Contains(prompt, "/bin/sh syntax") {
t.Fatalf("expected POSIX shell guidance in prompt, got %q", prompt)
@@ -3462,73 +3370,3 @@ func TestRunDoesNotFlagCleanToolOutput(t *testing.T) {
t.Errorf("clean output should not get a reminder, got %q", captured.Output)
}
}
// TestRunTracingWrapperStampsUsage verifies the per-turn tracing setup in Run:
// a wired recorder is Started, OnUsage is wrapped so it still forwards to the
// caller's callback AND stamps token counters, and the run completes.
func TestRunTracingWrapperStampsUsage(t *testing.T) {
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 100, CachedInputTokens: 20, OutputTokens: 40}},
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
}}}
onUsageCalls := 0
rec := trace.NewRecorder("tracing-session", "run-1", "test")
if _, err := Run(context.Background(), "hi", provider, Options{
SessionID: "tracing-session",
Cwd: t.TempDir(),
ProviderName: "test-provider",
Model: "test-model",
Trace: rec,
OnUsage: func(Usage) { onUsageCalls++ },
}); err != nil {
t.Fatalf("Run: %v", err)
}
tr := rec.Finish()
if tr.StartedAt.IsZero() {
t.Fatal("tracing wrapper did not Start the recorder")
}
// FirstTokenAt must stamp even though no OnText/OnReasoning user callback is
// set: a headless traced run (e.g. `zero exec --trace`) sets Trace but no UI
// callbacks, so the loop installs trace-only forwarding handlers to capture
// TTFT. Without them FirstTokenAt stays zero and the trace loses its signal.
if tr.FirstTokenAt.IsZero() {
t.Fatal("FirstTokenAt not stamped for a traced run with no OnText/OnReasoning callbacks")
}
if got := tr.Counter(trace.CounterInputTokens); got != 100 {
t.Fatalf("input token counter = %d, want 100", got)
}
if got := tr.Counter(trace.CounterCachedInputTokens); got != 20 {
t.Fatalf("cached input token counter = %d, want 20", got)
}
if got := tr.Counter(trace.CounterOutputTokens); got != 40 {
t.Fatalf("output token counter = %d, want 40", got)
}
if onUsageCalls == 0 {
t.Fatal("wrapped OnUsage did not forward to the caller's callback")
}
}
// TestRunNilTraceForwardsUsage verifies a nil recorder leaves the loop
// byte-identical: OnUsage is not wrapped, so the caller's callback still fires
// and nothing panics.
func TestRunNilTraceForwardsUsage(t *testing.T) {
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 7}},
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
}}}
onUsageCalls := 0
if _, err := Run(context.Background(), "hi", provider, Options{
SessionID: "nil-trace-session",
Cwd: t.TempDir(),
ProviderName: "test-provider",
Model: "test-model",
OnUsage: func(Usage) { onUsageCalls++ },
}); err != nil {
t.Fatalf("Run: %v", err)
}
if onUsageCalls == 0 {
t.Fatal("OnUsage not forwarded when Trace is nil")
}
}
@@ -34,7 +34,7 @@ func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
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 {
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.
-11
View File
@@ -8,7 +8,6 @@ import (
"time"
"github.com/Gitlawb/zero/internal/errhint"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -75,21 +74,14 @@ func stallRetryNoticeFor(options Options) reconnectNotifier {
// already past its deadline is returned immediately (no retry) — those have
// their own handling (compaction for context-limit, image-rejection, etc.).
func streamWithReconnect(ctx context.Context, provider Provider, request zeroruntime.CompletionRequest, notify reconnectNotifier) (<-chan zeroruntime.StreamEvent, error) {
recorder := trace.FromContext(ctx)
stream, err := provider.StreamCompletion(ctx, request)
if err == nil {
if recorder != nil {
recorder.Counter(trace.CounterModelRequests, 1)
}
return stream, nil
}
for attempt := 1; attempt <= maxStreamReconnects; attempt++ {
if !shouldReconnect(ctx, err) {
return nil, err
}
if recorder != nil {
recorder.Counter(trace.CounterReconnectCount, 1)
}
if notify != nil {
notify(attempt, maxStreamReconnects)
}
@@ -98,9 +90,6 @@ func streamWithReconnect(ctx context.Context, provider Provider, request zerorun
}
stream, err = provider.StreamCompletion(ctx, request)
if err == nil {
if recorder != nil {
recorder.Counter(trace.CounterModelRequests, 1)
}
return stream, nil
}
}
-3
View File
@@ -9,7 +9,6 @@ import (
"github.com/Gitlawb/zero/internal/lsp"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/verify"
)
@@ -134,9 +133,7 @@ func (sc *SelfCorrector) inspect(ctx context.Context, changedFiles []string) Cor
}
if sc.cfg.IncludeTests && sc.verifier != nil {
verifySpan := trace.FromContext(ctx).Span(trace.SpanVerification)
vr, err := sc.verifier.Verify(ctx)
verifySpan.End()
if err != nil {
// "No plan / no tests" is not an error (DetectPlan returns an empty
// plan), so a non-nil error means verification could not run at all —
+9 -13
View File
@@ -286,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. To put | & > < etc inside an arg value, use double quotes around the value, not single quotes (single quotes do not protect metachars in cmd.exe): gh --jq \".a | b\", go test -run \"A|B\". Do not pipe to or invoke POSIX coreutils from Git for Windows (usr\\bin head/grep/tail/cat/...): they are MSYS binaries and fail under the write-restricted sandbox; use native Zero tools (grep, read_file, list_directory, glob) or cmd.exe findstr/more instead, or sandbox_permissions require_escalated only when host-level execution is truly required. Prefer the workdir/cwd argument over cd when changing directories.\n")
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
} else {
b.WriteString("Shell syntax: /bin/sh syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
}
@@ -295,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)
}
@@ -542,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)
@@ -563,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 {
@@ -619,11 +619,7 @@ 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 {
gitRoot := FindProjectGitRoot(cwd)
if gitRoot == "" {
return ""
}
gitPath := filepath.Join(gitRoot, ".git")
gitPath := filepath.Join(cwd, ".git")
info, err := os.Stat(gitPath)
if err != nil {
return ""
@@ -639,10 +635,10 @@ func gitBranchForPrompt(cwd string) string {
return ""
}
// In worktree mode the gitdir is often RELATIVE (e.g.
// "gitdir: ../.git/worktrees/<name>") — resolve it against the worktree root (gitRoot), not the
// "gitdir: ../.git/worktrees/<name>") — resolve it against cwd, not the
// process working directory, or HEAD lookup fails and we drop the branch.
if !filepath.IsAbs(dir) {
dir = filepath.Join(gitRoot, dir)
dir = filepath.Join(cwd, dir)
}
headPath = filepath.Join(dir, "HEAD")
}
+2 -2
View File
@@ -424,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)
}
}
+5 -18
View File
@@ -7,7 +7,6 @@ import (
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/streamjson"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -280,29 +279,17 @@ type Options struct {
// request), so every existing caller is unaffected. A returned error is
// non-fatal: the run continues on the current model.
ModelSwitcher func(ctx context.Context, modelID string) (Provider, error)
// Trace, when set, records per-turn timing for the run: the loop stamps
// spans (prompt build, generation, tool execution, permission wait,
// compaction, provider connect) and counters (model requests, tool calls,
// retries, tokens) into it. nil DISABLES tracing entirely — every stamp is
// nil-safe and the loop is byte-identical to an untraced run. The caller
// owns the recorder: Run stamps into it but does not Finish or emit it.
// A fresh Recorder is required per Run — reusing one across runs merges
// their spans, counters, and first-event timestamps, and Finish freezes a
// recorder so no further stamps take.
Trace *trace.Recorder
// SelfCorrect, when set, runs a post-edit verify-and-correct cycle after a
// mutating tool call: it verifies the changed files (LSP diagnostics + project
// tests) and feeds failures back to the model to fix, bounded by an attempt
// 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, when set, is passed to mutating tools so edit_file /
// write_file append error-severity language diagnostics for the file they
// just wrote directly to their tool output — the model sees an error it
// introduced in the same turn instead of a later verification pass. Build
// one with NewFileDiagnostics. nil disables inline diagnostics.
FileDiagnostics func(ctx context.Context, absPath string) string
// RequireCompletionSignal gates run completion for HEADLESS exec. Without it,
+1 -14
View File
@@ -5,7 +5,6 @@ package background
import (
"os"
"os/exec"
"path/filepath"
"strconv"
)
@@ -15,8 +14,7 @@ import (
func ConfigureChildProcessGroup(cmd *exec.Cmd) {}
func terminateProcess(pid int) error {
taskkill := taskkillPath()
if err := exec.Command(taskkill, "/T", "/F", "/PID", strconv.Itoa(pid)).Run(); err == nil {
if err := exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(pid)).Run(); err == nil {
return nil
}
process, err := os.FindProcess(pid)
@@ -25,14 +23,3 @@ 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")
}
+17 -83
View File
@@ -25,7 +25,6 @@ import (
"github.com/Gitlawb/zero/internal/plugins"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/provideroauth"
"github.com/Gitlawb/zero/internal/provideronboarding"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/redaction"
@@ -51,7 +50,7 @@ type appDeps struct {
stdin io.Reader
userConfigPath func() (string, error)
resolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
resolveMCPConfig func(workspaceRoot string, excludeProject bool) (config.MCPConfig, error)
resolveMCPConfig func(workspaceRoot string) (config.MCPConfig, error)
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
// exportActiveProvider pins spawned children to the run's provider (production:
// config.SetActiveProviderEnv, set in defaultAppDeps — deliberately NOT filled
@@ -61,7 +60,6 @@ type appDeps struct {
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
openRouterLogin func(context.Context, provideroauth.OpenRouterOptions) (string, error)
newSessionStore func() *sessions.Store
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
@@ -125,12 +123,11 @@ func defaultAppDeps() appDeps {
options.Overrides = overrides
return config.Resolve(options)
},
resolveMCPConfig: func(workspaceRoot string, excludeProject bool) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
options, err := config.DefaultResolveOptions(workspaceRoot)
if err != nil {
return config.MCPConfig{}, err
}
options.ExcludeProject = excludeProject
return config.ResolveMCP(options)
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
@@ -147,7 +144,6 @@ func defaultAppDeps() appDeps {
probeProviderHealth: providerhealth.Probe,
discoverProviderModels: defaultDiscoverProviderModels,
detectLocalRuntimes: provideronboarding.DetectLocalRuntimes,
openRouterLogin: provideroauth.OpenRouterLogin,
newSessionStore: func() *sessions.Store {
return sessions.NewStore(sessions.StoreOptions{})
},
@@ -407,8 +403,6 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
return runWorktrees(args[1:], stdout, stderr, deps)
case "verify":
return runVerifyCommand(args[1:], stdout, stderr, deps)
case "trust":
return runTrust(args[1:], stdout, stderr, deps)
case "eval":
return runAgentEvalCommand(args[1:], stdout, stderr, deps)
case "changes", "change":
@@ -471,9 +465,6 @@ func fillAppDeps(deps appDeps) appDeps {
if deps.detectLocalRuntimes == nil {
deps.detectLocalRuntimes = defaults.detectLocalRuntimes
}
if deps.openRouterLogin == nil {
deps.openRouterLogin = defaults.openRouterLogin
}
if deps.newSessionStore == nil {
deps.newSessionStore = defaults.newSessionStore
}
@@ -563,11 +554,6 @@ func fillAppDeps(deps appDeps) appDeps {
deps.newProvider = func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
return baseNewProvider(applyStoredProviderKeyAt(profile, userConfigPath))
}
baseProbeProviderHealth := deps.probeProviderHealth
deps.probeProviderHealth = func(ctx context.Context, options providerhealth.Options) providerhealth.Result {
options.Profile = applyStoredProviderKeyAt(options.Profile, userConfigPath)
return baseProbeProviderHealth(ctx, options)
}
return deps
}
@@ -672,17 +658,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1)
}
defer closeSpecialistRuntime(stderr, specialistRuntime)
// The TUI has no --worktree reassignment, so trustRoot == workspaceRoot here.
// Gate the project MCP layer behind the workspace-trust check (fail-closed): an
// untrusted workspace must not spawn its ./.zero/config.json stdio MCP servers.
// Keep the store-read error so the notice below can distinguish a fail-closed
// store error from a clean untrusted verdict.
mcpExcludeProject, mcpTrustErrored := resolveTrust(workspaceRoot)
mcpSkip := trustSkip{
excludedProjectConfig: mcpExcludeProject && projectMCPConfigExists(workspaceRoot),
trustCheckErrored: mcpTrustErrored,
}
mcpConfig, err := deps.resolveMCPConfig(workspaceRoot, mcpExcludeProject)
mcpConfig, err := deps.resolveMCPConfig(workspaceRoot)
if err != nil {
return writeAppError(stderr, err.Error(), 1)
}
@@ -710,24 +686,15 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
defer closeMCPRuntime(stderr, mcpRuntime)
// A server that could not be reached or validated is skipped, not fatal (one
// bad MCP server must not abort startup) — surface each so a missing tool set is
// explained rather than silently absent. A built-in default the user never
// configured (e.g. keyless Firecrawl with no credentials) is the exception: it
// was never asked for, so its failure is not worth a startup warning — only
// servers the user actually configured warn on failure (issue #552).
// explained rather than silently absent.
for _, skipped := range mcpRuntime.Skipped() {
if skipped.UnconfiguredDefault {
continue
}
fmt.Fprintf(stderr, "warning: MCP server %s unavailable, skipped: %s\n", skipped.Name, redaction.ErrorMessage(skipped.Err, redaction.Options{}))
}
// Make local plugins live: register their declared tools into the registry and
// collect their hooks + skill roots for the dispatcher and skill tool below.
// Done after specialist + MCP registration so plugin tools are part of the
// deferral count, and it fails OPEN — a malformed plugin is warned and skipped.
// The interactive TUI is not worktree-reassigned, so the trust root is the
// launch directory itself.
trustRoot := workspaceRoot
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot)
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr)
// Ask (not Auto) is the interactive default: in Auto, ToolAdvertised exposes
// only PermissionAllow tools, so prompt-gated tools (write_file/edit_file/bash/
// apply_patch) would never be offered to the model — the TUI could neither edit
@@ -762,26 +729,8 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
Scope: scope,
})
lastKnownMCPConfig := mcpConfig
fileTracker := tools.NewFileTracker()
var scratchBaseline scratchFileBaseline
sttServerManager := newDictationServerManager(resolved.STT)
// Keep STT downloads in the SAME config tree the rest of the TUI uses. Deriving
// from userConfigPath (rather than config.UserConfigDir()) matters when the config
// root is overridden — e.g. in tests or a custom ZERO config dir — so the two
// don't diverge. userConfigPath points at .../zero/config.json, so its dir is the
// zero config dir.
sttDownloadRoot := ""
if userConfigPath != "" {
sttDownloadRoot = filepath.Join(filepath.Dir(userConfigPath), "stt")
}
// Build the hooks dispatcher out of the AgentOptions literal so its trust skip
// report can be combined with the plugin activation's, and emit at most one
// notice when project hooks/plugins were dropped for an untrusted workspace.
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot)
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
return deps.runTUI(context.Background(), tui.Options{
Cwd: workspaceRoot,
Version: version,
Theme: theme,
SavedTheme: resolved.Preferences.Theme,
UserConfigPath: userConfigPath,
@@ -792,24 +741,17 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
ProviderProfile: resolved.Provider,
SavedProviders: usableSavedProviders(resolved.Providers),
FavoriteModels: resolved.Preferences.FavoriteModels,
RecentModels: resolved.Preferences.RecentModels,
RecapsEnabled: resolved.Preferences.RecapsEnabled(),
Provider: provider,
NewProvider: deps.newProvider,
ProbeProviderHealth: deps.probeProviderHealth,
UserAgent: userAgent(),
PrepareRunCompletionWarning: func() {
scratchBaseline = scratchFileSnapshot(workspaceRoot)
},
RunCompletionWarning: func() string {
return scratchFileWarning(workspaceRoot, scratchBaseline)
},
Registry: registry,
SessionStore: deps.newSessionStore(),
SandboxStore: sandboxStore,
MCPConfig: mcpConfig,
MCPPermissionStore: mcpPermissionStore,
MCPTokenStore: mcpTokenStore,
Registry: registry,
SessionStore: deps.newSessionStore(),
SandboxStore: sandboxStore,
MCPConfig: mcpConfig,
MCPPermissionStore: mcpPermissionStore,
MCPTokenStore: mcpTokenStore,
MCPCommand: func(ctx context.Context, args []string) tui.MCPCommandResult {
if ctx == nil {
ctx = context.Background()
@@ -817,7 +759,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
var stdout, stderr bytes.Buffer
exitCode := runMCPWithContext(ctx, args, &stdout, &stderr, deps)
nextConfig := lastKnownMCPConfig
if refreshed, err := deps.resolveMCPConfig(workspaceRoot, mcpExcludeProject); err == nil {
if refreshed, err := deps.resolveMCPConfig(workspaceRoot); err == nil {
lastKnownMCPConfig = refreshed
nextConfig = refreshed
}
@@ -835,8 +777,8 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
PermissionMode: permissionMode,
Autonomy: "low",
Sandbox: sandboxEngine,
FileTracker: fileTracker,
Hooks: hookDispatcher,
FileTracker: tools.NewFileTracker(),
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
DeferThreshold: resolved.Tools.DeferThreshold,
Specialists: specialistRuntime.specialists,
Skills: pluginActivation.skillInfos(deps.skillsDir()),
@@ -849,15 +791,9 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
merged, _ := plugins.MergedSkillsLoaded(deps.skillsDir(), pluginActivation.skillRoots)
return merged
}),
PermissionMode: permissionMode,
Notify: resolved.Notify,
KeyBindings: resolved.KeyBindings,
STT: resolved.STT,
BuildDictationTranscriber: newDictationTranscriberFactory(resolved, userConfigPath, sttServerManager),
ShutdownDictationServer: sttServerManager.Shutdown,
STTDownloadRoot: sttDownloadRoot,
STTKeyStatus: newSTTKeyStatus(resolved, userConfigPath),
SaveSTTKey: newSaveSTTKey(userConfigPath),
PermissionMode: permissionMode,
Notify: resolved.Notify,
KeyBindings: resolved.KeyBindings,
Setup: tui.SetupOptions{
Visible: setupVisible,
Required: needsSetup,
@@ -1347,8 +1283,6 @@ 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
}
+4 -131
View File
@@ -20,7 +20,6 @@ import (
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/tui"
"github.com/Gitlawb/zero/internal/update"
"github.com/Gitlawb/zero/internal/workspacetrust"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -159,86 +158,6 @@ func TestRunNoArgsLaunchesSetupTUIWithNilProviderWhenNoProviderConfigured(t *tes
assertAgentOptions(t, launchedOptions, 12, agent.PermissionModeAsk)
}
type fakeMCPRuntimeWithSkips struct {
skipped []mcp.SkippedServer
}
func (r fakeMCPRuntimeWithSkips) Close() error { return nil }
func (r fakeMCPRuntimeWithSkips) Skipped() []mcp.SkippedServer { return r.skipped }
func TestTUIStartupSuppressesWarningForUnconfiguredDefaultServer(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cwd := t.TempDir()
setCLIUserConfigRoot(t)
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{MaxTurns: 8}, nil
},
resolveMCPConfig: func(workspaceRoot string, excludeProject bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"firecrawl": config.DefaultMCPServers()["firecrawl"],
}}, nil
},
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return fakeMCPRuntimeWithSkips{skipped: []mcp.SkippedServer{
{Name: "firecrawl", Err: errors.New("returned HTTP 401"), UnconfiguredDefault: true},
}}, nil
},
runTUI: func(ctx context.Context, options tui.Options) int {
return 0
},
})
if exitCode != 0 {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
if strings.Contains(stderr.String(), "firecrawl") {
t.Fatalf("stderr = %q, want no warning for an unconfigured default server", stderr.String())
}
}
func TestTUIStartupWarnsForUserConfiguredServerSkip(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cwd := t.TempDir()
setCLIUserConfigRoot(t)
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{MaxTurns: 8}, nil
},
resolveMCPConfig: func(workspaceRoot string, excludeProject bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"custom": {Type: "stdio", Command: "custom-mcp"},
}}, nil
},
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return fakeMCPRuntimeWithSkips{skipped: []mcp.SkippedServer{
{Name: "custom", Err: errors.New("connect failed"), UnconfiguredDefault: false},
}}, nil
},
runTUI: func(ctx context.Context, options tui.Options) int {
return 0
},
})
if exitCode != 0 {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stderr.String(), "warning: MCP server custom unavailable") {
t.Fatalf("stderr = %q, want a warning for a user-configured server that failed", stderr.String())
}
}
func TestRunNoArgsEntersSetupWhenResolveReportsNoActiveProvider(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -405,7 +324,7 @@ func TestRunNoArgsLaunchesTUIWithMCPState(t *testing.T) {
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{MaxTurns: 8}, nil
},
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
if workspaceRoot != cwd {
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
}
@@ -480,7 +399,7 @@ func TestTUIMCPCommandUsesLastGoodConfigOnRefreshError(t *testing.T) {
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{MaxTurns: 8}, nil
},
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
resolveCalls++
switch resolveCalls {
case 1:
@@ -547,7 +466,7 @@ func TestRunNoArgsClosesPartialMCPRuntimeWhenRegistrationFails(t *testing.T) {
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{MaxTurns: 8}, nil
},
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
}}, nil
@@ -602,7 +521,7 @@ func TestRunNoArgsSoftFailsMCPTokenStoreInit(t *testing.T) {
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{MaxTurns: 8}, nil
},
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
return config.MCPConfig{}, nil
},
newMCPStore: func() (*mcp.PermissionStore, error) {
@@ -741,52 +660,6 @@ func TestRunNoArgsLaunchesTUIInAskPermissionMode(t *testing.T) {
}
}
// TestRunInteractiveSurfacesMCPTrustNotice executes the interactive (TUI) path's MCP
// trust notice (app.go), the third notice site alongside exec and spec-draft. An
// untrusted repo whose only project config is MCP must print the notice before runTUI;
// trusting it silences it. runTUI is stubbed so nothing renders; resolveMCPConfig
// returns no servers so nothing spawns -- the notice depends only on the trust verdict
// and the real ./.zero/config.json.
func TestRunInteractiveSurfacesMCPTrustNotice(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir()
if err := os.MkdirAll(filepath.Join(repo, ".zero"), 0o700); err != nil {
t.Fatal(err)
}
body := `{"mcp":{"servers":{"proj":{"type":"stdio","command":"proj-cmd"}}}}`
if err := os.WriteFile(filepath.Join(repo, ".zero", "config.json"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
run := func() string {
var stdout, stderr bytes.Buffer
code := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return repo, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{MaxTurns: 3}, nil
},
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
runTUI: func(context.Context, tui.Options) int { return 0 },
})
if code != 0 {
t.Fatalf("interactive launch exit = %d, stderr=%q", code, stderr.String())
}
return stderr.String()
}
untrusted := run()
if !strings.Contains(untrusted, "MCP servers") || !strings.Contains(untrusted, "zero trust") {
t.Fatalf("untrusted interactive launch must surface the MCP trust notice, stderr=%q", untrusted)
}
if err := workspacetrust.Trust(repo); err != nil {
t.Fatal(err)
}
if trusted := run(); strings.Contains(trusted, "ignoring project") {
t.Fatalf("trusted interactive launch must not emit a trust notice, stderr=%q", trusted)
}
}
func TestRunSkipPermissionsUnsafeLaunchesTUIInUnsafeMode(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
+9 -100
View File
@@ -6,52 +6,15 @@ import (
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/provideroauth"
"github.com/Gitlawb/zero/internal/redaction"
)
// ensureLoginProviderProfile makes a freshly stored OAuth login visible as a
// provider: without a profile in config.json the login shows up nowhere — not in
// `zero providers list`, not in the TUI picker — and `zero providers use <name>`
// fails "not found", so the user's working active provider looks broken while
// the new login looks lost. Returns user-facing guidance lines ("" when the
// login has no catalog entry to scaffold from). Best-effort by design: the
// token is already stored, so a profile-write failure degrades to a warning
// line instead of failing a login that succeeded.
func ensureLoginProviderProfile(deps appDeps, provider string) string {
if _, ok := providercatalog.Get(provider); !ok {
// Custom OAuth server without a catalog entry — no endpoint/model defaults
// to scaffold a profile from; the user wires their own profile.
return ""
}
configPath, err := deps.userConfigPath()
if err != nil {
return "warning: login saved, but no provider profile was written: " + err.Error()
}
ensured, err := config.EnsureCatalogProvider(configPath, provider)
if err != nil {
return "warning: login saved, but no provider profile was written: " + err.Error()
}
active := strings.EqualFold(strings.TrimSpace(ensured.Active), strings.TrimSpace(ensured.Name))
switch {
case ensured.Created && active:
return fmt.Sprintf("Added provider %q to your config and set it active.", ensured.Name)
case ensured.Created:
return fmt.Sprintf("Added provider %q to your config; the active provider is still %q.\nSwitch with: zero providers use %s", ensured.Name, ensured.Active, ensured.Name)
case active:
return fmt.Sprintf("Provider %q is configured and active.", ensured.Name)
default:
return fmt.Sprintf("Provider %q is already configured.\nSwitch with: zero providers use %s", ensured.Name, ensured.Name)
}
}
// runAuth dispatches `zero auth <command>` for provider OAuth login. It is
// additive and independent of `zero mcp oauth` (MCP server auth), which is
// unchanged.
@@ -83,11 +46,11 @@ func runAuth(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
}
// runAuthOpenRouter runs OpenRouter's browser PKCE login and saves the freshly
// minted API key into the user's provider credential store. Unlike `auth login`
// (which stores an OAuth bearer token), OpenRouter's flow mints a normal API key;
// persist it immediately so the provider is usable after the command completes.
func runAuthOpenRouter(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
// runAuthOpenRouter runs OpenRouter's browser PKCE login and prints the freshly
// minted API key. Unlike `auth login` (which stores an OAuth bearer token),
// OpenRouter's flow mints a normal API key; the setup wizard saves it to a
// provider profile, while this command prints it for manual configuration.
func runAuthOpenRouter(args []string, stdout io.Writer, stderr io.Writer, _ appDeps) int {
for _, a := range args {
if a == "-h" || a == "--help" || a == "help" {
_ = writeAuthHelp(stdout)
@@ -99,7 +62,7 @@ func runAuthOpenRouter(args []string, stdout io.Writer, stderr io.Writer, deps a
if len(args) > 0 {
return writeExecUsageError(stderr, fmt.Sprintf("zero auth openrouter takes no arguments (got %q)", args[0]))
}
key, err := deps.openRouterLogin(context.Background(), provideroauth.OpenRouterOptions{
key, err := provideroauth.OpenRouterLogin(context.Background(), provideroauth.OpenRouterOptions{
Out: stdout,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
// ZERO_OPENROUTER_BASE_URL overrides the endpoint (self-hosted gateway or tests).
@@ -108,59 +71,12 @@ func runAuthOpenRouter(args []string, stdout io.Writer, stderr io.Writer, deps a
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
key = strings.TrimSpace(key)
line, err := saveOpenRouterProviderKey(deps, key)
if err != nil {
if _, writeErr := fmt.Fprintf(stdout, "\nOpenRouter login complete — new API key minted, but Zero could not save it: %s\nUse it manually, e.g.:\n export OPENROUTER_API_KEY=%s\n", err, key); writeErr != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintf(stdout, "\nOpenRouter login complete — new API key saved.\n%s\n", line); err != nil {
if _, err := fmt.Fprintf(stdout, "\nOpenRouter login complete — new API key minted.\nUse it with zero, e.g.:\n export OPENROUTER_API_KEY=%s\n(or add it to a provider profile with catalogID \"openrouter\").\n", key); err != nil {
return exitCrash
}
return exitSuccess
}
func saveOpenRouterProviderKey(deps appDeps, key string) (string, error) {
key = strings.TrimSpace(key)
if key == "" {
return "", fmt.Errorf("OpenRouter returned an empty API key")
}
configPath, err := deps.userConfigPath()
if err != nil {
return "", err
}
ensured, err := config.EnsureCatalogProvider(configPath, "openrouter")
if err != nil {
return "", err
}
store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath))
if err != nil {
return "", err
}
if err := store.Set(ensured.Name, key); err != nil {
return "", err
}
if err := config.MarkProviderAPIKeyStored(configPath, ensured.Name); err != nil {
// Best-effort rollback: don't leave the key orphaned in the credential
// store while config.json still says it isn't there.
_, _ = store.Delete(ensured.Name)
return "", err
}
active := strings.EqualFold(strings.TrimSpace(ensured.Active), strings.TrimSpace(ensured.Name))
switch {
case ensured.Created && active:
return fmt.Sprintf("Added provider %q to your config and set it active.", ensured.Name), nil
case ensured.Created:
return fmt.Sprintf("Added provider %q to your config; the active provider is still %q.\nSwitch with: zero providers use %s", ensured.Name, ensured.Active, ensured.Name), nil
case active:
return fmt.Sprintf("Provider %q is configured, active, and ready to use.", ensured.Name), nil
default:
return fmt.Sprintf("Provider %q is configured with the new key.\nSwitch with: zero providers use %s", ensured.Name, ensured.Name), nil
}
}
// runAuthChatGPT runs the ChatGPT (Codex) browser PKCE login, persists the
// bearer + chatgpt-account-id claim under the "chatgpt" provider key, and
// prints a status block. The bearer routes to chatgpt.com/backend-api/codex
@@ -222,10 +138,8 @@ func runAuthChatGPT(args []string, stdout io.Writer, stderr io.Writer, deps appD
if _, err := fmt.Fprint(stdout, statuses); err != nil {
return exitCrash
}
if line := ensureLoginProviderProfile(deps, "chatgpt"); line != "" {
if _, err := fmt.Fprintf(stdout, "\n%s\n", line); err != nil {
return exitCrash
}
if _, err := fmt.Fprint(stdout, "\nUse it with zero, e.g.:\n zero --provider chatgpt --model gpt-5.5\n"); err != nil {
return exitCrash
}
return exitSuccess
}
@@ -417,11 +331,6 @@ func runAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps appDep
if _, err := fmt.Fprintf(stdout, "Logged in to %s.\n%s\n", parsed.positional[0], oauth.FormatStatuses([]oauth.Status{status})); err != nil {
return exitCrash
}
if line := ensureLoginProviderProfile(deps, provider); line != "" {
if _, err := fmt.Fprintln(stdout, line); err != nil {
return exitCrash
}
}
return exitSuccess
}
-28
View File
@@ -2,14 +2,12 @@ package cli
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -74,32 +72,6 @@ func TestFillAppDepsWrapsNewProviderWithStoredKey(t *testing.T) {
}
}
// fillAppDeps also wraps probeProviderHealth, the code path zero providers
// check --connectivity, zero doctor --connectivity, and the TUI doctor panel
// all go through. Passing the raw resolved profile previously sent an
// unauthenticated (keyless) health probe for apiKeyStored profiles, same
// regression class as TestFillAppDepsWrapsNewProviderWithStoredKey but for
// health checks instead of the runtime provider build.
func TestFillAppDepsWrapsProbeProviderHealthWithStoredKey(t *testing.T) {
configPath := seedStoredProviderKey(t, "echo", "sk-stored-health")
var captured config.ProviderProfile
deps := fillAppDeps(appDeps{
userConfigPath: func() (string, error) { return configPath, nil },
probeProviderHealth: func(_ context.Context, options providerhealth.Options) providerhealth.Result {
captured = options.Profile
return providerhealth.Result{Status: providerhealth.StatusPass}
},
})
deps.probeProviderHealth(context.Background(), providerhealth.Options{
Profile: config.ProviderProfile{Name: "echo", APIKeyStored: true},
})
if captured.APIKey != "sk-stored-health" {
t.Fatalf("wrapped probeProviderHealth ran with APIKey = %q, want the stored key (keyless health-check regression)", captured.APIKey)
}
}
// buildProvider (the TUI/exec STARTUP construction site) must export
// ZERO_PROVIDER so children spawned at any point in the run are pinned to the
// parent's provider from launch — not only after an in-session switch. Without
+1 -125
View File
@@ -2,26 +2,18 @@ package cli
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/provideroauth"
)
// withAuthStore points the provider OAuth store at a temp file for the test,
// pinning the file backend so an inherited ZERO_OAUTH_STORAGE=keyring can't
// ignore the temp path and hit the OS keychain.
// withAuthStore points the provider OAuth store at a temp file for the test.
func withAuthStore(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
t.Setenv("ZERO_OAUTH_STORAGE", "file")
return path
}
@@ -153,44 +145,6 @@ func TestRunAuthOpenRouterRejectsArgs(t *testing.T) {
}
}
func TestRunAuthOpenRouterSavesMintedKey(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
var stdout, stderr bytes.Buffer
code := runWithDeps([]string{"auth", "openrouter"}, &stdout, &stderr, appDeps{
userConfigPath: func() (string, error) { return configPath, nil },
openRouterLogin: func(context.Context, provideroauth.OpenRouterOptions) (string, error) {
return "sk-openrouter-test", nil
},
})
if code != exitSuccess {
t.Fatalf("exit = %d, stderr = %q", code, stderr.String())
}
if !strings.Contains(stdout.String(), "new API key saved") {
t.Fatalf("expected saved-key confirmation, got %q", stdout.String())
}
cfg := readCLIConfigFixture(t, configPath)
if cfg.ActiveProvider != "openrouter" || len(cfg.Providers) != 1 {
t.Fatalf("config = %#v", cfg)
}
profile := cfg.Providers[0]
if profile.Name != "openrouter" || profile.CatalogID != "openrouter" || !profile.APIKeyStored || profile.APIKey != "" || profile.APIKeyEnv != "" {
t.Fatalf("provider not stored-key sanitized: %#v", profile)
}
store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath))
if err != nil {
t.Fatal(err)
}
key, ok, err := store.Get("openrouter")
if err != nil {
t.Fatal(err)
}
if !ok || key != "sk-openrouter-test" {
t.Fatalf("stored key = %q, %v", key, ok)
}
}
func TestRunAuthHelp(t *testing.T) {
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"auth", "--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
@@ -243,81 +197,3 @@ func TestRunAuthLoginChatGPTRejectsScope(t *testing.T) {
t.Fatalf("stderr = %q, want the ChatGPT-specific --scope rejection", stderr.String())
}
}
func TestEnsureLoginProviderProfileAddsProviderWithoutStealingActive(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
seed := `{"activeProvider":"opengateway","providers":[{"name":"opengateway","provider_kind":"openai-compatible","baseURL":"https://gateway.example.com/v1","apiKeyStored":true,"model":"some-model"}]}`
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
t.Fatalf("seed config: %v", err)
}
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
line := ensureLoginProviderProfile(deps, "chatgpt")
if !strings.Contains(line, `Added provider "chatgpt"`) {
t.Fatalf("expected added-provider guidance, got %q", line)
}
if !strings.Contains(line, "zero providers use chatgpt") {
t.Fatalf("expected switch hint, got %q", line)
}
cfg := readCLIConfigFixture(t, configPath)
if cfg.ActiveProvider != "opengateway" {
t.Fatalf("active provider changed to %q", cfg.ActiveProvider)
}
names := make([]string, 0, len(cfg.Providers))
for _, provider := range cfg.Providers {
names = append(names, provider.Name)
}
if len(cfg.Providers) != 2 || cfg.Providers[1].CatalogID != "chatgpt" {
t.Fatalf("expected chatgpt profile appended, got %v", names)
}
// A second login must be a no-op with switch guidance, not a duplicate.
line = ensureLoginProviderProfile(deps, "chatgpt")
if !strings.Contains(line, "already configured") {
t.Fatalf("expected already-configured guidance, got %q", line)
}
cfg = readCLIConfigFixture(t, configPath)
if len(cfg.Providers) != 2 {
t.Fatalf("repeat login duplicated the profile: %d providers", len(cfg.Providers))
}
}
func TestEnsureLoginProviderProfileActivatesOnFreshConfig(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
line := ensureLoginProviderProfile(deps, "chatgpt")
if !strings.Contains(line, "set it active") {
t.Fatalf("fresh config should adopt the login as active, got %q", line)
}
cfg := readCLIConfigFixture(t, configPath)
if cfg.ActiveProvider != "chatgpt" {
t.Fatalf("active provider = %q, want chatgpt", cfg.ActiveProvider)
}
}
func TestEnsureLoginProviderProfileSkipsNonCatalogProviders(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
if line := ensureLoginProviderProfile(deps, "my-custom-oauth-server"); line != "" {
t.Fatalf("custom OAuth server must not scaffold a profile, got %q", line)
}
if _, err := os.Stat(configPath); err == nil {
t.Fatalf("config must not be created for a non-catalog login")
}
}
func readCLIConfigFixture(t *testing.T, path string) config.FileConfig {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config: %v", err)
}
var cfg config.FileConfig
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("decode config: %v", err)
}
return cfg
}
+2 -6
View File
@@ -125,9 +125,7 @@ func backendLifecycleSnapshot(deps appDeps) (zerocommands.BackendLifecycleSnapsh
return zerocommands.BackendLifecycleSnapshot{}, fmt.Errorf("failed to resolve workspace: %w", err)
}
// Reporting/enumeration only, never spawns a server, so it is left ungated
// (excludeProject=false) to mirror the doctor/status hooks and plugins reports.
cfg, err := deps.resolveMCPConfig(cwd, false)
cfg, err := deps.resolveMCPConfig(cwd)
if err != nil {
return zerocommands.BackendLifecycleSnapshot{}, err
}
@@ -154,9 +152,7 @@ func backendDoctorReport(deps appDeps) (zerocommands.BackendDoctorReport, error)
return zerocommands.BackendDoctorReport{}, fmt.Errorf("failed to resolve workspace: %w", err)
}
// Reporting/enumeration only, never spawns a server, so it is left ungated
// (excludeProject=false) to mirror the doctor/status hooks and plugins reports.
cfg, err := deps.resolveMCPConfig(cwd, false)
cfg, err := deps.resolveMCPConfig(cwd)
if err != nil {
return zerocommands.BackendDoctorReport{}, err
}
+4 -4
View File
@@ -26,7 +26,7 @@ func TestRunBackendsJSONUsesLifecycleSnapshotWithoutConnectingMCP(t *testing.T)
secret := "sk-proj-" + strings.Repeat("a", 24)
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
if workspaceRoot != cwd {
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
}
@@ -123,7 +123,7 @@ func TestRunBackendsJSONUsesLifecycleSnapshotWithoutConnectingMCP(t *testing.T)
func TestRunBackendsTextAndHelp(t *testing.T) {
deps := appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
resolveMCPConfig: func(string) (config.MCPConfig, error) {
return config.MCPConfig{}, nil
},
loadHooks: func(hooks.LoadOptions) (hooks.LoadResult, error) {
@@ -164,7 +164,7 @@ func TestRunBackendsDoctorJSONAndTextWithoutConnectingMCP(t *testing.T) {
secret := "sk-proj-" + strings.Repeat("b", 24)
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
if workspaceRoot != cwd {
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
}
@@ -301,7 +301,7 @@ func TestRunBackendsDoctorDoesNotConnectOrExecuteConfiguredBackends(t *testing.T
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
resolveMCPConfig: func(string) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"remote": {Type: "http", URL: server.URL + "/mcp"},
}}, nil
+2 -33
View File
@@ -75,12 +75,6 @@ func runProviders(args []string, stdout io.Writer, stderr io.Writer, deps appDep
if command == "use" {
return runProvidersUse(args, stdout, stderr, deps)
}
if command == "remove" || command == "rm" {
return runProvidersRemove(args, stdout, stderr, deps)
}
if command == "rename" {
return runProvidersRename(args, stdout, stderr, deps)
}
if command == "setup" {
return runProvidersSetup(args, stdout, stderr, deps)
}
@@ -252,20 +246,6 @@ func parseCommandCenterArgs(args []string, allowModelFilters bool, allowProvider
func summarizeConfig(resolved config.ResolvedConfig) configSummary {
summary := zerocommands.ConfigSnapshotFromResolved(resolved)
// Mark keyless profiles that authenticate via a stored OAuth login (e.g.
// ChatGPT) so the list shows "oauth login" instead of "api key: not set" —
// the same candidate matching the runtime resolver uses.
logins := oauthLoggedInProviders()
if len(logins) > 0 {
for index := range summary.Providers {
for _, profile := range resolved.Providers {
if profile.Name == summary.Providers[index].Name {
summary.Providers[index].OAuthLogin = providerHasOAuthLogin(profile, logins)
break
}
}
}
}
sort.SliceStable(summary.Providers, func(i int, j int) bool {
if summary.Providers[i].Active != summary.Providers[j].Active {
return summary.Providers[i].Active
@@ -343,7 +323,7 @@ func formatProviderSummaries(command string, providers []providerSummary) string
"model: "+displayCLIValue(provider.Model, "none"),
"api model: "+displayCLIValue(provider.APIModel, "unknown"),
"base url: "+displayCLIValue(provider.BaseURL, "default"),
"api key: "+providerCredentialState(provider),
"api key: "+apiKeyState(provider.APIKeySet),
)
if provider.Message != "" {
lines = append(lines, "status: "+provider.Status+" - "+provider.Message)
@@ -360,7 +340,7 @@ func formatProviderLine(provider providerSummary) string {
if provider.Active {
marker = "*"
}
line := fmt.Sprintf("%s %s [%s] model=%s apiModel=%s api key: %s", marker, displayCLIValue(provider.Name, "none"), displayCLIValue(provider.ProviderKind, "unknown"), displayCLIValue(provider.Model, "none"), displayCLIValue(provider.APIModel, "unknown"), providerCredentialState(provider))
line := fmt.Sprintf("%s %s [%s] model=%s apiModel=%s api key: %s", marker, displayCLIValue(provider.Name, "none"), displayCLIValue(provider.ProviderKind, "unknown"), displayCLIValue(provider.Model, "none"), displayCLIValue(provider.APIModel, "unknown"), apiKeyState(provider.APIKeySet))
if provider.Message != "" {
line += " (" + provider.Status + ": " + provider.Message + ")"
}
@@ -433,15 +413,6 @@ func apiKeyState(set bool) string {
return "not set"
}
// providerCredentialState renders the credential column: a configured key wins,
// then a stored OAuth login, then "not set".
func providerCredentialState(provider providerSummary) string {
if !provider.APIKeySet && provider.OAuthLogin {
return "oauth login"
}
return apiKeyState(provider.APIKeySet)
}
func displayCLIValue(value string, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
@@ -495,8 +466,6 @@ func writeProvidersHelp(w io.Writer) error {
zero providers add <catalog-id> [flags]
zero providers check [name] [flags]
zero providers use <name> [flags]
zero providers remove <name> [flags]
zero providers rename <old> <new> [flags]
zero providers setup <catalog-id> [flags]
zero providers detect [flags]
zero providers models [name] [flags]
-55
View File
@@ -11,7 +11,6 @@ import (
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/zerocommands"
"github.com/Gitlawb/zero/internal/zeroruntime"
@@ -836,57 +835,3 @@ func (commandCenterProvider) StreamCompletion(context.Context, zeroruntime.Compl
close(ch)
return ch, nil
}
func TestProviderCredentialStateShowsOAuthLogin(t *testing.T) {
if got := providerCredentialState(providerSummary{APIKeySet: true}); got != "set" {
t.Fatalf("keyed provider = %q, want set", got)
}
if got := providerCredentialState(providerSummary{OAuthLogin: true}); got != "oauth login" {
t.Fatalf("token-login provider = %q, want oauth login", got)
}
if got := providerCredentialState(providerSummary{}); got != "not set" {
t.Fatalf("credential-less provider = %q, want not set", got)
}
}
// TestProvidersListMarksOAuthLoginProviders: a keyless profile whose credential
// is a stored OAuth login (the shape `zero auth chatgpt` now writes) must render
// as "oauth login", not as a broken "api key: not set" entry.
func TestProvidersListMarksOAuthLoginProviders(t *testing.T) {
tokensPath := filepath.Join(t.TempDir(), "oauth-tokens.json")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokensPath)
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
t.Fatalf("oauth store: %v", err)
}
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "bearer-123"}); err != nil {
t.Fatalf("save token: %v", err)
}
resolved := config.ResolvedConfig{
ActiveProvider: "opengateway",
Providers: []config.ProviderProfile{
{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", APIKeyStored: true, Model: "some-model"},
{Name: "chatgpt", CatalogID: "chatgpt", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://chatgpt.com/backend-api/codex", Model: "gpt-5.5"},
},
}
summary := summarizeConfig(resolved)
byName := map[string]providerSummary{}
for _, provider := range summary.Providers {
byName[provider.Name] = provider
}
if !byName["opengateway"].APIKeySet {
t.Fatalf("stored-key provider must report a credential: %+v", byName["opengateway"])
}
if byName["opengateway"].OAuthLogin {
t.Fatalf("key-authed provider must not claim the OAuth login: %+v", byName["opengateway"])
}
if !byName["chatgpt"].OAuthLogin || byName["chatgpt"].APIKeySet {
t.Fatalf("chatgpt must be marked oauth-login and keyless: %+v", byName["chatgpt"])
}
rendered := formatProviderSummaries("list", summary.Providers)
if !strings.Contains(rendered, "oauth login") {
t.Fatalf("list should render the oauth login state, got:\n%s", rendered)
}
}
+4 -5
View File
@@ -211,12 +211,11 @@ 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
}
current.FireCount = job.FireCount
current.NextRunAt = job.NextRunAt
if current.Status != cron.StatusPaused {
current.Status = job.Status
if current.Status == cron.StatusPaused {
// Honor an external pause that landed while the job ran.
job.Status = cron.StatusPaused
}
return current, nil
return job, nil
})
if errors.Is(err, cron.ErrJobNotFound) {
// Genuinely removed mid-run: don't recreate it (no run record, no persist).
+3 -3
View File
@@ -142,7 +142,7 @@ func TestRunExecListToolsAdvertisesMCPToolsWithoutToolSearch(t *testing.T) {
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
resolveMCPConfig: func(string) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
}}, nil
@@ -177,7 +177,7 @@ func TestRunExecListToolsHonorsJSONFormat(t *testing.T) {
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
resolveMCPConfig: func(string) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
registerMCPTools: func(_ context.Context, _ *tools.Registry, _ config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
return closeFunc(func() error { return nil }), nil
@@ -234,7 +234,7 @@ func TestTUIRunThreadsDeferThresholdAndRegistersToolSearch(t *testing.T) {
Tools: config.ToolsConfig{DeferThreshold: 2},
}, nil
},
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
resolveMCPConfig: func(string) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
}}, nil
-248
View File
@@ -1,248 +0,0 @@
package cli
import (
"fmt"
neturl "net/url"
"os"
"path/filepath"
"strings"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/dictation"
"github.com/Gitlawb/zero/internal/tui"
)
// Default batch cloud models (§8). Overridable via stt.model.
const (
defaultGroqSTTModel = "whisper-large-v3-turbo"
defaultOpenAISTTModel = "whisper-1"
groqSTTBaseURL = "https://api.groq.com/openai/v1"
)
// newDictationTranscriberFactory returns the closure the TUI calls to build a
// transcriber when a recording starts. It lives here, not in the TUI, because it
// resolves provider API keys (credstore + env + configured providers) — logic
// that belongs with the rest of the CLI's credential handling.
//
// serverMgr is the shared, session-long sherpa-onnx streaming server; it is
// spawned lazily on the first local-streaming recording and torn down on exit.
//
// preferStreaming asks for the streaming backend; the returned bool reports
// whether streaming was actually built (false → the caller uses the batch
// pipeline). A misconfigured streaming backend surfaces its own SetupError.
func newDictationTranscriberFactory(resolved config.ResolvedConfig, userConfigPath string, serverMgr *dictation.ServerManager) func(config.STTConfig, bool) (tui.Transcriber, bool, error) {
return func(stt config.STTConfig, preferStreaming bool) (tui.Transcriber, bool, error) {
// stt is passed in (not captured) so a mid-session change — e.g. the F9
// auto-download writing localModelPath — takes effect on the next call.
if serverMgr != nil {
serverMgr.SetModelPath(stt.LocalModelPath)
}
if preferStreaming {
t, err := buildStreamingTranscriber(stt, resolved, userConfigPath, serverMgr)
switch {
case err == nil && t != nil:
return t, true, nil
case err != nil && stt.STTStreamProvider() != config.STTProviderLocal:
// An explicitly-chosen cloud streaming backend that failed to build
// (e.g. a missing key) should surface its own error, not silently
// fall back to batch — the user asked for that backend specifically.
return nil, true, err
}
// The default local streaming backend isn't set up: fall through to the
// batch pipeline so a cloud-batch provider (or a batch-only setup) still
// works instead of erroring on a streaming server the user never asked for.
}
t, err := buildBatchTranscriber(stt, resolved, userConfigPath)
return t, false, err
}
}
// buildStreamingTranscriber constructs the configured streaming backend, or
// returns (nil, nil) when streaming is not applicable so the caller falls back
// to batch.
func buildStreamingTranscriber(stt config.STTConfig, resolved config.ResolvedConfig, userConfigPath string, serverMgr *dictation.ServerManager) (dictation.Transcriber, error) {
switch stt.STTStreamProvider() {
case config.STTProviderDeepgram:
key := resolveSTTKey(resolved, userConfigPath, dictation.StreamProviderDeepgram, "DEEPGRAM_API_KEY")
return dictation.NewDeepgramTranscriber(dictation.DeepgramConfig{
APIKey: key,
Model: stt.StreamModel,
Language: stt.Language,
})
case config.STTProviderOpenAI:
key := resolveSTTKey(resolved, userConfigPath, dictation.ProviderOpenAI, "OPENAI_API_KEY")
return dictation.NewOpenAIRealtimeTranscriber(dictation.OpenAIRealtimeConfig{
APIKey: key,
Model: stt.StreamModel,
})
default: // local streaming via the warm sherpa-onnx server
// STTStreamProvider() defaults to "local" whenever streamProvider is unset —
// including when the user picked a CLOUD batch provider (Groq/OpenAI) and
// never touched streaming. Don't silently stream through the local sherpa
// server in that case: local streaming is only appropriate when it was
// explicitly requested (streamProvider: "local") OR the batch provider is
// itself local. Otherwise report "not applicable" so the factory falls back
// to the cloud batch pipeline the user actually chose.
localWanted := stt.StreamProvider == config.STTProviderLocal || stt.STTProvider() == config.STTProviderLocal
if !localWanted {
return nil, nil
}
// The local streaming transcriber validates the model only lazily (at
// connect time), so gate on the model path here — with none set, report
// "not applicable" (nil, nil) so the factory falls back to the batch
// pipeline instead of committing to a server that can't start.
if serverMgr == nil || strings.TrimSpace(stt.LocalModelPath) == "" {
return nil, nil
}
return dictation.NewLocalStreamingTranscriber(serverMgr), nil
}
}
// newDictationServerManager builds the warm sherpa-onnx streaming server manager
// from config. Construction is side-effect free — the server spawns lazily on
// the first local-streaming recording — so it is always safe to create.
func newDictationServerManager(stt config.STTConfig) *dictation.ServerManager {
return dictation.NewServerManager(dictation.ServerConfig{
Binary: stt.LocalServerBinary,
ModelPath: stt.LocalModelPath,
Port: stt.LocalServerPort,
NumThreads: stt.NumThreads,
})
}
// buildBatchTranscriber constructs the configured batch transcriber, resolving
// credentials for the cloud providers.
func buildBatchTranscriber(stt config.STTConfig, resolved config.ResolvedConfig, userConfigPath string) (dictation.Transcriber, error) {
switch stt.STTProvider() {
case config.STTProviderGroq:
key := resolveSTTKey(resolved, userConfigPath, dictation.ProviderGroq, "GROQ_API_KEY")
model := stt.Model
if model == "" {
model = defaultGroqSTTModel
}
return dictation.NewCloudTranscriber(dictation.CloudConfig{
Provider: dictation.ProviderGroq,
BaseURL: groqSTTBaseURL,
APIKey: key,
Model: model,
Language: stt.Language,
})
case config.STTProviderOpenAI:
key := resolveSTTKey(resolved, userConfigPath, dictation.ProviderOpenAI, "OPENAI_API_KEY")
model := stt.Model
if model == "" {
model = defaultOpenAISTTModel
}
return dictation.NewCloudTranscriber(dictation.CloudConfig{
Provider: dictation.ProviderOpenAI,
BaseURL: config.OpenAIBaseURL,
APIKey: key,
Model: model,
Language: stt.Language,
})
default: // local
return dictation.NewLocalTranscriber(dictation.LocalConfig{
Binary: stt.LocalBinary,
ModelPath: stt.LocalModelPath,
NumThreads: stt.NumThreads,
})
}
}
// sttEnvVar returns the conventional environment variable a cloud STT provider's
// key is read from.
func sttEnvVar(provider string) string {
switch provider {
case dictation.ProviderGroq:
return "GROQ_API_KEY"
case dictation.ProviderOpenAI: // == StreamProviderOpenAI ("openai")
return "OPENAI_API_KEY"
case dictation.StreamProviderDeepgram:
return "DEEPGRAM_API_KEY"
}
return ""
}
// newSTTKeyStatus returns a predicate reporting whether a cloud STT provider
// already has a resolvable API key (configured provider, credstore, or env).
func newSTTKeyStatus(resolved config.ResolvedConfig, userConfigPath string) func(string) bool {
return func(provider string) bool {
return resolveSTTKey(resolved, userConfigPath, provider, sttEnvVar(provider)) != ""
}
}
// newSaveSTTKey returns a function that stores a cloud STT provider's API key in
// the encrypted credential store, so the inline prompt can persist it.
func newSaveSTTKey(userConfigPath string) func(string, string) error {
return func(provider, key string) error {
if strings.TrimSpace(userConfigPath) == "" {
return fmt.Errorf("no config path to store the key")
}
store, err := config.ProviderKeyStoreAt(filepath.Dir(userConfigPath))
if err != nil {
return err
}
return store.Set(provider, strings.TrimSpace(key))
}
}
// resolveSTTKey finds an API key for an STT cloud provider, reusing Zero's
// existing credentials (§2 — "zero new credential UI"). Precedence: a matching
// configured provider's resolved key, then the encrypted credential store, then
// the provider's conventional environment variable.
func resolveSTTKey(resolved config.ResolvedConfig, userConfigPath, provider, envVar string) string {
if key := keyFromConfiguredProviders(resolved, provider); key != "" {
return key
}
if userConfigPath != "" {
if store, err := config.ProviderKeyStoreAt(filepath.Dir(userConfigPath)); err == nil {
if key, ok, _ := store.Get(provider); ok && strings.TrimSpace(key) != "" {
return strings.TrimSpace(key)
}
}
}
return strings.TrimSpace(os.Getenv(envVar))
}
// keyFromConfiguredProviders returns the resolved API key of a configured
// provider that belongs to the given cloud provider (matched by catalog id,
// provider name/kind, or base-URL host), or "" when none is configured.
func keyFromConfiguredProviders(resolved config.ResolvedConfig, provider string) string {
provider = strings.ToLower(provider)
profiles := append([]config.ProviderProfile{resolved.Provider}, resolved.Providers...)
for _, p := range profiles {
if strings.TrimSpace(p.APIKey) == "" {
continue
}
if providerMatches(p, provider) {
return strings.TrimSpace(p.APIKey)
}
}
return ""
}
func providerMatches(p config.ProviderProfile, provider string) bool {
provider = strings.ToLower(provider)
// Match on stable identifiers (catalog id, provider name/kind, profile
// name) rather than raw BaseURL substring matching: a Groq profile's
// BaseURL (api.groq.com/openai/v1) contains "openai", so a substring
// check can hand the wrong key to an OpenAI STT lookup and fail auth.
for _, f := range []string{
strings.ToLower(p.CatalogID),
strings.ToLower(p.Provider),
strings.ToLower(string(p.ProviderKind)),
strings.ToLower(p.Name),
} {
if f != "" && strings.Contains(f, provider) {
return true
}
}
// BaseURL: compare the parsed host only, not a raw substring on the
// full URL (which lets "openai" match api.groq.com/openai/v1).
if u, err := neturl.Parse(p.BaseURL); err == nil && u.Host != "" {
if host := strings.ToLower(u.Host); strings.Contains(host, provider) {
return true
}
}
return false
}
-96
View File
@@ -1,96 +0,0 @@
package cli
import (
"errors"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/dictation"
)
func TestFactoryFallsBackToBatchWhenLocalStreamingNotConfigured(t *testing.T) {
t.Setenv("GROQ_API_KEY", "gsk-test")
resolved := config.ResolvedConfig{STT: config.STTConfig{
Provider: config.STTProviderGroq, // cloud batch
// StreamProvider unset → defaults to local, which has no model configured.
}}
factory := newDictationTranscriberFactory(resolved, "", newDictationServerManager(resolved.STT))
tr, streaming, err := factory(resolved.STT, true) // prefer streaming
if err != nil {
t.Fatalf("expected batch fallback, got error: %v", err)
}
if streaming {
t.Error("should have fallen back to batch (streaming=false)")
}
if tr == nil {
t.Error("expected a Groq batch transcriber")
}
}
func TestFactoryCloudProviderIgnoresLeftoverLocalModel(t *testing.T) {
// Regression: pick local once (LocalModelPath gets set), then switch to a
// cloud batch provider. streamProvider stays unset (defaults to "local"), so
// the streaming factory must NOT fire up the sherpa server on the leftover
// path — it should fall back to the cloud batch pipeline the user chose.
t.Setenv("GROQ_API_KEY", "gsk-test")
stt := config.STTConfig{
Provider: config.STTProviderGroq, // cloud batch, chosen now
LocalModelPath: "/leftover/model/dir", // set by an earlier local selection
}
resolved := config.ResolvedConfig{STT: stt}
factory := newDictationTranscriberFactory(resolved, "", newDictationServerManager(stt))
tr, streaming, err := factory(stt, true) // prefer streaming
if err != nil {
t.Fatalf("expected cloud batch fallback, got error: %v", err)
}
if streaming {
t.Error("a cloud provider must not stream through the local sherpa server")
}
if tr == nil {
t.Error("expected a Groq batch transcriber")
}
}
func TestFactorySurfacesExplicitCloudStreamingError(t *testing.T) {
// Deepgram chosen explicitly but no key resolvable → surface the setup error.
resolved := config.ResolvedConfig{STT: config.STTConfig{
StreamProvider: config.STTProviderDeepgram,
}}
factory := newDictationTranscriberFactory(resolved, "", nil)
_, streaming, err := factory(resolved.STT, true)
if err == nil {
t.Fatal("expected a setup error for missing Deepgram key")
}
if !streaming {
t.Error("an explicit cloud streaming choice should report streaming=true even on error")
}
var setupErr *dictation.SetupError
if !errors.As(err, &setupErr) {
t.Errorf("want *SetupError, got %v", err)
}
}
func TestFactoryLocalBatchSetupErrorWhenNoModel(t *testing.T) {
resolved := config.ResolvedConfig{STT: config.STTConfig{Provider: config.STTProviderLocal}}
factory := newDictationTranscriberFactory(resolved, "", newDictationServerManager(resolved.STT))
_, _, err := factory(resolved.STT, true)
var setupErr *dictation.SetupError
if !errors.As(err, &setupErr) {
t.Fatalf("want *SetupError guiding local model setup, got %v", err)
}
}
func TestResolveSTTKeyPrefersConfiguredProvider(t *testing.T) {
resolved := config.ResolvedConfig{
Providers: []config.ProviderProfile{
{Name: "groq", CatalogID: "groq", BaseURL: "https://api.groq.com/openai/v1", APIKey: "gsk-configured"},
},
}
if got := resolveSTTKey(resolved, "", dictation.ProviderGroq, "GROQ_API_KEY"); got != "gsk-configured" {
t.Errorf("key = %q, want the configured provider's key", got)
}
}
+9 -118
View File
@@ -25,7 +25,6 @@ import (
"github.com/Gitlawb/zero/internal/specmode"
"github.com/Gitlawb/zero/internal/streamjson"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/usage"
"github.com/Gitlawb/zero/internal/worktrees"
"github.com/Gitlawb/zero/internal/zeroruntime"
@@ -111,26 +110,10 @@ 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.
addDirs []string
// tracePath, when set, writes a per-turn NDJSON trace (agenteval-compatible)
// to the given file path — or to stderr when the value is "-". Falls back to
// the ZERO_TRACE env var when the flag is absent. Off by default: a run
// without it leaves agent.Options.Trace nil and is byte-identical to before.
// The trace is pure observation — enabling it does not change agent behavior.
tracePath string
}
type execUsageError struct {
@@ -172,10 +155,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if err != nil {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
}
// trustRoot is the ORIGINAL launch directory, captured before any --worktree
// reassignment below, so a worktree of a trusted repo inherits that repo's
// trust instead of being seen as a fresh untrusted path.
trustRoot := workspaceRoot
if options.worktree {
preparedWorktree, err := deps.prepareWorktree(context.Background(), worktrees.Options{
Cwd: workspaceRoot,
@@ -222,7 +201,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if options.useSpec {
permissionMode = agent.PermissionModeSpecDraft
}
mcpRuntime, mcpSkip, err := registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options), trustRoot)
mcpRuntime, err := registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options))
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error())
}
@@ -231,7 +210,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// registry and collect their hooks + skill roots for the dispatcher and skill
// tool below. Done before --list-tools and filter validation so plugin tools
// are listable and filter-validatable; it fails OPEN (a bad plugin is skipped).
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot)
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr)
if options.useSpec {
specmode.RegisterDraftTools(registry, workspaceRoot, deps.now)
}
@@ -429,12 +408,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
notify.MaybeAddWebhookSink(notifier, os.Getenv, func(format string, args ...any) {
fmt.Fprintf(stderr, "[notify] "+format+"\n", args...)
})
// Spec-draft runs synthesize a prompt offline and never drive a model turn, so
// there is no per-turn trace to emit. Reject --trace / ZERO_TRACE up front with
// a clear error rather than silently accepting and writing nothing.
if options.useSpec && resolveTracePath(options) != "" {
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, "--trace / ZERO_TRACE are not supported for spec-draft runs")
}
if options.useSpec {
return runExecSpecDraft(execSpecDraftRun{
options: options,
@@ -442,8 +415,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
stderr: stderr,
deps: deps,
workspaceRoot: workspaceRoot,
trustRoot: trustRoot,
mcpSkip: mcpSkip,
registry: registry,
modelRegistry: modelRegistry,
resolved: resolved,
@@ -487,24 +458,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if err != nil {
return writeAppError(stderr, "failed to create run id: "+err.Error(), exitCrash)
}
// Per-turn tracing is opt-in (--trace <path> or ZERO_TRACE=<path>). The
// recorder is stamped throughout agent.Run and the providerio seam; the run
// itself is byte-identical to an untraced run. We finish the recorder at the
// agent.Run boundary (below) so the snapshot captures exactly one turn, then
// serialize the snapshot on every exit path via the defer. "-" writes NDJSON
// to stderr; otherwise the path is created/truncated. A failure to emit is
// logged to stderr, never fatal.
tracePath := resolveTracePath(options)
var traceRecorder *trace.Recorder
var traceSnapshot *trace.TurnTrace
if tracePath != "" {
traceRecorder = trace.NewRecorder(preparedSession.Session.SessionID, runID, "")
defer func() {
if err := writeTraceSnapshot(traceSnapshot, tracePath, stderr); err != nil {
fmt.Fprintf(stderr, "[zero] failed to write trace: %s\n", err)
}
}()
}
writer := execEventWriter{
stdout: stdout,
stderr: stderr,
@@ -557,20 +510,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// 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),
@@ -587,7 +526,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
Model: resolved.Provider.Model,
ModelSwitcher: modelSwitcher,
ReasoningEffort: forwardEffort,
Trace: traceRecorder,
Cwd: workspaceRoot,
Images: images,
Registry: registry,
@@ -598,13 +536,11 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// 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, and
// --no-completion-gate lets conversational exec callers (a chat frontend
// with an operator present) opt out the same way.
RequireCompletionSignal: !options.noCompletionGate,
// if the model keeps stalling. The interactive TUI leaves this off.
RequireCompletionSignal: true,
Sandbox: sandboxEngine,
FileTracker: fileTracker,
Hooks: hookDispatcher,
FileTracker: tools.NewFileTracker(),
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
EnabledTools: options.enabledTools,
DisabledTools: options.disabledTools,
OnText: writer.text,
@@ -657,18 +593,11 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
sessionRecorder.append(sessions.EventUsage, payload)
},
})
// Finish the trace now that the turn is done, so the snapshot captures exactly
// agent.Run's work and nothing the post-run cleanup stamps. The deferred
// writer serializes the snapshot on every exit path.
if traceRecorder != nil {
traceSnapshot = traceRecorder.Finish()
}
notifier.Notify(notify.Completion, notify.DefaultMessage(notify.Completion))
if writer.err != nil {
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"})
@@ -719,10 +648,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if notice := result.TruncationNotice(); notice != "" {
writer.warning(notice)
}
// Surface new scratch/debug-like files left untracked in git when we finish —
// including files created by shell commands that bypass the file tools (issue
// #551). Normal new deliverable files stay quiet to avoid warning fatigue.
emitScratchWarning()
// A headless run the completion gate marked INCOMPLETE (no-tool-call stall,
// self-reported non-completion, or max-turns cutoff) must NOT be reported as a
// success: exit 4 AND a machine-readable terminal event saying so. Handled per
@@ -786,10 +711,9 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// sessions); it is a no-op when self-correct is off.
func newExecSelfCorrector(enabled bool, workspaceRoot string, autonomy string) (*agent.SelfCorrector, func(context.Context, string) string, func()) {
// The manager is created regardless of --self-correct: it also backs the
// always-on background post-edit diagnostics (agent.NewFileDiagnostics,
// collected off the tool-call path by the loop), and it stays lazy — no
// language server is spawned unless an edited file's language actually has
// one installed on PATH.
// always-on inline post-edit diagnostics (agent.NewFileDiagnostics), 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)
@@ -1275,36 +1199,3 @@ func execNotifyMode(options execOptions, resolved config.ResolvedConfig) string
}
return resolved.Notify.Mode
}
// resolveTracePath returns the trace destination for this run: the --trace flag
// value when set, else the ZERO_TRACE env var, else "" (tracing off). A value of
// "-" means "write to stderr".
func resolveTracePath(options execOptions) string {
if v := strings.TrimSpace(options.tracePath); v != "" {
return v
}
if v := strings.TrimSpace(os.Getenv("ZERO_TRACE")); v != "" {
return v
}
return ""
}
// writeTraceSnapshot writes an already-finished trace snapshot to dest ("-" =>
// stderr, otherwise a file path created/truncated). A nil snapshot is a no-op
// (tracing off, or the run exited before agent.Run produced a turn). The trace
// is best-effort: a write error is returned to the caller, which logs it but
// never fails the run.
func writeTraceSnapshot(snapshot *trace.TurnTrace, dest string, stderr io.Writer) error {
if snapshot == nil || dest == "" {
return nil
}
if strings.TrimSpace(dest) == "-" {
return trace.WriteNDJSON(stderr, snapshot)
}
file, err := os.Create(dest)
if err != nil {
return err
}
defer file.Close()
return trace.WriteNDJSON(file, snapshot)
}
-59
View File
@@ -30,17 +30,6 @@ 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 == "--trace":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.tracePath = value
index = next
case strings.HasPrefix(arg, "--trace="):
options.tracePath = strings.TrimSpace(strings.TrimPrefix(arg, "--trace="))
case arg == "--notify":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
@@ -413,11 +402,6 @@ 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."}
}
@@ -477,49 +461,6 @@ 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
}
@@ -1,34 +0,0 @@
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")
}
}
-17
View File
@@ -21,20 +21,3 @@ 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)
}
}
+2 -72
View File
@@ -5,7 +5,6 @@ import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"slices"
"strings"
@@ -150,7 +149,7 @@ func TestRunExecListsMCPToolsWithoutProviderConstruction(t *testing.T) {
providerBuilt = true
return nil, errors.New("provider should not be constructed for --list-tools")
},
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
if workspaceRoot != cwd {
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
}
@@ -193,7 +192,7 @@ func TestRunExecLogsMCPRuntimeCloseError(t *testing.T) {
getwd: func() (string, error) {
return cwd, nil
},
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
if workspaceRoot != cwd {
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
}
@@ -713,48 +712,6 @@ func TestRunExecJSONInterruptedEmitsTerminalEvents(t *testing.T) {
}
}
func TestRunExecWarnsAboutScratchFileWhenProviderErrors(t *testing.T) {
dataHome := t.TempDir()
t.Setenv("XDG_DATA_HOME", dataHome)
cwd := t.TempDir()
runGitForScratchTest(t, cwd, "init")
if err := os.WriteFile(filepath.Join(cwd, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGitForScratchTest(t, cwd, "add", "README.md")
runGitForScratchTest(t, cwd, "commit", "-m", "init")
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"exec", "--output-format", "stream-json", "write scratch then fail"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return toolThenErrorExecProvider{
toolCallID: "call_scratch",
toolName: "write_file",
arguments: `{"path":"_debug.py","content":"print('debug')"}`,
err: errors.New("provider failed after tool"),
}, nil
},
newSandboxStore: func() (*sandbox.GrantStore, error) {
return sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
},
})
if exitCode != exitProvider {
t.Fatalf("expected provider exit %d, got %d: stdout=%q stderr=%q", exitProvider, exitCode, stdout.String(), stderr.String())
}
events := decodeJSONLines(t, stdout.String())
warning := findJSONEvent(t, events, "warning")
message, _ := warning["message"].(string)
if !strings.Contains(message, "_debug.py") {
t.Fatalf("expected scratch warning to mention _debug.py, got %#v", warning)
}
}
func TestRunExecReadsStreamJSONPromptFromStdin(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
cwd := t.TempDir()
@@ -833,13 +790,6 @@ type toolCallingExecProvider struct {
answer string
}
type toolThenErrorExecProvider struct {
toolCallID string
toolName string
arguments string
err error
}
type reasoningExecProvider struct{}
func (reasoningExecProvider) StreamCompletion(context.Context, zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
@@ -875,26 +825,6 @@ func (provider toolCallingExecProvider) StreamCompletion(ctx context.Context, re
return ch, nil
}
func (provider toolThenErrorExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
for _, message := range request.Messages {
if message.Role == zeroruntime.MessageRoleTool {
return nil, provider.err
}
}
ch := make(chan zeroruntime.StreamEvent, 4)
select {
case <-ctx.Done():
close(ch)
return ch, ctx.Err()
case ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: provider.toolCallID, ToolName: provider.toolName}:
}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: provider.toolCallID, ArgumentsFragment: provider.arguments}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: provider.toolCallID}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
func findJSONEvent(t *testing.T, events []map[string]any, eventType string) map[string]any {
t.Helper()
_, event := findJSONEventIndex(t, events, eventType)
+3 -3
View File
@@ -20,8 +20,8 @@ func TestWriteExecProviderErrorAppendsHint(t *testing.T) {
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)
if !strings.Contains(out, "zero auth") {
t.Fatalf("expected an actionable hint referencing `zero auth`, got %q", out)
}
})
@@ -43,7 +43,7 @@ func TestWriteExecProviderErrorAppendsHint(t *testing.T) {
if stderr.Len() != 0 {
t.Fatalf("json mode must not write to stderr, got %q", stderr.String())
}
if strings.Contains(stdout.String(), "zero setup") {
if strings.Contains(stdout.String(), "zero auth") {
t.Fatalf("json mode must not inject a hint into the structured payload, got %q", stdout.String())
}
})
+6 -21
View File
@@ -21,19 +21,11 @@ import (
)
type execSpecDraftRun struct {
options execOptions
stdout io.Writer
stderr io.Writer
deps appDeps
workspaceRoot string
// trustRoot is the ORIGINAL launch directory (captured before any --worktree
// reassignment), so a --use-spec run inside a --worktree of a trusted repo
// still keys the trust check on the source repo, not the generated worktree path.
trustRoot string
// mcpSkip is the trust verdict from the workspace MCP registration in runExec
// (which runs before this spec-draft path), so the spec-draft notice can report a
// dropped project MCP layer instead of leaving it silent.
mcpSkip trustSkip
options execOptions
stdout io.Writer
stderr io.Writer
deps appDeps
workspaceRoot string
registry *tools.Registry
modelRegistry modelregistry.Registry
resolved config.ResolvedConfig
@@ -104,13 +96,6 @@ func runExecSpecDraft(run execSpecDraftRun) int {
var draftInfo execSpecDraftInfo
runCtx, stopSignals := signalContext()
defer stopSignals()
// The spec-draft path activates no plugins, so the plugin skip is omitted. Trust
// keys on run.trustRoot (the original launch dir), not run.workspaceRoot, which
// may be a --worktree path; this keeps a --use-spec --worktree run of a trusted
// repo trusted. Emit at most one notice when project hooks or the project MCP
// layer (registered earlier in runExec, carried in run.mcpSkip) were dropped.
hookDispatcher, hookSkip := newHookDispatcher(run.workspaceRoot, run.trustRoot)
emitTrustNotice(run.stderr, hookSkip, run.mcpSkip)
result, err := agent.Run(runCtx, run.prompt, run.provider, agent.Options{
MaxTurns: run.resolved.MaxTurns,
ContextWindow: resolveAgentContextWindow(runCtx, run.modelRegistry, run.resolved.Provider),
@@ -127,7 +112,7 @@ func runExecSpecDraft(run execSpecDraftRun) int {
Autonomy: "low",
Sandbox: run.sandboxEngine,
FileTracker: tools.NewFileTracker(),
Hooks: hookDispatcher,
Hooks: newHookDispatcher(run.workspaceRoot),
EnabledTools: run.options.enabledTools,
DisabledTools: run.options.disabledTools,
OnText: writer.text,
+1 -1
View File
@@ -51,7 +51,7 @@ func TestRunExecUseSpecCreatesDraftSession(t *testing.T) {
MaxTurns: 3,
}, nil
},
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
resolveMCPConfig: func(string) (config.MCPConfig, error) {
return config.MCPConfig{}, nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
+2 -2
View File
@@ -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 != 160 {
t.Fatalf("overrides.MaxTurns = %d, want 160 (deep mode)", gotMaxTurns)
if gotMaxTurns != 50 {
t.Fatalf("overrides.MaxTurns = %d, want 50", gotMaxTurns)
}
}
+2 -14
View File
@@ -205,9 +205,7 @@ func runMCPLegacyList(args []string, stdout io.Writer, stderr io.Writer, deps ap
if err != nil {
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash)
}
// Enumeration for the extensions listing, never spawns a server, so it is left
// ungated (excludeProject=false) like the other doctor/status report sites.
cfg, err := deps.resolveMCPConfig(cwd, false)
cfg, err := deps.resolveMCPConfig(cwd)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
@@ -288,21 +286,11 @@ func runMCPTools(ctx context.Context, args []string, stdout io.Writer, stderr io
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash)
}
registry := tools.NewRegistry()
// `mcp tools list` connects to (spawns) each server to enumerate its live tools,
// so it is a spawn site and gates the project layer behind the trust check. No
// --worktree reassignment on this command path, so trustRoot == cwd.
mcpRuntime, mcpSkip, err := registerMCPToolsForWorkspace(ctx, cwd, registry, deps, mcp.AutonomyLow, cwd)
mcpRuntime, err := registerMCPToolsForWorkspace(ctx, cwd, registry, deps, mcp.AutonomyLow)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
defer closeMCPRuntime(stderr, mcpRuntime)
// Surface the trust skip on stderr so an empty (or short) list in an untrusted
// workspace is explained rather than read as "nothing configured". The notice
// goes to stderr, leaving the list (text or --json) on stdout intact.
emitTrustNotice(stderr, mcpSkip)
// Surface the trust skip on stderr so an empty (or short) list in an untrusted
// workspace is explained rather than read as "nothing configured". The notice
// goes to stderr, leaving the list (text or --json) on stdout intact.
items := mcpToolList(registry)
if options.json {
payload := struct {
+2 -2
View File
@@ -204,7 +204,7 @@ func TestRunMCPToolsListJSONAndText(t *testing.T) {
closeCalls := 0
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
if workspaceRoot != cwd {
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
}
@@ -264,7 +264,7 @@ func TestRunMCPLegacyListAliases(t *testing.T) {
closeCalls := 0
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
if workspaceRoot != cwd {
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
}
+6 -101
View File
@@ -1,63 +1,17 @@
package cli
import (
"fmt"
"io"
"os"
"path/filepath"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/workspacetrust"
)
// trustSkip reports whether a runtime chokepoint dropped the project layer because
// the workspace was not trusted, so the caller can emit a single combined notice.
// Both chokepoints (hooks and plugins) return one, and the caller ORs them.
type trustSkip struct {
// excludedProjectConfig is true when the project layer was excluded AND a
// project config for this surface actually exists on disk (so a notice is
// worth showing). It stays false in a trusted workspace, or when the
// workspace has no project config to skip.
excludedProjectConfig bool
// trustCheckErrored is true when the exclusion was caused by a trust-store
// read error rather than a clean untrusted verdict, so the caller can name
// the error in its notice.
trustCheckErrored bool
}
// resolveTrust computes the fail-closed trust verdict for a chokepoint. It treats
// any error OR an empty trustRoot as untrusted, so a forgotten or future call site
// cannot fail open. It returns whether the project layer should be excluded and
// whether the decision was driven by a store-read error.
//
// Each chokepoint (hooks and plugins) calls this itself rather than the caller
// resolving trust once and passing a bool down. That is deliberate: keeping the
// check inside the chokepoint is what makes the gate fail-closed by construction.
// Do NOT hoist it to the callers behind an excludeProject bool whose zero value
// includes the project layer, that reintroduces a fail-open default. The extra
// store read per session is negligible.
func resolveTrust(trustRoot string) (excludeProject bool, trustCheckErrored bool) {
if trustRoot == "" {
return true, false
}
trusted, err := workspacetrust.IsTrusted(trustRoot)
if err != nil {
return true, true
}
return !trusted, false
}
// newHookDispatcher builds the per-session hooks dispatcher for a workspace,
// merging user + project hooks.json and wiring the audit store. It fails OPEN:
// any load or setup error yields a nil dispatcher, which Dispatch treats as a
// no-op, so a malformed hooks config can never wedge tool execution. With no
// hooks configured the dispatcher selects nothing and runs no commands, so the
// hot path stays free of overhead until a user opts in via hooks.json.
//
// trustRoot is the original launch directory (resolved before any --worktree
// reassignment); the project layer loads only when that root is trusted.
func newHookDispatcher(workspaceRoot string, trustRoot string) (*hooks.Dispatcher, trustSkip) {
return newHookDispatcherWithExtra(workspaceRoot, nil, trustRoot)
func newHookDispatcher(workspaceRoot string) *hooks.Dispatcher {
return newHookDispatcherWithExtra(workspaceRoot, nil)
}
// newHookDispatcherWithExtra builds the dispatcher like newHookDispatcher but also
@@ -66,22 +20,10 @@ func newHookDispatcher(workspaceRoot string, trustRoot string) (*hooks.Dispatche
// appended after the configured hooks; their ids are plugin-namespaced (plugin
// id + hook name) so they never collide with hooks.json ids. A nil/empty extra
// slice is byte-equivalent to newHookDispatcher.
//
// The trust check lives here, inside the chokepoint, so no caller can bypass it:
// the project layer is dropped (ExcludeProject) whenever trustRoot is empty, the
// trust store cannot be read, or the workspace is not trusted (fail-closed). The
// returned trustSkip lets the caller emit one combined notice; the notice itself
// is NOT emitted here.
func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition, trustRoot string) (*hooks.Dispatcher, trustSkip) {
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
skip := trustSkip{
excludedProjectConfig: excludeProject && projectHooksFileExists(workspaceRoot),
trustCheckErrored: trustCheckErrored,
}
loaded, err := hooks.LoadConfig(hooks.LoadOptions{Cwd: workspaceRoot, ExcludeProject: excludeProject})
func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition) *hooks.Dispatcher {
loaded, err := hooks.LoadConfig(hooks.LoadOptions{Cwd: workspaceRoot})
if err != nil {
return nil, skip
return nil
}
var audit *hooks.AuditStore
if store, err := hooks.NewAuditStore(hooks.AuditStoreOptions{}); err == nil {
@@ -110,42 +52,5 @@ func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition,
Config: config,
Audit: audit,
Cwd: workspaceRoot,
}), skip
}
// projectHooksFileExists reports whether a ./.zero/hooks.json is present under
// workspaceRoot, so the caller only notices about config it actually skipped.
func projectHooksFileExists(workspaceRoot string) bool {
if workspaceRoot == "" {
return false
}
info, err := os.Stat(filepath.Join(workspaceRoot, ".zero", "hooks.json"))
return err == nil && !info.IsDir()
}
// emitTrustNotice writes at most one stderr line summarizing that project-scoped
// hooks, plugins, and/or MCP servers were skipped in an untrusted workspace. It
// takes the skip report from each trust-gated surface (hooks, plugins, MCP) and ORs
// them, so a workspace that only drops one surface still gets the notice. It is
// computed once per session by the caller (each session-setup site runs once), so it
// is naturally once-per-process. When any surface's skip was a trust-store read
// error, the notice names that so a transient config-dir problem is diagnosable.
func emitTrustNotice(stderr io.Writer, skips ...trustSkip) {
if stderr == nil {
return
}
excluded := false
storeErrored := false
for _, skip := range skips {
excluded = excluded || skip.excludedProjectConfig
storeErrored = storeErrored || skip.trustCheckErrored
}
if !excluded {
return
}
if storeErrored {
_, _ = fmt.Fprintln(stderr, "zero: the workspace-trust store could not be read; ignoring project hooks/plugins/MCP servers (fail-closed). Run 'zero trust' to enable.")
return
}
_, _ = fmt.Fprintln(stderr, "zero: ignoring project hooks/plugins/MCP servers in an untrusted workspace. Run 'zero trust' to enable.")
})
}
+3 -3
View File
@@ -519,7 +519,7 @@ func TestRunMCPListRedactsURLCredentialsAndSensitiveQueryParams(t *testing.T) {
commandSecret := "sk-proj-" + strings.Repeat("a", 24)
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
if workspaceRoot != cwd {
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
}
@@ -566,7 +566,7 @@ func TestRunMCPCheckRegistersOnlyRequestedServer(t *testing.T) {
exitCode := runWithDeps([]string{"mcp", "check", "docs", "--json"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
if workspaceRoot != cwd {
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
}
@@ -618,7 +618,7 @@ func TestRunMCPCheckClosesRuntimeReturnedWithError(t *testing.T) {
exitCode := runWithDeps([]string{"mcp", "check", "docs"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
}}, nil
+1 -35
View File
@@ -29,27 +29,6 @@ type mcpWritableConfig struct {
serverRaw map[string]json.RawMessage
}
// projectMCPConfigExists reports whether the workspace's project ./.zero/config.json
// declares any MCP servers, so the trust notice fires only when project MCP config was
// actually skipped (mirroring projectHooksFileExists / projectPluginsDirExists). A
// missing or unparseable file, or one that declares no servers, returns false: there
// is nothing to notice about. This only reads the file; it never spawns a server, so
// it is safe to call on an untrusted workspace.
func projectMCPConfigExists(workspaceRoot string) bool {
if workspaceRoot == "" {
return false
}
data, err := os.ReadFile(filepath.Join(workspaceRoot, ".zero", "config.json"))
if err != nil {
return false
}
var fc config.FileConfig
if err := json.Unmarshal(data, &fc); err != nil {
return false
}
return len(fc.MCP.Servers) > 0
}
func runMCPAdd(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseMCPAddArgs(args)
if err != nil {
@@ -264,25 +243,12 @@ func runMCPCheck(ctx context.Context, args []string, stdout io.Writer, stderr io
if err != nil {
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash)
}
// `mcp check` connects to (spawns) the named server below to enumerate its tools,
// so it is a spawn site: gate the project layer behind the trust check (fail-closed)
// so a cloned repo cannot have `zero mcp check <its-server>` run its command. No
// --worktree reassignment on this command path, so trustRoot == cwd.
mcpExcludeProject, trustCheckErrored := resolveTrust(cwd)
cfg, err := deps.resolveMCPConfig(cwd, mcpExcludeProject)
cfg, err := deps.resolveMCPConfig(cwd)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
raw, ok := cfg.Servers[serverName]
if !ok {
// The server may be missing because the project layer was gated out in an
// untrusted workspace; emit the same one-line notice the other spawn sites use so
// a dropped project server reads as "run zero trust", not a bare miss. The notice
// self-gates: it stays silent unless a project MCP config was actually excluded.
emitTrustNotice(stderr, trustSkip{
excludedProjectConfig: mcpExcludeProject && projectMCPConfigExists(cwd),
trustCheckErrored: trustCheckErrored,
})
return writeAppError(stderr, fmt.Sprintf("MCP server %q is not configured", serverName), exitCrash)
}
if raw.Disabled {
+13 -31
View File
@@ -55,12 +55,8 @@ func runMCPOAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps ap
}
serverName := positional[0]
server, skip, err := resolveOAuthServer(deps, serverName)
server, err := resolveOAuthServer(deps, serverName)
if err != nil {
// When the named server was dropped because the workspace is untrusted, explain
// that with the same one-line notice the other trust-gated MCP paths emit, so a
// gated project OAuth server reads as "run zero trust", not a bare miss.
emitTrustNotice(stderr, skip)
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
@@ -86,7 +82,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.SaveForServer(server, token); err != nil {
if err := store.Save(serverName, token); err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
@@ -116,7 +112,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.DeleteForServerName(serverName)
removed, err := store.Delete(serverName)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
@@ -197,47 +193,33 @@ func filterTokenStatuses(statuses []mcp.TokenStatus, serverName string) []mcp.To
}
// resolveOAuthServer loads the workspace MCP config and returns the named server
// after verifying it declares OAuth authentication. It also returns a trustSkip so the
// caller can emit the workspace-trust notice when a project server was dropped.
//
// Login is gated behind workspace trust (fail-closed) like the stdio spawn paths: it
// loads a project-scoped MCP server and drives its OAuth flow (endpoint discovery,
// dynamic client registration, code exchange, token persist) against the server's
// configured URL. That is a project-scoped server being activated and contacted, so an
// untrusted clone's ./.zero/config.json OAuth server must be dropped rather than loaded —
// leaving it ungated would let a cloned repo initiate outbound OAuth I/O and persist a
// token. There is no --worktree reassignment on this command path, so trustRoot == cwd.
func resolveOAuthServer(deps appDeps, serverName string) (mcp.Server, trustSkip, error) {
// after verifying it declares OAuth authentication.
func resolveOAuthServer(deps appDeps, serverName string) (mcp.Server, error) {
if err := mcp.ValidateServerName(serverName); err != nil {
return mcp.Server{}, trustSkip{}, err
return mcp.Server{}, err
}
cwd, err := deps.getwd()
if err != nil {
return mcp.Server{}, trustSkip{}, fmt.Errorf("failed to resolve workspace: %w", err)
return mcp.Server{}, fmt.Errorf("failed to resolve workspace: %w", err)
}
excludeProject, trustCheckErrored := resolveTrust(cwd)
skip := trustSkip{
excludedProjectConfig: excludeProject && projectMCPConfigExists(cwd),
trustCheckErrored: trustCheckErrored,
}
cfg, err := deps.resolveMCPConfig(cwd, excludeProject)
cfg, err := deps.resolveMCPConfig(cwd)
if err != nil {
return mcp.Server{}, skip, err
return mcp.Server{}, err
}
servers, err := mcp.NormalizeConfig(cfg)
if err != nil {
return mcp.Server{}, skip, err
return mcp.Server{}, err
}
for _, server := range servers {
if server.Name != serverName {
continue
}
if !strings.EqualFold(server.Auth, mcp.ServerAuthOAuth) {
return mcp.Server{}, skip, fmt.Errorf("MCP server %q does not declare auth: \"oauth\"", serverName)
return mcp.Server{}, fmt.Errorf("MCP server %q does not declare auth: \"oauth\"", serverName)
}
return server, skip, nil
return server, nil
}
return mcp.Server{}, skip, fmt.Errorf("MCP server %q is not configured", serverName)
return mcp.Server{}, fmt.Errorf("MCP server %q is not configured", serverName)
}
func oauthConfigForServer(server mcp.Server) mcp.OAuthConfig {
+17 -316
View File
@@ -6,17 +6,14 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/workspacetrust"
)
// syncBuffer is a goroutine-safe writer used when a background goroutine reads
@@ -88,9 +85,6 @@ 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
@@ -104,9 +98,6 @@ 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) {
@@ -128,24 +119,23 @@ 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(_ string, _ bool) (config.MCPConfig, error) {
return mcpConfig, 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
},
now: time.Now,
}
@@ -180,13 +170,9 @@ func TestRunMCPOAuthLoginStoresTokens(t *testing.T) {
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stderr=%s stdout=%s", exitCode, stderr.String(), stdout.String())
}
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])
token, ok, err := store.Load("remote")
if err != nil || !ok {
t.Fatalf("LoadForServer() ok=%v err=%v", ok, err)
t.Fatalf("Load() ok=%v err=%v", ok, err)
}
if token.AccessToken != "access-final" || token.RefreshToken != "refresh-final" {
t.Fatalf("stored token = %#v", token)
@@ -208,7 +194,7 @@ func TestRunMCPOAuthLoginRejectsNonOAuthServer(t *testing.T) {
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
newMCPTokenStore: func() (*mcp.TokenStore, error) { return store, nil },
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"plain": {Type: "http", URL: "https://plain.invalid/mcp"},
}}, nil
@@ -224,291 +210,6 @@ func TestRunMCPOAuthLoginRejectsNonOAuthServer(t *testing.T) {
}
}
// TestRunMCPOAuthLoginGatedInUntrustedWorkspace is the load-bearing security test for
// the OAuth login gate: an OAuth MCP server defined ONLY in an untrusted workspace's
// ./.zero/config.json must be dropped before login. `zero mcp oauth login` must refuse
// with "not configured", surface the trust notice, and store NO token — so the
// discovery/registration/token-exchange flow never fires against the project URL. If the
// gate is reverted to resolveMCPConfig(cwd, false), the server survives, login proceeds,
// a token is stored, and every assertion below fails.
func TestRunMCPOAuthLoginGatedInUntrustedWorkspace(t *testing.T) {
setTrustConfigRoot(t) // isolates the trust store; the workspace stays untrusted
// A live server standing in for the untrusted project's OAuth server. A gated login
// must never reach it, so hits must stay 0 — a direct assertion of no outbound I/O
// (discovery/registration/token exchange) against a cloned repo's configured URL. If
// the gate leaks, mcp.Login contacts this URL and the hit count rises.
var hits atomic.Int64
oauthSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hits.Add(1)
w.WriteHeader(http.StatusNotFound)
}))
defer oauthSrv.Close()
cwd := t.TempDir()
// A real project MCP config on disk so projectMCPConfigExists() is true and the
// notice fires. resolveMCPConfig is faked, but the notice detector reads disk.
if err := os.MkdirAll(filepath.Join(cwd, ".zero"), 0o700); err != nil {
t.Fatal(err)
}
body := `{"mcp":{"servers":{"remote":{"type":"http","url":"` + oauthSrv.URL + `","auth":"oauth"}}}}`
if err := os.WriteFile(filepath.Join(cwd, ".zero", "config.json"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
store, err := mcp.NewTokenStore(mcp.TokenStoreOptions{
FilePath: filepath.Join(t.TempDir(), "mcp-oauth-tokens.json"),
})
if err != nil {
t.Fatalf("NewTokenStore() error = %v", err)
}
// Points every OAuth endpoint at the live server: if the gate leaks and login
// proceeds, the flow contacts oauthSrv and hits.Load() becomes non-zero.
projectServer := config.MCPServerConfig{
Type: "http",
URL: oauthSrv.URL,
Auth: "oauth",
OAuth: &config.MCPOAuthConfig{
ClientID: "client-123",
AuthorizationEndpoint: oauthSrv.URL + "/authorize",
TokenEndpoint: oauthSrv.URL + "/token",
Scopes: []string{"read"},
},
}
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
newMCPTokenStore: func() (*mcp.TokenStore, error) { return store, nil },
resolveMCPConfig: func(_ string, excludeProject bool) (config.MCPConfig, error) {
servers := map[string]config.MCPServerConfig{}
if !excludeProject {
servers["remote"] = projectServer
}
return config.MCPConfig{Servers: servers}, nil
},
now: time.Now,
}
// The gate must short-circuit BEFORE the interactive OAuth flow. Bound the run so a
// regression that drops the gate (login proceeds and blocks on the loopback callback)
// fails here in 10s instead of hanging until the package test timeout.
stdout := &syncBuffer{}
stderr := &syncBuffer{}
done := make(chan int, 1)
go func() {
done <- runWithDeps([]string{"mcp", "oauth", "login", "remote"}, stdout, stderr, deps)
}()
var exitCode int
select {
case exitCode = <-done:
case <-time.After(10 * time.Second):
t.Fatalf("login was not gated; it blocked on the OAuth flow — the untrusted gate is missing. stderr=%q", stderr.String())
}
if exitCode == exitSuccess {
t.Fatalf("login on a project-only OAuth server in an untrusted workspace must fail; stdout=%q stderr=%q", stdout.String(), stderr.String())
}
if !strings.Contains(stderr.String(), "not configured") {
t.Fatalf("stderr must report the server as not configured, got %q", stderr.String())
}
if !strings.Contains(stderr.String(), "MCP servers") || !strings.Contains(stderr.String(), "zero trust") {
t.Fatalf("stderr must surface the workspace-trust notice, got %q", stderr.String())
}
if _, ok, _ := store.Load("remote"); ok {
t.Fatal("no token must be stored when login is gated")
}
if got := hits.Load(); got != 0 {
t.Fatalf("gated login must never contact the project OAuth server URL, got %d hit(s)", got)
}
}
// TestResolveOAuthServerTrustedWorkspaceReturnsServer proves the gate does not
// over-block: in a TRUSTED workspace the project OAuth server resolves normally with a
// clean skip, so login proceeds as before. Calls resolveOAuthServer directly to avoid
// driving the full interactive login flow.
func TestResolveOAuthServerTrustedWorkspaceReturnsServer(t *testing.T) {
setTrustConfigRoot(t)
cwd := t.TempDir()
if err := workspacetrust.Trust(cwd); err != nil {
t.Fatalf("Trust(cwd): %v", err)
}
var gotExclude bool
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(_ string, excludeProject bool) (config.MCPConfig, error) {
gotExclude = excludeProject
servers := map[string]config.MCPServerConfig{}
if !excludeProject {
servers["remote"] = oauthTestServerConfig()
}
return config.MCPConfig{Servers: servers}, nil
},
}
server, skip, err := resolveOAuthServer(deps, "remote")
if err != nil {
t.Fatalf("trusted workspace must resolve the project OAuth server, got err %v", err)
}
if gotExclude {
t.Fatalf("trusted workspace must resolve MCP config with excludeProject=false")
}
if server.Name != "remote" {
t.Fatalf("server.Name = %q, want remote", server.Name)
}
if skip.excludedProjectConfig {
t.Fatalf("trusted workspace must not report an excluded project config")
}
}
// TestResolveOAuthServerGatedInUntrustedWorkspace is the fast load-bearing guard for the
// gate: in an untrusted workspace a project-only OAuth server is dropped, so resolution
// fails with "not configured" and flags the excluded project config for the notice.
// Reverting the gate to resolveMCPConfig(cwd, false) makes the server resolve and this
// test fails immediately (no 10s login-flow timeout needed).
func TestResolveOAuthServerGatedInUntrustedWorkspace(t *testing.T) {
setTrustConfigRoot(t)
cwd := t.TempDir()
if err := os.MkdirAll(filepath.Join(cwd, ".zero"), 0o700); err != nil {
t.Fatal(err)
}
body := `{"mcp":{"servers":{"remote":{"type":"http","url":"https://remote.invalid/mcp","auth":"oauth"}}}}`
if err := os.WriteFile(filepath.Join(cwd, ".zero", "config.json"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(_ string, excludeProject bool) (config.MCPConfig, error) {
servers := map[string]config.MCPServerConfig{}
if !excludeProject {
servers["remote"] = oauthTestServerConfig()
}
return config.MCPConfig{Servers: servers}, nil
},
}
_, skip, err := resolveOAuthServer(deps, "remote")
if err == nil {
t.Fatal("untrusted workspace must not resolve a project-only OAuth server")
}
if !strings.Contains(err.Error(), "not configured") {
t.Fatalf("want not-configured error, got %v", err)
}
if !skip.excludedProjectConfig {
t.Fatal("a dropped project OAuth config must flag excludedProjectConfig for the notice")
}
if skip.trustCheckErrored {
t.Fatal("a clean untrusted verdict is not a store-read error")
}
}
func oauthTestServerConfig() config.MCPServerConfig {
return config.MCPServerConfig{
Type: "http",
URL: "https://remote.invalid/mcp",
Auth: "oauth",
OAuth: &config.MCPOAuthConfig{
ClientID: "client-123",
AuthorizationEndpoint: "https://remote.invalid/authorize",
TokenEndpoint: "https://remote.invalid/token",
Scopes: []string{"read"},
},
}
}
// TestRunMCPOAuthLoginTrustedProjectServerStoresToken proves the gate does not over-block
// end-to-end: in a TRUSTED workspace, a project-only OAuth server (returned by the fake
// ONLY when excludeProject is false) is resolved, the full login flow runs, and the token
// is persisted with exitSuccess. This exercises the trusted-resolve + login-success path
// as one execution; if trust were dropped, excludeProject would be true, the fake would
// drop the server, and login would fail with "not configured".
func TestRunMCPOAuthLoginTrustedProjectServerStoresToken(t *testing.T) {
setTrustConfigRoot(t)
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "access-final",
"refresh_token": "refresh-final",
"token_type": "Bearer",
"expires_in": 3600,
})
}))
defer tokenServer.Close()
cwd := t.TempDir()
if err := workspacetrust.Trust(cwd); err != nil {
t.Fatalf("Trust(cwd): %v", err)
}
store, err := mcp.NewTokenStore(mcp.TokenStoreOptions{
FilePath: filepath.Join(t.TempDir(), "mcp-oauth-tokens.json"),
})
if err != nil {
t.Fatalf("NewTokenStore() error = %v", err)
}
projectServer := config.MCPServerConfig{
Type: "http",
URL: "https://remote.invalid/mcp",
Auth: "oauth",
OAuth: &config.MCPOAuthConfig{
ClientID: "client-123",
AuthorizationEndpoint: "https://remote.invalid/authorize",
TokenEndpoint: tokenServer.URL,
Scopes: []string{"read"},
},
}
var gotExclude bool
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
newMCPTokenStore: func() (*mcp.TokenStore, error) { return store, nil },
resolveMCPConfig: func(_ string, excludeProject bool) (config.MCPConfig, error) {
gotExclude = excludeProject
servers := map[string]config.MCPServerConfig{}
if !excludeProject {
servers["remote"] = projectServer
}
return config.MCPConfig{Servers: servers}, nil
},
now: time.Now,
}
stdout := &syncBuffer{}
stderr := &syncBuffer{}
go func() {
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
callbackURL := extractCallbackURL(stdout.String())
if callbackURL != "" {
_, _ = http.Get(callbackURL)
return
}
time.Sleep(5 * time.Millisecond)
}
}()
done := make(chan int, 1)
go func() {
done <- runWithDeps([]string{"mcp", "oauth", "login", "remote"}, stdout, stderr, deps)
}()
var exitCode int
select {
case exitCode = <-done:
case <-time.After(10 * time.Second):
t.Fatalf("trusted login did not complete within 10s; stderr=%s stdout=%s", stderr.String(), stdout.String())
}
if exitCode != exitSuccess {
t.Fatalf("trusted login must succeed; exit=%d stderr=%s", exitCode, stderr.String())
}
if gotExclude {
t.Fatal("trusted workspace must resolve MCP config with excludeProject=false")
}
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{})
+6 -22
View File
@@ -19,38 +19,22 @@ type mcpToolListItem struct {
Permission string `json:"permission"`
}
// registerMCPToolsForWorkspace resolves and registers the workspace's MCP servers.
// This spawns stdio servers, so it gates the project config layer behind the
// workspace-trust check: trustRoot is the ORIGINAL launch directory (resolved before
// any --worktree reassignment) so a worktree of a trusted repo inherits that trust.
// resolveTrust fails closed, so an empty trustRoot or a store-read error excludes the
// project layer and a cloned repo cannot spawn its ./.zero/config.json MCP servers.
//
// It returns a trustSkip alongside the runtime so the caller can fold the MCP gate
// into the one-line trust notice (mirroring the hooks and plugins chokepoints);
// otherwise a workspace whose only project config is MCP would be gated silently.
func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, registry *tools.Registry, deps appDeps, autonomy mcp.PermissionAutonomy, trustRoot string) (mcpToolRuntime, trustSkip, error) {
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
skip := trustSkip{
excludedProjectConfig: excludeProject && projectMCPConfigExists(workspaceRoot),
trustCheckErrored: trustCheckErrored,
}
cfg, err := deps.resolveMCPConfig(workspaceRoot, excludeProject)
func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, registry *tools.Registry, deps appDeps, autonomy mcp.PermissionAutonomy) (mcpToolRuntime, error) {
cfg, err := deps.resolveMCPConfig(workspaceRoot)
if err != nil {
return nil, skip, err
return nil, err
}
if len(cfg.Servers) == 0 {
return noopMCPRuntime{}, skip, nil
return noopMCPRuntime{}, nil
}
store, err := deps.newMCPStore()
if err != nil {
return nil, skip, err
return nil, err
}
runtime, err := deps.registerMCPTools(ctx, registry, cfg, mcp.RegisterOptions{
return deps.registerMCPTools(ctx, registry, cfg, mcp.RegisterOptions{
PermissionStore: store,
Autonomy: autonomy,
})
return runtime, skip, err
}
func execMCPAutonomy(options execOptions) mcp.PermissionAutonomy {
-221
View File
@@ -1,221 +0,0 @@
package cli
// Edge-case coverage for the MCP trust gate on the oauth-login and check paths:
// the error-return contract of the gated resolveOAuthServer, the trust-store-read-error
// notice variant, and the accepted (advisory) notice over-emission on the not-oauth path.
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
)
// writeProjectMCPConfig drops a ./.zero/config.json under dir declaring one MCP server,
// so projectMCPConfigExists() reads it as present.
func writeProjectMCPConfig(t *testing.T, dir string) {
t.Helper()
if err := os.MkdirAll(filepath.Join(dir, ".zero"), 0o700); err != nil {
t.Fatal(err)
}
body := `{"mcp":{"servers":{"proj":{"type":"stdio","command":"proj-cmd"}}}}`
if err := os.WriteFile(filepath.Join(dir, ".zero", "config.json"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
// breakTrustStore makes the trust store unreadable (creates trust.json as a directory),
// so workspacetrust.IsTrusted returns an error and resolveTrust fails closed with
// trustCheckErrored=true.
func breakTrustStore(t *testing.T) {
t.Helper()
configRoot := setTrustConfigRoot(t)
if err := os.MkdirAll(filepath.Join(configRoot, "zero", "trust.json"), 0o700); err != nil {
t.Fatal(err)
}
}
// TestResolveOAuthServerValidationErrorReturnsZeroSkip locks the new 3-value contract on
// the pre-resolve error path: an invalid server name errors before trust is resolved, so
// the returned skip is zero (nothing was excluded yet).
func TestResolveOAuthServerValidationErrorReturnsZeroSkip(t *testing.T) {
deps := appDeps{getwd: func() (string, error) { return t.TempDir(), nil }}
_, skip, err := resolveOAuthServer(deps, "bad name with spaces")
if err == nil {
t.Fatal("invalid server name must error")
}
if skip.excludedProjectConfig || skip.trustCheckErrored {
t.Fatalf("a validation error must return a zero skip, got %+v", skip)
}
}
// TestResolveOAuthServerGetwdErrorPropagates covers the getwd error path of the gated
// resolver.
func TestResolveOAuthServerGetwdErrorPropagates(t *testing.T) {
deps := appDeps{getwd: func() (string, error) { return "", errors.New("boom") }}
_, _, err := resolveOAuthServer(deps, "remote")
if err == nil || !strings.Contains(err.Error(), "failed to resolve workspace") {
t.Fatalf("getwd error must propagate, got %v", err)
}
}
// TestRunMCPOAuthLoginStoreErrorNotice proves the store-read-error branch: when the trust
// store cannot be read, login fails closed AND the notice names the store error (the
// "could not be read" variant) rather than the plain untrusted wording.
func TestRunMCPOAuthLoginStoreErrorNotice(t *testing.T) {
breakTrustStore(t)
cwd := t.TempDir()
writeProjectMCPConfig(t, cwd)
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(_ string, excludeProject bool) (config.MCPConfig, error) {
servers := map[string]config.MCPServerConfig{}
if !excludeProject {
servers["proj"] = config.MCPServerConfig{Type: "http", URL: "https://x.invalid", Auth: "oauth"}
}
return config.MCPConfig{Servers: servers}, nil
},
}
var out, errBuf bytes.Buffer
if code := runWithDeps([]string{"mcp", "oauth", "login", "proj"}, &out, &errBuf, deps); code == exitSuccess {
t.Fatal("login must fail closed on a trust-store read error")
}
if got := errBuf.String(); !strings.Contains(got, "could not be read") || !strings.Contains(got, "not configured") {
t.Fatalf("store-error login must emit the store-errored notice AND not-configured, got %q", got)
}
}
// TestRunMCPCheckStoreErrorNotice is the mcp-check sibling of the store-error case.
func TestRunMCPCheckStoreErrorNotice(t *testing.T) {
breakTrustStore(t)
cwd := t.TempDir()
writeProjectMCPConfig(t, cwd)
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(_ string, excludeProject bool) (config.MCPConfig, error) {
servers := map[string]config.MCPServerConfig{}
if !excludeProject {
servers["proj"] = config.MCPServerConfig{Type: "stdio", Command: "proj-cmd"}
}
return config.MCPConfig{Servers: servers}, nil
},
}
var out, errBuf bytes.Buffer
if code := runWithDeps([]string{"mcp", "check", "proj"}, &out, &errBuf, deps); code == exitSuccess {
t.Fatal("mcp check must fail closed on a trust-store read error")
}
if got := errBuf.String(); !strings.Contains(got, "could not be read") {
t.Fatalf("mcp check store-error must emit the store-errored notice, got %q", got)
}
}
// TestRunMCPOAuthLoginNoticeCoFiresWithNonOAuthError documents accepted behavior: in an
// untrusted workspace with project MCP config on disk, a login that fails because the
// named USER-config server is not OAuth still co-emits the trust notice (project MCP was
// excluded this run, which is true). The notice is advisory and keyed on "project config
// was dropped", not on "this server was the dropped one" — the same coarse scoping the
// mcp-check path uses. If this behavior changes intentionally, update this test.
func TestRunMCPOAuthLoginNoticeCoFiresWithNonOAuthError(t *testing.T) {
setTrustConfigRoot(t) // untrusted
cwd := t.TempDir()
writeProjectMCPConfig(t, cwd)
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
// "foo" is a user-config server (survives the gate) that does not declare oauth.
resolveMCPConfig: func(_ string, _ bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"foo": {Type: "http", URL: "https://foo.invalid"},
}}, nil
},
}
var out, errBuf bytes.Buffer
if code := runWithDeps([]string{"mcp", "oauth", "login", "foo"}, &out, &errBuf, deps); code == exitSuccess {
t.Fatal("login on a non-oauth server must fail")
}
got := errBuf.String()
if !strings.Contains(got, "oauth") {
t.Fatalf("want the not-oauth error, got %q", got)
}
if !strings.Contains(got, "zero trust") {
t.Fatalf("expected the advisory trust notice to co-fire (accepted behavior), got %q", got)
}
}
// TestResolveOAuthServerResolveConfigErrorPropagates covers the resolveMCPConfig-error
// return of the gated resolver: the error propagates with the computed skip.
func TestResolveOAuthServerResolveConfigErrorPropagates(t *testing.T) {
setTrustConfigRoot(t)
deps := appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
resolveMCPConfig: func(_ string, _ bool) (config.MCPConfig, error) {
return config.MCPConfig{}, errors.New("resolve boom")
},
}
_, _, err := resolveOAuthServer(deps, "remote")
if err == nil || !strings.Contains(err.Error(), "resolve boom") {
t.Fatalf("resolveMCPConfig error must propagate, got %v", err)
}
}
// TestResolveOAuthServerNormalizeErrorPropagates covers the NormalizeConfig-error return:
// a malformed server (stdio with no command) fails normalization.
func TestResolveOAuthServerNormalizeErrorPropagates(t *testing.T) {
setTrustConfigRoot(t)
deps := appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
resolveMCPConfig: func(_ string, _ bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"remote": {Type: "stdio"}, // no command -> NormalizeConfig rejects
}}, nil
},
}
_, _, err := resolveOAuthServer(deps, "remote")
if err == nil || !strings.Contains(err.Error(), "requires command") {
t.Fatalf("NormalizeConfig error must propagate, got %v", err)
}
}
// TestRunMCPCheckDisabledServer covers the found-but-disabled branch of runMCPCheck.
func TestRunMCPCheckDisabledServer(t *testing.T) {
setTrustConfigRoot(t)
deps := appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
resolveMCPConfig: func(_ string, _ bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"proj": {Type: "stdio", Command: "c", Disabled: true},
}}, nil
},
}
var out, errBuf bytes.Buffer
if code := runWithDeps([]string{"mcp", "check", "proj"}, &out, &errBuf, deps); code == exitSuccess {
t.Fatal("mcp check on a disabled server must fail")
}
if got := errBuf.String(); !strings.Contains(got, "is disabled") {
t.Fatalf("want disabled error, got %q", got)
}
}
// TestRunMCPCheckNormalizeError covers the found-but-malformed branch of runMCPCheck: the
// scoped NormalizeConfig fails before any spawn.
func TestRunMCPCheckNormalizeError(t *testing.T) {
setTrustConfigRoot(t)
deps := appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
resolveMCPConfig: func(_ string, _ bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"proj": {Type: "stdio"}, // no command
}}, nil
},
}
var out, errBuf bytes.Buffer
if code := runWithDeps([]string{"mcp", "check", "proj"}, &out, &errBuf, deps); code == exitSuccess {
t.Fatal("mcp check on a malformed server must fail")
}
if got := errBuf.String(); !strings.Contains(got, "requires command") {
t.Fatalf("want normalize error, got %q", got)
}
}
-358
View File
@@ -1,358 +0,0 @@
package cli
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/workspacetrust"
)
// mcpTrustDeps builds an appDeps whose resolveMCPConfig HONORS excludeProject: it
// returns a project stdio server only when excludeProject is false, mirroring the
// real ResolveMCP gate. It records the excludeProject value it was called with and
// whether registerMCPTools (which spawns servers) actually fired.
func mcpTrustDeps(gotExclude *bool, spawned *bool) appDeps {
return appDeps{
resolveMCPConfig: func(_ string, excludeProject bool) (config.MCPConfig, error) {
*gotExclude = excludeProject
servers := map[string]config.MCPServerConfig{}
if !excludeProject {
servers["proj-srv"] = config.MCPServerConfig{Type: "stdio", Command: "proj-cmd"}
}
return config.MCPConfig{Servers: servers}, nil
},
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
registerMCPTools: func(_ context.Context, _ *tools.Registry, _ config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
*spawned = true
return closeFunc(func() error { return nil }), nil
},
}
}
// TestMCPGateUntrustedExcludesProjectServer proves the P0 fix: an untrusted trustRoot
// makes registerMCPToolsForWorkspace resolve MCP config with excludeProject=true and,
// because that drops the project server, it never spawns anything. This test is
// load-bearing: if the gate is removed (a hardcoded excludeProject=false passed to
// resolveMCPConfig), gotExclude is false, the project server survives, spawned flips
// true, and every assertion below fails.
func TestMCPGateUntrustedExcludesProjectServer(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir() // never trusted
var gotExclude, spawned bool
deps := mcpTrustDeps(&gotExclude, &spawned)
registry := tools.NewRegistry()
runtime, skip, err := registerMCPToolsForWorkspace(context.Background(), repo, registry, deps, mcp.AutonomyLow, repo)
if err != nil {
t.Fatalf("registerMCPToolsForWorkspace: %v", err)
}
defer func() { _ = runtime.Close() }()
if !gotExclude {
t.Fatalf("untrusted workspace must resolve MCP config with excludeProject=true")
}
if spawned {
t.Fatalf("untrusted workspace must not spawn the project MCP server")
}
if _, ok := runtime.(noopMCPRuntime); !ok {
t.Fatalf("with the project server dropped, the runtime should be the noop runtime, got %T", runtime)
}
// This repo has no ./.zero/config.json, so there is no project MCP config to
// notice about even though it is untrusted; the skip must stay clean.
if skip.excludedProjectConfig {
t.Fatalf("no project MCP config on disk, so the skip must not flag an excluded config")
}
if skip.trustCheckErrored {
t.Fatalf("a clean untrusted verdict is not a store-read error")
}
}
// TestMCPGateEmptyTrustRootFailsClosed proves fail-closed-by-construction: a caller
// that forgot to resolve trustRoot (empty) still excludes the project layer.
func TestMCPGateEmptyTrustRootFailsClosed(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir()
// Even trusting the repo must not help when the caller passes an empty root.
if err := workspacetrust.Trust(repo); err != nil {
t.Fatalf("Trust(repo): %v", err)
}
var gotExclude, spawned bool
deps := mcpTrustDeps(&gotExclude, &spawned)
registry := tools.NewRegistry()
runtime, skip, err := registerMCPToolsForWorkspace(context.Background(), repo, registry, deps, mcp.AutonomyLow, "")
if err != nil {
t.Fatalf("registerMCPToolsForWorkspace: %v", err)
}
defer func() { _ = runtime.Close() }()
if !gotExclude {
t.Fatalf("empty trustRoot must fail closed (excludeProject=true)")
}
if spawned {
t.Fatalf("empty trustRoot must not spawn the project MCP server")
}
// Empty trustRoot is a clean fail-closed verdict, not a store-read error.
if skip.trustCheckErrored {
t.Fatalf("empty trustRoot is not a store-read error")
}
}
// TestMCPGateTrustedSpawnsProjectServer proves R3 for MCP: after Trust(repo) the
// project layer is included (excludeProject=false) and the project server spawns.
func TestMCPGateTrustedSpawnsProjectServer(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir()
if err := workspacetrust.Trust(repo); err != nil {
t.Fatalf("Trust(repo): %v", err)
}
var gotExclude, spawned bool
deps := mcpTrustDeps(&gotExclude, &spawned)
registry := tools.NewRegistry()
runtime, skip, err := registerMCPToolsForWorkspace(context.Background(), repo, registry, deps, mcp.AutonomyLow, repo)
if err != nil {
t.Fatalf("registerMCPToolsForWorkspace: %v", err)
}
defer func() { _ = runtime.Close() }()
if gotExclude {
t.Fatalf("trusted workspace must resolve MCP config with excludeProject=false")
}
if !spawned {
t.Fatalf("trusted workspace must spawn the project MCP server")
}
if skip.excludedProjectConfig {
t.Fatalf("trusted workspace must not report the project MCP layer excluded")
}
}
// TestMCPToolsListSurfacesTrustNotice proves `zero mcp tools list` no longer drops the
// project MCP layer silently in an untrusted workspace: the gated skip is surfaced on
// stderr (the list stays on stdout), so an empty list is explained rather than read as
// "nothing configured". Trusting the repo silences it.
func TestMCPToolsListSurfacesTrustNotice(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir()
if err := os.MkdirAll(filepath.Join(repo, ".zero"), 0o700); err != nil {
t.Fatal(err)
}
body := `{"mcp":{"servers":{"proj":{"type":"stdio","command":"proj-cmd"}}}}`
if err := os.WriteFile(filepath.Join(repo, ".zero", "config.json"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
run := func() string {
var out, errBuf bytes.Buffer
deps := appDeps{
getwd: func() (string, error) { return repo, nil },
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
}
if code := runWithDeps([]string{"mcp", "tools", "list"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("mcp tools list exit = %d, stderr=%q", code, errBuf.String())
}
return errBuf.String()
}
// Untrusted: the gated project MCP layer must be explained on stderr.
if errUntrusted := run(); !strings.Contains(errUntrusted, "MCP servers") || !strings.Contains(errUntrusted, "zero trust") {
t.Fatalf("untrusted `mcp tools list` must surface the trust notice on stderr, got %q", errUntrusted)
}
// Trusted: nothing is skipped, so no notice.
if err := workspacetrust.Trust(repo); err != nil {
t.Fatal(err)
}
if errTrusted := run(); strings.Contains(errTrusted, "ignoring project") {
t.Fatalf("trusted `mcp tools list` must not emit a trust notice, got %q", errTrusted)
}
}
// TestProjectMCPConfigExists exercises every branch of the notice-gating detector:
// only a ./.zero/config.json that parses AND declares at least one server is true.
func TestProjectMCPConfigExists(t *testing.T) {
writeCfg := func(t *testing.T, dir, body string) {
t.Helper()
if err := os.MkdirAll(filepath.Join(dir, ".zero"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, ".zero", "config.json"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
if projectMCPConfigExists("") {
t.Fatal("empty workspace root must be false")
}
cases := []struct {
name string
body string // "" means write no config.json at all
want bool
}{
{"no file", "", false},
{"declares a server", `{"mcp":{"servers":{"a":{"type":"stdio","command":"x"}}}}`, true},
{"empty servers map", `{"mcp":{"servers":{}}}`, false},
{"config without mcp key", `{"model":"x"}`, false},
{"unparseable json", `{not valid`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
if tc.body != "" {
writeCfg(t, dir, tc.body)
}
if got := projectMCPConfigExists(dir); got != tc.want {
t.Fatalf("projectMCPConfigExists = %v, want %v", got, tc.want)
}
})
}
}
// TestMCPGateFailClosedOnStoreError proves the MCP surface reports a store-read error
// (trust.json created as a directory) as trustCheckErrored, so the caller's notice can
// name the fail-closed reason -- the same error path the hook and plugin gates cover.
func TestMCPGateFailClosedOnStoreError(t *testing.T) {
configRoot := setTrustConfigRoot(t)
trustPath := filepath.Join(configRoot, "zero", "trust.json")
if err := os.MkdirAll(trustPath, 0o700); err != nil {
t.Fatalf("create trust.json as a directory: %v", err)
}
repo := t.TempDir()
if err := os.MkdirAll(filepath.Join(repo, ".zero"), 0o700); err != nil {
t.Fatalf("mkdir project .zero: %v", err)
}
body := `{"mcp":{"servers":{"proj":{"type":"stdio","command":"proj-cmd"}}}}`
if err := os.WriteFile(filepath.Join(repo, ".zero", "config.json"), []byte(body), 0o600); err != nil {
t.Fatalf("write project config.json: %v", err)
}
var gotExclude, spawned bool
deps := mcpTrustDeps(&gotExclude, &spawned)
runtime, skip, err := registerMCPToolsForWorkspace(context.Background(), repo, tools.NewRegistry(), deps, mcp.AutonomyLow, repo)
if err != nil {
t.Fatalf("registerMCPToolsForWorkspace: %v", err)
}
defer func() { _ = runtime.Close() }()
if !gotExclude {
t.Fatalf("a store-read error must fail closed (excludeProject=true)")
}
if spawned {
t.Fatalf("a store-read error must not spawn the project MCP server")
}
if !skip.trustCheckErrored {
t.Fatalf("skip must mark the store-read error so the notice can name it")
}
if !skip.excludedProjectConfig {
t.Fatalf("the project MCP layer must be reported excluded on the error path")
}
}
// TestMCPGateUntrustedNoticesProjectMCPConfig proves the notice-surfacing fix: when an
// untrusted workspace actually declares project MCP servers in ./.zero/config.json,
// registerMCPToolsForWorkspace reports excludedProjectConfig=true so the caller can
// warn (the CodeRabbit finding: project MCP was gated silently). Trusting the repo
// clears the skip.
func TestMCPGateUntrustedNoticesProjectMCPConfig(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir()
// A real project MCP config on disk: projectMCPConfigExists reads this file.
if err := os.MkdirAll(filepath.Join(repo, ".zero"), 0o700); err != nil {
t.Fatalf("mkdir project .zero: %v", err)
}
body := `{"mcp":{"servers":{"proj":{"type":"stdio","command":"proj-cmd"}}}}`
if err := os.WriteFile(filepath.Join(repo, ".zero", "config.json"), []byte(body), 0o600); err != nil {
t.Fatalf("write project config.json: %v", err)
}
var gotExclude, spawned bool
deps := mcpTrustDeps(&gotExclude, &spawned)
registry := tools.NewRegistry()
// Untrusted: the project MCP layer is dropped AND flagged for the notice.
runtime, skip, err := registerMCPToolsForWorkspace(context.Background(), repo, registry, deps, mcp.AutonomyLow, repo)
if err != nil {
t.Fatalf("registerMCPToolsForWorkspace (untrusted): %v", err)
}
defer func() { _ = runtime.Close() }()
if !skip.excludedProjectConfig {
t.Fatalf("untrusted workspace with project MCP config must flag excludedProjectConfig for the notice")
}
if skip.trustCheckErrored {
t.Fatalf("a clean untrusted verdict is not a store-read error")
}
// Trusted: nothing is skipped, so no notice.
if err := workspacetrust.Trust(repo); err != nil {
t.Fatalf("Trust(repo): %v", err)
}
_, trustedSkip, err := registerMCPToolsForWorkspace(context.Background(), repo, tools.NewRegistry(), deps, mcp.AutonomyLow, repo)
if err != nil {
t.Fatalf("registerMCPToolsForWorkspace (trusted): %v", err)
}
if trustedSkip.excludedProjectConfig {
t.Fatalf("trusted workspace must not flag an excluded project MCP layer")
}
}
// TestMCPCheckSurfacesTrustNotice proves the `zero mcp check` notice fix: in an untrusted
// workspace whose only definition of a server lives in ./.zero/config.json, the gate
// drops the server and the command must emit the one-line trust notice before the
// "not configured" error, instead of a bare miss that hides the trust exclusion. The R4
// half proves the notice self-gates: a genuinely-absent server in a workspace with no
// project config on disk prints "not configured" with no notice.
func TestMCPCheckSurfacesTrustNotice(t *testing.T) {
setTrustConfigRoot(t)
// resolveMCPConfig fake that HONORS excludeProject: `proj` survives only when the
// project layer is included, mirroring the real gate.
dropFake := func(_ string, excludeProject bool) (config.MCPConfig, error) {
servers := map[string]config.MCPServerConfig{}
if !excludeProject {
servers["proj"] = config.MCPServerConfig{Type: "stdio", Command: "proj-cmd"}
}
return config.MCPConfig{Servers: servers}, nil
}
// Untrusted workspace whose ONLY definition of `proj` is project config on disk.
repo := t.TempDir()
if err := os.MkdirAll(filepath.Join(repo, ".zero"), 0o700); err != nil {
t.Fatal(err)
}
body := `{"mcp":{"servers":{"proj":{"type":"stdio","command":"proj-cmd"}}}}`
if err := os.WriteFile(filepath.Join(repo, ".zero", "config.json"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
var out, errBuf bytes.Buffer
deps := appDeps{getwd: func() (string, error) { return repo, nil }, resolveMCPConfig: dropFake}
if code := runWithDeps([]string{"mcp", "check", "proj"}, &out, &errBuf, deps); code == exitSuccess {
t.Fatalf("mcp check on a gated project server must fail, got success; stderr=%q", errBuf.String())
}
if got := errBuf.String(); !strings.Contains(got, "not configured") ||
!strings.Contains(got, "MCP servers") || !strings.Contains(got, "zero trust") {
t.Fatalf("untrusted mcp check must report not-configured AND surface the trust notice, got %q", got)
}
// R4: an untrusted workspace with NO project MCP config on disk has nothing to
// notice about, so an absent server prints "not configured" with no trust notice.
bare := t.TempDir()
var out2, errBuf2 bytes.Buffer
deps2 := appDeps{getwd: func() (string, error) { return bare, nil }, resolveMCPConfig: dropFake}
if code := runWithDeps([]string{"mcp", "check", "ghost"}, &out2, &errBuf2, deps2); code == exitSuccess {
t.Fatalf("mcp check on an absent server must fail")
}
if got := errBuf2.String(); !strings.Contains(got, "not configured") {
t.Fatalf("stderr must report not configured, got %q", got)
}
if got := errBuf2.String(); strings.Contains(got, "ignoring project") || strings.Contains(got, "zero trust") {
t.Fatalf("no project config on disk means no trust notice, got %q", got)
}
}
+5 -36
View File
@@ -3,8 +3,6 @@ package cli
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/Gitlawb/zero/internal/agent"
@@ -17,13 +15,9 @@ import (
// later dispatcher + skill wiring can consume it: the plugin hook definitions and
// the plugin skill search roots. The zero value (no plugins) is inert — the
// dispatcher gets no extra hooks and the skill tool keeps only the default dir.
// The embedded trustSkip reports whether the project plugin layer was dropped for
// an untrusted workspace, so the caller can fold it into the single combined
// trust notice.
type pluginActivation struct {
hooks []hooks.Definition
skillRoots []string
trustSkip
}
// activatePlugins loads the workspace's plugins and makes their declared
@@ -33,26 +27,12 @@ type pluginActivation struct {
//
// It fails OPEN: any load error (or a malformed plugin) is surfaced as a warning
// on stderr and otherwise skipped, so a broken plugin can never wedge startup —
// mirroring how newHookDispatcher and skills.Load tolerate bad input. That
// fail-OPEN handling of MALFORMED plugins is separate from the trust gate below.
//
// The trust check lives here, inside the chokepoint, so no caller can bypass it:
// the project plugin root is dropped (ExcludeProject) whenever trustRoot is empty,
// the trust store cannot be read, or the workspace is not trusted (fail-closed).
// trustRoot is the original launch directory (resolved before any --worktree
// reassignment). The returned activation carries the skip report so the caller can
// emit one combined notice; the notice itself is NOT emitted here.
func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDeps, stderr io.Writer, trustRoot string) pluginActivation {
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
skip := trustSkip{
excludedProjectConfig: excludeProject && projectPluginsDirExists(workspaceRoot),
trustCheckErrored: trustCheckErrored,
}
loaded, err := deps.loadPlugins(plugins.LoadOptions{Cwd: workspaceRoot, ExcludeProject: excludeProject})
// mirroring how newHookDispatcher and skills.Load tolerate bad input.
func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDeps, stderr io.Writer) pluginActivation {
loaded, err := deps.loadPlugins(plugins.LoadOptions{Cwd: workspaceRoot})
if err != nil {
writePluginActivationWarning(stderr, "failed to load plugins: "+err.Error())
return pluginActivation{trustSkip: skip}
return pluginActivation{}
}
// Load fails OPEN per plugin: a malformed manifest is recorded as a diagnostic
@@ -76,18 +56,7 @@ func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDep
registry.Register(plugins.NewSkillTool(deps.skillsDir(), result.SkillRoots))
}
return pluginActivation{hooks: result.Hooks, skillRoots: result.SkillRoots, trustSkip: skip}
}
// projectPluginsDirExists reports whether a ./.zero/plugins directory is present
// under workspaceRoot, so the caller only notices about config it actually
// skipped.
func projectPluginsDirExists(workspaceRoot string) bool {
if workspaceRoot == "" {
return false
}
info, err := os.Stat(filepath.Join(workspaceRoot, ".zero", "plugins"))
return err == nil && info.IsDir()
return pluginActivation{hooks: result.Hooks, skillRoots: result.SkillRoots}
}
// skillInfos resolves the reusable skills the model can load via the skill tool —
+6 -11
View File
@@ -51,8 +51,7 @@ func TestActivatePluginsRegistersToolAndCollectsHooks(t *testing.T) {
registry := tools.NewRegistry()
var stderr bytes.Buffer
workspace := t.TempDir()
activation := activatePlugins(workspace, registry, fakePluginDeps(t, loaded), &stderr, workspace)
activation := activatePlugins(t.TempDir(), registry, fakePluginDeps(t, loaded), &stderr)
if _, ok := registry.Get("demo_lookup"); !ok {
t.Fatalf("plugin tool not registered into the bootstrap registry")
@@ -91,8 +90,7 @@ func TestActivatePluginsRegistersPluginSkillTool(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.NewSkillTool(t.TempDir())) // core skill tool, like the real bootstrap
var stderr bytes.Buffer
workspace := t.TempDir()
activation := activatePlugins(workspace, registry, fakePluginDeps(t, loaded), &stderr, workspace)
activation := activatePlugins(t.TempDir(), registry, fakePluginDeps(t, loaded), &stderr)
if len(activation.skillRoots) != 1 {
t.Fatalf("expected one plugin skill root, got %#v", activation.skillRoots)
@@ -126,8 +124,7 @@ func TestActivatePluginsSurfacesLoadDiagnostics(t *testing.T) {
}
registry := tools.NewRegistry()
var stderr bytes.Buffer
workspace := t.TempDir()
activatePlugins(workspace, registry, deps, &stderr, workspace)
activatePlugins(t.TempDir(), registry, deps, &stderr)
if stderr.Len() == 0 {
t.Fatal("a load diagnostic must be surfaced on stderr")
@@ -146,8 +143,7 @@ func TestActivatePluginsFailsOpenOnLoadError(t *testing.T) {
}
registry := tools.NewRegistry()
var stderr bytes.Buffer
workspace := t.TempDir()
activation := activatePlugins(workspace, registry, deps, &stderr, workspace)
activation := activatePlugins(t.TempDir(), registry, deps, &stderr)
if len(activation.hooks) != 0 || len(activation.skillRoots) != 0 {
t.Fatalf("a load error must yield an inert activation, got %#v", activation)
@@ -177,8 +173,7 @@ func TestActivatePluginsWarnsOnMalformedPluginButKeepsGood(t *testing.T) {
registry := tools.NewRegistry()
var stderr bytes.Buffer
workspace := t.TempDir()
activatePlugins(workspace, registry, fakePluginDeps(t, []plugins.LoadedPlugin{bad, good}), &stderr, workspace)
activatePlugins(t.TempDir(), registry, fakePluginDeps(t, []plugins.LoadedPlugin{bad, good}), &stderr)
if _, ok := registry.Get("good_tool"); !ok {
t.Fatal("the good plugin tool must still register despite a malformed sibling")
@@ -203,7 +198,7 @@ func TestNewHookDispatcherWithExtraFoldsPluginHooks(t *testing.T) {
Enabled: true,
}}
dispatcher, _ := newHookDispatcherWithExtra(workspace, extra, workspace)
dispatcher := newHookDispatcherWithExtra(workspace, extra)
if dispatcher == nil {
t.Fatal("dispatcher should never be nil for a clean workspace")
}
-14
View File
@@ -8,7 +8,6 @@ import (
"strings"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providermodelcatalog"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
)
@@ -52,19 +51,6 @@ func runProvidersModels(args []string, stdout io.Writer, stderr io.Writer, deps
return writeAppError(stderr, err.Error(), exitProvider)
}
// Apply provider-specific model filtering for catalog-backed profiles.
// For example, opencode-go-anthropic-compatible only permits Qwen and
// MiniMax model IDs; the raw /zen/go/v1/models endpoint returns many more.
if profile.CatalogID != "" {
filtered := make([]providermodeldiscovery.Model, 0, len(models))
for _, model := range models {
if providermodelcatalog.ModelIDAllowedForProvider(profile.CatalogID, model.ID) {
filtered = append(filtered, model)
}
}
models = filtered
}
if options.json {
items := make([]map[string]any, 0, len(models))
for _, model := range models {
-56
View File
@@ -132,62 +132,6 @@ func TestRunProvidersModelsUnknownProviderFails(t *testing.T) {
}
}
func TestRunProvidersModelsFiltersForCatalogProfile(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
deps := commandCenterDeps(t)
// Override the resolved config to include a catalog-backed profile.
deps.resolveConfig = func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
profile := config.ProviderProfile{
Name: "opencode-go",
ProviderKind: config.ProviderKindAnthropicCompat,
CatalogID: "opencode-go-anthropic-compatible",
BaseURL: "https://api.example.com/zen/go/v1",
APIKey: "sk-test",
Model: "minimax-max",
}
return config.ResolvedConfig{
ActiveProvider: "opencode-go",
Providers: []config.ProviderProfile{profile},
Provider: profile,
}, nil
}
deps.discoverProviderModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return []providermodeldiscovery.Model{
{ID: "deepseek-chat", Description: "DeepSeek Chat"},
{ID: "qwen-max", Description: "Qwen Max"},
{ID: "minimax-max-01", Description: "MiniMax Max"},
{ID: "kimi-k2", Description: "Kimi K2"},
{ID: "glm-4", Description: "GLM-4"},
}, nil
}
exitCode := runWithDeps([]string{"providers", "models"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
out := stdout.String()
// Only Qwen and MiniMax model IDs should survive the filter.
if strings.Contains(out, "deepseek-chat") {
t.Error("output should not contain deepseek-chat (blocked by opencode-go-anthropic filter)")
}
if strings.Contains(out, "kimi-k2") {
t.Error("output should not contain kimi-k2 (blocked by opencode-go-anthropic filter)")
}
if strings.Contains(out, "glm-4") {
t.Error("output should not contain glm-4 (blocked by opencode-go-anthropic filter)")
}
for _, want := range []string{"qwen-max", "minimax-max-01"} {
if !strings.Contains(out, want) {
t.Fatalf("output missing allowed model %q:\n%s", want, out)
}
}
if !strings.Contains(out, "2 models discovered") {
t.Fatalf("output should show 2 models:\n%s", out)
}
}
func TestRunProvidersModelsSurfacesDiscoveryError(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
-141
View File
@@ -3,7 +3,6 @@ package cli
import (
"fmt"
"io"
"path/filepath"
"strconv"
"strings"
"unicode"
@@ -307,143 +306,3 @@ func providerCommandArg(value string) string {
}
return value
}
type providerNamesOptions struct {
names []string
json bool
}
func parseProviderNamesArgs(args []string, want int, usage string) (providerNamesOptions, bool, error) {
options := providerNamesOptions{}
for _, arg := range args {
switch {
case arg == "-h" || arg == "--help" || arg == "help":
return options, true, nil
case arg == "--json":
options.json = true
case strings.HasPrefix(arg, "-"):
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
default:
options.names = append(options.names, arg)
}
}
if len(options.names) != want {
return options, false, execUsageError{usage}
}
return options, false, nil
}
// runProvidersRemove deletes a saved provider profile and its stored API key.
// The OAuth token (if any) is kept — logins outlive profiles so re-adding the
// provider needs no new browser round-trip; `zero auth logout <name>` removes it.
func runProvidersRemove(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseProviderNamesArgs(args, 1, "usage: zero providers remove <name>")
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeProvidersHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
configPath, err := deps.userConfigPath()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
name := options.names[0]
cfg, err := config.RemoveProvider(configPath, name)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
// Delete the key from the store BESIDE the config being edited — the same
// store setup/rename write to — not the default-path store, so a
// non-default config path cannot leave the encrypted key behind.
keyRemoved, keyErr := removeStoredProviderKeyAt(configPath, name)
if options.json {
payload := map[string]any{
"removed": name,
"keyRemoved": keyRemoved,
"activeProvider": cfg.ActiveProvider,
"configPath": configPath,
}
if keyErr != nil {
// A lingering secret must not read as a clean removal.
payload["keyError"] = keyErr.Error()
}
if err := writePrettyJSON(stdout, payload); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintf(stdout, "Removed provider %s\n", name); err != nil {
return exitCrash
}
if keyErr != nil {
if _, err := fmt.Fprintf(stderr, "warning: its stored API key could not be deleted and remains in the credential store: %v\n", keyErr); err != nil {
return exitCrash
}
} else if keyRemoved {
if _, err := fmt.Fprintln(stdout, "Deleted its stored API key."); err != nil {
return exitCrash
}
}
if active := strings.TrimSpace(cfg.ActiveProvider); active != "" {
if _, err := fmt.Fprintf(stdout, "Active provider: %s\n", active); err != nil {
return exitCrash
}
} else {
if _, err := fmt.Fprintln(stdout, "No providers remain — run zero setup to add one."); err != nil {
return exitCrash
}
}
return exitSuccess
}
// removeStoredProviderKeyAt deletes a provider's API key from the credential
// store co-located with configPath (the store SecureProviderProfile captured
// it into and RenameProvider migrates within).
func removeStoredProviderKeyAt(configPath string, provider string) (bool, error) {
store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath))
if err != nil {
return false, err
}
return store.Delete(provider)
}
// runProvidersRename renames a saved provider profile, migrating its stored
// API key and the activeProvider pointer along with it (config.RenameProvider).
func runProvidersRename(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseProviderNamesArgs(args, 2, "usage: zero providers rename <old> <new>")
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeProvidersHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
configPath, err := deps.userConfigPath()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
cfg, err := config.RenameProvider(configPath, options.names[0], options.names[1])
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
if options.json {
if err := writePrettyJSON(stdout, map[string]any{
"renamed": map[string]string{"from": options.names[0], "to": options.names[1]},
"activeProvider": cfg.ActiveProvider,
"configPath": configPath,
}); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintf(stdout, "Renamed provider %s to %s\n", options.names[0], options.names[1]); err != nil {
return exitCrash
}
return exitSuccess
}
-45
View File
@@ -234,48 +234,3 @@ func writeProviderOnboardingConfig(t *testing.T, path string, cfg config.FileCon
t.Fatalf("write config: %v", err)
}
}
// TestRunProvidersRemoveDeletesKeyBesideConfig: the stored key must be deleted
// from the credential store CO-LOCATED with the config being edited (where
// SecureProviderProfile captured it), not the default-path store.
func TestRunProvidersRemoveDeletesKeyBesideConfig(t *testing.T) {
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
seed := `{"activeProvider":"gw","providers":[{"name":"gw","provider_kind":"openai-compatible","baseURL":"https://gw.example.com/v1","apiKeyStored":true,"model":"m1"},{"name":"other","provider_kind":"openai-compatible","baseURL":"https://o.example.com/v1","model":"m2"}]}`
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
t.Fatalf("seed config: %v", err)
}
store, err := config.ProviderKeyStoreAt(dir)
if err != nil {
t.Fatalf("open store: %v", err)
}
if err := store.Set("gw", "sk-secret"); err != nil {
t.Fatalf("seed key: %v", err)
}
var stdout, stderr bytes.Buffer
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
if code := runWithDeps([]string{"providers", "remove", "gw", "--json"}, &stdout, &stderr, deps); code != exitSuccess {
t.Fatalf("remove failed: code=%d stderr=%s", code, stderr.String())
}
var payload struct {
Removed string `json:"removed"`
KeyRemoved bool `json:"keyRemoved"`
KeyError string `json:"keyError"`
ActiveProvider string `json:"activeProvider"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
}
if payload.Removed != "gw" || !payload.KeyRemoved || payload.KeyError != "" {
t.Fatalf("unexpected payload: %+v", payload)
}
if payload.ActiveProvider != "other" {
t.Fatalf("active must hand off, got %q", payload.ActiveProvider)
}
if _, ok, _ := store.Get("gw"); ok {
t.Fatalf("stored key must be deleted from the store beside the config")
}
}
+15 -13
View File
@@ -457,9 +457,7 @@ func selectProviderForCheck(resolved config.ResolvedConfig, name string) (config
}
func validateProviderRuntimeReady(profile config.ProviderProfile) error {
// A stored OAuth login (e.g. `zero auth chatgpt`) is a credential too: a
// keyless token-login profile must pass the readiness check instead of being
// told to set an API key it will never have.
hasCredential := providerProfileHasCredential(profile)
if profile.CatalogID != "" {
descriptor, err := providercatalog.Require(profile.CatalogID)
if err != nil {
@@ -468,19 +466,23 @@ func validateProviderRuntimeReady(profile config.ProviderProfile) error {
if !providercatalog.RuntimeSupported(descriptor) {
return fmt.Errorf("provider %q uses transport %q: %s", descriptor.ID, descriptor.Transport, providercatalog.RuntimeUnsupportedReason(descriptor))
}
}
if providerHasOAuthLogin(profile, oauthLoggedInProviders()) {
if descriptor.RequiresAuth && !hasCredential {
apiKeyEnv := strings.TrimSpace(profile.APIKeyEnv)
if apiKeyEnv == "" && len(descriptor.AuthEnvVars) > 0 {
apiKeyEnv = descriptor.AuthEnvVars[0]
}
if apiKeyEnv != "" {
return fmt.Errorf("provider %s requires API key; set %s", profile.Name, apiKeyEnv)
}
return fmt.Errorf("provider %s requires API key", profile.Name)
}
return nil
}
// Delegates to the single source of truth shared with the TUI and the
// setup wizard, so `providers check` never disagrees with them about
// whether a saved provider (including a no-auth custom endpoint) is
// actually missing a credential.
if envVar, missing := profile.MissingCredentialEnv(); missing {
if envVar != "" {
return fmt.Errorf("provider %s requires API key; set %s", profile.Name, envVar)
switch profile.ProviderKind {
case config.ProviderKindOpenAI, config.ProviderKindAnthropic, config.ProviderKindGoogle:
if !hasCredential {
return fmt.Errorf("provider %s requires API key", profile.Name)
}
return fmt.Errorf("provider %s requires API key", profile.Name)
}
return nil
}
-68
View File
@@ -1,68 +0,0 @@
package cli
import (
"testing"
"github.com/Gitlawb/zero/internal/config"
)
// Regression for issue #555's follow-up: `zero providers check` must not
// error that a no-auth custom endpoint requires an API key, matching what
// /model and /providers already treat as usable.
func TestValidateProviderRuntimeReadyCustomEndpoint(t *testing.T) {
cases := []struct {
name string
profile config.ProviderProfile
wantErr bool
}{
{
name: "custom openai compatible with no credential configured",
profile: config.ProviderProfile{
Name: "local-llama",
CatalogID: "custom-openai-compatible",
BaseURL: "http://192.168.1.50:8080/v1",
Model: "custom-model",
},
wantErr: false,
},
{
name: "custom openai compatible with stale legacy default env",
profile: config.ProviderProfile{
Name: "local-llama",
CatalogID: "custom-openai-compatible",
BaseURL: "http://192.168.1.50:8080/v1",
APIKeyEnv: "OPENAI_API_KEY",
Model: "custom-model",
},
wantErr: false,
},
{
name: "custom openai compatible with explicit non-default env still requires it",
profile: config.ProviderProfile{
Name: "local-llama",
CatalogID: "custom-openai-compatible",
BaseURL: "http://192.168.1.50:8080/v1",
APIKeyEnv: "LLAMA_CPP_API_KEY",
Model: "custom-model",
},
wantErr: true,
},
{
name: "catalog provider missing key still errors",
profile: config.ProviderProfile{
Name: "groq",
CatalogID: "groq",
Model: "llama-3.3-70b-versatile",
},
wantErr: true,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := validateProviderRuntimeReady(c.profile)
if (err != nil) != c.wantErr {
t.Fatalf("validateProviderRuntimeReady() error = %v, wantErr %v", err, c.wantErr)
}
})
}
}
+2 -14
View File
@@ -483,14 +483,6 @@ func TestTUISandboxSetupCommandGatedToWindowsNativeBackend(t *testing.T) {
}
func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) {
// Point HOME at an empty directory so the default credential-store
// deny-read entries (which depend on what exists in the real home, e.g.
// ~/.aws on the macOS CI image) cannot leak host paths into the golden
// comparison.
emptyHome := t.TempDir()
t.Setenv("HOME", emptyHome)
t.Setenv("USERPROFILE", emptyHome)
t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "")
store := newSandboxTestStore(t)
workspace := t.TempDir()
deps := appDeps{
@@ -624,12 +616,8 @@ func jsonValuesEqual(t *testing.T, wantBytes []byte, gotBytes []byte) bool {
func normalizePortableJSONRootString(value *any) {
switch current := (*value).(type) {
case string:
// Normalize OS-specific path separators so the shared golden file
// matches on both Unix (slash) and Windows (backslash) runners.
// readOnlySubpaths carve-outs are built with filepath.Join, which
// renders as backslash paths on Windows.
if current != "" {
*value = strings.ReplaceAll(current, `\`, "/")
if current == `\` {
*value = "/"
}
case []any:
for index := range current {
-114
View File
@@ -1,114 +0,0 @@
package cli
import (
"context"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
type scratchFileBaseline map[string]bool
// scratchFileSnapshot records the untracked scratch-like files that already
// existed before a run starts. Completion warnings compare against this baseline
// so Zero only reports files newly left behind by the run, including files made
// by shell commands that bypass the file tools.
func scratchFileSnapshot(workspaceRoot string) scratchFileBaseline {
untracked, ok := gitUntrackedFiles(workspaceRoot)
if !ok {
return nil
}
baseline := make(scratchFileBaseline, len(untracked))
for _, path := range untracked {
if scratchLikePath(path) {
baseline[path] = true
}
}
return baseline
}
// scratchFileWarning builds a completion-time warning listing scratch/debug-like
// files that became untracked during this run. It deliberately does not warn for
// every new untracked file: brand-new deliverables are a normal coding-task
// result, while names such as `_debug.py`, `_fix_test.py`, or `scratch.js` are
// the class of throwaway artifacts called out in issue #551.
//
// Returns "" when there is nothing to report: git is unavailable,
// workspaceRoot isn't a git repo, no baseline was captured, or no new
// scratch-like untracked files remain.
func scratchFileWarning(workspaceRoot string, baseline scratchFileBaseline) string {
if baseline == nil {
return ""
}
untracked, ok := gitUntrackedFiles(workspaceRoot)
if !ok {
return ""
}
var scratchFiles []string
for _, path := range untracked {
if baseline[path] || !scratchLikePath(path) {
continue
}
baseline[path] = true
scratchFiles = append(scratchFiles, path)
}
if len(scratchFiles) == 0 {
return ""
}
displayPaths := make([]string, len(scratchFiles))
for i, path := range scratchFiles {
displayPaths[i] = filepath.ToSlash(path)
}
plural := "s"
if len(displayPaths) == 1 {
plural = ""
}
return "This run left " + strconv.Itoa(len(displayPaths)) + " new scratch/debug-like file" + plural + " untracked in git: " +
strings.Join(displayPaths, ", ") + ". Review before committing/staging."
}
func gitUntrackedFiles(workspaceRoot string) ([]string, bool) {
if _, err := exec.LookPath("git"); err != nil {
return nil, false
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
output, err := exec.CommandContext(ctx, "git", "-C", workspaceRoot, "status", "--porcelain", "-z", "--untracked-files=all").Output()
if err != nil {
// Not a git repo (or git failed for some other reason) — nothing
// reliable to report against, so stay silent rather than guess.
return nil, false
}
var untracked []string
for _, entry := range strings.Split(string(output), "\x00") {
if !strings.HasPrefix(entry, "?? ") {
continue
}
relative := strings.TrimPrefix(entry, "?? ")
untracked = append(untracked, relative)
}
return untracked, true
}
func scratchLikePath(path string) bool {
name := strings.ToLower(filepath.Base(filepath.ToSlash(path)))
name = strings.TrimSpace(name)
if name == "" {
return false
}
if strings.HasPrefix(name, "scratch") || strings.HasPrefix(name, "debug") || strings.HasPrefix(name, "tmp") || strings.HasPrefix(name, "temp") || strings.HasPrefix(name, "repro") {
return true
}
for _, marker := range []string{"scratch", "debug", "tmp", "temp", "repro", "fix_test"} {
if strings.Contains(name, marker) {
return true
}
}
return false
}
-174
View File
@@ -1,174 +0,0 @@
package cli
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func runGitForScratchTest(t *testing.T, dir string, args ...string) {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=test", "[email protected]",
"GIT_COMMITTER_NAME=test", "[email protected]",
)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, output)
}
}
func TestScratchFileWarningFlagsUntrackedCreatedFile(t *testing.T) {
root := t.TempDir()
runGitForScratchTest(t, root, "init")
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGitForScratchTest(t, root, "add", "README.md")
runGitForScratchTest(t, root, "commit", "-m", "init")
baseline := scratchFileSnapshot(root)
scratchPath := filepath.Join(root, "_debug file.py")
if err := os.WriteFile(scratchPath, []byte("print('debug')"), 0o644); err != nil {
t.Fatal(err)
}
warning := scratchFileWarning(root, baseline)
if warning == "" {
t.Fatal("expected a warning about the untracked scratch file")
}
if !strings.Contains(warning, "_debug file.py") {
t.Fatalf("expected warning to mention _debug file.py, got %q", warning)
}
}
func TestScratchFileWarningDetectsShellCreatedScratchFile(t *testing.T) {
root := t.TempDir()
runGitForScratchTest(t, root, "init")
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGitForScratchTest(t, root, "add", "README.md")
runGitForScratchTest(t, root, "commit", "-m", "init")
baseline := scratchFileSnapshot(root)
// Simulate a shell heredoc/redirect creating a scratch file. The warning is
// based on the git before/after state, not only FileTracker-created paths.
if err := os.WriteFile(filepath.Join(root, "_fix_test.py"), []byte("print('debug')"), 0o644); err != nil {
t.Fatal(err)
}
warning := scratchFileWarning(root, baseline)
if !strings.Contains(warning, "_fix_test.py") {
t.Fatalf("expected warning to mention shell-created _fix_test.py, got %q", warning)
}
}
func TestScratchFileWarningSilentForNormalDeliverableFile(t *testing.T) {
root := t.TempDir()
runGitForScratchTest(t, root, "init")
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGitForScratchTest(t, root, "add", "README.md")
runGitForScratchTest(t, root, "commit", "-m", "init")
baseline := scratchFileSnapshot(root)
if err := os.WriteFile(filepath.Join(root, "app.py"), []byte("print('app')"), 0o644); err != nil {
t.Fatal(err)
}
if warning := scratchFileWarning(root, baseline); warning != "" {
t.Fatalf("expected no warning for a normal new deliverable file, got %q", warning)
}
}
func TestScratchFileWarningSilentForUnderscoreDeliverableFile(t *testing.T) {
root := t.TempDir()
runGitForScratchTest(t, root, "init")
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGitForScratchTest(t, root, "add", "README.md")
runGitForScratchTest(t, root, "commit", "-m", "init")
baseline := scratchFileSnapshot(root)
if err := os.WriteFile(filepath.Join(root, "_generated.go"), []byte("package main\n"), 0o644); err != nil {
t.Fatal(err)
}
if warning := scratchFileWarning(root, baseline); warning != "" {
t.Fatalf("expected no warning for an underscore-prefixed deliverable file, got %q", warning)
}
}
func TestScratchFileWarningSilentWhenFileIsTracked(t *testing.T) {
root := t.TempDir()
runGitForScratchTest(t, root, "init")
baseline := scratchFileSnapshot(root)
trackedPath := filepath.Join(root, "_debug.py")
if err := os.WriteFile(trackedPath, []byte("print('app')"), 0o644); err != nil {
t.Fatal(err)
}
runGitForScratchTest(t, root, "add", "_debug.py")
runGitForScratchTest(t, root, "commit", "-m", "init")
// A scratch-like file subsequently staged/committed by the
// model itself is no longer a loose scratch file, so no warning.
if warning := scratchFileWarning(root, baseline); warning != "" {
t.Fatalf("expected no warning for a tracked file, got %q", warning)
}
}
func TestScratchFileWarningSilentWhenNoFilesCreated(t *testing.T) {
root := t.TempDir()
runGitForScratchTest(t, root, "init")
baseline := scratchFileSnapshot(root)
if warning := scratchFileWarning(root, baseline); warning != "" {
t.Fatalf("expected no warning when nothing was created, got %q", warning)
}
}
func TestScratchFileWarningSilentWhenFileWasRemovedAgain(t *testing.T) {
root := t.TempDir()
runGitForScratchTest(t, root, "init")
baseline := scratchFileSnapshot(root)
scratchPath := filepath.Join(root, "_debug.py")
if err := os.WriteFile(scratchPath, []byte("print('debug')"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Remove(scratchPath); err != nil {
t.Fatal(err)
}
// The model created and then cleaned up its own scratch file — nothing to warn about.
if warning := scratchFileWarning(root, baseline); warning != "" {
t.Fatalf("expected no warning for a removed file, got %q", warning)
}
}
func TestScratchFileWarningSilentForPreexistingScratchFile(t *testing.T) {
root := t.TempDir()
runGitForScratchTest(t, root, "init")
if err := os.WriteFile(filepath.Join(root, "_debug.py"), []byte("print('debug')"), 0o644); err != nil {
t.Fatal(err)
}
baseline := scratchFileSnapshot(root)
if warning := scratchFileWarning(root, baseline); warning != "" {
t.Fatalf("expected no warning for a pre-existing scratch file, got %q", warning)
}
}
func TestScratchFileWarningSilentWhenNotAGitRepo(t *testing.T) {
root := t.TempDir()
scratchPath := filepath.Join(root, "_debug.py")
if err := os.WriteFile(scratchPath, []byte("print('debug')"), 0o644); err != nil {
t.Fatal(err)
}
if warning := scratchFileWarning(root, scratchFileSnapshot(root)); warning != "" {
t.Fatalf("expected no warning outside a git repo, got %q", warning)
}
}
+5 -90
View File
@@ -4,8 +4,6 @@ import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/Gitlawb/zero/internal/mcp"
@@ -16,21 +14,9 @@ import (
type serveOptions struct {
mcp bool
cwd string
addDirs []string
allowUnsafeTools bool
}
// serveRootsScope is a PathScope used only for MCP serve: the workspace root
// plus optional --add-dir roots, without the sandbox temp write roots that
// would otherwise pollute resources/list.
type serveRootsScope []string
func (s serveRootsScope) Roots() []string {
roots := make([]string, len(s))
copy(roots, s)
return roots
}
func runServe(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseServeArgs(args)
if err != nil {
@@ -50,11 +36,7 @@ func runServe(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) i
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
scope, err := buildServeScope(workspaceRoot, options.addDirs)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
registry := newServeRegistry(workspaceRoot, scope, options.allowUnsafeTools)
registry := newServeRegistry(workspaceRoot, options.allowUnsafeTools)
if options.allowUnsafeTools {
if _, err := fmt.Fprintln(stderr, "[zero] Unsafe MCP server tools enabled because --allow-unsafe-tools was passed."); err != nil {
return exitCrash
@@ -65,8 +47,6 @@ func runServe(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) i
Name: "zero",
Version: version,
PermissionGranted: options.allowUnsafeTools,
WorkspaceRoot: workspaceRoot,
Scope: scope,
})
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
@@ -74,11 +54,11 @@ func runServe(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) i
return exitSuccess
}
func newServeRegistry(workspaceRoot string, scope tools.PathScope, allowUnsafeTools bool) *tools.Registry {
func newServeRegistry(workspaceRoot string, allowUnsafeTools bool) *tools.Registry {
registry := tools.NewRegistry()
toolset := tools.CoreReadOnlyToolsScoped(workspaceRoot, scope)
toolset := tools.CoreReadOnlyTools(workspaceRoot)
if allowUnsafeTools {
toolset = tools.CoreToolsScoped(workspaceRoot, scope)
toolset = tools.CoreTools(workspaceRoot)
}
for _, tool := range toolset {
registry.Register(tool)
@@ -86,58 +66,6 @@ func newServeRegistry(workspaceRoot string, scope tools.PathScope, allowUnsafeTo
return registry
}
// buildServeScope returns nil when there are no extra roots (workspace-only),
// otherwise a PathScope listing the workspace first followed by each --add-dir.
func buildServeScope(workspaceRoot string, addDirs []string) (tools.PathScope, error) {
if len(addDirs) == 0 {
return nil, nil
}
roots := make([]string, 0, 1+len(addDirs))
workspaceRoot = filepath.Clean(workspaceRoot)
if abs, err := filepath.Abs(workspaceRoot); err == nil {
workspaceRoot = abs
}
// Keep lexical absolute paths in roots so path checks stay aligned with the
// un-evaluated WorkspaceRoot used when Scope is nil. Symlink-resolved forms
// are only keys for duplicate detection.
workspaceResolved := workspaceRoot
if resolved, err := filepath.EvalSymlinks(workspaceRoot); err == nil {
workspaceResolved = resolved
}
roots = append(roots, workspaceRoot)
seen := map[string]struct{}{workspaceResolved: {}}
for _, dir := range addDirs {
trimmed := strings.TrimSpace(dir)
if trimmed == "" {
return nil, execUsageError{"--add-dir requires a path"}
}
absolute, err := filepath.Abs(trimmed)
if err != nil {
return nil, execUsageError{fmt.Sprintf("--add-dir %q: %v", trimmed, err)}
}
info, err := os.Stat(absolute)
if err != nil {
return nil, execUsageError{fmt.Sprintf("--add-dir %q: %v", trimmed, err)}
}
if !info.IsDir() {
return nil, execUsageError{fmt.Sprintf("--add-dir %q is not a directory", trimmed)}
}
resolved := absolute
if r, err := filepath.EvalSymlinks(absolute); err == nil {
resolved = r
}
if _, ok := seen[resolved]; ok {
continue
}
seen[resolved] = struct{}{}
roots = append(roots, absolute)
}
return serveRootsScope(roots), nil
}
func parseServeArgs(args []string) (serveOptions, bool, error) {
options := serveOptions{}
for index := 0; index < len(args); index++ {
@@ -157,18 +85,6 @@ func parseServeArgs(args []string) (serveOptions, bool, error) {
options.cwd = args[index]
case strings.HasPrefix(arg, "--cwd="):
options.cwd = strings.TrimPrefix(arg, "--cwd=")
case arg == "--add-dir":
index++
if index >= len(args) {
return options, false, execUsageError{"--add-dir requires a path"}
}
options.addDirs = append(options.addDirs, args[index])
case strings.HasPrefix(arg, "--add-dir="):
value := strings.TrimPrefix(arg, "--add-dir=")
if strings.TrimSpace(value) == "" {
return options, false, execUsageError{"--add-dir requires a path"}
}
options.addDirs = append(options.addDirs, value)
default:
return options, false, execUsageError{fmt.Sprintf("unknown serve flag %q", arg)}
}
@@ -184,8 +100,7 @@ Starts Zero as an MCP stdio server.
Flags:
--mcp Run the MCP stdio server
-C, --cwd <path> Set the workspace directory (resources + tools)
--add-dir <path> Extra resource/tool root (repeatable)
-C, --cwd <path> Set the workspace directory
--allow-unsafe-tools Expose write and shell tools to the MCP host
-h, --help Show this help
`)
-140
View File
@@ -4,8 +4,6 @@ import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -82,120 +80,6 @@ func TestRunServeRequiresMCPMode(t *testing.T) {
}
}
func TestRunServeMCPWiresWorkspaceRootAndAddDirIntoResources(t *testing.T) {
workspace := t.TempDir()
extra := t.TempDir()
if err := os.WriteFile(filepath.Join(workspace, "workspace.txt"), []byte("ws\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(extra, "extra.txt"), []byte("ex\n"), 0o600); err != nil {
t.Fatal(err)
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps(
[]string{"serve", "--mcp", "-C", workspace, "--add-dir", extra},
&stdout,
&stderr,
appDeps{stdin: bytes.NewReader(serveMCPResourcesInput(t))},
)
if exitCode != exitSuccess {
t.Fatalf("exit=%d stderr=%q", exitCode, stderr.String())
}
output := stdout.String()
if !strings.Contains(output, "workspace.txt") {
t.Fatalf("expected workspace resource, got %q", output)
}
if !strings.Contains(output, "extra.txt") {
t.Fatalf("expected --add-dir resource, got %q", output)
}
}
func TestRunServeMCPRejectsMissingAddDir(t *testing.T) {
workspace := t.TempDir()
missing := filepath.Join(workspace, "does-not-exist")
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps(
[]string{"serve", "--mcp", "-C", workspace, "--add-dir", missing},
&stdout,
&stderr,
appDeps{stdin: bytes.NewReader(nil)},
)
if exitCode != exitUsage {
t.Fatalf("exit=%d want %d stderr=%q", exitCode, exitUsage, stderr.String())
}
if !strings.Contains(stderr.String(), "--add-dir") {
t.Fatalf("expected --add-dir error, got %q", stderr.String())
}
}
func TestParseServeArgsCollectsAddDirs(t *testing.T) {
options, help, err := parseServeArgs([]string{"--mcp", "--add-dir", "/one", "--add-dir=/two", "-C", "/ws"})
if err != nil {
t.Fatal(err)
}
if help {
t.Fatal("unexpected help")
}
if !options.mcp || options.cwd != "/ws" {
t.Fatalf("options=%#v", options)
}
if len(options.addDirs) != 2 || options.addDirs[0] != "/one" || options.addDirs[1] != "/two" {
t.Fatalf("addDirs=%v", options.addDirs)
}
}
func TestBuildServeScopeNilWithoutExtras(t *testing.T) {
scope, err := buildServeScope(t.TempDir(), nil)
if err != nil {
t.Fatal(err)
}
if scope != nil {
t.Fatalf("expected nil scope, got %#v", scope)
}
}
func TestBuildServeScopeKeepsLexicalPaths(t *testing.T) {
base := t.TempDir()
realWorkspace := filepath.Join(base, "real-workspace")
realExtra := filepath.Join(base, "real-extra")
for _, dir := range []string{realWorkspace, realExtra} {
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
}
linkWorkspace := filepath.Join(base, "link-workspace")
linkExtra := filepath.Join(base, "link-extra")
if err := os.Symlink(realWorkspace, linkWorkspace); err != nil {
t.Fatal(err)
}
if err := os.Symlink(realExtra, linkExtra); err != nil {
t.Fatal(err)
}
scope, err := buildServeScope(linkWorkspace, []string{linkExtra})
if err != nil {
t.Fatal(err)
}
if scope == nil {
t.Fatal("expected non-nil scope")
}
roots := scope.Roots()
wantWorkspace, err := filepath.Abs(linkWorkspace)
if err != nil {
t.Fatal(err)
}
wantExtra, err := filepath.Abs(linkExtra)
if err != nil {
t.Fatal(err)
}
if len(roots) != 2 || roots[0] != wantWorkspace || roots[1] != wantExtra {
t.Fatalf("roots=%v want [%q %q]", roots, wantWorkspace, wantExtra)
}
}
func serveMCPInput(t *testing.T) []byte {
t.Helper()
@@ -220,30 +104,6 @@ func serveMCPInput(t *testing.T) []byte {
return input.Bytes()
}
func serveMCPResourcesInput(t *testing.T) []byte {
t.Helper()
var input bytes.Buffer
writeServeMCPMessage(t, &input, map[string]any{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": map[string]any{},
})
writeServeMCPMessage(t, &input, map[string]any{
"jsonrpc": "2.0",
"method": "notifications/initialized",
"params": map[string]any{},
})
writeServeMCPMessage(t, &input, map[string]any{
"jsonrpc": "2.0",
"id": 2,
"method": "resources/list",
"params": map[string]any{},
})
return input.Bytes()
}
func writeServeMCPMessage(t *testing.T, buffer *bytes.Buffer, message map[string]any) {
t.Helper()
body, err := json.Marshal(message)
+41 -21
View File
@@ -322,9 +322,7 @@ func setupRequired(resolved config.ResolvedConfig) bool {
// the candidate set is permissive (profile name + catalog ID).
func providerHasOAuthLogin(profile config.ProviderProfile, oauthLogins map[string]bool) bool {
for _, name := range profile.OAuthLoginCandidates() {
// Store keys are normalized to lower case (oauth.ProviderKey), so the
// presence check must compare the same way.
if oauthLogins[strings.ToLower(strings.TrimSpace(name))] {
if oauthLogins[name] {
return true
}
}
@@ -346,19 +344,18 @@ func oauthLoggedInProviders() map[string]bool {
}
for _, status := range statuses {
if status.HasToken {
out[strings.ToLower(strings.TrimPrefix(status.Key, oauth.KeyPrefixProvider))] = true
out[strings.TrimPrefix(status.Key, oauth.KeyPrefixProvider)] = true
}
}
return out
}
// firstUsableProvider returns the saved provider best suited to run without
// onboarding: the first usable (inline credential, stored OAuth login, or
// no-auth/local) non-local provider, else the first usable local one. It lets
// the CLI fall back to an already-configured login when the active provider
// happens to lack a credential, instead of re-running onboarding every launch.
// onboarding: the first usable (inline credential present, or no-auth/local)
// non-local provider, else the first usable local one. It lets the CLI fall back
// to an already-configured login when the active provider happens to lack a
// credential, instead of re-running onboarding every launch.
func firstUsableProvider(providers []config.ProviderProfile) (config.ProviderProfile, bool) {
logins := oauthLoggedInProviders()
var localFallback config.ProviderProfile
haveLocal := false
for _, profile := range providers {
@@ -374,11 +371,7 @@ func firstUsableProvider(providers []config.ProviderProfile) (config.ProviderPro
continue
}
}
// A stored OAuth login (e.g. `zero auth login xai`) is a credential too, even
// when the profile has no inline key / env var — mirrors setupRequired and
// usableSavedProviders so this fallback doesn't force onboarding for a
// provider the user is already authenticated with.
if _, missing := setupMissingCredentialEnv(profile); missing && !providerHasOAuthLogin(profile, logins) {
if _, missing := setupMissingCredentialEnv(profile); missing {
continue
}
if providerProfileIsLocal(profile) {
@@ -503,14 +496,41 @@ func setupTryThisExample(profile config.ProviderProfile) string {
return example
}
// setupMissingCredentialEnv reports the environment variable a saved profile
// is expected to read an API key from, and whether that expectation is
// unmet. It delegates to config.ProviderProfile.MissingCredentialEnv, the
// single source of truth shared with the TUI's provider status/wizard
// surfaces and the CLI's `providers check` readiness gate, so all three never
// disagree about whether a saved provider is missing a credential.
func setupMissingCredentialEnv(profile config.ProviderProfile) (string, bool) {
return profile.MissingCredentialEnv()
if providerProfileHasCredential(profile) {
return "", false
}
if catalogID := strings.TrimSpace(profile.CatalogID); catalogID != "" {
descriptor, err := providercatalog.Require(catalogID)
if err != nil || !descriptor.RequiresAuth {
return "", false
}
return firstNonEmptyCLI(profile.APIKeyEnv, setupProviderEnvVar(descriptor)), true
}
switch normalizedSetupProviderKind(profile) {
case config.ProviderKindOpenAI, config.ProviderKindOpenAICompatible:
return firstNonEmptyCLI(profile.APIKeyEnv, "OPENAI_API_KEY"), true
case config.ProviderKindAnthropic, config.ProviderKindAnthropicCompat:
return firstNonEmptyCLI(profile.APIKeyEnv, "ANTHROPIC_API_KEY"), true
case config.ProviderKindGoogle:
return firstNonEmptyCLI(profile.APIKeyEnv, "GEMINI_API_KEY"), true
default:
if strings.TrimSpace(profile.APIKeyEnv) != "" {
return strings.TrimSpace(profile.APIKeyEnv), true
}
return "", false
}
}
func normalizedSetupProviderKind(profile config.ProviderProfile) config.ProviderKind {
if kind := strings.TrimSpace(string(profile.ProviderKind)); kind != "" {
return config.ProviderKind(strings.ToLower(kind))
}
if provider := strings.TrimSpace(profile.Provider); provider != "" {
return config.ProviderKind(strings.ToLower(provider))
}
return ""
}
func setupCheckCommand(name string) string {
-62
View File
@@ -2,14 +2,11 @@ package cli
import (
"testing"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
)
func TestFirstUsableProviderPrefersRemoteKeyed(t *testing.T) {
withAuthStore(t)
providers := []config.ProviderProfile{
{Name: "ollama", CatalogID: "ollama", BaseURL: "http://localhost:11434/v1", APIKey: "k"}, // usable but local
{Name: "moonshot", CatalogID: "moonshot", BaseURL: "https://api.moonshot.ai/v1", APIKey: "k"}, // usable, remote
@@ -22,7 +19,6 @@ func TestFirstUsableProviderPrefersRemoteKeyed(t *testing.T) {
}
func TestFirstUsableProviderFallsBackToLocal(t *testing.T) {
withAuthStore(t)
providers := []config.ProviderProfile{
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"}, // not usable
{Name: "ollama", CatalogID: "ollama", BaseURL: "http://localhost:11434/v1", APIKey: "k"}, // local, usable
@@ -34,7 +30,6 @@ func TestFirstUsableProviderFallsBackToLocal(t *testing.T) {
}
func TestFirstUsableProviderNoneUsable(t *testing.T) {
withAuthStore(t)
providers := []config.ProviderProfile{
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"},
{Name: "openai", CatalogID: "openai", APIKeyEnv: "OPENAI_API_KEY"},
@@ -47,10 +42,6 @@ func TestFirstUsableProviderNoneUsable(t *testing.T) {
// A keyless local proxy (chatgpt-proxy, RequiresAuth=false) is usable without a
// credential, so it can serve as a fallback rather than forcing onboarding.
func TestFirstUsableProviderAcceptsKeylessLocalProxy(t *testing.T) {
// Isolate the OAuth token store: on a developer machine with a real xai
// login, the env-only xai profile would count as usable and win over the
// local proxy this test is about.
withAuthStore(t)
providers := []config.ProviderProfile{
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"},
{Name: "chatgpt", CatalogID: "chatgpt-proxy", BaseURL: "http://localhost:10531/v1"},
@@ -65,7 +56,6 @@ func TestFirstUsableProviderAcceptsKeylessLocalProxy(t *testing.T) {
// BaseURL has no endpoint, so it must be skipped rather than selected as a
// fallback that fails at first use. A stale CatalogID with a BaseURL still works.
func TestFirstUsableProviderSkipsUnresolvableCatalogWithoutBaseURL(t *testing.T) {
withAuthStore(t)
providers := []config.ProviderProfile{
{Name: "ghost", CatalogID: "no-such-catalog-entry", APIKey: "k"}, // unusable: no endpoint
{Name: "custom", CatalogID: "no-such-catalog-entry", BaseURL: "https://api.custom.test/v1", APIKey: "k"},
@@ -76,29 +66,6 @@ func TestFirstUsableProviderSkipsUnresolvableCatalogWithoutBaseURL(t *testing.T)
}
}
// An OAuth-only provider (no inline key, no env var) must be selectable as a
// fallback, matching setupRequired/usableSavedProviders — otherwise a fully
// authenticated user gets forced back into onboarding when activeProvider
// goes stale.
func TestFirstUsableProviderRecognizesOAuthLogin(t *testing.T) {
withAuthStore(t)
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
if err := store.Save(oauth.ProviderKey("xai"), oauth.Token{AccessToken: "tok", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
t.Fatalf("seed token: %v", err)
}
providers := []config.ProviderProfile{
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"}, // no inline key/env, but logged in via OAuth
}
got, ok := firstUsableProvider(providers)
if !ok || got.Name != "xai" {
t.Fatalf("want OAuth-logged-in provider (xai), got %q ok=%v", got.Name, ok)
}
}
func TestProviderProfileIsLocal(t *testing.T) {
cases := []struct {
name string
@@ -122,32 +89,3 @@ func TestProviderProfileIsLocal(t *testing.T) {
})
}
}
// TestFirstUsableProviderAcceptsOAuthLoginProfile: a keyless catalog profile
// whose credential is a stored OAuth login (the shape the login flows persist)
// must be usable during active-pointer recovery — the same rule setupRequired
// and usableSavedProviders apply — instead of falling through to onboarding.
func TestFirstUsableProviderAcceptsOAuthLoginProfile(t *testing.T) {
withAuthStore(t)
providers := []config.ProviderProfile{
{Name: "chatgpt", CatalogID: "chatgpt", BaseURL: "https://chatgpt.com/backend-api/codex", Model: "gpt-5.5"},
}
// No login stored: still not usable.
if got, ok := firstUsableProvider(providers); ok {
t.Fatalf("keyless profile without a login must not be usable, got %q", got.Name)
}
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
t.Fatalf("oauth store: %v", err)
}
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "bearer-123"}); err != nil {
t.Fatalf("save token: %v", err)
}
got, ok := firstUsableProvider(providers)
if !ok || got.Name != "chatgpt" {
t.Fatalf("want OAuth-login profile to be usable, got %q ok=%v", got.Name, ok)
}
}
-48
View File
@@ -64,54 +64,6 @@ func TestSetupMissingCredentialEnv(t *testing.T) {
},
want: false,
},
{
// Regression for issue #555: a custom OpenAI-compatible profile saved
// with no credential (e.g. a local llama.cpp server with no auth) must
// not be reported as missing one, or it gets filtered out of /model on
// the next run even though it was configured exactly as intended.
name: "custom openai compatible with no credential configured",
profile: config.ProviderProfile{
Name: "local-llama",
CatalogID: "custom-openai-compatible",
BaseURL: "http://192.168.1.50:8080/v1",
},
want: false,
},
{
name: "custom openai compatible with explicit api key env",
profile: config.ProviderProfile{
Name: "local-llama",
CatalogID: "custom-openai-compatible",
BaseURL: "http://192.168.1.50:8080/v1",
APIKeyEnv: "LLAMA_CPP_API_KEY",
},
wantEnv: "LLAMA_CPP_API_KEY",
want: true,
},
{
// Self-heal regression: profiles saved by the pre-fix wizard were
// stamped with the catalog's own guessed default (OPENAI_API_KEY)
// even when the endpoint needed no auth. That stale value must be
// treated the same as unset so already-broken saved profiles start
// working again without a config.json rewrite.
name: "custom openai compatible with stale legacy default env",
profile: config.ProviderProfile{
Name: "local-llama",
CatalogID: "custom-openai-compatible",
BaseURL: "http://192.168.1.50:8080/v1",
APIKeyEnv: "OPENAI_API_KEY",
},
want: false,
},
{
name: "custom anthropic compatible with no credential configured",
profile: config.ProviderProfile{
Name: "local-proxy",
CatalogID: "custom-anthropic-compatible",
BaseURL: "http://localhost:9000/anthropic",
},
want: false,
},
}
for _, tt := range tests {
@@ -40,11 +40,8 @@
"writeRoots": [
{
"root": "$WORKSPACE",
"readOnlySubpaths": [
"$WORKSPACE/.git/hooks",
"$WORKSPACE/.git/config"
],
"protectedMetadataNames": [
".git",
".zero",
".agents"
]
-119
View File
@@ -1,119 +0,0 @@
package cli
import (
"fmt"
"io"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/workspacetrust"
)
// runTrust implements `zero trust`, letting the user opt a workspace into running
// its project-scoped executable config (hooks, plugins, MCP servers). Trust is
// keyed on the exact normalized working directory, matching the exact-match trust
// model and cwd-relative project-config discovery.
//
// zero trust trust the current working directory
// zero trust list print the trusted roots, one per line
// zero trust remove [path] untrust the current directory, or a named path
func runTrust(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
if len(args) == 0 {
return trustCurrentDir(stdout, stderr, deps)
}
switch args[0] {
case "list":
return trustList(stdout, stderr)
case "remove", "rm", "untrust":
return trustRemove(args[1:], stdout, stderr, deps)
case "-h", "--help", "help":
// Explicit help is a success path: write usage to stdout and exit 0, matching
// the other subcommands (mcp, sandbox, skills, cron, ...). Only the unknown-
// subcommand error path below writes usage to stderr with a usage exit code.
writeTrustUsage(stdout)
return exitSuccess
default:
if _, err := fmt.Fprintf(stderr, "zero trust: unknown subcommand %q\n\n", args[0]); err != nil {
return exitCrash
}
writeTrustUsage(stderr)
return exitUsage
}
}
// trustCurrentDir trusts the exact current working directory.
func trustCurrentDir(stdout io.Writer, stderr io.Writer, deps appDeps) int {
cwd, err := deps.getwd()
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(fmt.Errorf("resolve workspace: %w", err), redaction.Options{}), exitCrash)
}
if err := workspacetrust.Trust(cwd); err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if _, err := fmt.Fprintf(stdout, "Trusted %s\n", cwd); err != nil {
return exitCrash
}
return exitSuccess
}
// trustList prints each trusted root, one per line, or a friendly line when none
// are trusted.
func trustList(stdout io.Writer, stderr io.Writer) int {
roots, err := workspacetrust.List()
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if len(roots) == 0 {
if _, err := fmt.Fprintln(stdout, "No trusted workspaces."); err != nil {
return exitCrash
}
return exitSuccess
}
for _, root := range roots {
if _, err := fmt.Fprintln(stdout, root); err != nil {
return exitCrash
}
}
return exitSuccess
}
// trustRemove untrusts the current directory, or a named path when one is given.
// The path argument is passed to workspacetrust.Untrust verbatim, which normalizes
// it (filepath.Abs + filepath.EvalSymlinks) the same way the store does, so a
// relative or trailing-slash argument still matches the stored entry.
func trustRemove(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
var target string
switch len(args) {
case 0:
cwd, err := deps.getwd()
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(fmt.Errorf("resolve workspace: %w", err), redaction.Options{}), exitCrash)
}
target = cwd
case 1:
target = args[0]
default:
if _, err := fmt.Fprintln(stderr, "usage: zero trust remove [path]"); err != nil {
return exitCrash
}
return exitUsage
}
if err := workspacetrust.Untrust(target); err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if _, err := fmt.Fprintf(stdout, "Untrusted %s\n", target); err != nil {
return exitCrash
}
return exitSuccess
}
func writeTrustUsage(w io.Writer) {
_, _ = fmt.Fprint(w, `Usage:
zero trust Trust the current working directory
zero trust list List trusted workspace roots
zero trust remove [path] Untrust the current directory, or a named path
Trust lets Zero run a workspace's project-scoped hooks, plugins, and MCP
servers (./.zero/hooks.json, ./.zero/plugins/, project MCP config). Trust is
exact per directory.
`)
}
-363
View File
@@ -1,363 +0,0 @@
package cli
import (
"bytes"
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/workspacetrust"
"github.com/Gitlawb/zero/internal/worktrees"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// These tests close the end-to-end gap the chokepoint unit tests leave open:
// they drive the REAL agent loop (and, for the worktree cases, the real exec
// entry point) with a fake provider that emits a tool call, so a project
// beforeTool hook actually fires (or is gated away) through production code, not
// a direct dispatcher call.
// markerTool is a minimal, always-allowed tool the fake provider "calls" so the
// agent loop reaches dispatchBeforeTool (which only fires for a registered,
// permitted tool). Its own Run is a no-op; the observable effect is the project
// beforeTool hook the gate did or did not load.
type markerTool struct{}
func (markerTool) Name() string { return "marker_tool" }
func (markerTool) Description() string { return "test-only no-op tool" }
func (markerTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (markerTool) Safety() tools.Safety { return tools.Safety{Permission: tools.PermissionAllow} }
func (markerTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "ok"}
}
// toolThenTextProvider calls toolName on the first turn, then answers with text so
// the loop terminates. It detects "first turn" by the absence of a prior tool
// result in the message history.
type toolThenTextProvider struct{ toolName string }
func (p toolThenTextProvider) StreamCompletion(_ context.Context, req zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
toolAlreadyCalled := false
for _, m := range req.Messages {
if m.Role == zeroruntime.MessageRoleTool {
toolAlreadyCalled = true
break
}
}
ch := make(chan zeroruntime.StreamEvent, 8)
go func() {
defer close(ch)
if toolAlreadyCalled {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
return
}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: p.toolName}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "c1", ArgumentsFragment: `{"pattern":"*"}`}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
}()
return ch, nil
}
// writeMarkerHook writes a ./.zero/hooks.json under dir whose enabled beforeTool
// hook touches markerPath when it fires. The hook shells out to /bin/sh, so the
// trusted-case assertion (the marker must appear) is meaningless on Windows, where
// that interpreter does not exist; skip there, matching writeMarkerHookScript.
func writeMarkerHook(t *testing.T, dir, markerPath string) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("marker hook is POSIX-shell based (/bin/sh)")
}
if err := os.MkdirAll(filepath.Join(dir, ".zero"), 0o755); err != nil {
t.Fatal(err)
}
body := `{"enabled":true,"hooks":[{"id":"m","event":"beforeTool","command":"/bin/sh","args":["-c","touch '` + markerPath + `'"],"enabled":true}]}`
if err := os.WriteFile(filepath.Join(dir, ".zero", "hooks.json"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
// TestTrustGateBlocksToolHookThroughAgentRun closes gap #2: the gate blocks a
// real tool call's beforeTool hook through the production agent.Run loop, not
// just a direct dispatcher call. Untrusted => the project hook is not in the
// dispatcher, so the tool call fires nothing. Trusted => it fires.
func TestTrustGateBlocksToolHookThroughAgentRun(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir()
marker := filepath.Join(t.TempDir(), "marker")
writeMarkerHook(t, repo, marker)
runOnce := func() {
reg := tools.NewRegistry()
reg.Register(markerTool{})
disp, _ := newHookDispatcherWithExtra(repo, nil, repo)
if _, err := agent.Run(context.Background(), "go", toolThenTextProvider{toolName: "marker_tool"}, agent.Options{
Registry: reg,
Hooks: disp,
PermissionMode: agent.PermissionModeUnsafe,
MaxTurns: 3,
}); err != nil {
t.Fatalf("agent.Run: %v", err)
}
}
// Untrusted: gate excludes the project layer; the beforeTool hook must not run.
_ = os.Remove(marker)
runOnce()
if _, err := os.Stat(marker); err == nil {
t.Fatal("UNTRUSTED: project beforeTool hook ran through agent.Run (marker exists) -- gate failed OPEN")
}
// Trusted: the hook is in the dispatcher; the tool call fires it.
if err := workspacetrust.Trust(repo); err != nil {
t.Fatal(err)
}
_ = os.Remove(marker)
runOnce()
if _, err := os.Stat(marker); err != nil {
t.Fatalf("TRUSTED: project beforeTool hook did NOT run (marker absent): %v", err)
}
}
// TestTrustGateFiresInDefaultAutoMode is the in-scope proof for the security
// report: the beforeTool hook fires in the DEFAULT permission mode (auto), NOT
// only under --skip-permissions-unsafe. marker_tool has PermissionAllow safety,
// so effectivePermission grants it at loop.go without a prompt regardless of
// mode; dispatchBeforeTool then runs the project hook. This means the
// vulnerability is reachable in normal operation with the sandbox ON, so it is
// not the "requires the user to disable the sandbox" out-of-scope case.
//
// Trusted => the hook fires in auto mode without any unsafe flag; untrusted =>
// the gate blocks it, also in auto mode. No OnPermissionRequest is wired: an
// auto-allowed tool needs no approval callback, which is the whole point.
func TestTrustGateFiresInDefaultAutoMode(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir()
marker := filepath.Join(t.TempDir(), "marker")
writeMarkerHook(t, repo, marker)
runOnce := func(mode agent.PermissionMode) {
reg := tools.NewRegistry()
reg.Register(markerTool{})
disp, _ := newHookDispatcherWithExtra(repo, nil, repo)
if _, err := agent.Run(context.Background(), "go", toolThenTextProvider{toolName: "marker_tool"}, agent.Options{
Registry: reg,
Hooks: disp,
PermissionMode: mode,
MaxTurns: 3,
}); err != nil {
t.Fatalf("agent.Run (%s): %v", mode, err)
}
}
// Untrusted, default auto mode: the gate excludes the project layer, so the
// hook must NOT fire even though the tool call is permitted.
_ = os.Remove(marker)
runOnce(agent.PermissionModeAuto)
if _, err := os.Stat(marker); err == nil {
t.Fatal("UNTRUSTED auto mode: project beforeTool hook ran -- gate failed OPEN in the default mode")
}
// Trusted, default auto mode (sandbox on, NO --skip-permissions-unsafe): the
// auto-allowed tool is granted with no prompt and its beforeTool hook fires.
if err := workspacetrust.Trust(repo); err != nil {
t.Fatal(err)
}
_ = os.Remove(marker)
runOnce(agent.PermissionModeAuto)
if _, err := os.Stat(marker); err != nil {
t.Fatalf("TRUSTED auto mode: project beforeTool hook did NOT fire without an unsafe flag (marker absent): %v", err)
}
// And it is not an auto-only quirk: ask mode (also sandboxed, non-unsafe)
// fires the same way for an auto-allowed tool.
_ = os.Remove(marker)
runOnce(agent.PermissionModeAsk)
if _, err := os.Stat(marker); err != nil {
t.Fatalf("TRUSTED ask mode: project beforeTool hook did NOT fire (marker absent): %v", err)
}
}
// TestExecSurfacesMCPTrustNotice proves the exec path actually threads the MCP trust
// skip into the one-line notice (the CodeRabbit finding), not just that the unit
// pieces work in isolation: an untrusted repo whose only project config is MCP prints
// the notice through the real runExec wiring; trusting it silences it. resolveMCPConfig
// is stubbed to return no servers so nothing spawns -- the notice depends only on the
// trust verdict and the real ./.zero/config.json that projectMCPConfigExists reads.
func TestExecSurfacesMCPTrustNotice(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("exec fake-provider harness assumes a POSIX process environment")
}
setTrustConfigRoot(t)
repo := t.TempDir()
if err := os.MkdirAll(filepath.Join(repo, ".zero"), 0o700); err != nil {
t.Fatal(err)
}
body := `{"mcp":{"servers":{"proj":{"type":"stdio","command":"proj-cmd"}}}}`
if err := os.WriteFile(filepath.Join(repo, ".zero", "config.json"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
run := func() string {
var out, errBuf bytes.Buffer
code := runWithDeps([]string{"exec", "--skip-permissions-unsafe", "--max-turns", "3", "go"}, &out, &errBuf, appDeps{
getwd: func() (string, error) { return repo, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) { return execResolvedConfig(), nil },
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return toolThenTextProvider{toolName: "glob"}, nil
},
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
})
if code != exitSuccess {
t.Fatalf("exec exit = %d, stderr=%q", code, errBuf.String())
}
return errBuf.String()
}
// Untrusted: the project MCP layer is dropped, so the notice must name it. The repo
// has no project hooks/plugins, so the MCP skip is the ONLY thing that can fire it.
untrusted := run()
if !strings.Contains(untrusted, "MCP servers") || !strings.Contains(untrusted, "zero trust") {
t.Fatalf("untrusted exec must surface the MCP trust notice, stderr=%q", untrusted)
}
// Trusted: nothing is skipped, so no notice at all.
if err := workspacetrust.Trust(repo); err != nil {
t.Fatal(err)
}
if trusted := run(); strings.Contains(trusted, "ignoring project") {
t.Fatalf("trusted exec must not emit a trust notice, stderr=%q", trusted)
}
}
// TestExecSpecSurfacesMCPTrustNotice is the --use-spec analogue of the test above:
// the spec-draft path (exec_spec.go) must also thread the MCP skip into its notice.
// The fake provider never submits a spec, so the run exits non-zero; that is
// orthogonal to trust, so we assert only the notice, not the exit code.
func TestExecSpecSurfacesMCPTrustNotice(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("exec fake-provider harness assumes a POSIX process environment")
}
setTrustConfigRoot(t)
repo := t.TempDir()
if err := os.MkdirAll(filepath.Join(repo, ".zero"), 0o700); err != nil {
t.Fatal(err)
}
body := `{"mcp":{"servers":{"proj":{"type":"stdio","command":"proj-cmd"}}}}`
if err := os.WriteFile(filepath.Join(repo, ".zero", "config.json"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
var out, errBuf bytes.Buffer
_ = runWithDeps([]string{"exec", "--use-spec", "--skip-permissions-unsafe", "--max-turns", "3", "go"}, &out, &errBuf, appDeps{
getwd: func() (string, error) { return repo, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) { return execResolvedConfig(), nil },
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return toolThenTextProvider{toolName: "glob"}, nil
},
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
})
if !strings.Contains(errBuf.String(), "MCP servers") || !strings.Contains(errBuf.String(), "zero trust") {
t.Fatalf("untrusted --use-spec exec must surface the MCP trust notice, stderr=%q", errBuf.String())
}
}
// runExecTrust drives the full exec entry point with a fake worktree and a
// tool-calling provider, returning the exit code. The provider calls the core
// "glob" tool so dispatchBeforeTool fires inside the real exec-built registry.
func runExecTrust(t *testing.T, extraArgs []string, launchDir, worktreeDir string) int {
t.Helper()
t.Setenv("XDG_DATA_HOME", t.TempDir())
args := append([]string{"exec", "--worktree", "--skip-permissions-unsafe", "--max-turns", "3"}, extraArgs...)
args = append(args, "go")
var out, errBuf bytes.Buffer
return runWithDeps(args, &out, &errBuf, appDeps{
getwd: func() (string, error) { return launchDir, nil },
prepareWorktree: func(context.Context, worktrees.Options) (worktrees.Result, error) {
return worktrees.Result{Path: worktreeDir}, nil
},
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return toolThenTextProvider{toolName: "glob"}, nil
},
})
}
// TestExecWorktreeInheritsTrustEndToEnd closes gap #1 for the exec path: trust is
// keyed on the ORIGINAL launch dir, not the generated worktree path. The worktree
// checkout carries the committed .zero/hooks.json; the gate must load it only when
// the SOURCE repo (launch dir) is trusted, proving exec.go captures trustRoot
// before the --worktree reassignment and threads it into the chokepoint.
func TestExecWorktreeInheritsTrustEndToEnd(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir() // original launch dir -- the trust key
worktree := t.TempDir() // simulated worktree checkout (workspaceRoot after reassignment)
marker := filepath.Join(t.TempDir(), "marker")
writeMarkerHook(t, worktree, marker) // the checkout carries the committed hook
// Untrusted source repo: worktree hooks must NOT run.
_ = os.Remove(marker)
if code := runExecTrust(t, nil, repo, worktree); code != exitSuccess {
t.Fatalf("exec --worktree (untrusted) exit = %d", code)
}
if _, err := os.Stat(marker); err == nil {
t.Fatal("UNTRUSTED worktree: project hook ran -- exec keyed trust on the worktree path or failed open")
}
// Trusted source repo: the worktree inherits its trust, so the hook runs.
if err := workspacetrust.Trust(repo); err != nil {
t.Fatal(err)
}
_ = os.Remove(marker)
if code := runExecTrust(t, nil, repo, worktree); code != exitSuccess {
t.Fatalf("exec --worktree (trusted) exit = %d", code)
}
if _, err := os.Stat(marker); err != nil {
t.Fatalf("TRUSTED worktree: project hook did NOT run (marker absent) -- worktree trust inheritance broken: %v", err)
}
}
// TestExecSpecWorktreeInheritsTrustEndToEnd closes gap #1 for the spec-draft path
// (the exact --use-spec --worktree combination the review fix addressed): the
// spec-draft chokepoint must also key trust on the original launch dir.
func TestExecSpecWorktreeInheritsTrustEndToEnd(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir()
worktree := t.TempDir()
marker := filepath.Join(t.TempDir(), "marker")
writeMarkerHook(t, worktree, marker)
// The spec-draft flow itself exits non-zero here (the fake provider does not
// submit a real spec), which is orthogonal to trust: the hook dispatcher is
// built (keyed on run.trustRoot) before the agent runs, and glob (a read-only
// allow tool) is advertised in spec-draft, so beforeTool still fires. We assert
// only the marker, the trust behavior, not the spec-flow exit code.
// Untrusted: spec-draft in a worktree of an untrusted repo runs no project hook.
_ = os.Remove(marker)
_ = runExecTrust(t, []string{"--use-spec"}, repo, worktree)
if _, err := os.Stat(marker); err == nil {
t.Fatal("UNTRUSTED spec-draft worktree: project hook ran -- spec-draft keyed trust on the worktree path")
}
// Trusted: the spec-draft path inherits the source repo's trust.
if err := workspacetrust.Trust(repo); err != nil {
t.Fatal(err)
}
_ = os.Remove(marker)
_ = runExecTrust(t, []string{"--use-spec"}, repo, worktree)
if _, err := os.Stat(marker); err != nil {
t.Fatalf("TRUSTED spec-draft worktree: project hook did NOT run -- spec-draft trust inheritance broken: %v", err)
}
}
-466
View File
@@ -1,466 +0,0 @@
package cli
import (
"bytes"
"context"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/plugins"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/workspacetrust"
)
// setTrustConfigRoot redirects both the workspace-trust store and the user-level
// hooks/plugins config to a fresh temp dir with a GOOS-aware env switch, mirroring
// setUserConfigRoot in internal/config/paths_test.go, and returns that dir so tests
// can build the exact paths the code resolves to.
//
// XDG_CONFIG_HOME is the lever on macOS as well as Linux: config.UserConfigDir is
// XDG-first there (it only falls back to $HOME/.config when XDG_CONFIG_HOME is
// unset), and the hooks loader resolves its user layer from XDG_CONFIG_HOME too. An
// earlier version set HOME on darwin and returned the raw root; that left the store
// at <root>/.config/zero while the tests wrote to <root>/zero, so the store-error
// and user-hook cases silently missed. Only Windows needs its own variable (APPDATA,
// what os.UserConfigDir consults). XDG_DATA_HOME is redirected so the hook audit
// store never touches the user's real data dir.
func setTrustConfigRoot(t *testing.T) string {
t.Helper()
root := t.TempDir()
switch runtime.GOOS {
case "windows":
t.Setenv("APPDATA", root)
default:
t.Setenv("XDG_CONFIG_HOME", root)
}
t.Setenv("XDG_DATA_HOME", t.TempDir())
return root
}
// writeMarkerHookScript writes an executable script that creates markerPath when
// run, and returns its absolute path. The hook dispatcher runs commands directly
// (no shell), so a real executable is needed to observe execution.
func writeMarkerHookScript(t *testing.T, markerPath string) string {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("marker-hook script is POSIX-shell based")
}
dir := t.TempDir()
script := filepath.Join(dir, "mark.sh")
body := "#!/bin/sh\n: > " + shellQuote(markerPath) + "\n"
if err := os.WriteFile(script, []byte(body), 0o700); err != nil {
t.Fatalf("write marker hook script: %v", err)
}
return script
}
func shellQuote(path string) string {
return "'" + path + "'"
}
// writeProjectHooks writes a project ./.zero/hooks.json under repo with the given
// hooks and top-level enabled flag.
func writeProjectHooks(t *testing.T, repo string, enabled bool, defs ...hooks.Definition) {
t.Helper()
path := filepath.Join(repo, ".zero", "hooks.json")
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatalf("mkdir project .zero: %v", err)
}
if err := hooks.WriteConfig(path, hooks.Config{Enabled: enabled, Hooks: defs}); err != nil {
t.Fatalf("write project hooks.json: %v", err)
}
}
// writeUserHooks writes a user-level hooks.json into the redirected config root.
func writeUserHooks(t *testing.T, configRoot string, enabled bool, defs ...hooks.Definition) {
t.Helper()
path := filepath.Join(configRoot, "zero", "hooks.json")
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatalf("mkdir user config dir: %v", err)
}
if err := hooks.WriteConfig(path, hooks.Config{Enabled: enabled, Hooks: defs}); err != nil {
t.Fatalf("write user hooks.json: %v", err)
}
}
func dispatchBeforeTool(dispatcher *hooks.Dispatcher) hooks.DispatchOutcome {
return dispatcher.Dispatch(context.Background(), hooks.DispatchInput{
Event: hooks.EventBeforeTool,
ToolName: "bash",
})
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// TestHookGateUntrustedExcludesProjectLayer proves R1: an untrusted trustRoot with
// an enabled project beforeTool hook runs nothing and writes no marker.
func TestHookGateUntrustedExcludesProjectLayer(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir()
marker := filepath.Join(t.TempDir(), "ran")
script := writeMarkerHookScript(t, marker)
writeProjectHooks(t, repo, true, hooks.Definition{
ID: "proj.mark", Event: hooks.EventBeforeTool, Command: script, Enabled: true,
})
dispatcher, skip := newHookDispatcherWithExtra(repo, nil, repo)
outcome := dispatchBeforeTool(dispatcher)
if outcome.Ran != 0 {
t.Fatalf("untrusted workspace should run 0 project hooks, Ran=%d", outcome.Ran)
}
if fileExists(marker) {
t.Fatalf("untrusted workspace must not execute the project hook (marker exists)")
}
if !skip.excludedProjectConfig {
t.Fatalf("skip report should flag the excluded project hooks file")
}
if skip.trustCheckErrored {
t.Fatalf("a clean untrusted verdict is not a store-read error")
}
}
// TestHookGateTrustedRunsProjectHook proves R3: after Trust(repo) the project hook
// runs and the marker appears.
func TestHookGateTrustedRunsProjectHook(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir()
marker := filepath.Join(t.TempDir(), "ran")
script := writeMarkerHookScript(t, marker)
writeProjectHooks(t, repo, true, hooks.Definition{
ID: "proj.mark", Event: hooks.EventBeforeTool, Command: script, Enabled: true,
})
if err := workspacetrust.Trust(repo); err != nil {
t.Fatalf("Trust(repo): %v", err)
}
dispatcher, skip := newHookDispatcherWithExtra(repo, nil, repo)
outcome := dispatchBeforeTool(dispatcher)
if outcome.Ran != 1 {
t.Fatalf("trusted workspace should run the project hook, Ran=%d", outcome.Ran)
}
if !fileExists(marker) {
t.Fatalf("trusted workspace must execute the project hook (marker missing)")
}
if skip.excludedProjectConfig {
t.Fatalf("a trusted workspace must not report the project layer as excluded")
}
}
// TestHookGateEmptyTrustRootFailsClosed proves the fail-closed-by-construction
// guard: a caller that forgot to resolve trustRoot (empty) still excludes the
// project layer, so no marker appears.
func TestHookGateEmptyTrustRootFailsClosed(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir()
marker := filepath.Join(t.TempDir(), "ran")
script := writeMarkerHookScript(t, marker)
writeProjectHooks(t, repo, true, hooks.Definition{
ID: "proj.mark", Event: hooks.EventBeforeTool, Command: script, Enabled: true,
})
// Even trusting the repo must not help when the caller passes an empty root.
if err := workspacetrust.Trust(repo); err != nil {
t.Fatalf("Trust(repo): %v", err)
}
dispatcher, skip := newHookDispatcherWithExtra(repo, nil, "")
outcome := dispatchBeforeTool(dispatcher)
if outcome.Ran != 0 {
t.Fatalf("empty trustRoot must fail closed, Ran=%d", outcome.Ran)
}
if fileExists(marker) {
t.Fatalf("empty trustRoot must not execute the project hook (marker exists)")
}
if !skip.excludedProjectConfig {
t.Fatalf("empty trustRoot should report the project layer excluded")
}
}
// TestHookGateFailClosedOnStoreError proves R5: a real IsTrusted error (trust.json
// created as a directory, per U1) is treated as untrusted, the project hook does
// not run, and the skip report marks the store-read error.
func TestHookGateFailClosedOnStoreError(t *testing.T) {
configRoot := setTrustConfigRoot(t)
// Create the trust store path as a DIRECTORY so os.ReadFile fails with a
// non-ErrNotExist error, forcing IsTrusted to return (false, non-nil).
trustPath := filepath.Join(configRoot, "zero", "trust.json")
if err := os.MkdirAll(trustPath, 0o700); err != nil {
t.Fatalf("create trust.json as a directory: %v", err)
}
repo := t.TempDir()
marker := filepath.Join(t.TempDir(), "ran")
script := writeMarkerHookScript(t, marker)
writeProjectHooks(t, repo, true, hooks.Definition{
ID: "proj.mark", Event: hooks.EventBeforeTool, Command: script, Enabled: true,
})
dispatcher, skip := newHookDispatcherWithExtra(repo, nil, repo)
outcome := dispatchBeforeTool(dispatcher)
if outcome.Ran != 0 {
t.Fatalf("a store-read error must fail closed, Ran=%d", outcome.Ran)
}
if fileExists(marker) {
t.Fatalf("a store-read error must not execute the project hook (marker exists)")
}
if !skip.trustCheckErrored {
t.Fatalf("skip report must mark the store-read error")
}
if !skip.excludedProjectConfig {
t.Fatalf("the project layer must be reported excluded on the error path")
}
}
// TestHookGateUserHookStillRunsWhenProjectExcluded proves R4/R10: with the project
// layer excluded, a user-level hook still fires, and a project hooks.json that sets
// global enabled:false or defines a same-ID hook cannot disable or override it.
func TestHookGateUserHookStillRunsWhenProjectExcluded(t *testing.T) {
configRoot := setTrustConfigRoot(t)
repo := t.TempDir()
marker := filepath.Join(t.TempDir(), "ran")
script := writeMarkerHookScript(t, marker)
// User hook that writes the marker.
writeUserHooks(t, configRoot, true, hooks.Definition{
ID: "shared.mark", Event: hooks.EventBeforeTool, Command: script, Enabled: true,
})
// Project config that would, if loaded, disable the whole surface (enabled:false)
// AND override the user hook by ID with a disabled no-op. The gate drops the
// whole project layer, so neither takes effect.
writeProjectHooks(t, repo, false, hooks.Definition{
ID: "shared.mark", Event: hooks.EventBeforeTool, Command: "true", Enabled: false,
})
// Untrusted: the project layer is excluded.
dispatcher, _ := newHookDispatcherWithExtra(repo, nil, repo)
outcome := dispatchBeforeTool(dispatcher)
if outcome.Ran != 1 {
t.Fatalf("user hook must still run when the project layer is excluded, Ran=%d", outcome.Ran)
}
if !fileExists(marker) {
t.Fatalf("user hook must fire (marker missing); project config must not disable/override it")
}
}
// TestHookGateWorktreeInheritsTrust proves the worktree case: with repo trusted,
// calling the chokepoint with a different workspaceRoot (the worktree path) but
// trustRoot=repo loads the project hooks, so trust keys on trustRoot not
// workspaceRoot.
func TestHookGateWorktreeInheritsTrust(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir()
worktree := t.TempDir() // a distinct, never-trusted path
marker := filepath.Join(t.TempDir(), "ran")
script := writeMarkerHookScript(t, marker)
// The project hooks live under the worktree (that is where config is read from).
writeProjectHooks(t, worktree, true, hooks.Definition{
ID: "proj.mark", Event: hooks.EventBeforeTool, Command: script, Enabled: true,
})
if err := workspacetrust.Trust(repo); err != nil {
t.Fatalf("Trust(repo): %v", err)
}
// workspaceRoot is the worktree, but trustRoot is the original repo.
dispatcher, skip := newHookDispatcherWithExtra(worktree, nil, repo)
outcome := dispatchBeforeTool(dispatcher)
if outcome.Ran != 1 {
t.Fatalf("a worktree of a trusted repo should load project hooks, Ran=%d", outcome.Ran)
}
if !fileExists(marker) {
t.Fatalf("worktree run should execute the project hook (marker missing)")
}
if skip.excludedProjectConfig {
t.Fatalf("worktree of a trusted repo must not report the project layer excluded")
}
}
// TestPluginGateUntrustedExcludesProject proves R2/R5: an untrusted trustRoot makes
// activatePlugins load with ExcludeProject=true and report the skip.
func TestPluginGateUntrustedExcludesProject(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir()
// A ./.zero/plugins dir so the skip report flags an excluded project config.
if err := os.MkdirAll(filepath.Join(repo, ".zero", "plugins"), 0o700); err != nil {
t.Fatalf("mkdir project plugins dir: %v", err)
}
var gotExclude bool
deps := appDeps{
loadPlugins: func(opts plugins.LoadOptions) (plugins.LoadResult, error) {
gotExclude = opts.ExcludeProject
return plugins.LoadResult{}, nil
},
skillsDir: func() string { return t.TempDir() },
}
registry := tools.NewRegistry()
var stderr bytes.Buffer
activation := activatePlugins(repo, registry, deps, &stderr, repo)
if !gotExclude {
t.Fatalf("untrusted workspace must pass ExcludeProject=true to loadPlugins")
}
if !activation.excludedProjectConfig {
t.Fatalf("skip report should flag the excluded ./.zero/plugins dir")
}
if activation.trustCheckErrored {
t.Fatalf("a clean untrusted verdict is not a store-read error")
}
}
// TestPluginGateTrustedIncludesProject proves R3: after Trust(repo), activatePlugins
// loads with ExcludeProject=false.
func TestPluginGateTrustedIncludesProject(t *testing.T) {
setTrustConfigRoot(t)
repo := t.TempDir()
if err := workspacetrust.Trust(repo); err != nil {
t.Fatalf("Trust(repo): %v", err)
}
var gotExclude bool
deps := appDeps{
loadPlugins: func(opts plugins.LoadOptions) (plugins.LoadResult, error) {
gotExclude = opts.ExcludeProject
return plugins.LoadResult{}, nil
},
skillsDir: func() string { return t.TempDir() },
}
registry := tools.NewRegistry()
var stderr bytes.Buffer
activation := activatePlugins(repo, registry, deps, &stderr, repo)
if gotExclude {
t.Fatalf("trusted workspace must pass ExcludeProject=false to loadPlugins")
}
if activation.excludedProjectConfig {
t.Fatalf("trusted workspace must not report the project config excluded")
}
}
// TestPluginGateFailClosedOnStoreError proves R5 for plugins: a store-read error
// forces ExcludeProject=true and marks the error.
func TestPluginGateFailClosedOnStoreError(t *testing.T) {
configRoot := setTrustConfigRoot(t)
trustPath := filepath.Join(configRoot, "zero", "trust.json")
if err := os.MkdirAll(trustPath, 0o700); err != nil {
t.Fatalf("create trust.json as a directory: %v", err)
}
repo := t.TempDir()
if err := os.MkdirAll(filepath.Join(repo, ".zero", "plugins"), 0o700); err != nil {
t.Fatalf("mkdir project plugins dir: %v", err)
}
var gotExclude bool
deps := appDeps{
loadPlugins: func(opts plugins.LoadOptions) (plugins.LoadResult, error) {
gotExclude = opts.ExcludeProject
return plugins.LoadResult{}, nil
},
skillsDir: func() string { return t.TempDir() },
}
registry := tools.NewRegistry()
var stderr bytes.Buffer
activation := activatePlugins(repo, registry, deps, &stderr, repo)
if !gotExclude {
t.Fatalf("a store-read error must fail closed (ExcludeProject=true)")
}
if !activation.trustCheckErrored {
t.Fatalf("skip report must mark the store-read error")
}
}
// TestEmitTrustNoticeOneLineWhenSkipped proves R6: exactly one notice when either
// surface skips project config, none when trusted, and distinct error text on a
// store-read failure.
func TestEmitTrustNoticeOneLineWhenSkipped(t *testing.T) {
t.Run("both surfaces skipped yields one line", func(t *testing.T) {
var buf bytes.Buffer
emitTrustNotice(&buf,
trustSkip{excludedProjectConfig: true},
trustSkip{excludedProjectConfig: true})
lines := nonEmptyLines(buf.String())
if len(lines) != 1 {
t.Fatalf("expected exactly one notice line, got %d: %q", len(lines), buf.String())
}
if !bytes.Contains(buf.Bytes(), []byte("zero trust")) {
t.Fatalf("notice should point at 'zero trust', got %q", buf.String())
}
})
t.Run("trusted yields no notice", func(t *testing.T) {
var buf bytes.Buffer
emitTrustNotice(&buf, trustSkip{}, trustSkip{})
if buf.Len() != 0 {
t.Fatalf("a trusted session must emit no notice, got %q", buf.String())
}
})
t.Run("store-read error uses distinct text", func(t *testing.T) {
var buf bytes.Buffer
emitTrustNotice(&buf,
trustSkip{excludedProjectConfig: true, trustCheckErrored: true},
trustSkip{})
lines := nonEmptyLines(buf.String())
if len(lines) != 1 {
t.Fatalf("expected exactly one notice line, got %d: %q", len(lines), buf.String())
}
if !bytes.Contains(buf.Bytes(), []byte("could not be read")) {
t.Fatalf("error-path notice should name the store read failure, got %q", buf.String())
}
})
t.Run("only one surface skipped still yields one line", func(t *testing.T) {
var buf bytes.Buffer
emitTrustNotice(&buf, trustSkip{excludedProjectConfig: true}, trustSkip{})
lines := nonEmptyLines(buf.String())
if len(lines) != 1 {
t.Fatalf("expected exactly one notice line, got %d: %q", len(lines), buf.String())
}
})
t.Run("mcp skip alone yields one line naming mcp", func(t *testing.T) {
var buf bytes.Buffer
// Hooks and plugins clean; only the MCP surface (third arg) dropped project config.
emitTrustNotice(&buf, trustSkip{}, trustSkip{}, trustSkip{excludedProjectConfig: true})
lines := nonEmptyLines(buf.String())
if len(lines) != 1 {
t.Fatalf("expected exactly one notice line, got %d: %q", len(lines), buf.String())
}
if !bytes.Contains(buf.Bytes(), []byte("MCP")) {
t.Fatalf("notice should name MCP when the MCP surface is skipped, got %q", buf.String())
}
})
t.Run("store error with nothing excluded yields no notice", func(t *testing.T) {
// trustCheckErrored but no surface had project config to skip (excludedProjectConfig
// false): there is nothing to warn about, so the excluded-gate wins over the error.
var buf bytes.Buffer
emitTrustNotice(&buf, trustSkip{trustCheckErrored: true}, trustSkip{trustCheckErrored: true})
if buf.Len() != 0 {
t.Fatalf("a store error with no skipped project config must emit nothing, got %q", buf.String())
}
})
}
func nonEmptyLines(s string) []string {
out := []string{}
for _, line := range bytes.Split([]byte(s), []byte("\n")) {
if len(bytes.TrimSpace(line)) > 0 {
out = append(out, string(line))
}
}
return out
}
-227
View File
@@ -1,227 +0,0 @@
package cli
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/workspacetrust"
)
// trustDeps builds a test appDeps whose getwd returns a fixed directory, so the
// trust command can be exercised without touching the real cwd.
func trustDeps(cwd string) appDeps {
return appDeps{getwd: func() (string, error) { return cwd, nil }}
}
// TestRunTrustAddThenList proves `trust` trusts the cwd and `trust list` shows it.
func TestRunTrustAddThenList(t *testing.T) {
setTrustConfigRoot(t)
cwd := t.TempDir()
deps := trustDeps(cwd)
var out, errBuf bytes.Buffer
if code := runTrust(nil, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("trust returned %d, want %d; stderr=%q", code, exitSuccess, errBuf.String())
}
if !strings.Contains(out.String(), "Trusted "+cwd) {
t.Fatalf("trust stdout = %q, want it to contain %q", out.String(), "Trusted "+cwd)
}
trusted, err := workspacetrust.IsTrusted(cwd)
if err != nil {
t.Fatalf("IsTrusted after trust: %v", err)
}
if !trusted {
t.Fatalf("cwd should be trusted after `trust`")
}
// The store canonicalizes with filepath.EvalSymlinks, so `list` prints the
// resolved path. On platforms where the temp dir is a symlink (macOS /var) or a
// Windows 8.3 short name (RUNNER~1 -> runneradmin), that differs from the raw
// cwd; compare against the same canonical form the store records.
wantListed := cwd
if resolved, err := filepath.EvalSymlinks(cwd); err == nil {
wantListed = resolved
}
out.Reset()
errBuf.Reset()
if code := runTrust([]string{"list"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("trust list returned %d, want %d; stderr=%q", code, exitSuccess, errBuf.String())
}
if !strings.Contains(out.String(), wantListed) {
t.Fatalf("trust list stdout = %q, want it to contain the trusted root %q", out.String(), wantListed)
}
}
// TestRunTrustListShowsCanonicalPath executes the canonical-path behavior that broke
// TestRunTrustAddThenList on the Windows runner (an 8.3 short name expanded by
// EvalSymlinks). It uses a symlinked cwd -- the portable, Linux-runnable analogue of
// that divergence -- and proves `trust list` prints the resolved path the store
// records, never the raw pre-resolution path.
func TestRunTrustListShowsCanonicalPath(t *testing.T) {
setTrustConfigRoot(t)
realDir := t.TempDir()
link := filepath.Join(t.TempDir(), "link")
if err := os.Symlink(realDir, link); err != nil {
t.Skipf("symlinks unavailable on this platform: %v", err)
}
resolved, err := filepath.EvalSymlinks(link)
if err != nil {
t.Fatalf("EvalSymlinks(link): %v", err)
}
if resolved == link {
t.Skip("symlink did not change the path on this platform")
}
deps := trustDeps(link)
if code := runTrust(nil, &bytes.Buffer{}, &bytes.Buffer{}, deps); code != exitSuccess {
t.Fatalf("trust returned %d", code)
}
var out, errBuf bytes.Buffer
if code := runTrust([]string{"list"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("trust list returned %d; stderr=%q", code, errBuf.String())
}
if !strings.Contains(out.String(), resolved) {
t.Fatalf("trust list = %q, want the canonical path %q", out.String(), resolved)
}
if strings.Contains(out.String(), link) {
t.Fatalf("trust list = %q, must not contain the raw pre-resolution path %q", out.String(), link)
}
}
// TestRunTrustRemoveCurrentDir proves `trust remove` (no arg) untrusts the cwd and
// `trust list` then reports none.
func TestRunTrustRemoveCurrentDir(t *testing.T) {
setTrustConfigRoot(t)
cwd := t.TempDir()
deps := trustDeps(cwd)
if code := runTrust(nil, &bytes.Buffer{}, &bytes.Buffer{}, deps); code != exitSuccess {
t.Fatalf("trust setup returned %d", code)
}
var out, errBuf bytes.Buffer
if code := runTrust([]string{"remove"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("trust remove returned %d, want %d; stderr=%q", code, exitSuccess, errBuf.String())
}
if !strings.Contains(out.String(), "Untrusted "+cwd) {
t.Fatalf("trust remove stdout = %q, want it to contain %q", out.String(), "Untrusted "+cwd)
}
trusted, err := workspacetrust.IsTrusted(cwd)
if err != nil {
t.Fatalf("IsTrusted after remove: %v", err)
}
if trusted {
t.Fatalf("cwd should not be trusted after `trust remove`")
}
out.Reset()
errBuf.Reset()
if code := runTrust([]string{"list"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("trust list returned %d, want %d", code, exitSuccess)
}
if !strings.Contains(out.String(), "No trusted workspaces.") {
t.Fatalf("trust list stdout = %q, want %q for an empty store", out.String(), "No trusted workspaces.")
}
}
// TestRunTrustRemoveNamedPathNonCanonical proves `trust remove <path>` untrusts a
// specific path, and that a non-canonical argument (a trailing slash) still matches
// the stored normalized entry.
func TestRunTrustRemoveNamedPathNonCanonical(t *testing.T) {
setTrustConfigRoot(t)
// The cwd is a different directory than the one we trust-and-remove, so this
// exercises the named-path branch, not the bare-cwd branch.
cwd := t.TempDir()
target := t.TempDir()
deps := trustDeps(cwd)
if err := workspacetrust.Trust(target); err != nil {
t.Fatalf("Trust(target): %v", err)
}
trusted, err := workspacetrust.IsTrusted(target)
if err != nil || !trusted {
t.Fatalf("target should be trusted before remove (trusted=%v err=%v)", trusted, err)
}
// Pass the target with a trailing slash: normalization must still match the
// stored canonical entry.
var out, errBuf bytes.Buffer
if code := runTrust([]string{"remove", target + "/"}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("trust remove <path> returned %d, want %d; stderr=%q", code, exitSuccess, errBuf.String())
}
trusted, err = workspacetrust.IsTrusted(target)
if err != nil {
t.Fatalf("IsTrusted after named remove: %v", err)
}
if trusted {
t.Fatalf("target should be untrusted after `trust remove <path>/` despite the trailing slash")
}
}
// TestRunTrustUnknownSubcommand proves an unknown subcommand returns exit code 2 and
// writes usage to stderr (not stdout).
func TestRunTrustUnknownSubcommand(t *testing.T) {
setTrustConfigRoot(t)
deps := trustDeps(t.TempDir())
var out, errBuf bytes.Buffer
if code := runTrust([]string{"bogus"}, &out, &errBuf, deps); code != exitUsage {
t.Fatalf("unknown subcommand returned %d, want %d", code, exitUsage)
}
if errBuf.Len() == 0 {
t.Fatalf("unknown subcommand should write usage to stderr, stderr was empty")
}
if !strings.Contains(errBuf.String(), "trust") {
t.Fatalf("stderr usage = %q, want it to mention `trust`", errBuf.String())
}
if out.Len() != 0 {
t.Fatalf("unknown subcommand should not write to stdout, got %q", out.String())
}
}
// TestRunTrustRemoveTooManyArgs proves `trust remove a b` (more than one path) is a
// usage error: exit code 2, usage on stderr, nothing on stdout, and no store change.
func TestRunTrustRemoveTooManyArgs(t *testing.T) {
setTrustConfigRoot(t)
deps := trustDeps(t.TempDir())
var out, errBuf bytes.Buffer
if code := runTrust([]string{"remove", "a", "b"}, &out, &errBuf, deps); code != exitUsage {
t.Fatalf("remove with two args returned %d, want %d", code, exitUsage)
}
if errBuf.Len() == 0 {
t.Fatalf("remove with two args should write usage to stderr, stderr was empty")
}
if out.Len() != 0 {
t.Fatalf("remove with two args should not write to stdout, got %q", out.String())
}
}
// TestRunTrustHelp proves the -h / --help / help subcommands print usage to stdout,
// nothing to stderr, and return success, matching the other CLI help entrypoints
// (unknown subcommands, by contrast, write to stderr with the usage exit code).
func TestRunTrustHelp(t *testing.T) {
setTrustConfigRoot(t)
deps := trustDeps(t.TempDir())
for _, flag := range []string{"-h", "--help", "help"} {
var out, errBuf bytes.Buffer
if code := runTrust([]string{flag}, &out, &errBuf, deps); code != exitSuccess {
t.Fatalf("trust %s returned %d, want %d", flag, code, exitSuccess)
}
if !strings.Contains(out.String(), "Usage") {
t.Fatalf("trust %s stdout = %q, want it to contain usage text", flag, out.String())
}
if errBuf.Len() != 0 {
t.Fatalf("trust %s should not write to stderr, got %q", flag, errBuf.String())
}
}
}
-81
View File
@@ -8,7 +8,6 @@ import (
"strings"
"github.com/Gitlawb/zero/internal/credstore"
"github.com/Gitlawb/zero/internal/providercatalog"
)
// ProviderKeyStoreAt opens the encrypted credential store whose file backend lives
@@ -169,86 +168,6 @@ func (profile ProviderProfile) HasConfiguredCredential() bool {
strings.TrimSpace(profile.AuthHeaderValue) != ""
}
// MissingCredentialEnv reports the environment variable this profile is
// expected to read an API key from, and whether that expectation is
// currently unmet. It is the single source of truth for "does this saved
// provider need a credential it doesn't have" — the CLI (`providers check`,
// `usableSavedProviders`) and TUI (`/providers` status, provider wizard
// summary) surfaces all delegate here so they never disagree about a
// profile's status.
//
// For the catalog's two "bring your own endpoint" descriptors
// (custom-openai-compatible, custom-anthropic-compatible), RequiresAuth is
// just a wizard template default, not a fact about the user's actual
// endpoint: a custom target may need no auth at all. So for those two only,
// a credential is expected exclusively when this profile carries its own
// explicit APIKeyEnv that differs from the catalog's guessed default — a
// value matching that default is indistinguishable from the wizard's old
// auto-stamp bug (issue #555) and is treated the same as unset, which also
// self-heals profiles saved before that bug was fixed.
func (profile ProviderProfile) MissingCredentialEnv() (string, bool) {
if profile.HasConfiguredCredential() {
return "", false
}
if catalogID := strings.TrimSpace(profile.CatalogID); catalogID != "" {
descriptor, err := providercatalog.Require(catalogID)
if err != nil {
return "", false
}
defaultEnvVar := firstNonEmptyTrimmed(descriptor.AuthEnvVars...)
if descriptor.Custom {
envVar := strings.TrimSpace(profile.APIKeyEnv)
if envVar == "" || envVar == defaultEnvVar {
return "", false
}
return envVar, true
}
if !descriptor.RequiresAuth {
return "", false
}
if envVar := strings.TrimSpace(profile.APIKeyEnv); envVar != "" {
return envVar, true
}
return defaultEnvVar, true
}
switch profile.normalizedProviderKind() {
case ProviderKindOpenAI, ProviderKindOpenAICompatible:
return firstNonEmptyTrimmed(profile.APIKeyEnv, "OPENAI_API_KEY"), true
case ProviderKindAnthropic, ProviderKindAnthropicCompat:
return firstNonEmptyTrimmed(profile.APIKeyEnv, "ANTHROPIC_API_KEY"), true
case ProviderKindGoogle:
return firstNonEmptyTrimmed(profile.APIKeyEnv, "GEMINI_API_KEY"), true
default:
if envVar := strings.TrimSpace(profile.APIKeyEnv); envVar != "" {
return envVar, true
}
return "", false
}
}
// normalizedProviderKind resolves this profile's kind, falling back to the
// legacy Provider string field (lowercased) when ProviderKind is unset —
// matching how the resolver (resolver.go) treats the two fields as synonyms.
func (profile ProviderProfile) normalizedProviderKind() ProviderKind {
if kind := strings.TrimSpace(string(profile.ProviderKind)); kind != "" {
return ProviderKind(strings.ToLower(kind))
}
if provider := strings.TrimSpace(profile.Provider); provider != "" {
return ProviderKind(strings.ToLower(provider))
}
return ""
}
func firstNonEmptyTrimmed(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
// OAuthLoginCandidates returns the login names to try, in order, when resolving
// this profile's OAuth token — used by the runtime bearer resolver, the Codex
// account-header resolver, and the onboarding presence check so all three agree.
-79
View File
@@ -238,82 +238,3 @@ func TestOAuthLoginCandidates(t *testing.T) {
})
}
}
func TestProviderProfileMissingCredentialEnv(t *testing.T) {
cases := []struct {
name string
profile ProviderProfile
wantEnv string
want bool
}{
{
name: "catalog provider requiring auth",
profile: ProviderProfile{Name: "groq", CatalogID: "groq"},
wantEnv: "GROQ_API_KEY",
want: true,
},
{
name: "local catalog provider",
profile: ProviderProfile{Name: "local", CatalogID: "ollama"},
want: false,
},
{
name: "credential resolved via inline key",
profile: ProviderProfile{Name: "openai", ProviderKind: ProviderKindOpenAI, APIKey: "sk-test"},
want: false,
},
{
// issue #555: a custom endpoint left with no credential means "no
// auth needed" (e.g. a local llama.cpp server), not "missing".
name: "custom openai compatible with no credential configured",
profile: ProviderProfile{
Name: "local-llama",
CatalogID: "custom-openai-compatible",
BaseURL: "http://192.168.1.50:8080/v1",
},
want: false,
},
{
name: "custom openai compatible with explicit non-default api key env",
profile: ProviderProfile{
Name: "local-llama",
CatalogID: "custom-openai-compatible",
APIKeyEnv: "LLAMA_CPP_API_KEY",
},
wantEnv: "LLAMA_CPP_API_KEY",
want: true,
},
{
// Self-heal: a profile stamped by the pre-fix wizard with the
// catalog's own guessed default is indistinguishable from that bug
// and must not stay filtered out after upgrading.
name: "custom openai compatible with stale legacy default env",
profile: ProviderProfile{
Name: "local-llama",
CatalogID: "custom-openai-compatible",
APIKeyEnv: "OPENAI_API_KEY",
},
want: false,
},
{
name: "openai compatible without catalog falls back to provider kind",
profile: ProviderProfile{Name: "custom", ProviderKind: ProviderKindOpenAICompatible},
wantEnv: "OPENAI_API_KEY",
want: true,
},
{
name: "legacy Provider string field fallback",
profile: ProviderProfile{Name: "legacy", Provider: "anthropic"},
wantEnv: "ANTHROPIC_API_KEY",
want: true,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
gotEnv, got := c.profile.MissingCredentialEnv()
if got != c.want || gotEnv != c.wantEnv {
t.Fatalf("MissingCredentialEnv() = (%q, %v), want (%q, %v)", gotEnv, got, c.wantEnv, c.want)
}
})
}
}
+4 -4
View File
@@ -21,7 +21,7 @@ func TestResolveLocalControlFromUserConfig(t *testing.T) {
t.Fatalf("write config: %v", err)
}
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
resolved, err := Resolve(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
@@ -48,7 +48,7 @@ func TestResolveLocalControlIgnoresProjectOptIn(t *testing.T) {
t.Fatalf("write project config: %v", err)
}
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: projectPath, Env: map[string]string{}})
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: projectPath})
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
@@ -61,7 +61,7 @@ func TestResolveLocalControlIgnoresProjectOptIn(t *testing.T) {
}
func TestLocalControlDefaultsBrowserAndTerminalOn(t *testing.T) {
resolved, err := Resolve(ResolveOptions{Env: map[string]string{}})
resolved, err := Resolve(ResolveOptions{})
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
@@ -81,7 +81,7 @@ func TestLocalControlExplicitGlobalDisable(t *testing.T) {
if err := os.WriteFile(path, []byte(`{"localControl":{"enabled":false}}`), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
resolved, err := Resolve(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
+1 -30
View File
@@ -1,9 +1,6 @@
package config
import (
"reflect"
"strings"
)
import "strings"
// DefaultMCPServers returns the MCP servers Zero ships ENABLED by default so web
// search and scraping work out of the box with no setup and no API key. They are
@@ -32,29 +29,3 @@ func IsDefaultMCPServer(name string) bool {
_, ok := DefaultMCPServers()[strings.TrimSpace(name)]
return ok
}
// IsUnconfiguredDefault reports whether server is one of Zero's built-in
// defaults that the user never wrote an entry for in their config — i.e. it is
// running with whatever Zero ships (e.g. keyless Firecrawl, no credentials).
//
// Both conditions below must hold:
// - !server.configured: the user's JSON never declared an object for this
// server key at all (set by MCPServerConfig.UnmarshalJSON only when it
// actually ran for this key). Any explicit action — including a
// disable/enable toggle like `zero mcp enable firecrawl` that leaves the
// resolved value unchanged — sets configured, so it always counts as
// user-configured, even though the value comparison below could not tell
// the difference on its own.
// - reflect.DeepEqual(def, server): the value still matches the default.
// This is the fallback for callers that construct MCPServerConfig
// directly rather than through the JSON/merge pipeline (server.configured
// is then always false) — without it, any hand-built config with
// different field values would be misreported as unconfigured.
//
// Callers use this to tell "server we turned on for the user" apart from
// "server the user configured themselves," e.g. to avoid warning loudly when
// an out-of-the-box default that was never given credentials fails to connect.
func IsUnconfiguredDefault(name string, server MCPServerConfig) bool {
def, ok := DefaultMCPServers()[strings.TrimSpace(name)]
return ok && !server.configured && reflect.DeepEqual(def, server)
}
-63
View File
@@ -49,69 +49,6 @@ func TestResolveMCPUserCanDisableDefault(t *testing.T) {
}
}
func TestIsUnconfiguredDefault(t *testing.T) {
if !IsUnconfiguredDefault("firecrawl", DefaultMCPServers()["firecrawl"]) {
t.Fatal("an untouched firecrawl default should be reported as unconfigured")
}
if IsUnconfiguredDefault("firecrawl", MCPServerConfig{Type: "http", URL: "http://localhost:3002/mcp"}) {
t.Fatal("a server overriding the default URL is no longer unconfigured")
}
if IsUnconfiguredDefault("firecrawl", MCPServerConfig{Type: "http", URL: "https://mcp.firecrawl.dev/v2/mcp", Auth: "bearer"}) {
t.Fatal("a server with credentials added is no longer unconfigured")
}
if IsUnconfiguredDefault("not-a-default", MCPServerConfig{}) {
t.Fatal("a server with no matching default can never be unconfigured-default")
}
}
func TestResolveMCPExplicitReenableIsNotUnconfiguredDefault(t *testing.T) {
// `zero mcp enable firecrawl` after a prior disable writes {"disabled":false}
// explicitly. The resolved value is identical to the untouched default (both
// enabled, no credentials), but the user DID take an explicit action here, so
// IsUnconfiguredDefault must not treat it as untouched (issue #563 review).
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":false}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
firecrawl := cfg.Servers["firecrawl"]
if firecrawl.Disabled {
t.Fatalf("explicit re-enable should leave the server enabled: %#v", firecrawl)
}
if IsUnconfiguredDefault("firecrawl", firecrawl) {
t.Fatal("an explicit enable/disable toggle must count as user-configured, even though the resolved value matches the default")
}
}
func TestResolveMCPExplicitRedeclareOfDefaultValuesIsNotUnconfiguredDefault(t *testing.T) {
// A user who copies firecrawl's exact default type/url into their config
// (e.g. from an example file, planning to add credentials later) produces a
// resolved value byte-identical to DefaultMCPServers()["firecrawl"] — the
// same trap TestResolveMCPExplicitReenableIsNotUnconfiguredDefault covers for
// the disabled toggle. IsUnconfiguredDefault must still treat this as
// user-configured because the user's JSON declared an entry for it, even
// though a plain resolved-value comparison could not tell the difference.
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"type":"http","url":"https://mcp.firecrawl.dev/v2/mcp"}}}}`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
if err != nil {
t.Fatalf("ResolveMCP: %v", err)
}
firecrawl := cfg.Servers["firecrawl"]
want := DefaultMCPServers()["firecrawl"]
if firecrawl.Type != want.Type || firecrawl.URL != want.URL || firecrawl.Disabled != want.Disabled {
t.Fatalf("expected the resolved value to match the default's fields exactly: %#v", firecrawl)
}
if IsUnconfiguredDefault("firecrawl", firecrawl) {
t.Fatal("redeclaring the default's exact values is still an explicit user configuration, not an untouched default")
}
}
func TestResolveMCPUserCanOverrideDefaultURLKeepingOtherFields(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
// Point firecrawl at a self-hosted instance; the default's Type must survive.
-168
View File
@@ -1,168 +0,0 @@
package config
import (
"fmt"
"slices"
"strings"
)
func mergeMCPConfig(dst *MCPConfig, src MCPConfig) {
if len(src.Servers) == 0 {
return
}
if dst.Servers == nil {
dst.Servers = map[string]MCPServerConfig{}
}
for name, server := range src.Servers {
dst.Servers[name] = mergeMCPServer(dst.Servers[name], server)
}
}
func mergeProjectMCPConfig(dst *MCPConfig, src MCPConfig) error {
if len(src.Servers) == 0 {
return nil
}
if dst.Servers == nil {
dst.Servers = map[string]MCPServerConfig{}
}
for name, server := range src.Servers {
base := dst.Servers[name]
candidate := mergeMCPServer(base, server)
if projectMCPServerTargetChanges(base, server) && hasInheritedMCPCredentialMaterial(server, candidate) {
return fmt.Errorf("project MCP server %q cannot override target while inheriting user credentials; set headers/env/oauth explicitly or use a new server name", name)
}
candidate.ProjectConfigured = true
dst.Servers[name] = candidate
}
return nil
}
func mergeMCPServer(base MCPServerConfig, next MCPServerConfig) MCPServerConfig {
if strings.TrimSpace(next.Type) != "" {
base.Type = next.Type
}
if strings.TrimSpace(next.Command) != "" {
base.Command = next.Command
}
if next.Args != nil {
base.Args = append([]string{}, next.Args...)
}
if next.Env != nil {
base.Env = copyMCPStringMap(next.Env)
}
if strings.TrimSpace(next.URL) != "" {
base.URL = next.URL
}
if next.Headers != nil {
base.Headers = copyMCPStringMap(next.Headers)
}
if strings.TrimSpace(next.Auth) != "" {
base.Auth = next.Auth
}
if next.OAuth != nil {
base.OAuth = next.OAuth
}
if next.disabledSet || next.Disabled {
base.Disabled = next.Disabled
}
if next.configured {
base.configured = true
}
if next.ProjectConfigured {
base.ProjectConfigured = true
}
return base
}
func projectMCPServerTargetChanges(base MCPServerConfig, next MCPServerConfig) bool {
if strings.TrimSpace(next.Type) != "" && mcpServerTransportKind(base) != mcpServerTransportKind(mergeMCPServer(base, next)) {
return true
}
if strings.TrimSpace(next.URL) != "" && strings.TrimSpace(next.URL) != strings.TrimSpace(base.URL) {
return true
}
if strings.TrimSpace(next.Command) != "" && strings.TrimSpace(next.Command) != strings.TrimSpace(base.Command) {
return true
}
if next.Args != nil && !slices.Equal(trimMCPArgs(next.Args), trimMCPArgs(base.Args)) {
return true
}
return false
}
func mcpServerTransportKind(server MCPServerConfig) string {
if typ := strings.TrimSpace(strings.ToLower(server.Type)); typ != "" {
return typ
}
if strings.TrimSpace(server.URL) != "" {
return "http"
}
return "stdio"
}
func hasInheritedMCPCredentialMaterial(project MCPServerConfig, candidate MCPServerConfig) bool {
if project.Headers == nil && hasMCPStringMapMaterial(candidate.Headers) {
return true
}
if project.Env == nil && hasMCPStringMapMaterial(candidate.Env) {
return true
}
if project.OAuth == nil && hasMCPOAuthMaterial(candidate.OAuth) {
return true
}
return false
}
func hasMCPStringMapMaterial(values map[string]string) bool {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return true
}
}
return false
}
func hasMCPOAuthMaterial(oauth *MCPOAuthConfig) bool {
if oauth == nil {
return false
}
if strings.TrimSpace(oauth.ClientID) != "" ||
strings.TrimSpace(oauth.ClientSecret) != "" ||
strings.TrimSpace(oauth.AuthorizationEndpoint) != "" ||
strings.TrimSpace(oauth.TokenEndpoint) != "" ||
strings.TrimSpace(oauth.RegistrationEndpoint) != "" ||
strings.TrimSpace(oauth.IssuerURL) != "" {
return true
}
for _, scope := range oauth.Scopes {
if strings.TrimSpace(scope) != "" {
return true
}
}
return false
}
func trimMCPArgs(values []string) []string {
if values == nil {
return nil
}
trimmed := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
trimmed = append(trimmed, value)
}
}
return trimmed
}
func copyMCPStringMap(values map[string]string) map[string]string {
if values == nil {
return nil
}
copied := make(map[string]string, len(values))
for key, value := range values {
copied[key] = value
}
return copied
}
Loaded 100 of 370 files, more files were not shown because too many files have changed in this diff. Show more