mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e98c17dad | ||
|
|
3db4aa90f4 | ||
|
|
d0ee963f76 | ||
|
|
2a8802e39c | ||
|
|
d53052071a | ||
|
|
1c65f59be6 | ||
|
|
7fb0092de7 | ||
|
|
5aced721b3 | ||
|
|
02a3d12f34 | ||
|
|
a0548d707b | ||
|
|
83a371d669 | ||
|
|
84823d7be1 | ||
|
|
cfd065b565 | ||
|
|
fb3a0bff44 | ||
|
|
072f3877b5 | ||
|
|
1dd884d6e9 | ||
|
|
726f1512a3 | ||
|
|
8c5898d5db | ||
|
|
7a09a42a17 | ||
|
|
c0a0389b09 | ||
|
|
d99ac4d1e3 | ||
|
|
aa45edacc0 | ||
|
|
ca5e0b9bbb | ||
|
|
653cee60c1 | ||
|
|
8aae0be6db | ||
|
|
8ea8831c30 | ||
|
|
a4b2e01fe6 | ||
|
|
fb3277f46e | ||
|
|
95aaca6055 | ||
|
|
1d1fa7c514 | ||
|
|
b8b0bb7f65 | ||
|
|
f8ac5eceb0 | ||
|
|
d29ab98a79 | ||
|
|
80ab238361 | ||
|
|
98bd1acbe6 | ||
|
|
2b6568f82b | ||
|
|
f1502f0bfe | ||
|
|
0e7318ff72 | ||
|
|
ca41ac2b6c | ||
|
|
f9a26e7148 | ||
|
|
01fed03a59 | ||
|
|
46c44a8753 | ||
|
|
1c94168245 | ||
|
|
644f61fa58 | ||
|
|
11926cc5d2 | ||
|
|
258f1a31c9 | ||
|
|
4a5a417156 | ||
|
|
8acc1be7b2 | ||
|
|
0b1fdef98e | ||
|
|
bc5c24ef87 | ||
|
|
215170b967 | ||
|
|
212f1a0ff8 | ||
|
|
6be50cd3a7 | ||
|
|
c498a8b870 | ||
|
|
b776a9281f | ||
|
|
63c0e278ff | ||
|
|
a2e0f3c1fe |
No files matched your search
@@ -20,10 +20,15 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# `zero-release package` builds + smoke-tests natively (host==target), so each
|
||||
# target platform needs its own native runner. These five cover the platforms
|
||||
# install.sh / install.ps1 / the npm wrapper resolve.
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- macos-latest
|
||||
- windows-latest
|
||||
- ubuntu-latest # linux-x64
|
||||
- ubuntu-24.04-arm # linux-arm64
|
||||
- macos-latest # macos-arm64 (Apple Silicon)
|
||||
- macos-15-intel # macos-x64 (Intel)
|
||||
- windows-latest # windows-x64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -6,7 +6,7 @@ alwaysApply: false
|
||||
|
||||
# Extending Zero
|
||||
|
||||
Zero is an open-source terminal coding agent in the same family as Claude Code, Codex, and Droid. 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.
|
||||
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:
|
||||
|
||||
@@ -315,6 +315,5 @@ That's it. Run `zero` from the repo root and the agent has the team's full instr
|
||||
|
||||
- [README](README.md) — install, quickstart, command reference.
|
||||
- [docs/SPECIALISTS.md](docs/SPECIALISTS.md) — full specialist manifest spec.
|
||||
- [docs/PRD.md](docs/PRD.md) — product requirements, full feature surface.
|
||||
- [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.
|
||||
+9
-3
@@ -11,7 +11,14 @@ tagged. Until then, source builds report the version `dev`.
|
||||
### Added
|
||||
- `SECURITY.md` with a private vulnerability-reporting path, `CODE_OF_CONDUCT.md`, this changelog, and
|
||||
GitHub issue/PR templates.
|
||||
- `--theme {auto|dark|light}` flag for the TUI (previously only the `ZERO_THEME` env var existed).
|
||||
- Interactive `/theme` picker: bare `/theme` opens a popup that live-previews each palette as you move
|
||||
and applies on select (Esc reverts).
|
||||
- Ten built-in color themes alongside the `dark`/`light` built-ins — `dracula`, `nord`, `gruvbox`,
|
||||
`tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, and
|
||||
`solarized-light` — selectable via `/theme <name>`, `--theme <name>`, or `ZERO_THEME`. Every palette
|
||||
is contrast-audited to WCAG AA. The built-in light theme was reworked for legibility.
|
||||
- `--theme <name>` flag for the TUI, accepting `auto` or any registered theme (previously only the
|
||||
`ZERO_THEME` env var existed).
|
||||
- "Accessibility / Appearance" section in the README documenting `NO_COLOR`, `ZERO_THEME`, `/theme`,
|
||||
and `ZERO_NO_FADE`.
|
||||
|
||||
@@ -33,5 +40,4 @@ tagged. Until then, source builds report the version `dev`.
|
||||
- The inert `/input-style` slash command (it had no backend).
|
||||
|
||||
### Fixed
|
||||
- README/`go.mod` Go-version mismatch and other stale docs claims surfaced by the product/UX audit
|
||||
(`docs/audit/2026-06-21-product-ux-audit.md`).
|
||||
- README/`go.mod` Go-version mismatch and other stale public-release docs claims.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Gitlawb
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,363 +1,301 @@
|
||||
<div align="center">
|
||||
<p align="center">
|
||||
<img src="docs/assets/zero-logo.png" alt="Zero" width="385">
|
||||
</p>
|
||||
|
||||
```
|
||||
███████╗ ███████╗ ██████╗ ██████╗
|
||||
╚══███╔╝ ██╔════╝ ██╔══██╗ ██╔═══██╗
|
||||
███╔╝ █████╗ ██████╔╝ ██║ ██║
|
||||
███╔╝ ██╔══╝ ██╔══██╗ ██║ ██║
|
||||
███████╗ ███████╗ ██║ ██║ ╚██████╔╝
|
||||
╚══════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═════╝
|
||||
```
|
||||
<p align="center"><strong>A terminal coding agent you own.</strong></p>
|
||||
|
||||
### The terminal coding agent you fully own.
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||
<img alt="Go 1.25+" src="https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
</p>
|
||||
|
||||
**Any model. Any provider. Your rules.**
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
Zero is an AI coding agent that lives in your terminal. It runs a full agentic loop — reading, searching, editing, and executing in your repo — against **whatever model you choose**: frontier APIs, fast cloud inference, or a local model on your own machine. One interface, no vendor lock-in, no telemetry phoning home.
|
||||
Zero is an AI coding agent for your local terminal. It can inspect a repository,
|
||||
edit files, run commands, use browser/terminal helpers, and keep durable local
|
||||
sessions while you choose the model and the permission level.
|
||||
|
||||
```bash
|
||||
zero # interactive TUI
|
||||
zero exec "fix the failing test in ./pkg" # headless one-shot
|
||||
zero exec -o stream-json < turns.jsonl # programmatic, for scripts & CI
|
||||
zero
|
||||
zero exec "fix the failing test in ./pkg"
|
||||
zero exec --output-format stream-json < turns.jsonl
|
||||
```
|
||||
|
||||
> Zero treats the **model as a swappable, per-task choice** and **never mutates your system without a permission decision**.
|
||||
|
||||
## Why Zero
|
||||
|
||||
- 🔌 **25+ providers, one interface** — OpenAI, Anthropic, Gemini, Ollama (local & cloud), LM Studio, OpenRouter, Groq, DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, and any OpenAI- or Anthropic-compatible endpoint. Switch mid-session with `/model`.
|
||||
- 🖥️ **A TUI that feels premium** — truecolor Bubble Tea interface with a first-run setup wizard, searchable live model picker, scrollback, themes, image input, and slash commands for everything.
|
||||
- 🤖 **Headless & scriptable** — `zero exec` with `text` / `json` / `stream-json` I/O, session resume & fork, isolated `--worktree` runs, and meaningful exit codes. Built for CI.
|
||||
- 🧠 **Subagents** — delegate to built-in `worker`, `explorer`, and `code-review` specialists (or generate your own) that run as real background tasks, even out-of-process.
|
||||
- 📋 **Spec mode** — have the agent draft a spec first, review and approve it, *then* let it build. No more runaway sessions.
|
||||
- 📈 **Mid-run model escalation** — start cheap, and let the agent request a stronger model only when it hits a wall (`--allow-escalation`).
|
||||
- 🗺️ **Repo intelligence** — deterministic repo maps, workspace indexing, and context-budget reports keep the agent grounded in *your* codebase, not hallucinations.
|
||||
- ⏰ **Scheduled agents** — `zero cron` runs file-backed, dependency-free agent jobs on a schedule.
|
||||
- 🛡️ **Safe by default** — permission-gated mutations, autonomy ceilings, sandbox policy (writes stay inside the workspace unless you grant extra directories with `--add-dir` / `/add-dir`), and secret redaction everywhere. Unsafe mode is an explicit, loudly-labeled opt-in.
|
||||
- 💾 **Durable sessions** — append-only local event store with full-text search, resume, fork, and rewind. Your history never leaves your disk.
|
||||
- 🧩 **Extensible** — skills, plugins, hooks, and MCP (Zero is both an MCP client *and* an MCP server).
|
||||
- **Use the model you want.** Bring OpenAI, Anthropic, Gemini, Groq, OpenRouter,
|
||||
DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, Ollama, LM Studio, or any
|
||||
OpenAI-/Anthropic-compatible endpoint.
|
||||
- **Stay in control.** File writes, shell commands, network access, and
|
||||
out-of-workspace writes go through Zero's permission and sandbox policy.
|
||||
- **Works in the terminal.** The TUI has model/provider pickers, image input,
|
||||
slash commands, live plan/tool rendering, scrollback, themes, and resume/fork
|
||||
support.
|
||||
- **Works without the TUI.** `zero exec` is scriptable, supports text/JSON/
|
||||
stream-JSON I/O, isolated worktrees, spec-first runs, and meaningful exit
|
||||
codes for CI.
|
||||
- **Keeps context local.** Sessions are stored on disk, searchable, resumable,
|
||||
and never uploaded as telemetry by Zero.
|
||||
- **Extensible when you need it.** Use MCP servers, skills, plugins, hooks, and
|
||||
specialist subagents from the same CLI.
|
||||
|
||||
## Quick start
|
||||
## Install
|
||||
|
||||
```bash
|
||||
# run from source (requires Go 1.25+)
|
||||
go run ./cmd/zero
|
||||
```
|
||||
|
||||
Or, once the first release is published, install the prebuilt binary via npm — the wrapper
|
||||
downloads the right binary for your platform:
|
||||
### npm
|
||||
|
||||
```bash
|
||||
npm install -g @gitlawb/zero
|
||||
zero
|
||||
```
|
||||
|
||||
> **Pre-built binaries are coming soon.** The npm package and the `scripts/install.sh`
|
||||
> (Linux/macOS) / `scripts/install.ps1` (Windows) installers all download GitHub Release
|
||||
> assets, which require the first release to be published. Until then, build from source with
|
||||
> `go run ./cmd/zero` (or `go build -o zero ./cmd/zero`).
|
||||
The npm package installs a small wrapper plus the matching Zero binary for your
|
||||
platform from GitHub Releases. It supports Linux, macOS, and Windows on x64 and
|
||||
arm64.
|
||||
|
||||
First launch opens a **guided setup wizard** — pick a provider, paste a key, choose a model, done. Or do it non-interactively:
|
||||
### Install scripts
|
||||
|
||||
Linux/macOS:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=sk-... # or ANTHROPIC_API_KEY, GEMINI_API_KEY, GROQ_API_KEY, ...
|
||||
zero setup # guided first-run provider setup
|
||||
zero doctor # verify config, keys, and connectivity
|
||||
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
Local models need no key at all:
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
### From source
|
||||
|
||||
Source builds require Go 1.25+.
|
||||
|
||||
```bash
|
||||
# Ollama or LM Studio running locally? Zero finds them.
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
cd zero
|
||||
go run ./cmd/zero
|
||||
```
|
||||
|
||||
Release installers and the npm wrapper require published GitHub Release assets.
|
||||
If you are testing before the first public release, build from source:
|
||||
|
||||
```bash
|
||||
go build -o zero ./cmd/zero
|
||||
```
|
||||
|
||||
On Linux, build the sandbox helper too if you want native sandboxing:
|
||||
|
||||
```bash
|
||||
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
|
||||
go build -o zero-seccomp ./cmd/zero-seccomp # optional compatibility wrapper
|
||||
```
|
||||
|
||||
Put `zero` and `zero-linux-sandbox` in the same directory on `PATH`
|
||||
(`~/.local/bin` is a good default). macOS does not need an extra helper binary.
|
||||
Windows source builds can use the main `zero.exe` as their sandbox helper; release
|
||||
archives still ship standalone Windows helper executables.
|
||||
|
||||
More install details: [docs/INSTALL.md](docs/INSTALL.md).
|
||||
|
||||
## First Run
|
||||
|
||||
Start the TUI:
|
||||
|
||||
```bash
|
||||
zero
|
||||
```
|
||||
|
||||
The setup wizard helps you pick a provider and model. You can also configure
|
||||
providers from the command line:
|
||||
|
||||
```bash
|
||||
zero setup
|
||||
zero providers list
|
||||
zero models list
|
||||
zero doctor
|
||||
```
|
||||
|
||||
## The TUI
|
||||
|
||||
Type to chat, **Enter** to send. `/` opens command suggestions, **Shift+Tab** cycles permission modes, **Ctrl+C** exits.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `/model` `/provider` | switch model or provider mid-session (searchable picker) |
|
||||
| `/spec` `/plan` | spec-mode drafting and live plan view |
|
||||
| `/image` | attach images for vision models |
|
||||
| `/resume` `/rewind` | time-travel across sessions |
|
||||
| `/compact` `/context` | manage the context window |
|
||||
| `/permissions` `/tools` | inspect what the agent can touch |
|
||||
| `/add-dir` | grant an extra write directory for the session, or list current write roots |
|
||||
| `/theme` `/style` | make it yours |
|
||||
| `/doctor` `/config` | health and config without leaving the chat |
|
||||
|
||||
Turn-completion notifications (terminal bell / OSC-9) ping you when the agent finishes or needs input — go make coffee.
|
||||
|
||||
## Accessibility & appearance
|
||||
|
||||
Zero ships sensible defaults and a few environment/command controls. Meaning never depends on color
|
||||
alone — diffs carry `+`/`−` signs, permission outcomes use `✓`/`✗` glyphs plus a text `PERMISSION`
|
||||
badge, and permission modes show text labels — so the UI stays readable when color is stripped.
|
||||
|
||||
| Control | What it does |
|
||||
|---|---|
|
||||
| `NO_COLOR` (any non-empty value) | Disables color, per [no-color.org](https://no-color.org). Zero honors **any** non-empty value (`NO_COLOR=1`, `yes`, `true`, …); bold/underline still render. |
|
||||
| `ZERO_THEME=auto\|dark\|light` | Selects the palette at startup. `auto` (default) detects the terminal background. |
|
||||
| `--theme auto\|dark\|light` | Same as `ZERO_THEME`, as a launch flag (takes precedence over the env var). |
|
||||
| `/theme [auto\|dark\|light]` | Switches the palette live inside the TUI. |
|
||||
| `ZERO_NO_FADE=1` | Reduce-motion opt-out: disables the streaming-text fade animation. Fade also auto-disables over SSH/tmux and on low-color/no-TTY terminals. |
|
||||
|
||||
## Headless `exec`
|
||||
For API providers, set the matching environment variable before setup or enter
|
||||
the key in the wizard:
|
||||
|
||||
```bash
|
||||
# one-shot
|
||||
zero exec "explain internal/agent/loop.go and suggest one improvement"
|
||||
export OPENAI_API_KEY=sk-...
|
||||
export ANTHROPIC_API_KEY=...
|
||||
export GEMINI_API_KEY=...
|
||||
```
|
||||
|
||||
# pick a model and mode preset per task
|
||||
zero exec --model claude-sonnet-4.5 --mode deep "refactor the session store"
|
||||
For local models, run Ollama or LM Studio and then use `zero setup` or
|
||||
`zero providers detect`.
|
||||
|
||||
# spec-first: draft → review → approve → build
|
||||
## Daily Use
|
||||
|
||||
### Interactive TUI
|
||||
|
||||
```bash
|
||||
zero
|
||||
```
|
||||
|
||||
Useful controls:
|
||||
|
||||
| Control | Action |
|
||||
|---|---|
|
||||
| `Enter` | send the prompt |
|
||||
| `/` | open slash-command suggestions |
|
||||
| `Shift+Tab` | cycle permission mode |
|
||||
| `Ctrl+B` | show/hide the sidebar |
|
||||
| `Ctrl+C` | cancel or exit |
|
||||
|
||||
Common slash commands:
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `/model`, `/provider` | switch the active model/provider |
|
||||
| `/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 |
|
||||
| `/compact`, `/context` | manage context usage |
|
||||
| `/permissions`, `/tools` | inspect available tools and policy |
|
||||
| `/add-dir` | allow an extra write directory for this session |
|
||||
| `/theme`, `/doctor`, `/config` | adjust appearance and inspect setup |
|
||||
|
||||
### Headless `exec`
|
||||
|
||||
```bash
|
||||
zero exec "explain internal/agent/loop.go"
|
||||
zero exec --model claude-sonnet-4.5 "refactor the config loader"
|
||||
zero exec --use-spec "add rate limiting to the API client"
|
||||
zero exec --worktree "try the migration in an isolated worktree"
|
||||
zero exec --resume
|
||||
zero exec --fork <session-id> "try the other approach"
|
||||
```
|
||||
|
||||
# run in an isolated git worktree, escalate model only if needed
|
||||
zero exec -w --allow-escalation "migrate the config loader to v2"
|
||||
Programmatic use:
|
||||
|
||||
# multi-turn programmatic I/O over stdio
|
||||
```bash
|
||||
zero exec --input-format stream-json --output-format stream-json < turns.jsonl
|
||||
|
||||
# resume or fork any previous session
|
||||
zero exec --resume # latest
|
||||
zero exec --fork <session-id> "now try the other approach"
|
||||
```
|
||||
|
||||
Key flags: `-m/--model` · `--mode <smart|deep|fast|large|precise>` · `--image` · `--use-spec` · `--auto <low|medium|high>` · `--enabled-tools/--disabled-tools` · `-w/--worktree` · `--add-dir <path>` (repeatable) · `--resume/--fork` · `--allow-escalation` · `--notify` · `-o <text|json|stream-json>`.
|
||||
The stream-JSON contract is documented in
|
||||
[docs/STREAM_JSON_PROTOCOL.md](docs/STREAM_JSON_PROTOCOL.md).
|
||||
|
||||
stdout carries **only** program output; logs go to stderr. Full contract in [`docs/STREAM_JSON_PROTOCOL.md`](docs/STREAM_JSON_PROTOCOL.md).
|
||||
## Safety Model
|
||||
|
||||
## Commands
|
||||
Zero is designed to make side effects visible.
|
||||
|
||||
- Workspace reads are allowed by default.
|
||||
- File writes are limited to the workspace unless you grant another directory.
|
||||
- Shell commands, network access, destructive commands, and elevated actions are
|
||||
permission-gated.
|
||||
- `--add-dir <path>` and `/add-dir <path>` grant additional write roots without
|
||||
giving the agent the whole filesystem.
|
||||
- Unsafe/autonomous modes are explicit opt-ins.
|
||||
- Secrets are redacted from tool output and logs where Zero controls the surface.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
zero --add-dir ../docs-site
|
||||
zero exec --add-dir ../shared "update both repos"
|
||||
```
|
||||
|
||||
Sandbox behavior can be inspected with:
|
||||
|
||||
```bash
|
||||
zero sandbox policy
|
||||
zero sandbox grants list
|
||||
```
|
||||
|
||||
## Web And Local Control
|
||||
|
||||
Zero includes local file/search/edit/shell tools, `web_fetch` for public URLs,
|
||||
and MCP support for additional tools.
|
||||
|
||||
For local dev servers, use shell commands such as `curl` through `exec_command`
|
||||
so the normal sandbox and permission policy applies. Long-running commands stay
|
||||
attached to a background terminal session and can be listed or stopped from the
|
||||
TUI.
|
||||
|
||||
The npm package also includes browser and terminal helper packages used by local
|
||||
browser/terminal tools. Source builds can use the same helpers when they are on
|
||||
`PATH` or configured in Zero's local-control settings.
|
||||
|
||||
## Common Commands
|
||||
|
||||
```text
|
||||
zero interactive TUI
|
||||
zero exec one-shot / scripted agent runs
|
||||
zero setup guided first-run provider setup
|
||||
zero models model registry (capabilities, context, cost)
|
||||
zero providers provider profiles + 25-provider catalog
|
||||
zero doctor config, key, and connectivity health checks
|
||||
zero context workspace context-budget report
|
||||
zero repo-map deterministic repository map for agent context
|
||||
zero repo-info local (network-free) repository characterizer
|
||||
zero search | find full-text search over local session history
|
||||
zero sessions session lineage inspection
|
||||
zero spec review & approve saved spec-mode drafts
|
||||
zero specialist manage subagent profiles
|
||||
zero skills markdown instruction skills
|
||||
zero plugins plugin manifests
|
||||
zero hooks lifecycle hook configuration
|
||||
zero mcp MCP client settings
|
||||
zero serve --mcp expose Zero's tools over MCP stdio
|
||||
zero sandbox sandbox policy & persistent grants
|
||||
zero worktrees isolated git worktrees for agent runs
|
||||
zero verify detect & run local verification checks
|
||||
zero changes inspect & commit local git changes
|
||||
zero exec one-shot or scripted agent run
|
||||
zero setup first-run provider setup
|
||||
zero auth OAuth/login helpers for supported providers
|
||||
zero models model registry and capabilities
|
||||
zero providers provider profiles and detection
|
||||
zero doctor setup, key, and connectivity checks
|
||||
zero context context-budget report
|
||||
zero repo-map deterministic repository map
|
||||
zero repo-info local repository summary
|
||||
zero search | find search local session history
|
||||
zero sessions inspect, resume, fork, and rewind sessions
|
||||
zero spec manage spec-mode drafts
|
||||
zero specialist manage specialist subagents
|
||||
zero skills manage markdown instruction skills
|
||||
zero plugins manage plugins
|
||||
zero hooks manage lifecycle hooks
|
||||
zero mcp manage MCP servers and tools
|
||||
zero serve --mcp expose Zero tools over MCP stdio
|
||||
zero sandbox inspect sandbox policy and grants
|
||||
zero worktrees prepare isolated git worktrees
|
||||
zero verify detect and run local verification checks
|
||||
zero changes inspect and commit local git changes
|
||||
zero usage token usage and estimated cost
|
||||
zero cron scheduled agent jobs (file-backed, dep-free)
|
||||
zero cron scheduled agent jobs
|
||||
zero update check for newer releases
|
||||
```
|
||||
|
||||
## Providers
|
||||
## Appearance And Accessibility
|
||||
|
||||
Bring your own key — or no key at all for local runtimes.
|
||||
|
||||
| Tier | Providers |
|
||||
| Control | Effect |
|
||||
|---|---|
|
||||
| **Frontier APIs** | OpenAI · Anthropic · Google Gemini |
|
||||
| **Fast cloud inference** | Groq · OpenRouter · Together AI · DeepSeek · Mistral · xAI · NVIDIA NIM |
|
||||
| **Local — no key, no cloud** | Ollama · LM Studio |
|
||||
| **More clouds** | Ollama Cloud · DashScope (Qwen) · Moonshot (Kimi) · MiniMax · Z.ai · Venice · GitHub Models · and more |
|
||||
| **Enterprise (catalog)** | Amazon Bedrock · Vertex AI *(adapters in progress)* |
|
||||
| **Anything else** | any OpenAI-compatible or Anthropic-compatible endpoint |
|
||||
| `NO_COLOR=<anything>` | disables color output |
|
||||
| `ZERO_THEME=<name>` | selects the startup theme (`auto`, `dark`, `light`, or a color theme like `dracula`, `nord`, `gruvbox`, `tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, `solarized-light`) |
|
||||
| `--theme <name>` | selects the TUI theme from the CLI (same names) |
|
||||
| `/theme` | opens the theme picker inside the TUI (live preview; `/theme <name>` switches directly) |
|
||||
| `ZERO_NO_FADE=1` | disables streaming fade animation |
|
||||
|
||||
The model registry tracks each model's capabilities, context window, and cost — and the live model picker discovers what your provider actually serves.
|
||||
|
||||
**Slow or stalling streams.** Every provider aborts a stream that goes completely silent (no data and no keep-alive) for `ZERO_STREAM_IDLE_TIMEOUT` (default **5 minutes**) so a hung connection can't block the agent forever. SSE keep-alives reset the timer, so a heartbeating-but-slow backend is never killed. If a slow cloud or reasoning model still trips it, raise the limit — `ZERO_STREAM_IDLE_TIMEOUT=10m` (accepts a Go duration or bare seconds; `0`/`off` disables the watchdog).
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Purpose | Side effect |
|
||||
|---|---|---|
|
||||
| `read_file` · `list_directory` · `grep` · `glob` | explore & search | read |
|
||||
| `web_fetch` | fetch docs & references | network |
|
||||
| `update_plan` · `ask_user` | plan & clarify | none |
|
||||
| `write_file` · `edit_file` · `apply_patch` | create & modify | write (gated) |
|
||||
| `exec_command` · `write_stdin` | run shell commands, keep long-running processes alive, poll/send input | shell (gated on start/input) |
|
||||
| `bash` | run one-shot legacy shell commands | shell (gated) |
|
||||
| `Task` · `TaskOutput` · `TaskStop` | delegate to specialist subagents | per-tool gating |
|
||||
| `GenerateSpecialist` | create new subagent manifests | write (gated) |
|
||||
| `skill` | load markdown instruction skills | read |
|
||||
| `tool_search` | lazily load deferred tools (large MCP sets stay cheap) | none |
|
||||
| `escalate_model` | request a stronger model mid-run | gated by `--allow-escalation` |
|
||||
|
||||
Every mutating tool routes through the permission policy **before** any side effect.
|
||||
|
||||
### Web search & scraping (free, no API key)
|
||||
|
||||
`web_fetch` is built in and always available: it pulls a single public remote URL
|
||||
into clean markdown locally, with no third party. For localhost/private dev-server
|
||||
URLs, use shell commands such as `curl` through `exec_command` so sandbox network
|
||||
permission applies. For local dev servers, start the server in the foreground with
|
||||
`exec_command` and keep the returned `session_id`; use `write_stdin` to poll or
|
||||
interrupt it. Use `/ps` to list running background terminals and `/stop` or
|
||||
`/stop <session_id>` to close them. Interactive terminal-style commands can set
|
||||
`tty: true` on `exec_command` so stdin works like a terminal session.
|
||||
|
||||
For **search**, JS-rendered scraping,
|
||||
whole-site crawls, PDF→markdown, and structured extraction, Zero ships the
|
||||
[Firecrawl](https://firecrawl.dev) **keyless** MCP server **enabled by default**, so
|
||||
web search and scraping work out of the box with **no setup and no API key** (1,000
|
||||
free credits/month). It is a normal MCP server, so you stay in control:
|
||||
|
||||
```bash
|
||||
zero mcp tools list # see the firecrawl_* tools it exposes
|
||||
zero mcp disable firecrawl # turn it off — web_fetch stays as the local floor
|
||||
```
|
||||
|
||||
- **Privacy / disclosure:** keyless requests route through `firecrawl.dev`. If you
|
||||
prefer nothing leaves your machine, `zero mcp disable firecrawl` and rely on
|
||||
`web_fetch`, or self-host below.
|
||||
- **Self-host (unlimited, private):** Firecrawl is open source (AGPL-3.0). Run your
|
||||
own instance and override the default URL — Zero only *calls* it over the network,
|
||||
so the AGPL never reaches into Zero's own code:
|
||||
```jsonc
|
||||
// config.json
|
||||
{ "mcp": { "servers": { "firecrawl": { "type": "http", "url": "http://localhost:3002/v2/mcp" } } } }
|
||||
```
|
||||
- **Bring your own key (higher limits):** add an auth header over the default:
|
||||
```bash
|
||||
zero mcp add firecrawl --type http --url https://mcp.firecrawl.dev/v2/mcp \
|
||||
--header "Authorization: Bearer fc-your-key"
|
||||
```
|
||||
|
||||
An MCP server that can't be reached at startup (e.g. Firecrawl on an offline
|
||||
machine) is **skipped with a warning, not fatal** — Zero still launches and the
|
||||
rest of its tools, including `web_fetch`, keep working.
|
||||
|
||||
### Extra write directories (`--add-dir`)
|
||||
|
||||
Zero confines writes to the workspace by default. To let the agent write somewhere
|
||||
else, pass the repeatable `--add-dir` flag — it works for both the interactive TUI
|
||||
and `zero exec`:
|
||||
|
||||
```bash
|
||||
zero --add-dir ~/Desktop/scratch # launch the TUI with an extra write root
|
||||
zero exec --add-dir ../sibling-repo "update both repos"
|
||||
```
|
||||
|
||||
In the TUI, `/add-dir <path>` grants a directory mid-session (session-only), and a
|
||||
bare `/add-dir` lists the current write roots. To persist extra roots across
|
||||
sessions, set `sandbox.additionalWriteRoots` in the **global** user config
|
||||
(`~/.config/zero/config.json`); the key is deliberately ignored in project config
|
||||
so a checked-out repo can't widen its own sandbox. Flag and config sources merge
|
||||
as a union.
|
||||
|
||||
Granted roots must already exist (the filesystem root is rejected), symlinks are
|
||||
resolved when the grant is made, and the same per-root symlink-traversal checks
|
||||
that protect the workspace apply to each extra root. Relative paths in tool calls
|
||||
still resolve against the workspace only, and network and destructive-shell policy
|
||||
are unchanged. A write denied outside all roots returns an error that suggests
|
||||
`/add-dir`.
|
||||
|
||||
Two extra hardening/diagnostic flags are available in the `sandbox` config block,
|
||||
both **off by default** and safe to leave unset:
|
||||
|
||||
- `sandbox.blockUnixSockets` (Linux) — asks the Linux sandbox helper to install a
|
||||
best-effort seccomp filter that denies `AF_UNIX` socket creation in the
|
||||
sandboxed command. Linux packages also continue to ship `zero-seccomp` as a
|
||||
compatibility wrapper for existing direct uses; new sandbox plans do not depend
|
||||
on that external wrapper.
|
||||
- `sandbox.monitorDenials` (macOS) — tails the unified log for this run's seatbelt
|
||||
denials and appends them to a command's stderr as a `<sandbox_blocks>` block
|
||||
so blocked operations are visible. A no-op on OS versions that do not deliver
|
||||
seatbelt denials to the queryable log.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
TUI (Bubble Tea) headless exec MCP server cron runner
|
||||
└──────────────────────┬──────────────────────┘
|
||||
surface-agnostic agent core
|
||||
(loop · typed event stream · tool registry)
|
||||
┌──────────┬──────────┬───────────┬───────────┬────────────┬──────────┐
|
||||
providers tools sessions specialist repo intel permissions
|
||||
+ catalog registry + search + background + workspace + sandbox
|
||||
+ registry + rewind tasks index + redaction
|
||||
```
|
||||
|
||||
- **Surface-agnostic core** — the agent loop streams text + tool calls and emits one typed event stream consumed identically by the TUI, `exec`, the MCP server, and cron.
|
||||
- **Edges are interfaces** — `Provider`, `Tool`, `SessionStore`, and the permission policy are swappable.
|
||||
- **Model is data** — capabilities, cost, and routing live in the registry, never hard-coded.
|
||||
- **Pure Go** — one static binary per platform; the npm wrapper just delegates to it.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
cmd/
|
||||
zero/ production CLI entrypoint
|
||||
zero-release/ release builder + smoke tests
|
||||
zero-perf-bench/ performance benchmarks
|
||||
zero-pr-review/ deterministic PR review helper
|
||||
internal/
|
||||
agent/ zeroruntime/ agent loop & runtime orchestration
|
||||
cli/ command surface (exec, doctor, cron, ...)
|
||||
tui/ Bubble Tea terminal interface
|
||||
providers/ providercatalog/ providermodelcatalog/
|
||||
modelregistry/ capabilities, context windows, cost
|
||||
tools/ read/write/edit/bash/grep/glob/patch/...
|
||||
specialist/ background/ subagents + out-of-process tasks
|
||||
sessions/ search/ append-only store, full-text search
|
||||
repomap/ repoinfo/ workspaceindex/ contextreport/
|
||||
specmode/ cron/ skills/ plugins/ hooks/ mcp/
|
||||
sandbox/ redaction/ secrets/ safety surfaces
|
||||
doctor/ providerhealth/ verify/ selfverify/
|
||||
docs/ PRD, protocols, install/update/perf
|
||||
scripts/ installers
|
||||
```
|
||||
Meaning does not rely on color alone; diffs, permissions, and statuses also use
|
||||
text or glyph markers.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
go test ./... # full test suite (200+ test files)
|
||||
go run ./cmd/zero-release build # compile the release binary
|
||||
go run ./cmd/zero-release smoke # smoke-test it
|
||||
go run ./cmd/zero-perf-bench # perf benchmarks (docs/PERFORMANCE.md)
|
||||
go test ./...
|
||||
go run ./cmd/zero-release build
|
||||
go run ./cmd/zero-release smoke
|
||||
go run ./cmd/zero-perf-bench
|
||||
```
|
||||
|
||||
# cross-compile
|
||||
Cross-compile examples:
|
||||
|
||||
```bash
|
||||
go run ./cmd/zero-release build --goos linux --goarch amd64
|
||||
go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero.exe
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Product Requirements (PRD)](docs/PRD.md) — vision, full feature spec, roadmap
|
||||
- [Stream-JSON protocol](docs/STREAM_JSON_PROTOCOL.md) — headless I/O contract
|
||||
- [Specialists](docs/SPECIALISTS.md) — subagent manifests, Task tools, background state
|
||||
- [Install](docs/INSTALL.md) · [Update](docs/UPDATE.md) · [Performance](docs/PERFORMANCE.md)
|
||||
- [Install](docs/INSTALL.md)
|
||||
- [Update flow](docs/UPDATE.md)
|
||||
- [Stream-JSON protocol](docs/STREAM_JSON_PROTOCOL.md)
|
||||
- [Specialists](docs/SPECIALISTS.md)
|
||||
- [GitHub Action](docs/GITHUB_ACTION.md)
|
||||
- [Benchmarks](docs/BENCHMARK.md)
|
||||
- [Performance](docs/PERFORMANCE.md)
|
||||
- [Agent evals](docs/AGENT_EVALS.md)
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). Run `go test ./...` and the relevant build or smoke command before opening a PR.
|
||||
Contributions are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md), run the
|
||||
relevant tests, and open a focused pull request.
|
||||
|
||||
Security reports should follow [SECURITY.md](SECURITY.md).
|
||||
|
||||
## License
|
||||
|
||||
License is being finalized; a `LICENSE` file will be added before the public release.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<sub><b>Zero</b> — one terminal · every model</sub>
|
||||
</div>
|
||||
Zero is released under the [MIT License](LICENSE).
|
||||
+49
@@ -9,8 +9,49 @@ function zeroBinaryName(platform = process.platform) {
|
||||
return platform === 'win32' ? 'zero.exe' : 'zero';
|
||||
}
|
||||
|
||||
function helperShimNames(name, platform = process.platform) {
|
||||
if (platform === 'win32') {
|
||||
return [`${name}.cmd`, `${name}.exe`, name];
|
||||
}
|
||||
return [name];
|
||||
}
|
||||
|
||||
function commandForShim(path, platform = process.platform) {
|
||||
if (platform === 'win32' && path.toLowerCase().endsWith('.cmd')) {
|
||||
return {
|
||||
command: process.env.ComSpec || 'cmd.exe',
|
||||
prefixArgs: ['/d', '/s', '/c', `"${path.replace(/"/g, '""')}"`],
|
||||
};
|
||||
}
|
||||
return { command: path, prefixArgs: [] };
|
||||
}
|
||||
|
||||
function resolveHelper(packageRoot, name) {
|
||||
const binDir = join(packageRoot, 'node_modules', '.bin');
|
||||
for (const shimName of helperShimNames(name)) {
|
||||
const candidate = join(binDir, shimName);
|
||||
if (!existsSync(candidate)) continue;
|
||||
return {
|
||||
...commandForShim(candidate),
|
||||
pathPrepend: [binDir],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function localControlHelperManifest(packageRoot) {
|
||||
const helpers = {};
|
||||
for (const name of ['agent-browser', 'tuistory']) {
|
||||
const helper = resolveHelper(packageRoot, name);
|
||||
if (helper) helpers[name] = helper;
|
||||
}
|
||||
if (Object.keys(helpers).length === 0) return '';
|
||||
return JSON.stringify({ version: 1, helpers });
|
||||
}
|
||||
|
||||
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const nativePath = join(packageRoot, zeroBinaryName());
|
||||
const localControlHelpers = localControlHelperManifest(packageRoot);
|
||||
|
||||
if (!existsSync(nativePath)) {
|
||||
console.error(
|
||||
@@ -19,8 +60,16 @@ if (!existsSync(nativePath)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const env = { ...process.env };
|
||||
if (localControlHelpers) {
|
||||
env.ZERO_LOCAL_CONTROL_HELPERS = localControlHelpers;
|
||||
} else {
|
||||
delete env.ZERO_LOCAL_CONTROL_HELPERS;
|
||||
}
|
||||
|
||||
const child = spawnSync(nativePath, process.argv.slice(2), {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
});
|
||||
|
||||
if (child.error) {
|
||||
|
||||
+104
-37
@@ -1,33 +1,37 @@
|
||||
# Zero Install Scripts
|
||||
# Installing Zero
|
||||
|
||||
> **Status: pre-release.** No GitHub release has been published yet, so the install scripts below
|
||||
> (which download release assets from `Gitlawb/zero`'s Releases) will fail with a 404 until the first
|
||||
> release exists. Until then, build from source: `go run ./cmd/zero` or `go build -o zero ./cmd/zero`
|
||||
> (requires Go 1.25+). The instructions below describe how the scripts work once releases are published.
|
||||
Zero is distributed as:
|
||||
|
||||
Zero release archives are published as:
|
||||
- an npm package, `@gitlawb/zero`
|
||||
- release archives on GitHub Releases
|
||||
- source builds with Go 1.25+
|
||||
|
||||
- `zero-v<version>-linux-<arch>.tar.gz`
|
||||
- `zero-v<version>-macos-<arch>.tar.gz`
|
||||
- `zero-v<version>-windows-<arch>.zip`
|
||||
The npm package and install scripts download a platform-specific release archive.
|
||||
They require a published GitHub Release for the requested version.
|
||||
|
||||
Each archive must have a matching `.sha256` file. The install scripts download both files and verify the checksum before copying the binary.
|
||||
|
||||
Maintainers can verify the release directory before upload:
|
||||
## npm
|
||||
|
||||
```bash
|
||||
go run ./cmd/zero-release package
|
||||
go run ./cmd/zero-release verify
|
||||
npm install -g @gitlawb/zero
|
||||
zero
|
||||
```
|
||||
|
||||
`verify:release` requires every archive in `dist/release` to have a same-directory `.sha256` file whose contents are:
|
||||
The package supports Linux, macOS, and Windows on x64 and arm64. It installs the
|
||||
`zero` command and downloads the matching release binary during `postinstall`.
|
||||
|
||||
```text
|
||||
<sha256> <archive-name>
|
||||
Requirements:
|
||||
|
||||
- Node.js 18+
|
||||
- network access to npm and GitHub Releases
|
||||
|
||||
## Linux And macOS Script
|
||||
|
||||
Install the latest release:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
## Linux And macOS
|
||||
|
||||
From a checkout:
|
||||
|
||||
```bash
|
||||
@@ -38,18 +42,14 @@ Install a specific version:
|
||||
|
||||
```bash
|
||||
ZERO_VERSION=0.1.0 scripts/install.sh
|
||||
scripts/install.sh --version 0.1.0
|
||||
```
|
||||
|
||||
Install to a custom directory:
|
||||
Install somewhere else:
|
||||
|
||||
```bash
|
||||
ZERO_INSTALL_DIR="$HOME/bin" scripts/install.sh
|
||||
```
|
||||
|
||||
Install from a fork or internal repository:
|
||||
|
||||
```bash
|
||||
scripts/install.sh --repo owner/repo
|
||||
scripts/install.sh --install-dir "$HOME/bin"
|
||||
```
|
||||
|
||||
Defaults:
|
||||
@@ -58,9 +58,15 @@ Defaults:
|
||||
- Version: latest GitHub release
|
||||
- Install path: `~/.local/bin/zero`
|
||||
|
||||
Requirements: `curl` or `wget`, `tar`, and `shasum` or `sha256sum`.
|
||||
Requirements: Bash, `curl` or `wget`, `tar`, and `shasum` or `sha256sum`.
|
||||
|
||||
## Windows
|
||||
## Windows PowerShell Script
|
||||
|
||||
Install the latest release:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
From a checkout:
|
||||
|
||||
@@ -74,30 +80,91 @@ Install a specific version:
|
||||
powershell -ExecutionPolicy Bypass -File scripts/install.ps1 -Version 0.1.0
|
||||
```
|
||||
|
||||
Install to a custom directory:
|
||||
Install somewhere else:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File scripts/install.ps1 -InstallDir "$env:USERPROFILE\bin"
|
||||
```
|
||||
|
||||
Install from a fork or internal repository:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File scripts/install.ps1 -Repository owner/repo
|
||||
```
|
||||
|
||||
Defaults:
|
||||
|
||||
- Repository: `Gitlawb/zero`
|
||||
- Version: latest GitHub release
|
||||
- Install path: `%LOCALAPPDATA%\zero\bin\zero.exe`
|
||||
|
||||
## From Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
cd zero
|
||||
go run ./cmd/zero
|
||||
```
|
||||
|
||||
Build a local binary:
|
||||
|
||||
```bash
|
||||
go build -o zero ./cmd/zero
|
||||
```
|
||||
|
||||
Source builds require Go 1.25+.
|
||||
|
||||
### Sandbox Helpers For Source Builds
|
||||
|
||||
Release archives include the platform sandbox helpers. If you build directly
|
||||
from source, build the helpers you need:
|
||||
|
||||
Linux:
|
||||
|
||||
```bash
|
||||
go build -o zero ./cmd/zero
|
||||
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
|
||||
go build -o zero-seccomp ./cmd/zero-seccomp
|
||||
```
|
||||
|
||||
Put `zero` and `zero-linux-sandbox` in the same directory on `PATH`, for example
|
||||
`~/.local/bin`. `zero-seccomp` is kept as a compatibility wrapper; the sandbox
|
||||
helper applies the Unix-socket filter itself when that sandbox option is enabled.
|
||||
Linux native sandboxing also requires Bubblewrap to be installed.
|
||||
|
||||
macOS uses the system sandbox and does not need an extra helper binary.
|
||||
|
||||
Windows source builds can use the main `zero.exe` as the command runner and setup
|
||||
helper through Zero's built-in self-dispatch path. If you want a release-style
|
||||
layout anyway, build the standalone helper executables next to `zero.exe`:
|
||||
|
||||
```powershell
|
||||
go build -o zero.exe ./cmd/zero
|
||||
go build -o zero-windows-command-runner.exe ./cmd/zero-windows-command-runner
|
||||
go build -o zero-windows-sandbox-setup.exe ./cmd/zero-windows-sandbox-setup
|
||||
```
|
||||
|
||||
## Release Archive Format
|
||||
|
||||
Release archives are named:
|
||||
|
||||
- `zero-v<version>-linux-<arch>.tar.gz`
|
||||
- `zero-v<version>-macos-<arch>.tar.gz`
|
||||
- `zero-v<version>-windows-<arch>.zip`
|
||||
|
||||
Supported targets:
|
||||
|
||||
- `linux-x64`
|
||||
- `linux-arm64`
|
||||
- `macos-x64`
|
||||
- `macos-arm64`
|
||||
- `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
|
||||
directory.
|
||||
|
||||
## Updating
|
||||
|
||||
Check whether a newer release exists:
|
||||
Check for a newer release:
|
||||
|
||||
```bash
|
||||
zero update --check
|
||||
```
|
||||
|
||||
For M2, updates are check-only. Re-run the installer to replace the local binary after reviewing the release.
|
||||
Then reinstall with npm or rerun the install script for the version you want.
|
||||
@@ -1,87 +0,0 @@
|
||||
# M1 Headless Exec PRD
|
||||
|
||||
Status: Draft
|
||||
Owner: Vasanth
|
||||
Milestone: M1
|
||||
Scope: `zero exec` CLI surface
|
||||
|
||||
## Goal
|
||||
|
||||
Ship the first production-shaped headless command for Zero:
|
||||
|
||||
```sh
|
||||
zero exec "inspect this repo and summarize the next fix"
|
||||
```
|
||||
|
||||
This command should be useful for local scripts, CI smoke runs, and later VS Code integration without pulling in the full future protocol, session, MCP, sandbox, or plugin roadmap.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No daemon mode.
|
||||
- No JSON-RPC input protocol.
|
||||
- No MCP, plugin, marketplace, or subagent support.
|
||||
- No session resume/fork/search.
|
||||
- No new provider adapters in this slice.
|
||||
- No architecture rewrite of the agent loop.
|
||||
|
||||
## User Stories
|
||||
|
||||
1. As a CLI user, I can run one prompt headlessly and receive the assistant answer on stdout.
|
||||
2. As a script author, I can choose `text` or JSONL output.
|
||||
3. As a reviewer, I can see tool activity without corrupting text stdout.
|
||||
4. As a model tester, I can override the configured model for one run.
|
||||
5. As a cautious user, prompt-gated tools stay disabled unless I pass an explicit unsafe flag.
|
||||
|
||||
## CLI Contract
|
||||
|
||||
```sh
|
||||
zero exec [prompt...] [options]
|
||||
```
|
||||
|
||||
Options for M1:
|
||||
|
||||
- `-f, --file <path>`: read the prompt from a file.
|
||||
- `-m, --model <model>`: override the configured model for this run.
|
||||
- `-C, --cwd <path>`: run from another working directory.
|
||||
- `-o, --output-format <text|json>`: choose stdout format.
|
||||
- `--skip-permissions-unsafe`: grant prompt-gated tools for this run.
|
||||
|
||||
The existing `zero -p "prompt"` shortcut remains as a compatibility path and should call the same runner.
|
||||
|
||||
## Output Contract
|
||||
|
||||
Text mode:
|
||||
|
||||
- stdout contains assistant text only.
|
||||
- stderr contains tool rows, warnings, and errors.
|
||||
|
||||
JSON mode:
|
||||
|
||||
- stdout is newline-delimited JSON events.
|
||||
- Required event types for this slice: `run_start`, `text`, `tool_call`, `tool_result`, `final`, `warning`, `error`, `done`.
|
||||
|
||||
## Exit Codes
|
||||
|
||||
- `0`: success
|
||||
- `1`: unexpected crash
|
||||
- `2`: usage error
|
||||
- `3`: provider/config/runtime error
|
||||
- `4`: reserved for future tool failure policy
|
||||
- `5`: reserved for future permission-denied policy
|
||||
|
||||
## Permission Behavior
|
||||
|
||||
Default headless mode advertises only tools marked `permission: "allow"`.
|
||||
|
||||
`--skip-permissions-unsafe` advertises tools that are not marked `deny` and grants prompt-gated tools through `ToolRegistry.run`. It must not bypass schema validation or direct tool execution safeguards.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `go test ./...` passes.
|
||||
- `go run ./cmd/zero-release build` passes.
|
||||
- `go run ./cmd/zero-release smoke` passes.
|
||||
- `zero exec --help` documents the M1 flags.
|
||||
- Usage errors do not initialize providers.
|
||||
- Provider failures return exit code `3`.
|
||||
- Normal mode does not advertise `bash`, `write_file`, `edit_file`, or `apply_patch`.
|
||||
- Unsafe mode still uses the registry execution path.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Zero Performance Benchmarks
|
||||
|
||||
The M2 performance harness tracks three release-facing signals:
|
||||
The performance harness tracks three release-facing signals:
|
||||
|
||||
- Cold start: process startup time for `zero --version`.
|
||||
- Binary first output: time from spawning the built `zero --version` command to
|
||||
|
||||
-602
@@ -1,602 +0,0 @@
|
||||
# Zero — Product Requirements Document (v1.0)
|
||||
|
||||
**Status:** DRAFT v1.0
|
||||
**Target:** Compete with Claude Code + Cursor + OpenCode
|
||||
**Timeline:** 6-12 months
|
||||
**Stack:** Go native core + npm distribution wrapper
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision
|
||||
|
||||
**Zero is a terminal-first, editor-aware, multi-provider AI coding platform.**
|
||||
|
||||
It combines the best of:
|
||||
- **Claude Code** — terminal-native agentic coding
|
||||
- **Cursor** — IDE integration + smart editing
|
||||
- **OpenCode** — multi-provider + headless
|
||||
|
||||
**One Go-native agent core. Three surfaces. Zero lock-in.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Surfaces
|
||||
|
||||
### 2.1 Terminal (TUI) — Primary
|
||||
- Interactive coding agent in terminal
|
||||
- Streaming, append-style (like Claude Code)
|
||||
- Bubble Tea + Lip Gloss-based
|
||||
- Full keyboard control
|
||||
- Mouse support
|
||||
|
||||
### 2.2 Editor (VS Code Extension)
|
||||
- Same agent core, different UI
|
||||
- Inline suggestions + chat panel
|
||||
- File editing with diff view
|
||||
- Diagnostics integration
|
||||
- LSP-aware
|
||||
|
||||
### 2.3 Headless (CLI/CI)
|
||||
- `zero exec "prompt"` for scripts
|
||||
- `zero serve` for daemon mode
|
||||
- JSON-RPC over stdio/WebSocket
|
||||
- MCP server support
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ ZERO PLATFORM │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ TUI │ │ VSCode │ │ Headless │ │
|
||||
│ │ Surface │ │ Surface │ │ Surface │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||
│ │ │ │ │
|
||||
│ └─────────────┼─────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────▼──────┐ │
|
||||
│ │ Agent Core │ │
|
||||
│ │ (ReAct) │ │
|
||||
│ └──────┬──────┘ │
|
||||
│ │ │
|
||||
│ ┌──────┬──────┬────┴────┬──────┬──────┐ │
|
||||
│ │Tools │Perms │Sessions │Provi │Memory│ │
|
||||
│ └──────┴──────┴─────────┴──────┴──────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Layering Rules:**
|
||||
- Agent Core never imports a surface
|
||||
- Surfaces are thin shells (I/O + rendering)
|
||||
- All state persists to local files
|
||||
- No cloud dependency
|
||||
|
||||
---
|
||||
|
||||
## 4. Functional Requirements (24 Features)
|
||||
|
||||
### F1 — Agent Core (P0)
|
||||
- ReAct loop: think → tool → result → think
|
||||
- Stream text + tool calls + thinking
|
||||
- Parallel tool execution where safe
|
||||
- Bound turns (max 100)
|
||||
- Clean interrupt (Esc/Ctrl-C)
|
||||
- Event stream: `text | tool_call | tool_result | thinking | usage | plan_update | error | turn_end`
|
||||
|
||||
### F2 — Tools (P0)
|
||||
|
||||
**Core (8):**
|
||||
- `read_file` — read with line ranges, images
|
||||
- `write_file` — create/overwrite
|
||||
- `edit_file` — exact-string edit (uniqueness guard)
|
||||
- `bash` — run shell command
|
||||
- `grep` — ripgrep content search
|
||||
- `glob` — file pattern matching
|
||||
- `apply_patch` — multi-hunk patches
|
||||
- `update_plan` — todo list
|
||||
|
||||
**Advanced (4):**
|
||||
- `web_fetch` / `web_search` — web access
|
||||
- `task` — spawn subagent
|
||||
- `structured_output` — JSON-Schema validated
|
||||
- `notebook_edit` — Jupyter notebooks
|
||||
|
||||
**Tool Requirements:**
|
||||
- Go typed parameter schemas -> JSON Schema
|
||||
- Side-effect class: `read | write | shell | network | out_of_workspace`
|
||||
- Structured results: `{ status, output, truncated?, meta? }`
|
||||
- Atomic execution
|
||||
- Permission gating
|
||||
|
||||
### F3 — Providers & Model Registry (P0)
|
||||
- OpenAI-compatible (works with OpenAI, OpenRouter, Ollama, etc.)
|
||||
- Anthropic (Claude)
|
||||
- Google Gemini
|
||||
- Azure OpenAI
|
||||
- AWS Bedrock
|
||||
- xAI (Grok)
|
||||
|
||||
**Model Registry:**
|
||||
|
||||
```go
|
||||
type ModelEntry struct {
|
||||
ID string
|
||||
Name string
|
||||
Provider string
|
||||
APIProviders []string
|
||||
ContextLimits ContextLimits
|
||||
ReasoningEffort ReasoningEffort
|
||||
Capabilities ModelCapabilities
|
||||
Cost ModelCost
|
||||
Tier string
|
||||
MatchPatterns []string
|
||||
}
|
||||
```
|
||||
|
||||
- Per-task model selection (`-m`)
|
||||
- Provider rotation/failover
|
||||
- Cost tracking
|
||||
- "Spec model" for planning
|
||||
- Fallback model on failure
|
||||
|
||||
### F4 — Terminal UI (P0)
|
||||
- **Layout:** header (cwd · git · model) → tool rows → diff → response → input → footer
|
||||
- **Streaming:** append-only transcript model with live tail updates
|
||||
- **Markdown:** full CommonMark + syntax highlighting
|
||||
- **Tool rows:** collapsible with one-line summaries
|
||||
- **Input:** `/` commands, `@` files, `!` bash, `Esc` interrupt
|
||||
- **Robustness:** resize, wide-char, ANSI, non-TTY fallback
|
||||
- **Splash:** ZERO wordmark
|
||||
- **Themes:** light/dark + custom
|
||||
|
||||
### F5 — Sessions (P0)
|
||||
- Persist to `~/.local/share/zero/sessions/<id>/`
|
||||
- `meta.json` + `events.jsonl` + `messages.jsonl` + `checkpoints/`
|
||||
- `--resume [id]` (default: latest)
|
||||
- `--fork <id>` (copy with lineage)
|
||||
- Search: `zero search <query>` over messages/tools/results
|
||||
- Atomic writes (write-temp-then-rename)
|
||||
- Schema versioning
|
||||
- Export to Markdown/JSON
|
||||
|
||||
### F6 — Configuration (P0)
|
||||
- Layered: defaults → user → project → env → CLI
|
||||
- `~/.config/zero/config.json` (user)
|
||||
- `./.zero/config.json` (project)
|
||||
- Runtime overlay (`--settings <path>`)
|
||||
- Feature flags for staged rollout
|
||||
- Validation on load
|
||||
|
||||
### F7 — MCP (Model Context Protocol) (P0)
|
||||
- Connect to stdio/HTTP/SSE servers
|
||||
- Surface MCP tools into registry
|
||||
- `zero mcp add/remove/list`
|
||||
- Per-server and per-tool permissions
|
||||
- Persistent grants with revoke/clear
|
||||
- MCP server mode (zero as MCP server)
|
||||
|
||||
### F8 — Headless / Programmatic (P0)
|
||||
- `zero exec [prompt]` — one-shot
|
||||
- `zero serve` — daemon mode
|
||||
- `--input-format stream-json` — line-delimited events
|
||||
- `--input-format stream-jsonrpc` — JSON-RPC protocol
|
||||
- `-o text|json` output formats
|
||||
- Exit codes: `0=success, 1=crash, 2=usage, 3=provider, 4=tool, 5=permission, 6=budget`
|
||||
|
||||
### F9 — Permissions & Autonomy (P0)
|
||||
- **Side-effect classes:** read, write, shell, network, out_of_workspace
|
||||
- **Autonomy levels:** low (ask), medium (smart), high (auto)
|
||||
- `--skip-permissions-unsafe` (loud warning)
|
||||
- TUI: inline prompts (allow/deny/always)
|
||||
- Headless: default-deny unless granted
|
||||
- Persistent grants with list/revoke/clear
|
||||
- Audit log in `events.jsonl`
|
||||
|
||||
**Permission Matrix:**
|
||||
|
||||
| Tool Class | low | medium | high |
|
||||
|------------|-----|--------|------|
|
||||
| Read | allow | allow | allow |
|
||||
| Workspace write | prompt | allow | allow |
|
||||
| Shell | prompt | prompt | allow |
|
||||
| Network | prompt | allow | allow |
|
||||
| Out-of-workspace | deny | prompt | prompt |
|
||||
|
||||
### F10 — Observability (P0)
|
||||
- Structured logging (stdout/stderr split)
|
||||
- Secret redaction (API keys, tokens, passwords)
|
||||
- No vendor telemetry (local only)
|
||||
- `zero doctor` — health check command
|
||||
- Crash reports (opt-in, local)
|
||||
- Performance metrics (TTFT, tokens/sec)
|
||||
|
||||
### F11 — Subagents (P1)
|
||||
- `task` tool spawns scoped subagent
|
||||
- Built-in agents: code-review, security-review, refactor, test-gen
|
||||
- Recursion depth limit
|
||||
- Parent session linkage
|
||||
- Shared/isolated budgets
|
||||
|
||||
### F12 — Plugins (P1)
|
||||
- User/project-scoped plugins
|
||||
- Commands, tools, prompts, hooks
|
||||
- Plugin manifest: `plugin.json`
|
||||
- `zero plugin install <path|url>`
|
||||
- No marketplace in v1 (manual install)
|
||||
|
||||
### F13 — Distribution (P0)
|
||||
- **Single binary** via Go cross-compilation
|
||||
- npm package as a thin installer/launcher for the platform binary
|
||||
- Cross-platform: Linux, macOS, Windows
|
||||
- `zero update [--check]` self-update
|
||||
- Checksum verification
|
||||
- Rollback on failure
|
||||
|
||||
### F14 — VS Code Extension (P1)
|
||||
- Same agent core via stream protocol
|
||||
- Inline chat panel
|
||||
- Diff view in editor
|
||||
- Diagnostics integration
|
||||
- LSP awareness
|
||||
- File watching
|
||||
|
||||
### F15 — Memory & Context (P1)
|
||||
- `AGENTS.md` hierarchical memory
|
||||
- `@import` for modular rules
|
||||
- Auto-generated from `init` command
|
||||
- Project conventions learning
|
||||
- Skills system (reusable instruction packs)
|
||||
- Context budgeting (token tracking)
|
||||
- Auto-compaction when full
|
||||
|
||||
### F16 — Hooks System (P1)
|
||||
- Lifecycle hooks: `PreToolUse`, `PostToolUse`, `SessionStart`, `SessionEnd`
|
||||
- User-defined shell commands
|
||||
- JSON output for conditional flow
|
||||
- `.zero/hooks/` directory
|
||||
|
||||
### F17 — Slash Commands (P0)
|
||||
|
||||
**Built-in (21):**
|
||||
- Session: `/clear`, `/compact`, `/context`, `/add-dir`, `/rewind`, `/resume`, `/exit`
|
||||
- Model: `/model`, `/effort`, `/style`, `/permissions`
|
||||
- Project: `/init`, `/memory`, `/skills`
|
||||
- Extensibility: `/mcp`, `/hooks`, `/agents`
|
||||
- Meta: `/help`, `/config`, `/doctor`, `/feedback`
|
||||
|
||||
**Custom:** `./.zero/commands/*.md`
|
||||
|
||||
### F18 — Git Integration (P1)
|
||||
- Worktree isolation per task
|
||||
- Auto-commit with messages
|
||||
- Branch management
|
||||
- PR creation
|
||||
- Diff review
|
||||
- Conflict resolution
|
||||
|
||||
### F19 — Testing & Verification (P1)
|
||||
- `run_tests` tool
|
||||
- Self-verification loop
|
||||
- Test runner integration
|
||||
- Coverage reporting
|
||||
- Diagnostics (typecheck, lint)
|
||||
- LSP integration
|
||||
|
||||
### F20 — Web Access (P1)
|
||||
- `web_fetch` — fetch URL content
|
||||
- `web_search` — search engine
|
||||
- Provider/permission gated
|
||||
- Rate limiting
|
||||
- Caching
|
||||
|
||||
### F21 — Sandbox (P2)
|
||||
- Linux: bubblewrap
|
||||
- macOS: sandbox-exec
|
||||
- Windows: AppContainer
|
||||
- Filesystem policies (workspace write confinement + user-granted extra roots via `--add-dir`)
|
||||
- Network policies
|
||||
- Unix socket controls
|
||||
|
||||
### F22 — Multi-modal (P2)
|
||||
- Image input (screenshots, diagrams)
|
||||
- PDF parsing
|
||||
- Voice input (optional)
|
||||
- File attachments
|
||||
- Paste support
|
||||
|
||||
### F23 — Telemetry (P2)
|
||||
- Local-only metrics
|
||||
- Token usage tracking
|
||||
- Cost reports
|
||||
- Performance profiling
|
||||
- Crash reports (opt-in)
|
||||
- NO vendor telemetry
|
||||
|
||||
### F24 — Advanced Features (P2)
|
||||
- Code review automation
|
||||
- Auto-refactor
|
||||
- Dependency updates
|
||||
- Security scanning
|
||||
- Documentation generation
|
||||
|
||||
---
|
||||
|
||||
## 5. Data Models
|
||||
|
||||
### 5.1 Headless Event Stream
|
||||
|
||||
```json
|
||||
{ "type": "run_start", "runId": "r1", "sessionId": "s1", "cwd": "/repo", "model": "..." }
|
||||
{ "type": "text", "delta": "..." }
|
||||
{ "type": "tool_call", "id": "t1", "name": "bash", "args": {...}, "sideEffect": "shell" }
|
||||
{ "type": "permission", "id": "t1", "decision": "needs_approval", "reason": "shell" }
|
||||
{ "type": "tool_result", "id": "t1", "status": "ok", "output": "...", "truncated": false }
|
||||
{ "type": "usage", "promptTokens": 1234, "completionTokens": 567, "costUsd": 0.012 }
|
||||
{ "type": "plan_update", "plan": [{ "id": "1", "content": "...", "status": "in_progress" }] }
|
||||
{ "type": "turn_end", "stopReason": "end_turn" }
|
||||
{ "type": "error", "code": "rate_limit", "message": "...", "recoverable": true }
|
||||
{ "type": "run_end", "status": "success", "exitCode": 0 }
|
||||
```
|
||||
|
||||
### 5.2 Session On Disk
|
||||
|
||||
```
|
||||
~/.local/share/zero/sessions/<id>/
|
||||
meta.json # { schemaVersion, id, parentId?, title, cwd, model, createdAt, updatedAt, tokens, cost }
|
||||
events.jsonl # append-only AgentEvent stream
|
||||
messages.jsonl # provider-facing messages
|
||||
checkpoints/ # snapshots for restore/fork/compact
|
||||
```
|
||||
|
||||
### 5.3 Tool Contract
|
||||
|
||||
```go
|
||||
type Tool interface {
|
||||
Name() string
|
||||
Description() string
|
||||
Parameters() JSONSchema
|
||||
SideEffect() SideEffect
|
||||
Execute(ctx ToolContext, args map[string]any) ToolResult
|
||||
}
|
||||
|
||||
type ToolResult struct {
|
||||
Status string
|
||||
Output string
|
||||
Truncated bool
|
||||
Meta map[string]any
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 Config Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "claude-sonnet-4-6",
|
||||
"providers": {
|
||||
"anthropic": { "apiKey": "env:AN..._KEY" },
|
||||
"openai": { "apiKey": "env:OP..._KEY" }
|
||||
},
|
||||
"permissions": {
|
||||
"autonomy": "medium",
|
||||
"grants": [{ "tool": "bash", "pattern": "git status", "decision": "allow" }]
|
||||
},
|
||||
"ui": { "theme": "dark", "syntaxHighlighting": true }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Non-Functional Requirements
|
||||
|
||||
### Performance
|
||||
- TTFT < 500ms after provider's first byte
|
||||
- No full-transcript repaint per token
|
||||
- Cold start < 300ms (warm cache)
|
||||
- Tool execution < 100ms overhead
|
||||
- Session resume < 1s
|
||||
|
||||
### Reliability
|
||||
- Crash-free TUI under resize, wide-char, non-TTY
|
||||
- Clean interrupt (no half-written state)
|
||||
- Atomic session writes
|
||||
- Recovery from malformed JSONL
|
||||
- Provider failover
|
||||
|
||||
### Security
|
||||
- No mutation without permission decision (hard invariant)
|
||||
- API keys never logged
|
||||
- Secret redaction everywhere
|
||||
- Headless stdout free of logs
|
||||
- Workspace boundary by default
|
||||
- Sandboxing for untrusted code
|
||||
|
||||
### Portability
|
||||
- Linux, macOS, Windows
|
||||
- Any OpenAI-compatible endpoint
|
||||
- x86_64, ARM64
|
||||
- Single binary distribution
|
||||
|
||||
### Testability
|
||||
- Mock provider for unit tests
|
||||
- Fake MCP server/client
|
||||
- Fake terminal/PTY
|
||||
- Sandbox simulator
|
||||
- Deterministic event replay
|
||||
|
||||
---
|
||||
|
||||
## 7. Milestones
|
||||
|
||||
### M0 — Foundation (Weeks 1-2)
|
||||
- Go module setup
|
||||
- Agent core skeleton
|
||||
- 8 core tools (read, write, edit, bash, grep, glob, apply_patch, update_plan)
|
||||
- OpenAI-compatible provider
|
||||
- Basic Bubble Tea TUI
|
||||
- Permission gate (read vs mutate)
|
||||
- Session persistence
|
||||
|
||||
**Exit:** `zero` runs, can read/edit files, basic chat works.
|
||||
|
||||
### M1 — Multi-Provider (Weeks 3-4)
|
||||
- Anthropic provider
|
||||
- Gemini provider
|
||||
- Model registry
|
||||
- Per-task model selection
|
||||
- Provider rotation
|
||||
- Cost tracking
|
||||
- Headless `exec` mode
|
||||
|
||||
**Exit:** Switch between Claude/GPT/Gemini, see cost, run headless.
|
||||
|
||||
### M2 — Polish (Weeks 5-8)
|
||||
- Advanced TUI features
|
||||
- Full markdown + syntax highlighting
|
||||
- Tool result truncation/pagination
|
||||
- Auto-compaction
|
||||
- Context budgeting
|
||||
- 20 slash commands
|
||||
- Doctor command
|
||||
- Search command
|
||||
|
||||
**Exit:** Production-ready TUI, smooth streaming, all commands work.
|
||||
|
||||
### M3 — Distribution (Weeks 9-10)
|
||||
- Single binary build
|
||||
- Self-update
|
||||
- Cross-platform CI
|
||||
- VS Code extension MVP
|
||||
- Documentation site
|
||||
|
||||
**Exit:** `zero` ships as one binary, works on Win/Mac/Linux.
|
||||
|
||||
### M4 — Extensibility (Weeks 11-14)
|
||||
- MCP client + permissions
|
||||
- Hooks system
|
||||
- Skills system
|
||||
- Memory (AGENTS.md)
|
||||
- Plugin loading
|
||||
- Subagents (built-in: review, security)
|
||||
|
||||
**Exit:** Plugins work, MCP servers connect, memory persists.
|
||||
|
||||
### M5 — Advanced (Weeks 15-20)
|
||||
- Web fetch/search
|
||||
- Git worktrees
|
||||
- Self-verification
|
||||
- Diagnostics (LSP, typecheck)
|
||||
- Testing integration
|
||||
- Code review automation
|
||||
|
||||
**Exit:** Zero can review code, run tests, manage git.
|
||||
|
||||
### M6 — Sandbox & Security (Weeks 21-24)
|
||||
- Linux sandbox (bubblewrap)
|
||||
- macOS sandbox
|
||||
- Windows AppContainer
|
||||
- Network policies
|
||||
- Filesystem policies
|
||||
|
||||
**Exit:** Untrusted code runs safely in sandbox.
|
||||
|
||||
### Beyond v1.0
|
||||
- Multi-modal (images, PDFs)
|
||||
- Voice input
|
||||
- Cloud sync (optional)
|
||||
- Team features
|
||||
- Marketplace (if demand)
|
||||
|
||||
---
|
||||
|
||||
## 8. Risks & Mitigations
|
||||
|
||||
| # | Risk | Mitigation |
|
||||
|---|------|------------|
|
||||
| R1 | Scope creep | Stick to milestones, defer features |
|
||||
| R2 | Provider API changes | Abstract via interface, test with mocks |
|
||||
| R3 | TUI complexity | Use Bubble Tea/Lip Gloss, follow mature terminal agent UX patterns |
|
||||
| R4 | Performance | Benchmark early, optimize streaming |
|
||||
| R5 | Security vulnerabilities | Permission gate from day 1, security review |
|
||||
| R6 | Cross-platform bugs | CI on Win/Mac/Linux from M0 |
|
||||
| R7 | Breaking changes | Schema versioning, migration tools |
|
||||
|
||||
---
|
||||
|
||||
## 9. Success Metrics
|
||||
|
||||
### v1.0 (6 months)
|
||||
- 5+ providers supported
|
||||
- 12+ tools
|
||||
- 100% test coverage on core
|
||||
- < 300ms cold start
|
||||
- Single binary < 50MB
|
||||
- Works on Win/Mac/Linux
|
||||
- 1000+ GitHub stars
|
||||
- 50+ contributors
|
||||
|
||||
### v2.0 (12 months)
|
||||
- VS Code extension published
|
||||
- MCP server mode
|
||||
- Plugin ecosystem (20+ plugins)
|
||||
- 10K+ active users
|
||||
- Compete with Claude Code on benchmarks
|
||||
|
||||
---
|
||||
|
||||
## 10. Out of Scope (v1.0)
|
||||
|
||||
- Cloud sync / hosted backend
|
||||
- Relay / remote SSH execution
|
||||
- Multi-agent mission mode
|
||||
- Plugin marketplace
|
||||
- Auto-generated wikis
|
||||
- Git authorship notes
|
||||
- Vendor telemetry
|
||||
- Voice input
|
||||
- Mobile support
|
||||
|
||||
---
|
||||
|
||||
## 11. Open Decisions
|
||||
|
||||
1. **TUI library:** Bubble Tea + Lip Gloss — **Decide: Go-native TUI** (single-binary friendly, mature terminal ecosystem)
|
||||
2. **Storage:** JSONL files vs SQLite — **Decide: JSONL** (inspectable, simple)
|
||||
3. **Package structure:** Single vs monorepo — **Decide: Single** until M4
|
||||
4. **Provider abstraction:** Direct API vs wrapper — **Decide: Direct** (portability)
|
||||
5. **Patch strategy:** apply_patch vs edit_file — **Decide: Both** (apply_patch for bulk, edit_file for small)
|
||||
|
||||
---
|
||||
|
||||
## 12. Team & Timeline
|
||||
|
||||
**Team (3 people):**
|
||||
- You (lead + TUI + tools)
|
||||
- Backend dev (providers + core)
|
||||
- Designer (VS Code extension + docs)
|
||||
|
||||
**Timeline:**
|
||||
- M0-M1: 4 weeks (foundation)
|
||||
- M2-M3: 6 weeks (polish + distribution)
|
||||
- M4-M5: 8 weeks (extensibility)
|
||||
- M6: 4 weeks (sandbox)
|
||||
- **Total v1.0: 6 months**
|
||||
|
||||
---
|
||||
|
||||
## 13. Why This Will Work
|
||||
|
||||
1. **Proven patterns** — Based on clean-room analysis of existing terminal coding-agent UX patterns
|
||||
2. **Multi-provider** — Unique advantage vs Claude Code/Cursor
|
||||
3. **Open source** — Community contributions
|
||||
4. **Go** — Native binary, fast startup, strong concurrency, cross-platform CLI ergonomics
|
||||
5. **npm distribution wrapper** — Keeps JavaScript ecosystem installation while the runtime stays native
|
||||
6. **Clear milestones** — Ship early, iterate
|
||||
|
||||
---
|
||||
|
||||
**This is the real PRD. 13 sections, 24 features, 6 months to v1.0.**
|
||||
@@ -0,0 +1,23 @@
|
||||
# Zero Documentation
|
||||
|
||||
This directory contains public documentation for using, extending, and releasing
|
||||
Zero.
|
||||
|
||||
## User Docs
|
||||
|
||||
- [Install](INSTALL.md)
|
||||
- [Update flow](UPDATE.md)
|
||||
- [OAuth logins and subscription-backed providers](oauth-subscriptions.md)
|
||||
|
||||
## Automation And Integrations
|
||||
|
||||
- [Stream-JSON protocol](STREAM_JSON_PROTOCOL.md)
|
||||
- [GitHub Action](GITHUB_ACTION.md)
|
||||
- [Specialists](SPECIALISTS.md)
|
||||
|
||||
## Maintainer Docs
|
||||
|
||||
- [npm wrapper smoke checklist](NPM_WRAPPER_SMOKE.md)
|
||||
- [Benchmarks](BENCHMARK.md)
|
||||
- [Performance benchmarks](PERFORMANCE.md)
|
||||
- [Offline agent evals](AGENT_EVALS.md)
|
||||
+27
-21
@@ -1,38 +1,44 @@
|
||||
# Zero Update Flow
|
||||
# Update Flow
|
||||
|
||||
`zero update --check` checks the latest GitHub release for `Gitlawb/zero` and compares it with the local CLI version.
|
||||
`zero update --check` checks the latest GitHub release and compares it with the
|
||||
local CLI version.
|
||||
|
||||
```bash
|
||||
zero update --check
|
||||
zero update --check --json
|
||||
zero update --check --repo Gitlawb/zero
|
||||
zero update --check --endpoint https://api.github.com/repos/Gitlawb/zero/releases/latest
|
||||
zero update --check --timeout 3s
|
||||
zero update --check --target windows-x64
|
||||
```
|
||||
|
||||
For M2 this command is intentionally check-only:
|
||||
The command is intentionally check-only:
|
||||
|
||||
- It does not replace the running binary.
|
||||
- It exits with code `0` when the check succeeds, even when an update is available.
|
||||
- It exits with code `0` when the check succeeds, even when an update is
|
||||
available.
|
||||
- It exits with code `1` when the release check cannot be completed.
|
||||
- `--json` prints the same result in a machine-readable format for scripts and CI.
|
||||
- `--repo <owner/repo>` checks a different GitHub repository when no endpoint is provided.
|
||||
- `--endpoint <url|owner/repo>` checks a specific release API URL or repository slug.
|
||||
- `--timeout <duration>` overrides the default release check timeout.
|
||||
- `--target <platform-arch>` validates release metadata for another supported target from the current machine.
|
||||
- Release checks time out after 5 seconds by default.
|
||||
- It validates that the latest release includes the expected archive and matching `.sha256` asset for the selected platform target.
|
||||
- `--json` prints the same result in a machine-readable format for scripts and
|
||||
CI.
|
||||
|
||||
Supported targets are `linux-x64`, `linux-arm64`, `macos-x64`, `macos-arm64`, `windows-x64`, and `windows-arm64`. Without `--target`, Zero checks the current platform.
|
||||
Useful flags:
|
||||
|
||||
The release endpoint resolves in this order:
|
||||
| Flag | Purpose |
|
||||
|---|---|
|
||||
| `--repo <owner/repo>` | Check another GitHub repository. |
|
||||
| `--endpoint <url|owner/repo>` | Check a specific release API URL or repository slug. |
|
||||
| `--timeout <duration>` | Override the default release check timeout. |
|
||||
| `--target <platform-arch>` | Validate release metadata for another supported target. |
|
||||
|
||||
- `--endpoint` from the CLI, or `Options.Endpoint` when calling `update.Check` from code.
|
||||
- `ZERO_UPDATE_RELEASE_URL` from the environment.
|
||||
- `--repo` from the CLI, or `Options.Repository` when calling `update.Check` from code.
|
||||
- `https://api.github.com/repos/Gitlawb/zero/releases/latest`.
|
||||
Supported targets are `linux-x64`, `linux-arm64`, `macos-x64`, `macos-arm64`,
|
||||
`windows-x64`, and `windows-arm64`. Without `--target`, Zero checks the current
|
||||
platform.
|
||||
|
||||
`--endpoint`, `Options.Endpoint`, and `ZERO_UPDATE_RELEASE_URL` may be a full URL or an `owner/repo` slug.
|
||||
Endpoint resolution order:
|
||||
|
||||
Installer scripts download the matching release asset for the local platform and verify its `.sha256` file. If Zero is already installed, use this command before re-running the installer.
|
||||
1. `--endpoint`
|
||||
2. `ZERO_UPDATE_RELEASE_URL`
|
||||
3. `--repo`
|
||||
4. `https://api.github.com/repos/Gitlawb/zero/releases/latest`
|
||||
|
||||
Installer scripts download the matching release asset for the local platform and
|
||||
verify its `.sha256` file. If Zero is already installed, run `zero update --check`
|
||||
before reinstalling.
|
||||
@@ -1,286 +0,0 @@
|
||||
# Zero WorkSplit PRD v3: Go-Native Product Ownership Plan
|
||||
|
||||
Status: Draft v3.0
|
||||
Date: 2026-06-06
|
||||
Timeline: Active sprint plan toward v0.1, then v1.0 hardening
|
||||
|
||||
Team:
|
||||
|
||||
- Vasanth
|
||||
- Gnanam
|
||||
- Anandan
|
||||
|
||||
## 1. Product Goal
|
||||
|
||||
Zero is a terminal-first AI coding agent built in Go.
|
||||
|
||||
The goal is not just to remove the old TypeScript runtime. The goal is to make Zero feel like a mature daily-driver CLI agent:
|
||||
|
||||
- fast startup
|
||||
- strong command UX
|
||||
- safe autonomy
|
||||
- clear permission handling
|
||||
- useful local tools
|
||||
- reliable provider/runtime behavior
|
||||
- installable, updateable, and testable on real developer machines
|
||||
|
||||
This document replaces the older migration-era work split. The Go runtime is now the main product baseline, so future work should focus on product depth rather than repeating the old M0 migration plan.
|
||||
|
||||
## 2. Current Baseline On Main
|
||||
|
||||
The current main branch is Go-native for the runtime and CLI.
|
||||
|
||||
Implemented baseline:
|
||||
|
||||
- `cmd/zero` is the primary CLI entrypoint.
|
||||
- `cmd/zero-pr-review` exists for PR automation workflows.
|
||||
- Core runtime packages live under `internal/`.
|
||||
- Current CLI surface includes `exec`, `config`, `models`, `providers`, `doctor`, `search`, `sessions`, `plugins`, `hooks`, `mcp`, `sandbox`, `update`, `worktrees`, `verify`, `changes`, `serve`, `help`, and `version`.
|
||||
- Core areas exist for agent flow, provider/model registry, tools, TUI, sessions, search, sandbox, self-verification, stream-json, usage, redaction, hooks, MCP, plugins, update, worktrees, and git/change handling.
|
||||
- TypeScript is no longer part of the repository baseline. The npm package keeps a small Node wrapper that delegates to the Go binary.
|
||||
|
||||
Still needed before Zero feels production-ready:
|
||||
|
||||
- Permission and sandbox UX must become clearer and harder to misuse.
|
||||
- TUI command flows need polish beyond basic rendering.
|
||||
- Platform behavior, especially Windows sandbox/update/install paths, needs stronger product ownership.
|
||||
- Review automation should produce useful, repeatable output for maintainers.
|
||||
- Release trust needs checksums, install smoke tests, and update verification.
|
||||
|
||||
## 3. Ownership Rules
|
||||
|
||||
- Every feature has one DRI.
|
||||
- UI, runtime, and platform work should be split by contract, not by vague file ownership.
|
||||
- Every PR must expose user-runnable behavior or a concrete contract consumed by another owner.
|
||||
- Docs, CI, and website work can support a product PR, but they should not be the whole lane for any owner.
|
||||
- Anandan must own platform product work, not only docs/site/CI.
|
||||
- Shared contracts should be agreed before large UI or integration work lands.
|
||||
- Keep PRs large enough to move the product forward, but still reviewable.
|
||||
|
||||
## 4. Team Roles
|
||||
|
||||
| Owner | Primary Role | Owns | Should Not Own Alone |
|
||||
| --- | --- | --- | --- |
|
||||
| Vasanth | Product UX and TUI owner | Bubble Tea TUI, startup screen, command palette, slash command UX, tool rendering, permission prompts, sandbox block rendering, verification/test/git output UX, themes, daily CLI feel | Provider internals, release pipeline, platform adapters |
|
||||
| Gnanam | Runtime core owner | Provider/model registry, provider factory, agent/runtime protocols, stream-json, sessions/search backends, config/doctor/usage/redaction, MCP/hooks/plugins backend, sandbox policy/grants, permission event contract | TUI composition, release packaging, platform-specific install trust |
|
||||
| Anandan | Platform product owner | Install/update/release trust, binary/package smoke tests, platform sandbox adapters, Windows behavior, PR/review automation CLI, CI as product verification, performance and release checks | TUI rendering, provider semantics, model registry ownership |
|
||||
|
||||
## 5. Anandan Platform Product Rule
|
||||
|
||||
Anandan should not be assigned only docs/site/CI forever. His lane must produce platform behavior that users and maintainers can run.
|
||||
|
||||
Accepted Anandan work:
|
||||
|
||||
- `zero sandbox policy` improvements and platform-specific sandbox behavior.
|
||||
- Windows sandbox adapter behavior and clear fallback reporting.
|
||||
- `zero update` verification, release metadata validation, and install smoke tests.
|
||||
- Package and binary checksums.
|
||||
- PR automation CLI workflows that reviewers can run locally or in CI.
|
||||
- CI jobs that prove real Zero commands work after installation.
|
||||
|
||||
Not accepted as standalone Anandan work:
|
||||
|
||||
- Docs-only updates.
|
||||
- Website-only updates.
|
||||
- Future VS Code notes with no current CLI behavior.
|
||||
- CI matrix changes that do not prove a user-facing command or release flow.
|
||||
- Release notes or checklists without executable validation.
|
||||
|
||||
Docs and CI are still useful, but they must attach to real platform product behavior.
|
||||
|
||||
## 6. Shared Contracts
|
||||
|
||||
| Contract | DRI | Consumers | Purpose |
|
||||
| --- | --- | --- | --- |
|
||||
| Provider stream events | Gnanam | Vasanth, Anandan | Lets TUI and automation render model output, tool calls, and errors consistently. |
|
||||
| Permission and sandbox events | Gnanam | Vasanth, Anandan | Lets TUI ask for decisions and platform code enforce them without bypasses. |
|
||||
| Session event schema | Gnanam | Vasanth, Anandan | Lets TUI, resume, search, and automation share one session model. |
|
||||
| Command metadata registry | Vasanth | Gnanam, Anandan | Lets slash commands, help, shell completions, and docs use one command source. |
|
||||
| Platform/release contract | Anandan | Vasanth, Gnanam | Defines install, update, checksum, smoke, and platform capability results. |
|
||||
| Stream-json protocol | Gnanam | Anandan | Lets CI and PR automation consume Zero output without scraping text UI. |
|
||||
|
||||
## 7. Active Workstreams
|
||||
|
||||
### A. Safe Autonomy
|
||||
|
||||
Top priority. Zero must be able to run tools confidently while showing users what is happening and asking when risk increases.
|
||||
|
||||
Planned PRs:
|
||||
|
||||
- Vasanth: `feat/tui-sandbox-permissions`
|
||||
- permission prompt UX
|
||||
- allow, deny, and always-allow flows
|
||||
- sandbox block rendering
|
||||
- `/permissions` or equivalent permission state UI
|
||||
- Gnanam: `feat/runtime-permission-events`
|
||||
- structured permission request events
|
||||
- sandbox grant contract
|
||||
- stream-json support
|
||||
- tests for denial, approval, and block cases
|
||||
- Anandan: `feat/windows-sandbox-platform`
|
||||
- platform sandbox capability checks
|
||||
- stronger Windows behavior or explicit safe fallback
|
||||
- `zero sandbox policy --json` output useful enough for CI and support
|
||||
- platform tests where possible
|
||||
|
||||
Done when:
|
||||
|
||||
- High-autonomy tool attempts produce a clear prompt, decision, and result in TUI and headless modes.
|
||||
- Denied commands do not run.
|
||||
- Sandbox blocks are visible and actionable.
|
||||
- Platform fallback is explicit, tested, and not silently unsafe.
|
||||
|
||||
### B. Command UX And Daily CLI
|
||||
|
||||
The CLI should feel like a product, not a collection of backend commands.
|
||||
|
||||
Planned PRs:
|
||||
|
||||
- Vasanth: `feat/tui-command-polish`
|
||||
- `/model`, `/provider`, `/context`, `/doctor`, `/verify`, `/resume`, and `/compact` become clear interactive flows
|
||||
- command results render consistently
|
||||
- startup screen stays minimal until the user asks for panels
|
||||
- Gnanam: `feat/runtime-command-contracts`
|
||||
- stable command data APIs
|
||||
- provider/model/session/config result structs
|
||||
- error types suitable for TUI rendering
|
||||
- Anandan: `feat/install-update-user-flow`
|
||||
- `zero update` and install flows are user-runnable
|
||||
- package smoke command verifies installed binary behavior
|
||||
- CI proves the packaged command starts and reports version/help
|
||||
|
||||
Done when:
|
||||
|
||||
- A new user can run Zero, inspect config/provider/model state, and understand the next action without reading source.
|
||||
|
||||
### C. Verification And Review Automation
|
||||
|
||||
Zero should help maintainers verify changes and review PRs with repeatable local commands.
|
||||
|
||||
Planned PRs:
|
||||
|
||||
- Vasanth: `feat/tui-verification-results`
|
||||
- render verify/test/git/review results in TUI
|
||||
- show pass/fail/blocked states clearly
|
||||
- Gnanam: `feat/runtime-self-verify-contract`
|
||||
- structured verification results
|
||||
- stable test/change event model
|
||||
- redacted logs for model and automation use
|
||||
- Anandan: `feat/pr-review-cli-workflow`
|
||||
- make `cmd/zero-pr-review` or `zero review` useful for changed files, validations, and summary output
|
||||
- support local and CI invocation
|
||||
- avoid brittle text scraping where stream-json can be used
|
||||
|
||||
Done when:
|
||||
|
||||
- A maintainer can run one local command before review and get useful changed-file, validation, and risk output.
|
||||
|
||||
### D. Release Trust And Distribution
|
||||
|
||||
This is Anandan's main product lane after platform sandbox work.
|
||||
|
||||
Planned PRs:
|
||||
|
||||
- Anandan: `feat/release-trust`
|
||||
- release metadata validation
|
||||
- checksums
|
||||
- install smoke tests
|
||||
- update verification
|
||||
- clear error output when release artifacts are invalid
|
||||
- Vasanth:
|
||||
- consume release/update results in CLI UX where needed
|
||||
- Gnanam:
|
||||
- ensure config, errors, and logs are structured and redacted
|
||||
|
||||
Done when:
|
||||
|
||||
- Users can install, run, verify, and update Zero with confidence.
|
||||
- Maintainers can prove release artifacts work before publishing.
|
||||
|
||||
### E. Extensibility UX
|
||||
|
||||
This comes after safe autonomy and daily CLI polish.
|
||||
|
||||
Planned PRs:
|
||||
|
||||
- Vasanth:
|
||||
- `/mcp`, `/hooks`, `/plugins`, and related UI flows
|
||||
- Gnanam:
|
||||
- harden backend contracts and lifecycle behavior
|
||||
- Anandan:
|
||||
- automation and docs only after executable behavior exists
|
||||
|
||||
Done when:
|
||||
|
||||
- Extensibility feels discoverable and safe from the terminal.
|
||||
|
||||
## 8. Immediate Next PRs
|
||||
|
||||
These are the next reviewable slices. Do not use the old migration-era next steps.
|
||||
|
||||
1. Vasanth: `feat/tui-sandbox-permissions`
|
||||
2. Gnanam: `feat/runtime-permission-events`
|
||||
3. Anandan: `feat/windows-sandbox-platform`
|
||||
4. Anandan: `feat/update-release-verification`
|
||||
5. Vasanth: `feat/tui-command-polish`
|
||||
6. Gnanam: `feat/session-rewind-compaction-contract`
|
||||
|
||||
Recommended first parallel set:
|
||||
|
||||
- Vasanth starts the TUI permission prompt and block display.
|
||||
- Gnanam defines the permission event/grant contract.
|
||||
- Anandan owns Windows/platform sandbox reporting and tests.
|
||||
|
||||
## 9. Command Ownership Matrix
|
||||
|
||||
| Command Area | Vasanth | Gnanam | Anandan |
|
||||
| --- | --- | --- | --- |
|
||||
| `exec` | TUI and interactive UX | Runtime execution contract | Package smoke and platform validation |
|
||||
| `sandbox` | Prompt and block UX | Policy, grants, and events | Platform adapters and Windows behavior |
|
||||
| `update` | Display and user messaging | Config and redacted errors | Primary owner of update verification |
|
||||
| `verify` | Result rendering | Verification backend contract | CI and release integration |
|
||||
| `changes` | TUI rendering | Change detection backend | PR automation integration |
|
||||
| `mcp`, `hooks`, `plugins`, `serve` | Discoverability and UI | Backend lifecycle and contracts | Automation/docs after behavior exists |
|
||||
| `sessions`, `search` | Resume/search UX | Storage, query, and event model | Stream-json and automation consumers |
|
||||
|
||||
## 10. Definition Of Done
|
||||
|
||||
For every code PR:
|
||||
|
||||
- Add or update tests for the changed behavior.
|
||||
- Run `go test ./...` when Go files changed.
|
||||
- Run the relevant build command when CLI entrypoints changed.
|
||||
- Run package smoke checks when install/update/npm wrapper files changed.
|
||||
- Include manual CLI output or screenshots only when they prove a user-facing flow.
|
||||
|
||||
For docs-only PRs:
|
||||
|
||||
- State clearly that no runtime behavior changed.
|
||||
- Do not use docs-only PRs as a substitute for platform product work.
|
||||
|
||||
For Anandan's PRs specifically:
|
||||
|
||||
- Must include one of:
|
||||
- user-runnable command behavior
|
||||
- install/update/release artifact verification
|
||||
- platform adapter behavior
|
||||
- CI that executes real installed Zero commands
|
||||
- Docs/CI can support the PR, but cannot be the entire value.
|
||||
|
||||
## 11. Review Rotation
|
||||
|
||||
- Vasanth PRs:
|
||||
- Gnanam reviews runtime contracts.
|
||||
- Anandan reviews platform/release impact when touched.
|
||||
- Gnanam PRs:
|
||||
- Vasanth reviews user-facing contract impact.
|
||||
- Anandan reviews platform/protocol impact when touched.
|
||||
- Anandan PRs:
|
||||
- Vasanth reviews user-facing CLI behavior.
|
||||
- Gnanam reviews runtime contract and safety impact.
|
||||
|
||||
## 12. Retired Historical Plan
|
||||
|
||||
The old M0-M6 TypeScript-to-Go migration schedule is now historical.
|
||||
|
||||
Do not assign new work from the old migration next-steps section. The Go migration foundation has already landed on main. Future work should move Zero from "ported" to "mature terminal coding agent."
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
@@ -1,208 +0,0 @@
|
||||
# Deep-audit status ledger (re-verified against HEAD on branch fix/audit-batch, 2026-06-13)
|
||||
|
||||
Total verified: 175 of 175 extracted findings
|
||||
|
||||
| Status | Count |
|
||||
|---|---|
|
||||
| fixed | 97 |
|
||||
| open | 66 |
|
||||
| partial | 12 |
|
||||
| obsolete | 0 |
|
||||
|
||||
<!--
|
||||
Counts are the post-re-verification view: of the 87 rows the body still marks
|
||||
open/partial (69 open + 18 partial), the re-verification found 9 already fixed on
|
||||
`main` (3 open + 6 partial; listed in 2026-06-13-reverification.md), leaving 66
|
||||
open + 12 partial = 78 genuinely pending, and fixed 88 + 9 = 97. The per-finding
|
||||
rows below retain their original verdicts pending a row-level reclassification pass.
|
||||
-->
|
||||
|
||||
|
||||
> **Authoritative re-verification (2026-06-13, branch `fix/audit-batch` off current `main`).**
|
||||
> All 87 rows the 06-11 snapshot marked `open`/`partial` were re-checked against the ACTUAL
|
||||
> current code by independent agents (the 06-11 ledger was verified on `fix/audit-critical-high`,
|
||||
> which has since diverged). Every `already_fixed` verdict was adversarially re-checked by a
|
||||
> second agent trying to prove the defect was still present — none were refuted.
|
||||
> **Result: 78 genuinely pending** (66 still_open + 12 partial); **9 rows were already fixed on
|
||||
> `main`** and are reclassified to `fixed` here. Full per-finding table:
|
||||
> [`2026-06-13-reverification.md`](2026-06-13-reverification.md).
|
||||
> Pending by severity: **high 2, medium 42, low 34**. Counts above reflect this re-verification
|
||||
> plus this branch's own fixes (scanner.go:81, compaction.go:58, worktrees.go:247).
|
||||
|
||||
| Sev | Status | Location | Evidence |
|
||||
|---|---|---|---|
|
||||
| critical | fixed | internal/search/search.go:346 | search.go:363-428 findMatch maps lowered offsets back via lowerWithOffsets + snapRuneStart clamps context; /tmp probe of audit repro (100x'Ⱥ'+' hello') returns match {201 206}, valid UTF-8, no panic |
|
||||
| critical | fixed | internal/mcp/protocol.go:69 | 82b9c8d: const maxMessageBytes=64MiB (protocol.go:18) bounds both framing paths — Content-Length values above the cap are rejected (:90-92) and the newline-delimited readLine errors past the cap (:78-80); stdio transport switched to newline-delimited JSON. Covered by protocol_test.go; go test green |
|
||||
| high | fixed | internal/cli/usage.go:121 | usage.go:123-131: diff defaults to zerogit.DiffStat{}; resolveWorkspaceRoot/inspectChanges errors are ignored (degrade to net-LOC 0) instead of returning |
|
||||
| high | fixed | internal/tui/transcript.go:137 | transcript.go:121-142 keys are run-scoped ("%d:%d:%s" with row.runID) plus effectiveToolRowID ordinal suffix; model.go:1501/1553/1572/1584 set runID on rows |
|
||||
| high | fixed | internal/tui/model.go:853 | model.go:1034-1043 commandExit now calls cancelRun(), sets exiting, and defers tea.Quit while flushRunIDs is non-empty; deferred quit fires after flush at model.go:645-647 |
|
||||
| high | fixed | internal/tui/model.go:599 | internal/tui/flush.go:11-30 settled-row flush frontier prints history to native scrollback via tea.Println; model.go transcriptView() renders only live tail from m.flushed; covered by internal/tui/flush_test.go |
|
||||
| high | fixed | internal/tui/model.go:512 | model.go:614,633-637 flush goes to flushSessionID recorded at cancel time (model.go:1334) via appendSessionEventsTo (tui/session.go:76), not the active session |
|
||||
| high | fixed | internal/tui/session_controls.go:176 | session_controls.go:182-184 blocks /rewind while len(m.flushRunIDs) > 0 ('cannot rewind while a cancelled run is still flushing'); covered by tui/session_test.go |
|
||||
| high | fixed | internal/tui/model.go:584 | flush.go:71-120 settles rows into native scrollback via tea.Println; View()/transcriptView renders only rows from m.flushed (model.go:751-784) |
|
||||
| high | fixed | internal/tui/transcript.go:137 | transcript.go:121-141 bakes runID into every key; within-run repeats get ordinal suffix via effectiveToolRowID (transcript.go:148, used at model.go:1493-1496 with callSeq) |
|
||||
| high | fixed | internal/tui/rendering.go:525 | live cap 16 kept for tidy tail only; flushed scrollback cards use flushCardBodyMaxLines=400 (rendering.go:154-158) and Ctrl+O detailed view is uncapped (renderRowDetailed bodyCap:0, rendering.go:165; capCardLines treats <=0 as no cap, rendering.go:718-720) |
|
||||
| high | fixed | internal/tui/transcript.go:135 | transcript.go:121-141 transcriptRowKey bakes runID into every key ("%d:%d:%s"); model.go:1501/1553 set row.runID and effectiveToolRowID suffixes within-run repeats |
|
||||
| high | fixed | internal/tui/model.go:512 | model.go:614-637 — flushRunIDs maps cancelled run -> session id captured at cancel time (model.go:1334); flush goes via appendSessionEventsTo(flushSessionID,…) (session.go:76) when active session differs |
|
||||
| high | fixed | internal/tui/run.go:26 | flush.go:71-120 settled-row frontier prints history to native scrollback via tea.Println; model.go:769 View renders only the unflushed live tail |
|
||||
| high | fixed | internal/tui/transcript.go:139 | transcript.go:125 key is now "%d:%d:%s" (kind:runID:id) for all keyed kinds; transcript.go:148 effectiveToolRowID adds per-run ordinal suffix for within-run repeats |
|
||||
| high | fixed | internal/tui/model.go:311 | model.go:355-360 Ctrl+C sets exiting and returns nil (no Quit) while flushRunIDs non-empty; deferred tea.Quit fires at model.go:645-647 once the cancelled run's session events flush |
|
||||
| high | fixed | internal/tui/model.go:853 | model.go:1034-1043 commandExit now calls cancelRun(), sets m.exiting, and defers tea.Quit while len(m.flushRunIDs) > 0 — same protection as Ctrl+C |
|
||||
| high | fixed | internal/cli/extensions.go:186 | internal/cli/extensions.go:191 passes Servers through redactMCPServerConfigs (:205-224) which replaces every env/header value with [REDACTED]; asserted by extensions_test.go:108 (go test ok) |
|
||||
| high | fixed | internal/mcp/protocol.go:42 | 82b9c8d: read() now accepts newline-delimited JSON frames (isJSONStart→decodeMessage) and only falls back to Content-Length headers for back-compat; write() emits one JSON message per line. Covered by protocol_test.go; go test green |
|
||||
| high | fixed | internal/streamjson/streamjson.go:310 | 3142de6: secretPatterns now anchored — sk keys require a word boundary plus a 16-char body and bearer requires a 16-char token — so 'task-list' and prose like 'bearer of bad news' are no longer redacted while real keys/tokens still are. Covered by streamjson_test.go |
|
||||
| high | fixed | internal/cli/cron.go:112 | 7a47059: cronAdd now consumes an explicit positional expr (cron.go:113-118) BEFORE applying recipe defaults, so a schedule given alongside --recipe is honored; extra positionals error. Covered by cron_test.go |
|
||||
| high | fixed | internal/providerhealth/providerhealth.go:285 | 600057d: default probe now uses newConnectivityClient — CheckRedirect re-validates every hop (capped at 5) and DialContext validates+pins the resolved IP, dialing the IP literal to close the DNS-rebinding TOCTOU. Covered by connectivity_client_test.go |
|
||||
| high | fixed | internal/sessions/store.go:547 | 12a3f62 + 8c75074: ReadEvents now drops a torn final record (last non-empty line) only when the file lacks a trailing newline (tornTailPossible); a fully-flushed corrupt line, or any malformed earlier line, still fails loudly. Covered by store_test.go |
|
||||
| high | partial | internal/cli/cron_run.go:173 | 7a47059: fireJob now re-reads the job before persisting and preserves an external pause/removal mid-run (cron_run.go:170-181), closing the pause-clobber. But internal/cron/store.go still has no flock/CAS, so the concurrent-scheduler race (two schedulers both seeing NextRunAt due) remains — single-scheduler is the supported model |
|
||||
| high | fixed | internal/tools/bash.go:104 | 0dbb5e0: hardenProcessLifetime (bash_proc_unix.go) sets Setpgid, a Cancel that SIGKILLs the whole process group (negative pid), and a WaitDelay backstop so a backgrounded child cannot outlive the timeout or hang Run(); Windows sets WaitDelay only. Covered by bash_tool_test.go |
|
||||
| high | fixed | internal/sandbox/runner.go:262 | 2eeaea7: sandbox-exec profile write rule now also allows the standard device nodes (/dev/null, …) and temp trees (/tmp, /private/var/folders, …) for parity with bubblewrap's --dev/--tmpfs; the workspace stays the only writable project location. Covered by runner_test.go |
|
||||
| high | fixed | internal/tools/apply_patch.go:161 | b6182bf: patch headers are now parsed with hunk line-counting (git-apply semantics), so a hunk-body line like '-- /etc/old' is no longer mistaken for a file header and a valid patch is accepted. Covered by write_tools_test.go |
|
||||
| high | fixed | internal/tools/grep.go:224 | b6182bf: glob now matches against the path relative to the SEARCH directory (ripgrep semantics) via filepath.Rel(target,path); a single explicit file matches by base name. probe path=subdir glob=*.go now matches subdir/a.go. Covered by file_tools_test.go |
|
||||
| high | fixed | internal/providers/openai/types.go:3 | 46d495c (+7044d36/6664f71): chatCompletionRequest gained reasoning_effort; zeroruntime.CompletionRequest.ReasoningEffort is threaded through the agent loop and mapped per provider — OpenAI reasoning_effort, Anthropic extended thinking, Gemini thinking budget; CLI gates the value against the resolved model. Covered by openai/anthropic/gemini provider_test.go |
|
||||
| high | fixed | internal/specialist/exec.go:166 | 39a9c55: BuildArgs always prefixes the session title with the specialist name and falls back to the bare name when description is empty, so AgentName is non-empty and a description-less run resumes. Covered by specialist/exec_test.go |
|
||||
| high | fixed | internal/agent/loop.go:233 | 95a6a8e: Run now sets result.FinishReason from collected.FinishReason (and from the after-max-turns final answer); Result.Truncated()/TruncationNotice() drive an exec-writer warning and a TUI system row when a response was cut off. Covered by loop_test.go |
|
||||
| high | partial | internal/hooks/hooks.go:372 | 17a91d9: a Dispatcher that runs configured hooks and audits each via NewAuditStore now exists and is tested (dispatch.go + dispatch_test.go), closing the 'zero non-test callers' gap for Select/audit. But it has ZERO production callers — not wired into the beforeTool/afterTool tool lifecycle — and runHooks still supports only `list` (extensions.go:94); no dispatch/edit CLI surface |
|
||||
| low | fixed | internal/cli/exec_writer.go:412 | exec_writer.go:415 and :404 now call cutRuneBoundary (:420-431) which backs up to the last UTF-8 rune start before slicing; added in audit-fix commit 6fb0231 |
|
||||
| low | fixed | internal/mcp/server.go:129 | server.go:129-132 adds `case "ping": return server.writeResult(message.ID, map[string]any{})` (introduced in commit 6fb0231, present at HEAD) |
|
||||
| low | fixed | internal/search/search.go:309 | search.go:318-322: resolveSessions now returns fmt.Errorf("zero session not found: %s", sessionID) when store.Get returns nil instead of an empty success |
|
||||
| low | fixed | internal/doctor/doctor.go:134 | doctor.go:129-141: providerConfigCheck now reports apiKey presence ("set"/"not set") based on profile.APIKey != "", with comment explaining the change; doctor tests pass |
|
||||
| low | fixed | internal/tui/transcript.go:303 | truncateTUIOutput cuts via rune-boundary cutRunes (transcript.go:302,307-318); titles use cutRunes (session.go:112, spec_mode.go:305) and cutRuneBoundary (exec_sessions.go:90, exec_writer.go:420) |
|
||||
| low | fixed | go.mod:10 | go.mod:7-12 direct requires are now only bubbles/bubbletea/lipgloss/x-sys; glamour is gone entirely and x/ansi (line 18) and termenv (line 31) are demoted to // indirect |
|
||||
| low | fixed | internal/tui/model.go:525 | agentResponseMsg completion path now invokes m.runCancel() before nil-ing it (model.go:651-658, with explanatory comment about the per-prompt leak) |
|
||||
| low | fixed | internal/imageinput/imageinput.go:58 | imageinput.go:56-66 os.Open failure now wraps real cause ("cannot open image %s: %w") and read failure "cannot read image %s: %w"; only a stat miss reports "not found" (:38) |
|
||||
| low | fixed | internal/tui/session.go:93 | All three paths rune-safe: internal/tui/session.go:112 cutRunes(..., tuiSessionTitleLimit), internal/tui/spec_mode.go:305 cutRunes, internal/cli/exec_sessions.go:90 cutRuneBoundary(title, 80) |
|
||||
| low | fixed | internal/cli/exec_writer.go:412 | internal/cli/exec_writer.go:404 (truncateForStatus) and :415 (truncateForStreamJSONOutput) both use cutRuneBoundary defined at exec_writer.go:420-431 (backs up to utf8.RuneStart) |
|
||||
| low | fixed | internal/sessions/replay.go:237 | replay.go:245-256 cutPromptRuneBoundary (utf8.RuneStart backoff) now used at replay.go:174-177 and 238; titles rune-safe via cutRuneBoundary (cli/exec_sessions.go:90) and cutRunes (tui/session.go:112) |
|
||||
| low | fixed | internal/tui/rendering.go:68 | footerText/commandFooterText/formatCommandFooterText/defaultCommandFooterText (present at b144cf9~1 rendering.go) are absent at HEAD; dead actionAppendToolCall/ToolResult enum values also removed from transcript.go |
|
||||
| low | fixed | internal/tui/transcript.go:303 | cutRunes lands cuts on rune boundaries (transcript.go:305-318), used by truncateTUIOutput for tool-result text (transcript.go:302) and session titles (session.go:112, spec_mode.go:305) |
|
||||
| low | fixed | internal/tui/model.go:853 | commandExit cancels the run, sets exiting, and defers quit while flushRunIDs is non-empty (model.go:1034-1043); deferred tea.Quit fires when the last flush drains (model.go:645-647), and new prompts are gated while exiting (model.go:1012-1014) |
|
||||
| low | fixed | internal/tui/session.go:93 | cutRunes (transcript.go:307-318) cuts on rune boundaries; used by tuiSessionTitle (session.go:112) and truncateTUIOutput (transcript.go:302) for tool-result text |
|
||||
| low | fixed | internal/tui/rendering.go:482 | rendering.go:526 — permission card action row now renders "[esc] cancel run", matching the KeyEsc fall-through to m.cancelRun() (model.go:391-393) |
|
||||
| low | fixed | internal/cli/update.go:37 | internal/update/update.go:136-142: normalizeVersionTag failure now falls back to currentVersion="0.0.0" so the check proceeds to the network call (landed in #158) |
|
||||
| low | fixed | internal/notify/notify.go:134 | notify.go:137 now drops `(r >= 0x80 && r <= 0x9f)` with comment that C1 includes single-byte CSI/OSC/ST introducers (landed in #158) |
|
||||
| low | fixed | internal/tui/model.go:512 | model.go:616-624 the flushRunIDs (cancelled-run) branch of agentResponseMsg now records each usage event via recordUsageEvent → m.usageTracker.Record (session_controls.go:294) |
|
||||
| low | fixed | internal/tui/rendering.go:490 | rendering.go:560-564 the ask card now echoes the in-progress answer: "❯ " + fill(zeroTheme.ink).Render(input) + accent cursor, appended to the card lines |
|
||||
| low | fixed | internal/tui/view.go:227 | view.go:230-235 now matches `path == home` or strings.HasPrefix(path, home+os.PathSeparator) — a path-boundary check with a comment citing the /Users/alice2 sibling case |
|
||||
| low | fixed | internal/sandbox/risk.go:53 | defect not present: risk.go:11-50 patterns are package-level regexp.MustCompile compiled once at init (identical at audit commit b144cf9); per call only MatchString runs |
|
||||
| low | fixed | internal/modelregistry/catalog.go:36 | catalog.go:36 claude-haiku-3.5 extra capabilities now []ModelCapability{ModelCapabilityPromptCache} — ModelCapabilityVision removed (base withBaseCapabilities adds no vision), so SupportsVision is false |
|
||||
| low | fixed | internal/providers/providerio/retry.go:107 | retry.go:108-110 ShouldRetryStatus now returns true for `code == 529` alongside 429/503 (doc comment lines 99-107 updated to cover Anthropic overloaded), consistent with ClassifiedError's 529 rate-limit classification at providerio.go:224 |
|
||||
| low | fixed | internal/tui/spec_mode.go:301 | spec_mode.go:305 and session.go:112 now use cutRunes (transcript.go:307-318 backs off to utf8.RuneStart boundary); no byte-index title slicing remains |
|
||||
| low | fixed | internal/agent/system_prompt.go:89 | system_prompt.go:102-107 now walks cut back with `for cut > 0 && !utf8.RuneStart(content[cut])` before slicing, so truncation lands on a rune boundary (matches audit doc's rune-safe truncation fix claim) |
|
||||
| low | fixed | internal/config/writer.go:53 | internal/config/writer.go:55-74 — writeConfigFile now does os.CreateTemp + Chmod(0600) + write + os.Rename (atomic temp+rename, with explanatory comment at :53); go test ./internal/config -run Upsert passes |
|
||||
| low | fixed | internal/cli/exec_sessions.go:88 | exec_sessions.go:87-91: title = cutRuneBoundary(title, 80); helper (exec_writer.go:420-431) verified rune-safe by /tmp probe |
|
||||
| low | fixed | internal/cli/exec_writer.go:412 | exec_writer.go:404 (truncateForStatus/stderr) and :415 (truncateForStreamJSONOutput) both use cutRuneBoundary, which backs up to a utf8.RuneStart boundary |
|
||||
| low | open | internal/streamjson/streamjson.go:30 | repo-wide grep finds EventRestore only at its declaration (streamjson.go:30) — no emitter and not even test references; streamjson.go untouched by 6fb0231 or later merges |
|
||||
| low | open | internal/cli/cron_run.go:150 | cron_run.go:145-151: rec.Error still set only from errBuf on code!=0; outBuf (where stream-json error events land) is captured but never read |
|
||||
| low | open | internal/cli/exec_sessions.go:55 | exec_sessions.go:55 still constructs sessions.NewStore(sessions.StoreOptions{}) directly, and exec.go:375-389 PrepareExecOptions still omits the Store field; deps.newSessionStore is used by spec/usage/observability but not the exec run path |
|
||||
| low | fixed | internal/secrets/scanner.go:35 | [reverified 2026-06-13] already fixed on main: scanner.go:37 pattern is now `sk-[A-Za-z0-9_-]{20,}` (allows -/_ so sk-proj-/sk-svcacct- match), with comment at :35-36. Covered by scanner_test.go TestScanDetectsModernPrefixedOpenAIKeys |
|
||||
| low | open | internal/redaction/redaction.go:222 | redaction.go:221-235 pointer/map ptr added to context.seen and never deleted after subtree recursion, so a second (sibling) visit returns CircularReference |
|
||||
| low | open | internal/redaction/redaction.go:174 | redaction.go:174 `interface{ StackTrace() fmt.Stringer }` unchanged; grep finds no implementer in the module and go.mod has no pkg/errors or similar dep |
|
||||
| low | open | internal/zerogit/zerogit.go:417 | zerogit.go:418-420 fallback EnvRunner adapter still discards env (`_ []string`), so stagedSnapshotDiff's GIT_INDEX_FILE isolation is lost with a caller-supplied Runner |
|
||||
| low | open | internal/tui/commands_test.go:46 | internal/tui/commands_test.go:46 still defines commandTestStringSliceContains; repo-wide grep finds no callers, only the definition |
|
||||
| low | open | internal/tui/session_test.go:215 | internal/tui/session_test.go:215 'runtimeMessages := []tea.Msg{}' appended in RuntimeMessageSink at line 226 but never read in the test body (only runtimeMessageCh is consumed) |
|
||||
| low | open | internal/sessions/store.go:555 | store.go:554-556 timestamp() still time.RFC3339 (second precision); List ties broken lexically by SessionID (store.go:331-336), not actual recency, so Latest() can still pick the wrong session on same-second ties |
|
||||
| low | open | internal/tui/session_controls.go:165 | session_controls.go:165 still only increments compactRequests; sole reads are its own status display (lines 266, 280) with "Backend: state: pending integration" — no compaction is ever triggered by it |
|
||||
| low | open | internal/tui/model.go:837 | model.go:1012-1014 silently drops prompt submits while m.exiting (return m, nil — no feedback row); no exiting/flushRunIDs indicator anywhere in statusLine (view.go:113-139), composerLine, or any render path |
|
||||
| low | open | internal/tui/command_center.go:17 | tui/command_center.go:17-21 passes only Now/Runtime/Provider (no UserConfig/ProjectConfig) vs cli/observability.go:64-71; empty paths warn at doctor.go:120 and :273 |
|
||||
| low | open | internal/background/process_posix.go:38 | process_posix.go:39-47 still polls signal-0 on os.FindProcess(pid) while the Wait goroutine (specialist/exec.go:653) reaps the child; line 50 Kill() can hit a recycled pid |
|
||||
| low | open | internal/tools/registry.go:108 | repo-wide grep: OnSandboxDecision only at registry.go:19,108,116 — no caller or test ever sets it |
|
||||
| low | open | internal/sandbox/grants.go:146 | grants.go unchanged since #77: Lookup trims key (grants.go:146) but readState validates raw file keys via trimming ValidateToolName (grants.go:225-227,273) and keeps the raw map key, so a whitespace-padded key validates yet never matches |
|
||||
| low | open | internal/tools/read_file.go:89 | probe: max_lines=5 -> Truncated=true with no marker beyond '(lines 1-5 of 20)' header (read_file.go:102-111); rune-width sub-claim not reproducible (ASCII strconv.Itoa padding) |
|
||||
| low | open | internal/providers/factory.go:118 | factory.go:115 still `if !explicitProvider \|\| isImplicitOpenAI(...)`; isImplicitOpenAI (factory.go:159-164) requires TrimSpace(ProviderKind)=="" && TrimSpace(Provider)=="", which contradicts explicitProvider==true (factory.go:147-157), so it can never return true at its sole call site |
|
||||
| low | open | internal/providercatalog/catalog.go:46 | catalog.go:46 Public still declared; no constructor or descriptor ever sets it (always false); the only read is the vacuous guard in catalog_test.go:105, which already existed before the audit (added in 4accec5/#141) — no production read or write |
|
||||
| low | open | internal/verify/verify.go:215 | verify.go:215 RunLoop still defined, sole caller verify_test.go:207; production loop is selfverify.Run (selfverify.go:53-74) used by cli/workflows.go |
|
||||
| low | open | internal/specialist/output_tool.go:244 | output_tool.go:244 formatTaskOutput has zero callers (prod or tests); only formatTaskOutputSummary is used (output_tool.go:154) |
|
||||
| low | open | internal/specialist/exec.go:346 | exec.go:347-350: on SetPID error calls cleanupBackgroundPromptFile (runtime.go:73 deletes file/dir) and returns err with no recordSpecialistStop/UpdateStatus while child keeps running |
|
||||
| low | open | internal/agent/loop.go:188 | helpers.go:85 sets collected.Error = ctx.Err().Error(); loop.go:189-191 returns errors.New(collected.Error) before the ctx.Err() identity return at loop.go:193-195, so errors.Is(err, context.Canceled) still fails for that path |
|
||||
| low | open | internal/agent/types.go:183 | types.go:183 OnContext field and loop.go:102-103 emit site exist, but repo-wide grep finds no "OnContext:" assignment and no MeasureContext/ContextBreakdown use in cli/tui/cmd/contextreport (only tests) |
|
||||
| low | open | internal/agent/loop.go:1072 | loop.go:1120-1122 and loop.go:1129-1131 both still assign schema["items"] = propertyToRuntimeMap(*property.Items) |
|
||||
| low | open | internal/agent/loop.go:473 | loop.go:530-531 set requestEvent.GrantMatched/Grant but requestEvent is never read after the switch (post-run event rebuilt at loop.go:558); fallback at loop.go:510-511 unreachable since buildPermissionEvent always returns ok=true under the shouldRequestPermission gate (Prompt-permission tools, loop.go:751-758 vs 877-894) |
|
||||
| low | open | internal/agent/loop.go:183 | loop.go:184-186 retry CollectStreamWithOptions still passes only OnUsage; comment at loop.go:178-183 documents the OnText omission as deliberate anti-duplication, but streaming surfaces still receive only the aborted partial text |
|
||||
| low | open | internal/zeroruntime/helpers.go:104 | helpers.go:104 still does `collected.Text += event.Content` per StreamEventText (no strings.Builder); unchanged since audit commit b144cf9 |
|
||||
| low | open | internal/zerocommands/sandbox_snapshots.go:104 | grep for SandboxPlan/Decision/Policy/Backend/Risk/Violation/Hook/Plugin/MCPServer snapshots finds no consumers outside internal/zerocommands + tests; `zero sandbox policy --json` serializes raw sandbox.Policy/Backend/BackendPlan instead (internal/cli/sandbox.go:90-105) |
|
||||
| low | open | internal/config/contracts.go:12 | internal/config/contracts.go still defines ContractGap/DefaultContractGaps/FindContractGapsByMilestone; only reference repo-wide is internal/config/contracts_test.go |
|
||||
| low | open | internal/skills/skills.go:91 | skills.Duplicates (skills.go:91) and skills.Get (skills.go:217) have zero callers outside internal/skills; cli/skills.go uses List and tools/skill.go uses Load, so collisions are still dropped without any user-facing warning (dedup is now documented/deterministic but unsurfaced) |
|
||||
| low | open | internal/plugins/plugins.go:893 | internal/plugins/plugins.go:893 allows only beforeTool/afterTool/sessionStart/sessionEnd, while internal/hooks/hooks.go:812 also accepts specialistStart/specialistStop — sets still disagree |
|
||||
| low | open | internal/config/resolver.go:136 | internal/config/resolver.go:143, :173 and :502 all gate on `MaxTurns > 0` with no error path, so a configured maxTurns of 0 or negative is still silently ignored (falls back to default 30) |
|
||||
| low | open | internal/cli/exec.go:353 | exec.go:375 PrepareExec called without Store (defaults to NewStore in sessions/exec_session.go:58), exec_sessions.go:55 direct NewStore, exec.go:396 time.Now() instead of deps.now |
|
||||
| low | open | internal/zerocommands/contracts.go:166 | contracts.go:166 APIKeySet checks only profile.APIKey, ignoring AuthHeaderValue (valid sole credential per config/types.go:49-51); rendered "not set" at cli/command_center.go:328 |
|
||||
| low | partial | internal/cli/cron.go:275 | promptExcerpt fixed via cutRuneBoundary(p,47) at cron.go:275, but cronTruncate at cron_run.go:179-184 still returns s[:max]+"…" (byte slice, can split a rune in persisted run errors) |
|
||||
| low | partial | internal/tui/options.go:28 | app.go:413 production caller still sets none of the three, but newModel now self-defaults a usage.Tracker when nil (model.go:239-242) so usage works; effort/style are settable at runtime via /effort and /style |
|
||||
| low | partial | internal/tui/rendering.go:62 | Footer system, actionAppendToolCall/Result, and dead theme tokens (selRow/statusOk/statusErr/line2) are removed, but listCommandNames/formatCommandHelpLines remain test-only (commands.go:297,306), resumeText's args!="" branch is still unreachable (command_center.go:44-54; sole caller session.go:122 passes ""), OnContext is still wired by no production caller (agent/loop.go:102), and stale footer comments persist (commands.go:254, autocomplete.go:64) |
|
||||
| low | partial | internal/zerogit/zerogit.go:206 | truncateSubject now rune-based (zerogit.go:464-472, no invalid UTF-8), but ValidateMessage still byte-based `len(firstLine) > 72` (zerogit.go:207) so valid non-ASCII subjects are rejected |
|
||||
| low | partial | internal/cli/cron.go:275 | promptExcerpt fixed (cron.go:275 uses cutRuneBoundary) but error helper cronTruncate still byte-slices: internal/cli/cron_run.go:183 'return s[:max] + "…"', used for run errors at cron_run.go:150 |
|
||||
| low | partial | internal/tui/command_center.go:44 | Footer helpers now render (rendering.go:690-700, bash/grep footers 892-905/969-971), but resumeText's args!="" branch (command_center.go:43-54) is still unreachable (sole caller session.go:122 passes "") and stale footer comments persist (autocomplete.go:64, commands.go:254) |
|
||||
| low | partial | internal/cli/cron.go:275 | promptExcerpt fixed via cutRuneBoundary (cron.go:275), but cronTruncate still byte-slices `s[:max]` at cron_run.go:182, splitting runes in run-record error text |
|
||||
| low | partial | internal/tui/rendering.go:62 | footer builders (footerText/commandFooterText/etc.) and the four theme tokens (selRow/statusOk/statusErr/line2/panel2) are fully removed (zero grep hits), but formatCommandHelpLines/listCommandNames (commands.go:297-308) remain production code referenced only by tests, and stale "footer advertises" comments persist (commands.go:254, autocomplete.go:64) |
|
||||
| medium | fixed | internal/update/update.go:135 | update.go:136-142: normalizeVersionTag error now falls back to currentVersion="0.0.0" (commented for dev builds) instead of returning the error |
|
||||
| medium | fixed | internal/tui/session.go:190 | session.go:219-227 rehydrates role "ask_user"/"ask_user_answers" rows via askUserRequestFromPayload (session.go:349) and askUserAnswersText (session.go:380) |
|
||||
| medium | fixed | internal/tui/model.go:1067 | cancelRun appends rowSystem "Run cancelled." plus any partial streamed answer (model.go:1336-1343); asserted by flush_test.go:105 |
|
||||
| medium | fixed | internal/search/search.go:345 | search.go:363-407 findMatch maps lowered offsets back via lowerWithOffsets offset table; snapRuneStart (:417) guards context slicing; tests pass |
|
||||
| medium | fixed | internal/search/search.go:108 | search.go:111-118 LoadIndex error now `result.SkippedSessions++; continue` instead of returning; SkippedSessions reported in Result (:61) |
|
||||
| medium | fixed | .github/workflows/ci.yml:31 | .github/workflows/ci.yml:31-44 adds 'Check formatting' (gofmt -l, fails on output) and 'Vet' (go vet ./...) steps; gofmt -l . is clean at HEAD |
|
||||
| medium | fixed | internal/tui/transcript.go:303 | internal/tui/transcript.go:302 truncateTUIOutput now calls cutRunes (rune-boundary cut, transcript.go:307-318); non-ASCII test TestCutRunesNeverSplitsUTF8 at flush_test.go:158 |
|
||||
| medium | fixed | internal/cli/usage.go:46 | usage.go:47-51 collectUsageData now does set.skipped++ and continue on ReadEvents failure, so a corrupt session is skipped instead of aborting |
|
||||
| medium | fixed | internal/tui/model.go:1081 | cancelRun appends partial answer then rowSystem "Run cancelled." marker (model.go:1336-1343), shared by Esc (model.go:392) and Ctrl+C (model.go:355) paths |
|
||||
| medium | fixed | internal/tui/model.go:227 | WindowSizeMsg sets m.input.Width = maxInt(20, chatWidth(msg.Width)-14) so long input scrolls with cursor visible (model.go:556-558) |
|
||||
| medium | fixed | internal/tui/view.go:394 | view.go:412-431 now requires a hunk header match or BOTH '--- ' and '+++ ' file headers; a lone '---' line no longer triggers the diff renderer |
|
||||
| medium | fixed | internal/tui/rendering.go:261 | rendering.go:286-330 preserves explicit newlines and each line's leading indent (tabs→4 spaces), continuations keep the indent; only intra-line space runs collapse |
|
||||
| medium | fixed | internal/tui/model.go:456 | WindowSizeMsg resizes composer and prints title bar at real width (model.go:553-566); flush refuses to freeze history before a real width arrives (flush.go:72-77); width/tier recomputed every render (view.go:17-19) and history is native scrollback, so only the ephemeral pre-size frame uses the 96-col default |
|
||||
| medium | fixed | internal/tui/session_controls.go:176 | session_controls.go:182-184 — handleRewindCommand returns "cannot rewind while a cancelled run is still flushing" when len(m.flushRunIDs) > 0 |
|
||||
| medium | fixed | internal/tui/session.go:190 | model.go:1461-1476 persists ask_user question payload AND ask_user_answers; session.go:218-227 rehydrates both via askUserRequestFromPayload/askUserAnswersText (session.go:349, 380) |
|
||||
| medium | fixed | internal/tui/model.go:853 | model.go:1034-1043 — commandExit calls cancelRun(), sets exiting, defers tea.Quit until flushRunIDs drains (model.go:645); spec_mode.go:25 gates /spec on m.pending \|\| m.exiting |
|
||||
| medium | fixed | internal/tui/spec_mode.go:22 | spec_mode.go:25 handleSpecCommand gates on `if m.pending \|\| m.exiting` and refuses to start, with comment citing the post-Ctrl+C flush window |
|
||||
| medium | fixed | internal/tui/model.go:1081 | model.go:1340-1343 cancelRun appends the partial streamed answer plus rowSystem "Run cancelled." to m.transcript (live), in addition to the session-log marker |
|
||||
| medium | fixed | internal/tui/model.go:610 | model.go:769 transcriptView loops only `for index := m.flushed` (live tail; settled rows flushed once to scrollback per flush.go), and rendering.go:184-192 memoizes stable row renders in the LRU staticRenderCache (render_cache.go:19) |
|
||||
| medium | fixed | internal/tui/session.go:93 | session.go:112 tuiSessionTitle uses cutRunes; transcript.go:302/307-318 truncateTUIOutput cuts on the last rune boundary at or before the byte limit |
|
||||
| medium | fixed | internal/tui/model.go:227 | model.go:558 WindowSizeMsg handler sets `m.input.Width = maxInt(20, chatWidth(msg.Width)-14)` so long input scrolls horizontally with the cursor visible |
|
||||
| medium | fixed | internal/providers/providerio/providerio.go:184 | providerio.go:142-154 onComment now forwards a keepAlive marker to the consumer, which calls resetIdle() at providerio.go:203 before the keepAlive continue (line 204-205); providerio tests pass |
|
||||
| medium | fixed | internal/modelregistry/catalog.go:27 | catalog.go:27 gpt-4.1 entry now reads ContextLimits{ContextWindow: 1_047_576, MaxOutputTokens: 32_768}, matching mini/nano |
|
||||
| medium | fixed | internal/tui/run.go:41 | run.go:42-46 now prints `zero: tui error: <err>` to os.Stderr before returning 1 |
|
||||
| medium | open | internal/mcp/client.go:302 | client.go:295-317 readLoop dispatches any id-bearing message to pending[id] without checking message.Method; grep confirms no 'ping' handling anywhere in client.go |
|
||||
| medium | open | internal/mcp/client.go:246 | client.go:246 `client.writer.write(...)` is a blocking pipe write executed while client.mu is held (:228-:255) with no ctx awareness; file unchanged since the audit (only the response wait, which predates the audit, is ctx-aware) |
|
||||
| medium | open | internal/mcp/client.go:98 | client.go:98-99 `var stderr bytes.Buffer; cmd.Stderr = &stderr` — plain unbounded buffer attached for the process lifetime, only read on initialize failure |
|
||||
| medium | open | internal/mcp/network_client.go:621 | network_client.go:603-632 decodeSSERPCMessage stops at the first non-empty 'message' event (sets found, returns false); caller at :210 errors on id mismatch instead of scanning for the matching response — unchanged since audit |
|
||||
| medium | open | internal/mcp/network_client.go:637 | network_client.go:637 `scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)`; ErrTooLong propagates via readStream->failPending (:578-:592) setting a permanent streamErr with no reconnect, so request() (:234) fails forever |
|
||||
| medium | open | internal/cli/cron_run.go:115 | cron_run.go:102-126 reconcileOverdue still reschedules any active job with NextRunAt before the current minute without firing; no RunNow/RunOnce sentinel exists in internal/cron, and the file is unchanged since pre-audit commit 0f8ea50 |
|
||||
| medium | open | internal/sessions/lineage.go:140 | lineage.go:140 tree() still calls ListChildren per node, which calls store.List() (lineage.go:74) re-reading every metadata.json from disk (store.go:312-327, no cache); file unchanged since pre-audit commits |
|
||||
| medium | open | internal/cli/exec_sessions.go:114 | exec_sessions.go:117-123 still early-returns once recorder.err is set and stores AppendEvent errors there; recorder.err is never read anywhere (exec.go/exec_spec.go check only writer.err) |
|
||||
| medium | open | internal/tui/session_controls.go:117 | m.responseStyle still only displayed (command_views.go:155); /theme //input-style render "does not have a backend setting yet" (rendering.go:47); /compact shows "state: pending integration" (session_controls.go:272); all still in commandDefinitions help/autocomplete (commands.go:189-230) |
|
||||
| medium | fixed | internal/tui/session_controls.go:42 | 46d495c/7044d36/6664f71: zeroruntime.CompletionRequest now carries ReasoningEffort, threaded from agent.Options through every request the loop builds and mapped per provider (OpenAI reasoning_effort, Anthropic extended thinking with thinking-block replay, Gemini thinkingBudget). Covered by provider tests |
|
||||
| medium | fixed | internal/secrets/scanner.go:81 | [reverified+fixed 2026-06-13, fix/audit-batch] Redact now replaces longest matches first (copies findings, sorts by len(Match) desc) so a containing PRIVATE KEY block is redacted before any nested shorter match corrupts its exact string. Covered by scanner_test.go TestRedactNestedSecretStillRemovesWholeBlock (fails pre-fix: leaks BEGIN/END header) |
|
||||
| medium | fixed | internal/redaction/redaction.go:362 | [reverified 2026-06-13] already fixed on main: urlWithCredsPattern is a package-level var (redaction.go:367) and redactURLPasswords (:369) uses it via ReplaceAllStringFunc — no per-call regexp.MustCompile remains |
|
||||
| medium | open | internal/zerogit/zerogit.go:212 | zerogit.go:223 `path := strings.TrimSpace(line[3:])` — no C-quote unescaping and no `old -> new` rename split; no -z porcelain used |
|
||||
| medium | fixed | internal/worktrees/worktrees.go:247 | [reverified+fixed 2026-06-13, fix/audit-batch] defaultRunGit now captures stdout/stderr into separate bytes.Buffers and returns CommandResult{Stdout, Stderr}; git warnings no longer pollute parsed rev-parse output and error messages get real stderr. Covered by worktrees_test.go TestDefaultRunGitSeparatesStdoutAndStderr |
|
||||
| medium | open | internal/sessions/store.go:632 | store.go:632-635 writeMetadata still os.WriteFile temp + os.Rename with no file/dir Sync; zero Sync() calls anywhere in internal/sessions; appendEventLocked (store.go:512-517) also unsynced |
|
||||
| medium | open | internal/tui/session_controls.go:235 | session_controls.go:244 returns lastCheckpoint-1 while the tool_call is persisted immediately before its checkpoint (model.go:1505 then 1527), so truncate (rewind.go:205 keeps seq<=target) retains the dangling tool_call |
|
||||
| medium | open | internal/sessions/store.go:387 | store.go:387-391 Fork re-appends every parent event (no provider_usage skip) via AppendEvent which stamps a fresh CreatedAt (store.go:495-501); BuildReport has no event-id dedupe |
|
||||
| medium | open | internal/usage/report.go:17 | report.go:17-21 usageEventPayload has only token fields and report.go:102 prices from session Metadata.ModelID, while exec.go:510-512 still persists payload["model"] under --allow-escalation — never read |
|
||||
| medium | open | internal/cli/exec_sessions.go:115 | exec_sessions.go:117-121 append still early-returns once recorder.err is set, and grep shows sessionRecorder.err is never read in exec.go (no surfacing at run end) |
|
||||
| medium | open | internal/tui/session_controls.go:117 | m.responseStyle (session_controls.go:117) is only echoed in /style and /config text (command_views.go:155); grep shows it never reaches agent.Options/system prompt (runAgentWithOptions model.go:1366-1397 sets no style) |
|
||||
| medium | open | internal/cron/schedule.go:171 | Probe at HEAD: Next("30 1 * * *", 2026-11-01 01:30 EDT) = 01:30 EST same day (repeated hour); schedule.go:171-201 guards only spring-forward gaps |
|
||||
| medium | open | internal/cli/cron.go:112 | cron.go:112-114 sets expr=r.Expr while expr is still ""; cron.go:123 only uses positional[0] if expr=="" — user's expr silently ignored, no warning |
|
||||
| medium | open | internal/background/process_posix.go:50 | process_posix.go:29/50 signal only pid (no kill(-pid)); launchBackgroundProcess (internal/specialist/exec.go:642) sets no Setpgid; Windows still tree-kills via taskkill /T (process_windows.go:12) |
|
||||
| medium | open | internal/background/manager.go:462 | manager.go:462-471 normalizeLoadedTask rewrites Status==running to error/PID=0/ExitCode=-1; loadTasks persists it to disk at manager.go:419 |
|
||||
| medium | open | internal/tui/session_controls.go:117 | session_controls.go:117 still only stores m.responseStyle; sole readers are display strings (command_views.go:155, session_controls.go:120/132) — runAgentWithOptions (model.go:1366+) never threads it into agent.Options or the system prompt, so /style still changes nothing about responses |
|
||||
| medium | open | internal/tools/registry.go:224 | registry.go:190-236 Core* sets still omit escalate_model; only exec --allow-escalation registers it (cli/exec.go:157, predates audit), TUI path (cli/app.go:449) never does |
|
||||
| medium | open | internal/sandbox/safe_command.go:138 | file unchanged since #109; splitShellSegments (safe_command.go:431) is quote-unaware — probe: `git commit -m "use top \| less"` flagged as 'less', `echo "a; vim b"` flagged as 'vim' |
|
||||
| medium | open | internal/providers/gemini/types.go:88 | gemini/types.go:88-93 usageMetadata still has only Prompt/Candidates/Thoughts/Total token counts (no cachedContentTokenCount); grep shows CachedInputTokens is set only in openai/provider.go:253 and anthropic/provider.go:274, never in gemini |
|
||||
| medium | open | internal/providers/openai/tool_state.go:95 | tool_state.go:95-100 closeOpen still emits StreamEventToolCallDropped for calls with empty id (and applyDelta:58 never streams them); zeroruntime/helpers.go:141-207 empty-ID/synthetic-key collector support remains unreachable from the adapter |
|
||||
| medium | open | internal/specialist/exec.go:150 | exec.go:150 and :195 still hardcode "--auto","high"; cli/exec_tools.go:84-85 maps high -> agent.PermissionModeUnsafe |
|
||||
| medium | open | internal/specialist/accounting.go:72 | accounting.go:31/80 check (ReadEvents) then :47/91 append under separate store locks; concurrent callers remain at exec.go:336 (onExit) and output_tool.go:150 (TaskOutput); no serializing mutex in package |
|
||||
| medium | open | internal/specialist/streamer.go:43 | streamer.go:43 still caps scanner at 1024*1024; ErrTooLong fails ParseStream -> runChildProcess err (exec.go:624-627) aborts whole run; tool_call Args still emitted untruncated (cli/exec_writer.go:99) |
|
||||
| medium | open | internal/agent/loop.go:468 | loop.go:525-528 AlwaysAllow still returns deniedPermissionResult on persistPermissionGrant error; loop.go:778-779 still errors unconditionally when options.Sandbox == nil |
|
||||
| medium | partial | internal/agent/compaction.go:58 | [reverified+fixed 2026-06-13, fix/audit-batch] estimateTokens now adds len(message.Images)*imageTokenEstimate (flat per-image cost, ~1k; raw byte size is a poor proxy for vision tiles) — Images are no longer uncounted. Covered by compaction_test.go TestEstimateTokensCountsImages. REMAINING: maybeCompact still has no tool-definition term in its estimate |
|
||||
| medium | open | internal/agent/compaction.go:376 | compaction.go:376 summarizeMessagesOnce uses zeroruntime.CollectStream with no OnUsage; comment at compaction.go:314-315 documents OnText/OnUsage are intentionally not forwarded |
|
||||
| medium | open | internal/config/resolver.go:95 | ResolvedConfig.MCP set at resolver.go:102 has no readers (grep: zero non-test uses); MCP registration uses ResolveMCP (resolver.go:109-124) which ignores options.ProviderCommand, so ZERO_PROVIDER_COMMAND MCP servers are still dropped |
|
||||
| medium | open | internal/hooks/hooks.go:503 | internal/hooks/hooks.go:503 — append still calls store.ReadEvents() (full file read + JSON parse) on every append just to compute the next Sequence |
|
||||
| medium | open | internal/cli/app.go:149 | app.go:177-190: comment says trailing non-flag args "still are" ignored; rest scanned only for --add-dir then dropped before runInteractiveTUI |
|
||||
| medium | open | internal/cli/exec.go:500 | exec.go:524-532: interrupted branch emits errorEvent/runEnd only for execOutputStreamJSON; json path prints "Interrupted." to stderr, no terminal JSON event |
|
||||
| medium | open | internal/cli/exec.go:182 | exec.go:186-193: only stream-json is special-cased; -o json falls to writeExecToolList plain text (exec_tools.go:100-110 "Tools visible to model:") |
|
||||
| medium | partial | internal/tui/transcript.go:113 | Full-slice copy removed — in-place append, unkeyed rows skip the scan (transcript.go:92-101) — but hasTranscriptRow still linearly scans all rows per keyed tool/permission/ask append (transcript.go:103-114), so tool-heavy rehydration stays O(n²) |
|
||||
| medium | partial | internal/tui/transcript.go:113 | O(n) full-slice copy removed (in-place append, transcript.go:96-100), but hasTranscriptRow (transcript.go:103-114) still linearly scans all rows per keyed (tool/permission) row and m.transcript is never pruned, so tool-heavy rehydration remains O(n^2) |
|
||||
| medium | partial | internal/tui/model.go:569 | transcript half fixed: interim text appended as non-final assistant row on tool call/error/cancel (model.go:710-715, 681-683, 1340-1342); but session log still records only FinalAnswer (model.go:1638-1644) — interim segments never persisted, so /resume loses them |
|
||||
| medium | partial | internal/tui/transcript.go:117 | re-render gone (View renders only unflushed tail, model.go:769; render_cache.go memoizes) and copy-on-append removed (transcript.go:96-100); but hasTranscriptRow still scans the whole transcript per keyed append (transcript.go:103-114) — O(n²) cumulative for tool rows |
|
||||
| medium | partial | internal/tui/transcript.go:113 | Copy fixed: transcript.go:96-100 appends in place; but hasTranscriptRow (transcript.go:103-114) still linear-scans all rows per keyed (tool/permission/ask) row, so tool-heavy /resume rehydration remains O(N²) |
|
||||
| medium | partial | internal/tui/transcript.go:117 | transcript.go:100 full-slice copy removed (in-place append; non-keyed rows now O(1) via empty-key fast path), but hasTranscriptRow transcript.go:108-113 still linear-scans all rows per tool/permission/ask append — tool-heavy appends/rehydration remain quadratic |
|
||||
| medium | partial | internal/tui/rendering.go:261 | rendering.go:286-303 now preserves explicit newlines and each line's leading indentation (code blocks survive), but rendering.go:305 still runs strings.Fields(body) per line, collapsing intra-line space runs so mid-line column alignment is still lost |
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,117 +0,0 @@
|
||||
# Audit re-verification against current `main` (2026-06-13)
|
||||
|
||||
Every pending (open/partial) row from `2026-06-10-deep-audit-status.md` was re-checked
|
||||
against the **actual current code** by an independent agent (the ledger was verified on a
|
||||
since-diverged branch). Every `already_fixed` verdict was then adversarially re-checked by a
|
||||
second agent trying to prove the defect was still present; **none were refuted** (all 9 hold).
|
||||
|
||||
| Verdict | Count |
|
||||
|---|---|
|
||||
| still_open | 66 |
|
||||
| partial | 12 |
|
||||
| already_fixed (ledger over-counted) | 9 |
|
||||
| **genuinely pending (still_open + partial)** | **78** |
|
||||
|
||||
Pending by severity: high 2, medium 42, low 34.
|
||||
|
||||
## Already fixed on `main` (reclassify to fixed) (9)
|
||||
|
||||
| Sev | Location (current) | Rationale |
|
||||
|---|---|---|
|
||||
| medium | `internal/cli/cron.go:106-128` | The original defect (recipe set `expr=r.Expr` before the positional was consumed, silently ignoring a user-supplied expr) is gone: cronAdd now consumes the positional expr first at cron.go:113-114 (`if len(positional)==1 && expr=="" { expr=positional[0] }`), and the recipe only fills expr if still empty (cron.go:122-123); extra positionals error. Confirmed by TestCronAddExplicitExprOverridesRecipe (cron_test.go:95-118). |
|
||||
| low | `internal/cli/cron_run.go:198-205` | The original gap was that cronTruncate byte-sliced `s[:max]+"…"` and could split a multi-byte rune; current cronTruncate (cron_run.go:204) now returns `cutRuneBoundary(s, max) + "…"`, and cutRuneBoundary (exec_writer.go:420-431) backs up to a utf8.RuneStart boundary, so the rune-split defect is gone. promptExcerpt (cron.go:278) was already rune-safe. |
|
||||
| low | `internal/cli/cron_run.go:198-205` | Duplicate of the same underlying issue: the cited byte-slice `return s[:max] + "…"` for run errors (used at cron_run.go:151) no longer exists; cronTruncate now calls cutRuneBoundary(s, max) which lands on a UTF-8 rune boundary, so persisted run-error text can no longer be split mid-rune. |
|
||||
| low | `internal/cli/cron_run.go:198-205` | Duplicate of the same underlying issue: cronTruncate no longer byte-slices `s[:max]`; it uses the rune-safe cutRuneBoundary helper (cron_run.go:204), with a comment explaining the cut lands on a UTF-8 rune boundary, closing the run-record invalid-UTF-8 gap. |
|
||||
| low | `internal/redaction/redaction.go:217-231 (pointer), :232-252 (map)` | The pointer case now does context.seen[ptr]=struct{}{} (line 225) then delete(context.seen, ptr) after redactReflect returns (line 230); the map case mirrors this (line 240 add, line 251 delete). Comments at lines 226-228 explicitly state the pointer is dropped after recursion so a shared non-cyclic reference reached via a sibling branch is not mistaken for a cycle, exactly the originally-described defect, which is gone. |
|
||||
| low | `internal/sandbox/grants.go:392-406` | readState now rebuilds a new `normalized` map keyed by `key := strings.TrimSpace(name)` (grants.go:394, stored at :403) instead of retaining the raw padded file key, and Lookup indexes with `strings.TrimSpace(toolName)` (grants.go:172), so a whitespace-padded file key both validates AND matches. The ledger's premise ('unchanged since #77') is stale — this normalization was added in commit 3f3de1a (#170). |
|
||||
| low | `internal/tui/session_controls.go:196-241` | handleCompactCommand no longer just increments compactRequests: when sessionCompactor or hasSessionBackedCompactor() is available it sets compactInFlight and returns tea.Batch(m.runCompact(), ...), and runCompact()->compactActiveSession() actually calls sessionStore.PlanCompaction/RecordCompaction (replay.go:145/181). The 'pending integration' status string is gone (only a negative-assertion test references it), and sessionStore is always non-nil (model.go:272-274), so real compaction is triggered. |
|
||||
| low | `internal/zerocommands/contracts.go:169` | ProviderSnapshotFromProfile now sets APIKeySet via `strings.TrimSpace(profile.APIKey) != "" \|\| strings.TrimSpace(profile.AuthHeaderValue) != ""` (line 169) with a comment (lines 166-168) explaining auth-header-only profiles must not render as 'not set'; config/types.go:51 confirms AuthHeaderValue is a valid sole credential, and command_center.go:317/334 render apiKeyState(provider.APIKeySet) off this fixed boolean. |
|
||||
| low | `internal/zerogit/zerogit.go:209` | The remaining gap in the partial finding (ValidateMessage still byte-based `len(firstLine) > 72`) is gone: line 209 now uses `utf8.RuneCountInString(firstLine) > 72` with an explanatory comment, and truncateSubject (line 466) is already rune-based, so valid non-ASCII subjects are no longer rejected. |
|
||||
|
||||
## Genuinely partial (a real gap remains) (12)
|
||||
|
||||
| Sev | Location (current) | Rationale |
|
||||
|---|---|---|
|
||||
| high | `internal/cli/cron_run.go:176-188 (re-read fix) + internal/cron/store.go:170-178 (Update, no lock)` | fireJob does re-read via store.Get and preserves an external pause (current.Status == StatusPaused at cron_run.go:186-187) and handles ErrJobNotFound/transient errors, closing the pause-clobber; but cron/store.go Update still just calls writeJob (temp+rename) with no flock/CAS anywhere in the package, so the concurrent-scheduler read-modify-write race remains exactly as the ledger noted. |
|
||||
| high | `internal/hooks/dispatch.go:112 (Dispatch); wired at internal/agent/loop.go:584,620 and constructed at internal/cli/hook_dispatch.go:51` | The primary gap is closed: the Dispatcher now has production callers and IS wired into the tool lifecycle - constructed in app.go/exec.go/exec_spec.go, assigned to agent Options.Hooks, and invoked at loop.go:584 (beforeTool, with veto via blockedByHookResult) and loop.go:620 (afterTool feedback). But the original finding's CLI-surface gap remains: runHooks in internal/cli/extensions.go:88-135 still supports only `list`/help with no dispatch/edit subcommand. |
|
||||
| medium | `internal/agent/compaction.go:280 (maybeCompact) / 83-96 (estimateTokens)` | The image-counting fix is present (estimateTokens line 92 adds len(message.Images)*imageTokenEstimate), but the remaining gap stands: maybeCompact's trigger uses estimateTokens(messages), which has no tool-definition term, and is called at loop.go:85 with no tools (the exposed tool defs are built afterward at loop.go:87). The tool-definition estimator estimateToolTokens exists only in the separate MeasureContext reporting path (context_measurement.go:53), not in the compaction trigger. |
|
||||
| medium | `internal/tui/rendering.go:292-336 (gap at :311)` | wrapPlainText now preserves explicit newlines (split on \n, :297) and each line's leading indentation (TrimLeft + reconstructed indent, :302-305), so code blocks survive. But the word loop at :311 still iterates strings.Fields(body), which collapses intra-line space runs, so mid-line column alignment within a line is still lost exactly as the finding describes. |
|
||||
| medium | `internal/tui/transcript.go:103-114` | The full-slice copy is genuinely gone (in-place append at transcript.go:100 with a comment about the old O(n²)), but hasTranscriptRow (transcript.go:108-112) still linear-scans every existing row computing transcriptRowKey per keyed tool/permission/ask append, and m.transcript is never pruned, so rehydration via appendTranscriptRow (session.go:145-146) remains O(n²). |
|
||||
| medium | `internal/tui/transcript.go:103-114` | Duplicate of the same finding: copy-on-append removed (transcript.go:96-100) but hasTranscriptRow (transcript.go:108-112) still linearly scans all rows per keyed row and the transcript is never bounded, so tool-heavy rehydration stays O(n²). |
|
||||
| medium | `internal/tui/transcript.go:103-114` | Duplicate of the transcript.go:113 finding: append is in-place (transcript.go:100), but hasTranscriptRow (transcript.go:108-112) still linear-scans all rows per keyed (tool/permission/ask) row, so tool-heavy /resume rehydration remains O(n²). |
|
||||
| medium | `internal/tui/transcript.go:103-114` | View re-render and copy-on-append are fixed (in-place append at transcript.go:100, empty-key fast path at transcript.go:105-107), but hasTranscriptRow (transcript.go:108-112) still scans the whole transcript per keyed append — O(n²) cumulative for tool rows during rehydration. |
|
||||
| medium | `internal/tui/transcript.go:103-114` | Duplicate of the transcript.go:117 finding: the full-slice copy is removed and non-keyed rows are O(1) via the empty-key fast path (transcript.go:105-107), but hasTranscriptRow (transcript.go:108-112) still linear-scans all rows per tool/permission/ask append, so those appends/rehydration remain quadratic. |
|
||||
| low | `internal/agent/loop.go:553-580, 611-616` | The main "requestEvent never read after the switch" claim is now fixed (requestEvent is passed to emitDeniedPermission/deniedPermissionResult on the denial paths at 571-572,577-578), but requestEvent.GrantMatched/Grant set at 574-575 in the AlwaysAllow success case remain dead writes (the post-run event at 611-616 is rebuilt fresh) and the fallbackPermissionEvent at 555 is still unreachable because shouldRequestPermission only fires for PermissionPrompt tools, for which buildPermissionEvent never returns ok=false (false only at 1071/1076 for non-Prompt/Allow). |
|
||||
| low | `internal/tui/commands.go:297,306; internal/tui/command_center.go:43-55; internal/agent/loop.go:105-106; internal/tui/commands.go:254` | The main fix landed: footer builders, theme tokens (selRow/statusOk/statusErr/line2/panel2), and actionAppendToolCall/Result are all gone (zero grep hits). But the cited gaps remain: listCommandNames (commands.go:297) and formatCommandHelpLines (commands.go:306) are referenced only by test files; resumeText's args!="" branch (command_center.go:45-55) is still unreachable since its sole non-test caller session.go:122 passes ""; OnContext (agent/loop.go:105-106) is emitted but has no production assignment; and the stale footer comment persists at commands.go:254. |
|
||||
| low | `internal/tui/commands.go:297,306; internal/tui/command_center.go:43-55; internal/agent/loop.go:105-106; internal/tui/commands.go:254` | Duplicate of the other rendering.go:62 row; same underlying code. Footer builders and the theme tokens are fully removed, but formatCommandHelpLines/listCommandNames (commands.go:297-308) remain production-defined yet referenced only by tests, and a stale 'footer advertises' comment persists at commands.go:254 (the autocomplete.go:64 stale comment cited in the older row is now gone, but commands.go:254 still has one). |
|
||||
|
||||
## Genuinely still open (66)
|
||||
|
||||
| Sev | Location (current) | Rationale |
|
||||
|---|---|---|
|
||||
| medium | `internal/agent/compaction.go:422 (summarizeMessagesOnce)` | summarizeMessagesOnce still calls plain zeroruntime.CollectStream (compaction.go:422), which is CollectStreamWithOptions with empty CollectOptions{} (helpers.go:78), so no OnUsage callback fires; the collected Usage is discarded and the deliberate non-forwarding is now documented at summarizeClosure (compaction.go:359-361). Compaction-call token usage is still not surfaced/tracked. |
|
||||
| medium | `internal/agent/loop.go:567-573, 928-931` | The AlwaysAllow case still calls deniedPermissionResult/emitDeniedPermission when persistPermissionGrant errors (loop.go:569-573), and persistPermissionGrant (now at 928-931) still errors unconditionally when options.Sandbox == nil, so choosing AlwaysAllow without a configured sandbox denies the already-approved call; both sub-claims persist (only relocated from the stale 778-779 reference). |
|
||||
| medium | `internal/background/manager.go:462-471 (and persistTaskLocked call at :419)` | normalizeLoadedTask still unconditionally rewrites task.Status==StatusRunning to StatusError, sets PID=0 and ExitCode=-1, and marks changed=true; loadTasks then persists this to disk via persistTaskLocked at line 419, with no process-liveness check or reconciliation. The defect is present essentially as the auditor described. |
|
||||
| medium | `internal/background/process_posix.go:29,50` | SIGTERM (line 29) and Kill() (line 50) still target only the single pid with no kill(-pid)/negative-pid group signal, and launchBackgroundProcess (specialist/exec.go:650) sets no SysProcAttr{Setpgid:true} (grep finds zero process-group setup), while Windows still tree-kills via taskkill /T /F (process_windows.go:12). All three claims hold. |
|
||||
| medium | `internal/cli/app.go:200-220` | In the `--skip-permissions-unsafe` case, the code re-splits args[1:] via splitLeadingAddDirFlags and only iterates `rest` to reject a misplaced --add-dir (lines 215-219); it then calls runInteractiveTUI with merged addDirs only, never forwarding `rest`, so any trailing non-flag args are still silently dropped. The in-code comment at lines 208-209 explicitly confirms this ('were ignored on this path... and still are'), matching the original finding verbatim. |
|
||||
| medium | `internal/cli/cron_run.go:103-128` | reconcileOverdue still reschedules any strictly-overdue active job (NextRunAt before nowMin) to sched.Next without firing it (lines 116-126); grep confirms no RunNow/RunOnce/FireNow sentinel exists in internal/cron, so the only way to fire backlog is the --catch-up flag that skips reconcileOverdue entirely. |
|
||||
| medium | `internal/cli/exec.go:191-199` | The --list-tools branch special-cases only execOutputStreamJSON (exec.go:192-194); -o json (execOutputJSON) falls through to writeExecToolList, which calls formatExecToolList and prints the plain-text "Tools visible to model:" listing (exec_tools.go:100-110) rather than any JSON output. |
|
||||
| medium | `internal/cli/exec.go:531-545` | The interrupted branch still emits writer.errorEvent/runEnd only when outputFormat == execOutputStreamJSON (exec.go:535-540); for -o json it just does fmt.Fprintln(stderr, "Interrupted.") at exec.go:542 and returns exitInterrupted, producing no terminal JSON event on stdout (unlike the provider-error path which writes type:error/type:done for json). |
|
||||
| medium | `internal/cli/exec_sessions.go:116-124` | execSessionRecorder.append still early-returns when recorder.err != nil (line 117) and latches AppendEvent failures into recorder.err (line 120); a package-wide grep shows recorder.err/sessionRecorder.err is read only inside append itself, never by exec.go or exec_spec.go (which check only writer.err), so persistence errors are silently swallowed at run end. |
|
||||
| medium | `internal/cli/exec_sessions.go:116-124` | Duplicate of the same recorder.err defect: append (lines 116-124) early-returns once recorder.err is set and stores AppendEvent errors there, but sessionRecorder.err is never read in exec.go or exec_spec.go, so a failed session append is never surfaced to the user at run end. |
|
||||
| medium | `internal/config/resolver.go:102, 110-124` | ResolvedConfig.MCP is still set at resolver.go:102 but has zero readers: every `.MCP` access in internal/cli is the config.MCPConfig type or the separate resolveMCPConfig/ResolveMCP path, and no resolveConfig caller reads resolved.MCP. MCP registration runs through ResolveMCP (resolver.go:110-124), which only merges config-file paths and Overrides and never calls LoadProviderCommand/touches options.ProviderCommand (asserted intentional by TestResolveMCPDoesNotRunProviderCommand), so ZERO_PROVIDER_COMMAND MCP servers are still dropped. |
|
||||
| medium | `internal/cron/schedule.go:171-201` | Empirically confirmed: on the 2026-11-01 US fall-back day Next("30 1 * * *") returns 01:30 EDT and then 01:30 EST again, firing the daily job twice during the repeated wall-clock hour. The Next loop and its forward-progress guard (schedule.go:182-198) only handle spring-forward non-existent instants and contain no de-duplication for the ambiguous repeated fall-back hour. |
|
||||
| medium | `internal/hooks/hooks.go:503` | AuditStore.append still calls store.ReadEvents() at line 503, which os.ReadFile's the entire JSONL audit file and json.Unmarshal's every line on every single append, solely to scan for the highest existing Sequence (lines 507-513); no cached/in-memory sequence counter was introduced, so the O(n) full re-read per append is unchanged. |
|
||||
| medium | `internal/mcp/client.go:246` | client.writer.write(...) at line 246 still executes while client.mu is held (locked at :228, unlocked at :255), and writer.write (protocol.go:148-163) does a blocking Write+Flush on the stdin pipe with no ctx awareness; only the response wait at :257 is ctx-aware, unchanged from the audit. |
|
||||
| medium | `internal/mcp/client.go:295-318` | readLoop still dispatches any id-bearing message to pending[id] (lines 302-316) with no check of message.Method; grep finds no 'ping' or Method handling anywhere in client.go, so server-initiated requests are still ignored/misrouted exactly as described. |
|
||||
| medium | `internal/mcp/client.go:98-99` | Still 'var stderr bytes.Buffer; cmd.Stderr = &stderr' — a plain unbounded buffer (no LimitReader/cap), never reset after startup, attached for the whole process lifetime and only read on initialize failure at line 114. |
|
||||
| medium | `internal/mcp/network_client.go:612-642 (decodeSSERPCMessage), caller at :219` | decodeSSERPCMessage still decodes the first non-empty 'message' SSE event, sets found=true and returns false (stops scanning) without checking the message ID; the synchronous caller networkClient.request at line 219 does `if !rpcIDMatches(message.ID, id)` and errors on mismatch instead of scanning for the matching response. Defect unchanged (caller line is now :219, was the stale :210). |
|
||||
| medium | `internal/mcp/network_client.go:646 (scanSSEEvents), failPending at :587-593, request gate at :243` | scanSSEEvents still calls scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) with the 1MiB cap; a bufio.Scanner ErrTooLong flows through readStream into failPending (:593) which sets a permanent client.streamErr, and request() returns that streamErr forever (:243-246) with no reconnect path (openStream is only called once at connect, :77). |
|
||||
| medium | `internal/providers/gemini/types.go:103-108` | The usageMetadata struct still declares only PromptTokenCount, CandidatesTokenCount, ThoughtsTokenCount, and TotalTokenCount with no cachedContentTokenCount field; provider.go:225-228 reads only those into input/output/reasoning tokens and emitDone (provider.go:282-286) builds TokenUsage without CachedInputTokens, which is set only in openai/provider.go:253 and anthropic/provider.go:330. |
|
||||
| medium | `internal/providers/openai/tool_state.go:95-101 (and applyDelta:58)` | applyDelta still returns early when call.id=="" (line 58), and closeOpen still only emits StreamEventToolCallDropped for empty-id/empty-name calls (lines 95-99) instead of streaming them with a synthetic key; every Start/Delta/End the adapter emits carries call.id, so the zeroruntime collector's empty-ID/synthetic-key support (helpers.go start/delta/end at ~173-216) is never reached from the OpenAI adapter. The provider_test still asserts such calls are dropped and never started, confirming the behavior is unchanged, and tool_state.go's last commit (4d2ce6b) predates the 2026-06-10 audit. |
|
||||
| medium | `internal/sandbox/safe_command.go:431` | splitShellSegments still uses a strings.NewReplacer that blindly turns \|, ;, &&, \|\|, $(, ), backtick into segment boundaries with zero quote awareness; a live probe confirms `git commit -m "use top \| less"` is flagged as 'less' (interactive=true) and `echo "a; vim b"` is flagged as 'vim', exactly as the original finding described. The file is unchanged since commit #109 and contains no quote-state parsing. |
|
||||
| medium | `internal/sessions/lineage.go:140` | tree() (lineage.go:128-154) still calls store.ListChildren(sessionID) at lineage.go:140 once per node, and ListChildren calls store.List() at lineage.go:74; List() (store.go:312-338) re-reads the whole sessions dir via os.ReadDir+Get->readMetadata (store.go:674-684, a fresh os.ReadFile+json.Unmarshal with no cache), so an N-node tree re-reads every metadata.json N times. The defect is present exactly as described. |
|
||||
| medium | `internal/sessions/store.go:428-432 (Fork copy loop), 526-567 (appendEventLocked); internal/usage/report.go:74-119 (BuildReport)` | Fork's loop at store.go:428-431 re-appends every parent event (no provider_usage/EventUsage skip) via AppendEvent, which stamps a fresh CreatedAt/ID in appendEventLocked (537-543), and BuildReport (report.go:74-119) aggregates all EventUsage events with no event-id dedupe, so forked usage events are double-counted. |
|
||||
| medium | `internal/sessions/store.go:686-701 (writeMetadata), 549-559 (appendEventLocked write path)` | writeMetadata still does os.WriteFile to a temp file + os.Rename (store.go:693-696) with no file or directory fsync, and a repo-wide grep finds zero Sync() calls anywhere in internal/sessions/; appendEventLocked's append write (549-557) is likewise unsynced. |
|
||||
| medium | `internal/specialist/accounting.go:80-91 (appendSpecialistUsageRollup) and :31-47 (recordSpecialistStop)` | The dedup is still a non-atomic check-then-append: specialistEventExists calls store.ReadEvents (which acquires NO lock, store.go:569) and only the later AppendEvent takes the per-session lock for its own write, so concurrent callers (exec.go:343 onExit and output_tool.go:150 TaskOutput) can both pass the existence check and each append a duplicate event. No serializing mutex spans the check and append in the specialist package, and the store's lockSession/appendEventLocked machinery is not used by the accounting path. |
|
||||
| medium | `internal/specialist/exec.go:150 (and :202)` | Both BuildArgs (:150) and BuildResumeArgs (:202) still hardcode args append "--auto","high", and internal/cli/exec_tools.go:84-85 maps "high" -> agent.PermissionModeUnsafe, so specialist children always run in unsafe permission mode. |
|
||||
| medium | `internal/specialist/streamer.go:43` | ParseStream still calls scanner.Buffer(make([]byte,0,64*1024), 1024*1024), so a line over 1MB yields bufio.ErrTooLong; exec.go:631-634 returns that error from runChildProcess aborting the whole run, and the producer at cli/exec_writer.go:99 (parseToolCallArgs) still emits tool_call Args with no length cap. All three elements of the original finding are unchanged. |
|
||||
| medium | `internal/tools/registry.go:198-245` | The Core* tool sets (CoreReadOnlyTools/CoreWriteTools/CoreShellTools at :224/CoreNetworkTools/CoreTools) still omit escalate_model; it is registered only under exec --allow-escalation (cli/exec.go:156-157), while the TUI registry built by newCoreRegistryScoped (cli/app.go:436,531-537) never registers it — app.go only mentions escalate_model in help text at :733. |
|
||||
| medium | `internal/tui/model.go:2022-2028 (session log) vs model.go:854/802/1721 (transcript)` | Interim narration is appended to the live transcript on tool call (model.go:852-857), error (801-803), and cancel (1720-1722), but the session log only persists result.FinalAnswer via EventMessage (model.go:2022-2028); onText (1781-1786) and sendAgentText (2064-2069) record no session event, so the gap the ledger flagged as 'partial' is unchanged — interim segments are still never persisted and /resume loses them. |
|
||||
| medium | `internal/tui/session_controls.go:148-177; internal/tui/model.go:1746-1778` | m.responseStyle is still only stored by handleStyleCommand (session_controls.go:156) and echoed in display strings (session_controls.go:159/171, command_views.go:275); runAgentWithOptions threads ReasoningEffort/SystemPrompt/PermissionMode into options but never references responseStyle, and agent.Options has no style field (no non-display ResponseStyle reader exists), so /style still changes nothing about responses. |
|
||||
| medium | `internal/tui/session_controls.go:148-177; internal/tui/model.go:1746-1778` | Duplicate of the responseStyle finding: m.responseStyle is set at session_controls.go:156 but runAgentWithOptions (model.go:1746-1778) never assigns it into agent.Options or the system prompt; the only readers are display strings (command_views.go:275, session_controls.go:159/171), so the style preference is inert. |
|
||||
| medium | `internal/tui/session_controls.go:148-177; internal/tui/model.go:1746-1778` | Duplicate of the responseStyle finding verified once: handleStyleCommand only stores m.responseStyle and grep shows no non-display consumer; runAgentWithOptions sets ReasoningEffort/SystemPrompt/etc. on agent.Options but never the style, so /style remains cosmetic. |
|
||||
| medium | `internal/tui/session_controls.go:318 (resolveRewindTarget); internal/sessions/rewind.go:205` | resolveRewindTarget still returns lastCheckpoint-1, while OnToolCall appends EventToolCall then the EventSessionCheckpoint right after it (model.go:1886-1911) so the tool_call gets the immediately-preceding sequence; TruncateEvents keeps ev.Sequence<=target (rewind.go:205), so the tool_call (seq==target) is retained while its checkpoint (seq>target) is dropped, leaving the dangling tool_call described in the finding. |
|
||||
| medium | `internal/usage/report.go:17-21 (struct) and :102 (pricing); writer at internal/cli/exec.go:521-524` | usageEventPayload (report.go:17-21) still has only PromptTokens/CompletionTokens/TotalTokens with no model field, and BuildReport at report.go:102 still prices via modelBySession[event.SessionID] from session Metadata.ModelID; meanwhile exec.go:521-522 still persists payload["model"]=currentModel under --allow-escalation, and a repo-wide grep confirms no consumer reads that per-event model key (the only usageEventPayload reader silently drops it on unmarshal). |
|
||||
| medium | `internal/zerogit/zerogit.go:225` | parseStatus still does `path := strings.TrimSpace(line[3:])` with no C-quote unescaping and no `old -> new` rename split, and the caller at line 119 runs `status --short --untracked-files=all` without `-z`/porcelain, so quoted or renamed paths are still mis-parsed exactly as described. |
|
||||
| low | `internal/agent/loop.go:1305-1316` | propertyToRuntimeMap still assigns schema["items"] = propertyToRuntimeMap(*property.Items) twice for the same property.Items != nil guard (lines 1305-1307 and 1314-1316); the second assignment is a redundant duplicate writing an identical value. |
|
||||
| low | `internal/agent/loop.go:189-191` | The reactive-retry CollectStreamWithOptions call still passes only OnUsage and omits OnText (comment at 183-188 documents this as deliberate anti-duplication), so streaming surfaces still receive only the aborted partial text on the retried turn exactly as the finding describes. |
|
||||
| low | `internal/agent/loop.go:194-201` | When ctx is canceled, internal/zeroruntime/helpers.go:89 sets collected.Error = ctx.Err().Error(); loop.go:194-197 returns errors.New(collected.Error) before the ctx.Err() identity return at 198-201, so the returned error no longer wraps the sentinel and errors.Is(err, context.Canceled) still fails on that path. |
|
||||
| low | `internal/agent/types.go:196 (field); internal/agent/loop.go:105-106 (emit site); internal/agent/context_measurement.go:31 (MeasureContext/ContextBreakdown)` | The OnContext callback field (types.go:196) and its emit site (loop.go:105-106 calling MeasureContext) still exist, but a repo-wide grep finds ZERO 'OnContext:' assignments anywhere (test or prod) and no use of MeasureContext/ContextBreakdown outside internal/agent tests; cli/tui/cmd and the contextreport package never reference them (contextreport only calls agent.BuildSystemPromptPreview), so the callback remains permanently a no-op exactly as the original finding described. |
|
||||
| low | `internal/background/process_posix.go:38-50` | terminateProcess still does os.FindProcess(pid) (line 24) and polls liveness via signal-0 in processAlive (lines 39-47, 59-61), while launchBackgroundProcess (specialist/exec.go:659-673) still reaps the child in a concurrent command.Wait() goroutine, so the PID can be reaped and recycled before process.Kill() at line 50 fires. Defect unchanged. |
|
||||
| low | `internal/cli/cron_run.go:146-152` | rec.Error is still set only from errBuf (cronTruncate(strings.TrimSpace(errBuf.String()),500) when code!=0); outBuf is declared and passed to exec at line 146-147 but never read anywhere in fireJob, so stream-json error events on stdout are still discarded. |
|
||||
| low | `internal/cli/exec.go:383-398 (PrepareExec call), exec.go:404 (time.Now), internal/cli/exec_sessions.go:55 (direct NewStore)` | All three sub-claims hold: the sessions.PrepareExec call at exec.go:383-398 omits the Store field so PrepareExec falls back to NewStore(StoreOptions{}) at exec_session.go:58-61; preflightExecSession still does sessions.NewStore(StoreOptions{}) directly at exec_sessions.go:55; and runExec uses streamjson.CreateRunID(time.Now()) at exec.go:404 instead of the injectable deps.now (which is available and used at exec.go:142/186). |
|
||||
| low | `internal/cli/exec_sessions.go:55` | preflightExecSession still calls sessions.NewStore(sessions.StoreOptions{}) directly at line 55, and exec.go's PrepareExec call (exec.go:383-397) omits the Store field so sessions/exec_session.go:58-60 defaults to NewStore again; deps.newSessionStore is used by spec/usage/observability/sessions paths but never by the exec run path in exec.go. |
|
||||
| low | `internal/config/contracts.go:5,12,65` | ContractGap (line 5), DefaultContractGaps (line 12), and FindContractGapsByMilestone (line 65) are all still defined, and a repo-wide grep for these symbols (plus ContractOwnerRuntime) finds references ONLY in internal/config/contracts_test.go. No production caller exists, so the dead-code defect persists exactly as described. |
|
||||
| low | `internal/config/resolver.go:144,177,506` | All three MaxTurns sites (mergeConfig :144, mergeProjectConfig :177, applyOverrides :506) still gate purely on `if src/overrides.MaxTurns > 0` with no error path, and Resolve() has no MaxTurns validation, so a configured 0 or negative maxTurns is silently dropped and falls back to defaultMaxTurns=30 (resolver.go:16,22). A negative-value error path exists for tools.deferThreshold (:57-59) but none was added for MaxTurns. |
|
||||
| low | `internal/plugins/plugins.go:894` | parseHookEvent's switch (plugins.go:894) still accepts only HookBeforeTool/HookAfterTool/HookSessionStart/HookSessionEnd and the HookEvent consts (plugins.go:39-42) omit specialist events entirely, whereas hooks.go parseEvent (hooks.go:812) accepts those four plus EventSpecialistStart/EventSpecialistStop, so the two validation sets still disagree exactly as described. |
|
||||
| low | `internal/providercatalog/catalog.go:46` | The `Public bool` field is still declared on Descriptor (catalog.go:46) but is never assigned anywhere in the codebase: there are zero `Public:` struct-literal assignments and zero `.Public =` writes. The only read remains the vacuous guard at catalog_test.go:106 (`if descriptor.Public && !descriptor.RequiresAuth`), which is dead code since Public is always false. |
|
||||
| low | `internal/providers/factory.go:115` | At the sole call site (line 115) the `\|\|` short-circuits so isImplicitOpenAI is only evaluated when explicitProvider==true, which (per explicitProviderKind at 147-157) requires ProviderKind!="" or Provider!=""; but isImplicitOpenAI (159-164) requires both ProviderKind=="" and Provider=="", so it can never return true there — the term remains dead code exactly as described. |
|
||||
| low | `internal/redaction/redaction.go:174` | Line 174 still declares `var stackTracer interface{ StackTrace() fmt.Stringer }`; a repo-wide grep finds StackTrace only at redaction.go:174 and :176 (no implementer in the module), and go.mod has no pkg/errors-style dependency, so this errors.As branch is dead code that can never match exactly as the finding described. |
|
||||
| low | `internal/sessions/store.go:615-617 (timestamp), 331-336 (List), 340-346 (Latest)` | timestamp() at store.go:616 still formats with time.RFC3339 (second precision), and List() at 332-334 breaks UpdatedAt ties lexically by SessionID, so Latest() (line 345 returns sessions[0]) can still pick the wrong session on same-second ties exactly as described. |
|
||||
| low | `internal/skills/skills.go:91 (Duplicates); consumers: internal/cli/skills.go:62, internal/plugins/activate.go:376` | skills.Duplicates and skills.Get still have zero callers outside internal/skills (Get has zero callers anywhere, even tests), and the duplicate-collision data is never surfaced: cli/skills.go runSkillsList calls skills.List with no warning, and the skill tool discards the dups at activate.go:376 with `merged, _ := MergedSkillsLoaded(...)`. Although plugins.mergeSkills now computes a parallel DuplicateName slice that MergedSkills returns, every call site drops it, so shadowed/dropped skills are still resolved silently with no user-facing warning, exactly the defect described. |
|
||||
| low | `internal/specialist/exec.go:353-358` | On manager.SetPID failure the code only calls executor.cleanupBackgroundPromptFile and returns the error; it does not call recordSpecialistStop or manager.UpdateStatus(StatusError) while the already-launched child keeps running, unlike the launch-error path at :347-352 which does record a stop. |
|
||||
| low | `internal/specialist/output_tool.go:244` | formatTaskOutput is still dead code: a codebase-wide grep finds only its own definition at :244 and no callers in prod or tests; the only used formatter is formatTaskOutputSummary, called from readOutput at :154. |
|
||||
| low | `internal/streamjson/streamjson.go:30` | Repo-wide grep for EventRestore (and the literal "restore" EventType) returns only the declaration at streamjson.go:30 — no emitter and no test reference, whereas the sibling EventCheckpoint is emitted at cli/exec_writer.go:119 and referenced in streamjson_test.go:435. The constant remains dead exactly as the original finding described. |
|
||||
| low | `internal/tools/read_file.go:98-121` | The truncation path is unchanged from main (empty git diff): when max_lines truncates, it sets Result.Truncated=true (line 120) but the only in-output signal is the '(lines 1-5 of 20)' range header (line 114) — no distinct truncation marker is appended to Output (line 119). The rune-width sub-claim remains non-reproducible because line-number padding uses ASCII digits via strconv.Itoa (lines 105-109), exactly as the original finding concluded. |
|
||||
| low | `internal/tools/registry.go:19` | Repo-wide grep finds OnSandboxDecision only at registry.go:19 (declaration), :113 (nil check), and :121 (invocation) — zero struct-literal (OnSandboxDecision:) or dot-assignment (.OnSandboxDecision =) sites anywhere in non-test or test code, so the field is still never set by any caller. |
|
||||
| low | `internal/tui/command_center.go:45-55` | resumeText's args!="" branch (command_center.go:45-55) is still dead: the only production caller, handleResumeCommand at session.go:121-122, passes "" (the non-empty case is handled separately via resolveResumeSession at session.go:125), so the branch remains unreachable; one stale 'footer advertises' comment also persists at commands.go:254 (the autocomplete.go one is gone). |
|
||||
| low | `internal/tui/command_center.go:17-24` | doctorText() still constructs doctor.Run(doctor.Options{Now, Runtime:"go", Provider}) with no UserConfig/ProjectConfig (command_center.go:18-22), unlike the CLI path at observability.go:64-71; so configFilesCheck (doctor.go:111-123) gets empty paths and emits the StatusWarn 'No explicit Zero config files were inspected.' exactly as the finding describes. |
|
||||
| low | `internal/tui/commands_test.go:46` | The function commandTestStringSliceContains is still defined at line 46, and a repo-wide grep across all *.go files returns only the definition itself with zero call sites, matching the original dead-helper finding exactly. |
|
||||
| low | `internal/tui/model.go:1363-1365` | handleSubmit still drops a prompt while m.exiting with a bare `return m, nil` and no feedback row, and no render path surfaces an exiting/flushRunIDs indicator: statusLine (view.go:137-156) shows only provider+usage, and composerLine's hint switch (model.go:1130-1134) only shows 'esc stop' when m.pending. |
|
||||
| low | `internal/tui/options.go:34,39-40 (UsageTracker/ReasoningEffort/ResponseStyle); internal/cli/app.go:484-516; internal/tui/model.go:277-279` | The exact residual gap the ledger evidence describes is still present verbatim: the production caller's tui.Options{...} block in app.go (484-516) sets none of UsageTracker, ReasoningEffort, or ResponseStyle. The mitigations cited also still hold — newModel self-defaults a usage.Tracker when options.UsageTracker is nil (model.go:277-279) and effort/style remain runtime-settable via /effort (session_controls.go:81) and /style (session_controls.go:156) — so the code matches the partial state described, with the same unset-fields gap unchanged. |
|
||||
| low | `internal/tui/session_test.go:215` | The local slice `runtimeMessages := []tea.Msg{}` (line 215) is still only appended to in the RuntimeMessageSink closure (line 226) and never read or asserted anywhere in the test body; the test exclusively consumes the channel runtimeMessageCh via receiveRuntimeMessage, leaving the slice write-only exactly as the finding described. |
|
||||
| low | `internal/verify/verify.go:215` | RunLoop is still defined at verify.go:215 and a repo-wide grep finds only two references: the definition and its sole caller verify_test.go:207. The production retry loop is selfverify.Run (selfverify.go:53), which re-implements the attempt loop calling verify.Run directly (selfverify.go:75) and is the one wired into cli/workflows.go, so RunLoop plus LoopOptions/LoopReport remain dead production code. |
|
||||
| low | `internal/zerocommands/sandbox_snapshots.go:82-104 (SandboxPlanSnapshot/SandboxDecisionSnapshot); raw serialization at internal/cli/sandbox.go:92-94,146-148` | Grep confirms SandboxPlanSnapshot/Decision/Policy/Backend/Risk/Violation snapshots are referenced only inside internal/zerocommands and its tests (no production consumer; the only new consumers in tui/command_views.go are SandboxGrantSnapshots and ProviderSnapshot, which are different types). `zero sandbox policy --json` in internal/cli/sandbox.go still builds its payload from raw zeroSandbox.Policy/Backend/BackendPlan (lines 92-94 and 146-148) instead of the snapshot constructors, exactly as the original evidence described. |
|
||||
| low | `internal/zerogit/zerogit.go:419-423` | The fallback EnvRunner adapter at lines 420-422 is still `func(ctx, dir, _ []string, args ...string)` which discards the env slice and calls `runGit(ctx, dir, args...)`, so a caller-supplied Runner with no RunGitEnv still loses stagedSnapshotDiff's GIT_INDEX_FILE isolation. |
|
||||
| low | `internal/agent/loop.go:194-201 (origin internal/zeroruntime/helpers.go:89)` | helpers.go:89 still sets collected.Error = ctx.Err().Error() (stringifying the cancellation, losing identity), and loop.go:194-196 returns errors.New(collected.Error) BEFORE the identity-preserving `return result, ctx.Err()` at loop.go:198-200, so on the stream-cancellation path the returned error is a plain string error and errors.Is(err, context.Canceled) still fails. |
|
||||
@@ -1,649 +0,0 @@
|
||||
# Zero — Deep Adversarial Code Audit (2026-06-20)
|
||||
|
||||
- **Target:** `github.com/Gitlawb/zero` @ `origin/main` commit `bfbdbb1` (a terminal coding agent: Go, Bubble Tea v2 TUI, surface-agnostic core feeding TUI / headless `exec` / MCP server / cron).
|
||||
- **Toolchain:** Go 1.25.0 (toolchain go1.26.4). 72 packages.
|
||||
- **Method:** Fresh checkout of `origin/main` (the audit-batch worktree lags main). Recon → build/vet/`-race` gates → 13-subsystem deep read (each reconciling its slice of the prior audit docs) → adversarial re-verification of every finding by independent skeptics (2 for high, 1 otherwise) that re-read guards/locks/callers to try to *disprove* it; only survivors are reported. Findings are grounded in current code with `path:line` + quoted evidence; several were empirically reproduced with throwaway tests.
|
||||
- **Scope note:** Audit only — no source was modified.
|
||||
|
||||
## 1. Executive summary
|
||||
|
||||
The tree is in markedly better shape than the 2026-06-10 audit: of ~150 prior findings, **64 are fully fixed and 14 partially fixed** in current `main` (atomic config write, durable session metadata, cross-process cron/hooks/oauth locks, MCP framing + alloc cap, SSRF DNS-rebind pinning, sandbox quote-aware interactive detection, FinishReason consumption, transcript O(n²) + per-frame re-render, UTF-8 rune-safe truncation across the board, secret-pattern anchoring, and more). Build and vet are clean; the race detector found **zero data races** across 67/68 packages.
|
||||
|
||||
The residue clusters into four recurring root causes (§5). The single most important new item is **one security finding (M10):** the provider health-probe forwards `x-api-key`/`x-goog-api-key`/custom headers across an HTTP redirect to a public host — a credential-exfil vector the recently-added DNS-rebind/internal-redirect hardening does not cover. The lone high-severity item (**H1**) is a trust/correctness gap: four of six documented, user-configurable hook events are never dispatched. The remaining mediums are durability (events.jsonl not fsync'd; PTY last-chunk drop), cron edge cases (DST double-fire, claim double-grant, `--run-now` cancelled), connection-lifecycle gaps (remote-bridge idle deadline, Shutdown can't close bridge conns, MCP SSE never reconnects), a sandbox over-block false-positive, a stream-json prose-mangling regex, and a TUI streaming O(n²).
|
||||
|
||||
### Severity rollup
|
||||
|
||||
| Severity | Count |
|
||||
|---|---|
|
||||
| Critical | 0 |
|
||||
| High | 1 |
|
||||
| Medium | 15 |
|
||||
| Low | 17 |
|
||||
| Info | 2 |
|
||||
| **Total (surviving)** | **35** |
|
||||
|
||||
Prior-audit reconciliation: **64 fixed · 14 partial · 12 still-open** (§4). Two drafted findings were dropped in adversarial review (§4.4).
|
||||
|
||||
## 2. Build / vet / test results (verbatim)
|
||||
|
||||
Run in the fresh `origin/main` checkout, Go-native only (per AGENTS.md), with test isolation `HOME`/`XDG_CONFIG_HOME` pointed at temp dirs and `CI=1`.
|
||||
|
||||
```
|
||||
$ go build ./...
|
||||
(no output) — exit 0 ✅ clean
|
||||
|
||||
$ go vet ./...
|
||||
(no output) — exit 0 ✅ clean
|
||||
|
||||
$ go test ./... -race -count=1
|
||||
ok <67 packages> ✅
|
||||
--- FAIL: TestIndependentExecCommandConstructorsShareDefaultManager (1.01s)
|
||||
exec_command_test.go:43: expected shared manager to find completed session, got ... session_id:"1000" ...
|
||||
--- FAIL: TestExecCommandReturnsSessionAndWriteStdinPollsCompletion (1.02s)
|
||||
exec_command_test.go:82: expected exit_code 0, got ... session_id:"1000" ...
|
||||
--- FAIL: TestExecCommandReturnsExitCodeWhenCommandCompletesDuringInitialYield (1.01s)
|
||||
exec_command_test.go:99: completed command must not return session_id, got ... session_id:"1000" ...
|
||||
FAIL github.com/Gitlawb/zero/internal/tools 13.664s
|
||||
exit 1
|
||||
```
|
||||
|
||||
**Race detector: 0 data races reported in any package.** The 3 `internal/tools` failures are **`-race`-only timing flakes, not product defects**: they pass cleanly 3× without `-race` (`go test ./internal/tools -run <them> -count=3` → `ok`), and fail under `-race` only because the test's fixed 10ms yield window is too short when the binary runs ~5–10× slower under race instrumentation — the command is still running when the window elapses, so the tool *correctly* returns a still-running `session_id` instead of an `exit_code`. This is a test-robustness gap (the suite is not `-race`-clean) recorded under Confidence notes (§7); it is the same exec-session timing family that recently churned in PRs #270/#273. (A genuine, separate `-race`-detectable data race in this package — `lastUsedAt` — is reported as **L15**, found by inspection because no test exercises it.)
|
||||
|
||||
## 3. Findings by severity
|
||||
|
||||
> Each finding was re-verified against current code by independent skeptics; the evidence below is what withstood that challenge. Locations are `path:line` relative to the repo root.
|
||||
|
||||
### H1 · Four of six hook events (sessionStart/sessionEnd/specialistStart/specialistStop) are validated and user-configurable but NEVER dispatched
|
||||
- **Severity / category:** high / dead-inert · **Subsystem:** hooks
|
||||
- **Location:** `internal/hooks/dispatch.go:112; internal/agent/loop.go:914,937`
|
||||
- **Evidence:**
|
||||
```
|
||||
KnownEvents() returns all six (hooks.go:888) and IsValidEvent gates `zero hooks add` (hooks_manage.go:297-298) advertising all six in help (hooks_manage.go:315 "beforeTool, afterTool, sessionStart, sessionEnd, specialistStart, specialistStop"). But the only two production call sites of Dispatcher.Dispatch are loop.go:915 (`Event: hooks.EventBeforeTool`) and loop.go:938 (`Event: hooks.EventAfterTool`). grep over internal/+cmd/ for `.Dispatch(` finds exactly those two; no dispatch ever passes EventSessionStart/EventSessionEnd/EventSpecialistStart/EventSpecialistStop (the specialist lifecycle in internal/specialist/accounting.go:38,60 emits sessions.EventSpecialistStart/Stop to the SESSION store, never to hooks.Dispatcher).
|
||||
```
|
||||
- **Impact:** Across a whole session a user (or plugin) who configures a sessionStart/sessionEnd/specialistStart/specialistStop hook — e.g. a sessionStart hook that loads secrets/env, or a sessionEnd hook that flushes/cleans up, or a specialist audit hook — gets a green `zero hooks add` and a hook that `zero hooks list` shows as enabled, yet the command is silently never executed for the entire run. This is a correctness/trust gap: lifecycle automation users believe is wired (cleanup, audit, env priming) just does not happen, with no error surfaced. This is the same class as the prior 2026-06-10 high finding 'loaded but never executed' — the beforeTool/afterTool half was fixed; the other four events remai
|
||||
- **Fix:** Either (a) actually dispatch these events from their lifecycle points: call options.Hooks.Dispatch with EventSessionStart at session/agent-run start, EventSessionEnd at run teardown, and EventSpecialistStart/Stop alongside the sessions events in internal/specialist/accounting.go; or (b) if only beforeTool/afterTool are intended to be runnable, remove the other four from KnownEvents()/IsValidEvent and the CLI help so users cannot configure dead hooks. Option (a) is preferable since the events are already a documented surface.
|
||||
|
||||
### M1 · streamjson api_key secret pattern still mangles ordinary prose/output in every stream-json event
|
||||
- **Severity / category:** medium / robustness · **Subsystem:** cli-usage-streamjson
|
||||
- **Location:** `internal/streamjson/streamjson.go:314`
|
||||
- **Evidence:**
|
||||
```
|
||||
regexp.MustCompile(`(?i)(api[_-]?key["'=:\s]+)[^"',\s)]+`) — the ["'=:\s]+ class lets a plain space follow apikey, so the next word is captured. Verified: redactString("The function apikey: foo is documented here") -> "...[REDACTED] is documented..."; redactString("The user's apiKey value spans...") -> "...apiKey [REDACTED] spans...". The sibling sk-/bearer patterns were anchored, but this one was not.
|
||||
```
|
||||
- **Impact:** FormatEvent runs redactValue->redactString over EVERY string field of EVERY emitted stream-json event (text-event Delta = model output, tool_result Output, final Text, warning/error Message). Any time the model's answer, a tool's output, a diff, a log line, or documentation contains the literal token "api_key:"/"apiKey "/"api-key=" followed by a word, a chunk of that non-secret content is silently replaced with [REDACTED] in the machine-readable protocol a downstream consumer parses. Over a session that discusses configs/auth (common for a coding agent) this corrupts arbitrary output with no warning. This is the unfinished half of prior finding M48.
|
||||
- **Fix:** Require an actual credential shape after the key marker rather than 'any next word': drop bare whitespace as a value delimiter (use `(api[_-]?key)\s*[=:]\s*["']?` for the prefix) and require a credential-length body, e.g. `[A-Za-z0-9._-]{12,}` (mirror the sk-/bearer anchoring already applied). Add a prose regression case ("the api_key: foo setting" must survive) to TestRedactStringDoesNotOverMatchProse.
|
||||
|
||||
### M2 · ResolvedConfig.MCP is computed but never read; ZERO_PROVIDER_COMMAND MCP servers are silently dropped
|
||||
- **Severity / category:** medium / correctness · **Subsystem:** config-plugins-skills
|
||||
- **Location:** `internal/config/resolver.go:106`
|
||||
- **Evidence:**
|
||||
```
|
||||
Resolve() merges provider-command output (LoadProviderCommand at resolver.go:54 + mergeConfig at :58, which calls mergeMCPConfig) and sets `MCP: cfg.MCP` (resolver.go:106). But `grep -rn 'resolved.MCP' internal/cli` returns nothing — no caller reads ResolvedConfig.MCP. Runtime MCP registration goes through the separate ResolveMCP (resolver.go:115-133), which only seeds DefaultMCPServers + merges UserConfigPath/ProjectConfigPath/Overrides.MCP and never calls LoadProviderCommand or touches options.ProviderCommand.
|
||||
```
|
||||
- **Impact:** A provider command (ZERO_PROVIDER_COMMAND) that emits an mcp.servers block has those servers folded into Resolve()'s result but the runtime registers from ResolveMCP, so provider-command-supplied MCP servers are never registered for the whole session — the user sees no MCP tools from them and no error. ResolvedConfig.MCP is also dead weight.
|
||||
- **Fix:** Have ResolveMCP also merge LoadProviderCommand(options.ProviderCommand).MCP (between the file merges and the overrides merge), or make the runtime read resolved.MCP from the single Resolve() path. Drop ResolvedConfig.MCP if it stays unread.
|
||||
|
||||
### M3 · Project-checked-in slash commands auto-load and expand-to-prompt with no provenance marker or confirmation; Command.Project is dead
|
||||
- **Severity / category:** medium / robustness · **Subsystem:** config-plugins-skills
|
||||
- **Location:** `internal/tui/user_commands.go:29`
|
||||
- **Evidence:**
|
||||
```
|
||||
model.go:473 `usercommands.Load(usercommands.DefaultPaths(cwd, userConfigDir))` auto-loads `<cwd>/.zero/commands/*.md` at TUI startup. handleUserCommand (user_commands.go:29-33) does `prompt := usercommands.Expand(cmd.Template, args)` then `m.launchPrompt(prompt)` — no confirmation, no display of whether the command is project- or user-sourced. The provenance field exists (usercommands.go:120 `Project: project`) but `grep -rn '\.Project' internal/tui internal/usercommands` shows it is only ever WRITTEN, never read. Project commands shadow user commands of the same name (Load merges project after user, byName overwrite).
|
||||
```
|
||||
- **Impact:** Opening Zero inside a cloned/untrusted repo silently loads that repo's `.zero/commands/*.md`. A repo can name a command after a common one (`/test`, `/fix`, `/pr`) so the user invoking it expands attacker-authored instructions and submits them to the model verbatim, driving the tool-enabled agent — with no indication the command body came from the repo rather than the user's own config. The Project flag built to distinguish this is never surfaced.
|
||||
- **Fix:** Surface provenance: in the command list/help and on first invocation of a project-sourced command, show it is repo-sourced (and ideally one-time confirm), using the already-present Command.Project flag; or scope auto-load of project commands behind a trust prompt like other workspace-trust gates.
|
||||
|
||||
### M4 · DST fall-back double-fire: the collapse guard is dead on the real scheduler path (sub-minute `fired`)
|
||||
- **Severity / category:** medium / correctness · **Subsystem:** cron
|
||||
- **Location:** `internal/cron/schedule.go:205`
|
||||
- **Evidence:**
|
||||
```
|
||||
Guard: `if sameWallClockMinute(t, after) && after.Truncate(time.Minute).Equal(after) { t = t.Add(time.Minute) } else { return t }`. But fireJob feeds raw wall-clock time: `fired := now()` (cron_run.go:136) then `sched.Next(fired)` (cron_run.go:190), and reconcileOverdue uses `sched.Next(now())` (cron_run.go:122) — none truncate to the minute. With real time.Now() the second clause `after.Truncate(time.Minute).Equal(after)` is false, so the collapse never engages. Reproduced: Next(2026-11-01 01:30:15 EDT) for `30 1 * * *` = 2026-11-01 01:30:00 EST (06:30 UTC) — same day, one absolute hour later.
|
||||
```
|
||||
- **Impact:** On the annual US/EU DST fall-back day, a daily job scheduled in the repeated wall-clock hour (e.g. `30 1 * * *` in a DST zone, NextRunAt stored in that location) runs twice that day: after firing at 01:30 EDT the post-exec advance computes NextRunAt=01:30 EST one hour later, and the next tick fires it again (claimFire's `current.NextRunAt.After(fired)` does not block it because the second 01:30 EST instant is not after the stored 01:30 EST). Duplicate scheduled agent run = duplicate model-token spend. The unit tests pass because they pass a minute-aligned `after`; the production caller never does.
|
||||
- **Fix:** Truncate the fire instant to the minute before computing the next slot, so the guard's precondition holds for the real caller: in fireJob set `fired := now().Truncate(time.Minute)` (or pass a truncated value into sched.Next at cron_run.go:122/190 and claimFire). Alternatively make Next collapse the fall-back repeat whenever the candidate shares wall-clock minute with `after` AND is a later absolute time, independent of after's sub-minute precision, adjusting the strictly-after contract accordingly.
|
||||
|
||||
### M5 · claimFire grants the slot to BOTH concurrent schedulers when the schedule cannot advance (invalid/exhausted spec)
|
||||
- **Severity / category:** medium / concurrency · **Subsystem:** cron
|
||||
- **Location:** `internal/cli/cron_run.go:256`
|
||||
- **Evidence:**
|
||||
```
|
||||
claimFire only advances NextRunAt when the expr parses AND Next is non-zero: `if sched, perr := cron.Parse(current.Expr); perr == nil { if nxt := sched.Next(fired); !nxt.IsZero() { current.NextRunAt = nxt } }` but unconditionally leaves `claimed := true` for an active, due job. For an unadvanceable schedule NextRunAt is left unchanged, so a second caller still sees Status==active and `!NextRunAt.After(fired)` and also returns claimed=true. Reproduced by calling claimFire twice with no fire in between: `claim1=true claim2=true`.
|
||||
```
|
||||
- **Impact:** Two concurrent schedulers (the documented `cron run --once` under system cron overlapping a forever-mode `cron run`, cron_run.go:61-63) both claim and both run `zero exec` for the same fire of a job whose Expr is impossible/exhausted (e.g. `0 0 30 2 *` stored directly per TestCronRunPausesUnadvanceableJob, a hand-edited metadata.json, or a finite spec that has run out near the 9-year search cap). Both spawn an agent before either pauses the job in its post-exec Mutate. This is the exact double-fire the claim mechanism exists to prevent; the prior concurrent test masks it only because the fake exec returns instantly so the first fire pauses the job before the second claims — a real minutes-lo
|
||||
- **Fix:** Make claimFire fail the claim for an unadvanceable schedule: if Parse errors or Next(fired) is zero, set `claimed = false` (let a single deterministic caller pick it up) or advance NextRunAt to a sentinel/pause it inside the same locked Mutate so the loser observes the change. The winner's post-exec block already pauses it; the claim just needs to not hand the same un-advanced slot to a second runner.
|
||||
|
||||
### M6 · `cron add --run-now` job is silently cancelled by forever-mode startup reconcile instead of firing
|
||||
- **Severity / category:** medium / correctness · **Subsystem:** cron
|
||||
- **Location:** `internal/cli/cron_run.go:118`
|
||||
- **Evidence:**
|
||||
```
|
||||
cronAdd --run-now sets `next = now()` (cron.go:159) and prints `next run <now>`. reconcileOverdue (run at forever-startup when !catchUp, cron_run.go:71) treats any NextRunAt before the current minute as strictly overdue and reschedules without firing: `nowMin := now().Truncate(time.Minute) ... if !j.NextRunAt.Before(nowMin) { continue } ... j.NextRunAt = nxt; store.Update(j)`. The Job struct carries no run-now marker, so the immediate request is indistinguishable from an ordinary backlog. Reproduced: --run-now job added at 08:00, `cron run` started at 08:05 → NextRunAt moved to 09:00, FireCount=0, no output.
|
||||
```
|
||||
- **Impact:** A user who runs `zero cron add ... --run-now` and then starts the foreground scheduler more than a minute later never gets the promised immediate run; the job silently waits until its next regular slot. Contradicts both the --run-now contract and the message cronAdd printed. Still open from the prior audit (docs/audit/2026-06-10-deep-audit.md:2208).
|
||||
- **Fix:** Persist a one-shot intent (e.g. a RunNow bool on Job cleared on first fire, or set NextRunAt to a future-safe sentinel that reconcile fires rather than skips). In reconcileOverdue, exempt jobs explicitly marked run-now (fire them in the first fireDue pass) instead of treating their now() NextRunAt as stale backlog.
|
||||
|
||||
### M7 · Remote bridge clears the connection deadline before the daemon handshake, so an authenticated-but-idle peer pins a connection slot forever
|
||||
- **Severity / category:** medium / resource-perf · **Subsystem:** daemon
|
||||
- **Location:** `internal/daemon/remote/bridge.go:223`
|
||||
- **Evidence:**
|
||||
```
|
||||
// Clear the handshake deadline: a session may stream...
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
...
|
||||
b.server.ServeConn(conn) // performs the daemon handshake + one command
|
||||
--- server.go:225-231 (reached via ServeConn) ---
|
||||
func (s *Server) handleConn(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
hello, err := ReadControl(conn) // no deadline
|
||||
```
|
||||
- **Impact:** The bridge bounds only the auth handshake (line 181) and then fully clears the conn deadline. After a peer passes auth it is handed to ServeConn -> handleConn, which does ReadControl(conn) for the daemon hello and again for the command with NO read deadline and no per-command timeout. A peer that completes the auth handshake (valid token) but then never sends the daemon hello (or sends hello but never the command) blocks its handler goroutine indefinitely while holding one of the b.sem slots (default MaxConnections=32). 32 such stalled connections exhaust the bridge: every other remote client is refused 'at capacity' (bridge.go:164-168) until process restart. This is an authenticated-client
|
||||
- **Fix:** Do not fully clear the deadline before ServeConn. Either keep a (larger) per-command deadline for the daemon hello+command exchange in the session path, or set a fresh deadline covering the daemon handshake and clear it only once streaming begins. Simplest: in bridge.go before ServeConn, set conn.SetDeadline(now+handshakeTimeout) for the session path and let streamToClient clear it, OR add a read deadline inside handleConn around the two ReadControl handshake calls.
|
||||
|
||||
### M8 · Server.Shutdown cannot close remote bridge connections (ServeConn bypasses trackConn), so a stalled remote handshake-read survives Shutdown
|
||||
- **Severity / category:** medium / robustness · **Subsystem:** daemon
|
||||
- **Location:** `internal/daemon/server.go:222`
|
||||
- **Evidence:**
|
||||
```
|
||||
func (s *Server) ServeConn(conn net.Conn) { s.handleConn(conn) }
|
||||
--- vs the local accept loop (server.go:136-141) ---
|
||||
s.trackConn(conn)
|
||||
s.wg.Add(1)
|
||||
go func() { defer s.wg.Done(); defer s.untrackConn(conn); s.handleConn(conn) }()
|
||||
--- Shutdown only closes tracked conns (server.go:181-183) ---
|
||||
for c := range s.conns { _ = c.Close() }
|
||||
```
|
||||
- **Impact:** The D3 fix (Shutdown closes every open connection so a handler blocked on a read returns) only covers connections accepted by the LOCAL Serve loop, which calls trackConn. The remote bridge enters via ServeConn, which calls handleConn directly and never registers the conn in s.conns. The streaming phase is still interruptible (streamToClient selects on <-s.done), but a remote connection blocked in the pre-stream ReadControl handshake (combined with the no-deadline gap above) is NOT closed by Shutdown and is NOT covered by s.wg, so it leaks until process exit and keeps occupying a bridge slot through the entire graceful-drain window. The asymmetry means the carefully-built D3/D4/D5 shutdown gu
|
||||
- **Fix:** Have ServeConn register the conn for shutdown closure too: call s.trackConn(conn)/defer s.untrackConn(conn) inside ServeConn (or expose a tracked variant the bridge uses), so Shutdown's conns-close loop unblocks a stalled remote handshake read as it does for local connections. Pair with a handshake read deadline (previous finding) for defense in depth.
|
||||
|
||||
### M9 · MCP SSE client never reconnects: any stream close permanently kills the session
|
||||
- **Severity / category:** medium / robustness · **Subsystem:** mcp
|
||||
- **Location:** `internal/mcp/network_client.go:517`
|
||||
- **Evidence:**
|
||||
```
|
||||
readStream end: `client.failPending(fmt.Errorf("MCP SSE stream closed for server %s", client.server.Name))`; failPending sets `client.streamErr = err` (line 593); request() gate: `if client.streamErr != nil { ... return err }` (lines 243-246). openStream is invoked exactly once, only from connectRemoteSSE at connect (line 77) — grep finds no other caller and no reconnect loop. registry.go connects each server once at startup and reuses the client for the whole session with no reconnect-on-error.
|
||||
```
|
||||
- **Impact:** For an SSE-transport MCP server, the persistent GET stream closing for ANY reason during a session — clean server-side EOF (the normal `MCP SSE stream closed` path), an idle/proxy timeout, a network blip, or an over-8MiB SSE line tripping bufio.ErrTooLong — sets a permanent streamErr. Every subsequent tools/call and tools/list for that server then returns that error for the rest of the process lifetime; the only recovery is restarting Zero (or `zero mcp enable` per tui hint). A long agent session that idles past a server keep-alive window silently loses all of that server's tools.
|
||||
- **Fix:** On a non-cancelled stream termination, attempt a bounded re-open: clear streamErr/endpointURL, call openStream again with backoff (e.g. mirror agent/reconnect.go's maxStreamReconnects + exponential backoff), and only set a permanent streamErr after retries are exhausted or ctx is done. Re-send `notifications/initialized` after a successful re-open so the server re-establishes session state.
|
||||
|
||||
### M10 · providerhealth probe re-sends x-api-key / x-goog-api-key / CustomHeaders to a cross-host redirect target (credential exfil to a public attacker host)
|
||||
- **Severity / category:** medium / security · **Subsystem:** oauth-providers
|
||||
- **Location:** `internal/providerhealth/providerhealth.go:430`
|
||||
- **Evidence:**
|
||||
```
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= maxConnectivityRedirects {...}; return validateEndpoint(req.Context(), req.URL.String(), resolver) } — re-validates the redirect HOST against the private/special-use blocklist but never strips auth headers. applyAuth (line 529-561) sets DefaultAuthHeader "x-api-key" (Anthropic), "x-goog-api-key" (Google), and arbitrary profile.CustomHeaders (lines 539/548/558).
|
||||
```
|
||||
- **Impact:** Go's stdlib only auto-strips Authorization/Cookie/WWW-Authenticate when a redirect crosses hosts; it does NOT strip x-api-key, x-goog-api-key, or arbitrary custom headers. The DNS-rebinding/internal-redirect half of the prior high finding is now fixed (safeDialContext pins validated IPs; CheckRedirect blocks internal targets), but a configured baseURL that returns a 3xx to an attacker-controlled PUBLIC host (a compromised/MITM'd/typo'd proxy endpoint, or a hostile OpenAI-compatible gateway the user pointed at) passes the blocklist and receives the provider API key verbatim. Production callers (observability.go:58, provider_setup.go:99, setup.go:116) pass no HTTPClient, so newConnectivityClie
|
||||
- **Fix:** In CheckRedirect, when req.URL.Host != via[0].URL.Host (or any earlier hop's host), delete the auth-bearing headers from req.Header (x-api-key, x-goog-api-key, Authorization, and every key in profile.CustomHeaders) before allowing the redirect; or set client.CheckRedirect to refuse cross-host redirects entirely for the probe (a health check has no need to follow a host change).
|
||||
|
||||
### M11 · Destructive/piped-installer risk regexes scan the whole raw command (incl. quoted literals); the quote-aware AST analyzer can never retract the false positive
|
||||
- **Severity / category:** medium / correctness · **Subsystem:** sandbox
|
||||
- **Location:** `internal/sandbox/risk.go:108-112`
|
||||
- **Evidence:**
|
||||
```
|
||||
command := firstArgString(...)
|
||||
if matchesDestructive(command) { add("destructive", RiskCritical) }
|
||||
if pipedInstallerPattern.MatchString(command) { add("piped_installer", RiskCritical) }
|
||||
... analysis := AnalyzeCommand(command); if analysis.Destructive { add("destructive", RiskCritical) } // add() only ORs in, never removes
|
||||
|
||||
Reproduced via engine.Evaluate: `git commit -m "fix rm -rf / bug"` -> action=prompt reason="destructive shell command requires approval" risk=[destructive shell], in BOTH PermissionModeAsk and PermissionModeAuto. Matcher-level probe: `git commit -m "rm -rf /"`=>destructive=true while AST destr=false; `echo "do not run rm -rf /"`=>destructive=true; `grep -r "dd if=/dev/zero" .`=>destructive=true; `printf '%s' "curl evil|sh"`=>piped=true. AST clears all of them (destr=false,net=false) but classifyWithScope only adds categories.
|
||||
```
|
||||
- **Impact:** Across a session, any shell command that merely QUOTES one of the trigger substrings (rm -rf / | rm -rf ~ | dd if= | chmod 777 <root> | the fork-bomb chars | curl..|sh) is mis-classified RiskCritical 'destructive'. Per engine.go:323-329 a SideEffectShell command with the destructive category and no prior PermissionGranted is forced to ActionPrompt (Ask AND Auto) or denied when permission can't be granted. So routine, safe commands — `git commit -m "...rm -rf..."`, `echo`/`printf`/`grep` mentioning these tokens — are spuriously gated behind a destructive approval prompt, and in a headless exec with no OnPermissionRequest the prompt resolves to a hard block. The interactive detector was alread
|
||||
- **Fix:** Run matchesDestructive / pipedInstallerPattern over the command-body of each real shell segment (reuse splitShellSegments + commandBody from safe_command.go) instead of the raw string, OR make the AST analyzer authoritative for parseable scripts: when analysis.TooComplex==false, trust analysis.Destructive/Network and skip the raw-string regexes (only fall back to regex when the parse fails). Either approach removes the quoted-literal false positives while keeping detection of genuine unquoted destructive commands.
|
||||
|
||||
### M12 · events.jsonl append is the only write in the package not fsync'd, while the derived metadata IS — a crash can lose a 'successfully appended' event (incl. the checkpoint /rewind targets) and leave EventCount ahead of the durable log
|
||||
- **Severity / category:** medium / correctness · **Subsystem:** sessions
|
||||
- **Location:** `internal/sessions/store.go:606-616`
|
||||
- **Evidence:**
|
||||
```
|
||||
file, err := os.OpenFile(store.eventsPath(sessionID), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o600)
|
||||
...
|
||||
if _, err := file.Write(append(data, '\n')); err != nil { _ = file.Close(); ... }
|
||||
if err := file.Close(); err != nil { ... } // no file.Sync(), no syncDir
|
||||
...
|
||||
session.EventCount = sequence
|
||||
if err := store.writeMetadata(session); err != nil { ... } // writeMetadata fsyncs temp + dir
|
||||
```
|
||||
- **Impact:** appendEventLocked writes the event line into the page cache (no fsync, no dir sync) and THEN durably writes metadata (writeFileSync + syncDir). On power loss / OS crash after the metadata fsync but before the events.jsonl page is flushed, the persisted metadata.EventCount=N but events.jsonl is missing line N. Across a session this silently drops the last appended event even though AppendEvent returned success: a just-recorded EventSessionCheckpoint vanishes (its blob, written via writeFileAtomicSync, IS durable but is now unreferenced, so /rewind cannot target it and the file content is orphaned), and the user's last turn (message/tool_call/usage) is lost on resume. It also inverts the seque
|
||||
- **Fix:** Make the append durable like every other write: after file.Write, call file.Sync() before Close (and ideally syncDir(sessionPath) on first create of events.jsonl). Cheapest: replace the open/append with an fsync'd append helper, e.g. f.Write(...); if err := f.Sync(); err != nil {...}; f.Close(). This restores the ordering guarantee that the log is at least as durable as the metadata derived from it.
|
||||
|
||||
### M13 · Mailbox stale-lock break compares the lock file to itself, not to the snapshot observed as stale — can delete a healthy fresh lock (split-brain writers)
|
||||
- **Severity / category:** medium / concurrency · **Subsystem:** specialist-swarm
|
||||
- **Location:** `internal/swarm/mailbox.go:371`
|
||||
- **Evidence:**
|
||||
```
|
||||
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > lockStaleAfter {
|
||||
stale, _ := os.ReadFile(lockPath)
|
||||
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == string(stale) {
|
||||
os.Remove(lockPath)
|
||||
}
|
||||
continue
|
||||
}
|
||||
```
|
||||
- **Impact:** The comment claims the break is 'conditional on the file's content being unchanged since we observed it,' but `stale` and `data` are two consecutive reads of the SAME file microseconds apart, so the equality check is essentially always true and provides no protection. If a holder crashes and leaves a stale lock, two (or more) waiters can each observe it stale and each call os.Remove(); meanwhile a third writer may legitimately re-create the lock via O_EXCL between the two removes. The second waiter's Remove then deletes that fresh, validly-held lock, after which both that holder and the waiter (on its next O_EXCL create) hold the lock simultaneously — two concurrent writers to the same per-a
|
||||
- **Fix:** Capture the lock content at Stat time and only remove if it is byte-identical AND the ModTime is unchanged at remove time; better, make the break ownership-aware like release(): read the token, then remove via an atomic rename-then-unlink or re-stat-and-verify-mtime guard. Simplest robust fix: snapshot (content, modTime) when first seen stale, sleep a short grace, re-stat, and only Remove if both still match — so a freshly rotated lock (new mtime/content) is never deleted.
|
||||
|
||||
### M14 · PTY exec session can drop its final output chunk on exit (copy goroutine unsynchronized with markDone), then the session is removed
|
||||
- **Severity / category:** medium / concurrency · **Subsystem:** tools
|
||||
- **Location:** `internal/tools/exec_command.go:515-526 (with exec_pty_linux.go:34-37)`
|
||||
- **Evidence:**
|
||||
```
|
||||
startSession's Wait goroutine: `err := command.Wait(); ... if session.cleanup != nil { session.cleanup() }; plan.Cleanup(); cancel(); session.markDone(...)`. For a TTY session the only thing copying PTY output into the buffer is the fire-and-forget goroutine in exec_pty_linux.go:34 `go func() { _, _ = io.Copy(output, master) }()`. command.Stdout/Stderr are the *os.File slave (exec starts NO copy goroutine and WaitDelay does not cover our own goroutine), so command.Wait() returns as soon as the process exits — before io.Copy has necessarily drained the master FD. markDone then closes `done`. collect (line 541-545) does a final drainString after doneClosed, but if the copy goroutine has not yet written the tail, that drain misses it; run()/RunWithOptions then call `tool.manager.remove(session.id)` (lines 465, 676) so the session is gone and no later poll can recover the lost tail.
|
||||
```
|
||||
- **Impact:** Across a session, the last lines a TTY (tty:true) command prints right before it exits can be silently dropped from the tool result the model sees — e.g. a test runner's final PASS/FAIL summary or a build's last error line — and because the exited session is immediately removed, the model cannot poll for it. Pipe-mode (tty:false) is unaffected because exec's own Wait drains its os.Pipe. Linux-only (other platforms fall back to pipe via exec_pty_fallback.go).
|
||||
- **Fix:** Make the PTY copy goroutine observable: have startPTYProcess return a `copied <-chan struct{}` (closed when io.Copy returns), and in the Wait goroutine block on it (with a bounded timeout matching bashWaitDelay) AFTER cleanup() closes master and BEFORE markDone, so all buffered PTY output is in the buffer before `done` is closed and the session is eligible for removal.
|
||||
|
||||
### M15 · Streaming live block re-parses the entire growing answer markdown on every spinner/fade tick (cumulative O(N^2) per turn)
|
||||
- **Severity / category:** medium / resource-perf · **Subsystem:** tui-render
|
||||
- **Location:** `internal/tui/model.go:2079`
|
||||
- **Evidence:**
|
||||
```
|
||||
interimBlock: `lines := renderAssistantMarkdownText(text, assistantMeasure(width), width)` where `text := strings.TrimRight(m.streamingText, "\n")`. streamingText only grows (model.go:1185 `m.streamingText += msg.delta`) and is cleared solely at turn end (1411/1522/3208). interimBlock is called from the per-frame body item closure (transcript_selection.go:245 `lines: viewLines(m.interimBlock(width))`). The spinner self-reschedules while pending (model.go:1212-1238, gated on m.pending) and the fade ticks every 150ms (streaming_fade.go:79), so the full markdown parse+wrap runs ~12x/sec.
|
||||
```
|
||||
- **Impact:** Settled transcript rows are memoized via defaultRenderCache (rendering.go:190-197), but the streaming block is NOT cached. For a long single answer (e.g. a model emitting a multi-hundred/thousand-line file or table as prose), each ~80-150ms tick re-runs renderAssistantMarkdownText over the whole accumulated buffer — fence scanning, table parsing, per-line inline wrap with lipgloss.Width. Cost per frame is O(current length); over the turn the work is O(N^2/delta). On a slow/large stream the TUI visibly chews CPU and the frame rate drops the longer the answer gets, exactly when responsiveness matters most.
|
||||
- **Fix:** Memoize the rendered streaming lines keyed by (streamingText length or a content hash, width); only re-render when streamingText or width actually changed since the last frame. The fade is applied per-line AFTER the markdown render (styleStreamingLine, model.go:2087), so the expensive markdown step can be cached while the cheap per-line color pass still runs each tick. Alternatively bound the markdown render to the visible tail (last height lines) since the viewport clips anyway.
|
||||
|
||||
### L1 · Reactive-compaction retry and max-turns final-answer call bypass streamWithReconnect (no transient-disconnect retry)
|
||||
- **Severity / category:** low / resource-perf · **Subsystem:** agent-loop
|
||||
- **Location:** `internal/agent/loop.go:205,253,518`
|
||||
- **Evidence:**
|
||||
```
|
||||
stream, err = provider.StreamCompletion(ctx, request) // reactive connect-failure retry
|
||||
retryStream, retryStreamErr := provider.StreamCompletion(ctx, retryRequest) // reactive mid-stream retry
|
||||
stream, err := provider.StreamCompletion(ctx, ...) // finalAnswerAfterMaxTurns
|
||||
```
|
||||
- **Impact:** The main turn connect goes through streamWithReconnect (2 retries with backoff on EOF/reset/502/503/timeout), but the post-compaction retry calls and the max-turns final-answer call call provider.StreamCompletion directly. A single transient upstream hiccup on exactly those calls (e.g. the final summary after a long autonomous/cron run, or the retried turn right after a context-limit compaction) fails the whole run with no reconnect, re-burning the entire session's tokens — the precise failure mode streamWithReconnect was added to prevent.
|
||||
- **Fix:** Route these three calls through streamWithReconnect(ctx, provider, request, reconnectNoticeFor(options)) as well; they are pre-content connects so no OnText duplication risk, matching the existing reconnect contract.
|
||||
|
||||
### L2 · OnContext / MeasureContext / estimateToolTokens context-utilization pipeline has no production consumer
|
||||
- **Severity / category:** low / dead-inert · **Subsystem:** agent-loop
|
||||
- **Location:** `internal/agent/loop.go:164-166`
|
||||
- **Evidence:**
|
||||
```
|
||||
if options.OnContext != nil {
|
||||
options.OnContext(MeasureContext(messages, request.Tools, options.ContextWindow))
|
||||
}
|
||||
```
|
||||
- **Impact:** Repo-wide grep shows zero `OnContext:` assignments outside agent tests (cli/tui/exec never set it), so MeasureContext and its private estimateToolTokens (a near-duplicate of compaction.go's estimateToolDefTokens) are computed for nobody in production and ship as permanently inert plumbing. Carries a maintenance hazard (two divergent tool-token estimators) and a tiny per-turn branch. Matches the prior low finding — still open.
|
||||
- **Fix:** Either wire OnContext from the TUI/exec to drive a context-utilization bar, or delete OnContext + MeasureContext + ContextBreakdown + estimateToolTokens and keep the single estimateToolDefTokens used by compaction.
|
||||
|
||||
### L3 · zero --skip-permissions-unsafe still silently drops trailing positional args (e.g. a prompt)
|
||||
- **Severity / category:** low / robustness · **Subsystem:** cli-usage-streamjson
|
||||
- **Location:** `internal/cli/app.go:234`
|
||||
- **Evidence:**
|
||||
```
|
||||
moreDirs, rest, err := splitLeadingAddDirFlags(args[1:]) ... for _, arg := range rest { if arg == "--add-dir"... { return error } } ... return runInteractiveTUI(stderr, deps, agent.PermissionModeUnsafe, append(append([]string{}, addDirs...), moreDirs...)). `rest` is iterated only to reject a misplaced --add-dir; every other trailing arg is discarded and the interactive TUI (which takes no positional prompt) is launched.
|
||||
```
|
||||
- **Impact:** `zero --skip-permissions-unsafe "fix the bug"` silently discards the prompt and drops the user into the interactive TUI with no error or warning, so a scripted/one-shot unsafe invocation appears to hang/ignore input. Documented in-code as intentional ('were ignored on this path... and still are'), but it is still a silent UX drop and unchanged since the prior audit (prior M81 / app.go:149).
|
||||
- **Fix:** If `rest` contains any non-flag tokens, fail loudly ("--skip-permissions-unsafe launches the interactive TUI and takes no prompt; use `zero --skip-permissions-unsafe exec -p ...` or pipe input") instead of discarding them, matching the loud-rejection style already used for misplaced --add-dir two lines above.
|
||||
|
||||
### L4 · usercommands frontmatter model:/agent:/mode: are parsed and documented but never applied
|
||||
- **Severity / category:** low / dead-inert · **Subsystem:** config-plugins-skills
|
||||
- **Location:** `internal/usercommands/usercommands.go:125`
|
||||
- **Evidence:**
|
||||
```
|
||||
parseCommand fills cmd.Model (usercommands.go:125) and cmd.Agent (usercommands.go:126-129 from `agent:`/`mode:`), and the package doc (lines 8-9) advertises `model:` as a real override. But the sole invocation path, handleUserCommand (tui/user_commands.go:29-33), calls launchPrompt(prompt) — and `launchPrompt(prompt string)` (model.go:3020) takes only the prompt; cmd.Model/cmd.Agent are never read by any caller (`grep` for the fields outside the struct/parse shows only the parse assignment).
|
||||
```
|
||||
- **Impact:** A user who writes `model: <x>` or `agent: <x>` in a `.zero/commands/foo.md` frontmatter, per the documented feature, gets it silently ignored — the command always runs on the current model/agent. The fields are inert surface that mislead users into thinking per-command model routing works.
|
||||
- **Fix:** Either honor cmd.Model/cmd.Agent in handleUserCommand (route the launched prompt through the model/agent switch before launchPrompt) or remove the fields and the package-doc `model:` line.
|
||||
|
||||
### L5 · Runtime skill tool discards duplicate-name collisions (no user-facing warning)
|
||||
- **Severity / category:** low / dead-inert · **Subsystem:** config-plugins-skills
|
||||
- **Location:** `internal/plugins/activate.go:376`
|
||||
- **Evidence:**
|
||||
```
|
||||
skillTool.Run does `merged, _ := MergedSkillsLoaded(tool.defaultDir, tool.pluginRoots)` (activate.go:376), discarding the DuplicateName slice that mergeSkills computes (activate.go:311). The CLI `zero skills list` path now DOES surface dups (cli/skills.go:69-81), but the agent-facing skill tool silently drops a shadowed same-named skill.
|
||||
```
|
||||
- **Impact:** When a plugin skill and a user skill (or two plugin roots) share a frontmatter name, the loser is silently dropped at agent runtime and the model/user is never told which skill body it actually got — a shadowed skill disappears with no diagnostic during a session.
|
||||
- **Fix:** When the skill tool resolves a name that had a collision, append a one-line note to the tool Result (or emit a startup warning) listing the shadowed Loser path, reusing the already-returned DuplicateName slice.
|
||||
|
||||
### L6 · pause/resume do an unlocked Get then a locked Update (non-atomic read-modify-write)
|
||||
- **Severity / category:** low / concurrency · **Subsystem:** cron
|
||||
- **Location:** `internal/cli/cron.go:215`
|
||||
- **Evidence:**
|
||||
```
|
||||
cronSetStatus: `job, err := store.Get(args[0])` (Get takes no lock, store.go:150) ... `job.Status = status; store.Update(job)` (Update takes the per-job lock, store.go:170-183). cronResume (cron.go:234-253) is the same Get-then-Update shape, overwriting the whole struct including FireCount/NextRunAt from the pre-read snapshot.
|
||||
```
|
||||
- **Impact:** If a fireJob post-exec Mutate lands between resume's unlocked Get and resume's locked Update, the fire's FireCount++ and advanced NextRunAt are clobbered by resume's stale snapshot (resume recomputes NextRunAt from now() so scheduling stays sane, but FireCount silently regresses). The pause race is benign because fireJob re-reads Status under its lock and honors an external pause; resume has no such re-read. Low frequency (user-driven), cosmetic FireCount loss, no double-fire.
|
||||
- **Fix:** Route pause/resume through store.Mutate so the read and the field update happen under the same per-job lock: read current, set Status (and for resume recompute NextRunAt from current.Expr), preserving current.FireCount, and write — instead of Get+Update on a snapshot.
|
||||
|
||||
### L7 · Single-instance lock refuses startup on PID reuse (bare-PID lock file, no identity/start-time)
|
||||
- **Severity / category:** low / robustness · **Subsystem:** daemon
|
||||
- **Location:** `internal/daemon/lock.go:58`
|
||||
- **Evidence:**
|
||||
```
|
||||
pid, perr := readPidFile(path)
|
||||
if perr == nil && pid > 0 && isAlive(pid) {
|
||||
return nil, fmt.Errorf("%w (pid %d)", ErrAlreadyRunning, pid)
|
||||
}
|
||||
```
|
||||
- **Impact:** The lock file stores only a bare PID. If a daemon dies uncleanly and the OS later recycles its exact PID for an unrelated live process, acquireLock sees isAlive(pid)==true and refuses to start with ErrAlreadyRunning, even though no zero daemon is running. The reclaim path (reclaimStaleLock, D6) is sound for the genuinely-dead case and the rename-aside race is well handled, but it never triggers here because the recycled PID reads as alive. Recovery requires manual lock removal. Low probability (exact PID reuse) and self-inflicted only after an unclean crash, but it is a real availability edge the bare-PID format cannot disambiguate.
|
||||
- **Fix:** Record more than the PID in the lock file (e.g. PID + process start time, or a daemon-specific marker / boot id) and treat a live PID as the holder only when the recorded identity matches. On mismatch, treat as stale and reclaim via the existing rename-aside path.
|
||||
|
||||
### L8 · Plugin hook event set disagrees with hooks package: plugins reject specialist events and cannot register them
|
||||
- **Severity / category:** low / robustness · **Subsystem:** hooks
|
||||
- **Location:** `internal/plugins/plugins.go:38-42,893-899; internal/plugins/activate.go:412-423`
|
||||
- **Evidence:**
|
||||
```
|
||||
hooks.go parseEvent (hooks.go:901-911) accepts six events. plugins.go HookEvent consts (lines 38-42) declare only HookBeforeTool/HookAfterTool/HookSessionStart/HookSessionEnd, and parseHookEvent (plugins.go:893-898) switches on exactly those four, rejecting specialist* with "Expected beforeTool, afterTool, sessionStart, or sessionEnd." mapHookEvent (activate.go:412-423) likewise maps only those four and returns ok=false otherwise.
|
||||
```
|
||||
- **Impact:** A plugin manifest declaring a specialistStart/specialistStop hook is rejected as a schema error even though hooks.json (user/project) accepts the same value. The two config surfaces for the identical Dispatcher have divergent valid-event sets, so plugin authors hit an inconsistent contract. Low because (per the finding above) those events do not actually fire from any surface today, so the practical effect is a confusing validation error rather than lost execution. This is the still-open prior reverification low (2026-06-13 line 97).
|
||||
- **Fix:** Once the dead events are resolved (finding above), make the two sets agree: drive plugin validation/mapping off hooks.KnownEvents()/IsValidEvent (or add HookSpecialistStart/Stop consts + cases) so a single source of truth defines which events are valid, eliminating the drift.
|
||||
|
||||
### L9 · Stdio request write blocks under client.mu with no ctx awareness
|
||||
- **Severity / category:** low / robustness · **Subsystem:** mcp
|
||||
- **Location:** `internal/mcp/client.go:282`
|
||||
- **Evidence:**
|
||||
```
|
||||
request() locks client.mu at line 264 and, still holding it, calls `client.writer.write(rpcMessage{...})` at line 282 before unlocking at 291. writer.write (protocol.go:148-163) does a blocking bufio Write+Flush to the child's stdin pipe with no context. Only the response wait (select at 293) is ctx-aware.
|
||||
```
|
||||
- **Impact:** If a misbehaving child stops draining its stdin, the OS pipe buffer (~64 KiB) fills and write() blocks while holding client.mu, serializing and stalling every other in-flight/queued request on that server with no ctx-cancel escape. Mitigated because Close() uses a separate closeMu (not mu) and closes stdin, which unblocks the stuck write — so it is recoverable rather than a hard deadlock; impact is limited to a pathological non-reading child.
|
||||
- **Fix:** Keep id allocation + pending registration under mu but perform the write outside mu (or write under a dedicated writeMu), and bound it: run the write in a goroutine and select on ctx.Done() so a stalled pipe surfaces ctx.Err() to the caller instead of blocking peers.
|
||||
|
||||
### L10 · Child stderr capture keeps the head, dropping the likely-relevant tail on crash
|
||||
- **Severity / category:** low / robustness · **Subsystem:** mcp
|
||||
- **Location:** `internal/mcp/client.go:106`
|
||||
- **Evidence:**
|
||||
```
|
||||
boundedBuffer.Write: `if remaining := b.cap - b.buf.Len(); remaining > 0 { ... b.buf.Write(p[:remaining]) ... }` — once cap (64 KiB) is reached, all further bytes are discarded; only the earliest bytes are retained. connectStdio reads stderr only on initialize failure (lines 150-154).
|
||||
```
|
||||
- **Impact:** Diagnostics quality: a server that prints a verbose startup banner (>64 KiB) and then dies with an error message at the end shows only the banner in the wrapped initialize error; the actual fatal line is dropped. Not a correctness/security issue, but it can make MCP startup failures hard to diagnose.
|
||||
- **Fix:** Prefer a ring buffer that retains the LAST cap bytes (or head+tail split) so the terminal error is preserved; the bound is unchanged.
|
||||
|
||||
### L11 · ChatGPT account-id silently omitted (no warning) when the id_token claim exists but is the wrong shape
|
||||
- **Severity / category:** low / robustness · **Subsystem:** oauth-providers
|
||||
- **Location:** `internal/provideroauth/chatgpt.go:227`
|
||||
- **Evidence:**
|
||||
```
|
||||
if ns, ok := claims[chatgptAuthClaimNamespace].(map[string]any); ok { if value, ok := ns[chatgptAccountClaim].(string); ok { return strings.TrimSpace(value), nil } } ... return "", nil — a present-but-non-string nested claim (or a present namespace with a missing key) falls through to `return "", nil`, the same as 'no id_token'. The caller (line 154-162) only warns on claimErr!=nil and only sets Account when account!="".
|
||||
```
|
||||
- **Impact:** If OpenAI ever changes the claim's JSON shape (e.g. nests it one level deeper, or makes it a number), extraction returns ("", nil) with no error: token.Account stays empty, the chatgpt-account-id header is omitted on every Codex call (codex.go:172 sets it only when ok&&account!=""), and the user gets Cloudflare 401s on every request with zero diagnostic — neither the login warning nor a parse error fires. The current 401 is then indistinguishable from an expired token.
|
||||
- **Fix:** Distinguish 'claim absent' from 'claim present but unusable': when claims[ns] exists but yields no non-empty string account id, return a non-nil error (e.g. 'account-id claim present but not a string') so the existing warning path at chatgpt.go:159 fires and the user is told to re-auth.
|
||||
|
||||
### L12 · Latest()/List() order by second-granularity UpdatedAt with a SessionID lexical tiebreak; same-second updates with user-supplied IDs can resume the wrong session
|
||||
- **Severity / category:** low / correctness · **Subsystem:** sessions
|
||||
- **Location:** `internal/sessions/store.go:332-337,708-710`
|
||||
- **Evidence:**
|
||||
```
|
||||
func (store *Store) timestamp() string { return store.now().UTC().Format(time.RFC3339) } // second precision
|
||||
...
|
||||
if sessions[left].UpdatedAt == sessions[right].UpdatedAt { return sessions[left].SessionID < sessions[right].SessionID }
|
||||
return sessions[left].UpdatedAt > sessions[right].UpdatedAt
|
||||
```
|
||||
- **Impact:** UpdatedAt is RFC3339 (1s resolution). When two sessions are touched in the same wall-clock second (rapid back-to-back exec runs, or cron firing several jobs), the tiebreak is lexical SessionID, not real recency. exec_sessions.go:76 store.Latest() (used to resume the most recent session) and session.go:170 LatestResumable() can therefore land on the wrong session. For store-generated IDs the ID embeds UnixNano so lexical order roughly tracks recency, but for caller-supplied SessionIDs (Create/Fork/Child accept an explicit SessionID) the order is arbitrary, so `--continue`-style resume can attach to a stale session. Low: narrow timing window and mostly mitigated for auto-generated IDs.
|
||||
- **Fix:** Persist a higher-resolution UpdatedAt (RFC3339Nano) so same-second ties are broken by true time, and keep SessionID only as a final deterministic tiebreak; or carry a monotonically increasing per-store update counter into the sort key.
|
||||
|
||||
### L13 · Scheduler leaks a per-job context when a bounded job completes via MaxRuns
|
||||
- **Severity / category:** low / resource-perf · **Subsystem:** specialist-swarm
|
||||
- **Location:** `internal/swarm/scheduler.go:280`
|
||||
- **Evidence:**
|
||||
```
|
||||
runs := job.incRuns()
|
||||
if max := job.schedule.MaxRuns; max > 0 && runs >= max {
|
||||
return
|
||||
}
|
||||
```
|
||||
- **Impact:** Each scheduled job gets its own ctx, cancel := context.WithCancel(s.ctx) (Add, line 182). On the MaxRuns-completion path run() returns without ever calling job.cancel(); only Cancel() and Close() invoke it. The derived context (and the goroutine the runtime spawns to propagate parent cancellation) is retained until the whole scheduler/Swarm context is cancelled at session end. A long session that adds many bounded daily/interval jobs accumulates one leaked context per completed job. Bounded by session lifetime and small, but it is a real, avoidable leak the codebase elsewhere is careful about.
|
||||
- **Fix:** Add `defer job.cancel()` at the top of run() (alongside the existing `defer s.wg.Done()` / `defer s.forget(job.id)`), so the job's context is released on every exit path including MaxRuns completion.
|
||||
|
||||
### L14 · terminateProcess returns success without killing surviving group members when the leader PID was already reaped
|
||||
- **Severity / category:** low / robustness · **Subsystem:** specialist-swarm
|
||||
- **Location:** `internal/background/process_posix.go:55`
|
||||
- **Evidence:**
|
||||
```
|
||||
target := pid
|
||||
if pgid, err := syscall.Getpgid(pid); err == nil {
|
||||
if pgid == pid {
|
||||
target = -pid
|
||||
}
|
||||
} else if processGoneError(err) {
|
||||
return nil // already gone
|
||||
}
|
||||
```
|
||||
- **Impact:** launchBackgroundProcess (specialist/exec.go:797) runs a command.Wait() goroutine that reaps the background specialist leader as soon as it exits. If the leader exits on its own but had forked a detached grandchild into the same process group, a later TaskStop calls terminateProcess(pid): Getpgid(reaped pid) returns ESRCH, the code treats it as 'already gone' and returns nil — never signalling the surviving group. TaskStop reports success while grandchildren (e.g. a dev server the sub-agent started and the parent zero exec exited without reaping) keep running. The full-tree kill the M6 fix added only works while the leader is still alive to identify the group.
|
||||
- **Fix:** Persist the child's pgid at launch (it equals the pid under Setpgid) and, when the leader is gone, fall back to syscall.Kill(-pgid, ...) to sweep any survivors instead of returning nil immediately on a reaped leader.
|
||||
|
||||
### L15 · Data race on execSession.lastUsedAt: prune reads it under manager.mu while touch writes it under session.mu
|
||||
- **Severity / category:** low / concurrency · **Subsystem:** tools
|
||||
- **Location:** `internal/tools/exec_command.go:123 vs :278`
|
||||
- **Evidence:**
|
||||
```
|
||||
sessionToPruneLocked (called from store under manager.mu) sorts with `sessions[i].lastUsedAt.Before(sessions[j].lastUsedAt)` (line 123), reading lastUsedAt with only manager.mu held. touch() writes it under a DIFFERENT lock: `session.mu.Lock(); session.lastUsedAt = time.Now(); session.mu.Unlock()` (lines 277-279). snapshot() correctly reads it under session.mu (line 291), but the prune path does not. The two paths use disjoint mutexes, so there is no happens-before between the write and the read.
|
||||
```
|
||||
- **Impact:** When a new exec_command at the session cap triggers eviction (store -> sessionToPruneLocked) concurrently with a write_stdin poll calling touch() on another live session (or with removeCompletedLater/stopAll churn), this is a genuine data race on a time.Time (a multi-word struct), which `go -race` would flag and which can yield a torn/garbage timestamp that mis-selects the prune victim. Unexercised by current tests (no test drives concurrent touch+prune), so it is latent rather than observed.
|
||||
- **Fix:** Read lastUsedAt through the session lock in the prune comparator (snapshot a `[]struct{id int; last time.Time}` by calling a small locked getter per session before sorting), or store lastUsedAt as an atomic.Int64 of UnixNano accessed the same way from both touch and prune.
|
||||
|
||||
### L16 · truncateRunes/middleTruncate truncate by rune COUNT but are used with display-WIDTH budgets — wide/CJK content overshoots its budget
|
||||
- **Severity / category:** low / correctness · **Subsystem:** tui-render
|
||||
- **Location:** `internal/tui/view.go:918`
|
||||
- **Evidence:**
|
||||
```
|
||||
truncateRunes: `runes := []rune(text); if len(runes) <= limit { return text }; ... return string(runes[:limit-1]) + "…"` — purely rune-count based. middleTruncate (startup.go:138-153) likewise slices `runes[:front]`/`runes[len-back:]`. Both are called with display-width budgets: diffCardBody `truncateRunes(strings.TrimPrefix(line, "+"), textBudget)` (rendering.go:1511), then diffBodyLine pads `pad := textBudget - lipgloss.Width(text)` (rendering.go:1536); toolCardHead `truncateRunes(arg, maxInt(12, width/3))` (1336) and `middleTruncate(shown, maxInt(16, width/2))` (1328); grep/read/bash card budgets (1594/1628/1711).
|
||||
```
|
||||
- **Impact:** For a diff/grep/tool line of double-width (CJK/emoji) characters, truncateRunes keeps up to `limit` runes each ~2 cells wide, so the kept text can be ~2x its display budget. In diffBodyLine the band pad then computes negative and is skipped, so the add/del tint band no longer fills to the card edge and rows of CJK code render with uneven/short solid bands. Overflow past the card width is contained because toolCard re-clamps every body line with the width-aware fitStyledLine (rendering.go:1387) and the head with fitStyledLine (1380); the residual impact is cosmetic misalignment of the diff/tool band for wide-char content, not layout breakage.
|
||||
- **Fix:** Add a display-width-aware truncate (loop accumulating lipgloss.Width per rune until budget, reserving 1 cell for the ellipsis) and use it where the argument is a width budget; or route these unstyled budgets through the existing width-aware fitStyledLine/truncateStyledLine primitives instead of rune-count truncateRunes.
|
||||
|
||||
### L17 · Cancelled run's usage events/rows are applied to whatever session is active when the goroutine returns, even after /resume switched sessions
|
||||
- **Severity / category:** low / correctness · **Subsystem:** tui-render
|
||||
- **Location:** `internal/tui/model.go:1319`
|
||||
- **Evidence:**
|
||||
```
|
||||
In the agentResponseMsg `msg.runID != m.activeRunID` (flushing) branch, the session-EVENT flush is correctly routed to the recorded session (`if flushSessionID == m.activeSession.SessionID { appendSessionEvents } else { appendSessionEventsTo(flushSessionID, ...) }`, model.go:1335-1338). But the usage events just above are applied unconditionally to the current model: `for _, event := range msg.usageEvents { m, usageRows = m.recordUsageEvent(...); for _, row := range usageRows { m.transcript = appendTranscriptRow(m.transcript, row) } }` (1319-1325) — no flushSessionID gating.
|
||||
```
|
||||
- **Impact:** If the user cancels an in-flight run and then /resume's a DIFFERENT session before the cancelled run's goroutine returns its accumulated response, the cancelled run's usage tracker delta and any usage transcript rows land in the now-active different session's in-memory transcript/usage readout. Narrow timing window, in-memory display only (the durable session-event flush is already correctly routed), so impact is a transient mis-attributed usage line, not persisted corruption.
|
||||
- **Fix:** Only record usage events and append usage rows when `flushSessionID == m.activeSession.SessionID`; when the run was recording into another session, persist its usage to that session's tracker (or drop the display rows) rather than mutating the active model.
|
||||
|
||||
### I1 · Local control socket handleConn has no read deadline (owner-only mitigates)
|
||||
- **Severity / category:** info / robustness · **Subsystem:** daemon
|
||||
- **Location:** `internal/daemon/server.go:228`
|
||||
- **Evidence:**
|
||||
```
|
||||
hello, err := ReadControl(conn)
|
||||
if err != nil { return }
|
||||
...
|
||||
cmd, err := ReadControl(conn) // both reads unbounded
|
||||
```
|
||||
- **Impact:** The locally-accepted control connection performs the hello + command reads with no deadline. Unlike the remote bridge this is gated by an owner-only Unix socket (secureSocketParent/hardenSocketFile), so the only peer able to stall it is the socket owner — the same principal that controls the daemon — making it a non-threat in practice. Noted because the same unbounded-read code is shared with the remote path (where it IS exploitable, see the medium findings); a defensive deadline here would make handleConn safe regardless of how the conn was obtained.
|
||||
- **Fix:** Optionally add a modest read deadline around the two handshake ReadControl calls in handleConn so the protection is intrinsic to the function rather than relying on the caller (local socket perms / remote bridge handshake bound).
|
||||
|
||||
### I2 · Audit append failures are silently swallowed by the dispatcher
|
||||
- **Severity / category:** info / robustness · **Subsystem:** hooks
|
||||
- **Location:** `internal/hooks/dispatch.go:223-249`
|
||||
- **Evidence:**
|
||||
```
|
||||
recordStarted (line 227 `_, _ = dispatcher.audit.AppendStarted(...)`) and recordCompleted (line 240 `_, _ = dispatcher.audit.AppendCompleted(...)`) discard the error. AppendStarted/AppendCompleted can fail on lock-acquire timeout (lock.go:86 "timed out acquiring audit lock" after 10s) or any I/O/marshal error.
|
||||
```
|
||||
- **Impact:** Over a session, if the cross-process audit lock is contended/stranded (a crashed holder's lock not yet 60s stale, or a permissions issue under XDG_DATA_HOME), every hook still runs and the tool proceeds normally, but the audit JSONL silently gains no record — there is no log line, metric, or diagnostic that the security/compliance audit trail dropped events. For a feature whose stated purpose is an auditable record of policy-hook executions, silent gaps undermine the trail. Informational because the dispatcher fails OPEN by design and the audit store is optional.
|
||||
- **Fix:** Surface a one-time/rate-limited warning (or a diagnostic on DispatchOutcome) when an audit append errors, so an operator can tell the audit trail is incomplete rather than assuming the absence of a record means the hook did not run.
|
||||
|
||||
## 4. Reconciliation with prior audit docs
|
||||
|
||||
Reconciled against `docs/audit/2026-06-10-deep-audit.md`, `2026-06-10-deep-audit-status.md`, and `2026-06-13-reverification.md`. Each prior item for an audited area was re-checked against current code. Summary: **64 fixed, 14 partial, 12 still-open** (deduplicated across overlapping subsystem reports). The still-open and partial items are the ones that matter for remediation; the 12 still-open are all re-derived above as current findings (or noted as out-of-scope dead code).
|
||||
|
||||
#### Still open (12)
|
||||
|
||||
| Subsystem | Prior finding | Location | Evidence |
|
||||
|---|---|---|---|
|
||||
| agent-loop | Dead stores to requestEvent after always-allow, and unreachable fallbackPermissionEvent | `internal/agent/loop.go:473` | In the AlwaysAllow case requestEvent.GrantMatched/Grant are still set at loop.go:776-778 but the model-visible event is rebuilt fresh from the sandbox decision at 830-840, and fallbackPermissionEvent (loop.go:1694) is st |
|
||||
| agent-loop | Reactive mid-stream retry never forwards retried assistant text to OnText | `internal/agent/loop.go:183` | The reactive retry still collects with only OnUsage (loop.go:264-266), deliberately omitting OnText to avoid duplicating the already-forwarded partial; documented at 258-263. Behavior unchanged (intentional trade-off). |
|
||||
| agent-loop | OnContext / MeasureContext context-utilization pipeline has no consumer on any surface | `internal/agent/types.go:183` | Grep finds no `OnContext:` assignment outside agent tests; MeasureContext is only called at loop.go:165 behind the never-set OnContext guard. Reported again above as a current low finding. |
|
||||
| cli-usage-streamjson | zero --skip-permissions-unsafe silently discards all trailing arguments (M81) | `internal/cli/app.go:149` | app.go:234-243 — `rest` is only scanned for misplaced --add-dir; all other trailing args are dropped and the TUI is launched. Confirmed still-open at 2026-06-13 reverification and unchanged in current code. |
|
||||
| config-plugins-skills | ResolvedConfig.MCP is computed but never read; provider-command MCP servers are silently dropped | `internal/config/resolver.go:95` | resolver.go:106 still sets MCP: cfg.MCP with zero readers in internal/cli (grep 'resolved.MCP' empty); ResolveMCP (resolver.go:115-133) still seeds defaults+file+overrides only and never calls LoadProviderCommand, so ZER |
|
||||
| config-plugins-skills | maxTurns<=0 is silently ignored | `internal/config/resolver.go:144,177,506` | All three merge sites still gate on `> 0` (resolver.go:152,191,539) and Resolve() still has no MaxTurns validation, unlike deferThreshold (:66-68) and maxTeamSize (:70-72); a 0/negative value is silently replaced by defa |
|
||||
| config-plugins-skills | plugin vs standalone hook-event validators disagree | `internal/plugins/plugins.go:894` | plugins.parseHookEvent (plugins.go:894) + mapHookEvent (activate.go:412) still accept only the four non-specialist events; hooks.parseEvent/IsValidEvent (hooks.go:888,906) still accept six. The sets still disagree. |
|
||||
| config-plugins-skills | config/contracts.go (ContractGap API) is unused dead code | `internal/config/contracts.go:12` | Per the 2026-06-13 reverification (line 95), ContractGap/DefaultContractGaps/FindContractGapsByMilestone are referenced only by contracts_test.go; no production change in this area since (outside my named focus but noted |
|
||||
| cron | cron run start-up reconcile silently cancels --run-now jobs | `internal/cli/cron_run.go:115` | reconcileOverdue (cron_run.go:105-130) still reschedules any NextRunAt before nowMin without firing, with no run-now exemption, and Job has no run-now marker. Reproduced: --run-now job (NextRunAt=now at add) pushed to ne |
|
||||
| hooks | Plugin/hooks event-set disagreement (plugin parseHookEvent accepts only 4 events; hooks accepts 6) | `internal/plugins/plugins.go:894 (2026-06-13 reverification l` | plugins.go:38-42 still declares only 4 HookEvent consts and parseHookEvent (plugins.go:893-898) still switches on those 4, while hooks.go parseEvent (hooks.go:901-911) accepts all 6. Re-derived above as a low finding. |
|
||||
| sandbox | OnSandboxDecision callback option is set up to fire but never wired by any caller (dead code) | `internal/tools/registry.go:108` | This field lives in internal/tools/registry.go, outside the sandbox package's audited scope; the 2026-06-13 reverification (line 107) confirms repo-wide grep still finds OnSandboxDecision only at the declaration, nil-che |
|
||||
| sessions | Second-granularity RFC3339 timestamps make Latest()/list ordering pick the wrong session on same-second ties | `internal/sessions/store.go:555` | store.go:709 still formats with time.RFC3339 (1s) and List tiebreak (332-336) is lexical SessionID; re-reported as the low finding above. |
|
||||
|
||||
#### Partially fixed (14)
|
||||
|
||||
| Subsystem | Prior finding | Location | Evidence |
|
||||
|---|---|---|---|
|
||||
| cli-usage-streamjson | streamjson secret patterns mangle ordinary text in every stream-json output event (M48) | `internal/streamjson/streamjson.go:310` | sk- and bearer patterns are now word-boundary + length anchored (streamjson.go:313,317) and TestRedactStringDoesNotOverMatchProse covers task-list/bearer; but the api[_-]?key pattern at streamjson.go:314 still over-match |
|
||||
| config-plugins-skills | Hooks subsystem is loaded and listed but never executed (no event dispatch, no edit command) | `internal/hooks/hooks.go:372` | Now partially wired: beforeTool/afterTool DO dispatch from the agent loop (internal/agent/loop.go:914,937) and a CLI edit surface exists (internal/cli/hooks_manage.go `zero hooks add/...`). But the other four events (ses |
|
||||
| config-plugins-skills | skills.Get/Duplicates unused; duplicate-name collisions never warned | `internal/skills/skills.go:91 + consumers` | skills.Duplicates is now consumed by the CLI list path (internal/cli/skills.go:69-81 emits per-collision warnings to stderr), so collisions ARE surfaced for `zero skills list`. But the agent-facing skill tool still drops |
|
||||
| cron | Cron store has no inter-process locking: paused/edited jobs clobbered; concurrent schedulers double-fire | `internal/cli/cron_run.go:173` | A real cross-process lock now exists (internal/cron/lock.go: O_EXCL sibling <id>.lock with atomic stale reclaim) and is taken by Update/Mutate/Remove/AppendRun (store.go:174,196,219,266). fireJob now claims-before-fire u |
|
||||
| cron | Cron Next double-fires wall-clock schedules on DST fall-back (repeated hour) | `internal/cron/schedule.go:171` | A fall-back collapse guard was added (schedule.go:191-209, sameWallClockMinute) and is covered by TestNextDSTFallBackDoesNotRepeatHour. But the guard only engages when `after` is exactly minute-aligned (schedule.go:205 ` |
|
||||
| hooks | Hooks subsystem is loaded and listed but never executed (no event dispatch) | `internal/hooks/hooks.go:372 (2026-06-10 deep audit)` | The core gap is closed for the two tool events: Dispatcher.Dispatch is wired into the agent loop at internal/agent/loop.go:914 (beforeTool, with veto routed through blockedByHookResult at loop.go:955) and loop.go:937 (af |
|
||||
| mcp | Client readLoop misroutes server-to-client requests as responses (id collision) and never answers ping | `internal/mcp/client.go:302` | client.go now has a single readLoop (331-355) that dispatches strictly by `client.pending[id]` and DROPS unmatched id-bearing messages (responses==nil) instead of corrupting a waiter, and skips id==nil notifications (338 |
|
||||
| mcp | Stdio request write blocks indefinitely while holding client.mu and ignores ctx cancellation | `internal/mcp/client.go:246` | The unbounded RESPONSE wait was fixed: mu is released at client.go:291 before the ctx-aware select on the response channel (293-311), so a hung server no longer holds the lock during the wait. But the WRITE itself (line |
|
||||
| mcp | 1 MiB SSE scanner token cap permanently kills the SSE session on large messages | `internal/mcp/network_client.go:637` | maxSSEEventBytes raised to 8MiB (network_client.go:658) and scanner.Buffer uses it (662), plus an aggregate multi-line data cap (707-713), so the common large-message case no longer trips ErrTooLong. But a single SSE lin |
|
||||
| oauth-providers | Health probe SSRF blocklist and credentials defeated by redirects and DNS rebinding | `internal/providerhealth/providerhealth.go:285` | DNS rebinding TOCTOU is fixed: safeDialContext (providerhealth.go:442-474) re-resolves at dial time, validates every resolved addr, and dials the validated IP literal via dialValidatedAddrs. Internal-redirect escape is f |
|
||||
| oauth-providers | OpenAI adapter drops tool calls lacking an id; agent loops forever on retry | `internal/providers/openai/tool_state.go:95` | tool_state.go:235-240 closeOpen now emits StreamEventToolCallDropped for an id-less/name-less call that streamed an id/name/arguments, and loop.go:300-306 converts a dropped-call-with-no-other-calls turn into a single re |
|
||||
| sessions | No fsync before rename/append: metadata.json can be empty after a crash, silently hiding the session | `internal/sessions/store.go:632` | writeMetadata (store.go:779-804) now writes a temp file via writeFileSync (fsyncs data), os.Rename, then syncDir(parent) — metadata is fully crash-safe. BUT the sibling events.jsonl append (store.go:606-616) still has no |
|
||||
| specialist-swarm | Post-SIGTERM grace polling can SIGKILL a recycled PID | `internal/background/process_posix.go:38-50` | terminateProcess now resolves pgid up front and signals the group (-pid) only when pid leads its own group, returning nil early if Getpgid reports the leader already gone (process_posix.go:55-61); group-leader targeting |
|
||||
| tools | escalate_model tool is implemented and registry-aware but never included in any Core* tool set | `internal/tools/registry.go:224` | Still absent from CoreReadOnly/Write/Shell/Network/CoreTools sets (registry.go:204-266) and from the TUI registry (cli/app.go:660 newCoreRegistryScoped only iterates CoreToolsScoped). It IS now registered, but only in he |
|
||||
|
||||
#### Fixed (64)
|
||||
|
||||
| Subsystem | Prior finding | Location | Evidence |
|
||||
|---|---|---|---|
|
||||
| agent-loop | Truncated/filtered responses (FinishReason) are produced by every provider but never consumed by the agent loop | `internal/agent/loop.go:233` | FinishReason is now consumed at loop.go:284 (result.FinishReason = collected.FinishReason) and loop.go:502/539, exposed via Result.TruncationNotice() (types.go:278-292), and surfaced to users in both internal/tui/model.g |
|
||||
| agent-loop | "Always allow" permission decision is converted into a denial when grant persistence fails (always, when Options.Sandbox | `internal/agent/loop.go:468` | PermissionDecisionAlwaysAllow case (loop.go:763-779) now sets permissionGranted=true unconditionally and only attempts persistPermissionGrant when options.Sandbox != nil, ignoring a persist error (the call is honored reg |
|
||||
| agent-loop | estimateTokens ignores image attachments and the compaction trigger ignores tool definitions, so the context budget unde | `internal/agent/compaction.go:58` | estimateTokens adds len(message.Images)*imageTokenEstimate (compaction.go:114); maybeCompact now takes the exposed tools and counts estimateToolDefTokens(tools) into both the threshold and shrink checks (compaction.go:33 |
|
||||
| agent-loop | Compaction summarizer provider calls bypass OnUsage — their token spend is invisible to usage accounting | `internal/agent/compaction.go:376` | summarizeMessagesOnce now calls CollectStreamWithOptions(ctx, stream, CollectOptions{OnUsage: onUsage}) (compaction.go:501); onUsage is threaded from options.OnUsage via newCompactionState -> summarizeClosure. |
|
||||
| agent-loop | Mid-stream context cancellation is returned as a flattened errors.New string; the ctx.Err() identity check is unreachabl | `internal/agent/loop.go:188` | loop.go:272-275 now checks `if ctx.Err() != nil { return result, ctx.Err() }` BEFORE the `if collected.Error != ""` return at 276-279, so a canceled stream returns the wrapped context.Canceled and errors.Is succeeds. |
|
||||
| agent-loop | Duplicate items-schema assignment in propertyToRuntimeMap | `internal/agent/loop.go:1072` | propertyToRuntimeMap (loop.go:1978-1980) assigns schema["items"] exactly once under `if property.Items != nil`. |
|
||||
| agent-loop | Project-guidelines truncation can split a multibyte UTF-8 rune in the system prompt | `internal/agent/system_prompt.go:89` | projectGuidelines walks back to a rune boundary before cutting: `for cut > 0 && !utf8.RuneStart(content[cut]) { cut-- }` (system_prompt.go:255-258); compaction_preserve.go capBody does the same (304-307). |
|
||||
| cli-usage-streamjson | Ctrl+C with -o json ends the JSON event stream with no error/done terminal event (M82) | `internal/cli/exec.go:500` | exec.go:567-595 emits a terminal error + done event for both execOutputStreamJSON (errorEvent+runEnd) and execOutputJSON (writeJSONLine type=error then type=done, exit_code=130) on context cancellation. |
|
||||
| cli-usage-streamjson | zero exec --list-tools -o json ignores the requested JSON format and prints plain text (M83) | `internal/cli/exec.go:182` | exec.go:206-219 branches on outputFormat: stream-json -> writeExecStreamJSONFinal, json -> writeExecToolListJSON (exec_tools.go:103), else plain text. |
|
||||
| cli-usage-streamjson | Per-event 'model' field persisted under --allow-escalation is never read by the usage report, mispricing escalated runs | `internal/usage/report.go:17` | report.go:138-141 prefers payload.Model (set on escalation runs) before falling back to modelBySession; exec.go:555-556 writes payload["model"]=currentModel only under options.allowEscalation, and currentModel is reassig |
|
||||
| cli-usage-streamjson | One corrupt session aborts the entire zero usage report (M101) | `internal/cli/usage.go:46` | usage.go:47-51 — a ReadEvents error increments set.skipped and continues instead of returning, so a single unreadable session no longer aborts the report. (Minor: set.skipped is never surfaced to the user — info-level on |
|
||||
| cli-usage-streamjson | Fork duplicates provider_usage events, double-counting usage in zero usage report (M100) | `internal/sessions/store.go:387` | store.go:431-436 — Fork explicitly skips EventUsage when copying parent events into the fork ('Do NOT copy usage accounting into the fork'), so report aggregation over parent+fork no longer double-counts. |
|
||||
| cli-usage-streamjson | zero usage hard-fails outside a git repository (M72) | `internal/cli/usage.go:121` | usage.go:122-131 — net-LOC is best-effort: resolveWorkspaceRoot/inspectChanges errors are swallowed and DiffStat degrades to zero, so the token report still renders outside a git repo. |
|
||||
| cli-usage-streamjson | zero mcp list --json leaks MCP server env/header secret values (M74) | `internal/cli/extensions.go:186` | extensions.go:220-223 redacts via redactMCPServerConfigs (env/header values -> [REDACTED], URL creds via redactMCPURL at 236-256) and then RedactValue before writePrettyJSON; the plain-text path also runs RedactString (2 |
|
||||
| cli-usage-streamjson | redactURLPasswords recompiles its regexp on every RedactString call (M93) | `internal/redaction/redaction.go:362` | urlWithCredsPattern is now a package-level var (redaction.go:424) compiled once; redactURLPasswords (426) reuses it. |
|
||||
| cli-usage-streamjson | redaction opaque/custom-scheme Authorization not redacted (M12) | `internal/redaction/redaction.go:92` | headerPattern makes the scheme optional and captures the whole value to EOL (redaction.go:92, 186-194); opaque_auth_test.go verifies opaque tokens, custom schemes, and Proxy-Authorization are redacted while a known schem |
|
||||
| cli-usage-streamjson | secrets.Redact leaves a private key un-redacted when an inner pattern matches inside it (M90) | `internal/secrets/scanner.go:81` | scanner.go:91-99 replaces longest matches first (order sorted by len desc) so the whole PEM PRIVATE KEY block is redacted before any shorter nested match corrupts it. |
|
||||
| cli-usage-streamjson | Secret scanner misses modern OpenAI key formats / private key block header (M158) | `internal/secrets/scanner.go:35` | scanner.go:41 openai_key body is `sk-[A-Za-z0-9_-]{20,}` (covers sk-proj-/sk-svcacct- and -/_), and scanner.go:44 matches the full BEGIN..END PRIVATE KEY block. |
|
||||
| cli-usage-streamjson | Stream-json / status / tool-output truncation slices strings mid-rune (M153/M155/M165) | `internal/cli/exec_writer.go:412` | exec_writer.go:401-431 — truncateForStatus and truncateForStreamJSONOutput both call cutRuneBoundary, which walks back to the last UTF-8 rune boundary at or before n. |
|
||||
| config-plugins-skills | config writer performs a non-atomic in-place write of the user config | `internal/config/writer.go:53` | writeConfigFile (writer.go:149-184) now uses os.CreateTemp in the target dir, tmp.Chmod(0o600), Write, Close, then os.Rename(tmpPath, path) with a deferred os.Remove cleanup (lines 161-182) — the documented write-to-temp |
|
||||
| config-plugins-skills | parseThinkTags setting silently dropped on every profile merge (L17) | `internal/config/resolver.go (mergeProfile)` | mergeProfile now preserves it: resolver.go:420-422 `if next.ParseThinkTags != nil { base.ParseThinkTags = next.ParseThinkTags }`, and it is read end-to-end by providers/factory.go:97-98 (parseThinkTagsForProfile) into op |
|
||||
| config-plugins-skills | Plugin-declared skill paths are not merged into skill lookup (inert) | `internal/skills/skills.go (Load single-root)` | Plugin skills are now merged at runtime: plugins.NewSkillTool (activate.go:340) overlays default dir + plugin SkillRoots via mergeSkills, and it is registered when SkillRoots are present (cli/plugin_activate.go:53-54), i |
|
||||
| cron | cron add <expr> --recipe R silently discards the user's cron expression | `internal/cli/cron.go:112` | cronAdd now consumes the positional expression BEFORE applying recipe defaults: `if len(positional) == 1 && expr == "" { expr = positional[0] }` (cron.go:113-115) precedes the recipe block whose guard is `if expr == "" { |
|
||||
| cron | cron run records lose the failure reason: exec errors go to the discarded stdout stream | `internal/cli/cron_run.go:150` | fireJob now recovers the failure detail from the stream-json error event on stdout: `if streamErr := extractStreamJSONError(outBuf.String()); streamErr != "" { detail = streamErr }` then `rec.Error = cronTruncate(detail, |
|
||||
| cron | promptExcerpt/cronTruncate byte-slice strings mid-rune (invalid UTF-8) | `internal/cli/cron.go:275` | promptExcerpt now cuts on a rune boundary: `cutRuneBoundary(p, 47) + "…"` (cron.go:278), and cronTruncate does `cutRuneBoundary(s, max) + "…"` (cron_run.go:272) instead of the prior raw byte slice. |
|
||||
| hooks | AuditStore.append is O(n^2): re-reads and re-parses the entire audit JSONL on every append | `internal/hooks/hooks.go:503 (2026-06-10) / reverified still-` | append() no longer calls ReadEvents(); it calls lastSequence() (hooks.go:515) which os.Open + Stat + ReadAt only the trailing 8KB window (hooks.go:550-602: `const tailWindow = 8 * 1024`, `file.ReadAt(buf, start)`), parse |
|
||||
| hooks | Cross-process sequence collision on the shared audit JSONL (read-then-append not atomic across processes) | `internal/hooks/hooks.go append (implied by 2026-06-13 line 6` | append() now wraps the lastSequence()+write in a cross-process O_EXCL file lock via store.lockAudit() (hooks.go:509-513 -> lock.go:33-90), with stale-lock reclaim (lock.go:97-108) mirroring cron/oauth. TestAuditStoreSequ |
|
||||
| mcp | Unbounded Content-Length allocation lets a peer crash the whole process | `internal/mcp/protocol.go:69` | protocol.go:17 defines maxMessageBytes=64MiB; readHeaderFramed caps Content-Length (lines 121-123: `if parsed > maxMessageBytes { return ... exceeds ... limit }`) before `body := make([]byte, contentLength)` (138); readL |
|
||||
| mcp | MCP stdio transport uses LSP Content-Length framing instead of MCP newline-delimited JSON | `internal/mcp/protocol.go:42` | read() (protocol.go:54-76) now reads newline-delimited JSON by default (isJSONStart -> decodeMessage) and only falls back to LSP Content-Length framing when the first line is not JSON; write() (148-163) emits one JSON ob |
|
||||
| mcp | zero mcp list --json leaks MCP server env/header secret values | `internal/cli/extensions.go:186` | extensions.go:222 wraps cfg.Servers in redactMCPServerConfigs (236-255), which replaces every Env and Header value with `[REDACTED]` and runs redactMCPURL on the URL (252) before writePrettyJSON; redactMCPURL (mcp_tools. |
|
||||
| mcp | Child stderr captured into an unbounded bytes.Buffer for the entire server lifetime | `internal/mcp/client.go:98` | stderr is now `&boundedBuffer{cap: maxStderrCapture}` (client.go:134, cap=64KiB at line 91); boundedBuffer.Write (103-115) discards bytes past cap. (Tail-vs-head retention is a separate low finding, not unboundedness.) |
|
||||
| mcp | Streamable-HTTP SSE response decoding takes the first event instead of the matching response | `internal/mcp/network_client.go:621` | decodeSSERPCMessage (network_client.go:612-652) now skips events whose decoded message has a non-empty Method (server-initiated notifications/requests) — `if candidate.Method != "" { return true }` (636-638) — and return |
|
||||
| oauth-providers | Gemini adapter never reads cachedContentTokenCount; CachedInputTokens always 0 | `internal/providers/gemini/types.go:88` | gemini/types.go:107 now declares `CachedContentTokenCount int \`json:"cachedContentTokenCount"\``; provider.go:225 `state.cachedTokens = payload.UsageMetadata.CachedContentTokenCount` and provider.go:283 emits `CachedInp |
|
||||
| oauth-providers | HTTP 529 classified as rate-limit but excluded from the 429/503 retry policy | `internal/providers/providerio/retry.go:107` | retry.go:108-110 `func ShouldRetryStatus(code int) bool { return code == http.StatusTooManyRequests // code == http.StatusServiceUnavailable // code == 529 }` — 529 now retried; doc comment updated to include Anthropic ' |
|
||||
| sandbox | macOS sandbox-exec profile blocks /tmp and /dev/null, breaking most wrapped shell commands | `internal/sandbox/runner.go:262` | runner.go:412-436 sandboxWritableDevices (/dev/null,/dev/zero,/dev/stdin,...) and sandboxWritableSubpaths (/tmp,/private/tmp,/var/tmp,/var/folders,/dev/fd) are now emitted by seatbeltWriteRule (runner.go:559-581, AllowTe |
|
||||
| sandbox | apply_patch misparses unified-diff body lines starting with '--- '/'+++ ' as file paths | `internal/tools/apply_patch.go:161` | risk.go:291-327 patchHeaderPaths now tracks hunk state (inHunk, oldRemaining/newRemaining from parsePatchHunkCounts) and decrements per body line, so '--- '/'+++ ' header parsing at risk.go:319-324 is only reached OUTSID |
|
||||
| sandbox | Interactive-command detector flags pager/REPL names inside quoted arguments (splitShellSegments not quote-aware) | `internal/sandbox/safe_command.go:138` | safe_command.go:474-583 splitShellSegments is now a full quote/substitution-aware scanner (inSingle/inDouble state, substStack for $()/backtick, backslash escapes); operators inside quotes are literal. Detection also anc |
|
||||
| sandbox | Grant store prefix/substring safety: Lookup trims while stored writer key may not be normalized (trim asymmetry => load | `internal/sandbox/grants.go:146` | grants.go:556-568 readState rebuilds a `normalized` map keyed by `key := strings.TrimSpace(name)` and re-keys every bucket; normalizeStoredGrant(key, grant) compares against the trimmed key (grants.go:614). Lookup/Revoke |
|
||||
| sandbox | Destructive/network/installer classification recompiles N regexes per shell call | `internal/sandbox/risk.go:53` | risk.go:12-55 destructiveCommandPattern, pipedInstallerPattern, unparseableNetworkPattern, and destructiveExtraPatterns are all package-level regexp.MustCompile vars compiled once at init, not per call. |
|
||||
| sessions | Torn append (crash mid-write) permanently bricks a session: ReadEvents hard-fails on any malformed JSONL line | `internal/sessions/store.go:547` | store.go:677-704 now computes tornTailPossible (trailing byte != newline) and lastNonEmpty, and breaks (drops the partial record) only when the malformed line is the last non-empty line AND the file lacks a trailing newl |
|
||||
| sessions | Fork duplicates provider_usage events and rewrites their timestamps, double-counting and re-dating usage in `zero usage | `internal/sessions/store.go:387` | Fork (store.go:430-442) explicitly `if event.Type == EventUsage { continue }` so usage events are never copied into the fork; usage/report.go BuildReport iterates every session's events (cli/usage.go collectUsageData), s |
|
||||
| sessions | `zero sessions tree` is O(nodes x sessions) disk reads (full store re-list per tree node) | `internal/sessions/lineage.go:140` | Tree (lineage.go:127-167) now does a single store.List() up front, indexes byID and childrenByParent in memory, and treeFrom (169-189) recurses over the in-memory maps with no per-node disk scan. |
|
||||
| sessions | Naive byte-slice truncation can split multi-byte UTF-8 runes in prompts and titles (replay) | `internal/sessions/replay.go:237` | replay.go:382-393 cutPromptRuneBoundary backs up while !utf8.RuneStart(s[n]) before slicing; it is used at the prompt-truncation sites (310-314) and payloadPreview (376), so replay/preview truncation is rune-safe. |
|
||||
| sessions | execSessionRecorder.append latches AppendEvent errors into recorder.err but no caller reads it (silent session-persisten | `internal/cli/exec_sessions.go:116-124` | warnIfRecordingFailed (exec_sessions.go:128-132) now surfaces recorder.err to stderr and is deferred by both runExec (exec.go:464) and the spec-draft path (exec_spec.go:90). |
|
||||
| specialist-swarm | Specialist sessions launched without a description are unresumable (AgentName never recorded / recorded as garbage) | `internal/specialist/exec.go:166` | exec.go:250-255 now always emits --session-title: when Description is empty it passes the bare manifest name (`args = append(args, "--session-title", name)`), so specialistAgentName resolves to the real specialist name a |
|
||||
| specialist-swarm | Specialist children always run with --auto high (PermissionModeUnsafe): one Task approval silently grants unprompted she | `internal/specialist/exec.go:150` | specialistAutonomy() (exec.go:125-132) maps only an explicit 'unsafe' parent mode to 'high' and everything else (including empty) to 'low'; BuildArgs/BuildResumeArgs (exec.go:230,282) call it with input.PermissionMode. T |
|
||||
| specialist-swarm | Race: duplicate specialist stop/usage accounting events — check-then-append dedupe is not atomic | `internal/specialist/accounting.go:72` | recordSpecialistStop and appendSpecialistUsageRollup now route through appendSpecialistEventOnce → Store.AppendEventUnlessExists (accounting.go:60,105,160-172), which performs the existence check and the append atomicall |
|
||||
| specialist-swarm | Foreground specialist run fails entirely when a child stream-json line exceeds 1 MiB | `internal/specialist/streamer.go:43` | ParseStream (streamer.go:41-73) now uses bufio.NewReader + ReadString('\n') with no per-line cap, and the live foreground path runChildProcess (exec.go:735-759) likewise uses bufio.NewReader.ReadString. The 1 MiB Scanner |
|
||||
| specialist-swarm | SetPID failure path deletes the prompt file under a running child and records no terminal accounting/status | `internal/specialist/exec.go:346` | runBackground SetPID-failure branch (exec.go:451-466) now terminates the orphaned child (background.TerminateProcess(pid), joining any kill error), calls manager.UpdateStatus(StatusError) and recordSpecialistStop, in add |
|
||||
| specialist-swarm | POSIX background-task kill terminates only the leader PID; the specialist's subprocess tree leaks on SIGKILL escalation | `internal/background/process_posix.go:50` | launchBackgroundProcess now calls background.ConfigureChildProcessGroup(command) before Start (exec.go:791), which sets Setpgid (process_posix.go:24-29), and terminateProcess signals the negative PID (the whole group) wh |
|
||||
| specialist-swarm | formatTaskOutput is dead code | `internal/specialist/output_tool.go:244` | grep across the repo finds no `formatTaskOutput(` definition or call; the only formatter in use is formatTaskOutputSummary, called from readOutput (output_tool.go:154). The dead function was removed. |
|
||||
| tools | bash tool timeout does not kill the command when a child inherits the output pipes | `internal/tools/bash.go:104` | bash.go:119 now calls hardenProcessLifetime(command); bash_proc_unix.go:28-44 sets Setpgid + WaitDelay=bashWaitDelay(2s) + Cancel that SIGKILLs the whole negative-pid process group, so a backgrounded child can no longer |
|
||||
| tools | grep glob filter is applied to root-relative paths, silently dropping matches under a subdirectory search | `internal/tools/grep.go:224` | grepFiles now matches the glob against the path relative to the SEARCH directory: grep.go:307-311 computes `globPath = filepath.ToSlash(rel)` via `filepath.Rel(target, path)` and matches globMatcher against that, so `glo |
|
||||
| tools | OnSandboxDecision callback option is set up to fire but is never wired by any caller (dead code) | `internal/tools/registry.go:108` | registry.go no longer declares or invokes OnSandboxDecision anywhere; RunOptions (registry.go:15-43) has no such field and RunWithOptions (registry.go:93-171) propagates decisions via res.SandboxDecision instead. The dea |
|
||||
| tools | read_file returns Truncated but no truncation marker in output; rune-width line numbering can misalign | `internal/tools/read_file.go:89` | read_file.go:118-123 now appends an explicit marker on truncation: `[truncated: N more line(s) in the requested range not shown; set start_line=... to continue]`. Line-number padding uses ASCII strconv.Itoa width (read_f |
|
||||
| tools | lsp_navigate workspace confinement (recently fixed) — verify | `internal/tools/lsp_navigate.go` | lsp_navigate.go:85 resolves the model path via resolveScopedReadPath BEFORE reading or handing it to the LSP manager (line 90 readWorkspaceFile(absPath), line 97 req.Path=absPath), so a '..'/absolute path cannot open a f |
|
||||
| tui-render | wrapPlainText collapses internal whitespace, destroying code indentation/alignment in assistant answers | `internal/tui/rendering.go:261 (also 2026-06-13 reverify rend` | rendering.go:399-409 now detects preformatted/columnar bodies (an internal run of >=2 spaces) and emits them verbatim via splitPreservingWidth (459-479) instead of strings.Fields, preserving every space; leading indent i |
|
||||
| tui-render | appendTranscriptRow O(n) copy + O(n) dedup scan per row → O(n^2) append and /resume rehydration | `internal/tui/transcript.go:113/117 (multiple rows)` | appendTranscriptRow now appends in place (transcript.go:99-108, comment notes the old O(n^2) is gone). The prior reverification gap — hasTranscriptRow still linear-scanning per keyed row during rehydration — is closed fo |
|
||||
| tui-render | Full transcript re-rendered from scratch every frame, including per-line regex parsing | `internal/tui/model.go:610/124` | renderRowMode/renderRowDetailed now memoize per-row output via the LRU defaultRenderCache (rendering.go:172-197) keyed on width/flush/bodyCap/cwd/row fields/rc hints (render_cache.go:126-174); only rows tied to the activ |
|
||||
| tui-render | looksLikeDiff false-positives on any output containing a line starting with '---' | `internal/tui/view.go:394` | view.go:897-916 now requires a real hunk header (hunkHeaderPattern) OR BOTH '+++ ' and '--- ' file headers before returning true; a lone '---' line no longer hijacks bash/generic output into the diff renderer. |
|
||||
| tui-render | truncateTUIOutput byte-slices UTF-8 mid-rune, emitting invalid UTF-8 | `internal/tui/transcript.go:303` | truncateTUIOutput (transcript.go:436-445) now calls cutRunes (449-460), which walks back to the nearest UTF-8 rune boundary via utf8.RuneStart before slicing, so the transcript/session log can no longer receive a split m |
|
||||
| tui-render | Cancelled-run event flush appends to whichever session is active at flush time (cross-session contamination) | `internal/tui/model.go:512` | flushRunIDs now maps runID -> recording sessionID (model.go:161, set at cancel 3177-3180), and agentResponseMsg routes the durable flush to that session: `if flushSessionID == m.activeSession.SessionID { appendSessionEve |
|
||||
| tui-render | Transcript dedupe key omits runID — repeated provider tool-call IDs silently drop later tool rows | `internal/tui/transcript.go:135/137/139` | transcriptRowKey bakes runID into every key, e.g. tool rows return fmt.Sprintf("%d:%d:%s", row.kind, row.runID, row.id) (transcript.go:157-181), with a comment explaining Gemini's repeating synthetic gemini_tool_N ids; w |
|
||||
| tui-render | Resize unhandled beyond storing width/height; composer not width-bounded (blind typing) | `internal/tui/model.go:456/227` | tea.WindowSizeMsg (model.go:1249-1267) stores width/height, resets the streaming fade line-age mapping that a re-wrap would invalidate (1256-1257), sizes the composer for horizontal scroll `m.input.SetWidth(maxInt(20, ch |
|
||||
### 4.4 Findings dropped in adversarial review
|
||||
|
||||
Two drafted findings did not survive re-verification and are explicitly withdrawn:
|
||||
|
||||
| Subsystem | Claimed | Why dropped |
|
||||
|---|---|---|
|
||||
| agent-loop | Compactor context-window budget frozen at run start, not updated on mid-run model escalation | Mechanics accurate, but the user-visible failure mode is unreachable today — the escalation path that would change the window does not feed back into the frozen budget in a way that produces wrong behavior. Not a defect. |
|
||||
| config-plugins-skills | `maxTurns <= 0` silently dropped with no validation error | Disproven: a 0/negative `maxTurns` is replaced by the documented default (30) by design, not silently mis-handled; there is no incorrect behavior. (The "no explicit validation error" observation is retained only as the low-priority still-open consistency note, not as a defect.) |
|
||||
|
||||
## 5. Root-cause synthesis
|
||||
|
||||
The 35 surviving findings cluster into four underlying causes:
|
||||
|
||||
**A. Pattern/substring matching on un-structured text where a structure-aware path exists but isn't authoritative.** The sandbox destructive/piped-installer classifier scans the *raw* command string (M11) even though a quote-aware AST analyzer runs alongside — but the analyzer can only *add* risk categories, never retract a regex false positive. The stream-json `api_key` redactor (M1) matches "any next word" after the marker. Both over-match real content (a `git commit -m "…rm -rf…"`, prose containing `apiKey foo`). Fix posture: make the structured parser authoritative when the parse succeeds; anchor patterns to credential/command shape.
|
||||
|
||||
**B. "Success" returned before the durable/drain step completes.** `AppendEvent` returns success after a page-cache write with no `fsync`, while the *derived* metadata is fsync'd — so a crash can leave `EventCount` ahead of the durable log and lose the last event, including the checkpoint `/rewind` targets (M12). A PTY exec session's `command.Wait()` returns and the session is removed before the fire-and-forget copy goroutine has drained the master FD, dropping the command's final output (M14). The hook dispatcher discards audit-append errors (I2). Fix posture: tie the success signal to completion of the durable write / output drain.
|
||||
|
||||
**C. Surfaces shipped ahead of (or drifting from) their wiring.** Four hook events are validated, documented, and `zero hooks add`-able but never dispatched (H1); `ResolvedConfig.MCP` is computed but unread so provider-command MCP servers are dropped (M2); usercommands `model:`/`agent:` frontmatter is parsed/documented but ignored (L4); project slash-commands auto-load with a built-but-unused provenance flag (M3); `OnContext`/`MeasureContext` has no consumer (L2); plugin vs hooks event-set validators disagree (L8); the runtime skill tool drops dup-name collisions silently (L5). Fix posture: a single source of truth for valid events; either wire the surface or remove it from config/CLI/docs.
|
||||
|
||||
**D. Lifecycle-edge handling asymmetric with the happy path.** Idle/close/exhausted/redirect edges are under-handled: the remote bridge clears the conn deadline before the daemon handshake so an authenticated-idle peer pins a slot forever (M7) and `Shutdown` can't even close bridge conns because `ServeConn` bypasses `trackConn` (M8); the MCP SSE client never reconnects after any stream close (M9); cron double-fires on DST fall-back (M4), double-grants a claim when the schedule can't advance (M5), and silently cancels `--run-now` on reconcile (M6); the swarm scheduler leaks a per-job context on `MaxRuns` completion (L13); the mailbox stale-lock break compares the file to itself instead of the observed-stale snapshot (M13); and the health probe forwards credentials across a cross-host redirect (M10 — the security item, an edge of the same "redirect handling" family the DNS-rebind fix addressed for the internal case but not the cross-host-public-credential case).
|
||||
|
||||
## 6. Prioritized remediation order
|
||||
|
||||
1. **M10 — strip auth headers on cross-host redirect in the health probe** (only security finding; credential exfil to a public host).
|
||||
2. **H1 — wire or remove the four un-dispatched hook events** (trust/correctness; silent no-op of a documented automation surface).
|
||||
3. **M12 — `fsync` the events.jsonl append** (data-loss of the last event, including `/rewind` checkpoints).
|
||||
4. **M11 — make the sandbox AST authoritative / segment the destructive regexes** (false-positive hard-blocks safe commands in headless exec).
|
||||
5. **M14 — synchronize the PTY copy goroutine with session exit** (model loses a command's final output).
|
||||
6. **M4 / M5 / M6 — cron: DST double-fire, claim double-grant on unadvanceable spec, `--run-now` reconcile** (duplicate/again missed scheduled agent runs = token spend / broken contract).
|
||||
7. **M7 / M8 / M9 — connection lifecycle: bridge handshake deadline, `ServeConn` tracking for Shutdown, MCP SSE bounded reconnect** (slot exhaustion / lost MCP tools mid-session).
|
||||
8. **M1 — anchor the stream-json `api_key` redactor** (corrupts machine-readable output).
|
||||
9. **M2 / M3 / M13 / M15 — provider-command MCP drop, project-command provenance, mailbox stale-break, TUI streaming O(n²)**.
|
||||
10. **Lows / infos** as hygiene — notably **L15** (genuine `lastUsedAt` data race; add a `-race` test), **L1** (route the three remaining stream connects through `streamWithReconnect`), and the dead/inert cleanups (L2/L4/L5/L8).
|
||||
|
||||
## 7. Confidence notes (what could not be fully verified)
|
||||
|
||||
- **`-race` suite is not green:** 3 `internal/tools` exec-session tests fail under `-race` due to fixed-timing yield-window assumptions (characterized in §2 as flakes, confirmed by passing 3× without `-race`). I could not make those tests deterministic without modifying source (audit only). No data race underlies them; the one real race in that package (**L15**) was found by inspection because no test exercises concurrent `touch()`+prune.
|
||||
- **Platform-specific backends only skimmed:** Windows sandbox/process files (`*_windows.go`), `landlock_linux.go`, `seccomp_*.go`, `log_monitor_darwin.go`, and the OS keyring backends (`secret_access_*.go`) were read shallowly — outside the symlink/parsing/risk/locking focus. A dedicated Windows/Linux-LSM pass is advisable.
|
||||
- **Not deep-read (out of stated focus):** `internal/tools/web_fetch.go` & `web_search.go` (network tools), Anthropic provider internals, the plugin/skill **install/checksum/lockfile** paths, and TUI overlays (`mouse.go`, picker/wizard/onboarding, `command_center.go` specifics). Build/vet cover them; behavior was not audited.
|
||||
- **Daemon prior-audit reconciliation is necessarily empty:** the `D1–D11` labels referenced in current daemon code comments come from a review not present in the three on-disk `docs/audit/*.md`, so "fixed since" for the daemon is against code comments, not those docs. The daemon findings here (M7/M8/L7/I1) are re-derived against current code.
|
||||
- **Reproductions:** M4, M5, M6, M11 were empirically reproduced with throwaway tests against the current tree (then removed). M10, M12, M14, M9, H1 are verified by code reading + caller/grep tracing, not a live exploit. Severity calibration for M10 is *medium* (requires a malicious/compromised/typo'd `baseURL` that 3xx-redirects to an attacker host) rather than high; if a deployment commonly points providers at untrusted gateways, treat it as high.
|
||||
|
||||
---
|
||||
|
||||
## 8. Remediation status — `fix/audit-2026-06-20`
|
||||
|
||||
Implemented in staged commits on branch `fix/audit-2026-06-20` (one finding/tight-cluster per commit; each gated build/vet/gofmt + `-race` on the affected package; full `-race` suite green on the final tree). Each finding was re-confirmed against current code before fixing.
|
||||
|
||||
### Fixed (15 findings + 1 test-robustness)
|
||||
|
||||
| ID | Sev | Status | Note |
|
||||
|---|---|---|---|
|
||||
| M10 | medium | ✅ Fixed | strip auth headers (x-api-key/x-goog-api-key/custom) on cross-host redirect in the health probe |
|
||||
| M14 | medium | ✅ Fixed | join the PTY copy goroutine before markDone/remove so a command's final output isn't dropped on exit (also fixes the flaky `TestExecCommandTTYSessionAcceptsInputOnLinux`) |
|
||||
| M12 | medium | ✅ Fixed | fsync events.jsonl append so the log is as durable as its metadata |
|
||||
| M4 | medium | ✅ Fixed | feed `sched.Next` a minute-aligned instant so the DST fall-back collapse guard engages |
|
||||
| M5 | medium | ✅ Fixed | pause an unadvanceable job inside the claim so two schedulers can't both fire it |
|
||||
| M7 | medium | ✅ Fixed | bound the daemon control handshake with a read deadline (idle remote peer can't pin a slot) |
|
||||
| M8 | medium | ✅ Fixed | `ServeConn` tracks the conn so `Shutdown` can close a stalled remote handshake |
|
||||
| M13 | medium | ✅ Fixed | atomic rename-with-verify mailbox stale-lock break (no self-compare; no split-brain) |
|
||||
| M1 | medium | ✅ Fixed | anchor the streamjson `api_key` redaction so prose isn't mangled |
|
||||
| I1 | info | ✅ Fixed | local control socket handshake now deadline-bounded (same fix as M7) |
|
||||
| L15 | low | ✅ Fixed | snapshot exec-session `lastUsedAt` under its lock — fixes a real `-race` data race |
|
||||
| L13 | low | ✅ Fixed | `defer job.cancel()` releases the scheduler's per-job context on MaxRuns completion |
|
||||
| L1 | low | ✅ Fixed | route the post-compaction / max-turns connects through `streamWithReconnect` |
|
||||
| L3 | low | ✅ Fixed | `--skip-permissions-unsafe` rejects trailing positional args instead of dropping them |
|
||||
| L11 | low | ✅ Fixed | error on a present-but-wrong-shape chatgpt account-id claim so the login warning fires |
|
||||
| (report §2) | — | ✅ Fixed | de-flaked the 3 exec-session timing tests so `go test ./... -race` is green |
|
||||
|
||||
### Deferred (larger/riskier than a clean stage — rationale)
|
||||
|
||||
| ID | Sev | Why deferred |
|
||||
|---|---|---|
|
||||
| H1 | high | Wiring `sessionStart/End` + `specialistStart/Stop` correctly is a cross-surface design change: the dispatcher lives only in `agent.Options` (fires per-tool), so session boundaries differ across exec/TUI/daemon and the specialist events need the parent dispatcher threaded through the child boundary. The audit's alternative (delete the events) would disable a documented feature (against the guardrails). Needs a focused design pass, not a surgical edit. |
|
||||
| M9 | medium | MCP SSE reconnect is risky concurrency (re-open + re-`initialize`d session state) and not deterministically unit-testable; warrants its own change with a fault-injection harness. |
|
||||
| M11 | medium | Making the sandbox AST authoritative is security-sensitive — a wrong call under-blocks a genuinely destructive command. Needs an extensive "still blocks real destructive commands" matrix before flipping the precedence; the guardrails forbid weakening confinement to land a fix. |
|
||||
| M2 | medium | Merging provider-command MCP into `ResolveMCP` is a real wiring decision (which resolve path the runtime should read); deferred to avoid guessing the intended single source of truth. |
|
||||
| M3, M6 | medium | Project-command provenance marker (UX) and cron `--run-now` one-shot marker (schema field) are design choices better made deliberately. |
|
||||
| M15 | medium | Streaming-block memoization is perf-only (no correctness risk) and fiddly in the TUI render hot path. |
|
||||
| L2, L4, L5, L6, L7, L8, L9, L10, L12, L14, L16, L17, I2 | low/info | Lower-value hygiene / cross-cutting (dead-code removal decisions, cross-platform lock identity, RFC3339Nano format change, TUI width math, minor concurrency hardening). Batchable in a follow-up. |
|
||||
|
||||
### Skipped (not a real defect)
|
||||
- The two findings the audit itself dropped in §4.4 (compactor frozen-budget; `maxTurns<=0`) were already withdrawn and not implemented.
|
||||
@@ -1,444 +0,0 @@
|
||||
# Zero — Product / UX / TUI Audit (2026-06-21)
|
||||
|
||||
- **Target:** `github.com/Gitlawb/zero` @ `origin/main` commit `f7ee189` — a terminal coding agent (Go, Bubble Tea v2 TUI; one event stream feeding TUI / headless `exec` / MCP server / cron).
|
||||
- **Lens:** product, UX, TUI/visual correctness, and open-source-release readiness, judged from a **stranger who just cloned the repo**. The code/security audit is separate (`docs/audit/2026-06-20-deep-audit.md`) and is **not** repeated here.
|
||||
- **Scope note:** audit only — no source changed.
|
||||
|
||||
## 1. Executive summary
|
||||
|
||||
Zero is a **functionally mature** agent with a thoughtfully-built TUI (memoized rendering, width tiers, light/dark themes, a real reduce-motion opt-out, color-independent diff/permission encoding, and a genuinely good guided setup wizard). The gap is the **release/packaging and last-mile UX layer**, and it is not yet shippable as open source.
|
||||
|
||||
Two **release blockers**: there is **no `LICENSE` file** (the repo is legally all-rights-reserved despite being marketed as open source), and the **headline binary-install path is dead** — `scripts/install.sh` / `install.ps1` resolve `Gitlawb/zero`'s GitHub Releases, which 404 (no release has ever been published), so the only working install is `go run` — which then trips a **Go-version mismatch** (README says 1.24, `go.mod` requires 1.25). A new user on a machine without Go 1.25 cannot get to first run.
|
||||
|
||||
Beyond that, the recurring themes are **docs/claims drifting from the code** (a `/usage` slash command and a `--theme` flag that don't exist; a keyless local-model on-ramp the health probe blocks), **error messages that don't guide recovery** (no-key errors dump a raw OpenAI URL instead of pointing at `zero setup`; `doctor` reports "Overall: pass" with no credential and leaks Go `map[...]` syntax), and **low-contrast theme tokens carrying real content** (faint/faintest fail WCAG while holding line numbers, diff headers, and help text).
|
||||
|
||||
### Severity rollup (35 findings, post-dedup)
|
||||
|
||||
| Severity | Count |
|
||||
|---|---|
|
||||
| Critical | 2 |
|
||||
| High | 11 |
|
||||
| Medium | 11 |
|
||||
| Low | 5 |
|
||||
| Info | 6 |
|
||||
|
||||
(Counts merge cross-area duplicates — LICENSE, install-404, Go-version, and the no-provider-setup-pointer cluster each appeared from multiple area finders.)
|
||||
|
||||
## 2. Environment & method
|
||||
|
||||
- **Build:** `go build ./cmd/zero` → clean on go1.26.4; binary run as `/tmp/zero` with **isolated config** (`HOME`/`XDG_CONFIG_HOME` → temp dirs) so findings reflect a fresh machine, not local state.
|
||||
- **Ran (real output captured):** `zero --help`, `doctor`, `exec` (no provider / bad key / unreachable URL / empty prompt), `setup <provider> --verify` (no key / wrong key, openai + ollama), `providers check --connectivity`, `config`, `version`; bad-flag / bad-mode / bad-effort / malformed-config error paths; the existing TUI visual tests (`command_card_contrast`, `theme_select`/luminance, `width_tiers`, `wrap_whitespace`, `theme_reprobe`) — all pass; throwaway Go tests measuring display-cell widths of the truncation helpers with CJK input; WCAG contrast ratios computed from the theme hex tokens; a `creack/pty` capture of a monochrome onboarding frame to diff SGR codes across `NO_COLOR` values.
|
||||
- **Terminal profiles:** color/`NO_COLOR`/profile behavior was checked via the `colorprofile` dependency + one pty capture; **truecolor/256/16-color and a physically light terminal were not driven frame-by-frame** (see Confidence notes).
|
||||
- **Could not capture:** live interactive TUI frames — Bubble Tea v2's alternate-screen buffer yields only escape setup/teardown to `script`/`expect`. Rendered first-run wizard, resize redraw, streaming fade, and narrow-tier painting are assessed **from code + the passing visual tests** and marked unverified where relevant.
|
||||
- **Adversarial pass:** every finding faced 1–2 skeptics that re-ran/re-read to disprove it; 2 were dropped (see §A.dropped). Several of my own first-pass guesses were corrected (exit codes are correct; meaning never depends on color alone; a reduce-motion opt-out does exist).
|
||||
|
||||
## 3. "First 5 minutes" — new-user walkthrough & verdict
|
||||
|
||||
1. **Clone, read README.** Strong pitch ("terminal coding agent you fully own", "no telemetry"). Two install options offered as peers: a binary one-liner and `go run ./cmd/zero`.
|
||||
2. **Try the binary one-liner** (`scripts/install.sh`) — the natural choice without Go. → **Dead-ends** with a curl 404 / "could not read tag_name" (no GitHub Release exists). [C1]
|
||||
3. **Fall back to `go run ./cmd/zero`** per the README ("requires Go 1.24+"). On Go 1.24 → **build error** `go.mod requires go >= 1.25.0`. [H2] (Works on 1.25+.)
|
||||
4. **First launch with no key.** Headless `exec` prints a clear, exit-coded error — but it names only `OPENAI_MODEL`/`OPENAI_API_KEY` and a raw `platform.openai.com` URL, **never `zero setup` or `zero auth`** (the commands built for this moment). [H/M cluster] The guided `zero setup` wizard itself is excellent [I1] — if the user discovers it.
|
||||
5. **Verify setup** (`zero setup openai --verify`) with no key yet → "**The provider rejected the API key**" — but no key was sent. [M1] Or try the README's headlined **keyless local-model path** (Ollama at localhost) and run `doctor`/`--verify` → "**localhost hosts are blocked**" + "make sure the server is running" (self-contradictory). [H1]
|
||||
6. **Run `zero doctor`** to sanity-check → "**Overall: pass**" even with no provider credential [H], and an unreadable `missing: map[gopls:...]` Go-map blob [H].
|
||||
|
||||
**Verdict: a stranger cannot reliably reach a working first session in 5 minutes.** Not because the agent is weak — it's the install/license/version/onboarding-error plumbing around it. Once a user is past setup with a real key, the core loop and TUI are solid.
|
||||
|
||||
## 4. Findings by area
|
||||
|
||||
### A. First-run & onboarding
|
||||
|
||||
#### C1 · Both README binary-install paths point at a repo that 404s — `scripts/install.sh` and `install.ps1` dead-end for every new user
|
||||
|
||||
**Status (2026-06-21):** Fixed (in-repo) — `622e4b4`. README/INSTALL demote the binary path to "coming soon" and lead with `go run`; release-artifacts.yml now publishes a GitHub Release on a `v*` tag. Publishing the release + making the repo public are MANUAL maintainer steps (repo owner/name already matched go.mod).
|
||||
- **Severity:** critical · also: G-oss-readiness
|
||||
- **Where:** `README.md:55-57, docs/INSTALL.md:52, scripts/install.sh:4 (ZERO_REPO default Gitlawb/zero)`
|
||||
- **Evidence:** curl -s -o /dev/null -w '%{http_code}' https://api.github.com/repos/Gitlawb/zero -> 404. /releases, /releases/latest, /tags all return {"message":"Not Found","status":"404"}. install.sh:106 resolves api_url="${ZERO_GITHUB_API%/}/repos/${ZERO_REPO}/releases/latest" then install.sh:111 `[ -n "$tag" ] || fail "could not read tag_name from $api_url"`. With curl --fail (install.sh:80) the API call itself errors out first. README presents this as the 'or install a release binary' path alongside go run.
|
||||
- **Impact:** A stranger who picks the README's headline binary-install path (the natural choice on a machine without Go) hits an immediate, opaque failure ('could not read tag_name' or a curl --fail HTTP error). There is no published release, so the documented one-liner cannot succeed for anyone. Only the `go run ./cmd/zero` path works.
|
||||
- **Fix:** Publish at least one GitHub release with the documented archive+.sha256 assets under the correct public repo, and fix the ZERO_REPO default / README links to that repo's real owner/name. Until releases exist, demote the binary-install path in README/INSTALL to 'coming soon' and lead with `go run ./cmd/zero`.
|
||||
|
||||
#### C2 · No LICENSE file; README says license 'being finalized' — OSS-release blocker (confirming established fact, still true @ f7ee189)
|
||||
|
||||
**Status (2026-06-21):** Skipped — license choice deferred by the maintainer ("leave the license portion now"). LICENSE / README §License / package.json license remain a MANUAL step.
|
||||
- **Severity:** critical · also: G-oss-readiness
|
||||
- **Where:** `README.md:330-332; repo root (no LICENSE* file)`
|
||||
- **Evidence:** `ls LICENSE*` -> no matches. README.md:332: 'License is being finalized; a `LICENSE` file will be added before the public release.'
|
||||
- **Impact:** A new user cloning the repo has no legal grant to use, modify, or redistribute. For an 'open-source' project this blocks adoption/redistribution and is a release blocker.
|
||||
- **Fix:** Add a LICENSE file (e.g. Apache-2.0/MIT) and update README §License to name it.
|
||||
|
||||
#### H1 · Local-model verification/health commands fail with a misleading 'localhost hosts are blocked' error — breaks the README's headlined keyless path
|
||||
|
||||
**Status (2026-06-21):** Fixed — `24c8de3`. Loopback allowed only for a user-configured local base_url; redirects + other private ranges stay blocked.
|
||||
- **Severity:** high
|
||||
- **Where:** `internal/providerhealth/providerhealth.go:374-377 (also :81,:94 loopback prefixes); surfaced by `zero setup <local> --verify`, `zero providers check <local> --connectivity`, `zero doctor``
|
||||
- **Evidence:** `zero setup ollama --verify` -> `[zero] setup verification failed: Could not reach the provider endpoint. Check the base URL ... and that the server is running. (provider connectivity URL is unsafe: localhost hosts are blocked)`. `zero providers check ollama --connectivity` -> status: fail / `provider.connectivity: provider connectivity URL is unsafe: localhost hosts are blocked` / exit 3. validateEndpoint() hard-rejects normalized=='localhost' and 127.0.0.0/8 + ::1. Guard is confined to providerhealth probe path (grep shows blockedAddrReason/endpointSafetyError only in providerhealth + unrelated web_fetch.go) so actual `zero exec` chat again
|
||||
- **Impact:** README §Why Zero and Quick start headline 'Local models need no key at all' as the easiest on-ramp. A new user who runs Ollama/LM Studio at localhost:11434 and then runs setup --verify / doctor / providers check (all suggested) sees FAIL with a self-contradictory message ('make sure the server is running' + 'localhost is blocked'), and the 'next' hint tells them to re-run the exact command that will keep failing. The keyless p
|
||||
- **Fix:** Allow loopback/localhost in the provider connectivity probe for explicitly user-configured local providers (the SSRF guard is meant for fetched/remote URLs, not the user's own configured base_url). At minimum, special-case local provider kinds to skip the loopback block, and drop the contradictory 'server is running' wrapper when the real cause is the loopback policy.
|
||||
|
||||
#### H2 · go.mod requires `go 1.25.0` (+ toolchain go1.26.4) but README badge and Quick start say Go 1.24 — a Go 1.24 user cannot build from source
|
||||
|
||||
**Status (2026-06-21):** Fixed — `622e4b4`. README badge + quick start now say Go 1.25+.
|
||||
- **Severity:** high · also: G-oss-readiness
|
||||
- **Where:** `go.mod:3 (`go 1.25.0`), go.mod:4 (`toolchain go1.26.4`); README.md:16 badge 'Go-1.24', README.md:52 'requires Go 1.24+'`
|
||||
- **Evidence:** go.mod: `go 1.25.0` / `toolchain go1.26.4`. README.md:16 ``, README.md:52 `# run from source (requires Go 1.24+)`. A Go 1.24.x toolchain refuses a module declaring `go 1.25.0` unless auto-toolchain download is enabled (GOTOOLCHAIN=auto + network); offline/pinned 1.24 users get a 'go.mod requires go >= 1.25.0' build error.
|
||||
- **Impact:** The README explicitly invites Go 1.24 users to `go run ./cmd/zero` (the ONLY working install path given the missing releases). They hit a build failure on the documented version, with no hint that 1.25 is actually required. Builds confirmed working only on go1.26.4 in this env.
|
||||
- **Fix:** Make the docs match go.mod: change the badge and Quick-start note to 'requires Go 1.25+'. (Or, if 1.24 support is intended, lower the go.mod directive to 1.24 and verify it compiles.)
|
||||
|
||||
#### M1 · `zero setup <provider> --verify` with NO key set reports 'The provider rejected the API key' — but no key was ever sent
|
||||
|
||||
**Status (2026-06-21):** Fixed — `42b7123`. `--verify` distinguishes "no API key found" from "rejected".
|
||||
- **Severity:** medium
|
||||
- **Where:** `internal/cli/setup.go:59-77 (verify branch); verified via runtime`
|
||||
- **Evidence:** With OPENAI_API_KEY unset: `zero setup openai --verify` -> '... setup verification failed: The provider rejected the API key. Double-check the key value (and that it is for this provider), then re-run setup. (Provider openai requires API credentials.)' — identical message to the WRONG-key case (OPENAI_API_KEY=sk-wrongkey123).
|
||||
- **Impact:** Confusing for a first-timer who ran setup without exporting a key yet (a very common ordering): the tool says the key was 'rejected'/'double-check the key value' when the real problem is there is no key at all. Sends them debugging a key they never set.
|
||||
- **Fix:** Distinguish the missing-credentials case from the rejected-credentials case: when no key/env is present, say 'No API key found — export OPENAI_API_KEY (or pass --api-key-env) then re-run', and reserve 'rejected the key' for actual 401s.
|
||||
|
||||
#### I1 · The guided setup wizard copy itself is well-written and self-explanatory (source-level; live frames unverified)
|
||||
|
||||
**Status (2026-06-21):** Info — no action (positive finding).
|
||||
- **Severity:** info
|
||||
- **Where:** `internal/tui/onboarding.go:1191-1650`
|
||||
- **Evidence:** Strings include 'Welcome to Zero' / 'A terminal agent for changing real code.' (1202-1204), a Safety panel 'Zero asks before running shell commands or changing files. Unsafe mode stays off unless you explicitly enable it. Default: ask before risky work.' (1191-1196), clear step headers ('Choose a provider', 'Choose a model', 'Paste your <name> API key' with 'Saved keys stay in your user config' / 'Blank uses <ENV> from your shell'), model-ID examples (1311), and a consistent footer ('↑/↓ choose Enter continue q quit'). Non-interactive `zero setup <p>` output is also good: prints config path + concrete 'next:' steps + a runnable 'try this:'
|
||||
- **Impact:** Positive: the wizard and non-interactive setup are a strong on-ramp; the onboarding problems above are in the surrounding install/license/version/verify plumbing, not the wizard copy.
|
||||
- **Fix:** None — keep. (Recommend capturing real wizard frames via a pty harness in CI since alt-screen content isn't visible to script/expect, to guard the rendering that could not be verified here.)
|
||||
|
||||
|
||||
### B. Interaction flows & UX
|
||||
|
||||
#### H3 · /input-style is a fully inert command — registered, in /help, autocompletes, but does nothing
|
||||
|
||||
**Status (2026-06-21):** Fixed — `1b2e453`. Command + kind + handler + dead helper removed.
|
||||
- **Severity:** high
|
||||
- **Where:** `internal/tui/commands.go:288-296 (registry) + internal/tui/model.go:2964-2969 (handler) + internal/tui/rendering.go:43-53 (output)`
|
||||
- **Evidence:** Handler: `case commandInputStyle: ... text: shellOnlyCommandText(command.name)`. shellOnlyCommandText renders a warning card whose only body line is "This control is available in the TUI but does not have a backend setting yet." Captured from a test I ran: `command-card input-style / status: warning / State / This control is available in the TUI but does not have a backend setting yet. / hint: use /help to inspect active commands`. grep confirms NO other input-style logic exists: `grep -rn 'input-style|inputStyle|InputStyle' internal --include='*.go' | grep -v _test` returns only the registry entry + this handler. Yet it is listed in /help (S
|
||||
- **Impact:** A new user discovers /input-style via `/` autocomplete or /help (where its description, "Show input style state", implies it surfaces real configuration), runs it, and gets a warning card admitting it's a no-op stub. This is the one remaining surfaced-but-inert slash command (the sibling stubs /style, /compact, /theme, /effort flagged in the earlier code audit are now actually wired on current main). It erodes trust in the com
|
||||
- **Fix:** Remove the /input-style entry from commandDefinitions (commands.go) and drop the commandInputStyle kind + its case in model.go, so /help and autocomplete stop advertising it. Re-add it only when a backend setting exists.
|
||||
|
||||
#### H4 · README's TUI slash-command table advertises /usage, which is not a slash command (resolves to 'unknown command')
|
||||
|
||||
**Status (2026-06-21):** Fixed — `e68d6cb`. `/usage` removed from the README slash table.
|
||||
- **Severity:** high
|
||||
- **Where:** `README.md:89 (`| \`/doctor\` \`/usage\` \`/config\` | health, cost, and config without leaving the chat |`) under the "## The TUI" slash-command table`
|
||||
- **Evidence:** The registry has no /usage (full name+alias dump from commands.go contains /doctor, /config, but no /usage). I ran a test: `parseCommand("/usage").kind == commandUnknown` (PASS), and `formatGroupedCommandHelp()` does NOT contain /usage (`HELP has ... /usage=false`). `zero --help` shows `usage Summarize token usage and estimated cost` exists ONLY as a CLI subcommand. So a user who types `/usage` in-chat (per the README) hits the commandUnknown path (model.go:2976) -> "unknown command: /usage" (unless they happen to have authored .zero/commands/usage.md, which a new user has not). The table's caption literally promises "...cost...without leavi
|
||||
- **Impact:** A brand-new user reads the only slash-command reference in the README, types /usage to check token cost mid-session, and gets an error. The advertised in-chat cost readout does not exist as a slash command; the real feature is the separate `zero usage` CLI subcommand, which contradicts the table's 'without leaving the chat' promise.
|
||||
- **Fix:** Either add a /usage slash command (kind+handler showing the same token/cost summary as the `zero usage` subcommand) or remove `/usage` from the README:89 table and document `zero usage` as a CLI command instead.
|
||||
|
||||
#### L1 · Ctrl+E in the composer is hijacked by mouse-release toggle, breaking the emacs end-of-line binding (while Ctrl+A=home still works)
|
||||
|
||||
**Status (2026-06-21):** Deferred — low (Ctrl+E composer binding).
|
||||
- **Severity:** low
|
||||
- **Where:** `internal/tui/model.go:818-825 (top-level Ctrl+E intercept) vs internal/tui/composer.go:223-225 (composer Ctrl+E = end-of-line)`
|
||||
- **Evidence:** The top-level key switch handles `case keyCtrl(msg, 'e'):` and unconditionally toggles m.mouseReleased then `return`s, before the composer dispatch (model.go:1140 `applyComposerKey`) is ever reached. composer.go:223 declares `case keyIs(msg, tea.KeyEnd) || keyCtrl(msg, 'e'): state.cursor = composerLineEnd(state)` — that Ctrl+E arm is therefore unreachable from the composer. By contrast Ctrl+A/K/U/W are NOT intercepted at top level (`grep -n "keyCtrl(msg, 'a'|'e'|'k'|'u'|'w')" model.go` returns ONLY line 818 = Ctrl+E), so they reach the composer normally. The `?`-help overlay (keybinding_help.go:53) documents Ctrl+E only as 'release the mouse
|
||||
- **Impact:** A user with emacs/readline muscle memory: Ctrl+A correctly jumps to line start, but Ctrl+E (expected: jump to line end) instead toggles mouse-capture mode and prints a 'Mouse released' system notice — a surprising, asymmetric behavior in the input editor.
|
||||
- **Fix:** Either remove the dead Ctrl+E arm from composer.go:223 (leaving only tea.KeyEnd), or gate the top-level Ctrl+E mouse-toggle so it only fires on an empty/inactive composer (like the `?` overlay does at model.go:982) and let Ctrl+E reach the composer when editing text; then update keybinding_help.go accordingly.
|
||||
|
||||
#### I2 · Two permission decisions share hotkey 'y' but are mutually exclusive by tool type (no actual collision)
|
||||
|
||||
**Status (2026-06-21):** Info — no action (no real collision).
|
||||
- **Severity:** info
|
||||
- **Where:** `internal/tui/permission_prompt.go:45-48 + internal/agent/loop.go:1737-1742,1798-1805`
|
||||
- **Evidence:** permissionOptions maps both AlwaysAllowPrefix and AlwaysAllow to hotkey "y". The dedup in permissionOptions is keyed by decision action, not hotkey, so if both appeared in one prompt both 'y' rows would render. However availablePermissionDecisions only appends AlwaysAllowPrefix for shell tools with a command prefix (loop.go:1738), and AlwaysAllow only when permissionSupportsPersistentDecision(toolName) is true — which returns false for `bash/exec_command/write_stdin/apply_patch` (loop.go:1800). So a shell prompt gets AlwaysAllowPrefix=y and a non-shell prompt gets AlwaysAllow=y; they never co-occur.
|
||||
- **Impact:** None today — verified mutually exclusive by tool type. Noting it because the safety relies on a non-local invariant (the loop.go gating); a future tool that is both a shell tool AND persistent-decision-eligible would surface two 'y' rows where the first match wins, silently shadowing the second.
|
||||
- **Fix:** Optional defensive measure: de-duplicate by hotkey in permissionOptions (or assign distinct hotkeys) so the invariant is enforced at the render layer rather than depending on the agent's decision-set gating.
|
||||
|
||||
|
||||
### C. TUI rendering & visual correctness
|
||||
|
||||
#### M5 · middleTruncate (diff card file paths) overflows the budget on wide-rune paths
|
||||
|
||||
**Status (2026-06-21):** Deferred — explicitly out of scope for this PR (wide-rune truncation budget; outer width clip already prevents frame breakage per I4).
|
||||
- **Severity:** medium
|
||||
- **Where:** `internal/tui/startup.go:138 middleTruncate (used for the diff card path head at internal/tui/rendering.go:1471 with budget innerWidth/2)`
|
||||
- **Evidence:** middleTruncate is also rune-count based (`if len(runes) <= limit`, then string(runes[:front])+"…"+string(runes[len-back:])). Ran the real fn: `middleTruncate(strings.Repeat("路",40), 20)` returns 39 cells for a 20-cell budget (test log: `middleTruncate(20) -> cells=39 (want <=20)`).
|
||||
- **Impact:** A file path containing CJK characters in the diff card header renders ~2x its budget; the outer fitStyledLine re-clips it so the card edge survives, but the head/right-aligned counts (+N/-N) collide or the path is cut harder than intended — the path becomes harder to read for non-Latin filenames.
|
||||
- **Fix:** Make middleTruncate measure with lipgloss.Width and cut front/back halves with splitAtWidth on the reversed/forward string, or fall back to the width-aware truncateRunes once that is fixed.
|
||||
|
||||
#### I4 · Wide-rune handling is correct in prose/markdown/table/user-prompt wrap and at the outer card fit — overflow does NOT break the card frame
|
||||
|
||||
**Status (2026-06-21):** Info — no action (verified good).
|
||||
- **Severity:** info
|
||||
- **Where:** `internal/tui/rendering.go:381 wrapPlainText / :442 splitAtWidth / :463 splitPreservingWidth; internal/tui/startup.go:213 fitStyledLine / :223 truncateStyledLine`
|
||||
- **Evidence:** These all measure via lipgloss.Width / per-glyph glyphWidth (e.g. truncateStyledLine line 260 accumulates glyphWidth and also tracks OSC-8 link state so truncation can't leak a hyperlink). toolCard re-fits every body line to innerWidth (rendering.go:1387) so even the buggy truncateRunes output cannot push the card past the terminal width — verified: a 100-col CJK diff card produced all lines at exactly 100 cells. Assistant prose is also capped at 96 cols (assistantMeasureCap, rendering.go:360) for readability on wide terminals. WindowSizeMsg (model.go:1249) resizes composer and resets fade state cleanly.
|
||||
- **Impact:** Positive: the layout is robust against frame-breaking; the wide-rune bug manifests as silent content loss inside the card rather than a corrupted UI. Documents why the high-severity finding is content-loss, not layout-break.
|
||||
- **Fix:** No change needed; noted so the truncateRunes fix is scoped to content fidelity, and to record that interactive frames themselves remain UNVERIFIED (no pty).
|
||||
|
||||
|
||||
### C/E. Color, theme & accessibility
|
||||
|
||||
#### H5 · faintest fails WCAG even the 3.0 UI floor in BOTH themes; it carries functional content (line numbers, diff @@/+++/--- headers, tool args, separators)
|
||||
|
||||
**Status (2026-06-21):** Fixed — `cc3b5e3`. faintest raised to WCAG AA (>=4.5) in both themes.
|
||||
- **Severity:** high
|
||||
- **Where:** `internal/tui/theme.go:135 (dark faintest #3a3a40), :168 (light faintest #9b9ba3); consumed as zeroTheme.faintest / diffMeta / addLineNum / delLineNum / toolArg`
|
||||
- **Evidence:** Computed WCAG ratios: light faintest #9b9ba3 on panel #ececed = 2.34; dark faintest #3a3a40 on panel #0e0e10 = 1.71, and on the unpainted canvas #070708 = 1.78. AA-normal needs 4.5, the relaxed UI/large floor is 3.0 — these are below even 3.0. faintest renders diffMeta (theme.go:223: '@@ hunks, +++/--- headers') and the diff gutter line numbers (theme.go:226-227), which are functional, not decorative.
|
||||
- **Impact:** On a brand-new user's terminal, diff hunk headers, line numbers, and tool argument hints are near-invisible to anyone with even mild low vision, and washed out for everyone on a bright screen. The existing test passes because TestLightPaletteContrastAndHierarchy only asserts a luminance DELTA (panelL-inkL>=0.5) and monotonic ordering — it never checks a WCAG ratio, so these sub-floor values ship green.
|
||||
- **Fix:** Darken light faintest toward ~#6e6e76 (>=3.0 on #ececed) and lighten dark faintest toward ~#5a5a62 (>=3.0 on #0e0e10/#070708); then add a test asserting actual WCAG ratio >= 3.0 for every token used to carry text, not just a luminance delta.
|
||||
|
||||
#### H6 · faint is sub-AA in both themes yet carries instructional/navigation text (help footer, MCP wizard hints, composer placeholder, working timer)
|
||||
|
||||
**Status (2026-06-21):** Fixed — `cc3b5e3`. faint raised to WCAG AA in both themes.
|
||||
- **Severity:** high
|
||||
- **Where:** `internal/tui/theme.go:134 (dark faint #5b5b63), :167 (light faint #78787f); used at keybinding_help.go:89, mcp_add_wizard_view.go:26-160, mcp_manager.go:362/443, model.go:2111/2151/2231`
|
||||
- **Evidence:** WCAG: dark faint #5b5b63 on panel = 2.87 (below the 3.0 UI floor); light faint #78787f on panel = 3.71 (below AA-normal 4.5). It renders real instructions: keybinding_help.go:70 'keybindingHelpFooter = "? or Esc to close · /help for slash commands"' shown via zeroTheme.faint (the user's only on-screen cue for how to exit the help overlay), mcp_manager.go:362 the nav legend 'type search up/down navigate Enter action Esc close', and the composer placeholder (model.go:2231).
|
||||
- **Impact:** The dismiss hint, navigation legends, MCP setup guidance, and input placeholder are hard to read for low-vision users and dim for everyone — a new user trying to learn the keys is reading the help in the lowest-contrast color in the UI.
|
||||
- **Fix:** Raise faint to clear 4.5 (light) / >=3.0 (dark) on its panel, e.g. dark #6f6f78, light #5f5f66. Reserve sub-AA grays strictly for non-text decoration (rules/borders), which already have separate tokens (line/line2).
|
||||
|
||||
#### M2 · Light-theme accent (4.34) is below AA for normal text, despite a test that claims to verify light accent contrast
|
||||
|
||||
**Status (2026-06-21):** Fixed — `cc3b5e3`. Light accent #4d7a08->#477006 (AA); theme test now asserts a true WCAG ratio.
|
||||
- **Severity:** medium
|
||||
- **Where:** `internal/tui/theme.go:169 (light accent #4d7a08); test internal/tui/theme_select_test.go:83`
|
||||
- **Evidence:** WCAG: light accent #4d7a08 on panel #ececed = 4.34 (AA-normal needs 4.5). accent renders the user gutter '❯' (theme.go:38/210), bash prompt (theme.go:48/218), spinner, and focus. The test at theme_select_test.go:83 only asserts 'panelL - relLum(accent) < 0.25' (a luminance delta, not a contrast ratio), so 4.34 passes. Dark accent #caff3f is fine (16.4).
|
||||
- **Impact:** On a light terminal the brand prompt glyph and bash gutter sit just under the readability bar for small text. Borderline rather than broken, but it is the most-repeated colored glyph on screen.
|
||||
- **Fix:** Darken light accent slightly (e.g. #436a07 → ~4.7) to clear AA-normal, and change the test to assert a real WCAG ratio (>=4.5 for text-bearing accent) instead of a luminance delta.
|
||||
|
||||
#### M3 · NO_COLOR honored only for strconv.ParseBool-style values; NO_COLOR=yes / NO_COLOR=anything leaves the UI in full color, violating the no-color.org spec
|
||||
|
||||
**Status (2026-06-21):** Fixed — `7fc296b`. NO_COLOR with any non-empty value forces the Ascii profile.
|
||||
- **Severity:** medium
|
||||
- **Where:** `dependency github.com/charmbracelet/[email protected] env.go:115-117 (envNoColor uses strconv.ParseBool); zero never sets WithColorProfile (internal/tui/run.go:25-35), so it inherits this`
|
||||
- **Evidence:** Drove /tmp/zero under a pty across NO_COLOR variants and diffed emitted SGR: 'NO_COLOR=1' -> colorSGR=false (306 bytes, monochrome), 'NO_COLOR=true' -> false, but 'NO_COLOR=yes' -> colorSGR=true (676 bytes, full color) and 'NO_COLOR=foo' -> colorSGR=true. Source: colorprofile env.go:116 'noColor, _ := strconv.ParseBool(env.get("NO_COLOR"))'. The no-color.org spec says NO_COLOR present with ANY non-empty value must disable color. (NO_COLOR=1 correctly produced a readable bold-only frame — color degrades cleanly when the value is recognized.)
|
||||
- **Impact:** A user who follows the common convention 'export NO_COLOR=yes' (or any non-bool truthy value) still gets a full-color TUI, defeating their accessibility/preference setting. Silent and surprising.
|
||||
- **Fix:** Don't rely on the dependency's ParseBool: detect NO_COLOR yourself (present and non-empty => force colorprofile.Ascii via tea.WithColorProfile) in run.go, matching no-color.org. At minimum document that only NO_COLOR=1/true work today.
|
||||
|
||||
#### M4 · Documented-in-code --theme flag does not exist; Options.Theme is read but never populated, so the only non-interactive theme control is the undocumented ZERO_THEME env var
|
||||
|
||||
**Status (2026-06-21):** Fixed — `3b73074`. Real `--theme {auto|dark|light}` flag wired to Options.Theme; stale comment corrected.
|
||||
- **Severity:** medium
|
||||
- **Where:** `internal/tui/theme.go:12 and theme_select.go:18 (both claim a '--theme flag'); internal/tui/options.go:52-54 (Theme field); internal/tui/model.go:559 (reads it)`
|
||||
- **Evidence:** Binary: 'zero --theme light -p hi' -> 'unknown command "--theme"'. 'zero --help' global Flags list has no theme flag. grep for assignments to Options.Theme / '.Theme =' across cmd/ and internal/ returns ZERO hits — the field is only ever READ (model.go:559 resolveThemeMode(options.Theme, ...)). Yet theme_select.go:18 comments 'the --theme flag, threaded via Options.Theme' and theme.go:12 says startup selection happens via '--theme'.
|
||||
- **Impact:** A user reading the code (or expecting the documented flag) cannot pick a theme at launch via --theme. They must discover ZERO_THEME (documented nowhere) or use /theme after the TUI is already up. The stale comments actively mislead.
|
||||
- **Fix:** Either wire a real --theme {auto|dark|light} flag that sets Options.Theme, or delete the --theme references from theme.go:12 and theme_select.go:18 and document ZERO_THEME + /theme as the supported controls.
|
||||
|
||||
#### L2 · Accessibility/customization controls (NO_COLOR, ZERO_THEME, ZERO_NO_FADE, motion opt-out) are completely undocumented
|
||||
|
||||
**Status (2026-06-21):** Fixed — `3cd0a50`. README "Accessibility & appearance" section documents NO_COLOR/ZERO_THEME/--theme//theme/ZERO_NO_FADE.
|
||||
- **Severity:** low
|
||||
- **Where:** `README.md / docs/ (grep for NO_COLOR|ZERO_THEME|ZERO_NO_FADE|--theme|reduce.motion returns nothing); controls exist at streaming_fade.go:28-44 and theme_select.go:19`
|
||||
- **Evidence:** grep -rni 'NO_COLOR|ZERO_THEME|ZERO_NO_FADE|--theme|reduce.motion' README.md docs/ => no matches. The controls themselves are real and tested: streaming_fade.go:28-44 honors ZERO_NO_FADE and auto-disables fade over SSH/tmux/16-color/no-TTY; /theme and ZERO_THEME switch palettes.
|
||||
- **Impact:** A user who finds the streaming-text fade or color distracting (motion sensitivity) has no way to know ZERO_NO_FADE exists; a NO_COLOR/light-terminal user can't discover ZERO_THEME. The a11y story is decent in code but invisible to the new user it would help.
|
||||
- **Fix:** Add a short 'Accessibility / Appearance' section to the README listing NO_COLOR (note the value caveat), ZERO_THEME=auto|dark|light, /theme, and ZERO_NO_FADE (reduce-motion opt-out).
|
||||
|
||||
#### I3 · Meaning does not depend on color alone, and there IS a reduce-motion opt-out — both verified
|
||||
|
||||
**Status (2026-06-21):** Info — no action (verified good).
|
||||
- **Severity:** info
|
||||
- **Where:** `internal/tui/rendering.go:1512/1516 (diff signs), :1200-1203 (permission ✓/✗), view.go:277-285 (mode labels), :996 (PERMISSION text badge); streaming_fade.go:28-44 (ZERO_NO_FADE)`
|
||||
- **Evidence:** Diffs emit explicit sign columns: rendering.go:1512 diffBodyLine(...,"+",...) for adds and :1516 "−" for dels, so add/del survive color-stripping. Permission outcome uses glyphs ✓ (rendering.go:1200) / ✗ (:1203), the card is gated by a text 'PERMISSION' badge (:996), and permission MODE shows a text label 'auto-approve'/'ask'/'unsafe' (view.go:277-285) not color-only. Streaming fade has a reduce-motion path: ZERO_NO_FADE plus auto-disable on SSH/tmux/ANSI/no-TTY (streaming_fade.go:29-43); the spinner has no separate opt-out but is a single glyph, not a fade. NO_COLOR=1 pty frame rendered fully readable in bold-only.
|
||||
- **Impact:** Positive: the core surfaces (diffs, permissions, modes) remain interpretable for colorblind users and under NO_COLOR. Recorded so it's not re-flagged; the only motion gap is the spinner having no independent disable.
|
||||
- **Fix:** Optional: extend the reduce-motion opt-out to also still the spinner glyph when ZERO_NO_FADE (or a NO_COLOR/reduce-motion signal) is set, for full motion-sensitivity coverage.
|
||||
|
||||
|
||||
### D. Consistency & polish
|
||||
|
||||
#### H7 · Bad/missing API key produces a mangled, self-contradicting error paragraph (Redact corrupts the provider's own help text)
|
||||
|
||||
**Status (2026-06-21):** Fixed — `8b9b479`. 401/403 lead with an actionable curated message; Redact only scrubs token-shaped Bearer words.
|
||||
- **Severity:** high
|
||||
- **Where:** `internal/providers/providerio/providerio.go:234-249 (Redact); surfaced via exec/-p with no/invalid key`
|
||||
- **Evidence:** Live: `/tmp/zero -p "hi" --model not-a-real-model` -> `[zero] auth error: You didn't provide an API key. You need to provide your API key in an Authorization header using authorization [REDACTED] (i.e. Authorization: authorization [REDACTED] or as the password field (with blank username) if you're accessing the API from your browser and are prompted for a username and password. You can obtain an API key from https://platform.openai.com/account/api-keys.` The raw 401 prose contains the literal word 'Bearer' twice as INSTRUCTION text; Redact() (line 242-247: `if EqualFold(TrimRight(words[index],":"),"Bearer")` -> rewrite this+next word) treats
|
||||
- **Impact:** A brand-new user whose only mistake is a missing/typo'd key gets an incomprehensible, grammatically-broken paragraph that (a) reads like a bug, (b) gives browser-username/password advice irrelevant to a CLI, (c) names platform.openai.com even when they aren't trying to use OpenAI, and (d) never tells them to run `zero auth` / `zero setup`. This is the single most likely error a first-run user hits.
|
||||
- **Fix:** Don't pass raw upstream auth-error bodies through verbatim. For 401/403 emit a curated message: `auth error: your <provider> API key is missing or invalid. Run \`zero auth\` or set <ENV>=...`. Keep the raw body only behind a --verbose/debug flag. Also tighten Redact so the 'Bearer' heuristic only fires on token-shaped following words (e.g. matches sk-/long base64), not arbitrary prose.
|
||||
|
||||
#### H8 · doctor leaks raw Go map[...] syntax for ANY nested-map detail — confirmed on lsp.servers AND config.validation (shared formatDetails bug)
|
||||
|
||||
**Status (2026-06-21):** Fixed — `bb4b348`. formatDetailValue renders nested maps as "k: v"; no more raw map[...].
|
||||
- **Severity:** high
|
||||
- **Where:** `internal/doctor/doctor.go:277 (formatDetails uses %v on the value)`
|
||||
- **Evidence:** lsp.servers: `missing: map[gopls:install with \`go install golang.org/x/tools/gopls@latest\` ... pyright-langserver:install with \`npm install -g pyright\` ...] | present: map[rust-analyzer:on PATH]`. config.validation (with a malformed config.json): `/tmp/zbad/.zero/config.json: map[col:4 error:invalid config JSON: invalid character 't' looking for beginning of object key string line:1]` — the actual error sentence is buried inside a Go map dump. Root cause line 277: `fmt.Sprintf("%s: %v", ..., redaction.RedactValue(value, ...))` where value is a map[string]any.
|
||||
- **Impact:** Builds on the established lsp leak: this is not isolated to one check — every doctor detail whose value is a nested map renders as unreadable Go syntax. The MOST useful diagnostics (which LSP to install, what's wrong with your config + the line/col) are exactly the ones mangled into `map[...]`. New users running `zero doctor` to fix a problem get gibberish.
|
||||
- **Fix:** In formatDetails, special-case map[string]any values: render them as indented `key: value` sub-lines (or `k=v, k=v`) instead of `%v`. For config.validation specifically, hoist line/col/error into a one-line human string (`line 1, col 4: invalid character 't' ...`).
|
||||
|
||||
#### M6 · Inconsistent validation of enum-like flags: --mode rejects bad values with a great message, --reasoning-effort silently ignores garbage
|
||||
|
||||
**Status (2026-06-21):** Deferred — medium not in this PR's scope (crit/high + OSS files + contrast). Tracked for a follow-up (validate --reasoning-effort like --mode).
|
||||
- **Severity:** medium
|
||||
- **Where:** `internal/cli/exec.go:902 (mode) vs exec.go:965-998 (reasoning effort)`
|
||||
- **Evidence:** `zero -p hi --mode bogus` -> `[zero] unknown mode "bogus". Valid modes: smart, deep, fast, large, precise.` (exit 2 — exemplary microcopy). But `zero -p hi --reasoning-effort wat` -> `gpt-4.1 does not support reasoning effort; ignoring --reasoning-effort wat` then proceeds; `wat` is never validated as a value (the message only fires because gpt-4.1 lacks the capability — on a model that DOES support effort, an invalid value like `wat` would pass through unchecked).
|
||||
- **Impact:** Inconsistent error contract across sibling flags. A typo in --reasoning-effort is silently swallowed instead of corrected with the valid set (low/medium/high), so users think their setting applied when it didn't.
|
||||
- **Fix:** Validate --reasoning-effort against the allowed enum the same way --mode does, with a `Valid efforts: low, medium, high.` message; only fall back to the capability-skip notice when the value itself is valid.
|
||||
|
||||
#### L3 · Brand capitalization inconsistent across one CLI surface (ZERO / Zero / zero) and `zero version` prints no real version
|
||||
|
||||
**Status (2026-06-21):** Deferred — low (brand capitalization + real version string).
|
||||
- **Severity:** low
|
||||
- **Where:** ``zero --help` header vs body; `zero version``
|
||||
- **Evidence:** `zero --help` first line: `ZERO terminal coding agent`; the 10 command descriptions all say `Zero` (e.g. 'List Zero model registry entries'); `zero version` -> `zero dev`. So one help screen shows three casings, and the release binary reports its version as the literal word `dev`.
|
||||
- **Impact:** Cosmetic inconsistency a design-conscious user notices immediately; `zero dev` on a fresh clone gives no way to tell which build they have when filing issues.
|
||||
- **Fix:** Pick one brand casing (recommend 'Zero') and use it everywhere including the --help header. Stamp a real version (git describe / tag) into the build via ldflags so `zero version` is meaningful.
|
||||
|
||||
#### I5 · Empty states and existing polish tests are genuinely clean
|
||||
|
||||
**Status (2026-06-21):** Info — no action (verified good).
|
||||
- **Severity:** info
|
||||
- **Where:** `providers/sessions/skills/plugins/usage commands; internal/tui/*_test.go`
|
||||
- **Evidence:** Empty-state copy is clear and path-aware: `No Zero skills found in /tmp/.../skills.`, `No local Zero plugins loaded.`, `No Zero sessions found.`, and `usage` renders a tidy zero table with `n/a (net LOC <= 0)` guards. `providers` even flags `api key: not set`. Visual tests TestLightPaletteContrastAndHierarchy / TestWidthTierSegments / TestViewNeverExceedsTerminalWidth / TestWrapPlainTextPreservesAlignedWhitespace / TestThemeAutoReProbesBackground / TestHelpRoutesThroughStyledCard / TestStyleCommandCardContentRowTwoTonesCommands all PASS.
|
||||
- **Impact:** Positive: card alignment, width tiers, light/dark contrast, whitespace-preserving wrap, and theme re-probe are regression-guarded and look correct. The polish problems are concentrated in error/warning microcopy, not layout or empty states.
|
||||
- **Fix:** No change needed; keep these tests. Minor terminology nit to consider later: `config` says `active provider: none` while `providers` lists openai as the default keyless profile — reconcile the two so 'active' vs 'default' is unambiguous.
|
||||
|
||||
|
||||
### F. Product completeness & robustness
|
||||
|
||||
#### H9 · `zero doctor` reports "Overall: pass" with NO provider credential — the one tool meant to verify keys gives a false all-clear
|
||||
|
||||
**Status (2026-06-21):** Fixed — `bb4b348`. provider.config fails (not pass) for a remote provider with no credential; keyless local stays pass.
|
||||
- **Severity:** high
|
||||
- **Where:** `internal/doctor/doctor.go:136-156 (providerConfigCheck) + :83-84 (report.OK only flips on StatusFail) + :188 (connectivity is warn/skipped). Reproduced via `zero doctor`.`
|
||||
- **Evidence:** With unset OPENAI_API_KEY and empty config: `Overall: pass` ... `[pass] provider.config - Provider config loaded for openai.\n ... credentialConfigured: not set ... model: gpt-4.1` ... `[warn] provider.connectivity - Connectivity probe skipped. Run zero doctor --connectivity`. In code, providerConfigCheck returns StatusPass whenever the profile is non-empty (the built-in openai/gpt-4.1 default is non-empty), regardless of `credential := "not set"`; report.OK is false ONLY if some check is StatusFail (line 83-84).
|
||||
- **Impact:** README says `zero doctor` will "verify config, keys, and connectivity." A brand-new user who runs it before their first prompt sees a green "Overall: pass", concludes setup is done, then `zero exec` immediately fails with a raw upstream auth error. The diagnostic actively misleads instead of catching the single most common new-user misconfiguration.
|
||||
- **Fix:** In providerConfigCheck, when credential=="not set" AND the provider kind requires a key (i.e. not a local/keyless runtime like ollama/lmstudio), return StatusWarn (or StatusFail) with help text pointing to `zero setup` / `zero auth`. That makes Overall reflect "not actually usable yet" and gives the user the next action.
|
||||
|
||||
#### H10 · No-provider / missing-key error never names zero's own onboarding (`zero setup`/`zero auth`) — it dumps a raw OpenAI HTTP message telling the user to visit platform.openai.com
|
||||
|
||||
**Status (2026-06-21):** Fixed — `42b7123`. exec + TUI no-provider errors point at `zero setup` / `zero auth`.
|
||||
- **Severity:** high · also: A-onboarding, D-polish
|
||||
- **Where:** `internal/providers/providerio/providerio.go:218-232 (ClassifiedError) + internal/config/resolver.go:740 (silent default to openai/gpt-4.1). Reproduced via `zero exec "hello"` with no config.`
|
||||
- **Evidence:** `zero exec "hello"` (no key) → stderr: `[zero] auth error: You didn't provide an API key. You need to provide your API key in an Authorization header ... You can obtain an API key from https://platform.openai.com/account/api-keys.` (exit 3). The README's literal first example is `zero exec "fix the failing test in ./pkg"`. grep for `zero setup|zero auth|no provider configured` across internal/cli, internal/agent, internal/providers error paths returns nothing in this path — the only zero-added text is the `auth error: ` prefix.
|
||||
- **Impact:** An exec-first / CI user who copy-pastes the README's headline command before running the wizard gets a wall of OpenAI-branded text steering them to OpenAI's website, with zero mention that zero has a `zero setup` wizard or `zero auth`, and no hint that it silently defaulted to OpenAI. Onboarding dead-ends for anyone not using the interactive TUI.
|
||||
- **Fix:** When the resolved profile has no credential (credential not set) and the request fails auth, prepend a zero-owned line before the upstream text, e.g.: `No provider credential found. Run \`zero setup\` (interactive) or set OPENAI_API_KEY / ANTHROPIC_API_KEY / GEMINI_API_KEY. Local models need no key — see \`zero providers list\`.` Detect the no-credential case at the agent/exec boundary rather than relying on the upstream 401 b
|
||||
|
||||
#### M7 · Provider errors (auth / rate limit) are classified but give the user no recovery action
|
||||
|
||||
**Status (2026-06-21):** Largely addressed by H7 (`8b9b479`) — auth errors now name a recovery action (`zero auth`). Remaining per-class recovery hints DEFERRED.
|
||||
- **Severity:** medium
|
||||
- **Where:** `internal/providers/providerio/providerio.go:218-231 (ClassifiedError); rendered raw in TUI via internal/tui/model.go:1403-1409 (rowError text = msg.err.Error()) and to exec stderr.`
|
||||
- **Evidence:** ClassifiedError only adds a category prefix: `auth error: `, `rate limit error: `, `provider error: `. The TUI error row is `transcriptRow{kind: rowError, text: msg.err.Error(), final: true}` — the verbatim classified string, no appended next-step. Confirmed live: rate-limit/auth bodies are the raw upstream message with only the prefix.
|
||||
- **Impact:** On a 429 the user sees `rate limit error: <upstream text>` with no guidance (wait? `/model` to a cheaper or different provider? it already retried?). On auth failure, no pointer to `/provider`/`zero auth`. The failure is legible as a *category* but not *recoverable* without the user already knowing zero's surface. (Reconnect for transient disconnects in reconnect.go DOES surface a `[connection lost — reconnecting N/2…]` notice
|
||||
- **Fix:** Append a short, category-specific recovery hint when rendering rowError / exec error: auth → `Run \`zero auth\` or check your API key, or /provider to switch.`; rate limit → `Zero already retried with backoff; wait or use /model to switch provider.` Keep it one line; the classifier already knows the category.
|
||||
|
||||
#### M8 · TUI has no slash command to discover or manage subagents/cron/skills/plugins — features the README markets prominently
|
||||
|
||||
**Status (2026-06-21):** Deferred — larger feature (discovery slash commands for subagents/cron/skills/plugins); out of this PR's scope.
|
||||
- **Severity:** medium
|
||||
- **Where:** `internal/tui/commands.go (full registry: /spec and /mcp exist; no /specialist, /cron, /skills, /plugins, /subagent). CLI subcommands exist (zero specialist|cron|skills|plugins per `zero --help`).`
|
||||
- **Evidence:** grep over commands.go for `spec|specialist|cron|mcp|skill|plugin|subagent` matches only `/mcp` (:174) and `/spec` (:197). README §Why Zero headlines "Subagents", "Spec mode", "Scheduled agents", and "Extensible — skills, plugins, hooks"; the TUI command table only lists /spec /plan. The ? overlay (keybinding_help.go:58-61) references specialist *cards* but only as a reactive drill-in, not a way to list/create them.
|
||||
- **Impact:** A user living in the interactive TUI (the default `zero` with no args) can type `/` and will never surface cron, skills, plugins, or specialist management. These read as CLI-only features even though README presents them as first-class. Discoverability of half the headline feature set is gated on reading the README's Commands section and dropping out of the TUI.
|
||||
- **Fix:** Add thin TUI slash commands that shell into the existing CLI surfaces (or at least informational `/specialist`, `/cron`, `/skills`, `/plugins` that print status + the CLI command to manage them), and list them in `/help`. Even read-only listing closes the discoverability gap.
|
||||
|
||||
#### I6 · Strong robustness wins worth preserving: network-down framing, mid-stream reconnect, output truncation, and width-safe rendering
|
||||
|
||||
**Status (2026-06-21):** Info — no action (verified good).
|
||||
- **Severity:** info
|
||||
- **Where:** `internal/providers (upstream-unreachable message), internal/agent/reconnect.go, internal/tools/exec_command.go:802-812, internal/tui/rendering.go:1161-1233; tests in internal/tui.`
|
||||
- **Evidence:** Unreachable base URL → `[zero] upstream unreachable: the model server could not connect to 127.0.0.1:9 (connection refused). The request never reached the model — this is a network failure ... Verify the host is reachable (DNS/proxy/VPN/firewall) ...` (exit 3). reconnect.go retries connect-time disconnects (eof/reset/timeout/502/503) twice with backoff and surfaces `[connection lost — reconnecting N/2…]`. exec output truncates head+tail with `\n[zero] output truncated\n`. Tool cards cap at 16 live / 400 flushed lines and collapse noisy output. Visual tests PASS: TestViewNeverExceedsTerminalWidth, TestTinyTierSingleSegmentAndRailLessCards, Tes
|
||||
- **Impact:** These are genuinely good for a new user: network failures are legible and actionable, transient hiccups self-heal, and huge tool output won't flood the transcript or overflow narrow terminals. Listed so they are not regressed by fixes to the items above.
|
||||
- **Fix:** None — keep. The findings above should reuse this same quality bar (the unreachable-host message is the model for what auth/rate-limit/no-key errors should look like).
|
||||
|
||||
|
||||
### G. Open-source readiness
|
||||
|
||||
#### H11 · No SECURITY.md and no private vulnerability-reporting path
|
||||
|
||||
**Status (2026-06-21):** Fixed — `634f23f`. SECURITY.md with a private GitHub-advisory reporting path.
|
||||
- **Severity:** high
|
||||
- **Where:** `/tmp/zero-ux/ (no SECURITY.md at root or .github); CONTRIBUTING.md (no disclosure section)`
|
||||
- **Evidence:** find for security.md => none. grep -niE 'vulnerab|disclos|CVE|report.*privately' across CONTRIBUTING.md/README.md/INSTALL.md => only an unrelated 'security issue' mention in CONTRIBUTING:92 and a firecrawl privacy note. For a coding agent that executes shell, edits files, and handles API keys/secrets, there is no documented way to report a vuln.
|
||||
- **Impact:** A security researcher who finds a sandbox-escape or secret-leak in an agent that runs arbitrary commands has no private channel and will either drop a public issue (0-day exposure) or go silent. GitHub also won't surface a 'Security policy' for the repo.
|
||||
- **Fix:** Add SECURITY.md (root or .github/) with a private reporting address or GitHub Security Advisories link and a supported-versions/response-time note.
|
||||
|
||||
#### M9 · No issue templates and no PR template (.github/) despite CONTRIBUTING mandating their use
|
||||
|
||||
**Status (2026-06-21):** Fixed — `634f23f`. .github issue (bug/feature/config) + PR templates added.
|
||||
- **Severity:** medium
|
||||
- **Where:** `/tmp/zero-ux/.github/ (only workflows/); CONTRIBUTING.md:49 ('Open an issue using the appropriate issue template')`
|
||||
- **Evidence:** find .github -type f => only the 4 workflow YAMLs. No .github/ISSUE_TEMPLATE/, no PULL_REQUEST_TEMPLATE. Yet CONTRIBUTING.md:49 says 'Open an issue using the appropriate issue template' and the whole policy hinges on an `issue-approved` label workflow.
|
||||
- **Impact:** A newcomer told to 'use the appropriate issue template' finds none — bug reports arrive unstructured (the exact low-signal reports CONTRIBUTING says will be closed), and the documented contribution process is self-contradictory on first contact.
|
||||
- **Fix:** Add .github/ISSUE_TEMPLATE/bug_report.yml + feature_request.yml (matching the fields CONTRIBUTING already enumerates at lines 122-145) and a PULL_REQUEST_TEMPLATE.md that prompts for the `Fixes #123` issue link.
|
||||
|
||||
#### M10 · No CODE_OF_CONDUCT.md
|
||||
|
||||
**Status (2026-06-21):** Fixed — `634f23f`. CODE_OF_CONDUCT.md (Contributor Covenant 2.1) added.
|
||||
- **Severity:** medium
|
||||
- **Where:** `/tmp/zero-ux/ (none at root or .github)`
|
||||
- **Evidence:** find for code_of_conduct* => none.
|
||||
- **Impact:** Standard OSS-readiness/community-health item missing; GitHub's community profile flags it and some users/orgs treat its absence as a maturity signal. Low functional impact but expected for a public release.
|
||||
- **Fix:** Add CODE_OF_CONDUCT.md (e.g. Contributor Covenant) with a contact for enforcement.
|
||||
|
||||
#### M11 · No CHANGELOG and source builds report version 'dev' — `zero update` has nothing to compare against
|
||||
|
||||
**Status (2026-06-21):** Partially fixed — `634f23f` adds CHANGELOG.md; build-time version stamping (still `dev`) is DEFERRED.
|
||||
- **Severity:** medium
|
||||
- **Where:** `/tmp/zero-ux/ (no CHANGELOG); internal/update/update.go:138-141; `/tmp/zero --version` => 'zero dev'`
|
||||
- **Evidence:** `/tmp/zero --version` => `zero dev`. update.go:138-141: 'Source/dev builds carry a non-semver version ("dev"); ... currentVersion = "0.0.0"'. No CHANGELOG file anywhere. README.md:147 advertises `zero update` and INSTALL.md:91-98 documents `zero update --check`.
|
||||
- **Impact:** A user running a source build sees version 'dev' and `zero update` treats it as 0.0.0 (will always claim an update is available once any release exists). With no CHANGELOG, a user can't see what changed between versions before updating. Weakens the documented update flow.
|
||||
- **Fix:** Add a CHANGELOG.md (Keep-a-Changelog style) and document the release/versioning process; consider stamping the real version via -ldflags in the release builder so installed binaries report a semver.
|
||||
|
||||
#### L4 · README has no screenshot/GIF for a TUI-first product
|
||||
|
||||
**Status (2026-06-21):** Deferred — MANUAL maintainer step (add a screenshot/GIF; not auto-generated).
|
||||
- **Severity:** low
|
||||
- **Where:** `/tmp/zero-ux/README.md:1-21 (only ASCII logo + shields.io badges)`
|
||||
- **Evidence:** grep -niE '!\[|\.png|\.gif|demo|screenshot|asciinema' README.md => only the 4 shields.io badge images; no product screenshot or terminal recording. README.md:38 promises 'A TUI that feels premium'.
|
||||
- **Impact:** A newcomer evaluating a 'premium TUI' coding agent on the README/repo page can't see what it looks like before investing in a build — significant conversion/first-impression cost for a terminal-UI product.
|
||||
- **Fix:** Add a screenshot or asciinema/GIF of the TUI (setup wizard + a chat turn) near the top of the README.
|
||||
|
||||
#### L5 · npm package name is the un-scoped generic 'zero' — almost certainly unpublishable as-is
|
||||
|
||||
**Status (2026-06-21):** Deferred — low (npm package name).
|
||||
- **Severity:** low
|
||||
- **Where:** `/tmp/zero-ux/package.json:2`
|
||||
- **Evidence:** package.json: `"name": "zero"`. README.md:279 calls the npm wrapper a real install path ('the npm wrapper just delegates to it'). bin/zero.js exists as the wrapper.
|
||||
- **Impact:** `npm publish` of a bare common word like `zero` will collide with an existing package / be rejected, so the advertised npm distribution can't ship under this name. Minor since npm isn't the primary documented path, but it's a release blocker for that channel.
|
||||
- **Fix:** Scope the package (e.g. @gitlawb/zero) or pick an available name, and document the actual npm install command in the README (currently none is shown).
|
||||
|
||||
### Dropped in adversarial verification
|
||||
|
||||
| Claim | Why dropped |
|
||||
|---|---|
|
||||
| Wide-rune (CJK/emoji) diff/code/grep content truncated by rune-count, "half the line silently lost" | The mechanism (rune-count budget in `truncateRunes`) is real, but the **outer `fitStyledLine(line, innerWidth)` clip** (`rendering.go:1387`) is display-width-aware and absorbs the over-selection: an end-to-end width sweep showed **zero** content loss vs a width-correct budget and the ellipsis is preserved. No user-visible harm → invalid as a UX defect (kept only as a minor `middleTruncate` budget note, M). |
|
||||
| Default `firecrawl` MCP server 401s on "every first run", breaking the keyless web-search promise | **Split verdict** — one verifier reproduced an **intermittent ~20%** keyless 401 (Firecrawl-side flakiness, surfaced as a mangled Bearer-redacted warning); another got HTTP 200 + 25 tools registered and the warning not emitted by `zero setup`. Not reproducible enough to assert → recorded as Info below, not a finding. |
|
||||
|
||||
> **Info (intermittent, unverified):** the keyless `firecrawl` default endpoint returned a 401 on a minority of identical requests in one run; if it flakes, the warning is rendered mangled (the Bearer-redaction eats the message tail). Worth a real-world soak test of the "no API key" web-search claim before launch.
|
||||
|
||||
## 5. Open-source readiness checklist
|
||||
|
||||
| Item | Status | Note |
|
||||
|---|---|---|
|
||||
| **LICENSE** | ❌ FAIL | Missing; README says "being finalized." **Release blocker.** No SPDX headers, no `package.json` license field either. (C2) |
|
||||
| Working install path | ❌ FAIL | Binary install (`install.sh`/`.ps1`) 404s — no GitHub Release; CI never creates one. `go run` works but needs Go 1.25. (C1) |
|
||||
| README — newcomer quality | ⚠️ PARTIAL | Good what-it-is/quickstart/config prose + telemetry/privacy statement; but stale Go-version, a `/usage` slash + `--theme` flag that don't exist, and **no screenshot/GIF** for a TUI-first product. |
|
||||
| CONTRIBUTING.md | ⚠️ PARTIAL | Present and detailed — but mandates issue/PR templates that don't exist. |
|
||||
| CODE_OF_CONDUCT.md | ❌ FAIL | Missing. |
|
||||
| SECURITY.md + vuln path | ❌ FAIL | Missing; no private disclosure channel — a baseline expectation for a tool that runs shell/edits files. (H) |
|
||||
| Issue templates / PR template | ❌ FAIL | `.github/` has only workflows. |
|
||||
| CHANGELOG + versioning | ❌ FAIL | No CHANGELOG; binaries report `dev` → `zero update` always claims an update. |
|
||||
| CI coverage | ✅ PASS | Strong: 3-OS matrix, gofmt/vet/govulncheck/deadcode. |
|
||||
| Telemetry / privacy statement | ✅ PASS | "No telemetry" stated; Firecrawl keyless-routing disclosed. |
|
||||
| Dependency-license compatibility | ✅ PASS | No copyleft deps linked into the binary; Firecrawl AGPL is network-only and README's reasoning is correct. |
|
||||
| Reproducible build | ⚠️ PARTIAL | `go build` reproducible on Go 1.25+; binary distribution non-functional. |
|
||||
|
||||
## 6. Root-cause synthesis
|
||||
|
||||
1. **The release/packaging layer was never finished.** The product is mature but the OSS scaffolding isn't: no LICENSE, no published release (install scripts dead-end), no SECURITY/CoC/CHANGELOG/templates, version pinned at `dev`. Code shipped ahead of distribution. → C1, C2, and most of area G.
|
||||
2. **Docs/marketing copy drifted from the implementation.** README claims Go 1.24 (needs 1.25), a `/usage` slash command and a `--theme` flag that don't exist, and a keyless local-model on-ramp the SSRF probe blocks; accessibility env vars are undocumented. The pitch outran the code. → H1, H2, H4, M (theme flag), L (a11y env docs).
|
||||
3. **Raw core errors reach users without a "what to do next" UX layer.** The surface-agnostic core's classified/raw errors surface verbatim: no-key dumps an OpenAI URL instead of `zero setup`; "rejected the key" when none was sent; `doctor` says "pass" with no credential and prints Go `map[...]`; provider errors carry no recovery action. → the no-provider cluster, M1, doctor findings, provider-error finding.
|
||||
4. **Theme aesthetics undercut the readability bar.** `faint`/`faintest` fail WCAG yet carry functional content (line numbers, diff `@@`/`+++`/`---`, help footer, placeholders); light `accent` is sub-AA. → the contrast findings.
|
||||
|
||||
## 7. Prioritized fix order for the OSS launch
|
||||
|
||||
**MUST ship (launch blockers):**
|
||||
1. **Add a `LICENSE`** and name it in README. (C2)
|
||||
2. **Make an install path actually work** — publish a GitHub Release with the documented assets under the real repo (and fix `ZERO_REPO`/links), or demote binary-install to "coming soon" and lead with `go run`. (C1)
|
||||
3. **Fix the Go-version claim** (one-line: README → "Go 1.25+"). (H2)
|
||||
4. **Add `SECURITY.md`** with a private vuln-reporting path. (H)
|
||||
5. **Stop misleading on first contact:** remove the inert `/input-style` (H3) and the README's nonexistent `/usage` slash + `--theme` flag (H4, M); or wire them.
|
||||
6. **Unblock the keyless local-model on-ramp** — allow loopback in the provider connectivity probe for user-configured local providers. (H1)
|
||||
7. **Onboarding errors guide recovery:** no-provider/bad-key → point to `zero setup`/`zero auth`, distinguish *missing* key from *rejected* key, and don't dump a raw OpenAI URL. (no-provider cluster, M1)
|
||||
8. **`doctor` must not say "Overall: pass" with no credential**, and must stop leaking Go `map[...]`. (H, H)
|
||||
|
||||
**SHOULD (strongly recommended before a public launch):**
|
||||
- Lift `faint`/`faintest` (and light `accent`) to meet a contrast bar — they carry real content. (H, H, M)
|
||||
- Add `CODE_OF_CONDUCT.md`, `CHANGELOG.md`, issue/PR templates; honor `NO_COLOR=<any value>` per spec; document `NO_COLOR`/`ZERO_THEME`/`ZERO_NO_FADE`. (G + M/L cluster)
|
||||
- Add a README screenshot/GIF (TUI-first product). (L)
|
||||
- Give the TUI discoverability of cron/specialists/skills/plugins (slash commands or a `/help` pointer). (M)
|
||||
- Provider errors: append a recovery action; pre-soak-test the keyless Firecrawl path.
|
||||
|
||||
**NICE-TO-HAVE:** scope the npm package name; fix the `Ctrl+E` composer binding shadowed by mouse-toggle; normalize brand capitalization + stamp a real `version`; `middleTruncate` wide-rune budget. (L cluster)
|
||||
|
||||
## 8. Confidence notes (what could not be fully verified)
|
||||
|
||||
- **Live interactive TUI frames are UNVERIFIED.** Bubble Tea v2's alt-screen defeats `script`/`expect` (only escape setup/teardown captured). The rendered first-run wizard, resize redraw, streaming fade, and narrow-tier painting are assessed from render code + the **passing** visual tests, not captured frames. One color check did succeed via `creack/pty` + an OSC-11 reply (monochrome onboarding frame). **Recommend a pty golden-frame harness in CI** to lock the rendering this audit could only read.
|
||||
- **Contrast** ratios are computed deterministically from the theme hex tokens (solid), but "readable on a *physically* light terminal" was exercised with a dark OSC-11 reply, so the light-palette verdict rests on the hex values + the luminance tests, not a real light terminal.
|
||||
- **The Go 1.24 build failure** is reasoned from the `go.mod` directive (only go1.26.4 present in this env), not reproduced on a real 1.24 toolchain.
|
||||
- **Truecolor / 256 / 16-color downsampling** is library-handled (`colorprofile` writer) and verified by code, not by per-profile pty captures.
|
||||
- **GitHub Release existence** for `Gitlawb/zero` was probed via the public API (404 for repo/releases/tags) but the environment has no authenticated GitHub access; the repo may be private/unpublished rather than absent — either way the documented install path cannot succeed for a public stranger today.
|
||||
- **Firecrawl keyless 401** could not be settled (intermittent; see §dropped) — needs a real soak test.
|
||||
@@ -1,281 +0,0 @@
|
||||
# ZERO · Lime TUI rebuild — staged prompt
|
||||
|
||||
You are working in a fresh checkout of `github.com/Gitlawb/zero` (Go 1.24,
|
||||
Bubble Tea v1.3.10, Lip Gloss, Bubbles v1.0.0, Glamour — all in go.mod; add NO
|
||||
new dependencies).
|
||||
|
||||
Mission: **remove both existing TUI designs entirely** — the default cyan
|
||||
shell (`internal/tui/theme.go` palette + the centered splash in `startup.go`)
|
||||
AND the whole `zeroline` skin system — and replace them with ONE design:
|
||||
**Lime**, the near-black, lime-accent chat surface prototyped in
|
||||
`docs/design/zero_tui_lime.html` (commit the prototype there first; it is the
|
||||
visual source of truth). The prototype is a web mock — this spec is the
|
||||
authoritative terminal translation of it. Behavior must not regress: every
|
||||
slash command, prompt flow, overlay, and safety property that works today
|
||||
must work after.
|
||||
|
||||
This is a fresh clone: file names below were verified against a recent
|
||||
snapshot, but ALWAYS recon before editing. If a path or symbol has moved,
|
||||
adapt — do not blindly apply.
|
||||
|
||||
**Gate (run after EVERY stage, commit + tag before continuing):**
|
||||
```
|
||||
go vet ./...
|
||||
go test ./...
|
||||
go run ./cmd/zero-release build
|
||||
```
|
||||
Stage 4 additionally runs `go run ./cmd/zero-release smoke`. Never tag a red
|
||||
tree. AGENTS.MD applies: Go-native commands only.
|
||||
|
||||
---
|
||||
|
||||
## Design spec — Lime
|
||||
|
||||
### Palette — `internal/tui/theme.go` becomes the ONLY place colors exist
|
||||
| Token | Hex | Use |
|
||||
|------------|-----------|-----|
|
||||
| bg | `#070708` | canvas (terminal default bg; do not paint full-bleed) |
|
||||
| panel | `#0e0e10` | card backgrounds (tool/diff/read/grep/sessions rows) |
|
||||
| panel2 | `#121215` | card header rows, picker rows |
|
||||
| panel3 | `#17171b` | selected/hovered row bg |
|
||||
| line | `#242429` | default borders, rules, status separators |
|
||||
| line2 | `#2e2e34` | emphasized borders |
|
||||
| ink | `#ececee` | primary text |
|
||||
| muted | `#8b8b93` | secondary text, assistant interim prose |
|
||||
| faint | `#5b5b63` | hints, metadata |
|
||||
| faintest | `#3a3a40` | line numbers, separators, tool args |
|
||||
| accent | `#caff3f` | brand lime: prompts, badge, spinner, focus, final rail |
|
||||
| green | `#5dd1a4` | success, diff add sign, ✓ |
|
||||
| red | `#ff7a7a` | errors, diff del sign, ✗, deny |
|
||||
| amber | `#ffc25c` | permission surfaces, warnings, auto badge |
|
||||
| blue | `#7db4ff` | grep file locations, local-model dot |
|
||||
| addBg | `#15201d` | diff added-line bg (solid stand-in for green @9% over panel) |
|
||||
| delBg | `#241819` | diff deleted-line bg (solid stand-in for red @9%) |
|
||||
| permBg | `#1c1915` | permission card bg (amber @6% over panel) |
|
||||
| selBg | `#1d2114` | selected picker/suggestion row (accent @8%) |
|
||||
| addInk | `#bdeed7` | added-line text |
|
||||
| delInk | `#f2c4c4` | deleted-line text |
|
||||
| onAccent | `#000000` | text on accent or amber fills |
|
||||
|
||||
Terminals cannot alpha-blend or glow — the four solid tints above ARE the
|
||||
translation of the prototype's rgba/glow effects; do not attempt gradients,
|
||||
shadows, or blur. Truecolor hex throughout; lipgloss downsamples on limited
|
||||
terminals (keep the existing explanatory comment style in theme.go).
|
||||
|
||||
Keep the `tuiTheme` single-value pattern but rebuild the field set around
|
||||
these tokens (style per role: badge, userPrompt, sayText, finalRail, toolName,
|
||||
toolTarget, toolArg, autoTag, statusOk/Err, diffAdd/Del/Meta, permBadge,
|
||||
permRisk, grepLoc, bashPrompt, stGroup, etc.). No hex outside theme.go.
|
||||
|
||||
### Layout — single-column chat surface (matches the existing architecture)
|
||||
```
|
||||
zero / ~/dev/zero anthropic/claude-sonnet-4-5 · 200K
|
||||
────────────────────────────────────────────────────────────────────────
|
||||
❯ add a --version flag to the cli (user)
|
||||
I'll add a --version flag…▌ (interim, muted)
|
||||
╭ grep internal/cli flag\.|RegisterFlag ⠋ ──────────╮
|
||||
│ internal/cli/root.go:41 fs := flag.NewFlagSet("zero"… │
|
||||
╰ 3 matches ───────────────────────────────────────────────────────╯
|
||||
╭ edit internal/cli/root.go auto ✓ ────────────╮
|
||||
│ internal/cli/root.go new file +6 −1 │
|
||||
│ 41 │ fs.BoolVar(&o.Version, "version", false, … (addBg) │
|
||||
╰──────────────────────────────────────────────────────────────────╯
|
||||
│ Done — the CLI now prints its version. (final: accent rail)
|
||||
● done · 2 tools · 8.4s (done line)
|
||||
|
||||
❯ describe a task for zero… (composer)
|
||||
────────────────────────────────────────────────────────────────────────
|
||||
● anthropic │ claude-sonnet-4-5 │ │ 12.4K tok │ interactive │ ⏵⏵ auto
|
||||
```
|
||||
Render order stays: title line → rule → transcript → composer → rule →
|
||||
status line. NO alt-screen change, NO panes, NO mouse — keep `run.go`
|
||||
exactly as-is including its no-mouse-capture comment.
|
||||
|
||||
### Title bar (replaces `headerBar`)
|
||||
Left: badge — `lipgloss` style bg=accent fg=onAccent bold, content ` 0 ` —
|
||||
then `zero` (ink, bold, lowercase), ` / ` (faintest), cwd via existing
|
||||
`shortenPath` (muted), git branch (faint) when present. Right:
|
||||
`provider/model` (ink) + ` · ctx` (faint) where ctx comes from
|
||||
`modelregistry.ContextLimits.ContextWindow` formatted like `200K`. Reuse the
|
||||
existing width-candidate fallback pattern (`startupHeaderLine`) so segments
|
||||
drop gracefully: full → drop ctx → drop cwd → badge+model only. Below it,
|
||||
one rule line in `line`.
|
||||
|
||||
### Empty state (DELETE the splash entirely)
|
||||
Remove `startupView`, `startupHeader`, `commandChips`, the figlet
|
||||
`zeroLogoLines`, gap math, and the `showSplash` field/branch. The app boots
|
||||
into the chat surface; when the transcript is empty, the stream area shows,
|
||||
vertically centered:
|
||||
- a 5–6 line block-art `0` in accent (derive the glyph from the `O` columns
|
||||
of the old figlet constant before deleting it),
|
||||
- tagline (muted): `a std-lib-first coding agent · bring your own key · no lock-in`,
|
||||
- hint (faint): `running zero against <provider/model>` with the model in ink,
|
||||
- three suggestion chips, one per line: `❯` (accent bold) + suggestion (ink)
|
||||
inside a `line`-bordered rounded box; pressing `1`–`3` while the composer
|
||||
is empty inserts that suggestion. Define suggestions as a string slice in
|
||||
the tui package (e.g. add a --version flag / explain internal/agent/loop.go /
|
||||
fix the failing test in internal/tools).
|
||||
|
||||
### Stream blocks (restyle `transcript.go` / `rendering.go` rows)
|
||||
- **user** (`rowUser`): `❯ ` accent bold + ink text. Blank line above turns.
|
||||
- **assistant interim** (`rowAssistant` while streaming): muted text, wrapped
|
||||
to `min(width-4, 74)` columns, trailing `▌` cursor in accent while the
|
||||
turn is pending (drives off existing pending state — no new ticker).
|
||||
- **final answer** (last assistant row of a turn): a `│` accent rail gutter
|
||||
on every wrapped line + ink text, same 74-col measure. If distinguishing
|
||||
interim vs final is not already possible from row data, mark the row at
|
||||
append time — do not re-parse text.
|
||||
- **done line** (end of a turn): `●` green (red on error) + faint
|
||||
`done · N tools · Xs` with faintest `·` separators, derived from data the
|
||||
model already has (tool rows this turn + existing usage/timing if present;
|
||||
omit segments that don't exist rather than inventing counters).
|
||||
- **system / error notes** (`rowSystem`/`rowError` equivalents): one-line
|
||||
bordered note — system: faint on panel with `line` border; denial/error:
|
||||
red text, red-tinted border. Keep content unchanged.
|
||||
|
||||
### Tool cards (replaces `titledCard`/tool rows for `rowToolCall`+`rowToolResult`)
|
||||
Rounded border card on panel bg. Head row on panel2 with a bottom rule:
|
||||
status glyph + tool name (ink bold) + target (muted, middle-truncated) +
|
||||
one-line arg via the existing `argHint` (faintest, right side) + optional
|
||||
`auto` tag (amber text in amber-bordered mini box) when the call was
|
||||
auto-approved by mode or a stored grant. Status glyph: running =
|
||||
`bubbles/spinner` MiniDot in accent · ok = `✓` green · error = `✗` red.
|
||||
Border tint follows state: line → accent-ish `#5a6b2e` while running →
|
||||
red-mix on error (define both as theme tokens). Bodies by result shape,
|
||||
reusing the existing detection where present:
|
||||
- **diff** (`looksLikeDiff`): head row = path (ink) + `NEW FILE` tag (green
|
||||
on addBg) when applicable + right-aligned `+N` green / `−N` red counts;
|
||||
body lines = 4-col right-aligned line number (faintest) + sign col
|
||||
(green/red) + text — added rows on addBg in addInk, deleted on delBg in
|
||||
delInk, context muted. Keep the 16-line cap + `… N more lines` footer.
|
||||
- **read**: same gutter, muted text, head shows path + `Lstart–Lend` (faint).
|
||||
- **bash**: cmd row `❯` accent bold + command (ink) above a rule; output
|
||||
muted (stderr-ish in delInk), indented 2; footer `exit 0` green /
|
||||
`exit N` red when an exit code is in the result.
|
||||
- **grep**: rows `file:line` (blue) + text (muted, truncated); footer faint
|
||||
`N matches`.
|
||||
- Anything else: current generic output rendering, restyled to muted on panel.
|
||||
|
||||
### Permission card (restyle the existing `pendingPermission` prompt — keys unchanged)
|
||||
Card with amber-mixed border on permBg. Top row: `PERMISSION` badge (onAccent
|
||||
on amber, bold) + `risk: <low|medium|high>` (amber) from the request's
|
||||
existing risk data. Body: `<tool>` (amber bold) + target (ink) + reason
|
||||
(muted). Action row of bordered key-chips: `[a] allow once` (onAccent on
|
||||
accent), `[y] always` (ink, accent border), `[d] deny` (ink, red on hover is
|
||||
web-only — just red-bordered), `[esc]` hint faint. After resolution collapse
|
||||
to one faint line: `allowed once · <tool>` green / `always · <tool>` green /
|
||||
`denied · <tool>` red. Ask-user and spec-review prompts get the same card
|
||||
language with `line` borders. Do NOT alter `handlePermissionKey`,
|
||||
`nextPermissionMode`, or grant-store semantics.
|
||||
|
||||
### Composer + status line
|
||||
Composer: `❯` accent bold + the existing `bubbles/textinput`, borderless
|
||||
(remove the bordered input block), placeholder `describe a task for zero…`,
|
||||
switching to `running… ctrl+c to interrupt` while pending; right-aligned
|
||||
faint hint `run ↵` (idle, only when input is non-empty) / `esc stop`
|
||||
(pending). Rules above composer and above status line in `line`.
|
||||
Status line groups separated by ` │ ` (line color): `●` accent + provider ·
|
||||
model id · flexible gap · `<X.XK> tok` (existing usage segment data,
|
||||
keep cost when priced) · `interactive` · permission mode (reuse
|
||||
`modeLabel()`: `⏵⏵ auto-approve` green / `ask` amber / `unsafe` red) ·
|
||||
optional green `always: <n> grants` when the sandbox grant store exposes a
|
||||
cheap count (omit otherwise — do not add plumbing).
|
||||
|
||||
### Overlays (restyle in place, identical key handling)
|
||||
- Autocomplete: rows on panel, selected row on selBg with accent `❯` marker,
|
||||
name ink / desc faint.
|
||||
- Picker (`/model`, `/provider`, etc.): bordered panel; rows = provider dot
|
||||
(accent remote, blue local) + id (ink) + right meta `ctx · KEY_ENV`
|
||||
(faint/faintest) when the registry/provider catalog already exposes them;
|
||||
selected row selBg. Title row + `↑/↓ · ⏎ · esc` hints faint.
|
||||
- Sessions: `/resume` (alias `/sessions`) keeps its existing flow; restyle
|
||||
its list as stacked cards — id (accent) + age (faint) top row, title (ink),
|
||||
meta line (faint with faintest dots). No new persistence behavior.
|
||||
|
||||
### Explicitly OUT of scope
|
||||
The prototype's `text/json` toggle visualizes the headless
|
||||
`--output-format stream-json` CLI mode (see docs/STREAM_JSON_PROTOCOL.md) —
|
||||
not a TUI feature. The TweaksPanel in the HTML is a design harness — ignore
|
||||
it. No mouse support, no model dropdown (keyboard picker is the translation).
|
||||
|
||||
---
|
||||
|
||||
## Adaptive requirements (treat as acceptance criteria)
|
||||
- Width tiers, re-evaluated on every `WindowSizeMsg`:
|
||||
**≥100** full spec · **80–99** drop tool-arg column, header ctx, status
|
||||
`interactive` group · **60–79** drop diff/read line-number gutters, badge
|
||||
renders as `0` without padding, status keeps provider+tokens+mode only ·
|
||||
**<58** (current `minStartupWidth`) single-segment header, cards lose
|
||||
side borders (keep top/bottom rules), composer + mode line only.
|
||||
- Text measure: say/final wrap at `min(width-4, 74)`; paths middle-truncate
|
||||
(`internal/…/root.go`); never emit a styled line wider than the terminal
|
||||
(extend the existing `fitStyledLine`/`truncateRunes` helpers — reuse, don't
|
||||
fork).
|
||||
- Height: when the terminal is shorter than the frame, transcript trims from
|
||||
the top (existing inline behavior) — verify no overlap of composer/status.
|
||||
- Color: nothing may rely on truecolor to be legible — check every pairing
|
||||
at 256 colors mentally; the four tint tokens must remain darker than ink.
|
||||
- Tests for the tier logic: table-driven across widths {58, 70, 80, 100, 120}
|
||||
asserting which segments render, using the existing ANSI-stripping helpers.
|
||||
|
||||
---
|
||||
|
||||
## Stages
|
||||
|
||||
### Stage 0 — recon + delete the `zeroline` design system *(tag: `tui-lime-s0`)*
|
||||
Recon first: `grep -rni zeroline --include='*.go' .` and read
|
||||
`internal/tui/options.go`, `internal/cli/app.go`. Then remove completely:
|
||||
`internal/zeroline/` (whole package + tests), `internal/tui/zeroline_view.go`
|
||||
(+ test), `internal/cli/zeroline.go`, the `case "zeroline":` dispatch and the
|
||||
zeroline help-text line in `internal/cli/app.go`, the skin parameter on
|
||||
`runInteractiveTUIWithSkin` (rename to `runInteractiveTUI`), the `Skin`,
|
||||
`ThemeVariant`, `ThemeDark` fields in `tui.Options`, the model's `skin`
|
||||
field and every `m.skin == "zeroline"` branch (View dispatch, `/theme`
|
||||
special-case, update paths, `picker.go`, `image_attach.go`). Update or
|
||||
delete affected tests; the zeroline grep must return zero hits. Gate, tag.
|
||||
|
||||
### Stage 1 — Lime palette on the existing layout *(tag: `tui-lime-s1`)*
|
||||
Rewrite `theme.go` to the token table. Restyle every current render site
|
||||
(header, transcript rows, titledCard/diffCard, overlays, status line,
|
||||
prompts) to the new tokens WITHOUT changing structure yet — this isolates
|
||||
the string-assertion churn in `model_test.go` / `session_test.go` /
|
||||
`command_polish_test.go` from the layout work. Prefer asserting plain
|
||||
content via the ANSI-stripping helpers over styled bytes. Gate, tag.
|
||||
|
||||
### Stage 2 — chat-surface components *(tag: `tui-lime-s2`)*
|
||||
Title bar + rule, empty state replacing the splash (splash code deleted),
|
||||
stream blocks (user/interim/final/done/notes), tool cards with all four
|
||||
result bodies, MiniDot spinner, composer + status line redesign. Add
|
||||
rendering tests per block type with synthetic rows. Gate, tag.
|
||||
|
||||
### Stage 3 — interactive surfaces *(tag: `tui-lime-s3`)*
|
||||
Permission card + resolved collapse + auto tag, ask-user and spec-review
|
||||
cards, autocomplete/picker restyle with model-row metadata, `/resume`
|
||||
sessions card list, suggestion-chip `1–3` insertion. Key handling and
|
||||
semantics byte-identical to before — assert that in tests where they
|
||||
already exist. Gate, tag.
|
||||
|
||||
### Stage 4 — adaptive polish + docs *(tag: `tui-lime-s4`)*
|
||||
Implement + test the width tiers, sweep remaining render sites (spec mode,
|
||||
command center, doctor output if styled) for stray old-palette styles,
|
||||
update `internal/cli/app.go` help text and README's TUI section, manual run
|
||||
`go run ./cmd/zero` at 58/80/100/120 cols (no wrapped rules, no orphan ANSI,
|
||||
prompts render correctly). Full gate + `go run ./cmd/zero-release smoke`.
|
||||
Gate, tag.
|
||||
|
||||
---
|
||||
|
||||
## Hard constraints
|
||||
- No new module dependencies; charmbracelet stack only. No mouse capture.
|
||||
- Zero behavior change to permissions, grants, ask-user, spec review,
|
||||
sessions, slash commands, autocomplete, image attach, compaction, rewind.
|
||||
- All colors in `theme.go`; renderers consume named styles only.
|
||||
- Reuse existing helpers (`fitStyledLine`, `truncateRunes`, `argHint`,
|
||||
`shortenPath`, header candidates) instead of writing parallel ones.
|
||||
- Comment style matches the repo: explain WHY at non-obvious decisions.
|
||||
|
||||
## Definition of done
|
||||
One design in the tree (no zeroline references, no splash, no cyan tokens),
|
||||
the Lime chat surface matching this spec at ≥100 cols and degrading per the
|
||||
width tiers below that, every pre-existing flow keyboard-reachable and
|
||||
visually consistent, `go test ./...` + release build + smoke fully green.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -170,7 +170,7 @@ preset (no API key — the proxy authenticates):
|
||||
"name": "chatgpt",
|
||||
"catalogID": "chatgpt-proxy", // OpenAI-compatible, local, no key
|
||||
"baseURL": "http://localhost:10531/v1", // override for your proxy's port
|
||||
"model": "gpt-5" // whatever model your proxy serves
|
||||
"model": "gpt-5.5" // whatever model your proxy serves
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,304 +0,0 @@
|
||||
# Agent Eval Benchmark Harness Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a local, offline-testable benchmark harness that copies eval fixtures, runs an agent command per task, scores the resulting workspace, and reports aggregate results.
|
||||
|
||||
**Architecture:** Keep `internal/agenteval.Runner` as the scoring primitive. Add a `Harness` layer that materializes fixture workspaces into a work directory, initializes a clean Git baseline, invokes an injectable `AgentRunner`, then calls `Runner.Run`. The CLI exposes this as `zero eval bench`, while `zero eval run` remains the lower-level scorer for an already-mutated workspace.
|
||||
|
||||
**Tech Stack:** Go 1.24 standard library, existing `internal/agenteval`, existing `internal/cli`, Git CLI for workspace baselines.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Create `internal/agenteval/materialize.go`: fixture path resolution, directory copy, Git init/baseline helpers.
|
||||
- Create `internal/agenteval/materialize_test.go`: fixture copy and Git baseline tests.
|
||||
- Create `internal/agenteval/agent_command.go`: command-template based agent runner with `{prompt}`, `{workspace}`, `{task_id}` placeholders.
|
||||
- Create `internal/agenteval/agent_command_test.go`: placeholder substitution, cwd, exit/error behavior.
|
||||
- Create `internal/agenteval/benchmark.go`: benchmark orchestration and aggregate report.
|
||||
- Create `internal/agenteval/benchmark_test.go`: end-to-end harness with fake agent runner and sample fixtures.
|
||||
- Modify `internal/cli/agent_eval.go`: parse `zero eval bench` and wire `Harness`.
|
||||
- Modify `internal/cli/agent_eval_test.go`: bench parsing, JSON/text output, report writing.
|
||||
- Modify `docs/AGENT_EVALS.md`: document `bench` mode and real local command examples.
|
||||
|
||||
## Task 1: Fixture Materializer
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/agenteval/materialize.go`
|
||||
- Create: `internal/agenteval/materialize_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
```go
|
||||
func TestMaterializeTaskCopiesFixtureAndInitializesGit(t *testing.T) {
|
||||
suitePath := filepath.Join("testdata", "sample_suite.json")
|
||||
suite, err := LoadSuite(suitePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := suite.Tasks[0]
|
||||
workRoot := t.TempDir()
|
||||
|
||||
workspace, err := Materializer{}.MaterializeTask(context.Background(), suitePath, task, MaterializeInput{WorkRoot: workRoot})
|
||||
if err != nil {
|
||||
t.Fatalf("MaterializeTask: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(workspace.Path, "go.mod")); err != nil {
|
||||
t.Fatalf("fixture was not copied: %v", err)
|
||||
}
|
||||
if output, err := exec.Command("git", "-C", workspace.Path, "status", "--porcelain").CombinedOutput(); err != nil || strings.TrimSpace(string(output)) != "" {
|
||||
t.Fatalf("workspace baseline is dirty: err=%v output=%s", err, output)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/agenteval -run TestMaterializeTask -count=1`
|
||||
Expected: FAIL because `Materializer` is undefined.
|
||||
|
||||
- [ ] **Step 3: Implement minimal materializer**
|
||||
|
||||
Create a `Materializer` with:
|
||||
- `MaterializeTask(ctx, suitePath string, task Task, input MaterializeInput) (Workspace, error)`
|
||||
- `MaterializeInput{WorkRoot string}`
|
||||
- `Workspace{Path string, TaskID string, FixturePath string}`
|
||||
- Resolve relative `task.WorkspaceFixture` from `filepath.Dir(suitePath)`.
|
||||
- Copy directories recursively using stdlib only.
|
||||
- Skip `.git` while copying.
|
||||
- Run `git init`, `git add .`, and `git commit -m "baseline"` with local user config/env.
|
||||
- Return clear errors for missing fixture, absolute fixture escapes, and empty work root.
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
go test ./internal/agenteval -run 'TestMaterializeTask|TestMaterializer' -count=1
|
||||
```
|
||||
|
||||
## Task 2: Agent Command Runner
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/agenteval/agent_command.go`
|
||||
- Create: `internal/agenteval/agent_command_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
```go
|
||||
func TestCommandAgentRunnerExpandsPlaceholders(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
script := filepath.Join(dir, "agent.bat")
|
||||
if err := os.WriteFile(script, []byte("@echo %1|%2|%3> out.txt\r\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runner := CommandAgentRunner{Command: []string{script, "{task_id}", "{workspace}", "{prompt}"}}
|
||||
result := runner.Run(context.Background(), AgentRunInput{TaskID: "task-a", WorkspacePath: dir, Prompt: "fix bug"})
|
||||
if result.ExitCode != 0 || result.Error != "" {
|
||||
t.Fatalf("Run = %#v", result)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, "out.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), "task-a") || !strings.Contains(string(data), dir) || !strings.Contains(string(data), "fix bug") {
|
||||
t.Fatalf("placeholders not expanded: %q", data)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/agenteval -run TestCommandAgentRunner -count=1`
|
||||
Expected: FAIL because `CommandAgentRunner` is undefined.
|
||||
|
||||
- [ ] **Step 3: Implement runner**
|
||||
|
||||
Add:
|
||||
- `type AgentRunInput struct { TaskID, Prompt, WorkspacePath string }`
|
||||
- `type AgentRunResult struct { ExitCode int; Stdout, Stderr, Error string }`
|
||||
- `type AgentRunner interface { Run(context.Context, AgentRunInput) AgentRunResult }`
|
||||
- `type CommandAgentRunner struct { Command []string }`
|
||||
- `Run` executes without shell interpolation, with `cmd.Dir = WorkspacePath`.
|
||||
- Replace `{prompt}`, `{workspace}`, `{task_id}` in every arg.
|
||||
- Empty command returns `ExitCode:-1` and `Error:"agent command is required"`.
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
|
||||
Run: `go test ./internal/agenteval -run TestCommandAgentRunner -count=1`
|
||||
|
||||
## Task 3: Benchmark Harness
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/agenteval/benchmark.go`
|
||||
- Create: `internal/agenteval/benchmark_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
```go
|
||||
func TestHarnessRunsTaskFromFixtureAndScoresResult(t *testing.T) {
|
||||
suitePath := filepath.Join("testdata", "sample_suite.json")
|
||||
suite, err := LoadSuite(suitePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
harness := Harness{
|
||||
Materializer: Materializer{},
|
||||
Agent: agentRunnerFunc(func(ctx context.Context, input AgentRunInput) AgentRunResult {
|
||||
target := filepath.Join(input.WorkspacePath, "docs", "STREAM_JSON_PROTOCOL.md")
|
||||
err := os.WriteFile(target, []byte("updated"), 0o644)
|
||||
if err != nil {
|
||||
return AgentRunResult{ExitCode: -1, Error: err.Error()}
|
||||
}
|
||||
return AgentRunResult{ExitCode: 0}
|
||||
}),
|
||||
Runner: Runner{},
|
||||
}
|
||||
report := harness.Run(context.Background(), suitePath, suite, BenchmarkInput{
|
||||
TaskID: "document-stream-json-verify-events",
|
||||
WorkRoot: t.TempDir(),
|
||||
})
|
||||
if report.Summary.TotalTasks != 1 || report.Summary.PassedTasks != 1 || !report.OK {
|
||||
t.Fatalf("report = %#v", report)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/agenteval -run TestHarness -count=1`
|
||||
Expected: FAIL because `Harness` is undefined.
|
||||
|
||||
- [ ] **Step 3: Implement harness**
|
||||
|
||||
Add:
|
||||
- `BenchmarkInput{TaskID, WorkRoot string; KeepWorkspaces bool}`
|
||||
- `BenchmarkReport{Contract, SuiteID, OK, Summary, Tasks}`
|
||||
- `BenchmarkTaskReport{TaskID, WorkspacePath, FixturePath, Agent AgentRunResult, Report Report}`
|
||||
- `BenchmarkSummary{TotalTasks, PassedTasks, FailedTasks, BlockedTasks, ErrorTasks int}`
|
||||
- `Harness{Materializer Materializer; Agent AgentRunner; Runner Runner}`
|
||||
- If no `Agent`, produce blocked task reports with message `agent command is required`.
|
||||
- Select one task when `TaskID` set, otherwise all tasks.
|
||||
- For each task: materialize, run agent, if agent fails mark blocked; otherwise score with `Runner.Run`.
|
||||
- Remove each materialized task workspace after scoring unless `BenchmarkInput.KeepWorkspaces` is true.
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
|
||||
Run: `go test ./internal/agenteval -run 'TestHarness|TestBenchmark' -count=1`
|
||||
|
||||
## Task 4: CLI `zero eval bench`
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/cli/agent_eval.go`
|
||||
- Modify: `internal/cli/agent_eval_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
```go
|
||||
func TestRunEvalBenchJSONModePassesHarnessOptions(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{
|
||||
"eval", "bench",
|
||||
"--suite", "evals/context.json",
|
||||
"--task", "edit-reader",
|
||||
"--work-root", "D:\\tmp\\zero-evals",
|
||||
"--agent-command", "zero", "exec", "{prompt}",
|
||||
"--json",
|
||||
}, &stdout, &stderr, appDeps{
|
||||
runAgentEval: func(ctx context.Context, options agentEvalOptions) (agentEvalReport, error) {
|
||||
if options.Mode != "bench" || options.TaskID != "edit-reader" || options.WorkRoot == "" || len(options.AgentCommand) != 3 {
|
||||
t.Fatalf("unexpected bench options: %#v", options)
|
||||
}
|
||||
return agentEvalReport{Suite: "quality-context", Status: "pass", OK: true, Total: 1, Passed: 1}, nil
|
||||
},
|
||||
})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/cli -run TestRunEvalBench -count=1`
|
||||
Expected: FAIL because bench mode is not parsed.
|
||||
|
||||
- [ ] **Step 3: Implement CLI**
|
||||
|
||||
Add parser support:
|
||||
- Modes: `validate`, `run`, `bench`
|
||||
- Bench flags: `--work-root <path>`, `--agent-command <argv...>`, `--keep-workspaces`
|
||||
- `--task`, `--report-dir`, `--json` work in bench mode.
|
||||
- `--workspace` remains run-only.
|
||||
- Default work root for bench: temp dir with prefix `zero-eval-`.
|
||||
- Default `AgentCommand` empty -> harness report blocked rather than usage error.
|
||||
- Convert benchmark aggregate to existing CLI report shape.
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
go test ./internal/cli -run 'TestRunEvalBench|TestRunEvalRun' -count=1
|
||||
```
|
||||
|
||||
## Task 5: Docs and Manual Examples
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/AGENT_EVALS.md`
|
||||
|
||||
- [ ] **Step 1: Document modes**
|
||||
|
||||
Add a `bench` subsection:
|
||||
- `validate`: schema only
|
||||
- `run`: score an already-mutated worktree
|
||||
- `bench`: copy fixture, run agent command, score result
|
||||
|
||||
- [ ] **Step 2: Include examples**
|
||||
|
||||
Document:
|
||||
```bash
|
||||
go run ./cmd/zero eval bench \
|
||||
--suite internal/agenteval/testdata/sample_suite.json \
|
||||
--task document-stream-json-verify-events \
|
||||
--work-root /tmp/zero-evals \
|
||||
--agent-command zero exec --cwd {workspace} {prompt}
|
||||
```
|
||||
|
||||
Also document a deterministic fake-agent example for local testing.
|
||||
|
||||
- [ ] **Step 3: Verify**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
go run ./cmd/zero eval --suite internal/agenteval/testdata/sample_suite.json
|
||||
go test ./...
|
||||
```
|
||||
|
||||
## Task 6: Integration Validation
|
||||
|
||||
**Files:**
|
||||
- Modify as needed only in files above.
|
||||
|
||||
- [ ] **Step 1: Run focused validation**
|
||||
|
||||
```bash
|
||||
go test -count=1 ./internal/agenteval ./internal/cli
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run fixture validation**
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
from `internal/agenteval/testdata/fixtures/zero-mini`.
|
||||
|
||||
- [ ] **Step 3: Run full repo validation**
|
||||
|
||||
```bash
|
||||
gofmt -l internal/agenteval internal/cli
|
||||
git diff --check
|
||||
go test ./...
|
||||
go run ./cmd/zero-release build
|
||||
go run ./cmd/zero-release smoke
|
||||
```
|
||||
@@ -1,181 +0,0 @@
|
||||
# Agent Eval D-Level Upgrades Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Upgrade the local agent eval harness from a smoke benchmark into a quality signal that can compare models, score traces, catch context mistakes, and preserve regression artifacts.
|
||||
|
||||
**Architecture:** Keep the existing `internal/agenteval` harness as the core. Add small schema fields to `Task`, score them through `Score` and `Harness.Run`, then expose model matrix and full benchmark report data through `zero eval bench`. Avoid provider calls or UI work.
|
||||
|
||||
**Tech Stack:** Go 1.24, standard library JSON/process/git helpers, existing `go test ./...` validation.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Suite Schema And Rubric Checks
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/agenteval/suite.go`
|
||||
- Modify: `internal/agenteval/score.go`
|
||||
- Test: `internal/agenteval/agenteval_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing schema/rubric tests**
|
||||
|
||||
Add tests that load and normalize `tags`, `difficulty`, and `forbiddenChangedFiles`, reject malformed forbidden paths, and fail scoring when a forbidden file is touched.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify failure**
|
||||
|
||||
Run: `go test ./internal/agenteval -run "LoadSuite|Validate|Forbidden" -count=1`
|
||||
|
||||
- [ ] **Step 3: Implement minimal schema and scoring**
|
||||
|
||||
Add these fields:
|
||||
|
||||
```go
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Difficulty string `json:"difficulty,omitempty"`
|
||||
ForbiddenChangedFiles []string `json:"forbiddenChangedFiles,omitempty"`
|
||||
```
|
||||
|
||||
Normalize file lists, validate duplicate/malformed entries, and add a `forbidden_changed_files` result.
|
||||
|
||||
- [ ] **Step 4: Verify green**
|
||||
|
||||
Run: `go test ./internal/agenteval -count=1`
|
||||
|
||||
### Task 2: Agent Trace Scoring
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/agenteval/trace.go`
|
||||
- Test: `internal/agenteval/trace_test.go`
|
||||
- Modify: `internal/agenteval/benchmark.go`
|
||||
|
||||
- [ ] **Step 1: Write failing trace tests**
|
||||
|
||||
Cover JSONL events such as:
|
||||
|
||||
```json
|
||||
{"type":"tool","name":"read_file"}
|
||||
{"event":"verify","name":"go-test"}
|
||||
```
|
||||
|
||||
Require deterministic event keys like `tool:read_file` and `verify:go-test`.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify failure**
|
||||
|
||||
Run: `go test ./internal/agenteval -run Trace -count=1`
|
||||
|
||||
- [ ] **Step 3: Implement parser and benchmark scoring**
|
||||
|
||||
Parse agent stdout line by line, ignore non-JSON noise, and append a `trace` result when a task declares `requiredTraceEvents`.
|
||||
|
||||
- [ ] **Step 4: Verify green**
|
||||
|
||||
Run: `go test ./internal/agenteval -count=1`
|
||||
|
||||
### Task 3: Context Quality Checks
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/agenteval/context_checks.go`
|
||||
- Test: `internal/agenteval/context_checks_test.go`
|
||||
- Modify: `internal/agenteval/benchmark.go`
|
||||
|
||||
- [ ] **Step 1: Write failing context tests**
|
||||
|
||||
Cover a task that declares:
|
||||
|
||||
```json
|
||||
"contextChecks": {
|
||||
"requiredFiles": ["docs/STREAM_JSON_PROTOCOL.md"],
|
||||
"forbiddenFiles": ["node_modules/cache.txt"]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify failure**
|
||||
|
||||
Run: `go test ./internal/agenteval -run Context -count=1`
|
||||
|
||||
- [ ] **Step 3: Implement fixture/workspace context checks**
|
||||
|
||||
Check required files exist under the materialized workspace and forbidden files do not. Append a `context` result before summary finalization.
|
||||
|
||||
- [ ] **Step 4: Verify green**
|
||||
|
||||
Run: `go test ./internal/agenteval -count=1`
|
||||
|
||||
### Task 4: Model Matrix Benchmarking
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/agenteval/agent_command.go`
|
||||
- Modify: `internal/agenteval/benchmark.go`
|
||||
- Test: `internal/agenteval/agent_command_test.go`
|
||||
- Test: `internal/agenteval/benchmark_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing model-matrix tests**
|
||||
|
||||
Assert `{model}` expands in agent command argv and `BenchmarkInput{Models: []string{"a","b"}}` runs every task for each model.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify failure**
|
||||
|
||||
Run: `go test ./internal/agenteval -run "Model|Harness" -count=1`
|
||||
|
||||
- [ ] **Step 3: Implement model propagation**
|
||||
|
||||
Add `Model` to `AgentRunInput` and `BenchmarkTaskReport`; add `Models []string` to `BenchmarkInput`; loop task/model pairs deterministically.
|
||||
|
||||
- [ ] **Step 4: Verify green**
|
||||
|
||||
Run: `go test ./internal/agenteval -count=1`
|
||||
|
||||
### Task 5: CLI Flags And Regression Artifacts
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/cli/agent_eval.go`
|
||||
- Test: `internal/cli/agent_eval_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing CLI tests**
|
||||
|
||||
Cover repeated `--model`, comma-separated `--models`, help text, and report-dir JSON containing nested benchmark detail.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify failure**
|
||||
|
||||
Run: `go test ./internal/cli -run Eval -count=1`
|
||||
|
||||
- [ ] **Step 3: Implement CLI wiring**
|
||||
|
||||
Pass models into `agenteval.BenchmarkInput`, include benchmark detail in `agentEvalReport`, and prefix failure IDs with model when present.
|
||||
|
||||
- [ ] **Step 4: Verify green**
|
||||
|
||||
Run: `go test ./internal/cli -count=1`
|
||||
|
||||
### Task 6: Suite Expansion And Docs
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/agenteval/testdata/sample_suite.json`
|
||||
- Modify: `docs/AGENT_EVALS.md`
|
||||
|
||||
- [ ] **Step 1: Write failing suite expectation**
|
||||
|
||||
Update the sample suite test to require richer coverage count and metadata fields.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify failure**
|
||||
|
||||
Run: `go test ./internal/agenteval -run SampleSuite -count=1`
|
||||
|
||||
- [ ] **Step 3: Expand the fixture suite and docs**
|
||||
|
||||
Add more tasks using the existing `zero-mini` fixture, plus examples for `--model`, `{model}`, `requiredTraceEvents`, `contextChecks`, and report artifacts.
|
||||
|
||||
- [ ] **Step 4: Verify green**
|
||||
|
||||
Run: `go test ./internal/agenteval ./internal/cli -count=1`
|
||||
|
||||
### Final Verification
|
||||
|
||||
- [ ] Run `gofmt -l internal/agenteval internal/cli`
|
||||
- [ ] Run `git diff --check`
|
||||
- [ ] Run `go vet ./...`
|
||||
- [ ] Run `go test ./...`
|
||||
- [ ] Run `go run ./cmd/zero eval --suite internal/agenteval/testdata/sample_suite.json`
|
||||
- [ ] Run `go run ./cmd/zero eval bench --suite internal/agenteval/testdata/sample_suite.json --task document-stream-json-verify-events --model test-model --json --agent-command powershell -NoProfile -Command "& { param(`$ws) Set-Content -LiteralPath (Join-Path `$ws 'docs/STREAM_JSON_PROTOCOL.md') -Value updated; Write-Output '{\"type\":\"tool\",\"name\":\"read_file\"}' }" "{workspace}"`
|
||||
- [ ] Run `go run ./cmd/zero-release build`
|
||||
- [ ] Commit and push to `feat/agent-eval-harness`
|
||||
@@ -1,175 +0,0 @@
|
||||
# Additional write roots (`--add-dir` / `/add-dir`)
|
||||
|
||||
**Date:** 2026-06-10
|
||||
**Status:** Approved
|
||||
|
||||
## Problem
|
||||
|
||||
Zero's sandbox confines all writes to the workspace root, with no escape hatch.
|
||||
`EnforceWorkspace` is hardcoded `true` in `DefaultPolicy()`
|
||||
(`internal/sandbox/types.go`) and `SandboxConfig` exposes only `maxAutonomy`
|
||||
and `network`. When a user asks the agent to save output outside the workspace
|
||||
(e.g. "save this to ~/Desktop/dev/untitled folder"), every layer denies it:
|
||||
|
||||
1. **Policy engine** — `Engine.Evaluate` → `validateWorkspacePaths`
|
||||
(`internal/sandbox/paths.go`) denies tool requests whose paths fall outside
|
||||
the workspace root.
|
||||
2. **macOS seatbelt** — `sandboxExecProfile` (`internal/sandbox/runner.go`)
|
||||
emits `(allow file-write* (subpath "<workspaceRoot>"))`, so shell commands
|
||||
fail with `Operation not permitted`.
|
||||
3. **Linux bubblewrap** — `bubblewrapCommandPlan` binds only the workspace
|
||||
read-write; system paths are read-only binds.
|
||||
4. **File tools** — `resolveWorkspacePath` / `recheckWorkspaceWriteTarget`
|
||||
(`internal/tools/workspace.go`) confine `write_file`, `edit`, `read_file`,
|
||||
`list_directory`, `glob`, `grep`, and the bash tool's `cwd`.
|
||||
|
||||
The agent's only recourse is workarounds (zip in workspace, ask the user to
|
||||
move it), and the denial surfaces as a raw OS error with no guidance.
|
||||
|
||||
## Goal
|
||||
|
||||
Let users grant zero write access to specific directories outside the
|
||||
workspace — at launch (flag/config) and mid-session (TUI command) — without
|
||||
weakening the sandbox elsewhere. Decided UX: **launch flag + runtime command**
|
||||
(prompt-on-denial may layer on later).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No interactive prompt-on-denial flow in this iteration.
|
||||
- No persistence of `/add-dir` grants across sessions (config is the durable
|
||||
surface).
|
||||
- No widening of network or destructive-shell policy.
|
||||
- No `enforceWorkspace: false` config knob.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Core type: `sandbox.Scope`
|
||||
|
||||
New concurrency-safe type in `internal/sandbox` (e.g. `scope.go`):
|
||||
|
||||
```go
|
||||
type Scope struct {
|
||||
mu sync.RWMutex
|
||||
workspaceRoot string // normalized at construction
|
||||
extraRoots []string // normalized, deduplicated
|
||||
}
|
||||
|
||||
func NewScope(workspaceRoot string, extras []string) (*Scope, error)
|
||||
func (s *Scope) Roots() []string // workspace first, then extras
|
||||
func (s *Scope) Add(path string) (string, error) // returns normalized root
|
||||
func (s *Scope) validate(path string) *pathViolation // package-internal
|
||||
```
|
||||
|
||||
`Add` normalization and guardrails:
|
||||
|
||||
- Expand a leading `~` to the user home directory.
|
||||
- Make absolute, `filepath.Clean`, then `filepath.EvalSymlinks`.
|
||||
- The path must exist and be a directory (no auto-create).
|
||||
- Reject the filesystem root `/` (and Windows volume roots) — granting it
|
||||
would disable confinement entirely.
|
||||
- Adding the workspace root or a path inside any existing root is a
|
||||
no-op (idempotent success, not an error).
|
||||
|
||||
`Validate` generalizes today's `validateWorkspacePath`: a path is allowed if
|
||||
it validates under **any** root, including the per-segment symlink-traversal
|
||||
check against that root. The existing single-root function remains as the
|
||||
per-root helper.
|
||||
|
||||
One `*Scope` is constructed at startup and shared by the engine, the command
|
||||
runner, and the tool registry, so a mid-session `Add` is immediately visible
|
||||
to all layers.
|
||||
|
||||
### 2. Policy engine
|
||||
|
||||
- `EngineOptions` gains `Scope *Scope`; `NewEngine` builds a workspace-only
|
||||
scope when nil (back-compat for tests/embedders).
|
||||
- `Evaluate`'s workspace check (engine.go) calls `scope.Validate` per
|
||||
requested path instead of `validateWorkspacePaths(root, request)`.
|
||||
- Risk classification (`risk.go`) treats paths inside any scope root as
|
||||
in-workspace, so writes to granted roots are not classified
|
||||
`out_of_workspace`/critical.
|
||||
- Denial reason becomes actionable:
|
||||
`"<path> is outside the workspace (use /add-dir or --add-dir to allow writes there)"`.
|
||||
|
||||
### 3. OS runners
|
||||
|
||||
- `sandboxExecProfile` emits one `(subpath "...")` per scope root inside the
|
||||
single `(allow file-write* ...)` rule.
|
||||
- `bubblewrapCommandPlan` adds `--bind <root> <root>` (read-write, real path)
|
||||
for each extra root. The workspace keeps its `/workspace` remount; extra
|
||||
roots keep their host paths so absolute paths in commands work unchanged.
|
||||
- `resolveCommandDir` accepts a command `cwd` inside any scope root.
|
||||
- Profiles are built per command plan from `scope.Roots()`, so commands run
|
||||
after `/add-dir` get the widened profile with no restart.
|
||||
|
||||
### 4. File tools
|
||||
|
||||
- New narrow interface in `internal/tools`:
|
||||
`type PathScope interface { Roots() []string }` (satisfied by
|
||||
`*sandbox.Scope`) to avoid a hard dependency direction problem.
|
||||
- `resolveWorkspacePath` and `recheckWorkspaceWriteTarget` gain scope-aware
|
||||
variants: absolute paths are resolved against the first root that contains
|
||||
them; **relative paths still resolve against the workspace root only**.
|
||||
- Tool constructors (`CoreReadOnlyTools`, `CoreWriteTools`, `CoreShellTools`,
|
||||
`CoreTools` in `registry.go`) accept the scope (workspace-only default kept
|
||||
via a convenience wrapper so existing call sites/tests keep compiling).
|
||||
- Result: `write_file`, `edit`, `read_file`, `list_directory`, `glob`, `grep`,
|
||||
and bash `cwd` all work consistently inside extra roots. Write access
|
||||
implies read access within a granted root.
|
||||
|
||||
### 5. Config, CLI, TUI
|
||||
|
||||
- **Config:** `SandboxConfig` gains
|
||||
`AdditionalWriteRoots []string \`json:"additionalWriteRoots,omitempty"\``.
|
||||
The key is honored from the **global user config**
|
||||
(`os.UserConfigDir()/zero/config.json`) so a grant can apply to every
|
||||
project, and from CLI flags/overrides. It is **deliberately excluded from
|
||||
the project config allowlist** (`mergeProjectConfig`): a repo-controlled
|
||||
`.zero/config.json` must not be able to widen write access outside the
|
||||
workspace. Sources are merged as a union (append + dedupe), not replace.
|
||||
- **CLI:** repeatable `--add-dir <path>` flag on `zero` (TUI) and `zero exec`.
|
||||
Effective set = union(flag, config), normalized through `Scope` at startup;
|
||||
an invalid root fails fast with a clear error.
|
||||
- **TUI:** new `/add-dir` command (`internal/tui/commands.go`):
|
||||
- `/add-dir` (bare) — lists current write roots.
|
||||
- `/add-dir <path>` — calls `scope.Add`; on success appends a system line:
|
||||
`write access added: <path> (this session only)`; on failure shows the
|
||||
normalization error.
|
||||
- **Observability:** `zero sandbox` status output and sandbox snapshots
|
||||
(`internal/zerocommands/sandbox_snapshots.go`) list extra roots.
|
||||
|
||||
### 6. Security invariants
|
||||
|
||||
- Extra roots only widen *file-write* (and file-tool read) access under the
|
||||
named directories; network and destructive-shell policy are untouched.
|
||||
- Symlink-traversal protection applies per root exactly as it does for the
|
||||
workspace today.
|
||||
- `/` and volume roots are rejected; roots are symlink-resolved before being
|
||||
trusted so a symlinked grant cannot silently point elsewhere later.
|
||||
- Runtime grants are session-only; durable grants require editing config.
|
||||
|
||||
### 7. Testing
|
||||
|
||||
- **Scope unit tests:** normalization (`~`, relative, symlink), rejection of
|
||||
`/`, missing dirs, files; idempotent adds; multi-root `Validate` including
|
||||
symlink-traversal escapes from one root into another denied area.
|
||||
- **Runner tests:** seatbelt profile string contains one `subpath` per root;
|
||||
bubblewrap args contain the extra `--bind`s; `resolveCommandDir` accepts
|
||||
extra-root cwd (extend `runner_test.go`).
|
||||
- **Engine tests:** out-of-workspace write allowed when inside an extra root;
|
||||
still denied (with the new actionable message) when outside all roots.
|
||||
- **Tool tests:** `write_file`/`edit`/`read_file` in an extra root succeed;
|
||||
relative paths never resolve against extra roots (extend
|
||||
`file_tools_test.go`).
|
||||
- **TUI tests:** `parseCommand` recognizes `/add-dir`; handler list/add/error
|
||||
paths.
|
||||
- **Config/CLI tests:** flag-config union, fail-fast on invalid root.
|
||||
- Platform-gated integration tests follow the existing sandbox test patterns.
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. `sandbox.Scope` + tests.
|
||||
2. Engine + risk + runner integration (flag-less, scope seeded empty).
|
||||
3. File-tool scope plumbing.
|
||||
4. Config key + CLI flags.
|
||||
5. TUI `/add-dir` command + status output.
|
||||
@@ -1,107 +0,0 @@
|
||||
# Permission-UX Phase 1 — scope-enforced grants
|
||||
|
||||
Status: approved (2026-06-11). Branch: `feat/permission-ux` (continues Phase 0, commit `742ff48`).
|
||||
|
||||
## Problem
|
||||
|
||||
Phase 0 derives a human-readable *scope* for a tool call (the file, directory, or
|
||||
cwd it touches) and shows it on the permission card and the persisted decision
|
||||
row. But the grant that an "always allow" writes is still **tool-wide**:
|
||||
|
||||
- `persistPermissionGrant` (agent/loop.go) stores only `ToolName`.
|
||||
- the grant file is keyed by tool name: `state.Grants[toolName]`.
|
||||
- enforcement (`engine.Decide` → `store.Lookup(toolName, autonomy)`, engine.go:100)
|
||||
matches on tool name alone.
|
||||
|
||||
So clicking "always allow" on a write to `src/main.go` silently authorizes *every*
|
||||
future write — the blind-yes the Phase-0 card was written to warn against.
|
||||
|
||||
## Goal
|
||||
|
||||
Persist and enforce the scope so an "always" grant covers exactly what the card
|
||||
showed: that file, or that directory subtree — nothing else.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Match semantics:** a `file` scope re-matches only its exact path; a `dir`
|
||||
scope matches that directory and any descendant; an empty scope (a call with no
|
||||
path-like arg, e.g. `bash` with no `cwd`) stays tool-wide. A tool-wide *request*
|
||||
is **not** covered by a narrower grant — it re-prompts (fail-safe).
|
||||
2. **Path anchoring:** scopes are normalized to an absolute, cleaned path at grant
|
||||
time, anchored to the workspace root. Matching compares absolute paths, so
|
||||
`./src/main.go` == `src/main.go` == `/proj/src/main.go`, and a grant made in
|
||||
`/proj-a` never matches `/proj-b`. The card still displays the short relative form.
|
||||
3. **Storage:** `grants` becomes `map[toolName][]Grant`, one record per
|
||||
(scope, decision). Schema bumps `1 → 2`; a v1 file is migrated by reading each
|
||||
entry as a single tool-wide grant, so existing grants keep working unchanged.
|
||||
4. **Precedence:** among grants that cover a request, a covering **deny wins**
|
||||
(regardless of autonomy); otherwise the most-specific covering **allow**
|
||||
(file > dir > tool-wide; ties broken by longer path, then newest) whose
|
||||
`MaxAutonomy` satisfies the request.
|
||||
|
||||
## Design
|
||||
|
||||
### Scope derivation — single source of truth (`internal/sandbox/grant_scope.go`)
|
||||
|
||||
`DeriveScope(toolName, args) (raw string, kind ScopeKind)` is the one place that
|
||||
knows which args are path-like, reusing Phase 0's key priority (most specific
|
||||
first): `path`,`file` → `file`; `directory`,`dir`,`cwd` → `dir`. Empty / `.` →
|
||||
tool-wide. `resolveScopeAbs(raw, workspaceRoot)` cleans and absolutizes (relative
|
||||
anchored to the workspace root; falls back to `filepath.Abs` when the root is
|
||||
empty). `agent.permissionScope` (Phase-0 display) is refactored to call
|
||||
`DeriveScope`, so the displayed scope and the stored/matched scope can never diverge.
|
||||
|
||||
`ScopeKind`: `""` (tool-wide) | `"file"` | `"dir"`.
|
||||
|
||||
### Data model (`internal/sandbox/grants.go`)
|
||||
|
||||
`Grant` gains `Scope` (absolute path, `omitempty`) and `ScopeKind` (`omitempty`).
|
||||
`grantFile.Grants` becomes `map[string][]Grant`; `grantSchemaVersion` = 2.
|
||||
`GrantInput` gains `Scope` (raw) + `ScopeKind`.
|
||||
|
||||
### Grant creation
|
||||
|
||||
The agent derives `(raw, kind)` via `DeriveScope` and passes them in `GrantInput`.
|
||||
`engine.Grant` resolves `Scope` to absolute using its configured `workspaceRoot`
|
||||
(the engine already holds one — engine.go:33) before delegating to `store.Grant`,
|
||||
which appends to the tool's slice (replacing a same-scope entry). CLI grants
|
||||
(`zero sandbox grants allow|deny <tool>`) leave the scope empty → tool-wide, as today.
|
||||
|
||||
### Matching (`engine.Decide` → `store.Lookup`)
|
||||
|
||||
`Decide` derives the request's absolute scope from `request.Args` +
|
||||
`request.WorkspaceRoot` (already populated at engine.go:63) and passes it to a
|
||||
scope-aware `Lookup(toolName, reqScopeAbs, autonomy)`. `grantCovers` implements the
|
||||
match semantics above; `Lookup` applies the deny-wins / most-specific-allow
|
||||
precedence and the per-grant autonomy check. The surrounding allow/deny/ceiling
|
||||
flow (engine.go:99-126) is unchanged.
|
||||
|
||||
### Migration & a fixed bug
|
||||
|
||||
`readState` peeks at `schemaVersion`: v1 (`map[string]Grant`) entries become single
|
||||
tool-wide grants; v2 reads `map[string][]Grant`; anything else still errors with
|
||||
"unsupported schemaVersion". Keys are canonicalized (trimmed) on read so a
|
||||
whitespace-padded tool name matches at lookup — closing audit finding #90
|
||||
(grants.go:146). `writeState` always emits v2.
|
||||
|
||||
### CLI surface
|
||||
|
||||
`List()` flattens to `[]Grant` (sorted by tool, then scope). `FormatGrantList`
|
||||
shows the scope (`*` for tool-wide). `Revoke(toolName)` removes all of a tool's
|
||||
grants and returns the count. Per-scope revoke is out of scope.
|
||||
|
||||
## Testing
|
||||
|
||||
- `grant_scope_test` / `grants_test`: `DeriveScope` kinds; `resolveScopeAbs` (relative
|
||||
anchored, `./` equivalence, absolute passthrough, cross-project non-match);
|
||||
`grantCovers` matrix (tool-wide/file/dir × equal/descendant/sibling/parent/
|
||||
empty-request); deny-precedence; per-grant + policy autonomy; v1→v2 migration
|
||||
round-trip; whitespace-key canonicalization; `FormatGrantList` scope rendering.
|
||||
- `engine_test`: covered scoped request auto-allows; uncovered sibling re-prompts;
|
||||
dir deny blocks a subtree request; legacy tool-wide grant still allows.
|
||||
- `agent`: `persistPermissionGrant` forwards scope+kind; `TestPermissionScope`
|
||||
stays green (display unchanged).
|
||||
|
||||
## Out of scope
|
||||
|
||||
Card UI changes (Phase 0), per-scope revoke, glob/pattern scopes.
|
||||
@@ -10,8 +10,10 @@ require (
|
||||
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/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
|
||||
golang.org/x/sys v0.46.0
|
||||
mvdan.cc/sh/v3 v3.13.1
|
||||
@@ -20,7 +22,6 @@ require (
|
||||
require (
|
||||
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/term v0.2.2 // 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
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func enabledSelfCorrector(t *testing.T) *SelfCorrector {
|
||||
t.Helper()
|
||||
// checker/verifier nil → no LSP / project-test pass runs; we only need a
|
||||
// non-nil SelfCorrector to enable the task-grounded acceptance gate.
|
||||
return NewSelfCorrector(t.TempDir(), nil, nil, SelfCorrectConfig{Enabled: true})
|
||||
}
|
||||
|
||||
// BUG #2(a): a final message that admits the model guessed / could not meet the
|
||||
// objective must be reported as INCOMPLETE, never success — and immediately
|
||||
// (an admitted guess is not worth re-prompting).
|
||||
func TestAcceptanceSelfReportAdmissionIsIncomplete(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn("I couldn't analyze the board image, so I wrote the common opening move e2e4 as my best guess."),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "write the best chess move", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
SelfCorrect: enabledSelfCorrector(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Incomplete {
|
||||
t.Fatalf("admitted guess must be Incomplete, got success; final=%q", result.FinalAnswer)
|
||||
}
|
||||
if !strings.Contains(result.IncompleteReason, "objective was not met") {
|
||||
t.Fatalf("IncompleteReason = %q, want it to cite the admission", result.IncompleteReason)
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("an admission should downgrade immediately (1 turn), got %d", len(provider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
// BUG #2(a): the inability detector must generalize over the verb the model uses
|
||||
// (so re-phrasing can't defeat it) while NOT misreading success-y negations
|
||||
// ("could not find any issues") as admissions.
|
||||
func TestSelfReportedIncompletionMatching(t *testing.T) {
|
||||
cases := []struct {
|
||||
text string
|
||||
admit bool
|
||||
}{
|
||||
// real chess re-run wording that slipped a fixed phrase list:
|
||||
{"…which I cannot do without proper image analysis capabilities.", true},
|
||||
{"I cannot visually analyze the chess board image.", true},
|
||||
{"I could not recover the original values, so this is my best guess.", true},
|
||||
{"I was unable to complete the optimization.", true},
|
||||
{"The result may not be correct.", true},
|
||||
// multi-occurrence: an early guarded use ("could not find any") must NOT mask a
|
||||
// later genuine admission with the same stem (review #5):
|
||||
{"I could not find any examples in the codebase, so I could not implement the feature.", true},
|
||||
// genuine successes that must NOT be flagged:
|
||||
{"I implemented the function and all tests pass.", false},
|
||||
{"I verified the fix; I could not find any remaining issues.", false},
|
||||
{"I cannot reproduce the bug, so the fix holds.", false},
|
||||
{"Done — the output matches the required format and values.", false},
|
||||
// behavior descriptions that must NOT be flagged — these phrases describe
|
||||
// implemented behavior, not an admission (review #1, removed from the list):
|
||||
{"The parser will fall back to UTF-8 when no encoding is set.", false},
|
||||
{"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},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := selfReportedIncompletion(c.text) != ""
|
||||
if got != c.admit {
|
||||
t.Errorf("selfReportedIncompletion(%q) admitted=%v, want %v", c.text, got, c.admit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BUG #2(a): a "cannot analyze / cannot determine" admission (present tense) must
|
||||
// be caught even when the model also stamps a bogus "PASS" based on a shape check.
|
||||
// Mirrors the chess-best-move final ("I cannot analyze the position… PASS: I wrote
|
||||
// the move and verified by reading it back").
|
||||
func TestAcceptanceCannotPhrasingIsIncomplete(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn("I cannot analyze the specific position from the image, so I cannot determine the best move. PASS: I wrote e2e4 to the file and verified it by reading it back."),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "write the best move", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
SelfCorrect: enabledSelfCorrector(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Incomplete {
|
||||
t.Fatalf("a 'cannot analyze/determine' admission must be Incomplete despite the bogus PASS; final=%q", result.FinalAnswer)
|
||||
}
|
||||
}
|
||||
|
||||
// A pending/in_progress update_plan item is an AMBIGUOUS signal (the model may have
|
||||
// finished without re-marking the step), so it must NOT by itself force INCOMPLETE.
|
||||
// The gate nudges, but if the model keeps claiming completion with no continuation
|
||||
// cue and no admission, the run finalizes as SUCCESS — trusting the completion claim
|
||||
// over stale plan bookkeeping. (Only a continuation cue or self-report admission
|
||||
// forces INCOMPLETE; see TestCompletionGate* and TestAcceptance*Admission*.)
|
||||
func TestPendingPlanAloneDoesNotForceIncomplete(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
done := "All set." // confident; no cue, no admission — only the plan is stale
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
planTurn("completed", "in_progress"),
|
||||
textTurn(done), textTurn(done), textTurn(done), textTurn(done), textTurn(done),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "do the multi-step task", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
SelfCorrect: enabledSelfCorrector(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("pending-plan alone must NOT force Incomplete (stale bookkeeping != unfinished); reason=%q", result.IncompleteReason)
|
||||
}
|
||||
if result.FinalAnswer != done {
|
||||
t.Fatalf("final answer = %q, want the completion claim %q", result.FinalAnswer, done)
|
||||
}
|
||||
if !someRequestContains(provider.requests, continueNudgeMarker) {
|
||||
t.Fatalf("expected at least one continue-nudge before accepting completion")
|
||||
}
|
||||
}
|
||||
|
||||
// BUG #2(b): when self-correct is on, the run must pass a one-time task-grounded
|
||||
// acceptance check before success. A model that, when challenged, confirms its
|
||||
// work meets the criterion (no admission) then finalizes as success — no regression.
|
||||
func TestAcceptanceGroundedCompletionSucceeds(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn("I implemented the function."),
|
||||
textTurn("PASS. I ran the verification and it passes; the output meets the task requirement."),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "implement the function", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
SelfCorrect: enabledSelfCorrector(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("a verified completion must succeed, got Incomplete (%q)", result.IncompleteReason)
|
||||
}
|
||||
if result.FinalAnswer != "PASS. I ran the verification and it passes; the output meets the task requirement." {
|
||||
t.Fatalf("final answer = %q", result.FinalAnswer)
|
||||
}
|
||||
// Exactly one acceptance pass: the first "done" turn was challenged, the second confirmed.
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected 2 turns (1 acceptance challenge + 1 confirmation), got %d", len(provider.requests))
|
||||
}
|
||||
if !someRequestContains(provider.requests, acceptanceNudgeMarker) {
|
||||
t.Fatalf("expected the task-grounded acceptance check to be demanded")
|
||||
}
|
||||
}
|
||||
|
||||
// With the gate OFF (interactive/TUI default), even an admission is accepted as the
|
||||
// final answer exactly as before — proving no behavior change for non-headless callers.
|
||||
func TestAcceptanceGateOffLeavesAdmissionAsSuccess(t *testing.T) {
|
||||
admission := "I couldn't determine the value, so this is a guess."
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn(admission),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "do a thing", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
// RequireCompletionSignal false, SelfCorrect nil — the legacy path.
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("legacy path must never set Incomplete; final=%q", result.FinalAnswer)
|
||||
}
|
||||
if result.FinalAnswer != admission {
|
||||
t.Fatalf("final answer = %q, want the text verbatim (legacy behavior)", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("legacy path must not re-prompt; got %d turns", len(provider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
// The acceptance check is scoped to runs with self-correct on: a headless run
|
||||
// WITHOUT a SelfCorrector accepts a confident completion immediately (the (b) gate
|
||||
// is inert), so enabling RequireCompletionSignal alone adds no acceptance turn.
|
||||
func TestAcceptanceGateRequiresSelfCorrect(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn("Done. Implemented and it works."),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "implement it", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
// SelfCorrect deliberately nil → acceptance gate (b) must not fire.
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("confident completion without self-correct must succeed; reason=%q", result.IncompleteReason)
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("acceptance gate must not fire without self-correct; got %d turns", len(provider.requests))
|
||||
}
|
||||
if someRequestContains(provider.requests, acceptanceNudgeMarker) {
|
||||
t.Fatalf("acceptance nudge must not be injected when self-correct is off")
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"mvdan.cc/sh/v3/syntax"
|
||||
)
|
||||
|
||||
@@ -15,43 +17,106 @@ func proposedCommandPrefix(toolName string, args map[string]any) []string {
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
tokens, ok := safeShellCommandTokens(command)
|
||||
segments, ok := safeShellCommandSegments(command)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if requested, ok := requestedPrefixRule(args); ok {
|
||||
if safeRequestedPrefix(requested, tokens) {
|
||||
if safeRequestedPrefixForSegments(requested, segments) {
|
||||
return requested
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !sandbox.ValidCommandPrefix(tokens) {
|
||||
for _, tokens := range segments {
|
||||
if !knownSafeCommandSegment(tokens) && sandbox.ValidCommandPrefix(tokens) {
|
||||
return append([]string(nil), tokens...)
|
||||
}
|
||||
}
|
||||
if len(segments) == 0 || !sandbox.ValidCommandPrefix(segments[0]) {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), tokens...)
|
||||
return append([]string(nil), segments[0]...)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if shellCommandAdditionalPermissionsRequested(args) {
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
}
|
||||
command, ok := firstStringArg(args, "command", "cmd", "script", "shell")
|
||||
if !ok {
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
}
|
||||
tokens, ok := safeShellCommandTokens(command)
|
||||
segments, ok := safeShellCommandSegments(command)
|
||||
if !ok {
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
}
|
||||
if grant, ok := options.Sandbox.LookupCommandPrefix(toolName, tokens); ok {
|
||||
return grant, true, false
|
||||
var matched sandbox.CommandPrefixGrant
|
||||
matchedAny := false
|
||||
matchedSession := false
|
||||
for _, tokens := range segments {
|
||||
if grant, ok := options.Sandbox.LookupCommandPrefix(toolName, tokens); ok {
|
||||
if !matchedAny {
|
||||
matched = grant
|
||||
}
|
||||
matchedAny = true
|
||||
continue
|
||||
}
|
||||
if grant, ok := options.Sandbox.LookupCommandPrefixForSession(toolName, tokens); ok {
|
||||
if !matchedAny {
|
||||
matched = grant
|
||||
}
|
||||
matchedAny = true
|
||||
matchedSession = true
|
||||
continue
|
||||
}
|
||||
if knownSafeCommandSegment(tokens) {
|
||||
continue
|
||||
}
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
}
|
||||
if grant, ok := options.Sandbox.LookupCommandPrefixForSession(toolName, tokens); ok {
|
||||
return grant, true, true
|
||||
if matchedAny {
|
||||
return matched, true, matchedSession
|
||||
}
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
}
|
||||
|
||||
func shellExecutionArgsForApproval(toolName string, args map[string]any, action PermissionDecisionAction, options Options) map[string]any {
|
||||
if !isShellCommandTool(toolName) || !shellPrefixApprovalBypassesSandbox(action) {
|
||||
return args
|
||||
}
|
||||
if options.Sandbox == nil || !options.Sandbox.UnsandboxedExecutionAllowed() {
|
||||
return args
|
||||
}
|
||||
if shellCommandAdditionalPermissionsRequested(args) || shellCommandRequiresEscalated(args) {
|
||||
return args
|
||||
}
|
||||
planned := cloneArgs(args)
|
||||
if planned == nil {
|
||||
planned = map[string]any{}
|
||||
}
|
||||
planned["sandbox_permissions"] = string(tools.SandboxPermissionsRequireEscalated)
|
||||
return planned
|
||||
}
|
||||
|
||||
func shellPrefixApprovalBypassesSandbox(action PermissionDecisionAction) bool {
|
||||
return action == PermissionDecisionAllowPrefix || action == PermissionDecisionAlwaysAllowPrefix
|
||||
}
|
||||
|
||||
func shellCommandRequiresEscalated(args map[string]any) bool {
|
||||
raw, ok := args["sandbox_permissions"]
|
||||
if !ok || raw == nil {
|
||||
return false
|
||||
}
|
||||
value, ok := raw.(string)
|
||||
if !ok {
|
||||
value = fmt.Sprint(raw)
|
||||
}
|
||||
return strings.TrimSpace(value) == string(tools.SandboxPermissionsRequireEscalated)
|
||||
}
|
||||
|
||||
func isShellCommandTool(toolName string) bool {
|
||||
return toolName == "bash" || toolName == "exec_command"
|
||||
}
|
||||
@@ -104,32 +169,92 @@ func cleanPrefixRule(prefix []string) []string {
|
||||
}
|
||||
|
||||
func safeRequestedPrefix(prefix []string, command []string) bool {
|
||||
if len(prefix) == 0 || !sandbox.ValidCommandPrefix(prefix) || len(prefix) > len(command) {
|
||||
return safeRequestedPrefixForSegments(prefix, [][]string{command})
|
||||
}
|
||||
|
||||
func safeRequestedPrefixForSegments(prefix []string, segments [][]string) bool {
|
||||
if len(prefix) == 0 || !sandbox.ValidCommandPrefix(prefix) {
|
||||
return false
|
||||
}
|
||||
for index := range prefix {
|
||||
if prefix[index] != command[index] {
|
||||
matched := false
|
||||
for _, command := range segments {
|
||||
if len(prefix) > len(command) {
|
||||
if knownSafeCommandSegment(command) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
if hasStringPrefix(command, prefix) {
|
||||
matched = true
|
||||
continue
|
||||
}
|
||||
if knownSafeCommandSegment(command) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
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 == "" {
|
||||
return nil, false
|
||||
}
|
||||
file, err := syntax.NewParser().Parse(strings.NewReader(command), "")
|
||||
if err != nil || len(file.Stmts) != 1 {
|
||||
if err != nil || len(file.Stmts) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
stmt := file.Stmts[0]
|
||||
if stmt.Negated || stmt.Background || stmt.Coprocess || stmt.Disown || stmt.Semicolon.IsValid() || len(stmt.Redirs) > 0 {
|
||||
segments := make([][]string, 0, len(file.Stmts))
|
||||
for _, stmt := range file.Stmts {
|
||||
if !collectSafeShellStatement(stmt, &segments) {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
if len(segments) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
call, ok := stmt.Cmd.(*syntax.CallExpr)
|
||||
if !ok || len(call.Assigns) > 0 || len(call.Args) == 0 {
|
||||
return segments, true
|
||||
}
|
||||
|
||||
func collectSafeShellStatement(stmt *syntax.Stmt, segments *[][]string) bool {
|
||||
if stmt == nil || stmt.Negated || stmt.Background || stmt.Coprocess || stmt.Disown || len(stmt.Redirs) > 0 {
|
||||
return false
|
||||
}
|
||||
return collectSafeShellCommand(stmt.Cmd, segments)
|
||||
}
|
||||
|
||||
func collectSafeShellCommand(cmd syntax.Command, segments *[][]string) bool {
|
||||
switch node := cmd.(type) {
|
||||
case *syntax.CallExpr:
|
||||
tokens, ok := literalCallTokens(node)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
*segments = append(*segments, tokens)
|
||||
return true
|
||||
case *syntax.BinaryCmd:
|
||||
switch node.Op {
|
||||
case syntax.AndStmt, syntax.OrStmt, syntax.Pipe:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return collectSafeShellStatement(node.X, segments) && collectSafeShellStatement(node.Y, segments)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func literalCallTokens(call *syntax.CallExpr) ([]string, bool) {
|
||||
if call == nil || len(call.Assigns) > 0 || len(call.Args) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
tokens := make([]string, 0, len(call.Args))
|
||||
@@ -138,17 +263,228 @@ func safeShellCommandTokens(command string) ([]string, bool) {
|
||||
return nil, false
|
||||
}
|
||||
lit, ok := word.Parts[0].(*syntax.Lit)
|
||||
if !ok {
|
||||
if !ok || strings.ContainsAny(lit.Value, "*?[]{}") {
|
||||
return nil, false
|
||||
}
|
||||
tokens = append(tokens, lit.Value)
|
||||
}
|
||||
if !sandbox.ValidCommandPrefix(tokens) {
|
||||
return nil, false
|
||||
}
|
||||
return tokens, true
|
||||
}
|
||||
|
||||
func knownSafeCommandSegment(command []string) bool {
|
||||
if len(command) == 0 {
|
||||
return false
|
||||
}
|
||||
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":
|
||||
return true
|
||||
case "base64":
|
||||
return safeBase64Command(command[1:])
|
||||
case "find":
|
||||
return safeFindCommand(command[1:])
|
||||
case "rg":
|
||||
return safeRipgrepCommand(command[1:])
|
||||
case "sed":
|
||||
return safeSedCommand(command[1:])
|
||||
case "git":
|
||||
return safeGitCommand(command)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func safeBase64Command(args []string) bool {
|
||||
for _, arg := range args {
|
||||
if arg == "-o" || arg == "--output" || strings.HasPrefix(arg, "--output=") || (strings.HasPrefix(arg, "-o") && arg != "-o") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func safeFindCommand(args []string) bool {
|
||||
unsafe := map[string]bool{
|
||||
"-exec": true, "-execdir": true, "-ok": true, "-okdir": true,
|
||||
"-delete": true,
|
||||
"-fls": true, "-fprint": true, "-fprint0": true, "-fprintf": true,
|
||||
}
|
||||
for _, arg := range args {
|
||||
if unsafe[arg] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func safeRipgrepCommand(args []string) bool {
|
||||
for _, arg := range args {
|
||||
if arg == "--search-zip" || arg == "-z" || arg == "--pre" || arg == "--hostname-bin" ||
|
||||
strings.HasPrefix(arg, "--pre=") || strings.HasPrefix(arg, "--hostname-bin=") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func safeSedCommand(args []string) bool {
|
||||
if len(args) < 2 || len(args) > 3 || args[0] != "-n" {
|
||||
return false
|
||||
}
|
||||
return validSedPrintArg(args[1])
|
||||
}
|
||||
|
||||
func validSedPrintArg(arg string) bool {
|
||||
if !strings.HasSuffix(arg, "p") {
|
||||
return false
|
||||
}
|
||||
body := strings.TrimSuffix(arg, "p")
|
||||
if body == "" {
|
||||
return false
|
||||
}
|
||||
for _, part := range strings.Split(body, ",") {
|
||||
if part == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range part {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func safeGitCommand(command []string) bool {
|
||||
subIndex, subcommand, ok := gitSubcommand(command)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if gitHasUnsafeGlobalOption(command[1:subIndex]) {
|
||||
return false
|
||||
}
|
||||
args := command[subIndex+1:]
|
||||
switch subcommand {
|
||||
case "status", "log", "diff", "show":
|
||||
return gitArgsReadOnly(args)
|
||||
case "branch":
|
||||
return gitArgsReadOnly(args) && gitBranchReadOnly(args)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func gitSubcommand(command []string) (int, string, bool) {
|
||||
for index := 1; index < len(command); index++ {
|
||||
arg := command[index]
|
||||
if gitOptionConsumesValue(arg) {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if gitOptionHasInlineValue(arg) || arg == "--" || strings.HasPrefix(arg, "-") {
|
||||
continue
|
||||
}
|
||||
switch arg {
|
||||
case "status", "log", "diff", "show", "branch":
|
||||
return index, arg, true
|
||||
default:
|
||||
return 0, "", false
|
||||
}
|
||||
}
|
||||
return 0, "", false
|
||||
}
|
||||
|
||||
func gitOptionConsumesValue(arg string) bool {
|
||||
switch arg {
|
||||
case "-C", "-c", "--config-env", "--exec-path", "--git-dir", "--namespace", "--super-prefix", "--work-tree":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func gitOptionHasInlineValue(arg string) bool {
|
||||
return strings.HasPrefix(arg, "--config-env=") ||
|
||||
strings.HasPrefix(arg, "--exec-path=") ||
|
||||
strings.HasPrefix(arg, "--git-dir=") ||
|
||||
strings.HasPrefix(arg, "--namespace=") ||
|
||||
strings.HasPrefix(arg, "--super-prefix=") ||
|
||||
strings.HasPrefix(arg, "--work-tree=") ||
|
||||
((strings.HasPrefix(arg, "-C") || strings.HasPrefix(arg, "-c")) && len(arg) > 2)
|
||||
}
|
||||
|
||||
func gitHasUnsafeGlobalOption(args []string) bool {
|
||||
for index := 0; index < len(args); index++ {
|
||||
arg := args[index]
|
||||
switch {
|
||||
case strings.HasPrefix(arg, "--upload-pack"):
|
||||
return true
|
||||
case arg == "-C" || strings.HasPrefix(arg, "-C"):
|
||||
return true
|
||||
case gitOptionConsumesValue(arg):
|
||||
index++
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func gitArgsReadOnly(args []string) bool {
|
||||
for _, arg := range args {
|
||||
switch {
|
||||
case strings.HasPrefix(arg, "--output="), strings.HasPrefix(arg, "--exec="):
|
||||
return false
|
||||
case arg == "--output", arg == "--exec":
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func gitBranchReadOnly(args []string) bool {
|
||||
if len(args) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, arg := range args {
|
||||
if strings.HasPrefix(arg, "--format=") {
|
||||
continue
|
||||
}
|
||||
switch arg {
|
||||
case "--list", "-l", "--show-current", "-a", "--all", "-r", "--remotes", "-v", "-vv", "--verbose":
|
||||
continue
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func commandName(raw string) string {
|
||||
name := strings.TrimSpace(raw)
|
||||
if index := strings.LastIndexAny(name, `/\`); index >= 0 {
|
||||
name = name[index+1:]
|
||||
}
|
||||
name = strings.ToLower(name)
|
||||
for _, suffix := range []string{".exe", ".cmd", ".bat", ".com"} {
|
||||
if strings.HasSuffix(name, suffix) {
|
||||
return strings.TrimSuffix(name, suffix)
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func hasStringPrefix(values []string, prefix []string) bool {
|
||||
if len(prefix) == 0 || len(prefix) > len(values) {
|
||||
return false
|
||||
}
|
||||
for index := range prefix {
|
||||
if values[index] != prefix[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func equalStringSlices(left []string, right []string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{"command": "go test ./..."})
|
||||
@@ -29,6 +33,35 @@ func TestProposedCommandPrefixHonorsValidatedRequestedPrefix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixSupportsSegmentedCommands(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"})
|
||||
want := []string{"ps", "aux"}
|
||||
if !equalStringSlices(got, want) {
|
||||
t.Fatalf("prefix = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixHonorsRequestedPrefixAcrossSegments(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{
|
||||
"command": "git status --short && git status --branch",
|
||||
"prefix_rule": []any{"git", "status"},
|
||||
})
|
||||
want := []string{"git", "status"}
|
||||
if !equalStringSlices(got, want) {
|
||||
t.Fatalf("prefix = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixRejectsRequestedPrefixThatDoesNotCoverSegments(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{
|
||||
"command": "ps aux && npm install",
|
||||
"prefix_rule": []any{"ps", "aux"},
|
||||
})
|
||||
if got != nil {
|
||||
t.Fatalf("partial requested prefix should be rejected, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixRejectsUnsafeRequestedPrefix(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{
|
||||
"command": "git status --short",
|
||||
@@ -41,7 +74,6 @@ func TestProposedCommandPrefixRejectsUnsafeRequestedPrefix(t *testing.T) {
|
||||
|
||||
func TestProposedCommandPrefixRejectsUnsafeShellForms(t *testing.T) {
|
||||
cases := []string{
|
||||
"echo hi && echo bye",
|
||||
"cat < in > out",
|
||||
"FOO=bar go test",
|
||||
"echo $(whoami)",
|
||||
@@ -85,6 +117,25 @@ func TestProposedCommandPrefixRejectsUnsafeLaunchers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchCommandPrefixCoversSegmentedCommandWithSafeTail(t *testing.T) {
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
|
||||
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
|
||||
|
||||
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 TestMatchCommandPrefixRejectsUncoveredSegment(t *testing.T) {
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
|
||||
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
|
||||
|
||||
if grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": "ps aux && npm install"}, Options{Sandbox: engine}); ok {
|
||||
t.Fatalf("match = %#v session=%v, want no match because npm segment is uncovered", grant, session)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixRejectsRequestedUnsafeLauncherPrefix(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{
|
||||
"command": "find . -type f",
|
||||
|
||||
@@ -280,6 +280,25 @@ func isContextLimitError(message string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// isStreamTimeoutError reports whether a streamed error is a provider stream
|
||||
// idle/stall timeout (providerio surfaces these as "idle timeout after …" /
|
||||
// "no output for …" / "stream stalled"). Such a turn produced nothing, so when
|
||||
// no content was forwarded yet it can be safely re-issued on a fresh connection
|
||||
// (see the stall-retry in the agent loop). Substring + case-insensitive to
|
||||
// tolerate the prefixed "provider stream error: …" wrapping.
|
||||
func isStreamTimeoutError(message string) bool {
|
||||
lowered := strings.ToLower(strings.TrimSpace(message))
|
||||
if lowered == "" {
|
||||
return false
|
||||
}
|
||||
for _, needle := range []string{"idle timeout after", "no output for", "stream stalled"} {
|
||||
if strings.Contains(lowered, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// compactionState carries the per-run state the agent loop needs to compact a
|
||||
// conversation safely. It is created once per Run and is a no-op whenever
|
||||
// options.ContextWindow <= 0.
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// someRequestContains reports whether any message sent to the provider across all
|
||||
// turns contains substr — used to assert a continue nudge was actually injected.
|
||||
func someRequestContains(requests []zeroruntime.CompletionRequest, substr string) bool {
|
||||
for _, req := range requests {
|
||||
for _, msg := range req.Messages {
|
||||
if strings.Contains(msg.Content, substr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// planTurn is a turn that calls update_plan with the given item statuses (reusing
|
||||
// the package's shared toolTurn helper).
|
||||
func planTurn(statuses ...string) []zeroruntime.StreamEvent {
|
||||
items := make([]string, len(statuses))
|
||||
for i, s := range statuses {
|
||||
items[i] = `{"content":"step ` + s + `","status":"` + s + `"}`
|
||||
}
|
||||
return toolTurn("plan", "update_plan", `{"plan":[`+strings.Join(items, ",")+`]}`)
|
||||
}
|
||||
|
||||
// BUG #1 regression: a no-tool-call turn that ends mid-step while plan items are
|
||||
// still pending must NOT be accepted as success. The loop must re-prompt (bounded)
|
||||
// and, if the model keeps stalling, finalize as INCOMPLETE — never success.
|
||||
func TestCompletionGatePendingPlanContinuesThenIncomplete(t *testing.T) {
|
||||
// Mirrors the git-multibranch failure: plan with pending steps, then the model
|
||||
// keeps emitting "…Let me check the SSH configuration:" without acting.
|
||||
cue := "Now I need to configure the SSH server. Let me check the current SSH configuration:"
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
planTurn("completed", "pending", "pending"),
|
||||
textTurn(cue), textTurn(cue), textTurn(cue), textTurn(cue), textTurn(cue),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "set up a git server", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Incomplete {
|
||||
t.Fatalf("expected Incomplete=true (model stalled with pending plan), got false; final=%q turns=%d", result.FinalAnswer, result.Turns)
|
||||
}
|
||||
// 1 plan turn + maxContinueNudges(3) nudged turns + 1 final stalling turn = 5.
|
||||
// Critically it did NOT stop at the first text turn (request 2) as success.
|
||||
if len(provider.requests) != 1+maxContinueNudges+1 {
|
||||
t.Fatalf("expected %d provider turns (1 plan + %d nudges + 1 final), got %d",
|
||||
1+maxContinueNudges+1, maxContinueNudges, len(provider.requests))
|
||||
}
|
||||
if !someRequestContains(provider.requests, continueNudgeMarker) {
|
||||
t.Fatalf("expected a continue nudge (%q) to be injected into the conversation", continueNudgeMarker)
|
||||
}
|
||||
}
|
||||
|
||||
// A genuinely-complete single-turn answer (no plan, no continuation cue) must
|
||||
// still finalize as success — the gate must not break short/read-only tasks.
|
||||
func TestCompletionGateAcceptsGenuineCompletion(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn("The file contains 42 lines."),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "count the lines", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("genuine completion wrongly marked Incomplete; final=%q", result.FinalAnswer)
|
||||
}
|
||||
if result.FinalAnswer != "The file contains 42 lines." {
|
||||
t.Fatalf("final answer = %q, want the completed answer", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("expected exactly 1 turn (no spurious re-prompt), got %d", len(provider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
// A continuation-cue turn triggers a re-prompt, but once the model actually
|
||||
// finishes (clean answer, no cue, no pending plan) the run exits as success — the
|
||||
// nudge gives the model a path to a legitimate completion.
|
||||
func TestCompletionGateContinuesOnCueThenSucceeds(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn("Let me read the file:"),
|
||||
textTurn("Done. The file has 42 lines."),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "count the lines", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("run wrongly marked Incomplete after the model completed; final=%q", result.FinalAnswer)
|
||||
}
|
||||
if result.FinalAnswer != "Done. The file has 42 lines." {
|
||||
t.Fatalf("final answer = %q, want the completed answer", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected 2 turns (1 cue re-prompted + 1 completion), got %d", len(provider.requests))
|
||||
}
|
||||
if !someRequestContains(provider.requests, continueNudgeMarker) {
|
||||
t.Fatalf("expected a continue nudge after the continuation-cue turn")
|
||||
}
|
||||
}
|
||||
|
||||
// With the gate OFF (the interactive/TUI default), a continuation-cue turn is
|
||||
// accepted as the final answer exactly as before — guaranteeing no behavior
|
||||
// change for non-headless callers.
|
||||
func TestCompletionGateOffPreservesLegacyBehavior(t *testing.T) {
|
||||
cue := "Let me check the config:"
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn(cue),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "do a thing", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
// RequireCompletionSignal deliberately left false.
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("legacy path must never set Incomplete; final=%q", result.FinalAnswer)
|
||||
}
|
||||
if result.FinalAnswer != cue {
|
||||
t.Fatalf("final answer = %q, want %q (legacy: text-only turn is the answer)", result.FinalAnswer, cue)
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("legacy path must not re-prompt; got %d turns", len(provider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
// review #6: the continuation-cue detector must catch a mid-line action announcement
|
||||
// that stops on a colon (the git-multibranch case), without flagging genuine closers
|
||||
// — a recommendation, a plain summary colon, or a sign-off.
|
||||
func TestContinuationCueMatching(t *testing.T) {
|
||||
cases := []struct {
|
||||
text string
|
||||
cue bool
|
||||
}{
|
||||
{"Now I need to configure the SSH server. Let me check the current SSH configuration:", true},
|
||||
{"Let me read the file:", true},
|
||||
{"Now I'll run the tests:", true},
|
||||
{"Next, I suggest reviewing the changes.", false}, // recommendation, no colon
|
||||
{"Here is the summary:", false}, // summary colon, no action lead-in
|
||||
{"Let me know if you need anything:", false}, // sign-off
|
||||
{"The function is implemented and all tests pass.", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := endsWithContinuationCue(c.text); got != c.cue {
|
||||
t.Errorf("endsWithContinuationCue(%q) = %v, want %v", c.text, got, c.cue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// review #4: a run that loops to the MaxTurns ceiling (always calling a tool, so it
|
||||
// never reaches the no-tool-call gate) was reported as success. Under the headless
|
||||
// gate, a max-turns cutoff is INCOMPLETE — the agent was stopped mid-run, not done.
|
||||
func TestMaxTurnsCutoffIsIncompleteUnderGate(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
toolEvery := toolTurn("c", "update_plan", `{"plan":[{"content":"step","status":"in_progress"}]}`)
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
toolEvery, toolEvery, toolEvery, toolEvery,
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "keep working", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 2,
|
||||
RequireCompletionSignal: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Incomplete {
|
||||
t.Fatalf("a max-turns cutoff under the gate must be Incomplete; final=%q", result.FinalAnswer)
|
||||
}
|
||||
if !strings.Contains(result.IncompleteReason, "max-turns") {
|
||||
t.Fatalf("IncompleteReason = %q, want it to cite max-turns", result.IncompleteReason)
|
||||
}
|
||||
}
|
||||
@@ -81,3 +81,11 @@ Otherwise, confirm before executing.
|
||||
6. Avoid redundant confirmations if you already confirmed and there is no material new risk.
|
||||
7. Write/shell operations inside the workspace do not need the same scrutiny as operations outside it.
|
||||
8. Interactive programs (editors, pagers, REPLs, `ssh`/`psql`/`mysql` without a command, `top`/`htop`, `git rebase -i`) will be blocked because they hang the agent; always use the non-interactive alternative.
|
||||
|
||||
## Sandbox Permission Requests
|
||||
|
||||
- Prefer sandboxed additional permissions over full escalation: use `sandbox_permissions: "with_additional_permissions"` with the required file-system or network permission when that can satisfy the command.
|
||||
- Use `sandbox_permissions: "require_escalated"` only when the command must run outside the sandbox, such as inspecting or controlling host/global state that the sandbox intentionally hides.
|
||||
- If a sandboxed shell command returns only sandbox-local state when the user asked for host/global state, do not answer from that misleading output. Rerun the needed command with `sandbox_permissions: "require_escalated"`, a short user-facing `justification`, and a narrow `prefix_rule` when appropriate.
|
||||
- For `require_escalated`, include a short user-facing `justification`. When a reusable approval is appropriate, include a narrow `prefix_rule`; do not suggest broad shell, interpreter, or launcher prefixes.
|
||||
- Shell commands are evaluated as independent command segments across `|`, `&&`, `||`, and `;`. A reusable prefix must apply safely to the command segment it is meant to authorize.
|
||||
@@ -0,0 +1,99 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
// decodeToolArguments must (a) decode a normal single-object payload unchanged,
|
||||
// (b) tolerate a weak model packing multiple concatenated top-level JSON objects
|
||||
// into one arguments string by decoding the FIRST object and ignoring the rest
|
||||
// (the minimax-m3 "invalid character '{' after top-level value" failure), and
|
||||
// (c) still reject a genuinely malformed/truncated first object.
|
||||
func TestDecodeToolArguments(t *testing.T) {
|
||||
t.Run("single object decodes normally", func(t *testing.T) {
|
||||
var a map[string]any
|
||||
if err := decodeToolArguments(`{"path":"x"}`, &a); err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if a["path"] != "x" {
|
||||
t.Fatalf("decoded %v, want path=x", a)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("concatenated objects decode the first, ignore trailing", func(t *testing.T) {
|
||||
var b map[string]any
|
||||
if err := decodeToolArguments(`{"path":"first"}{"path":"second"}`, &b); err != nil {
|
||||
t.Fatalf("multi-object should decode the first, got err = %v", err)
|
||||
}
|
||||
if b["path"] != "first" {
|
||||
t.Fatalf("decoded %v, want the first object (path=first)", b)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("distinct tools' args concatenated (real minimax case): first object used", func(t *testing.T) {
|
||||
var c map[string]any
|
||||
if err := decodeToolArguments(`{"plan":[1,2]}{"cmd":"go version"}`, &c); err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if _, ok := c["plan"]; !ok || c["cmd"] != nil {
|
||||
t.Fatalf("decoded %v, want only the first object's keys", c)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("truncated first object still errors", func(t *testing.T) {
|
||||
var d map[string]any
|
||||
if err := decodeToolArguments(`{"path":`, &d); err == nil {
|
||||
t.Fatal("truncated JSON must still error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-JSON trailing garbage still errors (not masked)", func(t *testing.T) {
|
||||
var g map[string]any
|
||||
if err := decodeToolArguments(`{"x":1}xyz`, &g); err == nil {
|
||||
t.Fatal("a valid first object followed by non-JSON garbage must still error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("recoverableToolArguments: only whole-JSON trailing is tolerated", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
ok bool
|
||||
first string
|
||||
}{
|
||||
{`{"a":1}`, true, `{"a":1}`},
|
||||
{`{"a":1}{"b":2}`, true, `{"a":1}`},
|
||||
{`{"a":1} {"b":2} `, true, `{"a":1}`},
|
||||
{`{"a":1}xyz`, false, ""},
|
||||
{`{"a":`, false, ""},
|
||||
{` `, false, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
first, ok := recoverableToolArguments(c.in)
|
||||
if ok != c.ok || (ok && first != c.first) {
|
||||
t.Fatalf("recoverableToolArguments(%q) = (%q,%v), want (%q,%v)", c.in, first, ok, c.first, c.ok)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("historySafeToolCalls recovers the first object, not {}", func(t *testing.T) {
|
||||
out := historySafeToolCalls([]ToolCall{
|
||||
{ID: "c1", Name: "read_file", Arguments: `{"path":"README.md"}{"path":"AGENTS.md"}`}, // concatenated -> first
|
||||
{ID: "c2", Name: "read_file", Arguments: `{"path":"x"}xyz`}, // garbage -> {}
|
||||
{ID: "c3", Name: "read_file", Arguments: `{"path":"ok"}`}, // valid -> unchanged
|
||||
})
|
||||
if out[0].Arguments != `{"path":"README.md"}` {
|
||||
t.Fatalf("concatenated args should record the first object, got %q", out[0].Arguments)
|
||||
}
|
||||
if out[1].Arguments != "{}" {
|
||||
t.Fatalf("garbage-trailing args should record {}, got %q", out[1].Arguments)
|
||||
}
|
||||
if out[2].Arguments != `{"path":"ok"}` {
|
||||
t.Fatalf("valid args should be unchanged, got %q", out[2].Arguments)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("whitespace-only errors (caller guards empty)", func(t *testing.T) {
|
||||
var e map[string]any
|
||||
if err := decodeToolArguments(" ", &e); err == nil {
|
||||
t.Fatal("whitespace-only must error")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -129,6 +129,24 @@ func (fakeToolSearchTool) Run(_ context.Context, _ map[string]any) tools.Result
|
||||
return tools.Result{Status: tools.StatusOK, Output: "ok"}
|
||||
}
|
||||
|
||||
func toolDefinitionByName(definitions []zeroruntime.ToolDefinition, name string) *zeroruntime.ToolDefinition {
|
||||
for i := range definitions {
|
||||
if definitions[i].Name == name {
|
||||
return &definitions[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func assertNoDeferredDiscoveryMessage(t *testing.T, request zeroruntime.CompletionRequest) {
|
||||
t.Helper()
|
||||
for _, message := range request.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleUser && strings.Contains(message.Content, "Deferred tools:") {
|
||||
t.Fatalf("deferred discovery must not be appended as a user message: %q", message.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartitionToolsInactiveIsByteIdenticalAndDropsToolSearch(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
@@ -219,8 +237,8 @@ func TestPartitionToolsBelowThresholdInactive(t *testing.T) {
|
||||
// FIX 6(a): a deferred tool removed by DisabledTools must NOT count toward the
|
||||
// eligible total. With N deferred registered and threshold == N, disabling one
|
||||
// drops the surviving eligible count to N-1 < threshold, so the partition takes
|
||||
// the INACTIVE path (empty reminder, all VISIBLE tools exposed with full schemas,
|
||||
// the disabled one filtered out entirely).
|
||||
// the INACTIVE path (empty discovery text, all VISIBLE tools exposed with full
|
||||
// schemas, the disabled one filtered out entirely).
|
||||
func TestPartitionToolsDisabledDeferredDropsBelowThresholdInactive(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
||||
@@ -246,9 +264,9 @@ func TestPartitionToolsDisabledDeferredDropsBelowThresholdInactive(t *testing.T)
|
||||
}
|
||||
|
||||
// FIX 6(b): with deferral ACTIVE, a DisabledTools-hidden deferred tool must never
|
||||
// appear in the reminder NOR be exposed — it is filtered out before the partition
|
||||
// influence discovery NOR be exposed — it is filtered out before the partition
|
||||
// even considers it.
|
||||
func TestPartitionToolsActiveExcludesDisabledDeferredFromReminderAndExposed(t *testing.T) {
|
||||
func TestPartitionToolsActiveExcludesDisabledDeferredFromDiscoveryAndExposed(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
|
||||
@@ -256,25 +274,31 @@ func TestPartitionToolsActiveExcludesDisabledDeferredFromReminderAndExposed(t *t
|
||||
registry.Register(fakeToolSearchTool{}) // usable loader => deferral can activate
|
||||
|
||||
// 3 deferred, disable beta => 2 surviving eligible, threshold 2 => active.
|
||||
exposed, reminder := partitionTools(registry, PermissionModeAuto, Options{
|
||||
exposed, discovery := partitionTools(registry, PermissionModeAuto, Options{
|
||||
DeferThreshold: 2,
|
||||
DisabledTools: []string{"mcp__srv__beta"},
|
||||
}, map[string]bool{})
|
||||
|
||||
if reminder == "" {
|
||||
t.Fatalf("expected active path with a non-empty reminder for the unloaded tools")
|
||||
if discovery == "" {
|
||||
t.Fatalf("expected active path with discovery text for the unloaded tools")
|
||||
}
|
||||
if strings.Contains(reminder, "mcp__srv__beta") {
|
||||
t.Fatalf("disabled deferred tool must not appear in the reminder, got %q", reminder)
|
||||
if strings.Contains(discovery, "mcp__srv__beta") {
|
||||
t.Fatalf("disabled deferred tool must not appear in discovery text, got %q", discovery)
|
||||
}
|
||||
for _, def := range exposed {
|
||||
if def.Name == "mcp__srv__beta" {
|
||||
t.Fatalf("disabled deferred tool must not be exposed, got %#v", exposed)
|
||||
}
|
||||
}
|
||||
// The two surviving deferred tools are hidden (unloaded) and named in the reminder.
|
||||
if !strings.Contains(reminder, "mcp__srv__alpha") || !strings.Contains(reminder, "mcp__srv__gamma") {
|
||||
t.Fatalf("reminder must list the surviving unloaded deferred tools, got %q", reminder)
|
||||
search := toolDefinitionByName(exposed, tools.ToolSearchToolName)
|
||||
if search == nil {
|
||||
t.Fatalf("tool_search must be exposed on active path, got %#v", exposed)
|
||||
}
|
||||
if search.Description != discovery {
|
||||
t.Fatalf("tool_search description must carry discovery text\n got: %q\nwant: %q", search.Description, discovery)
|
||||
}
|
||||
if !strings.Contains(discovery, "mcp") {
|
||||
t.Fatalf("discovery text must list the surviving deferred source, got %q", discovery)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,7 +313,7 @@ func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
|
||||
loaded := map[string]bool{"mcp__srv__alpha": true}
|
||||
|
||||
// 2 eligible deferred tools, threshold 2 => active.
|
||||
exposed, reminder := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 2}, loaded)
|
||||
exposed, discovery := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 2}, loaded)
|
||||
|
||||
exposedNames := map[string]bool{}
|
||||
for _, def := range exposed {
|
||||
@@ -307,14 +331,21 @@ func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
|
||||
if exposedNames["mcp__srv__beta"] {
|
||||
t.Fatalf("unloaded deferred tool must be hidden from exposed, got %#v", exposed)
|
||||
}
|
||||
if reminder == "" {
|
||||
t.Fatalf("expected a non-empty reminder for the hidden tool")
|
||||
if discovery == "" {
|
||||
t.Fatalf("expected non-empty discovery text for the hidden tool")
|
||||
}
|
||||
if !strings.Contains(reminder, "mcp__srv__beta") {
|
||||
t.Fatalf("expected reminder to list the hidden tool, got %q", reminder)
|
||||
search := toolDefinitionByName(exposed, tools.ToolSearchToolName)
|
||||
if search == nil {
|
||||
t.Fatalf("expected tool_search exposed on active path, got %#v", exposed)
|
||||
}
|
||||
if strings.Contains(reminder, "mcp__srv__alpha") {
|
||||
t.Fatalf("loaded tool must not appear in the reminder, got %q", reminder)
|
||||
if search.Description != discovery || !strings.Contains(search.Description, "mcp") {
|
||||
t.Fatalf("tool_search description must carry discovery source, got %q", search.Description)
|
||||
}
|
||||
if strings.Contains(search.Description, "mcp__srv__alpha") {
|
||||
t.Fatalf("tool_search description must not list already-loaded tool names, got %q", search.Description)
|
||||
}
|
||||
if !strings.Contains(search.Description, "mcp__srv__beta") {
|
||||
t.Fatalf("tool_search description must list exact hidden tool names, got %q", search.Description)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,9 +368,9 @@ func TestPartitionToolsActiveNothingHiddenEmptyReminder(t *testing.T) {
|
||||
if !exposedNames["tool_search"] {
|
||||
t.Fatalf("expected tool_search exposed on active path, got %#v", exposed)
|
||||
}
|
||||
// BuildDeferredReminder returns "" for no hidden lines.
|
||||
// No hidden tools means no dynamic discovery text is needed.
|
||||
if reminder != "" {
|
||||
t.Fatalf("expected empty reminder when nothing is hidden, got %q", reminder)
|
||||
t.Fatalf("expected empty discovery text when nothing is hidden, got %q", reminder)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,7 +407,8 @@ func TestRunLoadsDeferredToolThenAdvertisesNextTurn(t *testing.T) {
|
||||
t.Fatalf("expected two turns, got %d", len(provider.requests))
|
||||
}
|
||||
|
||||
// Turn 1: alpha is hidden (not advertised) and the reminder is a trailing user msg.
|
||||
// Turn 1: alpha is hidden (not advertised), and discovery lives on the
|
||||
// tool_search definition rather than a trailing user message.
|
||||
turn1Tools := map[string]bool{}
|
||||
for _, def := range provider.requests[0].Tools {
|
||||
turn1Tools[def.Name] = true
|
||||
@@ -384,9 +416,13 @@ func TestRunLoadsDeferredToolThenAdvertisesNextTurn(t *testing.T) {
|
||||
if turn1Tools["mcp__srv__alpha"] {
|
||||
t.Fatalf("turn 1 must not advertise the not-yet-loaded deferred tool")
|
||||
}
|
||||
last1 := provider.requests[0].Messages[len(provider.requests[0].Messages)-1]
|
||||
if last1.Role != zeroruntime.MessageRoleUser || !strings.Contains(last1.Content, "tool_search") {
|
||||
t.Fatalf("turn 1 request must end with the deferred-tools reminder, got role=%s content=%q", last1.Role, last1.Content)
|
||||
assertNoDeferredDiscoveryMessage(t, provider.requests[0])
|
||||
search1 := toolDefinitionByName(provider.requests[0].Tools, tools.ToolSearchToolName)
|
||||
if search1 == nil {
|
||||
t.Fatalf("turn 1 must expose tool_search, got %#v", provider.requests[0].Tools)
|
||||
}
|
||||
if !strings.Contains(search1.Description, "Tool discovery") || !strings.Contains(search1.Description, "mcp") {
|
||||
t.Fatalf("tool_search description must carry deferred discovery, got %q", search1.Description)
|
||||
}
|
||||
|
||||
// Turn 2: alpha is now loaded and advertised with a full schema.
|
||||
@@ -401,20 +437,20 @@ func TestRunLoadsDeferredToolThenAdvertisesNextTurn(t *testing.T) {
|
||||
t.Fatalf("beta was never loaded; it must stay hidden in turn 2")
|
||||
}
|
||||
|
||||
// The reminder must NOT persist into the returned message history.
|
||||
// The discovery text must NOT persist into the returned message history.
|
||||
for _, m := range result.Messages {
|
||||
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Call tool_search") {
|
||||
t.Fatalf("the deferred-tools reminder must not be persisted in result.Messages")
|
||||
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Tool discovery") {
|
||||
t.Fatalf("deferred discovery must not be persisted in result.Messages")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunReactiveRetryKeepsLoadedDeferredToolAndReminder drives a mid-run
|
||||
// TestRunReactiveRetryKeepsLoadedDeferredToolAndDiscovery drives a mid-run
|
||||
// context-limit error that triggers reactive compaction+retry while deferral is
|
||||
// ACTIVE and a deferred tool is already loaded. The retried turn must NOT be
|
||||
// degraded to the empty-loaded/no-reminder state: it must still advertise the
|
||||
// loaded tool's FULL schema and carry the <system-reminder> for the hidden tool.
|
||||
func TestRunReactiveRetryKeepsLoadedDeferredToolAndReminder(t *testing.T) {
|
||||
// degraded to the empty-loaded/no-discovery state: it must still advertise the
|
||||
// loaded tool's FULL schema and keep tool_search discovery for hidden tools.
|
||||
func TestRunReactiveRetryKeepsLoadedDeferredToolAndDiscovery(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
// load_signal asks the loop to load mcp__srv__alpha next turn.
|
||||
registry.Register(loadSignalTool{value: "mcp__srv__alpha"})
|
||||
@@ -492,24 +528,20 @@ func TestRunReactiveRetryKeepsLoadedDeferredToolAndReminder(t *testing.T) {
|
||||
t.Fatalf("retry must advertise the loaded tool's FULL schema, got %#v", alpha.Parameters)
|
||||
}
|
||||
|
||||
// The retry must carry the deferred-tools reminder as its trailing user
|
||||
// message (the hidden tool is mcp__srv__beta) — not the no-reminder state.
|
||||
last := retry.Messages[len(retry.Messages)-1]
|
||||
if last.Role != zeroruntime.MessageRoleUser || !strings.Contains(last.Content, "tool_search") {
|
||||
t.Fatalf("retry request must end with the deferred-tools reminder, got role=%s content=%q", last.Role, last.Content)
|
||||
assertNoDeferredDiscoveryMessage(t, retry)
|
||||
search := toolDefinitionByName(retry.Tools, tools.ToolSearchToolName)
|
||||
if search == nil {
|
||||
t.Fatalf("retry must expose tool_search for still-hidden tools, got %#v", retry.Tools)
|
||||
}
|
||||
if !strings.Contains(last.Content, "mcp__srv__beta") {
|
||||
t.Fatalf("retry reminder must list the still-hidden tool mcp__srv__beta, got %q", last.Content)
|
||||
}
|
||||
if strings.Contains(last.Content, "mcp__srv__alpha") {
|
||||
t.Fatalf("loaded tool must not appear in the retry reminder, got %q", last.Content)
|
||||
if !strings.Contains(search.Description, "Tool discovery") || !strings.Contains(search.Description, "mcp") {
|
||||
t.Fatalf("retry tool_search description must carry deferred discovery, got %q", search.Description)
|
||||
}
|
||||
|
||||
// The reminder must NOT persist into the returned message history, even on
|
||||
// The discovery text must NOT persist into the returned message history, even on
|
||||
// the reactive-retry path.
|
||||
for _, m := range result.Messages {
|
||||
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Call tool_search") {
|
||||
t.Fatalf("the deferred-tools reminder must not be persisted in result.Messages")
|
||||
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Tool discovery") {
|
||||
t.Fatalf("deferred discovery must not be persisted in result.Messages")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -544,15 +576,15 @@ func (provider *connectErrorProvider) StreamCompletion(_ context.Context, reques
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndReminder mirrors
|
||||
// TestRunReactiveRetryKeepsLoadedDeferredToolAndReminder but drives the
|
||||
// TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndDiscovery mirrors
|
||||
// TestRunReactiveRetryKeepsLoadedDeferredToolAndDiscovery but drives the
|
||||
// CONNECT-TIME error path: StreamCompletion itself returns a non-nil
|
||||
// context-limit error (rather than a mid-stream StreamEventError). With deferral
|
||||
// ACTIVE and a deferred tool already loaded, the rebuilt retry request must still
|
||||
// advertise the loaded tool's FULL schema AND carry the deferred-tools reminder —
|
||||
// i.e. the first reactive block reuses exposed/reminder, not the empty-loaded
|
||||
// advertise the loaded tool's FULL schema AND carry tool_search discovery —
|
||||
// i.e. the first reactive block reuses exposed, not the empty-loaded
|
||||
// partition.
|
||||
func TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndReminder(t *testing.T) {
|
||||
func TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndDiscovery(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(loadSignalTool{value: "mcp__srv__alpha"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha tool"})
|
||||
@@ -625,22 +657,19 @@ func TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndReminder(t *testin
|
||||
t.Fatalf("retry must advertise the loaded tool's FULL schema, got %#v", alpha.Parameters)
|
||||
}
|
||||
|
||||
// The retry must carry the deferred-tools reminder as its trailing user message.
|
||||
last := retry.Messages[len(retry.Messages)-1]
|
||||
if last.Role != zeroruntime.MessageRoleUser || !strings.Contains(last.Content, "tool_search") {
|
||||
t.Fatalf("retry request must end with the deferred-tools reminder, got role=%s content=%q", last.Role, last.Content)
|
||||
assertNoDeferredDiscoveryMessage(t, retry)
|
||||
search := toolDefinitionByName(retry.Tools, tools.ToolSearchToolName)
|
||||
if search == nil {
|
||||
t.Fatalf("retry must expose tool_search for still-hidden tools, got %#v", retry.Tools)
|
||||
}
|
||||
if !strings.Contains(last.Content, "mcp__srv__beta") {
|
||||
t.Fatalf("retry reminder must list the still-hidden tool mcp__srv__beta, got %q", last.Content)
|
||||
}
|
||||
if strings.Contains(last.Content, "mcp__srv__alpha") {
|
||||
t.Fatalf("loaded tool must not appear in the retry reminder, got %q", last.Content)
|
||||
if !strings.Contains(search.Description, "Tool discovery") || !strings.Contains(search.Description, "mcp") {
|
||||
t.Fatalf("retry tool_search description must carry deferred discovery, got %q", search.Description)
|
||||
}
|
||||
|
||||
// The reminder must NOT persist into the returned message history.
|
||||
// The discovery text must NOT persist into the returned message history.
|
||||
for _, m := range result.Messages {
|
||||
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Call tool_search") {
|
||||
t.Fatalf("the deferred-tools reminder must not be persisted in result.Messages")
|
||||
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Tool discovery") {
|
||||
t.Fatalf("deferred discovery must not be persisted in result.Messages")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -649,8 +678,7 @@ func TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndReminder(t *testin
|
||||
// regression: N deferred tools are registered alongside tool_search, the operator
|
||||
// allowlists ONLY the N deferred names (NOT tool_search), and DeferThreshold == N.
|
||||
// Deferral must ACTIVATE, the partition must STILL expose tool_search (the gateway
|
||||
// to the allowlisted tools), and a tool_search call must be DISPATCH-ALLOWED — so
|
||||
// the reminder never points the model at an unreachable tool.
|
||||
// to the allowlisted tools), and a tool_search call must be DISPATCH-ALLOWED.
|
||||
func TestAllowlistedDeferredToolsKeepToolSearchReachable(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
deferredNames := []string{"mcp__srv__alpha", "mcp__srv__beta"}
|
||||
@@ -667,14 +695,14 @@ func TestAllowlistedDeferredToolsKeepToolSearchReachable(t *testing.T) {
|
||||
EnabledTools: append([]string{}, deferredNames...),
|
||||
}
|
||||
|
||||
exposed, reminder := partitionTools(registry, PermissionModeAuto, options, map[string]bool{})
|
||||
exposed, discovery := partitionTools(registry, PermissionModeAuto, options, map[string]bool{})
|
||||
|
||||
// Deferral active => non-empty reminder advertising the hidden deferred tools.
|
||||
if reminder == "" {
|
||||
t.Fatalf("expected deferral active (non-empty reminder) at threshold N, got empty")
|
||||
// Deferral active => non-empty tool_search discovery for hidden deferred tools.
|
||||
if discovery == "" {
|
||||
t.Fatalf("expected deferral active (non-empty discovery) at threshold N, got empty")
|
||||
}
|
||||
if !strings.Contains(reminder, "tool_search") {
|
||||
t.Fatalf("reminder must instruct the model to call tool_search, got %q", reminder)
|
||||
if !strings.Contains(discovery, "tool_search") {
|
||||
t.Fatalf("discovery must instruct the model to call tool_search, got %q", discovery)
|
||||
}
|
||||
|
||||
// FIX 2(a): tool_search is exposed even though the allowlist omits it.
|
||||
@@ -685,6 +713,10 @@ func TestAllowlistedDeferredToolsKeepToolSearchReachable(t *testing.T) {
|
||||
if !exposedNames["tool_search"] {
|
||||
t.Fatalf("tool_search must be exposed on the active path despite the allowlist omitting it, got %#v", exposed)
|
||||
}
|
||||
search := toolDefinitionByName(exposed, tools.ToolSearchToolName)
|
||||
if search == nil || search.Description != discovery {
|
||||
t.Fatalf("tool_search description must carry discovery text, got %#v discovery=%q", search, discovery)
|
||||
}
|
||||
// The allowlisted-but-unloaded deferred tools are hidden (not exposed).
|
||||
for _, name := range deferredNames {
|
||||
if exposedNames[name] {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -44,8 +45,192 @@ const (
|
||||
// with the same error, so NO model (weak or strong) burns turns looping on a
|
||||
// bad call.
|
||||
toolFailureStopAt = 4
|
||||
|
||||
// maxContinueNudges bounds how many times the headless completion gate
|
||||
// (Options.RequireCompletionSignal) re-prompts a model that stopped without a
|
||||
// tool call while work clearly remained. Once spent, the run finalizes as
|
||||
// INCOMPLETE rather than nudging forever (and it is still bounded by maxTurns
|
||||
// and the run deadline).
|
||||
maxContinueNudges = 3
|
||||
)
|
||||
|
||||
// continueNudgeMarker is a stable substring for tests.
|
||||
const continueNudgeMarker = "the task is not finished"
|
||||
|
||||
// continueNudge tells a model that stopped without a tool call — while work
|
||||
// clearly remained — to keep going, or to mark the plan complete and summarize if
|
||||
// it is genuinely done. The second path gives it a clean route to a legitimate
|
||||
// completion (a finished plan + no continuation cue then exits as success).
|
||||
func continueNudge(reason string) string {
|
||||
return "You stopped without calling a tool, but " + continueNudgeMarker + " (" + reason + "). " +
|
||||
"Do not stop here: take the next concrete action with a tool now. " +
|
||||
"If you are genuinely finished, first mark the plan complete with update_plan, then give your final summary."
|
||||
}
|
||||
|
||||
// endsWithContinuationCue reports whether an assistant message ends mid-thought —
|
||||
// the model announcing its OWN next action and then stopping on a colon
|
||||
// ("…Let me check the SSH configuration:") rather than concluding. It requires
|
||||
// BOTH a trailing colon AND an action lead-in on the last line, so genuine closers
|
||||
// are NOT flagged: a forward-looking recommendation ("Next, I suggest reviewing
|
||||
// the changes."), a sign-off ("Let me know if you need anything"), or a summary
|
||||
// that merely ends in a colon ("Here is the summary:"). A bare trailing colon alone
|
||||
// is too common in legitimate final answers to use as a signal.
|
||||
func endsWithContinuationCue(text string) bool {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
lines := strings.Split(trimmed, "\n")
|
||||
last := ""
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
if s := strings.TrimSpace(lines[i]); s != "" {
|
||||
last = strings.ToLower(s)
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.HasSuffix(last, ":") {
|
||||
return false
|
||||
}
|
||||
// Inspect the final clause (after the last sentence break) so a mid-line action
|
||||
// announcement is caught ("…configure the server. Let me check the config:")
|
||||
// while a plain summary colon ("Here is the summary:") or a recommendation is not.
|
||||
clause := last
|
||||
if idx := strings.LastIndex(last, ". "); idx >= 0 {
|
||||
clause = strings.TrimSpace(last[idx+2:])
|
||||
}
|
||||
if strings.HasPrefix(clause, "let me know") { // sign-off, not a mid-step action
|
||||
return false
|
||||
}
|
||||
for _, cue := range []string{
|
||||
"let me ", "let's ", "now i'll ", "now i will ", "now let me ", "let me now ",
|
||||
"i'll now ", "i will now ", "next i'll ", "next, i'll ", "first, i'll ", "first let me ",
|
||||
} {
|
||||
if strings.HasPrefix(clause, cue) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// planStatusRemaining reports whether a raw update_plan status string represents
|
||||
// unfinished work. Anything not clearly completed/failed (incl. empty/unknown,
|
||||
// which the update_plan tool coerces to "pending") counts as remaining, matching
|
||||
// that tool's own status normalization.
|
||||
func planStatusRemaining(status string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "completed", "complete", "done", "finished", "resolved", "✓", "x", "[x]",
|
||||
"failed", "fail", "error", "errored", "blocked", "cancelled", "canceled", "abandoned", "skipped":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// acceptanceNudgeMarker is a stable substring for tests; it is embedded verbatim
|
||||
// in acceptanceVerificationNudge.
|
||||
const acceptanceNudgeMarker = "verify your work against the task's stated success criterion"
|
||||
|
||||
// selfReportPhrases are high-signal admissions of guessing/fabrication or stated
|
||||
// uncertainty about the produced result. They are matched by plain substring with
|
||||
// NO context guard, so every entry must be FIRST-PERSON or unambiguously about the
|
||||
// model's own output — NOT a phrase that also describes implemented behavior.
|
||||
// (Earlier versions included "fall back to" / "placeholder value" / "as a
|
||||
// fallback" / bare "best guess" / "without proper", which match legitimate final
|
||||
// answers like "the parser will fall back to UTF-8" or "I replaced the placeholder
|
||||
// value", wrongly downgrading completed runs — so those are removed. Admissions of
|
||||
// INABILITY are handled separately by inabilityStems, which carry a guard.)
|
||||
var selfReportPhrases = []string{
|
||||
// first-person admissions of guessing / fabrication / assumption
|
||||
"i guessed", "my best guess", "i'm guessing", "this is a guess", "just a guess",
|
||||
"i made it up", "i fabricated", "i assumed a", "i had to assume",
|
||||
// first-person lack of capability (inability stems also cover cannot/could-not)
|
||||
"i do not have the ability", "i don't have the ability", "i lack the ability",
|
||||
"no way for me to", "not possible for me to",
|
||||
// stated uncertainty about the correctness of the produced result
|
||||
"may not be correct", "might not be correct", "may be incorrect", "might be incorrect",
|
||||
"this may not work", "this might not work", "not fully functional", "not fully working",
|
||||
}
|
||||
|
||||
// inabilityStems are first-person "I cannot / can't / could not / am unable to /
|
||||
// do not have" stems. Matching the STEM (not a fixed verb) generalizes over
|
||||
// whatever action the model claims it could not perform — "analyze", "determine",
|
||||
// "do", "complete", "verify", "see", etc. — so the detector is not defeated by
|
||||
// re-phrasing (the chess case slipped a fixed "cannot analyze" list by writing
|
||||
// "…which I cannot do without proper image analysis capabilities").
|
||||
var inabilityStems = []string{
|
||||
"i cannot ", "i can't ", "i can not ", "i could not ", "i couldn't ",
|
||||
"i am unable to", "i'm unable to", "i was unable to", "i wasn't able to",
|
||||
"i was not able to", "i do not have", "i don't have", "unable to ",
|
||||
"without being able to",
|
||||
}
|
||||
|
||||
// successNegationTails are negated phrasings that indicate SUCCESS, not an
|
||||
// admission ("I could not find any remaining issues", "I cannot reproduce the
|
||||
// bug"). When an inability stem is immediately followed by one of these, it is not
|
||||
// treated as an admission, so a clean result is not misreported as INCOMPLETE.
|
||||
var successNegationTails = []string{
|
||||
"find any", "found any", "find a ", "see any", "detect any", "identify any",
|
||||
"reproduce", "spot any", "locate any",
|
||||
}
|
||||
|
||||
// selfReportedIncompletion returns a short reason when the model's final text
|
||||
// admits it guessed or could not meet the objective, else "". Case-insensitive.
|
||||
func selfReportedIncompletion(text string) string {
|
||||
lower := strings.ToLower(text)
|
||||
for _, phrase := range selfReportPhrases {
|
||||
if strings.Contains(lower, phrase) {
|
||||
return selfReportReason(phrase)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
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 selfReportReason(marker string) string {
|
||||
return `the final message admits the objective was not met ("` + marker + `")`
|
||||
}
|
||||
|
||||
func hasAnyPrefix(s string, prefixes []string) bool {
|
||||
for _, p := range prefixes {
|
||||
if strings.HasPrefix(s, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// acceptanceVerificationNudge forces a TASK-GROUNDED acceptance check before a run
|
||||
// may finalize as success. It explicitly rejects the three false-success patterns
|
||||
// the bug-hunt surfaced: well-formed output treated as correct; pre-existing tests
|
||||
// passing treated as the objective being met; and a result that merely matches a
|
||||
// baseline the task asked to beat or improve. General — no task-specific content.
|
||||
func acceptanceVerificationNudge() string {
|
||||
return "Before this task can be marked complete, " + acceptanceNudgeMarker + " — " +
|
||||
"NOT the shape or format of your output, NOT that pre-existing tests pass, and NOT that your " +
|
||||
"result merely matches a baseline you were asked to beat or improve. " +
|
||||
"Re-read the original task, then run a concrete check that exercises the actual requirement: " +
|
||||
"execute the program or tests that demonstrate the required behavior, or directly probe the specific " +
|
||||
"thing the task asked you to produce, recover, fix, or optimize. " +
|
||||
"If that check passes, reply PASS and cite the evidence. " +
|
||||
"If it does not pass — or you cannot run such a check — say so plainly and keep working; do not claim success."
|
||||
}
|
||||
|
||||
// toolFailureHintMarker is a stable substring for tests.
|
||||
const toolFailureHintMarker = "kept failing with the same error"
|
||||
|
||||
@@ -177,6 +362,10 @@ type guardState struct {
|
||||
staleReminderSent bool
|
||||
toolOnlyTurns int
|
||||
toolOnlyReminderSent bool
|
||||
// planItemsPending is the number of remaining (pending/in_progress) items in
|
||||
// the most recent update_plan call, so the headless completion gate can tell
|
||||
// whether work is unfinished when the model stops without a tool call.
|
||||
planItemsPending int
|
||||
// toolFailures tracks consecutive same-error failures per tool, keyed by tool
|
||||
// name, so the loop can hint then halt instead of looping forever.
|
||||
toolFailures map[string]*toolFailureRecord
|
||||
@@ -245,6 +434,9 @@ func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (sto
|
||||
state.toolCallsSincePlanUpdate = 0
|
||||
// A fresh plan update opens a new stale interval.
|
||||
state.staleReminderSent = false
|
||||
// Record how many items remain so the completion gate knows whether
|
||||
// work is unfinished if the model later stops without a tool call.
|
||||
state.observePlanUpdate(call.Arguments)
|
||||
} else {
|
||||
state.toolCallsSincePlanUpdate++
|
||||
}
|
||||
@@ -253,6 +445,33 @@ func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (sto
|
||||
return state.emptyTurns >= maxEmptyTurns
|
||||
}
|
||||
|
||||
// pendingPlanItems reports whether the most recent update_plan call still has
|
||||
// unfinished (pending/in_progress) items. False when no plan was ever recorded.
|
||||
func (state *guardState) pendingPlanItems() bool {
|
||||
return state.planItemsPending > 0
|
||||
}
|
||||
|
||||
// observePlanUpdate parses an update_plan call's raw arguments and records how
|
||||
// many items are still remaining. Malformed arguments leave the prior count
|
||||
// unchanged (best-effort — the plan panel itself tolerates the same).
|
||||
func (state *guardState) observePlanUpdate(arguments string) {
|
||||
var parsed struct {
|
||||
Plan []struct {
|
||||
Status string `json:"status"`
|
||||
} `json:"plan"`
|
||||
}
|
||||
if json.Unmarshal([]byte(arguments), &parsed) != nil {
|
||||
return
|
||||
}
|
||||
pending := 0
|
||||
for _, item := range parsed.Plan {
|
||||
if planStatusRemaining(item.Status) {
|
||||
pending++
|
||||
}
|
||||
}
|
||||
state.planItemsPending = pending
|
||||
}
|
||||
|
||||
func (state *guardState) progressReminder() string {
|
||||
if state.toolOnlyReminderSent || state.toolOnlyTurns < toolOnlyProgressReminderAt {
|
||||
return ""
|
||||
|
||||
+697
-93
File diff suppressed because it is too large.
Load diff
+681
-10
@@ -37,6 +37,365 @@ func (provider *mockProvider) StreamCompletion(ctx context.Context, request zero
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
type sandboxDeniedRetryTool struct {
|
||||
calls []map[string]any
|
||||
}
|
||||
|
||||
func (tool *sandboxDeniedRetryTool) Name() string { return "bash" }
|
||||
func (tool *sandboxDeniedRetryTool) Description() string { return "test shell tool" }
|
||||
func (tool *sandboxDeniedRetryTool) Parameters() tools.Schema {
|
||||
return tools.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]tools.PropertySchema{
|
||||
"command": {Type: "string"},
|
||||
"sandbox_permissions": {Type: "string"},
|
||||
"justification": {Type: "string"},
|
||||
},
|
||||
Required: []string{"command"},
|
||||
AdditionalProperties: false,
|
||||
}
|
||||
}
|
||||
func (tool *sandboxDeniedRetryTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs shell commands"}
|
||||
}
|
||||
func (tool *sandboxDeniedRetryTool) Run(_ context.Context, args map[string]any) tools.Result {
|
||||
tool.calls = append(tool.calls, cloneArgs(args))
|
||||
if args["sandbox_permissions"] == string(tools.SandboxPermissionsRequireEscalated) {
|
||||
return tools.Result{Status: tools.StatusOK, Output: "installed"}
|
||||
}
|
||||
return tools.Result{
|
||||
Status: tools.StatusError,
|
||||
Output: "touch: cannot touch '/home/user/.npm/cache': Read-only file system",
|
||||
Meta: map[string]string{
|
||||
"exit_code": "1",
|
||||
tools.SandboxLikelyDeniedMeta: "true",
|
||||
tools.SandboxDenialReasonMeta: "sandbox blocked command execution",
|
||||
tools.SandboxDenialKeywordMeta: "read-only file system",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type sandboxDeniedExecCommandRetryTool struct {
|
||||
sandboxDeniedRetryTool
|
||||
}
|
||||
|
||||
func (tool *sandboxDeniedExecCommandRetryTool) Name() string { return "exec_command" }
|
||||
|
||||
type sandboxNetworkDeniedRetryTool struct {
|
||||
calls []map[string]any
|
||||
}
|
||||
|
||||
func (tool *sandboxNetworkDeniedRetryTool) Name() string { return "bash" }
|
||||
func (tool *sandboxNetworkDeniedRetryTool) Description() string { return "test shell tool" }
|
||||
func (tool *sandboxNetworkDeniedRetryTool) Parameters() tools.Schema {
|
||||
return tools.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]tools.PropertySchema{
|
||||
"command": {Type: "string"},
|
||||
},
|
||||
Required: []string{"command"},
|
||||
AdditionalProperties: false,
|
||||
}
|
||||
}
|
||||
func (tool *sandboxNetworkDeniedRetryTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs shell commands"}
|
||||
}
|
||||
func (tool *sandboxNetworkDeniedRetryTool) Run(ctx context.Context, args map[string]any) tools.Result {
|
||||
return tool.RunWithOptions(ctx, args, tools.RunOptions{})
|
||||
}
|
||||
func (tool *sandboxNetworkDeniedRetryTool) RunWithOptions(ctx context.Context, args map[string]any, options tools.RunOptions) tools.Result {
|
||||
tool.calls = append(tool.calls, cloneArgs(args))
|
||||
if shellNetworkAllowed(ctx, options.Sandbox, args) {
|
||||
return tools.Result{Status: tools.StatusOK, Output: "server started"}
|
||||
}
|
||||
return tools.Result{
|
||||
Status: tools.StatusOK,
|
||||
Output: "Cannot open a network socket.",
|
||||
Meta: map[string]string{
|
||||
"exit_code": "0",
|
||||
tools.SandboxLikelyDeniedMeta: "true",
|
||||
tools.SandboxDenialKindMeta: tools.SandboxDenialKindNetwork,
|
||||
tools.SandboxDenialReasonMeta: "sandbox blocked command execution",
|
||||
tools.SandboxDenialKeywordMeta: "cannot open a network socket",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func shellNetworkAllowed(ctx context.Context, engine *sandbox.Engine, args map[string]any) bool {
|
||||
if engine == nil {
|
||||
return false
|
||||
}
|
||||
decision := engine.Evaluate(ctx, sandbox.Request{
|
||||
ToolName: "bash",
|
||||
SideEffect: sandbox.SideEffectShell,
|
||||
Permission: sandbox.PermissionPrompt,
|
||||
PermissionGranted: true,
|
||||
PermissionMode: sandbox.PermissionModeAsk,
|
||||
Args: map[string]any{"command": "curl https://example.com"},
|
||||
})
|
||||
return decision.Action == sandbox.ActionAllow
|
||||
}
|
||||
|
||||
func agentNativeBackendStub() sandbox.Backend {
|
||||
return sandbox.Backend{
|
||||
Name: sandbox.BackendLinuxBwrap,
|
||||
Available: true,
|
||||
Executable: "/nonexistent/zero-linux-sandbox-stub",
|
||||
CommandWrapping: true,
|
||||
NativeIsolation: true,
|
||||
}
|
||||
}
|
||||
|
||||
type sandboxNamespaceLimitedRetryTool struct {
|
||||
calls []map[string]any
|
||||
}
|
||||
|
||||
func (tool *sandboxNamespaceLimitedRetryTool) Name() string { return "bash" }
|
||||
func (tool *sandboxNamespaceLimitedRetryTool) Description() string { return "test shell tool" }
|
||||
func (tool *sandboxNamespaceLimitedRetryTool) Parameters() tools.Schema {
|
||||
return tools.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]tools.PropertySchema{
|
||||
"command": {Type: "string"},
|
||||
"sandbox_permissions": {Type: "string"},
|
||||
},
|
||||
Required: []string{"command"},
|
||||
AdditionalProperties: false,
|
||||
}
|
||||
}
|
||||
func (tool *sandboxNamespaceLimitedRetryTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs shell commands"}
|
||||
}
|
||||
func (tool *sandboxNamespaceLimitedRetryTool) Run(_ context.Context, args map[string]any) tools.Result {
|
||||
tool.calls = append(tool.calls, cloneArgs(args))
|
||||
if args["sandbox_permissions"] == string(tools.SandboxPermissionsRequireEscalated) {
|
||||
return tools.Result{Status: tools.StatusOK, Output: "stdout:\nUSER PID COMMAND\nanaxy 42 firefox\nanaxy 43 Discord"}
|
||||
}
|
||||
return tools.Result{
|
||||
Status: tools.StatusOK,
|
||||
Output: "stdout:\nUSER PID COMMAND\nanaxy 1 bwrap --new-session --die-with-parent --unshare-user --unshare-pid --unshare-net --proc /proc -- /bin/sh -c ps aux\nanaxy 2 ps aux",
|
||||
Meta: map[string]string{
|
||||
"sandbox_backend": string(sandbox.BackendLinuxBwrap),
|
||||
"sandbox_target_backend": string(sandbox.BackendLinuxBwrap),
|
||||
"sandbox_wrapped": "true",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRetriesShellUnsandboxedAfterSandboxNamespaceLimitedOutput(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
retryTool := &sandboxNamespaceLimitedRetryTool{}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(retryTool)
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"ps aux"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var requests []PermissionRequest
|
||||
var events []PermissionEvent
|
||||
|
||||
result, err := Run(context.Background(), "check running applications", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: "medium",
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Backend: agentNativeBackendStub(),
|
||||
}),
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
requests = append(requests, request)
|
||||
return PermissionDecision{Action: PermissionDecisionAllowPrefix, Reason: "inspect host processes"}, nil
|
||||
},
|
||||
OnPermission: func(event PermissionEvent) {
|
||||
events = append(events, event)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q", result.FinalAnswer)
|
||||
}
|
||||
if len(retryTool.calls) != 2 {
|
||||
t.Fatalf("tool calls = %#v, want sandboxed attempt plus unsandboxed retry", retryTool.calls)
|
||||
}
|
||||
if retryTool.calls[1]["sandbox_permissions"] != string(tools.SandboxPermissionsRequireEscalated) {
|
||||
t.Fatalf("retry args = %#v, want require_escalated", retryTool.calls[1])
|
||||
}
|
||||
if len(requests) != 1 {
|
||||
t.Fatalf("permission requests = %#v, want one unsandboxed retry approval", requests)
|
||||
}
|
||||
if !strings.Contains(requests[0].Reason, "sandbox PID namespace") {
|
||||
t.Fatalf("permission reason = %q, want sandbox namespace reason", requests[0].Reason)
|
||||
}
|
||||
if !equalStringSlices(requests[0].CommandPrefix, []string{"ps", "aux"}) {
|
||||
t.Fatalf("request command prefix = %#v, want ps aux", requests[0].CommandPrefix)
|
||||
}
|
||||
if len(events) != 1 || events[0].Action != PermissionActionAllow || events[0].DecisionAction != PermissionDecisionAllowPrefix {
|
||||
t.Fatalf("permission events = %#v, want approved unsandboxed retry", events)
|
||||
}
|
||||
if len(provider.requests) < 2 {
|
||||
t.Fatalf("provider requests = %d, want retry result sent back to model", len(provider.requests))
|
||||
}
|
||||
last := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
|
||||
if !strings.Contains(last.Content, "firefox") || strings.Contains(last.Content, "bwrap --new-session") {
|
||||
t.Fatalf("tool result sent to model = %q, want unsandboxed retry output only", last.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUsesEscalatedJustificationForPermissionPrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(&sandboxDeniedRetryTool{})
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"ps aux","sandbox_permissions":"require_escalated","justification":"Need host process visibility."}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var requestReasons []string
|
||||
|
||||
_, err := Run(context.Background(), "check host processes", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Backend: agentNativeBackendStub(),
|
||||
}),
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
requestReasons = append(requestReasons, request.Reason)
|
||||
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "not needed"}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(requestReasons) != 1 {
|
||||
t.Fatalf("permission reasons = %#v, want one request", requestReasons)
|
||||
}
|
||||
if requestReasons[0] != "Need host process visibility." {
|
||||
t.Fatalf("permission reason = %q, want model justification", requestReasons[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUsesUserFacingEscalatedFallbackForPermissionPrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(&sandboxDeniedExecCommandRetryTool{})
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "exec_command"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"ps aux","sandbox_permissions":"require_escalated"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var requestReasons []string
|
||||
|
||||
_, err := Run(context.Background(), "check host processes", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Backend: agentNativeBackendStub(),
|
||||
}),
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
requestReasons = append(requestReasons, request.Reason)
|
||||
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "not needed"}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(requestReasons) != 1 {
|
||||
t.Fatalf("permission reasons = %#v, want one request", requestReasons)
|
||||
}
|
||||
if requestReasons[0] != "This command needs to run outside the sandbox." {
|
||||
t.Fatalf("permission reason = %q, want user-facing fallback", requestReasons[0])
|
||||
}
|
||||
if strings.Contains(requestReasons[0], sandbox.ReasonEscalatedSandboxRequired) {
|
||||
t.Fatalf("permission reason leaked internal sandbox copy: %q", requestReasons[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRetriesNetworkDeniedShellWithNetworkGrant(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
retryTool := &sandboxNetworkDeniedRetryTool{}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(retryTool)
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"node server.js"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var requests []PermissionRequest
|
||||
|
||||
result, err := Run(context.Background(), "start server", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: "medium",
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Backend: agentNativeBackendStub(),
|
||||
}),
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
requests = append(requests, request)
|
||||
return PermissionDecision{Action: PermissionDecisionAllow, Reason: "approve network"}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q", result.FinalAnswer)
|
||||
}
|
||||
if len(retryTool.calls) != 2 {
|
||||
t.Fatalf("tool calls = %#v, want denied attempt plus network-granted retry", retryTool.calls)
|
||||
}
|
||||
if _, escalated := retryTool.calls[1]["sandbox_permissions"]; escalated {
|
||||
t.Fatalf("network retry must stay sandboxed, got retry args %#v", retryTool.calls[1])
|
||||
}
|
||||
if len(requests) != 1 || requests[0].Reason != sandbox.ReasonNetworkBlocked {
|
||||
t.Fatalf("permission requests = %#v, want one network approval", requests)
|
||||
}
|
||||
if len(provider.requests) < 2 {
|
||||
t.Fatalf("provider requests = %d, want retry result sent back to model", len(provider.requests))
|
||||
}
|
||||
last := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
|
||||
if !strings.Contains(last.Content, "server started") {
|
||||
t.Fatalf("tool result sent to model = %q, want network retry output", last.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRetriableToolError(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -64,6 +423,145 @@ func TestIsRetriableToolError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRetriesShellUnsandboxedAfterSandboxDeniedExit(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
retryTool := &sandboxDeniedRetryTool{}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(retryTool)
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"npm install --save http-server"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var requests []PermissionRequest
|
||||
var events []PermissionEvent
|
||||
|
||||
result, err := Run(context.Background(), "install package", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: "medium",
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Backend: agentNativeBackendStub(),
|
||||
}),
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
requests = append(requests, request)
|
||||
return PermissionDecision{Action: PermissionDecisionAllow, Reason: "retry outside sandbox"}, nil
|
||||
},
|
||||
OnPermission: func(event PermissionEvent) {
|
||||
events = append(events, event)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q", result.FinalAnswer)
|
||||
}
|
||||
if len(retryTool.calls) != 2 {
|
||||
t.Fatalf("tool calls = %#v, want sandboxed attempt plus unsandboxed retry", retryTool.calls)
|
||||
}
|
||||
if retryTool.calls[1]["sandbox_permissions"] != string(tools.SandboxPermissionsRequireEscalated) {
|
||||
t.Fatalf("retry args = %#v, want require_escalated", retryTool.calls[1])
|
||||
}
|
||||
if len(requests) != 2 {
|
||||
t.Fatalf("permission requests = %#v, want network approval plus retry approval", requests)
|
||||
}
|
||||
if requests[0].Reason != sandbox.ReasonNetworkBlocked {
|
||||
t.Fatalf("first request reason = %q, want network approval", requests[0].Reason)
|
||||
}
|
||||
if !strings.Contains(requests[1].Reason, "sandbox blocked command execution") || !strings.Contains(requests[1].Reason, "read-only file system") {
|
||||
t.Fatalf("retry request reason = %q", requests[1].Reason)
|
||||
}
|
||||
if len(events) != 1 || events[0].Action != PermissionActionAllow || events[0].DecisionAction != PermissionDecisionAllow {
|
||||
t.Fatalf("permission events = %#v, want approved unsandboxed retry", events)
|
||||
}
|
||||
if len(provider.requests) < 2 {
|
||||
t.Fatalf("provider requests = %d, want retry result sent back to model", len(provider.requests))
|
||||
}
|
||||
last := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
|
||||
if !strings.Contains(last.Content, "installed") {
|
||||
t.Fatalf("tool result sent to model = %q, want retry output", last.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotRetryUnsandboxedWhenDeniedReadsActive(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
retryTool := &sandboxDeniedRetryTool{}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(retryTool)
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"touch cache-file"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var requests []PermissionRequest
|
||||
var events []PermissionEvent
|
||||
policy := sandbox.DefaultPolicy()
|
||||
policy.DenyRead = []string{filepath.Join(root, "secret.txt")}
|
||||
|
||||
result, err := Run(context.Background(), "touch cache", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: "medium",
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: policy,
|
||||
Backend: agentNativeBackendStub(),
|
||||
}),
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
requests = append(requests, request)
|
||||
return PermissionDecision{Action: PermissionDecisionAllow, Reason: "unexpected"}, nil
|
||||
},
|
||||
OnPermission: func(event PermissionEvent) {
|
||||
events = append(events, event)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q", result.FinalAnswer)
|
||||
}
|
||||
if len(retryTool.calls) != 1 {
|
||||
t.Fatalf("tool calls = %#v, want only the original sandboxed attempt", retryTool.calls)
|
||||
}
|
||||
if len(requests) != 0 {
|
||||
t.Fatalf("permission requests = %#v, want no unsandboxed retry request", requests)
|
||||
}
|
||||
for _, event := range events {
|
||||
if strings.Contains(event.Reason, "sandbox blocked command execution") {
|
||||
t.Fatalf("permission events = %#v, want no unsandboxed retry event", events)
|
||||
}
|
||||
}
|
||||
if len(provider.requests) < 2 {
|
||||
t.Fatalf("provider requests = %d, want tool result sent back to model", len(provider.requests))
|
||||
}
|
||||
last := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
|
||||
if !strings.Contains(last.Content, "Read-only file system") {
|
||||
t.Fatalf("tool result sent to model = %q, want original sandbox denial", last.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReturnsProviderText(t *testing.T) {
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{{
|
||||
@@ -555,7 +1053,10 @@ func TestRunSanitizesMalformedToolCallArgumentsBeforeRetry(t *testing.T) {
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"README.md"}{"path":"AGENTS.md"}`},
|
||||
// Genuinely malformed (truncated) args: still error -> sanitize -> retry.
|
||||
// (Concatenated multi-object args are now tolerated; see
|
||||
// TestRunRecoversFirstObjectFromConcatenatedToolArgs.)
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"README.md"`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
@@ -605,6 +1106,53 @@ func TestRunSanitizesMalformedToolCallArgumentsBeforeRetry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A weak model (e.g. minimax-m3) that packs MULTIPLE concatenated JSON objects into
|
||||
// one tool-call slot must RECOVER the first object and run the call, not fail with
|
||||
// "invalid character '{' after top-level value". The trailing object(s) are dropped
|
||||
// (the model re-issues them on a later turn if still needed).
|
||||
func TestRunRecoversFirstObjectFromConcatenatedToolArgs(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("hello readme"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"README.md"}{"path":"AGENTS.md"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "read", provider, Options{Registry: registry, MaxTurns: 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want done", result.FinalAnswer)
|
||||
}
|
||||
var toolResult string
|
||||
for _, message := range provider.requests[1].Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
toolResult = message.Content
|
||||
}
|
||||
}
|
||||
if strings.Contains(toolResult, "Failed to parse arguments") {
|
||||
t.Fatalf("concatenated args should recover the first object, not fail to parse: %q", toolResult)
|
||||
}
|
||||
if !strings.Contains(toolResult, "hello readme") {
|
||||
t.Fatalf("expected README.md contents (the first object's path), got %q", toolResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDefersSelfCorrectFeedbackUntilAfterToolBatch(t *testing.T) {
|
||||
// A single assistant turn with two tool calls where the first mutates and
|
||||
// self-correct fails must keep both tool_results contiguous, with the feedback
|
||||
@@ -1025,7 +1573,7 @@ func TestAvailablePermissionDecisionsSplitDenyAndCancel(t *testing.T) {
|
||||
got := availablePermissionDecisions(PermissionEvent{
|
||||
ToolName: tc.tool,
|
||||
Action: PermissionActionPrompt,
|
||||
}, options)
|
||||
}, nil, options)
|
||||
if !equalPermissionDecisions(got, tc.want) {
|
||||
t.Fatalf("decisions = %#v, want %#v", got, tc.want)
|
||||
}
|
||||
@@ -1260,6 +1808,129 @@ func TestRunCommandPrefixApprovalSkipsLaterMatchingBashPrompt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCommandPrefixApprovalBypassesSandboxForMatchingShellCalls(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
retryTool := &sandboxDeniedRetryTool{}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(retryTool)
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"echo prefix-ok"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"command":"echo prefix-ok again"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var requests []PermissionRequest
|
||||
|
||||
result, err := Run(context.Background(), "run twice", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: "medium",
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Message: "native sandbox unavailable"},
|
||||
}),
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
requests = append(requests, request)
|
||||
return PermissionDecision{Action: PermissionDecisionAllowPrefix, Reason: "trust this command prefix"}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected final answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
if len(requests) != 1 {
|
||||
t.Fatalf("expected only first command to request prefix approval, got %#v", requests)
|
||||
}
|
||||
if len(retryTool.calls) != 2 {
|
||||
t.Fatalf("tool calls = %#v, want two first-attempt executions", retryTool.calls)
|
||||
}
|
||||
for index, call := range retryTool.calls {
|
||||
if call["sandbox_permissions"] != string(tools.SandboxPermissionsRequireEscalated) {
|
||||
t.Fatalf("call %d args = %#v, want prefix-approved require_escalated execution", index, call)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCommandPrefixApprovalCoversSegmentedShellWithSafeTail(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
retryTool := &sandboxDeniedRetryTool{}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(retryTool)
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"ps aux"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"command":"ps aux | head -5"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var requests []PermissionRequest
|
||||
|
||||
result, err := Run(context.Background(), "inspect processes", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
Autonomy: "medium",
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Backend: sandbox.Backend{Name: sandbox.BackendUnavailable, Message: "native sandbox unavailable"},
|
||||
}),
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
requests = append(requests, request)
|
||||
if !equalStringSlices(request.CommandPrefix, []string{"ps", "aux"}) {
|
||||
t.Fatalf("request command prefix = %#v, want ps aux", request.CommandPrefix)
|
||||
}
|
||||
return PermissionDecision{Action: PermissionDecisionAllowPrefix, Reason: "trust this command prefix"}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected final answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
if len(requests) != 1 {
|
||||
t.Fatalf("expected only first command to request approval, got %#v", requests)
|
||||
}
|
||||
if len(retryTool.calls) != 2 {
|
||||
t.Fatalf("tool calls = %#v, want two first-attempt executions", retryTool.calls)
|
||||
}
|
||||
for index, call := range retryTool.calls {
|
||||
if call["sandbox_permissions"] != string(tools.SandboxPermissionsRequireEscalated) {
|
||||
t.Fatalf("call %d args = %#v, want require_escalated execution", index, call)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPersistentCommandPrefixApprovalSkipsFutureSessionPrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
|
||||
@@ -1494,8 +2165,8 @@ func TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant(t *testing.T) {
|
||||
t.Fatalf("expected tool result to be sent back to provider, got %d requests", len(provider.requests))
|
||||
}
|
||||
lastMessage := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
|
||||
if !strings.Contains(lastMessage.Content, "native sandbox backend is unavailable") {
|
||||
t.Fatalf("expected native sandbox unavailable error after approval, got %q", lastMessage.Content)
|
||||
if !strings.Contains(lastMessage.Content, "fake curl https://example.com") {
|
||||
t.Fatalf("expected approved network command output after degraded execution, got %q", lastMessage.Content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1507,7 +2178,7 @@ func TestRunDoesNotOfferPrefixApprovalForUnsafeBashCommand(t *testing.T) {
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"echo hi && echo bye","prefix_rule":["echo"]}`},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"echo hi && npm install","prefix_rule":["echo"]}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
@@ -1603,8 +2274,8 @@ func TestRunPromptsForDestructiveShellInsteadOfSandboxDeny(t *testing.T) {
|
||||
t.Fatalf("expected tool result to be sent back to provider, got %d requests", len(provider.requests))
|
||||
}
|
||||
lastMessage := provider.requests[1].Messages[len(provider.requests[1].Messages)-1]
|
||||
if !strings.Contains(lastMessage.Content, "native sandbox backend is unavailable") {
|
||||
t.Fatalf("expected native sandbox unavailable error after approval, got %q", lastMessage.Content)
|
||||
if !strings.Contains(lastMessage.Content, "rm -rf /") {
|
||||
t.Fatalf("expected approved shell command output after degraded execution, got %q", lastMessage.Content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1775,7 +2446,7 @@ func TestRunEmitsPermissionEventForPersistentSandboxGrant(t *testing.T) {
|
||||
|
||||
func TestRunPromptsAndAllowsOutsideWorkspaceWrite(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(t.TempDir(), "escape.txt")
|
||||
outside := filepath.Join(tempDirOutsideDefaultTemp(t), "escape.txt")
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
@@ -1837,7 +2508,7 @@ func TestRunPromptsAndAllowsOutsideWorkspaceWrite(t *testing.T) {
|
||||
|
||||
func TestRunSessionAllowsLaterOutsideWorkspaceWrite(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(t.TempDir(), "escape.txt")
|
||||
outside := filepath.Join(tempDirOutsideDefaultTemp(t), "escape.txt")
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: root,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
@@ -1907,7 +2578,7 @@ func TestRunSessionAllowsLaterOutsideWorkspaceWrite(t *testing.T) {
|
||||
|
||||
func TestRunAppliesSandboxEvenInUnsafeMode(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(t.TempDir(), "escape.txt")
|
||||
outside := filepath.Join(tempDirOutsideDefaultTemp(t), "escape.txt")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
provider := providerCallingWritePathThenAnswer(outside, "sandbox handled")
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
type safetyTool struct {
|
||||
name string
|
||||
safety tools.Safety
|
||||
}
|
||||
|
||||
func (t safetyTool) Name() string { return t.name }
|
||||
func (t safetyTool) Description() string { return "test tool" }
|
||||
func (t safetyTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", AdditionalProperties: false}
|
||||
}
|
||||
func (t safetyTool) Safety() tools.Safety { return t.safety }
|
||||
func (t safetyTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK}
|
||||
}
|
||||
|
||||
// member-auto advertises the in-workspace mutators a headless member needs to
|
||||
// build (write/edit + shell) that plain Auto hides, but NOT network or denied
|
||||
// tools — the sandbox still gates the advertised ones at call time.
|
||||
func TestToolAdvertisedMemberAuto(t *testing.T) {
|
||||
write := safetyTool{name: "write_file", safety: tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionPrompt}}
|
||||
shell := safetyTool{name: "bash", safety: tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt}}
|
||||
read := safetyTool{name: "read_file", safety: tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}}
|
||||
network := safetyTool{name: "net_tool", safety: tools.Safety{SideEffect: tools.SideEffectNetwork, Permission: tools.PermissionPrompt}}
|
||||
denied := safetyTool{name: "blocked", safety: tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionDeny}}
|
||||
|
||||
// Plain Auto hides prompt-requiring mutators (the read-only member problem).
|
||||
if ToolAdvertised(write, PermissionModeAuto) || ToolAdvertised(shell, PermissionModeAuto) {
|
||||
t.Fatal("Auto must NOT advertise write/shell prompt tools")
|
||||
}
|
||||
|
||||
for _, tool := range []tools.Tool{write, shell, read} {
|
||||
if !ToolAdvertised(tool, PermissionModeMemberAuto) {
|
||||
t.Fatalf("member-auto must advertise %q", tool.Name())
|
||||
}
|
||||
}
|
||||
if ToolAdvertised(network, PermissionModeMemberAuto) {
|
||||
t.Fatal("member-auto must NOT advertise a network prompt tool")
|
||||
}
|
||||
if ToolAdvertised(denied, PermissionModeMemberAuto) {
|
||||
t.Fatal("member-auto must NOT advertise a denied tool")
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
|
||||
func TestRequestPermissionsTurnGrantAllowsLaterToolAndCleansUp(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
outside := tempDirOutsideDefaultTemp(t)
|
||||
target := filepath.Join(outside, "vegetables.txt")
|
||||
scope, err := sandbox.NewScope(workspace, nil)
|
||||
if err != nil {
|
||||
@@ -120,6 +120,71 @@ func TestRequestPermissionsTurnGrantAllowsLaterToolAndCleansUp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func tempDirOutsideDefaultTemp(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir, err := os.MkdirTemp(".", ".zero-sandbox-outside-")
|
||||
if err != nil {
|
||||
t.Fatalf("MkdirTemp outside default temp: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(%q): %v", dir, err)
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
|
||||
return resolved
|
||||
}
|
||||
return filepath.Clean(abs)
|
||||
}
|
||||
|
||||
func TestInlineAdditionalPermissionsGrantAllowsNetworkCommand(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: workspace,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
})
|
||||
args := map[string]any{
|
||||
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
|
||||
"additional_permissions": map[string]any{
|
||||
"network": map[string]any{"enabled": true},
|
||||
},
|
||||
}
|
||||
cleanup, err := grantInlineAdditionalPermissions(args, sandbox.PermissionGrantScopeTurn, Options{
|
||||
Sandbox: engine,
|
||||
Cwd: workspace,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("grantInlineAdditionalPermissions: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
decision := engine.Evaluate(context.Background(), sandbox.Request{
|
||||
WorkspaceRoot: workspace,
|
||||
ToolName: "exec_command",
|
||||
SideEffect: sandbox.SideEffectShell,
|
||||
Permission: sandbox.PermissionPrompt,
|
||||
PermissionGranted: true,
|
||||
PermissionMode: sandbox.PermissionModeAsk,
|
||||
Args: map[string]any{"cmd": "python3 -m http.server 8080 --bind 127.0.0.1"},
|
||||
})
|
||||
if decision.Action != sandbox.ActionAllow {
|
||||
t.Fatalf("inline network grant should allow shell network command, got %#v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInlineAdditionalPermissionsRequiresPromptUnderAutoAllow(t *testing.T) {
|
||||
args := map[string]any{
|
||||
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
|
||||
"additional_permissions": map[string]any{
|
||||
"network": map[string]any{"enabled": true},
|
||||
},
|
||||
}
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionAllow, AutoAllowed: true}
|
||||
if !shouldRequestPermission(tools.NewBashTool(t.TempDir()), args, false, decision) {
|
||||
t.Fatal("inline additional permissions must request approval even when a sandboxed shell would otherwise auto-allow")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestPermissionsDenyReturnsEmptyGrantAndContinues(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// stallProvider connects successfully (HTTP 200) but the stream emits a stall/idle
|
||||
// timeout error for the first stallBefore calls, then succeeds with "done". When
|
||||
// partialText is set it emits that text BEFORE the stall, simulating a stall after
|
||||
// partial output (which must NOT be retried — re-issuing would duplicate it).
|
||||
type stallProvider struct {
|
||||
calls int32
|
||||
stallBefore int32
|
||||
partialText string
|
||||
reasoningText string
|
||||
}
|
||||
|
||||
func (p *stallProvider) StreamCompletion(_ context.Context, _ zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
n := atomic.AddInt32(&p.calls, 1)
|
||||
ch := make(chan zeroruntime.StreamEvent, 3)
|
||||
if n <= p.stallBefore {
|
||||
if p.partialText != "" {
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: p.partialText}
|
||||
}
|
||||
if p.reasoningText != "" {
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventReasoning, Content: p.reasoningText}
|
||||
}
|
||||
ch <- zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventError,
|
||||
Error: "provider stream error: no output for 10m (the model produced nothing)",
|
||||
}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// A no-output stall is re-issued on a fresh connection and recovers — this is the
|
||||
// macOS stale-pooled-connection hang turned into an automatic recovery.
|
||||
func TestRunRetriesStreamStallWithNoOutput(t *testing.T) {
|
||||
p := &stallProvider{stallBefore: 1}
|
||||
result, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
|
||||
if err != nil {
|
||||
t.Fatalf("a no-output stall should retry to success, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 stall + 1 retry), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A stall AFTER partial output must NOT be retried — re-issuing would duplicate
|
||||
// the already-streamed text. It surfaces the error instead.
|
||||
func TestRunDoesNotRetryStallAfterPartialOutput(t *testing.T) {
|
||||
p := &stallProvider{stallBefore: 1, partialText: "partial"}
|
||||
_, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
|
||||
if err == nil {
|
||||
t.Fatal("a stall after partial output must NOT be retried; want an error")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 1 {
|
||||
t.Fatalf("partial-then-stall must not retry, got %d calls", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A stall after only REASONING was forwarded (no text/tool-calls) must NOT be
|
||||
// retried: the user already saw the reasoning, so re-issuing with callbacks
|
||||
// re-enabled would duplicate it. collected.Text is empty here, so this exercises
|
||||
// the turn-level forwarded-output gate specifically, not the text check.
|
||||
func TestRunDoesNotRetryStallAfterForwardedReasoning(t *testing.T) {
|
||||
p := &stallProvider{stallBefore: 1, reasoningText: "thinking hard"}
|
||||
_, err := Run(context.Background(), "go", p, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnReasoning: func(string) {},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("a stall after forwarded reasoning must NOT be retried; want an error")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 1 {
|
||||
t.Fatalf("reasoning-then-stall must not retry, got %d calls", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A persistent stall surfaces an error after exhausting the capped retries.
|
||||
func TestRunGivesUpAfterMaxStallRetries(t *testing.T) {
|
||||
p := &stallProvider{stallBefore: 99}
|
||||
_, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
|
||||
if err == nil {
|
||||
t.Fatal("a persistent stall must surface an error after exhausting retries")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != int32(1+maxStreamStallRetries) {
|
||||
t.Fatalf("want %d calls (1 + %d retries), got %d", 1+maxStreamStallRetries, maxStreamStallRetries, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsStreamTimeoutError(t *testing.T) {
|
||||
timeouts := []string{
|
||||
"provider stream error: no output for 10m (the model produced nothing)",
|
||||
"provider stream error: idle timeout after 5m0s (upstream stopped sending data)",
|
||||
"stream stalled (upstream kept the connection alive but produced no output)",
|
||||
}
|
||||
for _, m := range timeouts {
|
||||
if !isStreamTimeoutError(m) {
|
||||
t.Fatalf("want timeout-classified: %q", m)
|
||||
}
|
||||
}
|
||||
notTimeouts := []string{"", "context length exceeded", "rate limit error: slow down", "model not found"}
|
||||
for _, m := range notTimeouts {
|
||||
if isStreamTimeoutError(m) {
|
||||
t.Fatalf("must NOT classify as timeout: %q", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,12 @@ work.
|
||||
implementation, verification, and a clear summary.
|
||||
- Only stop to ask the user (via the ask_user tool) when you are genuinely
|
||||
blocked on a decision that is theirs to make and that you cannot resolve from
|
||||
the code, the request, or sensible defaults.
|
||||
the code, the request, or sensible defaults. When the answer is likely one of a
|
||||
small set, include 2-4 suggested `options` and mark the best as `recommended`
|
||||
(it must be one of the options) so the user can pick quickly; give each option a
|
||||
short `optionDescriptions` line when a one-word label needs context, and a short
|
||||
`header` (2-3 words) per question as its tab label when you ask several. Omit
|
||||
options for genuinely open-ended questions.
|
||||
|
||||
## Workflow: plan then act
|
||||
|
||||
@@ -26,15 +31,29 @@ work.
|
||||
tests, or config before you modify behavior. Never edit a file you have not
|
||||
read.
|
||||
2. **Plan.** For multi-step work, call update_plan with an ordered checklist and
|
||||
keep it current as you go. Skip the plan for trivial one-step tasks.
|
||||
keep it live. The plan bar is the user's progress signal — call update_plan
|
||||
after EACH concrete unit of work (every file written, every command run), not
|
||||
just at coarse milestones: mark the finished item completed and the next one
|
||||
in_progress before you start it. A plan stuck at 0/N while files are landing is
|
||||
a bug, not economy — these calls are cheap, expected, and the update_plan cards
|
||||
are hidden from the transcript, so frequent updates cost the user nothing and
|
||||
never clutter the conversation. Keep at most one item in_progress, and never
|
||||
batch the updates to the end of the turn. Skip the plan for trivial one-step
|
||||
tasks.
|
||||
3. **Implement.** Make focused changes that match the surrounding code's style,
|
||||
naming, and conventions. Prefer the smallest change that fully solves the
|
||||
problem. Avoid broad refactors, unrelated rewrites, dependency churn, and
|
||||
formatting-only edits unless the user asked for them.
|
||||
4. **Verify.** Verify after edits; see the testing gate below. This is
|
||||
mandatory.
|
||||
5. **Summarize.** Give a concise final response: what changed, why, which files
|
||||
matter, and what validation ran. State plainly what was not verified.
|
||||
5. **Summarize.** For a substantial task, close with an ELABORATE, well-structured
|
||||
summary — never a single throwaway line. Use short headings and bullets: a
|
||||
one-line "what I built/changed"; **Where it lives** — the key files and how they
|
||||
fit together; **How each requirement is met** — walk through each requirement
|
||||
from the request and name the specifics that satisfy it; what validation ran and
|
||||
what was NOT verified; and a brief "Next steps" or "Want me to…?" offer. Scale
|
||||
the depth to the work — a big build earns several sections, a trivial fix earns
|
||||
a line or two.
|
||||
|
||||
## Editing discipline
|
||||
|
||||
@@ -63,8 +82,13 @@ work.
|
||||
|
||||
## Tool use
|
||||
|
||||
- Use tools to act, not to narrate. Do not announce each call; just do the work
|
||||
and explain the outcome.
|
||||
- Lead a multi-step task with a one- or two-sentence plain-language preamble on
|
||||
your approach, so the user can follow what you're about to do. Then keep a brief
|
||||
running account: drop a short, single-line note before each SIGNIFICANT step
|
||||
(e.g. "Now the stylesheet.", "Let me wire up the cart and filters.") and explain
|
||||
the outcome. Use tools to act, not to narrate — don't announce every individual
|
||||
call or read; narrate only the steps a person would care about, and skip
|
||||
narration entirely for trivial one-step tasks.
|
||||
- Run independent, read-only lookups together when you can, rather than one at a
|
||||
time, to move faster.
|
||||
- exec_command is for commands that have no native tool (build, test, git,
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
"avoid broad refactors",
|
||||
"search the web before answering",
|
||||
"do not recognize",
|
||||
"final response",
|
||||
"how each requirement is met",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected core system prompt to include %q, got:\n%s", want, buildSystemPrompt(Options{}))
|
||||
@@ -31,6 +31,19 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemPromptExplainsSandboxEscalationForHiddenHostState(t *testing.T) {
|
||||
prompt := strings.ToLower(buildSystemPrompt(Options{}))
|
||||
for _, want := range []string{
|
||||
"sandboxed shell command returns only sandbox-local state",
|
||||
"sandbox_permissions: \"require_escalated\"",
|
||||
"prefix_rule",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected sandbox escalation guidance %q, got:\n%s", want, buildSystemPrompt(Options{}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptIncludesWorkspaceSeedFromCwd(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
writeSystemPromptTestFile(t, cwd, "go.mod", "module example.test/zero\n")
|
||||
|
||||
+43
-4
@@ -24,6 +24,15 @@ const (
|
||||
PermissionModeAsk PermissionMode = "ask"
|
||||
PermissionModeUnsafe PermissionMode = "unsafe"
|
||||
PermissionModeSpecDraft PermissionMode = "spec-draft"
|
||||
// PermissionModeMemberAuto is a headless mode for swarm/specialist MEMBERS: it
|
||||
// advertises the in-workspace mutators a member needs to build (write/edit +
|
||||
// shell) on top of the Auto set, while the sandbox engine still gates them at
|
||||
// call time — in-workspace writes and sandbox-backed shell auto-allow, but
|
||||
// out-of-workspace writes, network, and destructive commands still prompt (and
|
||||
// a headless member has no approver, so they are denied). It normalizes to Auto
|
||||
// everywhere except ToolAdvertised, so authority is never widened beyond what an
|
||||
// interactive auto agent already has inside the sandbox.
|
||||
PermissionModeMemberAuto PermissionMode = "member-auto"
|
||||
)
|
||||
|
||||
type StopReason string
|
||||
@@ -129,11 +138,19 @@ type PermissionEvent struct {
|
||||
CommandPrefix []string `json:"commandPrefix,omitempty"`
|
||||
}
|
||||
|
||||
// AskUserQuestion is one clarifying question the agent wants answered.
|
||||
// AskUserQuestion is one clarifying question the agent wants answered. Options are
|
||||
// optional suggested answers an interactive front-end can render as a picker;
|
||||
// Recommended (when set) is the suggested default — it should match one of Options.
|
||||
// Header is an optional short tab title for a multi-question prompt (falls back to
|
||||
// the question text). OptionDescriptions, when present, holds a one-line description
|
||||
// per option aligned by index to Options (empty string = no description).
|
||||
type AskUserQuestion struct {
|
||||
Question string `json:"question"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
MultiSelect bool `json:"multiSelect,omitempty"`
|
||||
Question string `json:"question"`
|
||||
Header string `json:"header,omitempty"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
OptionDescriptions []string `json:"optionDescriptions,omitempty"`
|
||||
Recommended string `json:"recommended,omitempty"`
|
||||
MultiSelect bool `json:"multiSelect,omitempty"`
|
||||
}
|
||||
|
||||
// AskUserRequest is handed to OnAskUser when the model invokes the ask_user tool.
|
||||
@@ -253,6 +270,17 @@ type Options struct {
|
||||
// byte-identical to before). One instance per run — it holds attempt state.
|
||||
SelfCorrect *SelfCorrector
|
||||
|
||||
// RequireCompletionSignal gates run completion for HEADLESS exec. Without it,
|
||||
// any assistant turn that produces text but no tool call is accepted as the
|
||||
// final answer. With it, a no-tool-call turn is NOT treated as "done" while
|
||||
// work clearly remains — pending update_plan items, or a message that ends on a
|
||||
// continuation cue ("…Let me check the config:"). The loop then nudges the
|
||||
// model to continue instead, bounded by maxContinueNudges (and still by
|
||||
// MaxTurns and the run deadline); if the model keeps stalling, the run
|
||||
// finalizes as INCOMPLETE (Result.Incomplete) rather than success. Default
|
||||
// false leaves the loop byte-identical, so the interactive TUI is unaffected.
|
||||
RequireCompletionSignal bool
|
||||
|
||||
runPermissions *permissionRunState
|
||||
}
|
||||
|
||||
@@ -266,6 +294,17 @@ type Result struct {
|
||||
// hit the token cap, FinishReasonContentFilter when it was filtered. Empty for
|
||||
// a normal completion.
|
||||
FinishReason string
|
||||
// Incomplete reports that a headless run (RequireCompletionSignal) stopped with
|
||||
// work clearly unfinished: the model ended a turn with no tool call while plan
|
||||
// items were pending or the message ended mid-step, the model admitted it
|
||||
// guessed / could not meet the objective, and/or it failed a task-grounded
|
||||
// acceptance check. Callers map it to a non-success terminal status / exit
|
||||
// code. False for every normal completion.
|
||||
Incomplete bool
|
||||
// IncompleteReason is a short, model-derived explanation of why the run was
|
||||
// marked Incomplete (e.g. "pending plan items remain"). Empty when Incomplete
|
||||
// is false. Surfaced in logs / run_end so an abandoned run is debuggable.
|
||||
IncompleteReason string
|
||||
}
|
||||
|
||||
// Truncated reports whether the final response ended abnormally (cut off at the
|
||||
|
||||
+3
-1
@@ -56,7 +56,9 @@ func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return newCoreRegistryScoped(workspaceRoot, scope), engine, nil
|
||||
registry := newCoreRegistryScoped(workspaceRoot, scope)
|
||||
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
|
||||
return registry, engine, nil
|
||||
},
|
||||
ResolveWorkspaceRoot: acpWorkspaceRootResolver(deps),
|
||||
Store: deps.newSessionStore(),
|
||||
|
||||
+96
-14
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/localcontrol"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/observability"
|
||||
"github.com/Gitlawb/zero/internal/plugins"
|
||||
@@ -216,9 +217,9 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
// --theme {auto|dark|light} selects the TUI palette non-interactively (populates
|
||||
// tui.Options.Theme, which resolveThemeMode prefers over ZERO_THEME). Re-split
|
||||
// --add-dir afterward so it may appear on either side of --theme.
|
||||
// --theme <name> selects the TUI palette non-interactively (auto or any registered
|
||||
// theme; populates tui.Options.Theme, which resolveThemeMode prefers over
|
||||
// ZERO_THEME). Re-split --add-dir afterward so it may appear on either side of --theme.
|
||||
theme, args, err := splitLeadingThemeFlag(args)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
@@ -513,6 +514,13 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to resolve user config path: "+err.Error(), 1)
|
||||
}
|
||||
// Fail-soft, one-time: move any inline plaintext API keys in config.json into
|
||||
// the encrypted credential store (interactive runs only; headless exec keeps
|
||||
// its existing behavior). Non-fatal — a missing keyring or write error leaves
|
||||
// the inline key in place and this run still uses the already-resolved key.
|
||||
if store, storeErr := config.ProviderKeyStoreAt(filepath.Dir(userConfigPath)); storeErr == nil {
|
||||
_, _ = config.MigratePlaintextProviderKeys(userConfigPath, store)
|
||||
}
|
||||
doctorUserConfigPath := ""
|
||||
projectConfigPath := ""
|
||||
if resolveOptions, optErr := config.DefaultResolveOptions(workspaceRoot); optErr == nil {
|
||||
@@ -553,6 +561,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
}
|
||||
|
||||
registry := newCoreRegistryScoped(workspaceRoot, scope)
|
||||
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
|
||||
specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot, resolved.Swarm.MaxTeamSize)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1)
|
||||
@@ -632,13 +641,16 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
return deps.runTUI(context.Background(), tui.Options{
|
||||
Cwd: workspaceRoot,
|
||||
Theme: theme,
|
||||
SavedTheme: resolved.Preferences.Theme,
|
||||
UserConfigPath: userConfigPath,
|
||||
DoctorUserConfigPath: doctorUserConfigPath,
|
||||
ProjectConfigPath: projectConfigPath,
|
||||
ProviderName: resolved.Provider.Name,
|
||||
ModelName: resolved.Provider.Model,
|
||||
ProviderProfile: resolved.Provider,
|
||||
SavedProviders: usableSavedProviders(resolved.Providers),
|
||||
FavoriteModels: resolved.Preferences.FavoriteModels,
|
||||
RecapsEnabled: resolved.Preferences.RecapsEnabled(),
|
||||
Provider: provider,
|
||||
NewProvider: deps.newProvider,
|
||||
ProbeProviderHealth: deps.probeProviderHealth,
|
||||
@@ -712,7 +724,16 @@ func buildProvider(resolved config.ResolvedConfig, deps appDeps) (zeroruntime.Pr
|
||||
if !config.HasProviderProfile(resolved.Provider) {
|
||||
return nil, nil
|
||||
}
|
||||
return deps.newProvider(resolved.Provider)
|
||||
// Load the API key from the encrypted credential store when it isn't already
|
||||
// resolved from inline config or an env var. This keeps the resolver pure (no
|
||||
// I/O) and is a no-op for env/inline-key providers and when nothing is stored.
|
||||
profile := resolved.Provider
|
||||
if path, perr := deps.userConfigPath(); perr == nil {
|
||||
if store, err := config.ProviderKeyStoreAt(filepath.Dir(path)); err == nil {
|
||||
profile = config.ApplyStoredAPIKey(profile, store)
|
||||
}
|
||||
}
|
||||
return deps.newProvider(profile)
|
||||
}
|
||||
|
||||
func newCoreRegistry(workspaceRoot string) *tools.Registry {
|
||||
@@ -724,9 +745,71 @@ func newCoreRegistryScoped(workspaceRoot string, scope tools.PathScope) *tools.R
|
||||
for _, tool := range tools.CoreToolsScoped(workspaceRoot, scope) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
registerLocalControlTools(registry, workspaceRoot, config.LocalControlConfig{})
|
||||
return registry
|
||||
}
|
||||
|
||||
func registerLocalControlTools(registry *tools.Registry, workspaceRoot string, cfg config.LocalControlConfig) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
browserOptions := localBrowserOptionsFromConfig(cfg)
|
||||
desktopOptions := localDesktopOptionsFromConfig(cfg)
|
||||
terminalOptions := localTerminalOptionsFromConfig(cfg)
|
||||
for _, tool := range tools.NewLocalBrowserTools(browserOptions) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
for _, tool := range tools.NewLocalDesktopTools(desktopOptions) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
for _, tool := range tools.NewLocalTerminalTools(terminalOptions) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
for _, tool := range tools.NewLocalControlArtifactTools(tools.LocalControlArtifactOptions{
|
||||
Browser: browserOptions,
|
||||
Desktop: desktopOptions,
|
||||
Terminal: terminalOptions,
|
||||
ArtifactsDir: localArtifactsDirFromConfig(workspaceRoot, cfg),
|
||||
}) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
}
|
||||
|
||||
func localBrowserOptionsFromConfig(cfg config.LocalControlConfig) localcontrol.BrowserOptions {
|
||||
return localcontrol.BrowserOptions{
|
||||
Enabled: cfg.BrowserEnabled(),
|
||||
Driver: cfg.Browser.Driver,
|
||||
HelperPath: cfg.Browser.HelperPath,
|
||||
}
|
||||
}
|
||||
|
||||
func localDesktopOptionsFromConfig(cfg config.LocalControlConfig) localcontrol.DesktopOptions {
|
||||
return localcontrol.DesktopOptions{
|
||||
Enabled: cfg.DesktopEnabled(),
|
||||
Driver: cfg.Desktop.Driver,
|
||||
HelperPath: cfg.Desktop.HelperPath,
|
||||
}
|
||||
}
|
||||
|
||||
func localTerminalOptionsFromConfig(cfg config.LocalControlConfig) localcontrol.TerminalOptions {
|
||||
return localcontrol.TerminalOptions{
|
||||
Enabled: cfg.TerminalEnabled(),
|
||||
Driver: cfg.Terminal.Driver,
|
||||
HelperPath: cfg.Terminal.HelperPath,
|
||||
}
|
||||
}
|
||||
|
||||
func localArtifactsDirFromConfig(workspaceRoot string, cfg config.LocalControlConfig) string {
|
||||
dir := strings.TrimSpace(cfg.ArtifactsDir)
|
||||
if dir == "" {
|
||||
dir = filepath.Join(".zero", "artifacts")
|
||||
}
|
||||
if filepath.IsAbs(dir) {
|
||||
return dir
|
||||
}
|
||||
return filepath.Join(workspaceRoot, dir)
|
||||
}
|
||||
|
||||
// agentToolRuntime bundles the specialist runtime with the swarm it backs so
|
||||
// their lifetimes (and shutdown) stay paired: registerSpecialistTools brings up
|
||||
// both, closeSpecialistRuntime tears down both.
|
||||
@@ -951,9 +1034,10 @@ func splitLeadingAddDirFlags(args []string) ([]string, []string, error) {
|
||||
return addDirs, args, nil
|
||||
}
|
||||
|
||||
// splitLeadingThemeFlag strips a leading --theme {auto|dark|light} (space or =form)
|
||||
// from the root argument list and validates it. The last occurrence wins. A value
|
||||
// outside the allowed set is a loud error rather than a silent fallback.
|
||||
// splitLeadingThemeFlag strips a leading --theme <auto|theme-name> (space or =form)
|
||||
// from the root argument list and validates it against the registered themes. The
|
||||
// last occurrence wins. A value outside the allowed set is a loud error rather than
|
||||
// a silent fallback.
|
||||
func splitLeadingThemeFlag(args []string) (string, []string, error) {
|
||||
theme := ""
|
||||
for len(args) > 0 {
|
||||
@@ -961,7 +1045,7 @@ func splitLeadingThemeFlag(args []string) (string, []string, error) {
|
||||
switch {
|
||||
case args[0] == "--theme":
|
||||
if len(args) < 2 {
|
||||
return "", nil, errors.New("--theme requires a value (auto, dark, or light)")
|
||||
return "", nil, errors.New("--theme requires a value (auto or a theme name; try --theme auto)")
|
||||
}
|
||||
value = strings.TrimSpace(args[1])
|
||||
args = args[2:]
|
||||
@@ -971,12 +1055,10 @@ func splitLeadingThemeFlag(args []string) (string, []string, error) {
|
||||
default:
|
||||
return theme, args, nil
|
||||
}
|
||||
switch strings.ToLower(value) {
|
||||
case "auto", "dark", "light":
|
||||
theme = strings.ToLower(value)
|
||||
default:
|
||||
return "", nil, fmt.Errorf("--theme must be auto, dark, or light (got %q)", value)
|
||||
if !tui.ValidThemeArg(value) {
|
||||
return "", nil, fmt.Errorf("--theme must be auto or a registered theme name (got %q)", value)
|
||||
}
|
||||
theme = strings.ToLower(value)
|
||||
}
|
||||
return theme, args, nil
|
||||
}
|
||||
@@ -986,7 +1068,7 @@ func splitLeadingThemeFlag(args []string) (string, []string, error) {
|
||||
// setup result is not env-resolved, so checking APIKey alone would wrongly flag every
|
||||
// env-var-based provider as keyless.
|
||||
func profileHasCredential(profile config.ProviderProfile) bool {
|
||||
if strings.TrimSpace(profile.APIKey) != "" || strings.TrimSpace(profile.AuthHeaderValue) != "" {
|
||||
if strings.TrimSpace(profile.APIKey) != "" || profile.APIKeyStored || strings.TrimSpace(profile.AuthHeaderValue) != "" {
|
||||
return true
|
||||
}
|
||||
if env := strings.TrimSpace(profile.APIKeyEnv); env != "" {
|
||||
|
||||
@@ -617,9 +617,6 @@ func TestRunAddDirWiresExtraWriteRootIntoTUISandboxScope(t *testing.T) {
|
||||
t.Fatal("AgentOptions.Sandbox = nil, want sandbox engine")
|
||||
}
|
||||
roots := launchedOptions.AgentOptions.Sandbox.Scope().Roots()
|
||||
if len(roots) != 2 {
|
||||
t.Fatalf("scope roots = %v, want exactly workspace + extra", roots)
|
||||
}
|
||||
// The scope stores symlink-resolved roots (e.g. macOS /var -> /private/var),
|
||||
// so compare against the resolved extra dir.
|
||||
resolvedExtra, err := filepath.EvalSymlinks(extra)
|
||||
@@ -679,10 +676,14 @@ func TestRunSkipPermissionsUnsafeMergesAddDirGrants(t *testing.T) {
|
||||
t.Fatal("AgentOptions.Sandbox = nil, want sandbox engine")
|
||||
}
|
||||
roots := launchedOptions.AgentOptions.Sandbox.Scope().Roots()
|
||||
if len(roots) != 2 {
|
||||
t.Fatalf("scope roots = %v, want exactly workspace + extra", roots)
|
||||
found := false
|
||||
for _, root := range roots {
|
||||
if root == resolvedExtra {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if roots[1] != resolvedExtra {
|
||||
if !found {
|
||||
t.Fatalf("scope roots = %v, want extra root %q", roots, resolvedExtra)
|
||||
}
|
||||
})
|
||||
@@ -1396,7 +1397,7 @@ func TestRunThemeFlagRejectsBadValue(t *testing.T) {
|
||||
if launched {
|
||||
t.Fatal("the TUI must not launch on a bad --theme value")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "--theme must be auto, dark, or light") {
|
||||
if !strings.Contains(stderr.String(), "--theme must be auto or a registered theme name") {
|
||||
t.Fatalf("expected a clear --theme error, got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
+16
-2
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
"github.com/Gitlawb/zero/internal/provideroauth"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
@@ -137,7 +138,7 @@ func runAuthChatGPT(args []string, stdout io.Writer, stderr io.Writer, deps appD
|
||||
if _, err := fmt.Fprint(stdout, statuses); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if _, err := fmt.Fprint(stdout, "\nUse it with zero, e.g.:\n zero --provider chatgpt --model gpt-5\n"); err != nil {
|
||||
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
|
||||
@@ -336,6 +337,19 @@ func runAuthLogout(args []string, stdout io.Writer, stderr io.Writer, deps appDe
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
// Also drop any stored API key and its marker so `auth logout` clears the whole
|
||||
// credential (OAuth token AND key), not just the OAuth side. Surface deletion
|
||||
// failures rather than reporting success while a credential remains.
|
||||
keyRemoved, keyErr := config.ForgetProviderKey(provider)
|
||||
if keyErr != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(keyErr, redaction.Options{}), exitCrash)
|
||||
}
|
||||
if configPath, perr := deps.userConfigPath(); perr == nil {
|
||||
if _, clearErr := config.ClearProviderKeyStored(configPath, provider); clearErr != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(clearErr, redaction.Options{}), exitCrash)
|
||||
}
|
||||
}
|
||||
removed = removed || keyRemoved
|
||||
if parsed.json {
|
||||
payload := struct {
|
||||
Provider string `json:"provider"`
|
||||
@@ -346,7 +360,7 @@ func runAuthLogout(args []string, stdout io.Writer, stderr io.Writer, deps appDe
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
msg := fmt.Sprintf("No OAuth login stored for %s.\n", provider)
|
||||
msg := fmt.Sprintf("No stored credential for %s.\n", provider)
|
||||
if removed {
|
||||
msg = fmt.Sprintf("Logged out of %s.\n", provider)
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestRunAuthLogoutNothing(t *testing.T) {
|
||||
if code := runWithDeps([]string{"auth", "logout", "demo"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "No OAuth login stored for demo") {
|
||||
if !strings.Contains(stdout.String(), "No stored credential for demo") {
|
||||
t.Fatalf("logout output = %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,6 +277,9 @@ func listProviderCatalogSummaries(options commandCenterOptions) ([]providerCatal
|
||||
return nil, err
|
||||
}
|
||||
sort.SliceStable(summaries, func(i int, j int) bool {
|
||||
if summaries[i].Recommended != summaries[j].Recommended {
|
||||
return summaries[i].Recommended
|
||||
}
|
||||
return summaries[i].ID < summaries[j].ID
|
||||
})
|
||||
return summaries, nil
|
||||
@@ -354,10 +357,17 @@ func formatProviderCatalogSummaries(providers []providerCatalogSummary) string {
|
||||
}
|
||||
|
||||
func formatProviderCatalogLine(provider providerCatalogSummary) string {
|
||||
name := formatProviderCatalogValue(provider.Name, "unknown")
|
||||
prefix := " id="
|
||||
if provider.Recommended {
|
||||
prefix = " ★ id="
|
||||
name += " (recommended)"
|
||||
}
|
||||
lines := []string{
|
||||
fmt.Sprintf(" id=%s name=%s",
|
||||
fmt.Sprintf("%s%s name=%s",
|
||||
prefix,
|
||||
formatProviderCatalogValue(provider.ID, "unknown"),
|
||||
formatProviderCatalogValue(provider.Name, "unknown"),
|
||||
name,
|
||||
),
|
||||
fmt.Sprintf(" transport=%s defaultModel=%s",
|
||||
formatProviderCatalogValue(provider.Transport, "unknown"),
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -128,8 +127,8 @@ func TestValidateExecToolFiltersAllowsToolSearch(t *testing.T) {
|
||||
// TestRunExecListToolsAdvertisesMCPToolsWithoutToolSearch verifies that
|
||||
// `exec --list-tools` lists the core + MCP tools WITHOUT tool_search. This is
|
||||
// independent of the deferral threshold: --list-tools short-circuits and returns
|
||||
// before resolveConfig and registerToolSearchIfEligible ever run (see exec.go),
|
||||
// so tool_search is never registered on this path. The threshold gate itself is
|
||||
// before registerToolSearchIfEligible runs (see exec.go), so tool_search is
|
||||
// never registered on this path. The threshold gate itself is
|
||||
// exercised by TestRegisterToolSearchIfEligible{RegistersAtThreshold,
|
||||
// SkipsBelowThreshold,SkipsWhenThresholdZero} and the end-to-end at-threshold
|
||||
// path in TestTUIRunThreadsDeferThresholdAndRegistersToolSearch.
|
||||
@@ -141,7 +140,7 @@ func TestRunExecListToolsAdvertisesMCPToolsWithoutToolSearch(t *testing.T) {
|
||||
exitCode := runWithDeps([]string{"exec", "--list-tools"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
|
||||
return execResolvedConfig(), nil
|
||||
},
|
||||
resolveMCPConfig: func(string) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
@@ -176,7 +175,7 @@ func TestRunExecListToolsHonorsJSONFormat(t *testing.T) {
|
||||
exitCode := runWithDeps([]string{"exec", "--list-tools", "-o", "json"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
|
||||
return execResolvedConfig(), nil
|
||||
},
|
||||
resolveMCPConfig: func(string) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
|
||||
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
|
||||
|
||||
+159
-51
@@ -16,6 +16,8 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/lsp"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/notify"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/providers"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
@@ -32,6 +34,12 @@ const (
|
||||
exitCrash = 1
|
||||
exitUsage = 2
|
||||
exitProvider = 3
|
||||
// exitIncomplete marks a headless run that stopped with work clearly
|
||||
// unfinished — the completion gate exhausted its continue budget on a
|
||||
// no-tool-call turn while plan items were pending or the model ended mid-step.
|
||||
// Distinct from success so callers/benchmarks don't treat an abandoned run as
|
||||
// finished.
|
||||
exitIncomplete = 4
|
||||
// exitInterrupted is the conventional shell exit code for a process stopped by
|
||||
// SIGINT (128 + signal number 2).
|
||||
exitInterrupted = 130
|
||||
@@ -200,6 +208,44 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
if options.useSpec {
|
||||
specmode.RegisterDraftTools(registry, workspaceRoot, deps.now)
|
||||
}
|
||||
// Build the model registry once and reuse it across every model-aware
|
||||
// lookup in this exec run (model resolution, reasoning-effort advisory, and
|
||||
// context-window sizing). DefaultRegistry builds the full catalog and
|
||||
// compiles its match patterns, so rebuilding it per lookup is wasteful.
|
||||
// modelRegistry is the zero Registry when the catalog fails to build; the
|
||||
// helpers below degrade to safe no-op behavior in that case.
|
||||
modelRegistry, _ := modelregistry.DefaultRegistry()
|
||||
|
||||
overrides := config.Overrides{}
|
||||
modelOverride := options.model
|
||||
if options.useSpec && options.specModel != "" {
|
||||
modelOverride = options.specModel
|
||||
}
|
||||
if modelOverride != "" {
|
||||
resolvedModel, notice := resolveSelectedModel(modelRegistry, modelOverride)
|
||||
overrides.Provider.Model = resolvedModel
|
||||
if notice != "" {
|
||||
if _, err := fmt.Fprintln(stderr, notice); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
}
|
||||
if options.maxTurns > 0 {
|
||||
overrides.MaxTurns = options.maxTurns
|
||||
}
|
||||
resolved, err := deps.resolveConfig(workspaceRoot, overrides)
|
||||
if err != nil {
|
||||
if !options.listTools {
|
||||
if preflightErr := preflightExecSession(options); preflightErr != nil {
|
||||
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, preflightErr.Error())
|
||||
}
|
||||
if _, _, promptErr := resolveExecPrompt(options, workspaceRoot, deps.stdin); promptErr != nil {
|
||||
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, promptErr.Error())
|
||||
}
|
||||
}
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
|
||||
}
|
||||
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
|
||||
if err := validateExecToolFilters(options, registry); err != nil {
|
||||
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
|
||||
}
|
||||
@@ -228,35 +274,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
}
|
||||
sessionTitle := execSessionTitle(options, prompt)
|
||||
|
||||
// Build the model registry once and reuse it across every model-aware
|
||||
// lookup in this exec run (model resolution, reasoning-effort advisory, and
|
||||
// context-window sizing). DefaultRegistry builds the full catalog and
|
||||
// compiles its match patterns, so rebuilding it per lookup is wasteful.
|
||||
// modelRegistry is the zero Registry when the catalog fails to build; the
|
||||
// helpers below degrade to safe no-op behavior in that case.
|
||||
modelRegistry, _ := modelregistry.DefaultRegistry()
|
||||
|
||||
overrides := config.Overrides{}
|
||||
modelOverride := options.model
|
||||
if options.useSpec && options.specModel != "" {
|
||||
modelOverride = options.specModel
|
||||
}
|
||||
if modelOverride != "" {
|
||||
resolvedModel, notice := resolveSelectedModel(modelRegistry, modelOverride)
|
||||
overrides.Provider.Model = resolvedModel
|
||||
if notice != "" {
|
||||
if _, err := fmt.Fprintln(stderr, notice); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
}
|
||||
if options.maxTurns > 0 {
|
||||
overrides.MaxTurns = options.maxTurns
|
||||
}
|
||||
resolved, err := deps.resolveConfig(workspaceRoot, overrides)
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
|
||||
}
|
||||
if !config.HasProviderProfile(resolved.Provider) {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", "No provider configured. Run `zero setup` (guided), `zero auth` (OAuth providers), set a provider API key env var (e.g. OPENAI_API_KEY / ANTHROPIC_API_KEY / GEMINI_API_KEY), or add .zero/config.json.")
|
||||
}
|
||||
@@ -485,7 +502,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
defer lspShutdown()
|
||||
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
|
||||
MaxTurns: resolved.MaxTurns,
|
||||
ContextWindow: modelContextWindow(modelRegistry, resolved.Provider.Model),
|
||||
ContextWindow: resolveAgentContextWindow(runCtx, modelRegistry, resolved.Provider),
|
||||
DeferThreshold: effectiveDeferThreshold,
|
||||
Specialists: specialistRuntime.specialistInfos(),
|
||||
SessionID: preparedSession.Session.SessionID,
|
||||
@@ -504,12 +521,17 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
PermissionMode: permissionMode,
|
||||
Autonomy: options.autonomy,
|
||||
SelfCorrect: selfCorrector,
|
||||
Sandbox: sandboxEngine,
|
||||
FileTracker: tools.NewFileTracker(),
|
||||
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
|
||||
EnabledTools: options.enabledTools,
|
||||
DisabledTools: options.disabledTools,
|
||||
OnText: writer.text,
|
||||
// Headless exec: don't accept a no-tool-call turn as "done" while work
|
||||
// clearly remains (pending plan items / a mid-step continuation cue) —
|
||||
// nudge to continue, and finalize as INCOMPLETE rather than false success
|
||||
// if the model keeps stalling. The interactive TUI leaves this off.
|
||||
RequireCompletionSignal: true,
|
||||
Sandbox: sandboxEngine,
|
||||
FileTracker: tools.NewFileTracker(),
|
||||
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
|
||||
EnabledTools: options.enabledTools,
|
||||
DisabledTools: options.disabledTools,
|
||||
OnText: writer.text,
|
||||
OnToolCall: func(call agent.ToolCall) {
|
||||
writer.toolCall(call, registry)
|
||||
sessionRecorder.append(sessions.EventToolCall, map[string]any{
|
||||
@@ -613,6 +635,43 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
if notice := result.TruncationNotice(); notice != "" {
|
||||
writer.warning(notice)
|
||||
}
|
||||
// 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
|
||||
// output format BEFORE writer.final(), because for -o json final() emits a
|
||||
// {"type":"done","exit_code":0} that would otherwise mask the incomplete exit.
|
||||
// An `error` event (not just a warning) is emitted so log/cron consumers that
|
||||
// scan for type=="error" can recover the reason.
|
||||
if result.Incomplete {
|
||||
reason := result.IncompleteReason
|
||||
if reason == "" {
|
||||
reason = "run stopped with work unfinished"
|
||||
}
|
||||
sessionRecorder.append(sessions.EventError, map[string]any{"message": "incomplete: " + reason})
|
||||
switch options.outputFormat {
|
||||
case execOutputStreamJSON:
|
||||
writer.final(result.FinalAnswer)
|
||||
writer.errorEvent("incomplete", reason, false)
|
||||
writer.runEnd("incomplete", exitIncomplete)
|
||||
if writer.err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
case execOutputJSON:
|
||||
if writeErr := writeJSONLine(stdout, map[string]any{"type": "final", "text": result.FinalAnswer}); writeErr != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if writeErr := writeJSONLine(stdout, map[string]any{"type": "error", "code": "incomplete", "message": reason}); writeErr != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if writeErr := writeJSONLine(stdout, map[string]any{"type": "done", "exit_code": exitIncomplete}); writeErr != nil {
|
||||
return exitCrash
|
||||
}
|
||||
default:
|
||||
writer.final(result.FinalAnswer)
|
||||
fmt.Fprintln(stderr, "Run incomplete (not reported as success): "+reason)
|
||||
}
|
||||
return exitIncomplete
|
||||
}
|
||||
writer.final(result.FinalAnswer)
|
||||
writer.runEnd("success", exitSuccess)
|
||||
if writer.err != nil {
|
||||
@@ -659,7 +718,11 @@ func newExecSelfCorrector(enabled bool, workspaceRoot string, autonomy string) (
|
||||
func deferredEligibleCount(registry *tools.Registry, permissionMode agent.PermissionMode, enabledTools []string, disabledTools []string) int {
|
||||
count := 0
|
||||
for _, tool := range registry.All() {
|
||||
if !tools.IsDeferred(tool) {
|
||||
// Count by deferral-eligibility (not current deferred state) to match
|
||||
// partitionTools' active-gate: a tool that un-defers at runtime still
|
||||
// participates in the threshold. At registration time the swarm is empty
|
||||
// so this equals IsDeferred, but it keeps the two count sites consistent.
|
||||
if !tools.IsDeferralEligible(tool) {
|
||||
continue
|
||||
}
|
||||
if !agent.ToolVisible(tool, permissionMode, enabledTools, disabledTools) {
|
||||
@@ -937,30 +1000,71 @@ func resolveSelectedModel(registry modelregistry.Registry, input string) (string
|
||||
return entry.ID, notice
|
||||
}
|
||||
|
||||
// modelContextWindow returns the resolved model's context window (max input
|
||||
// tokens) from the model registry, used to enable agent-loop compaction. An
|
||||
// unknown model (e.g. a custom openai-compatible name) returns 0, which leaves
|
||||
// compaction DISABLED — a safe default that never compacts unexpectedly.
|
||||
// modelContextWindow returns the resolved model's exact context window (max input
|
||||
// tokens) from the registry, or 0 when the model isn't catalogued. Compaction call
|
||||
// sites wrap this in modelregistry.AgentContextWindow to apply a positive fallback;
|
||||
// display/report sites use the raw value so an unknown model shows no denominator.
|
||||
func modelContextWindow(registry modelregistry.Registry, modelID string) int {
|
||||
trimmed := strings.TrimSpace(modelID)
|
||||
if trimmed == "" {
|
||||
return 0
|
||||
}
|
||||
entry, ok := registry.Resolve(trimmed)
|
||||
if entry, ok := registry.Resolve(trimmed); ok {
|
||||
return entry.ContextLimits.ContextWindow
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// resolveAgentContextWindow returns the context window used to enable/size agent
|
||||
// compaction: the exact registry value when catalogued, else a window learned from
|
||||
// live provider discovery (so an uncatalogued proxy/custom model gets its real
|
||||
// window instead of the generic fallback), else the positive FallbackContextWindow.
|
||||
// Discovery runs only on a registry miss (catalogued models pay no latency), is
|
||||
// bounded by a short timeout, and degrades to the fallback on any error — so a
|
||||
// headless run is never blocked or failed by it.
|
||||
func resolveAgentContextWindow(ctx context.Context, registry modelregistry.Registry, profile config.ProviderProfile) int {
|
||||
if window := modelContextWindow(registry, profile.Model); window > 0 {
|
||||
return window
|
||||
}
|
||||
if window := discoveredModelContextWindow(ctx, profile); window > 0 {
|
||||
return window
|
||||
}
|
||||
return modelregistry.AgentContextWindow(0)
|
||||
}
|
||||
|
||||
// discoveredModelContextWindow queries the provider's live model list for the
|
||||
// active model's context window. Returns 0 when the provider isn't catalogued, the
|
||||
// credential can't be resolved, discovery fails, or the model reports no window.
|
||||
func discoveredModelContextWindow(ctx context.Context, profile config.ProviderProfile) int {
|
||||
descriptor, ok := providercatalog.Get(strings.TrimSpace(profile.CatalogID))
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return entry.ContextLimits.ContextWindow
|
||||
// Authenticate discovery with the resolved key (inline, then stored, then env).
|
||||
authed := profile
|
||||
if strings.TrimSpace(authed.APIKey) == "" {
|
||||
if store, err := config.ProviderKeyStore(); err == nil {
|
||||
authed = config.ApplyStoredAPIKey(authed, store)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(authed.APIKey) == "" && strings.TrimSpace(authed.APIKeyEnv) != "" {
|
||||
authed.APIKey = strings.TrimSpace(os.Getenv(authed.APIKeyEnv))
|
||||
}
|
||||
dctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
models, err := providermodeldiscovery.DiscoverCatalog(dctx, descriptor, authed, providermodeldiscovery.Options{})
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
target := strings.TrimSpace(profile.Model)
|
||||
for _, model := range models {
|
||||
if strings.EqualFold(strings.TrimSpace(model.ID), target) && model.ContextWindow > 0 {
|
||||
return model.ContextWindow
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// reasoningEffortNotice resolves the requested --reasoning-effort against the
|
||||
// selected model's supported efforts via EffectiveReasoningEffort and returns a
|
||||
// short advisory when the requested value is unsupported (and was coerced to the
|
||||
// model default).
|
||||
//
|
||||
// NOTE: the effective effort is not yet forwarded to the provider request — the
|
||||
// zeroruntime.CompletionRequest / provider wire schemas carry no effort field.
|
||||
// Full provider-request propagation is deferred (see slice-3 report).
|
||||
// forwardedReasoningEffort returns the effort to send on the provider request.
|
||||
// It mirrors reasoningEffortNotice: a known model that does not support reasoning
|
||||
// yields "" (matching the "ignoring" advisory, so the request never carries an
|
||||
@@ -983,6 +1087,10 @@ func forwardedReasoningEffort(registry modelregistry.Registry, modelID string, r
|
||||
return string(effective)
|
||||
}
|
||||
|
||||
// reasoningEffortNotice resolves the requested --reasoning-effort against the
|
||||
// selected model's supported efforts via EffectiveReasoningEffort and returns a
|
||||
// short advisory when the requested value is unsupported (and was coerced to the
|
||||
// model default).
|
||||
func reasoningEffortNotice(registry modelregistry.Registry, modelID string, requested string) string {
|
||||
trimmed := strings.TrimSpace(modelID)
|
||||
if trimmed == "" {
|
||||
|
||||
@@ -51,13 +51,18 @@ func TestRunExecListsFilteredToolsWithoutPromptOrProvider(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
providerBuilt := false
|
||||
|
||||
exitCode := runWithDeps([]string{"exec", "--list-tools", "--enabled-tools", "read_file,grep"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
|
||||
return execResolvedConfig(), nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
providerBuilt = true
|
||||
return nil, errors.New("provider should not be constructed for --list-tools")
|
||||
},
|
||||
})
|
||||
|
||||
@@ -67,6 +72,9 @@ func TestRunExecListsFilteredToolsWithoutPromptOrProvider(t *testing.T) {
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
}
|
||||
if providerBuilt {
|
||||
t.Fatal("provider should not be constructed for --list-tools")
|
||||
}
|
||||
for _, want := range []string{"Tools visible to model", "read_file", "grep"} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("expected tool list to contain %q, got %q", want, stdout.String())
|
||||
@@ -81,13 +89,18 @@ func TestRunExecListsToolsAsStreamJSONWhenRequested(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
providerBuilt := false
|
||||
|
||||
exitCode := runWithDeps([]string{"exec", "--list-tools", "--output-format", "stream-json", "--enabled-tools", "read_file"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
|
||||
return execResolvedConfig(), nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
providerBuilt = true
|
||||
return nil, errors.New("provider should not be constructed for --list-tools")
|
||||
},
|
||||
})
|
||||
|
||||
@@ -97,6 +110,9 @@ func TestRunExecListsToolsAsStreamJSONWhenRequested(t *testing.T) {
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||
}
|
||||
if providerBuilt {
|
||||
t.Fatal("provider should not be constructed for --list-tools")
|
||||
}
|
||||
events := decodeJSONLines(t, stdout.String())
|
||||
if len(events) != 3 {
|
||||
t.Fatalf("expected run_start, final, run_end events, got %#v", events)
|
||||
@@ -115,11 +131,11 @@ func TestRunExecListsToolsAsStreamJSONWhenRequested(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecListsMCPToolsWithoutProviderResolution(t *testing.T) {
|
||||
func TestRunExecListsMCPToolsWithoutProviderConstruction(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
providerResolved := false
|
||||
providerBuilt := false
|
||||
closed := false
|
||||
|
||||
exitCode := runWithDeps([]string{"exec", "--list-tools", "--enabled-tools", "mcp_docs_lookup"}, &stdout, &stderr, appDeps{
|
||||
@@ -127,8 +143,11 @@ func TestRunExecListsMCPToolsWithoutProviderResolution(t *testing.T) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
providerResolved = true
|
||||
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
|
||||
return execResolvedConfig(), nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
providerBuilt = true
|
||||
return nil, errors.New("provider should not be constructed for --list-tools")
|
||||
},
|
||||
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
||||
if workspaceRoot != cwd {
|
||||
@@ -150,8 +169,8 @@ func TestRunExecListsMCPToolsWithoutProviderResolution(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if providerResolved {
|
||||
t.Fatal("provider config should not be resolved for --list-tools")
|
||||
if providerBuilt {
|
||||
t.Fatal("provider should not be constructed for --list-tools")
|
||||
}
|
||||
if !closed {
|
||||
t.Fatal("MCP runtime was not closed after --list-tools")
|
||||
|
||||
@@ -44,7 +44,7 @@ func TestRunAddDirDispatchForwardsGrantIntoExecScope(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
||||
cwd := t.TempDir()
|
||||
extra := t.TempDir()
|
||||
extra := tempDirOutsideDefaultTemp(t)
|
||||
target := filepath.Join(extra, "granted.txt")
|
||||
|
||||
exitCode, stderr := runExecAddDirWriteProbe(t, cwd, tc.args(extra), target)
|
||||
@@ -117,7 +117,7 @@ func runExecAddDirWriteProbe(t *testing.T, cwd string, args []string, target str
|
||||
// prove both the overwrite and the scoped enforcement it ships.
|
||||
func TestExecScopeReRegistrationSwapsCoreToolsByName(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
extra := t.TempDir()
|
||||
extra := tempDirOutsideDefaultTemp(t)
|
||||
inside := filepath.Join(extra, "inside.txt")
|
||||
|
||||
registry := newCoreRegistry(root)
|
||||
@@ -160,7 +160,7 @@ func TestExecScopeReRegistrationSwapsCoreToolsByName(t *testing.T) {
|
||||
}
|
||||
|
||||
// A path outside every scope root must still be denied after re-registration.
|
||||
outside := filepath.Join(t.TempDir(), "outside.txt")
|
||||
outside := filepath.Join(tempDirOutsideDefaultTemp(t), "outside.txt")
|
||||
deniedOutside := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
|
||||
"path": outside,
|
||||
"content": "never",
|
||||
@@ -175,3 +175,20 @@ func TestExecScopeReRegistrationSwapsCoreToolsByName(t *testing.T) {
|
||||
t.Fatalf("expected outside file to remain absent, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func tempDirOutsideDefaultTemp(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir, err := os.MkdirTemp(".", ".zero-sandbox-outside-")
|
||||
if err != nil {
|
||||
t.Fatalf("MkdirTemp outside default temp: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(%q): %v", dir, err)
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
|
||||
return resolved
|
||||
}
|
||||
return filepath.Clean(abs)
|
||||
}
|
||||
@@ -98,7 +98,7 @@ func runExecSpecDraft(run execSpecDraftRun) int {
|
||||
defer stopSignals()
|
||||
result, err := agent.Run(runCtx, run.prompt, run.provider, agent.Options{
|
||||
MaxTurns: run.resolved.MaxTurns,
|
||||
ContextWindow: modelContextWindow(run.modelRegistry, run.resolved.Provider.Model),
|
||||
ContextWindow: resolveAgentContextWindow(runCtx, run.modelRegistry, run.resolved.Provider),
|
||||
SessionID: draftSession.SessionID,
|
||||
SessionTitle: run.sessionTitle,
|
||||
ProviderName: run.resolved.Provider.Name,
|
||||
|
||||
@@ -465,7 +465,7 @@ func TestRunExecModeModelSurfacesDeprecationNoticeViaSharedPath(t *testing.T) {
|
||||
func TestRunExecListToolsAppliesModeBeforeListing(t *testing.T) {
|
||||
// applyExecMode now runs before tool-filter validation and the --list-tools
|
||||
// branch, so a --mode preset is expanded for --list-tools. Combining a mode
|
||||
// with --list-tools must still succeed and never resolve a provider.
|
||||
// with --list-tools must still succeed without constructing a provider.
|
||||
cwd := t.TempDir()
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
@@ -475,7 +475,7 @@ func TestRunExecListToolsAppliesModeBeforeListing(t *testing.T) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
|
||||
return execResolvedConfig(), nil
|
||||
},
|
||||
})
|
||||
|
||||
@@ -505,6 +505,49 @@ func TestRunExecModeToolFilterReflectedInListTools(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecPermissionModeMember(t *testing.T) {
|
||||
cases := []struct {
|
||||
autonomy string
|
||||
want agent.PermissionMode
|
||||
}{
|
||||
{"", agent.PermissionModeAuto},
|
||||
{"low", agent.PermissionModeAuto},
|
||||
{"medium", agent.PermissionModeAuto},
|
||||
{"member", agent.PermissionModeMemberAuto}, // headless members: write + sandboxed shell
|
||||
{"high", agent.PermissionModeUnsafe},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := resolveExecPermissionMode(execOptions{autonomy: c.autonomy})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveExecPermissionMode(%q): %v", c.autonomy, err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("resolveExecPermissionMode(%q) = %q, want %q", c.autonomy, got, c.want)
|
||||
}
|
||||
}
|
||||
if _, err := resolveExecPermissionMode(execOptions{autonomy: "bogus"}); err == nil {
|
||||
t.Fatal("an unknown autonomy level must still be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// A member-auto headless tool list must include the in-workspace mutators that
|
||||
// plain Auto hides, so a swarm member can actually build. Match the tool ENTRY
|
||||
// line (" write_file [") — a bare substring would false-match tool descriptions.
|
||||
func TestExecMemberAutoToolListIncludesMutators(t *testing.T) {
|
||||
registry := newCoreRegistry(t.TempDir())
|
||||
const writeEntry = "\n write_file ["
|
||||
|
||||
member := formatExecToolList(registry, execOptions{}, agent.PermissionModeMemberAuto)
|
||||
if !strings.Contains(member, writeEntry) {
|
||||
t.Fatalf("member-auto tool list must include write_file, got %q", member)
|
||||
}
|
||||
// Plain Auto must still hide it (unchanged behavior — this is the read-only gate).
|
||||
auto := formatExecToolList(registry, execOptions{}, agent.PermissionModeAuto)
|
||||
if strings.Contains(auto, writeEntry) {
|
||||
t.Fatalf("plain Auto must still hide write_file, got %q", auto)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecAcceptsLegacyModelProfileFlags(t *testing.T) {
|
||||
exitCode, stdout, stderr := runExecWithEcho(t, []string{
|
||||
"exec",
|
||||
|
||||
@@ -81,6 +81,11 @@ func resolveExecPermissionMode(options execOptions) (agent.PermissionMode, error
|
||||
switch strings.ToLower(strings.TrimSpace(options.autonomy)) {
|
||||
case "", "low", "medium":
|
||||
mode = agent.PermissionModeAuto
|
||||
case "member":
|
||||
// Internal autonomy for headless swarm/specialist members: Auto plus
|
||||
// advertised in-workspace mutators (see PermissionModeMemberAuto). The
|
||||
// swarm launcher sets this; it is not part of the public low|medium|high set.
|
||||
mode = agent.PermissionModeMemberAuto
|
||||
case "high":
|
||||
mode = agent.PermissionModeUnsafe
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
func TestCoreRegistryIncludesDefaultLocalControlTools(t *testing.T) {
|
||||
registry := newCoreRegistry(t.TempDir())
|
||||
for _, name := range []string{"browser_install", "browser_launch", "browser_connect", "browser_open", "browser_snapshot", "browser_click", "browser_type", "browser_press", "terminal_session", "capture_artifact"} {
|
||||
tool, ok := registry.Get(name)
|
||||
if !ok {
|
||||
t.Fatalf("%s not registered", name)
|
||||
}
|
||||
if tool.Safety().Permission != tools.PermissionPrompt {
|
||||
t.Fatalf("%s permission = %s, want prompt", name, tool.Safety().Permission)
|
||||
}
|
||||
}
|
||||
desktop, ok := registry.Get("desktop_action")
|
||||
if !ok {
|
||||
t.Fatal("desktop_action not registered")
|
||||
}
|
||||
if desktop.Safety().Permission != tools.PermissionDeny {
|
||||
t.Fatalf("desktop_action permission = %s, want deny", desktop.Safety().Permission)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreRegistryHonorsExplicitLocalControlDisable(t *testing.T) {
|
||||
registry := newCoreRegistry(t.TempDir())
|
||||
var cfg config.LocalControlConfig
|
||||
if err := json.Unmarshal([]byte(`{"enabled":false}`), &cfg); err != nil {
|
||||
t.Fatalf("unmarshal local control config: %v", err)
|
||||
}
|
||||
registerLocalControlTools(registry, t.TempDir(), cfg)
|
||||
for _, name := range []string{"browser_install", "browser_launch", "browser_connect", "browser_open", "browser_snapshot", "browser_click", "browser_type", "browser_press", "desktop_action", "terminal_session", "capture_artifact"} {
|
||||
tool, ok := registry.Get(name)
|
||||
if !ok {
|
||||
t.Fatalf("%s not registered", name)
|
||||
}
|
||||
if tool.Safety().Permission != tools.PermissionDeny {
|
||||
t.Fatalf("%s permission = %s, want deny", name, tool.Safety().Permission)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecListToolsHonorsDisabledLocalControlConfig(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cfg := execResolvedConfig()
|
||||
var localControl config.LocalControlConfig
|
||||
if err := json.Unmarshal([]byte(`{"enabled":false}`), &localControl); err != nil {
|
||||
t.Fatalf("unmarshal local control config: %v", err)
|
||||
}
|
||||
cfg.LocalControl = localControl
|
||||
|
||||
exitCode := runWithDeps([]string{"exec", "--list-tools", "-o", "json"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return t.TempDir(), nil
|
||||
},
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return cfg, nil
|
||||
},
|
||||
})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Tools []struct {
|
||||
Name string `json:"name"`
|
||||
Permission string `json:"permission"`
|
||||
} `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(stdout.String())), &payload); err != nil {
|
||||
t.Fatalf("parse tools JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
for _, tool := range payload.Tools {
|
||||
if tool.Name == "browser_open" {
|
||||
t.Fatalf("browser_open should be hidden when local control is disabled, got permission %s", tool.Permission)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterLocalControlToolsAppliesDefaults(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
registry := newCoreRegistry(workspaceRoot)
|
||||
registerLocalControlTools(registry, workspaceRoot, config.LocalControlConfig{Enabled: true})
|
||||
for _, name := range []string{"browser_install", "browser_launch", "browser_connect", "browser_open", "browser_snapshot", "browser_click", "browser_type", "browser_press", "terminal_session", "capture_artifact"} {
|
||||
tool, ok := registry.Get(name)
|
||||
if !ok {
|
||||
t.Fatalf("%s not registered", name)
|
||||
}
|
||||
if tool.Safety().Permission != tools.PermissionPrompt {
|
||||
t.Fatalf("%s permission = %s, want prompt", name, tool.Safety().Permission)
|
||||
}
|
||||
}
|
||||
desktop, ok := registry.Get("desktop_action")
|
||||
if !ok {
|
||||
t.Fatal("desktop_action not registered")
|
||||
}
|
||||
if desktop.Safety().Permission != tools.PermissionDeny {
|
||||
t.Fatalf("desktop_action permission = %s, want deny without nested desktop opt-in", desktop.Safety().Permission)
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,9 @@ func runProvidersAdd(args []string, stdout io.Writer, stderr io.Writer, deps app
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
cfg, err := config.UpsertProvider(configPath, profile, options.setActive)
|
||||
// Persist with the key moved into the encrypted credential store (capture flip);
|
||||
// the local profile keeps the key for the verification build below.
|
||||
cfg, err := config.UpsertProvider(configPath, config.SecureProviderProfile(profile, configPath), options.setActive)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
@@ -486,7 +488,7 @@ func validateProviderRuntimeReady(profile config.ProviderProfile) error {
|
||||
}
|
||||
|
||||
func providerProfileHasCredential(profile config.ProviderProfile) bool {
|
||||
return strings.TrimSpace(profile.APIKey) != "" || strings.TrimSpace(profile.AuthHeaderValue) != ""
|
||||
return strings.TrimSpace(profile.APIKey) != "" || profile.APIKeyStored || strings.TrimSpace(profile.AuthHeaderValue) != ""
|
||||
}
|
||||
|
||||
func providerKindForDescriptor(descriptor providercatalog.Descriptor) config.ProviderKind {
|
||||
|
||||
@@ -31,8 +31,8 @@ func TestRunSandboxCheckJSONDeniesOutOfWorkspaceWrite(t *testing.T) {
|
||||
deps, _ := sandboxCheckDeps(t)
|
||||
// An absolute path outside the workspace, portable across OSes: a Unix literal
|
||||
// like /etc/passwd is not absolute on Windows (it would be joined into the
|
||||
// workspace and allowed), so build one under a separate temp dir instead.
|
||||
outside := filepath.Join(t.TempDir(), "outside.txt")
|
||||
// workspace and allowed), so build one under a non-temp test dir instead.
|
||||
outside := filepath.Join(tempDirOutsideDefaultTemp(t), "outside.txt")
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"sandbox", "check", "write_file", "--side-effect", "write", "--path", outside, "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
|
||||
@@ -510,15 +510,94 @@ func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) {
|
||||
got := stdout.String()
|
||||
got = replacePathToken(got, workspace, "$WORKSPACE")
|
||||
got = replacePathToken(got, store.FilePath(), "$GRANTS")
|
||||
gotBytes := normalizeSandboxPolicyGoldenTempRoots(t, []byte(got), workspace)
|
||||
wantBytes, err := os.ReadFile(filepath.Join("testdata", "sandbox_policy_windows_unavailable.golden.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read golden: %v", err)
|
||||
}
|
||||
if !jsonValuesEqual(t, wantBytes, []byte(got)) {
|
||||
t.Fatalf("policy JSON golden mismatch\nwant:\n%s\ngot:\n%s", string(wantBytes), got)
|
||||
if !jsonValuesEqual(t, wantBytes, gotBytes) {
|
||||
t.Fatalf("policy JSON golden mismatch\nwant:\n%s\ngot:\n%s", string(wantBytes), string(gotBytes))
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, workspace string) []byte {
|
||||
t.Helper()
|
||||
scope, err := sandbox.NewScope(workspace, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewScope(%q): %v", workspace, err)
|
||||
}
|
||||
roots := scope.Roots()
|
||||
if len(roots) <= 1 {
|
||||
return gotBytes
|
||||
}
|
||||
tempRoots := map[string]struct{}{}
|
||||
for _, root := range roots[1:] {
|
||||
tempRoots[root] = struct{}{}
|
||||
}
|
||||
var value any
|
||||
if err := json.Unmarshal(gotBytes, &value); err != nil {
|
||||
t.Fatalf("decode got JSON for normalization: %v\n%s", err, string(gotBytes))
|
||||
}
|
||||
plan, _ := value.(map[string]any)["plan"].(map[string]any)
|
||||
profile, _ := plan["permissionProfile"].(map[string]any)
|
||||
fileSystem, _ := profile["fileSystem"].(map[string]any)
|
||||
fileSystem["readRoots"] = filterJSONStringRoots(fileSystem["readRoots"], tempRoots)
|
||||
fileSystem["writeRoots"] = filterJSONWriteRoots(fileSystem["writeRoots"], tempRoots)
|
||||
normalized, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("encode normalized policy JSON: %v", err)
|
||||
}
|
||||
return append(normalized, '\n')
|
||||
}
|
||||
|
||||
func filterJSONStringRoots(value any, excluded map[string]struct{}) any {
|
||||
roots, ok := value.([]any)
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
out := make([]any, 0, len(roots))
|
||||
for _, root := range roots {
|
||||
text, ok := root.(string)
|
||||
if !ok {
|
||||
out = append(out, root)
|
||||
continue
|
||||
}
|
||||
if _, skip := excluded[text]; !skip {
|
||||
out = append(out, root)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filterJSONWriteRoots(value any, excluded map[string]struct{}) any {
|
||||
roots, ok := value.([]any)
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
out := make([]any, 0, len(roots))
|
||||
for _, root := range roots {
|
||||
entry, ok := root.(map[string]any)
|
||||
if !ok {
|
||||
out = append(out, root)
|
||||
continue
|
||||
}
|
||||
text, _ := entry["root"].(string)
|
||||
if _, skip := excluded[text]; !skip {
|
||||
out = append(out, root)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func containsString(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func jsonValuesEqual(t *testing.T, wantBytes []byte, gotBytes []byte) bool {
|
||||
t.Helper()
|
||||
var wantValue any
|
||||
@@ -529,9 +608,30 @@ func jsonValuesEqual(t *testing.T, wantBytes []byte, gotBytes []byte) bool {
|
||||
if err := json.Unmarshal(gotBytes, &gotValue); err != nil {
|
||||
t.Fatalf("decode got JSON: %v\n%s", err, string(gotBytes))
|
||||
}
|
||||
normalizePortableJSONRootString(&wantValue)
|
||||
normalizePortableJSONRootString(&gotValue)
|
||||
return reflect.DeepEqual(wantValue, gotValue)
|
||||
}
|
||||
|
||||
func normalizePortableJSONRootString(value *any) {
|
||||
switch current := (*value).(type) {
|
||||
case string:
|
||||
if current == `\` {
|
||||
*value = "/"
|
||||
}
|
||||
case []any:
|
||||
for index := range current {
|
||||
normalizePortableJSONRootString(¤t[index])
|
||||
}
|
||||
case map[string]any:
|
||||
for key := range current {
|
||||
entry := current[key]
|
||||
normalizePortableJSONRootString(&entry)
|
||||
current[key] = entry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func replacePathToken(value string, path string, token string) string {
|
||||
replace := func(value string, path string) string {
|
||||
value = strings.ReplaceAll(value, path, token)
|
||||
@@ -654,7 +754,7 @@ func TestEffectiveSandboxPolicyShowsWriteRootsError(t *testing.T) {
|
||||
|
||||
func TestRunSandboxPolicyEffectiveListsConfiguredWriteRoots(t *testing.T) {
|
||||
store := newSandboxTestStore(t)
|
||||
extra := t.TempDir()
|
||||
extra := tempDirOutsideDefaultTemp(t)
|
||||
resolvedExtra, err := filepath.EvalSymlinks(extra)
|
||||
if err != nil {
|
||||
t.Fatalf("EvalSymlinks(%q) returned error: %v", extra, err)
|
||||
@@ -693,11 +793,11 @@ func TestRunSandboxPolicyEffectiveListsConfiguredWriteRoots(t *testing.T) {
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode effective JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(payload.WriteRoots) != 2 {
|
||||
t.Fatalf("writeRoots = %#v, want workspace root + extra root", payload.WriteRoots)
|
||||
if len(payload.WriteRoots) < 2 {
|
||||
t.Fatalf("writeRoots = %#v, want workspace root, extra root, and default temp roots", payload.WriteRoots)
|
||||
}
|
||||
if payload.WriteRoots[1] != resolvedExtra {
|
||||
t.Fatalf("writeRoots[1] = %q, want %q", payload.WriteRoots[1], resolvedExtra)
|
||||
if !containsString(payload.WriteRoots, resolvedExtra) {
|
||||
t.Fatalf("writeRoots = %#v, want configured extra root %q", payload.WriteRoots, resolvedExtra)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "writeRootsError") {
|
||||
t.Fatalf("unexpected writeRootsError key for valid roots:\n%s", stdout.String())
|
||||
@@ -745,8 +845,11 @@ func TestRunSandboxPolicyEffectiveWriteRootsFailSoft(t *testing.T) {
|
||||
if !strings.Contains(payload.WriteRootsError, missing) {
|
||||
t.Fatalf("writeRootsError = %q, want it to name the stale root %q", payload.WriteRootsError, missing)
|
||||
}
|
||||
if len(payload.WriteRoots) != 1 {
|
||||
t.Fatalf("writeRoots = %#v, want workspace-only fallback", payload.WriteRoots)
|
||||
if len(payload.WriteRoots) == 0 {
|
||||
t.Fatalf("writeRoots = %#v, want workspace/default-temp fallback", payload.WriteRoots)
|
||||
}
|
||||
if containsString(payload.WriteRoots, missing) {
|
||||
t.Fatalf("writeRoots = %#v, must not include stale root %q", payload.WriteRoots, missing)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+48
-1
@@ -263,7 +263,9 @@ func saveSetupProvider(deps appDeps, selection tui.SetupSelection, options setup
|
||||
if err != nil {
|
||||
return tui.SetupResult{}, err
|
||||
}
|
||||
if _, err := config.UpsertProvider(configPath, profile, true); err != nil {
|
||||
// Persist with the key moved into the encrypted credential store (capture flip);
|
||||
// the returned profile keeps the key for this run's immediate use.
|
||||
if _, err := config.UpsertProvider(configPath, config.SecureProviderProfile(profile, configPath), true); err != nil {
|
||||
return tui.SetupResult{}, err
|
||||
}
|
||||
return tui.SetupResult{ConfigPath: configPath, Provider: profile}, nil
|
||||
@@ -283,6 +285,7 @@ func setupProviderOptions() []tui.SetupProviderOption {
|
||||
EnvVar: setupProviderEnvVar(descriptor),
|
||||
RequiresAuth: descriptor.RequiresAuth,
|
||||
Local: descriptor.Local,
|
||||
Recommended: descriptor.Recommended,
|
||||
})
|
||||
}
|
||||
return options
|
||||
@@ -381,6 +384,50 @@ func firstUsableProvider(providers []config.ProviderProfile) (config.ProviderPro
|
||||
return config.ProviderProfile{}, false
|
||||
}
|
||||
|
||||
// usableSavedProviders filters configured providers to those the user can actually
|
||||
// use: an inline/stored/env key, an auth header, a stored OAuth login, or a no-auth
|
||||
// local provider. This keeps /model from listing providers that are merely present
|
||||
// in config.json but never authenticated (e.g. a default openai entry with no key).
|
||||
func usableSavedProviders(providers []config.ProviderProfile) []config.ProviderProfile {
|
||||
logins := oauthLoggedInProviders()
|
||||
store, storeErr := config.ProviderKeyStore()
|
||||
usable := make([]config.ProviderProfile, 0, len(providers))
|
||||
for _, profile := range providers {
|
||||
if !config.HasProviderProfile(profile) {
|
||||
continue
|
||||
}
|
||||
// A provider whose only credential is the APIKeyStored marker counts as usable
|
||||
// only when the key is actually retrievable — the keyring/file entry may have
|
||||
// been deleted, leaving a stale marker that would otherwise list it in /model.
|
||||
if profile.APIKeyStored && strings.TrimSpace(profile.APIKey) == "" {
|
||||
if storeErr == nil {
|
||||
if key, ok, err := store.Get(profile.Name); err == nil && ok && strings.TrimSpace(key) != "" {
|
||||
usable = append(usable, profile)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Marker present but key missing/unreadable: fall through and judge the
|
||||
// profile on its other signals (env var, OAuth, local) with the marker off.
|
||||
withoutMarker := profile
|
||||
withoutMarker.APIKeyStored = false
|
||||
if _, missing := setupMissingCredentialEnv(withoutMarker); !missing {
|
||||
usable = append(usable, profile)
|
||||
} else if providerHasOAuthLogin(profile, logins) {
|
||||
usable = append(usable, profile)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, missing := setupMissingCredentialEnv(profile); !missing {
|
||||
usable = append(usable, profile)
|
||||
continue
|
||||
}
|
||||
if providerHasOAuthLogin(profile, logins) {
|
||||
usable = append(usable, profile)
|
||||
}
|
||||
}
|
||||
return usable
|
||||
}
|
||||
|
||||
// providerProfileIsLocal reports whether a provider points at a local endpoint
|
||||
// (a loopback URL or a catalog entry flagged Local), so the fallback prefers a
|
||||
// remote keyed provider that is more likely reachable.
|
||||
|
||||
@@ -103,6 +103,9 @@ func TestSetupProviderOptionsUseRuntimeSupportedCatalog(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSaveSetupProviderStoresPastedAPIKey(t *testing.T) {
|
||||
// Use the encrypted-file backend in the temp config dir so the test never
|
||||
// touches the real OS keychain.
|
||||
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
|
||||
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
|
||||
result, err := saveSetupProvider(appDeps{
|
||||
@@ -132,8 +135,20 @@ func TestSaveSetupProviderStoresPastedAPIKey(t *testing.T) {
|
||||
if len(cfg.Providers) != 1 {
|
||||
t.Fatalf("Providers = %#v, want one provider", cfg.Providers)
|
||||
}
|
||||
if cfg.Providers[0].APIKey != "sk-pasted-secret" || cfg.Providers[0].APIKeyEnv != "" {
|
||||
t.Fatalf("stored provider credentials = APIKey %q APIKeyEnv %q, want pasted key only", cfg.Providers[0].APIKey, cfg.Providers[0].APIKeyEnv)
|
||||
// The capture flip: config.json must NOT hold the cleartext key — it carries the
|
||||
// APIKeyStored marker, and the secret lives in the co-located credential store.
|
||||
if cfg.Providers[0].APIKey != "" || cfg.Providers[0].APIKeyEnv != "" {
|
||||
t.Fatalf("config must not persist the key: APIKey %q APIKeyEnv %q", cfg.Providers[0].APIKey, cfg.Providers[0].APIKeyEnv)
|
||||
}
|
||||
if !cfg.Providers[0].APIKeyStored {
|
||||
t.Fatal("expected APIKeyStored marker in config")
|
||||
}
|
||||
store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if key, ok, _ := store.Get("ollama-cloud"); !ok || key != "sk-pasted-secret" {
|
||||
t.Fatalf("stored key = %q,%v; want sk-pasted-secret in the credential store", key, ok)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"fileSystem": {
|
||||
"kind": "restricted",
|
||||
"readRoots": [
|
||||
"/",
|
||||
"$WORKSPACE"
|
||||
],
|
||||
"writeRoots": [
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// SetActiveProviderEnv must export the provider name so a child process (whose
|
||||
// resolution reads the inherited environment via the nil-map -> os.Getenv path in
|
||||
// applyEnv) resolves the SAME active provider. A blank name CLEARS it.
|
||||
func TestSetActiveProviderEnvRoundTrips(t *testing.T) {
|
||||
t.Setenv(ActiveProviderEnv, "") // register cleanup; restores the original after
|
||||
|
||||
if err := os.Unsetenv(ActiveProviderEnv); err != nil {
|
||||
t.Fatalf("unset: %v", err)
|
||||
}
|
||||
SetActiveProviderEnv("") // blank with nothing set stays unset
|
||||
if got := os.Getenv(ActiveProviderEnv); got != "" {
|
||||
t.Fatalf("empty name set the env to %q, want unset", got)
|
||||
}
|
||||
|
||||
// Regression: switching from a named provider back to an unnamed/default profile
|
||||
// must CLEAR a previously exported value, not leave it stale for children.
|
||||
if err := os.Setenv(ActiveProviderEnv, "stale-provider"); err != nil {
|
||||
t.Fatalf("seed stale: %v", err)
|
||||
}
|
||||
SetActiveProviderEnv("") // blank must unset the stale value
|
||||
if got := os.Getenv(ActiveProviderEnv); got != "" {
|
||||
t.Fatalf("blank name left stale env %q, want cleared", got)
|
||||
}
|
||||
cfgStale := &FileConfig{}
|
||||
applyEnv(cfgStale, nil)
|
||||
if cfgStale.ActiveProvider != "" {
|
||||
t.Fatalf("applyEnv still resolved stale provider %q after clear", cfgStale.ActiveProvider)
|
||||
}
|
||||
|
||||
SetActiveProviderEnv("ollama-cloud")
|
||||
if got := os.Getenv(ActiveProviderEnv); got != "ollama-cloud" {
|
||||
t.Fatalf("env = %q, want ollama-cloud", got)
|
||||
}
|
||||
|
||||
// applyEnv with a nil map is the exact path a child uses: it falls back to
|
||||
// os.Getenv, so the inherited value selects the active provider.
|
||||
cfg := &FileConfig{}
|
||||
applyEnv(cfg, nil)
|
||||
if cfg.ActiveProvider != "ollama-cloud" {
|
||||
t.Fatalf("applyEnv ActiveProvider = %q, want ollama-cloud (inherited env not read)", cfg.ActiveProvider)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/credstore"
|
||||
)
|
||||
|
||||
// ProviderKeyStoreAt opens the encrypted credential store whose file backend lives
|
||||
// in dir. The backend resolves keyring-first, then encrypted-file, with a plaintext
|
||||
// opt-out via ZERO_CRED_STORAGE. The dir parameter exists so tests can point the file
|
||||
// backend at a temp directory; production always uses the user config directory
|
||||
// (ProviderKeyStore) because provider API keys are user-scoped by design — they are
|
||||
// only ever captured under the user config, never project config (a cloned repo must
|
||||
// not carry keys), so runtime lookups deliberately use the user store regardless of
|
||||
// where a provider profile was resolved from.
|
||||
func ProviderKeyStoreAt(dir string) (*credstore.Store, error) {
|
||||
return credstore.New(credstore.Options{Dir: dir})
|
||||
}
|
||||
|
||||
// ProviderKeyStore opens the credential store beside the default user config.
|
||||
func ProviderKeyStore() (*credstore.Store, error) {
|
||||
configPath, err := DefaultUserConfigPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ProviderKeyStoreAt(filepath.Dir(configPath))
|
||||
}
|
||||
|
||||
// APIKeyGetter is the read surface of the credential store; *credstore.Store
|
||||
// satisfies it. Kept here so callers can inject a fake in tests.
|
||||
type APIKeyGetter interface {
|
||||
Get(provider string) (string, bool, error)
|
||||
}
|
||||
|
||||
// APIKeySetter is the write surface of the credential store.
|
||||
type APIKeySetter interface {
|
||||
Set(provider, key string) error
|
||||
}
|
||||
|
||||
// SecureProviderProfile moves an inline APIKey on the profile into the credential
|
||||
// store co-located with configPath, returning a profile with APIKeyStored set and
|
||||
// APIKey cleared so the secret is never written to config.json. On any store error
|
||||
// (e.g. no keyring) it returns the profile unchanged — the inline key persists and
|
||||
// the startup migration retries later — so capturing a provider never fails on a
|
||||
// flaky credential backend.
|
||||
func SecureProviderProfile(profile ProviderProfile, configPath string) ProviderProfile {
|
||||
if strings.TrimSpace(profile.APIKey) == "" || strings.TrimSpace(profile.Name) == "" {
|
||||
return profile
|
||||
}
|
||||
store, err := ProviderKeyStoreAt(filepath.Dir(configPath))
|
||||
if err != nil {
|
||||
return profile
|
||||
}
|
||||
if err := store.Set(profile.Name, profile.APIKey); err != nil {
|
||||
return profile
|
||||
}
|
||||
secured := profile
|
||||
secured.APIKey = ""
|
||||
secured.APIKeyStored = true
|
||||
return secured
|
||||
}
|
||||
|
||||
// ForgetProviderKey removes a provider's stored API key from the credential store,
|
||||
// reporting whether one existed. Used by the lifecycle "remove key" / auth logout.
|
||||
func ForgetProviderKey(provider string) (bool, error) {
|
||||
store, err := ProviderKeyStore()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return store.Delete(provider)
|
||||
}
|
||||
|
||||
// ClearProviderKeyStored unsets the APIKeyStored marker for a provider in the
|
||||
// config at path, so credential checks no longer claim a stored key after one is
|
||||
// removed. No-op when path/provider is empty, the config is absent, or the marker
|
||||
// is already unset.
|
||||
func ClearProviderKeyStored(path, provider string) (bool, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
provider = strings.TrimSpace(provider)
|
||||
if path == "" || provider == "" {
|
||||
return false, nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
var cfg FileConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return false, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
changed := false
|
||||
for index := range cfg.Providers {
|
||||
if strings.EqualFold(strings.TrimSpace(cfg.Providers[index].Name), provider) && cfg.Providers[index].APIKeyStored {
|
||||
cfg.Providers[index].APIKeyStored = false
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return false, nil
|
||||
}
|
||||
return true, writeConfigFile(path, cfg)
|
||||
}
|
||||
|
||||
// MigratePlaintextProviderKeys moves any inline plaintext API key in the config at
|
||||
// path into the credential store, marking the profile APIKeyStored and stripping
|
||||
// the inline secret — but ONLY after the store write succeeds, so a failed Set
|
||||
// leaves the plaintext key in place and never strands a credential. Returns how
|
||||
// many keys were migrated; a no-op (0, nil) when path is empty/absent or nothing
|
||||
// needs migrating. Safe to run on every startup (idempotent).
|
||||
func MigratePlaintextProviderKeys(path string, store APIKeySetter) (int, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" || store == nil {
|
||||
return 0, nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
var cfg FileConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return 0, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
migrated := 0
|
||||
for index := range cfg.Providers {
|
||||
profile := &cfg.Providers[index]
|
||||
key := strings.TrimSpace(profile.APIKey)
|
||||
if key == "" || strings.TrimSpace(profile.Name) == "" {
|
||||
continue
|
||||
}
|
||||
if err := store.Set(profile.Name, key); err != nil {
|
||||
// Leave the plaintext key untouched; a failed Set must not strand it.
|
||||
continue
|
||||
}
|
||||
profile.APIKey = ""
|
||||
profile.APIKeyStored = true
|
||||
migrated++
|
||||
}
|
||||
if migrated == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if err := writeConfigFile(path, cfg); err != nil {
|
||||
return migrated, fmt.Errorf("rewrite config after migration %s: %w", path, err)
|
||||
}
|
||||
return migrated, nil
|
||||
}
|
||||
|
||||
// ApplyStoredAPIKey fills profile.APIKey from the credential store when it is not
|
||||
// already resolved — an inline config key or a resolved APIKeyEnv always wins, so
|
||||
// this only supplies a key for providers whose secret lives in the store. A nil
|
||||
// store, a miss, or a store error leaves the profile unchanged.
|
||||
func ApplyStoredAPIKey(profile ProviderProfile, store APIKeyGetter) ProviderProfile {
|
||||
// Only load for profiles that opted into stored-key auth (APIKeyStored). Without
|
||||
// this gate a stale keyring/file entry could silently reactivate credentials for a
|
||||
// profile that no longer uses the store.
|
||||
if store == nil || !profile.APIKeyStored || strings.TrimSpace(profile.APIKey) != "" {
|
||||
return profile
|
||||
}
|
||||
name := strings.TrimSpace(profile.Name)
|
||||
if name == "" {
|
||||
return profile
|
||||
}
|
||||
if key, ok, err := store.Get(name); err == nil && ok && strings.TrimSpace(key) != "" {
|
||||
profile.APIKey = key
|
||||
}
|
||||
return profile
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeKeyGetter struct {
|
||||
keys map[string]string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeKeyGetter) Get(provider string) (string, bool, error) {
|
||||
if f.err != nil {
|
||||
return "", false, f.err
|
||||
}
|
||||
v, ok := f.keys[provider]
|
||||
return v, ok, nil
|
||||
}
|
||||
|
||||
// fakeKeySetter records Set calls; setErr (when non-nil) makes Set fail.
|
||||
type fakeKeySetter struct {
|
||||
keys map[string]string
|
||||
setErr error
|
||||
}
|
||||
|
||||
func (f *fakeKeySetter) Set(provider, key string) error {
|
||||
if f.setErr != nil {
|
||||
return f.setErr
|
||||
}
|
||||
f.keys[provider] = key
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestApplyStoredAPIKey(t *testing.T) {
|
||||
store := fakeKeyGetter{keys: map[string]string{"openai": "sk-stored"}}
|
||||
|
||||
// Stored marker + empty key => filled from the store.
|
||||
got := ApplyStoredAPIKey(ProviderProfile{Name: "openai", APIKeyStored: true}, store)
|
||||
if got.APIKey != "sk-stored" {
|
||||
t.Fatalf("expected stored key to fill empty APIKey, got %q", got.APIKey)
|
||||
}
|
||||
|
||||
// No APIKeyStored marker => store is NOT consulted (don't reactivate a stale key).
|
||||
got = ApplyStoredAPIKey(ProviderProfile{Name: "openai"}, store)
|
||||
if got.APIKey != "" {
|
||||
t.Fatalf("expected no load without APIKeyStored, got %q", got.APIKey)
|
||||
}
|
||||
|
||||
// Inline key present => store is NOT consulted (inline wins).
|
||||
got = ApplyStoredAPIKey(ProviderProfile{Name: "openai", APIKeyStored: true, APIKey: "sk-inline"}, store)
|
||||
if got.APIKey != "sk-inline" {
|
||||
t.Fatalf("inline key must win, got %q", got.APIKey)
|
||||
}
|
||||
|
||||
// Marker set but no stored key for this provider => unchanged (empty).
|
||||
got = ApplyStoredAPIKey(ProviderProfile{Name: "anthropic", APIKeyStored: true}, store)
|
||||
if got.APIKey != "" {
|
||||
t.Fatalf("expected no key for unstored provider, got %q", got.APIKey)
|
||||
}
|
||||
|
||||
// Nil store => unchanged.
|
||||
got = ApplyStoredAPIKey(ProviderProfile{Name: "openai", APIKeyStored: true}, nil)
|
||||
if got.APIKey != "" {
|
||||
t.Fatalf("nil store must leave profile unchanged, got %q", got.APIKey)
|
||||
}
|
||||
|
||||
// Empty name => unchanged (don't query the store).
|
||||
got = ApplyStoredAPIKey(ProviderProfile{APIKeyStored: true}, store)
|
||||
if got.APIKey != "" {
|
||||
t.Fatalf("empty name must not be filled, got %q", got.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigratePlaintextProviderKeys(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.json")
|
||||
cfgJSON := `{"activeProvider":"openai","providers":[` +
|
||||
`{"name":"openai","apiKey":"sk-PLAINTEXT","model":"gpt"},` +
|
||||
`{"name":"local","baseURL":"http://localhost"},` +
|
||||
`{"name":"acme","apiKeyEnv":"ACME_KEY"}]}`
|
||||
if err := os.WriteFile(path, []byte(cfgJSON), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := &fakeKeySetter{keys: map[string]string{}}
|
||||
|
||||
n, err := MigratePlaintextProviderKeys(path, store)
|
||||
if err != nil || n != 1 {
|
||||
t.Fatalf("migrate = %d,%v; want 1,nil", n, err)
|
||||
}
|
||||
if store.keys["openai"] != "sk-PLAINTEXT" {
|
||||
t.Fatalf("key not moved to store: %v", store.keys)
|
||||
}
|
||||
raw, _ := os.ReadFile(path)
|
||||
if strings.Contains(string(raw), "sk-PLAINTEXT") {
|
||||
t.Fatalf("plaintext key still in config.json:\n%s", raw)
|
||||
}
|
||||
if !strings.Contains(string(raw), "apiKeyStored") {
|
||||
t.Fatalf("apiKeyStored marker not written:\n%s", raw)
|
||||
}
|
||||
// Idempotent: a second run migrates nothing.
|
||||
if n2, _ := MigratePlaintextProviderKeys(path, store); n2 != 0 {
|
||||
t.Fatalf("second migrate = %d, want 0", n2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateLeavesKeyWhenStoreSetFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"providers":[{"name":"openai","apiKey":"sk-KEEP"}]}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := &fakeKeySetter{keys: map[string]string{}, setErr: errors.New("keychain locked")}
|
||||
n, err := MigratePlaintextProviderKeys(path, store)
|
||||
if err != nil || n != 0 {
|
||||
t.Fatalf("migrate with failing store = %d,%v; want 0,nil", n, err)
|
||||
}
|
||||
raw, _ := os.ReadFile(path)
|
||||
if !strings.Contains(string(raw), "sk-KEEP") {
|
||||
t.Fatalf("a failed Set must not strand the key; config.json:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearProviderKeyStored(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"providers":[{"name":"openai","apiKeyStored":true},{"name":"other","apiKeyStored":true}]}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleared, err := ClearProviderKeyStored(path, "openai")
|
||||
if err != nil || !cleared {
|
||||
t.Fatalf("clear = %v,%v; want true,nil", cleared, err)
|
||||
}
|
||||
raw, _ := os.ReadFile(path)
|
||||
// openai's marker is gone; other's remains.
|
||||
var cfg FileConfig
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range cfg.Providers {
|
||||
if p.Name == "openai" && p.APIKeyStored {
|
||||
t.Fatal("openai marker should be cleared")
|
||||
}
|
||||
if p.Name == "other" && !p.APIKeyStored {
|
||||
t.Fatal("other marker should be untouched")
|
||||
}
|
||||
}
|
||||
// Idempotent: clearing again reports no change.
|
||||
if cleared, _ := ClearProviderKeyStored(path, "openai"); cleared {
|
||||
t.Fatal("second clear should report no change")
|
||||
}
|
||||
// Unknown provider: no change.
|
||||
if cleared, _ := ClearProviderKeyStored(path, "nope"); cleared {
|
||||
t.Fatal("unknown provider should report no change")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderProfileAPIKeyStoredRoundTrips(t *testing.T) {
|
||||
// The apiKeyStored marker survives JSON decode (custom UnmarshalJSON).
|
||||
var p ProviderProfile
|
||||
if err := p.UnmarshalJSON([]byte(`{"name":"openai","apiKeyStored":true}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !p.APIKeyStored {
|
||||
t.Fatal("expected apiKeyStored=true to decode")
|
||||
}
|
||||
if p.APIKey != "" {
|
||||
t.Fatalf("no inline key expected, got %q", p.APIKey)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveLocalControlFromUserConfig(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{
|
||||
"localControl": {
|
||||
"enabled": true,
|
||||
"browser": {"driver": "agent-browser"},
|
||||
"desktop": {"enabled": true},
|
||||
"terminal": {"enabled": false}
|
||||
}
|
||||
}`), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve returned error: %v", err)
|
||||
}
|
||||
if !resolved.LocalControl.Enabled {
|
||||
t.Fatal("localControl.enabled = false, want true")
|
||||
}
|
||||
if !resolved.LocalControl.BrowserEnabled() {
|
||||
t.Fatal("BrowserEnabled = false, want true by default under localControl.enabled")
|
||||
}
|
||||
if !resolved.LocalControl.DesktopEnabled() {
|
||||
t.Fatal("DesktopEnabled = false, want explicit true")
|
||||
}
|
||||
if resolved.LocalControl.TerminalEnabled() {
|
||||
t.Fatal("TerminalEnabled = true, want explicit false")
|
||||
}
|
||||
if resolved.LocalControl.Browser.Driver != "agent-browser" {
|
||||
t.Fatalf("browser driver = %q, want agent-browser", resolved.LocalControl.Browser.Driver)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLocalControlIgnoresProjectOptIn(t *testing.T) {
|
||||
projectPath := filepath.Join(t.TempDir(), "project.json")
|
||||
if err := os.WriteFile(projectPath, []byte(`{"localControl":{"enabled":true,"desktop":{"enabled":true}}}`), 0o600); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: projectPath})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve returned error: %v", err)
|
||||
}
|
||||
if resolved.LocalControl.Enabled || resolved.LocalControl.DesktopEnabled() {
|
||||
t.Fatalf("project config enabled gated local control: %#v", resolved.LocalControl)
|
||||
}
|
||||
if !resolved.LocalControl.BrowserEnabled() || !resolved.LocalControl.TerminalEnabled() {
|
||||
t.Fatalf("browser/terminal should remain available by default: %#v", resolved.LocalControl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalControlDefaultsBrowserAndTerminalOn(t *testing.T) {
|
||||
resolved, err := Resolve(ResolveOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve returned error: %v", err)
|
||||
}
|
||||
if !resolved.LocalControl.BrowserEnabled() {
|
||||
t.Fatal("BrowserEnabled = false, want default true")
|
||||
}
|
||||
if !resolved.LocalControl.TerminalEnabled() {
|
||||
t.Fatal("TerminalEnabled = false, want default true")
|
||||
}
|
||||
if resolved.LocalControl.DesktopEnabled() {
|
||||
t.Fatal("DesktopEnabled = true, want explicit opt-in")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalControlExplicitGlobalDisable(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"localControl":{"enabled":false}}`), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve returned error: %v", err)
|
||||
}
|
||||
if resolved.LocalControl.BrowserEnabled() || resolved.LocalControl.TerminalEnabled() || resolved.LocalControl.DesktopEnabled() {
|
||||
t.Fatalf("local control not disabled globally: %#v", resolved.LocalControl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalControlExplicitFalseSurvivesConfigRewrite(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{
|
||||
"activeProvider": "openai",
|
||||
"providers": [{"name": "openai", "provider_kind": "openai", "model": "gpt-4.1"}],
|
||||
"localControl": {
|
||||
"enabled": true,
|
||||
"browser": {"enabled": false},
|
||||
"terminal": {"enabled": false}
|
||||
}
|
||||
}`), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
if _, err := SetFavoriteModels(path, []string{"gpt-4.1"}); err != nil {
|
||||
t.Fatalf("SetFavoriteModels returned error: %v", err)
|
||||
}
|
||||
persisted := readConfigFixture(t, path)
|
||||
if !persisted.LocalControl.enabledSet || !persisted.LocalControl.Enabled {
|
||||
t.Fatalf("localControl enabled not preserved: %#v", persisted.LocalControl)
|
||||
}
|
||||
if !persisted.LocalControl.Browser.enabledSet || persisted.LocalControl.Browser.Enabled {
|
||||
t.Fatalf("browser explicit false not preserved: %#v", persisted.LocalControl.Browser)
|
||||
}
|
||||
if !persisted.LocalControl.Terminal.enabledSet || persisted.LocalControl.Terminal.Enabled {
|
||||
t.Fatalf("terminal explicit false not preserved: %#v", persisted.LocalControl.Terminal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyLocalControlIsOmittedFromConfigJSON(t *testing.T) {
|
||||
data, err := json.Marshal(FileConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal config: %v", err)
|
||||
}
|
||||
if strings.Contains(string(data), "localControl") {
|
||||
t.Fatalf("empty localControl should be omitted, got %s", string(data))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// SetMaxTurnsEnv must export the per-run turn budget so a child process (whose
|
||||
// resolution reads the inherited environment via the nil-map -> os.Getenv path in
|
||||
// applyEnv) runs with the SAME budget the user set via /turns. n <= 0 is a no-op,
|
||||
// and a non-numeric/zero env value must not clobber the configured budget.
|
||||
func TestSetMaxTurnsEnvRoundTrips(t *testing.T) {
|
||||
t.Setenv(MaxTurnsEnv, "") // register cleanup; restores the original after
|
||||
|
||||
if err := os.Unsetenv(MaxTurnsEnv); err != nil {
|
||||
t.Fatalf("unset: %v", err)
|
||||
}
|
||||
SetMaxTurnsEnv(0) // non-positive must not set anything
|
||||
if got := os.Getenv(MaxTurnsEnv); got != "" {
|
||||
t.Fatalf("n<=0 set the env to %q, want unset", got)
|
||||
}
|
||||
|
||||
SetMaxTurnsEnv(150)
|
||||
if got := os.Getenv(MaxTurnsEnv); got != "150" {
|
||||
t.Fatalf("env = %q, want 150", got)
|
||||
}
|
||||
|
||||
// applyEnv with a nil map is the exact path a child uses: it falls back to
|
||||
// os.Getenv, so the inherited value sets the budget.
|
||||
cfg := &FileConfig{MaxTurns: defaultMaxTurns}
|
||||
applyEnv(cfg, nil)
|
||||
if cfg.MaxTurns != 150 {
|
||||
t.Fatalf("applyEnv MaxTurns = %d, want 150 (inherited env not read)", cfg.MaxTurns)
|
||||
}
|
||||
|
||||
// A garbage env value must leave the configured budget untouched.
|
||||
if err := os.Setenv(MaxTurnsEnv, "not-a-number"); err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
}
|
||||
cfg2 := &FileConfig{MaxTurns: defaultMaxTurns}
|
||||
applyEnv(cfg2, nil)
|
||||
if cfg2.MaxTurns != defaultMaxTurns {
|
||||
t.Fatalf("garbage env clobbered MaxTurns to %d, want default %d", cfg2.MaxTurns, defaultMaxTurns)
|
||||
}
|
||||
|
||||
// An over-ceiling env value (e.g. a raw shell export that bypasses the /turns
|
||||
// clamp) must be clamped to MaxTurnsCeiling at the read site.
|
||||
if err := os.Setenv(MaxTurnsEnv, "999999"); err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
}
|
||||
cfg3 := &FileConfig{MaxTurns: defaultMaxTurns}
|
||||
applyEnv(cfg3, nil)
|
||||
if cfg3.MaxTurns != MaxTurnsCeiling {
|
||||
t.Fatalf("applyEnv MaxTurns = %d, want clamped to ceiling %d", cfg3.MaxTurns, MaxTurnsCeiling)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
@@ -13,7 +14,15 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
const defaultMaxTurns = 30
|
||||
// defaultMaxTurns is the per-run tool-turn budget when none is configured. 30 was
|
||||
// too low for real multi-step agentic work (agents ran out mid-task before reaching
|
||||
// later steps); 50 matches the old "deep" preset. Raise per-session with /turns.
|
||||
const defaultMaxTurns = 50
|
||||
|
||||
// MaxTurnsCeiling caps the per-run tool-turn budget so a stray env value or typo
|
||||
// can't set an absurd ceiling. Shared between applyEnv (read site) and the /turns
|
||||
// command (write site) so the bound holds even if the env is set by a raw shell.
|
||||
const MaxTurnsCeiling = 500
|
||||
|
||||
// defaultDeferThreshold is the number of deferred-eligible (MCP) tools at which
|
||||
// Zero collapses their full JSON schemas into compact `tool_search` reminder
|
||||
@@ -109,6 +118,7 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
Tools: cfg.Tools,
|
||||
Swarm: cfg.Swarm,
|
||||
Preferences: cfg.Preferences,
|
||||
LocalControl: cfg.LocalControl,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -182,6 +192,13 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
|
||||
if src.Preferences.FavoriteModels != nil {
|
||||
dst.Preferences.FavoriteModels = normalizeFavoriteModels(src.Preferences.FavoriteModels)
|
||||
}
|
||||
if src.Preferences.Recaps != nil {
|
||||
dst.Preferences.Recaps = src.Preferences.Recaps
|
||||
}
|
||||
if strings.TrimSpace(src.Preferences.Theme) != "" {
|
||||
dst.Preferences.Theme = strings.TrimSpace(src.Preferences.Theme)
|
||||
}
|
||||
mergeLocalControlConfig(&dst.LocalControl, src.LocalControl)
|
||||
}
|
||||
|
||||
func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
@@ -225,6 +242,9 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
if src.Swarm.MaxTeamSize != 0 {
|
||||
dst.Swarm.MaxTeamSize = src.Swarm.MaxTeamSize
|
||||
}
|
||||
// Local control is intentionally user-config/override only. A cloned project
|
||||
// must not be able to make browser, desktop, or terminal automation tools
|
||||
// appear in the model's tool surface.
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -423,11 +443,53 @@ func mergeProfile(base ProviderProfile, next ProviderProfile) ProviderProfile {
|
||||
return base
|
||||
}
|
||||
|
||||
// ActiveProviderEnv selects the active provider profile by name (read in applyEnv).
|
||||
const ActiveProviderEnv = "ZERO_PROVIDER"
|
||||
|
||||
// SetActiveProviderEnv exports the active provider name to the process environment
|
||||
// so a spawned child process (which inherits the environment) resolves the SAME
|
||||
// provider profile — and therefore the same credentials (env key / stored key /
|
||||
// OAuth) — as its parent. Without this a sub-agent re-resolves config.json's
|
||||
// default provider and can land on one whose credentials don't match the parent's
|
||||
// live selection, failing auth the instant it spawns. A blank name CLEARS the
|
||||
// variable: switching back to an unnamed/default profile must not keep exporting a
|
||||
// stale provider to children.
|
||||
func SetActiveProviderEnv(name string) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
_ = os.Unsetenv(ActiveProviderEnv)
|
||||
return
|
||||
}
|
||||
_ = os.Setenv(ActiveProviderEnv, name)
|
||||
}
|
||||
|
||||
// MaxTurnsEnv overrides the per-run tool-turn budget by name (read in applyEnv).
|
||||
const MaxTurnsEnv = "ZERO_MAX_TURNS"
|
||||
|
||||
// SetMaxTurnsEnv exports the per-run tool-turn budget to the process environment so
|
||||
// a spawned child (sub-agent / swarm member, which inherits the environment) runs
|
||||
// with the SAME budget the user set via /turns. Without it a child re-resolves
|
||||
// config.json's default and a large delegated task can exhaust its turns mid-run
|
||||
// (exit 4 / max-turns). No-op for n <= 0.
|
||||
func SetMaxTurnsEnv(n int) {
|
||||
if n > 0 {
|
||||
_ = os.Setenv(MaxTurnsEnv, strconv.Itoa(n))
|
||||
}
|
||||
}
|
||||
|
||||
func applyEnv(cfg *FileConfig, env map[string]string) {
|
||||
activeProvider := strings.TrimSpace(envValue(env, "ZERO_PROVIDER"))
|
||||
activeProvider := strings.TrimSpace(envValue(env, ActiveProviderEnv))
|
||||
if activeProvider != "" {
|
||||
cfg.ActiveProvider = activeProvider
|
||||
}
|
||||
if maxTurns := strings.TrimSpace(envValue(env, MaxTurnsEnv)); maxTurns != "" {
|
||||
if n, err := strconv.Atoi(maxTurns); err == nil && n > 0 {
|
||||
if n > MaxTurnsCeiling {
|
||||
n = MaxTurnsCeiling
|
||||
}
|
||||
cfg.MaxTurns = n
|
||||
}
|
||||
}
|
||||
|
||||
applyProviderEnv(cfg, ProviderKindOpenAI, envProfile{
|
||||
Name: string(ProviderKindOpenAI),
|
||||
@@ -555,6 +617,7 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
|
||||
cfg.Tools.DeferThreshold = overrides.Tools.DeferThreshold
|
||||
cfg.Tools.deferThresholdSet = true
|
||||
}
|
||||
mergeLocalControlConfig(&cfg.LocalControl, overrides.LocalControl)
|
||||
for _, provider := range overrides.Providers {
|
||||
mergeProvider(cfg, provider)
|
||||
}
|
||||
@@ -564,6 +627,32 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
|
||||
mergeMCPConfig(&cfg.MCP, overrides.MCP)
|
||||
}
|
||||
|
||||
func mergeLocalControlConfig(dst *LocalControlConfig, src LocalControlConfig) {
|
||||
if src.enabledSet {
|
||||
dst.Enabled = src.Enabled
|
||||
dst.enabledSet = true
|
||||
}
|
||||
mergeLocalControlDriverConfig(&dst.Browser, src.Browser)
|
||||
mergeLocalControlDriverConfig(&dst.Desktop, src.Desktop)
|
||||
mergeLocalControlDriverConfig(&dst.Terminal, src.Terminal)
|
||||
if artifactsDir := strings.TrimSpace(src.ArtifactsDir); artifactsDir != "" {
|
||||
dst.ArtifactsDir = artifactsDir
|
||||
}
|
||||
}
|
||||
|
||||
func mergeLocalControlDriverConfig(dst *LocalControlDriverConfig, src LocalControlDriverConfig) {
|
||||
if src.enabledSet {
|
||||
dst.Enabled = src.Enabled
|
||||
dst.enabledSet = true
|
||||
}
|
||||
if helperPath := strings.TrimSpace(src.HelperPath); helperPath != "" {
|
||||
dst.HelperPath = helperPath
|
||||
}
|
||||
if driver := strings.TrimSpace(src.Driver); driver != "" {
|
||||
dst.Driver = driver
|
||||
}
|
||||
}
|
||||
|
||||
func mergeMCPConfig(dst *MCPConfig, src MCPConfig) {
|
||||
if len(src.Servers) == 0 {
|
||||
return
|
||||
|
||||
@@ -883,8 +883,8 @@ func TestResolveAllowsNoConfiguredProviders(t *testing.T) {
|
||||
if HasProviderProfile(resolved.Provider) {
|
||||
t.Fatalf("Provider = %#v, want zero value", resolved.Provider)
|
||||
}
|
||||
if resolved.MaxTurns != 30 {
|
||||
t.Fatalf("MaxTurns = %d, want 30", resolved.MaxTurns)
|
||||
if resolved.MaxTurns != defaultMaxTurns {
|
||||
t.Fatalf("MaxTurns = %d, want default %d", resolved.MaxTurns, defaultMaxTurns)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+211
-16
@@ -21,13 +21,18 @@ const (
|
||||
)
|
||||
|
||||
type ProviderProfile struct {
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
ProviderKind ProviderKind `json:"provider_kind,omitempty"`
|
||||
CatalogID string `json:"catalogID,omitempty"`
|
||||
BaseURL string `json:"baseURL,omitempty"`
|
||||
APIKey string `json:"apiKey,omitempty"`
|
||||
APIKeyEnv string `json:"apiKeyEnv,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
ProviderKind ProviderKind `json:"provider_kind,omitempty"`
|
||||
CatalogID string `json:"catalogID,omitempty"`
|
||||
BaseURL string `json:"baseURL,omitempty"`
|
||||
APIKey string `json:"apiKey,omitempty"`
|
||||
APIKeyEnv string `json:"apiKeyEnv,omitempty"`
|
||||
// APIKeyStored marks that this provider's API key lives in the encrypted
|
||||
// credential store (internal/credstore), not inline in APIKey. The effective
|
||||
// key is loaded from the store at provider-build time; config.json holds only
|
||||
// this marker, never the secret.
|
||||
APIKeyStored bool `json:"apiKeyStored,omitempty"`
|
||||
APIFormat string `json:"apiFormat,omitempty"`
|
||||
AuthHeader string `json:"authHeader,omitempty"`
|
||||
AuthScheme string `json:"authScheme,omitempty"`
|
||||
@@ -92,6 +97,70 @@ type ToolsConfig struct {
|
||||
|
||||
type PreferencesConfig struct {
|
||||
FavoriteModels []string `json:"favoriteModels,omitempty"`
|
||||
// Theme is the persisted TUI palette preference — "auto" or a registered theme
|
||||
// name (e.g. "dracula"). Applied at startup below the --theme flag and
|
||||
// ZERO_THEME, so a /theme choice survives restart. Empty = unset (defaults auto).
|
||||
Theme string `json:"theme,omitempty"`
|
||||
// Recaps is a tri-state: nil (unset) defaults to ON; an explicit false means
|
||||
// the user turned post-turn recaps off. A *bool is its own tri-state, so no
|
||||
// custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int).
|
||||
Recaps *bool `json:"recaps,omitempty"`
|
||||
}
|
||||
|
||||
// RecapsEnabled reports whether post-turn recaps are on. Unset defaults to ON.
|
||||
func (p PreferencesConfig) RecapsEnabled() bool {
|
||||
return p.Recaps == nil || *p.Recaps
|
||||
}
|
||||
|
||||
// LocalControlConfig controls local browser/desktop/terminal automation helpers.
|
||||
// Helpers are discovered lazily by the tool that needs them; no setup command or
|
||||
// background probe is required during startup.
|
||||
type LocalControlConfig struct {
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
Browser LocalControlDriverConfig `json:"browser,omitempty"`
|
||||
Desktop LocalControlDriverConfig `json:"desktop,omitempty"`
|
||||
Terminal LocalControlDriverConfig `json:"terminal,omitempty"`
|
||||
ArtifactsDir string `json:"artifactsDir,omitempty"`
|
||||
enabledSet bool
|
||||
}
|
||||
|
||||
type LocalControlDriverConfig struct {
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
HelperPath string `json:"helperPath,omitempty"`
|
||||
Driver string `json:"driver,omitempty"`
|
||||
enabledSet bool
|
||||
}
|
||||
|
||||
func (cfg LocalControlConfig) BrowserEnabled() bool {
|
||||
return !cfg.Disabled() && (!cfg.Browser.enabledSet || cfg.Browser.Enabled)
|
||||
}
|
||||
|
||||
func (cfg LocalControlConfig) DesktopEnabled() bool {
|
||||
return !cfg.Disabled() && cfg.Desktop.enabledSet && cfg.Desktop.Enabled
|
||||
}
|
||||
|
||||
func (cfg LocalControlConfig) TerminalEnabled() bool {
|
||||
return !cfg.Disabled() && (!cfg.Terminal.enabledSet || cfg.Terminal.Enabled)
|
||||
}
|
||||
|
||||
func (cfg LocalControlConfig) Disabled() bool {
|
||||
return cfg.enabledSet && !cfg.Enabled
|
||||
}
|
||||
|
||||
func (cfg LocalControlConfig) Empty() bool {
|
||||
return !cfg.Enabled &&
|
||||
!cfg.enabledSet &&
|
||||
cfg.ArtifactsDir == "" &&
|
||||
cfg.Browser.Empty() &&
|
||||
cfg.Desktop.Empty() &&
|
||||
cfg.Terminal.Empty()
|
||||
}
|
||||
|
||||
func (cfg LocalControlDriverConfig) Empty() bool {
|
||||
return !cfg.Enabled &&
|
||||
!cfg.enabledSet &&
|
||||
cfg.HelperPath == "" &&
|
||||
cfg.Driver == ""
|
||||
}
|
||||
|
||||
// SwarmConfig tunes the multi-agent swarm. MaxTeamSize caps how many members run
|
||||
@@ -128,15 +197,46 @@ func (cfg *ToolsConfig) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
type FileConfig struct {
|
||||
ActiveProvider string `json:"activeProvider,omitempty"`
|
||||
Providers []ProviderProfile `json:"providers,omitempty"`
|
||||
MaxTurns int `json:"maxTurns,omitempty"`
|
||||
MCP MCPConfig `json:"mcp,omitempty"`
|
||||
Sandbox SandboxConfig `json:"sandbox,omitempty"`
|
||||
Notify NotifyConfig `json:"notify,omitempty"`
|
||||
Tools ToolsConfig `json:"tools,omitempty"`
|
||||
Swarm SwarmConfig `json:"swarm,omitempty"`
|
||||
Preferences PreferencesConfig `json:"preferences,omitempty"`
|
||||
ActiveProvider string `json:"activeProvider,omitempty"`
|
||||
Providers []ProviderProfile `json:"providers,omitempty"`
|
||||
MaxTurns int `json:"maxTurns,omitempty"`
|
||||
MCP MCPConfig `json:"mcp,omitempty"`
|
||||
Sandbox SandboxConfig `json:"sandbox,omitempty"`
|
||||
Notify NotifyConfig `json:"notify,omitempty"`
|
||||
Tools ToolsConfig `json:"tools,omitempty"`
|
||||
Swarm SwarmConfig `json:"swarm,omitempty"`
|
||||
Preferences PreferencesConfig `json:"preferences,omitempty"`
|
||||
LocalControl LocalControlConfig `json:"localControl,omitempty"`
|
||||
}
|
||||
|
||||
func (cfg FileConfig) MarshalJSON() ([]byte, error) {
|
||||
type rawConfig struct {
|
||||
ActiveProvider string `json:"activeProvider,omitempty"`
|
||||
Providers []ProviderProfile `json:"providers,omitempty"`
|
||||
MaxTurns int `json:"maxTurns,omitempty"`
|
||||
MCP MCPConfig `json:"mcp,omitempty"`
|
||||
Sandbox SandboxConfig `json:"sandbox,omitempty"`
|
||||
Notify NotifyConfig `json:"notify,omitempty"`
|
||||
Tools ToolsConfig `json:"tools,omitempty"`
|
||||
Swarm SwarmConfig `json:"swarm,omitempty"`
|
||||
Preferences PreferencesConfig `json:"preferences,omitempty"`
|
||||
LocalControl *LocalControlConfig `json:"localControl,omitempty"`
|
||||
}
|
||||
raw := rawConfig{
|
||||
ActiveProvider: cfg.ActiveProvider,
|
||||
Providers: cfg.Providers,
|
||||
MaxTurns: cfg.MaxTurns,
|
||||
MCP: cfg.MCP,
|
||||
Sandbox: cfg.Sandbox,
|
||||
Notify: cfg.Notify,
|
||||
Tools: cfg.Tools,
|
||||
Swarm: cfg.Swarm,
|
||||
Preferences: cfg.Preferences,
|
||||
}
|
||||
if !cfg.LocalControl.Empty() {
|
||||
raw.LocalControl = &cfg.LocalControl
|
||||
}
|
||||
return json.Marshal(raw)
|
||||
}
|
||||
|
||||
type ResolveOptions struct {
|
||||
@@ -156,6 +256,7 @@ type Overrides struct {
|
||||
Sandbox SandboxConfig
|
||||
Notify NotifyConfig
|
||||
Tools ToolsConfig
|
||||
LocalControl LocalControlConfig
|
||||
}
|
||||
|
||||
type ResolvedConfig struct {
|
||||
@@ -169,6 +270,7 @@ type ResolvedConfig struct {
|
||||
Tools ToolsConfig
|
||||
Swarm SwarmConfig
|
||||
Preferences PreferencesConfig
|
||||
LocalControl LocalControlConfig
|
||||
}
|
||||
|
||||
type MCPConfig struct {
|
||||
@@ -214,6 +316,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
Tools ToolsConfig `json:"tools"`
|
||||
Swarm SwarmConfig `json:"swarm"`
|
||||
Preferences PreferencesConfig `json:"preferences"`
|
||||
LocalControl LocalControlConfig `json:"localControl"`
|
||||
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
|
||||
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
|
||||
}
|
||||
@@ -239,6 +342,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
cfg.Tools = raw.Tools
|
||||
cfg.Swarm = raw.Swarm
|
||||
cfg.Preferences = raw.Preferences
|
||||
cfg.LocalControl = raw.LocalControl
|
||||
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
|
||||
cfg.MCP.Servers = map[string]MCPServerConfig{}
|
||||
}
|
||||
@@ -254,6 +358,94 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *LocalControlConfig) UnmarshalJSON(data []byte) error {
|
||||
type rawLocalControl struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Browser LocalControlDriverConfig `json:"browser"`
|
||||
Desktop LocalControlDriverConfig `json:"desktop"`
|
||||
Terminal LocalControlDriverConfig `json:"terminal"`
|
||||
ArtifactsDir string `json:"artifactsDir"`
|
||||
}
|
||||
var raw rawLocalControl
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
*cfg = LocalControlConfig{
|
||||
Browser: raw.Browser,
|
||||
Desktop: raw.Desktop,
|
||||
Terminal: raw.Terminal,
|
||||
ArtifactsDir: raw.ArtifactsDir,
|
||||
}
|
||||
if raw.Enabled != nil {
|
||||
cfg.Enabled = *raw.Enabled
|
||||
cfg.enabledSet = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg LocalControlConfig) MarshalJSON() ([]byte, error) {
|
||||
type rawLocalControl struct {
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Browser *LocalControlDriverConfig `json:"browser,omitempty"`
|
||||
Desktop *LocalControlDriverConfig `json:"desktop,omitempty"`
|
||||
Terminal *LocalControlDriverConfig `json:"terminal,omitempty"`
|
||||
ArtifactsDir string `json:"artifactsDir,omitempty"`
|
||||
}
|
||||
raw := rawLocalControl{
|
||||
ArtifactsDir: cfg.ArtifactsDir,
|
||||
}
|
||||
if cfg.enabledSet || cfg.Enabled {
|
||||
raw.Enabled = &cfg.Enabled
|
||||
}
|
||||
if !cfg.Browser.Empty() {
|
||||
raw.Browser = &cfg.Browser
|
||||
}
|
||||
if !cfg.Desktop.Empty() {
|
||||
raw.Desktop = &cfg.Desktop
|
||||
}
|
||||
if !cfg.Terminal.Empty() {
|
||||
raw.Terminal = &cfg.Terminal
|
||||
}
|
||||
return json.Marshal(raw)
|
||||
}
|
||||
|
||||
func (cfg *LocalControlDriverConfig) UnmarshalJSON(data []byte) error {
|
||||
type rawDriver struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
HelperPath string `json:"helperPath"`
|
||||
Driver string `json:"driver"`
|
||||
}
|
||||
var raw rawDriver
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
*cfg = LocalControlDriverConfig{
|
||||
HelperPath: raw.HelperPath,
|
||||
Driver: raw.Driver,
|
||||
}
|
||||
if raw.Enabled != nil {
|
||||
cfg.Enabled = *raw.Enabled
|
||||
cfg.enabledSet = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg LocalControlDriverConfig) MarshalJSON() ([]byte, error) {
|
||||
type rawDriver struct {
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
HelperPath string `json:"helperPath,omitempty"`
|
||||
Driver string `json:"driver,omitempty"`
|
||||
}
|
||||
raw := rawDriver{
|
||||
HelperPath: cfg.HelperPath,
|
||||
Driver: cfg.Driver,
|
||||
}
|
||||
if cfg.enabledSet || cfg.Enabled {
|
||||
raw.Enabled = &cfg.Enabled
|
||||
}
|
||||
return json.Marshal(raw)
|
||||
}
|
||||
|
||||
func (server *MCPServerConfig) UnmarshalJSON(data []byte) error {
|
||||
type rawServer struct {
|
||||
Type string `json:"type"`
|
||||
@@ -302,6 +494,8 @@ func (profile *ProviderProfile) UnmarshalJSON(data []byte) error {
|
||||
APIKeySnake string `json:"api_key"`
|
||||
APIKeyEnv string `json:"apiKeyEnv"`
|
||||
APIKeyEnvSnake string `json:"api_key_env"`
|
||||
APIKeyStored bool `json:"apiKeyStored"`
|
||||
APIKeyStoredSnake bool `json:"api_key_stored"`
|
||||
APIFormat string `json:"apiFormat"`
|
||||
APIFormatSnake string `json:"api_format"`
|
||||
AuthHeader string `json:"authHeader"`
|
||||
@@ -331,6 +525,7 @@ func (profile *ProviderProfile) UnmarshalJSON(data []byte) error {
|
||||
profile.BaseURL = strings.TrimSpace(firstNonEmpty(raw.BaseURL, raw.BaseURLSnake))
|
||||
profile.APIKey = firstNonEmpty(raw.APIKey, raw.APIKeySnake)
|
||||
profile.APIKeyEnv = strings.TrimSpace(firstNonEmpty(raw.APIKeyEnv, raw.APIKeyEnvSnake))
|
||||
profile.APIKeyStored = raw.APIKeyStored || raw.APIKeyStoredSnake
|
||||
profile.APIFormat = strings.TrimSpace(firstNonEmpty(raw.APIFormat, raw.APIFormatSnake))
|
||||
profile.AuthHeader = strings.TrimSpace(firstNonEmpty(raw.AuthHeader, raw.AuthHeaderSnake))
|
||||
profile.AuthScheme = strings.TrimSpace(firstNonEmpty(raw.AuthScheme, raw.AuthSchemeSnake))
|
||||
|
||||
@@ -131,6 +131,51 @@ func SetFavoriteModels(path string, models []string) (FileConfig, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SetRecapsEnabled persists the post-turn recap preference, mirroring
|
||||
// SetFavoriteModels (read-modify-atomic-write).
|
||||
func SetRecapsEnabled(path string, enabled bool) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
cfg := FileConfig{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
v := enabled
|
||||
cfg.Preferences.Recaps = &v
|
||||
if err := writeConfigFile(path, cfg); err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SetTheme persists the TUI theme preference, mirroring SetFavoriteModels
|
||||
// (read-modify-atomic-write). A blank theme clears the stored preference.
|
||||
func SetTheme(path string, theme string) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
cfg := FileConfig{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
cfg.Preferences.Theme = strings.TrimSpace(theme)
|
||||
if err := writeConfigFile(path, cfg); err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func normalizeFavoriteModels(models []string) []string {
|
||||
seen := map[string]bool{}
|
||||
favorites := make([]string, 0, len(models))
|
||||
|
||||
@@ -272,6 +272,77 @@ func TestSetFavoriteModelsPersistsUserPreferences(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetThemePersistsUserPreference(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "openai",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "openai", ProviderKind: ProviderKindOpenAI, Model: "gpt-4.1"},
|
||||
},
|
||||
}, 0o600)
|
||||
|
||||
cfg, err := SetTheme(path, " dracula ")
|
||||
if err != nil {
|
||||
t.Fatalf("SetTheme() error = %v", err)
|
||||
}
|
||||
if cfg.Preferences.Theme != "dracula" {
|
||||
t.Fatalf("Theme = %q, want dracula (trimmed)", cfg.Preferences.Theme)
|
||||
}
|
||||
persisted := readConfigFixture(t, path)
|
||||
if persisted.Preferences.Theme != "dracula" {
|
||||
t.Fatalf("persisted Theme = %q, want dracula", persisted.Preferences.Theme)
|
||||
}
|
||||
if persisted.ActiveProvider != "openai" || len(persisted.Providers) != 1 {
|
||||
t.Fatalf("provider config was not preserved by SetTheme: %#v", persisted)
|
||||
}
|
||||
|
||||
// A blank value clears the stored preference.
|
||||
if cfg, err = SetTheme(path, ""); err != nil {
|
||||
t.Fatalf("SetTheme(\"\") error = %v", err)
|
||||
}
|
||||
if cfg.Preferences.Theme != "" {
|
||||
t.Fatalf("SetTheme(\"\") should clear the theme, got %q", cfg.Preferences.Theme)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecapsPreferenceRoundTrips(t *testing.T) {
|
||||
// Default (unset) is ON.
|
||||
if !(PreferencesConfig{}).RecapsEnabled() {
|
||||
t.Error("unset recaps should default to ON")
|
||||
}
|
||||
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{ActiveProvider: "openai"}, 0o600)
|
||||
|
||||
// Persist OFF, then read it back.
|
||||
cfg, err := SetRecapsEnabled(path, false)
|
||||
if err != nil {
|
||||
t.Fatalf("SetRecapsEnabled(false) error = %v", err)
|
||||
}
|
||||
if cfg.Preferences.RecapsEnabled() {
|
||||
t.Error("after SetRecapsEnabled(false), RecapsEnabled() should be false")
|
||||
}
|
||||
persisted := readConfigFixture(t, path)
|
||||
if persisted.Preferences.Recaps == nil || *persisted.Preferences.Recaps {
|
||||
t.Errorf("persisted recaps should be explicit false, got %v", persisted.Preferences.Recaps)
|
||||
}
|
||||
if persisted.ActiveProvider != "openai" {
|
||||
t.Errorf("unrelated config must be preserved, got %q", persisted.ActiveProvider)
|
||||
}
|
||||
|
||||
// Flip back ON — the write must succeed and persist an explicit true.
|
||||
cfg, err = SetRecapsEnabled(path, true)
|
||||
if err != nil {
|
||||
t.Fatalf("SetRecapsEnabled(true) error = %v", err)
|
||||
}
|
||||
if !cfg.Preferences.RecapsEnabled() {
|
||||
t.Error("after SetRecapsEnabled(true), RecapsEnabled() should be true")
|
||||
}
|
||||
if reread := readConfigFixture(t, path); reread.Preferences.Recaps == nil || !*reread.Preferences.Recaps {
|
||||
t.Errorf("re-enable should persist an explicit true, got %v", reread.Preferences.Recaps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetFavoriteModelsCreatesMissingConfig(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
|
||||
|
||||
@@ -221,7 +221,12 @@ func estimateRegistryTools(registry *tools.Registry) (int, int) {
|
||||
return all[left].Name() < all[right].Name()
|
||||
})
|
||||
total := 0
|
||||
count := 0
|
||||
for _, tool := range all {
|
||||
if tool.Safety().Permission == tools.PermissionDeny {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
total += estimateTextTokens(tool.Name())
|
||||
total += estimateTextTokens(tool.Description())
|
||||
if encoded, err := json.Marshal(tool.Parameters()); err == nil {
|
||||
@@ -229,7 +234,7 @@ func estimateRegistryTools(registry *tools.Registry) (int, int) {
|
||||
}
|
||||
total += toolDefinitionOverheadTokens
|
||||
}
|
||||
return len(all), total
|
||||
return count, total
|
||||
}
|
||||
|
||||
func readProjectGuidelines(root string, names []string) (string, string) {
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
// Package credstore persists provider API keys outside config.json so secrets are
|
||||
// not written to disk in cleartext. It resolves one backend at construction: the OS
|
||||
// keyring on macOS (the `security` keychain), otherwise an AES-256-GCM encrypted file
|
||||
// (the Linux secret-tool keyring needs a running secret service that is absent on
|
||||
// headless/CI machines, so it is opt-in via ZERO_CRED_STORAGE=keyring). A plaintext
|
||||
// file is available only as an explicit ZERO_CRED_STORAGE=file opt-out. OAuth tokens
|
||||
// stay in internal/oauth; this store is for raw API keys.
|
||||
package credstore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/keyring"
|
||||
"github.com/Gitlawb/zero/internal/securefile"
|
||||
)
|
||||
|
||||
const (
|
||||
keyringService = "zero"
|
||||
keyringPrefix = "apikey:" // keyring account = "apikey:<provider>"
|
||||
|
||||
encryptedFileName = "credentials.enc"
|
||||
plaintextFileName = "credentials.json"
|
||||
)
|
||||
|
||||
// KeyringClient is the minimal OS-keyring surface the store needs;
|
||||
// *keyring.Keyring satisfies it. Injectable for tests.
|
||||
type KeyringClient interface {
|
||||
Available() bool
|
||||
Set(service, account, secret string) error
|
||||
Get(service, account string) (string, bool, error)
|
||||
Delete(service, account string) (bool, error)
|
||||
}
|
||||
|
||||
// Options configures the credential store.
|
||||
type Options struct {
|
||||
// Dir is the per-user data directory for the file backends.
|
||||
Dir string
|
||||
// Storage selects the backend: "" (auto), "keyring", "encrypted-file", or "file"
|
||||
// (plaintext opt-out). When empty it falls back to ZERO_CRED_STORAGE, then auto:
|
||||
// keyring on macOS (the `security` keychain is reliable and needs no daemon),
|
||||
// encrypted-file everywhere else (Linux `secret-tool` needs a running secret
|
||||
// service that is absent on headless/CI boxes and would block/error). Linux/other
|
||||
// users can still opt into the keyring with ZERO_CRED_STORAGE=keyring.
|
||||
Storage string
|
||||
// Keyring is the keyring client; nil => keyring.New().
|
||||
Keyring KeyringClient
|
||||
// Env reads environment variables; nil => os.Getenv.
|
||||
Env func(string) string
|
||||
// GOOS overrides the platform for auto backend resolution; "" => runtime.GOOS.
|
||||
// Exists so tests can exercise the per-OS default deterministically.
|
||||
GOOS string
|
||||
}
|
||||
|
||||
// Store reads/writes provider API keys via the resolved backend.
|
||||
type Store struct {
|
||||
backend string // "keyring", "encrypted-file", or "file"
|
||||
kr KeyringClient
|
||||
file string
|
||||
crypter *securefile.Crypter // non-nil only for "encrypted-file"
|
||||
}
|
||||
|
||||
// New resolves the backend. It never silently downgrades to plaintext: "file" must
|
||||
// be chosen explicitly; auto mode picks keyring then encrypted-file.
|
||||
func New(options Options) (*Store, error) {
|
||||
env := options.Env
|
||||
if env == nil {
|
||||
env = os.Getenv
|
||||
}
|
||||
kr := options.Keyring
|
||||
if kr == nil {
|
||||
kr = keyring.New()
|
||||
}
|
||||
goos := strings.TrimSpace(options.GOOS)
|
||||
if goos == "" {
|
||||
goos = runtime.GOOS
|
||||
}
|
||||
storage := strings.TrimSpace(options.Storage)
|
||||
if storage == "" {
|
||||
storage = strings.TrimSpace(env("ZERO_CRED_STORAGE"))
|
||||
}
|
||||
if storage == "" {
|
||||
// Keyring is the default only on macOS, where `security` is reliable and needs
|
||||
// no running daemon. Elsewhere (Linux secret-tool, headless/CI) default to the
|
||||
// encrypted file; keyring stays available via an explicit ZERO_CRED_STORAGE.
|
||||
if goos == "darwin" && kr.Available() {
|
||||
storage = "keyring"
|
||||
} else {
|
||||
storage = "encrypted-file"
|
||||
}
|
||||
}
|
||||
|
||||
switch storage {
|
||||
case "keyring":
|
||||
if !kr.Available() {
|
||||
return nil, fmt.Errorf("credstore: keyring requested but no OS keyring backend on this platform; use encrypted-file")
|
||||
}
|
||||
return &Store{backend: "keyring", kr: kr}, nil
|
||||
case "encrypted-file":
|
||||
if strings.TrimSpace(options.Dir) == "" {
|
||||
return nil, fmt.Errorf("credstore: Dir is required for encrypted-file storage")
|
||||
}
|
||||
path := filepath.Join(options.Dir, encryptedFileName)
|
||||
return &Store{backend: "encrypted-file", file: path, crypter: securefile.NewCrypter(path + ".secret")}, nil
|
||||
case "file":
|
||||
if strings.TrimSpace(options.Dir) == "" {
|
||||
return nil, fmt.Errorf("credstore: Dir is required for file storage")
|
||||
}
|
||||
return &Store{backend: "file", file: filepath.Join(options.Dir, plaintextFileName)}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("credstore: unknown storage %q (want \"keyring\", \"encrypted-file\", or \"file\")", storage)
|
||||
}
|
||||
}
|
||||
|
||||
// Backend reports the resolved backend ("keyring", "encrypted-file", "file"), for
|
||||
// display ("how is this key stored").
|
||||
func (s *Store) Backend() string { return s.backend }
|
||||
|
||||
// Encrypted reports whether secrets are protected at rest (keyring or AES file).
|
||||
func (s *Store) Encrypted() bool { return s.backend == "keyring" || s.backend == "encrypted-file" }
|
||||
|
||||
// Set stores (or overwrites) the API key for provider.
|
||||
func (s *Store) Set(provider, key string) error {
|
||||
provider = normalizeProvider(provider)
|
||||
if provider == "" {
|
||||
return fmt.Errorf("credstore: provider is required")
|
||||
}
|
||||
if s.backend == "keyring" {
|
||||
return s.kr.Set(keyringService, keyringPrefix+provider, key)
|
||||
}
|
||||
data, err := s.read()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data[provider] = key
|
||||
return s.write(data)
|
||||
}
|
||||
|
||||
// Get returns the API key for provider and whether one is stored.
|
||||
func (s *Store) Get(provider string) (string, bool, error) {
|
||||
provider = normalizeProvider(provider)
|
||||
if provider == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
if s.backend == "keyring" {
|
||||
return s.kr.Get(keyringService, keyringPrefix+provider)
|
||||
}
|
||||
data, err := s.read()
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
key, ok := data[provider]
|
||||
return key, ok, nil
|
||||
}
|
||||
|
||||
// Delete removes the API key for provider, reporting whether one existed.
|
||||
func (s *Store) Delete(provider string) (bool, error) {
|
||||
provider = normalizeProvider(provider)
|
||||
if provider == "" {
|
||||
return false, nil
|
||||
}
|
||||
if s.backend == "keyring" {
|
||||
return s.kr.Delete(keyringService, keyringPrefix+provider)
|
||||
}
|
||||
data, err := s.read()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, ok := data[provider]; !ok {
|
||||
return false, nil
|
||||
}
|
||||
delete(data, provider)
|
||||
return true, s.write(data)
|
||||
}
|
||||
|
||||
// Providers lists providers with a stored key (sorted), for display/audit.
|
||||
func (s *Store) Providers() ([]string, error) {
|
||||
if s.backend == "keyring" {
|
||||
// The OS keyring has no enumerate-by-prefix in our minimal surface; callers
|
||||
// that need a list pass known provider names to Get. Return empty here.
|
||||
return nil, nil
|
||||
}
|
||||
data, err := s.read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(data))
|
||||
for name := range data {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// --- file backends (encrypted-file / file) ---------------------------------
|
||||
|
||||
func (s *Store) read() (map[string]string, error) {
|
||||
raw, err := os.ReadFile(s.file)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("credstore: read %s: %w", s.file, err)
|
||||
}
|
||||
if s.crypter != nil {
|
||||
raw, err = s.crypter.Open(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
data := map[string]string{}
|
||||
if len(raw) == 0 {
|
||||
return data, nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return nil, fmt.Errorf("credstore: parse %s: %w", s.file, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (s *Store) write(data map[string]string) error {
|
||||
payload, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("credstore: encode: %w", err)
|
||||
}
|
||||
if s.crypter != nil {
|
||||
payload, err = s.crypter.Seal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(s.file), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(s.file), filepath.Base(s.file)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("credstore: temp file: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
if err := tmp.Chmod(0o600); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("credstore: chmod: %w", err)
|
||||
}
|
||||
if _, err := tmp.Write(payload); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("credstore: write: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("credstore: write: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, s.file); err != nil {
|
||||
return fmt.Errorf("credstore: publish: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeProvider(provider string) string {
|
||||
return strings.ToLower(strings.TrimSpace(provider))
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package credstore
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeKeyring is an in-memory KeyringClient so tests never touch the real OS
|
||||
// keychain.
|
||||
type fakeKeyring struct {
|
||||
available bool
|
||||
data map[string]string
|
||||
}
|
||||
|
||||
func newFakeKeyring(available bool) *fakeKeyring {
|
||||
return &fakeKeyring{available: available, data: map[string]string{}}
|
||||
}
|
||||
func (f *fakeKeyring) Available() bool { return f.available }
|
||||
func (f *fakeKeyring) Set(service, account, secret string) error {
|
||||
f.data[service+"/"+account] = secret
|
||||
return nil
|
||||
}
|
||||
func (f *fakeKeyring) Get(service, account string) (string, bool, error) {
|
||||
v, ok := f.data[service+"/"+account]
|
||||
return v, ok, nil
|
||||
}
|
||||
func (f *fakeKeyring) Delete(service, account string) (bool, error) {
|
||||
k := service + "/" + account
|
||||
_, ok := f.data[k]
|
||||
delete(f.data, k)
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func noEnv(string) string { return "" }
|
||||
|
||||
func roundTrip(t *testing.T, s *Store) {
|
||||
t.Helper()
|
||||
if _, ok, _ := s.Get("openai"); ok {
|
||||
t.Fatal("expected no key initially")
|
||||
}
|
||||
if err := s.Set("OpenAI", "sk-abc-123"); err != nil { // mixed case normalizes
|
||||
t.Fatalf("set: %v", err)
|
||||
}
|
||||
got, ok, err := s.Get("openai")
|
||||
if err != nil || !ok || got != "sk-abc-123" {
|
||||
t.Fatalf("get = %q,%v,%v; want sk-abc-123,true,nil", got, ok, err)
|
||||
}
|
||||
if err := s.Set("openai", "sk-new-456"); err != nil { // overwrite
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _, _ := s.Get("openai"); got != "sk-new-456" {
|
||||
t.Fatalf("overwrite get = %q, want sk-new-456", got)
|
||||
}
|
||||
existed, err := s.Delete("openai")
|
||||
if err != nil || !existed {
|
||||
t.Fatalf("delete = %v,%v; want true,nil", existed, err)
|
||||
}
|
||||
if _, ok, _ := s.Get("openai"); ok {
|
||||
t.Fatal("expected key gone after delete")
|
||||
}
|
||||
if existed, _ := s.Delete("openai"); existed {
|
||||
t.Fatal("delete of absent key should report not-existed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyringBackendRoundTrip(t *testing.T) {
|
||||
s, err := New(Options{Storage: "keyring", Keyring: newFakeKeyring(true), Env: noEnv})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Backend() != "keyring" || !s.Encrypted() {
|
||||
t.Fatalf("backend=%q encrypted=%v", s.Backend(), s.Encrypted())
|
||||
}
|
||||
roundTrip(t, s)
|
||||
}
|
||||
|
||||
func TestEncryptedFileBackendRoundTripAndAtRest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := New(Options{Storage: "encrypted-file", Dir: dir, Keyring: newFakeKeyring(false), Env: noEnv})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.Encrypted() {
|
||||
t.Fatal("encrypted-file must report Encrypted()")
|
||||
}
|
||||
if err := s.Set("anthropic", "sk-ant-SECRET"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The on-disk file must NOT contain the plaintext key, and must be 0600.
|
||||
raw, err := os.ReadFile(filepath.Join(dir, encryptedFileName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), "sk-ant-SECRET") {
|
||||
t.Fatal("encrypted-file leaked the plaintext key on disk")
|
||||
}
|
||||
if runtime.GOOS != "windows" { // Windows doesn't honor Unix permission bits
|
||||
info, _ := os.Stat(filepath.Join(dir, encryptedFileName))
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("perm = %o, want 600", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
if got, ok, _ := s.Get("anthropic"); !ok || got != "sk-ant-SECRET" {
|
||||
t.Fatalf("get = %q,%v", got, ok)
|
||||
}
|
||||
roundTrip(t, s)
|
||||
}
|
||||
|
||||
func TestPlaintextFileBackendIsOptOut(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := New(Options{Storage: "file", Dir: dir, Keyring: newFakeKeyring(false), Env: noEnv})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Encrypted() {
|
||||
t.Fatal("plaintext file must NOT report Encrypted()")
|
||||
}
|
||||
roundTrip(t, s)
|
||||
}
|
||||
|
||||
func TestAutoPrefersKeyringThenEncryptedFile(t *testing.T) {
|
||||
// macOS + keyring available -> keyring
|
||||
s, err := New(Options{Dir: t.TempDir(), Keyring: newFakeKeyring(true), GOOS: "darwin", Env: noEnv})
|
||||
if err != nil || s.Backend() != "keyring" {
|
||||
t.Fatalf("auto on darwin with keyring = %q,%v; want keyring", s.Backend(), err)
|
||||
}
|
||||
// macOS but keyring unavailable -> encrypted-file (never plaintext)
|
||||
s2, err := New(Options{Dir: t.TempDir(), Keyring: newFakeKeyring(false), GOOS: "darwin", Env: noEnv})
|
||||
if err != nil || s2.Backend() != "encrypted-file" {
|
||||
t.Fatalf("auto on darwin without keyring = %q,%v; want encrypted-file", s2.Backend(), err)
|
||||
}
|
||||
// Non-macOS (e.g. Linux) -> encrypted-file even when a keyring is available, since
|
||||
// secret-tool needs a running secret service (keyring stays opt-in via env).
|
||||
s3, err := New(Options{Dir: t.TempDir(), Keyring: newFakeKeyring(true), GOOS: "linux", Env: noEnv})
|
||||
if err != nil || s3.Backend() != "encrypted-file" {
|
||||
t.Fatalf("auto on linux = %q,%v; want encrypted-file", s3.Backend(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyringRequestedButUnavailableErrors(t *testing.T) {
|
||||
if _, err := New(Options{Storage: "keyring", Keyring: newFakeKeyring(false), Env: noEnv}); err == nil {
|
||||
t.Fatal("expected error when keyring requested but unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvSelectsStorage(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
env := func(k string) string {
|
||||
if k == "ZERO_CRED_STORAGE" {
|
||||
return "encrypted-file"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
s, err := New(Options{Dir: dir, Keyring: newFakeKeyring(true), Env: env}) // keyring available, but env forces encrypted-file
|
||||
if err != nil || s.Backend() != "encrypted-file" {
|
||||
t.Fatalf("env override = %q,%v; want encrypted-file", s.Backend(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvidersListsFileBackend(t *testing.T) {
|
||||
s, _ := New(Options{Storage: "encrypted-file", Dir: t.TempDir(), Keyring: newFakeKeyring(false), Env: noEnv})
|
||||
_ = s.Set("openai", "a")
|
||||
_ = s.Set("gemini", "b")
|
||||
names, err := s.Providers()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(names) != 2 || names[0] != "gemini" || names[1] != "openai" {
|
||||
t.Fatalf("providers = %v, want [gemini openai]", names)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,9 @@ func TestUnixInstallerScriptMatchesReleaseContracts(t *testing.T) {
|
||||
`tar -xzf "$archive_path" -C "$extract_dir"`,
|
||||
`find_extracted_binary "$extract_dir"`,
|
||||
`cp "$binary_path" "$ZERO_INSTALL_DIR/zero"`,
|
||||
`copy_optional_file "zero-linux-sandbox"`,
|
||||
`copy_optional_file "zero-seccomp"`,
|
||||
`copy_optional_dir "helpers"`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -48,6 +51,8 @@ func TestPowerShellInstallerScriptMatchesWindowsReleaseContracts(t *testing.T) {
|
||||
`"zero-windows-command-runner.exe"`,
|
||||
`"zero-windows-sandbox-setup.exe"`,
|
||||
`Copy-Item -Path $sourcePath -Destination (Join-Path $InstallDir $fileName) -Force`,
|
||||
`Find-ZeroOptionalExtractedDirectory -Root $extractDir -DirectoryName "helpers"`,
|
||||
`Copy-Item -Path $helpersPath -Destination $targetHelpersPath -Recurse -Force`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -68,6 +73,12 @@ func TestUnixInstallerInstallsFromPrefixedReleaseArchiveWithoutNetwork(t *testin
|
||||
if !strings.Contains(string(installed), "mock-zero") {
|
||||
t.Fatalf("installed binary = %q, want mock-zero script", string(installed))
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(fixture.installDir, "helpers", "node_modules", ".bin", "agent-browser")); err != nil {
|
||||
t.Fatalf("installed helper package missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(fixture.installDir, "zero-linux-sandbox")); err != nil {
|
||||
t.Fatalf("installed linux sandbox helper missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnixInstallerRejectsChecksumMismatchWithoutNetwork(t *testing.T) {
|
||||
@@ -127,7 +138,16 @@ func newUnixInstallFixture(t *testing.T) unixInstallFixture {
|
||||
mustMkdirAll(t, packageDir)
|
||||
mustMkdirAll(t, filepath.Dir(fixture.archivePath))
|
||||
mustMkdirAll(t, fixture.installDir)
|
||||
mustMkdirAll(t, filepath.Join(packageDir, "helpers", "node_modules", ".bin"))
|
||||
writeFile(t, filepath.Join(packageDir, "zero"), []byte("#!/usr/bin/env sh\necho mock-zero\n"), 0o755)
|
||||
writeFile(t, filepath.Join(packageDir, "zero-linux-sandbox"), []byte("#!/usr/bin/env sh\n"), 0o755)
|
||||
writeFile(t, filepath.Join(packageDir, "zero-seccomp"), []byte("#!/usr/bin/env sh\n"), 0o755)
|
||||
mustMkdirAll(t, filepath.Join(packageDir, "helpers", "node_modules", "agent-browser", "bin"))
|
||||
writeFile(t, filepath.Join(packageDir, "helpers", "node_modules", "agent-browser", "bin", "agent-browser.js"), []byte("#!/usr/bin/env node\n"), 0o755)
|
||||
if err := os.Symlink("../agent-browser/bin/agent-browser.js", filepath.Join(packageDir, "helpers", "node_modules", ".bin", "agent-browser")); err != nil {
|
||||
t.Fatalf("Symlink agent-browser helper: %v", err)
|
||||
}
|
||||
writeFile(t, filepath.Join(packageDir, "helpers", "node_modules", ".bin", "tuistory"), []byte("#!/usr/bin/env sh\n"), 0o755)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package localcontrol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultDevToolsPort = 9222
|
||||
defaultDevToolsWaitTimeout = 20 * time.Second
|
||||
)
|
||||
|
||||
type BrowserAppLaunchOptions struct {
|
||||
Runner BrowserAppRunner
|
||||
WaitTimeout time.Duration
|
||||
}
|
||||
|
||||
type BrowserAppLaunchRequest struct {
|
||||
App string
|
||||
DebugPort int
|
||||
StopExisting bool
|
||||
Wait bool
|
||||
}
|
||||
|
||||
type BrowserAppLaunchResult struct {
|
||||
App string
|
||||
Command string
|
||||
Args []string
|
||||
PID int
|
||||
DebugPort int
|
||||
DevToolsURL string
|
||||
}
|
||||
|
||||
type BrowserAppRunner interface {
|
||||
Run(ctx context.Context, path string, args []string, env []string, timeout time.Duration) (CommandResult, error)
|
||||
StartDetached(ctx context.Context, path string, args []string, env []string) (int, error)
|
||||
}
|
||||
|
||||
type BrowserAppLauncher struct {
|
||||
runner BrowserAppRunner
|
||||
waitTimeout time.Duration
|
||||
}
|
||||
|
||||
type browserAppSpec struct {
|
||||
name string
|
||||
command string
|
||||
args []string
|
||||
env []string
|
||||
stopPath string
|
||||
stopArgs []string
|
||||
}
|
||||
|
||||
func NewBrowserAppLauncher(options BrowserAppLaunchOptions) BrowserAppLauncher {
|
||||
if options.Runner == nil {
|
||||
options.Runner = ExecBrowserAppRunner{}
|
||||
}
|
||||
if options.WaitTimeout <= 0 {
|
||||
options.WaitTimeout = defaultDevToolsWaitTimeout
|
||||
}
|
||||
return BrowserAppLauncher{
|
||||
runner: options.Runner,
|
||||
waitTimeout: options.WaitTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
func (launcher BrowserAppLauncher) LaunchBrowserApp(ctx context.Context, request BrowserAppLaunchRequest) (BrowserAppLaunchResult, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
port := request.DebugPort
|
||||
if port == 0 {
|
||||
port = DefaultDevToolsPort
|
||||
}
|
||||
if port < 1024 || port > 65535 {
|
||||
return BrowserAppLaunchResult{}, fmt.Errorf("debug_port must be between 1024 and 65535")
|
||||
}
|
||||
spec, err := browserAppLaunchSpec(request.App, port)
|
||||
if err != nil {
|
||||
return BrowserAppLaunchResult{}, err
|
||||
}
|
||||
|
||||
if request.StopExisting && spec.stopPath != "" {
|
||||
_, _ = launcher.runner.Run(ctx, spec.stopPath, spec.stopArgs, nil, 5*time.Second)
|
||||
}
|
||||
|
||||
pid, err := launcher.runner.StartDetached(ctx, spec.command, spec.args, spec.env)
|
||||
if err != nil {
|
||||
return BrowserAppLaunchResult{}, fmt.Errorf("launch %s: %w", spec.name, err)
|
||||
}
|
||||
|
||||
devToolsURL := "http://127.0.0.1:" + strconv.Itoa(port)
|
||||
if request.Wait {
|
||||
if err := waitForDevTools(ctx, devToolsURL, launcher.waitTimeout); err != nil {
|
||||
return BrowserAppLaunchResult{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return BrowserAppLaunchResult{
|
||||
App: spec.name,
|
||||
Command: spec.command,
|
||||
Args: append([]string(nil), spec.args...),
|
||||
PID: pid,
|
||||
DebugPort: port,
|
||||
DevToolsURL: devToolsURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func browserAppLaunchSpec(app string, port int) (browserAppSpec, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(app)) {
|
||||
case "discord":
|
||||
if runtime.GOOS != "linux" {
|
||||
return browserAppSpec{}, fmt.Errorf("discord launch is only supported for Linux flatpak installs")
|
||||
}
|
||||
return browserAppSpec{
|
||||
name: "discord",
|
||||
command: "flatpak",
|
||||
args: []string{"run", "--command=com.discordapp.Discord", "com.discordapp.Discord", "--remote-debugging-port=" + strconv.Itoa(port)},
|
||||
stopPath: "flatpak",
|
||||
stopArgs: []string{"kill", "com.discordapp.Discord"},
|
||||
}, nil
|
||||
default:
|
||||
return browserAppSpec{}, fmt.Errorf("app must be one of: discord")
|
||||
}
|
||||
}
|
||||
|
||||
func waitForDevTools(ctx context.Context, baseURL string, timeout time.Duration) error {
|
||||
if timeout <= 0 {
|
||||
timeout = defaultDevToolsWaitTimeout
|
||||
}
|
||||
deadline := time.Now().Add(timeout)
|
||||
client := http.Client{Timeout: 750 * time.Millisecond}
|
||||
endpoint := strings.TrimRight(baseURL, "/") + "/json/version"
|
||||
var lastErr error
|
||||
for {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err == nil {
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
lastErr = fmt.Errorf("DevTools endpoint returned HTTP %d", resp.StatusCode)
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("DevTools endpoint %s was not ready after %dms: %w", endpoint, timeout.Milliseconds(), lastErr)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ExecBrowserAppRunner struct{}
|
||||
|
||||
func (ExecBrowserAppRunner) Run(ctx context.Context, path string, args []string, env []string, timeout time.Duration) (CommandResult, error) {
|
||||
return ExecRunner{}.Run(ctx, path, args, env, timeout)
|
||||
}
|
||||
|
||||
func (ExecBrowserAppRunner) StartDetached(_ context.Context, path string, args []string, env []string) (int, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return 0, errors.New("launch command is required")
|
||||
}
|
||||
cmd := exec.Command(path, args...)
|
||||
cmd.Env = mergeEnv(os.Environ(), env)
|
||||
configureDetachedProcess(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
pid := cmd.Process.Pid
|
||||
if err := cmd.Process.Release(); err != nil {
|
||||
return pid, err
|
||||
}
|
||||
return pid, nil
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package localcontrol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeBrowserAppRunner struct {
|
||||
runPath string
|
||||
runArgs []string
|
||||
startPath string
|
||||
startArgs []string
|
||||
pid int
|
||||
err error
|
||||
}
|
||||
|
||||
func (runner *fakeBrowserAppRunner) Run(_ context.Context, path string, args []string, _ []string, _ time.Duration) (CommandResult, error) {
|
||||
runner.runPath = path
|
||||
runner.runArgs = append([]string(nil), args...)
|
||||
return CommandResult{Path: path, Args: append([]string(nil), args...), ExitCode: 0}, nil
|
||||
}
|
||||
|
||||
func (runner *fakeBrowserAppRunner) StartDetached(_ context.Context, path string, args []string, _ []string) (int, error) {
|
||||
runner.startPath = path
|
||||
runner.startArgs = append([]string(nil), args...)
|
||||
if runner.err != nil {
|
||||
return 0, runner.err
|
||||
}
|
||||
if runner.pid == 0 {
|
||||
runner.pid = 4321
|
||||
}
|
||||
return runner.pid, nil
|
||||
}
|
||||
|
||||
func TestBrowserAppLauncherMapsDiscordToFlatpakDevToolsLaunch(t *testing.T) {
|
||||
requireLinuxFlatpakLaunch(t)
|
||||
runner := &fakeBrowserAppRunner{}
|
||||
launcher := NewBrowserAppLauncher(BrowserAppLaunchOptions{Runner: runner})
|
||||
|
||||
result, err := launcher.LaunchBrowserApp(context.Background(), BrowserAppLaunchRequest{
|
||||
App: "discord",
|
||||
DebugPort: 9222,
|
||||
StopExisting: true,
|
||||
Wait: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LaunchBrowserApp returned error: %v", err)
|
||||
}
|
||||
if runner.runPath != "flatpak" {
|
||||
t.Fatalf("stop path = %q, want flatpak", runner.runPath)
|
||||
}
|
||||
if want := []string{"kill", "com.discordapp.Discord"}; !reflect.DeepEqual(runner.runArgs, want) {
|
||||
t.Fatalf("stop args = %#v, want %#v", runner.runArgs, want)
|
||||
}
|
||||
if runner.startPath != "flatpak" {
|
||||
t.Fatalf("start path = %q, want flatpak", runner.startPath)
|
||||
}
|
||||
wantStartArgs := []string{"run", "--command=com.discordapp.Discord", "com.discordapp.Discord", "--remote-debugging-port=9222"}
|
||||
if !reflect.DeepEqual(runner.startArgs, wantStartArgs) {
|
||||
t.Fatalf("start args = %#v, want %#v", runner.startArgs, wantStartArgs)
|
||||
}
|
||||
if result.PID != 4321 || result.DevToolsURL != "http://127.0.0.1:9222" {
|
||||
t.Fatalf("result = %#v, want pid/devtools URL", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserAppLauncherDefaultsPortAndCanSkipStop(t *testing.T) {
|
||||
requireLinuxFlatpakLaunch(t)
|
||||
runner := &fakeBrowserAppRunner{}
|
||||
launcher := NewBrowserAppLauncher(BrowserAppLaunchOptions{Runner: runner})
|
||||
|
||||
result, err := launcher.LaunchBrowserApp(context.Background(), BrowserAppLaunchRequest{
|
||||
App: "discord",
|
||||
Wait: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LaunchBrowserApp returned error: %v", err)
|
||||
}
|
||||
if runner.runPath != "" {
|
||||
t.Fatalf("stop path = %q, want no stop", runner.runPath)
|
||||
}
|
||||
wantStartArgs := []string{"run", "--command=com.discordapp.Discord", "com.discordapp.Discord", "--remote-debugging-port=9222"}
|
||||
if !reflect.DeepEqual(runner.startArgs, wantStartArgs) {
|
||||
t.Fatalf("start args = %#v, want %#v", runner.startArgs, wantStartArgs)
|
||||
}
|
||||
if result.DebugPort != DefaultDevToolsPort {
|
||||
t.Fatalf("debug port = %d, want %d", result.DebugPort, DefaultDevToolsPort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserAppLauncherRejectsUnsupportedApp(t *testing.T) {
|
||||
launcher := NewBrowserAppLauncher(BrowserAppLaunchOptions{Runner: &fakeBrowserAppRunner{}})
|
||||
_, err := launcher.LaunchBrowserApp(context.Background(), BrowserAppLaunchRequest{App: "telegram"})
|
||||
if err == nil || !strings.Contains(err.Error(), "app must be one of") {
|
||||
t.Fatalf("error = %v, want unsupported app", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserAppLauncherReportsStartFailure(t *testing.T) {
|
||||
requireLinuxFlatpakLaunch(t)
|
||||
launcher := NewBrowserAppLauncher(BrowserAppLaunchOptions{Runner: &fakeBrowserAppRunner{err: errors.New("boom")}})
|
||||
_, err := launcher.LaunchBrowserApp(context.Background(), BrowserAppLaunchRequest{App: "discord", Wait: false})
|
||||
if err == nil || !strings.Contains(err.Error(), "launch discord") {
|
||||
t.Fatalf("error = %v, want launch failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserAppLauncherRejectsDiscordOnUnsupportedPlatform(t *testing.T) {
|
||||
if runtime.GOOS == "linux" {
|
||||
t.Skip("discord launch is supported on Linux")
|
||||
}
|
||||
launcher := NewBrowserAppLauncher(BrowserAppLaunchOptions{Runner: &fakeBrowserAppRunner{}})
|
||||
_, err := launcher.LaunchBrowserApp(context.Background(), BrowserAppLaunchRequest{App: "discord", Wait: false})
|
||||
if err == nil || !strings.Contains(err.Error(), "only supported") {
|
||||
t.Fatalf("error = %v, want unsupported platform", err)
|
||||
}
|
||||
}
|
||||
|
||||
func requireLinuxFlatpakLaunch(t *testing.T) {
|
||||
t.Helper()
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("discord launch uses Linux flatpak")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//go:build !windows
|
||||
|
||||
package localcontrol
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func configureDetachedProcess(cmd *exec.Cmd) {
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
cmd.SysProcAttr.Setsid = true
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build windows
|
||||
|
||||
package localcontrol
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
const detachedProcess = 0x00000008
|
||||
|
||||
func configureDetachedProcess(cmd *exec.Cmd) {
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
cmd.SysProcAttr.CreationFlags |= syscall.CREATE_NEW_PROCESS_GROUP | detachedProcess
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
package localcontrol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultBrowserDriver = "agent-browser"
|
||||
DefaultDesktopDriver = "cua-driver"
|
||||
DefaultTerminalDriver = "tuistory"
|
||||
DefaultTimeout = 30 * time.Second
|
||||
EnvHelperManifest = "ZERO_LOCAL_CONTROL_HELPERS"
|
||||
)
|
||||
|
||||
type HelperOptions struct {
|
||||
Enabled bool
|
||||
Driver string
|
||||
HelperPath string
|
||||
ConfigPath string
|
||||
DisabledMessage string
|
||||
MissingHint string
|
||||
Timeout time.Duration
|
||||
Runner CommandRunner
|
||||
}
|
||||
|
||||
type BrowserOptions = HelperOptions
|
||||
type DesktopOptions = HelperOptions
|
||||
type TerminalOptions = HelperOptions
|
||||
|
||||
type CommandResult struct {
|
||||
Path string
|
||||
Args []string
|
||||
Stdout string
|
||||
Stderr string
|
||||
ExitCode int
|
||||
}
|
||||
|
||||
func (result CommandResult) Output() string {
|
||||
stdout := strings.TrimRight(result.Stdout, "\n")
|
||||
stderr := strings.TrimRight(result.Stderr, "\n")
|
||||
switch {
|
||||
case stdout == "" && stderr == "":
|
||||
return ""
|
||||
case stdout == "":
|
||||
return stderr
|
||||
case stderr == "":
|
||||
return stdout
|
||||
default:
|
||||
return stdout + "\n" + stderr
|
||||
}
|
||||
}
|
||||
|
||||
type CommandRunner interface {
|
||||
Run(ctx context.Context, path string, args []string, env []string, timeout time.Duration) (CommandResult, error)
|
||||
}
|
||||
|
||||
type ResolvedHelper struct {
|
||||
Command string
|
||||
PrefixArgs []string
|
||||
Env []string
|
||||
}
|
||||
|
||||
type Helper struct {
|
||||
options HelperOptions
|
||||
}
|
||||
|
||||
type Browser struct {
|
||||
helper Helper
|
||||
}
|
||||
|
||||
type Desktop struct {
|
||||
helper Helper
|
||||
}
|
||||
|
||||
type Terminal struct {
|
||||
helper Helper
|
||||
}
|
||||
|
||||
func NewHelper(options HelperOptions) Helper {
|
||||
if options.Timeout <= 0 {
|
||||
options.Timeout = DefaultTimeout
|
||||
}
|
||||
if options.Runner == nil {
|
||||
options.Runner = ExecRunner{}
|
||||
}
|
||||
return Helper{options: options}
|
||||
}
|
||||
|
||||
func NewBrowser(options BrowserOptions) Browser {
|
||||
if strings.TrimSpace(options.Driver) == "" {
|
||||
options.Driver = DefaultBrowserDriver
|
||||
}
|
||||
options.ConfigPath = "localControl.browser.helperPath"
|
||||
options.DisabledMessage = "local browser control is disabled by user config"
|
||||
options.MissingHint = "install the agent-browser package or set localControl.browser.helperPath"
|
||||
return Browser{helper: NewHelper(options)}
|
||||
}
|
||||
|
||||
func NewDesktop(options DesktopOptions) Desktop {
|
||||
if strings.TrimSpace(options.Driver) == "" {
|
||||
options.Driver = DefaultDesktopDriver
|
||||
}
|
||||
options.ConfigPath = "localControl.desktop.helperPath"
|
||||
options.DisabledMessage = "local desktop control is disabled; enable localControl.desktop.enabled in user config"
|
||||
options.MissingHint = "install cua-driver or set localControl.desktop.helperPath"
|
||||
return Desktop{helper: NewHelper(options)}
|
||||
}
|
||||
|
||||
func NewTerminal(options TerminalOptions) Terminal {
|
||||
if strings.TrimSpace(options.Driver) == "" {
|
||||
options.Driver = DefaultTerminalDriver
|
||||
}
|
||||
options.ConfigPath = "localControl.terminal.helperPath"
|
||||
options.DisabledMessage = "local terminal control is disabled by user config"
|
||||
options.MissingHint = "install the tuistory package or set localControl.terminal.helperPath"
|
||||
return Terminal{helper: NewHelper(options)}
|
||||
}
|
||||
|
||||
func (helper Helper) Enabled() bool {
|
||||
return helper.options.Enabled
|
||||
}
|
||||
|
||||
func (helper Helper) Run(ctx context.Context, args ...string) (CommandResult, error) {
|
||||
if !helper.options.Enabled {
|
||||
message := strings.TrimSpace(helper.options.DisabledMessage)
|
||||
if message == "" {
|
||||
message = "local control helper is disabled"
|
||||
}
|
||||
return CommandResult{}, errors.New(message)
|
||||
}
|
||||
resolved, err := helper.resolvedHelper()
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
runArgs := append([]string{}, resolved.PrefixArgs...)
|
||||
runArgs = append(runArgs, args...)
|
||||
return helper.options.Runner.Run(ctx, resolved.Command, runArgs, resolved.Env, helper.options.Timeout)
|
||||
}
|
||||
|
||||
func (helper Helper) resolvedHelper() (ResolvedHelper, error) {
|
||||
label := helper.helperLabel()
|
||||
if helperPath := strings.TrimSpace(helper.options.HelperPath); helperPath != "" {
|
||||
if !filepath.IsAbs(helperPath) {
|
||||
abs, err := filepath.Abs(helperPath)
|
||||
if err != nil {
|
||||
return ResolvedHelper{}, fmt.Errorf("resolve %s helper path: %w", label, err)
|
||||
}
|
||||
helperPath = abs
|
||||
}
|
||||
if err := validateHelperFile(label, helperPath); err != nil {
|
||||
return ResolvedHelper{}, err
|
||||
}
|
||||
return ResolvedHelper{Command: helperPath}, nil
|
||||
}
|
||||
|
||||
driver := strings.TrimSpace(helper.options.Driver)
|
||||
if driver == "" {
|
||||
driver = DefaultBrowserDriver
|
||||
}
|
||||
if resolved, ok, err := helperFromManifest(driver); ok || err != nil {
|
||||
return resolved, err
|
||||
}
|
||||
if resolved, ok, err := adjacentHelper(driver); ok || err != nil {
|
||||
return resolved, err
|
||||
}
|
||||
path, err := exec.LookPath(driver)
|
||||
if err != nil {
|
||||
hint := strings.TrimSpace(helper.options.MissingHint)
|
||||
if hint == "" {
|
||||
hint = "install it or configure the helper path"
|
||||
}
|
||||
return ResolvedHelper{}, fmt.Errorf("%s was not found on PATH; %s", driver, hint)
|
||||
}
|
||||
return ResolvedHelper{Command: path}, nil
|
||||
}
|
||||
|
||||
func (helper Helper) helperLabel() string {
|
||||
driver := strings.TrimSpace(helper.options.Driver)
|
||||
if driver == "" {
|
||||
return "local control"
|
||||
}
|
||||
return driver
|
||||
}
|
||||
|
||||
func (browser Browser) Enabled() bool {
|
||||
return browser.helper.Enabled()
|
||||
}
|
||||
|
||||
func (browser Browser) Run(ctx context.Context, args ...string) (CommandResult, error) {
|
||||
return browser.helper.Run(ctx, args...)
|
||||
}
|
||||
|
||||
func (desktop Desktop) Run(ctx context.Context, args ...string) (CommandResult, error) {
|
||||
return desktop.helper.Run(ctx, args...)
|
||||
}
|
||||
|
||||
func (desktop Desktop) Enabled() bool {
|
||||
return desktop.helper.Enabled()
|
||||
}
|
||||
|
||||
func (terminal Terminal) Run(ctx context.Context, args ...string) (CommandResult, error) {
|
||||
return terminal.helper.Run(ctx, args...)
|
||||
}
|
||||
|
||||
func (terminal Terminal) Enabled() bool {
|
||||
return terminal.helper.Enabled()
|
||||
}
|
||||
|
||||
type ExecRunner struct{}
|
||||
|
||||
var executablePath = os.Executable
|
||||
|
||||
func (ExecRunner) Run(ctx context.Context, path string, args []string, env []string, timeout time.Duration) (CommandResult, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = DefaultTimeout
|
||||
}
|
||||
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(runCtx, path, args...)
|
||||
cmd.Env = mergeEnv(os.Environ(), env)
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
result := CommandResult{
|
||||
Path: path,
|
||||
Args: append([]string(nil), args...),
|
||||
Stdout: stdout.String(),
|
||||
Stderr: stderr.String(),
|
||||
}
|
||||
if cmd.ProcessState != nil {
|
||||
result.ExitCode = cmd.ProcessState.ExitCode()
|
||||
} else if err != nil {
|
||||
result.ExitCode = -1
|
||||
}
|
||||
if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
|
||||
return result, fmt.Errorf("local control helper timed out after %dms", timeout.Milliseconds())
|
||||
}
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type helperManifest struct {
|
||||
Version int `json:"version"`
|
||||
Helpers map[string]manifestEntry `json:"helpers"`
|
||||
}
|
||||
|
||||
type manifestEntry struct {
|
||||
Command string `json:"command"`
|
||||
PrefixArgs []string `json:"prefixArgs"`
|
||||
PathPrepend []string `json:"pathPrepend"`
|
||||
Env map[string]string `json:"env"`
|
||||
}
|
||||
|
||||
func helperFromManifest(driver string) (ResolvedHelper, bool, error) {
|
||||
raw := strings.TrimSpace(os.Getenv(EnvHelperManifest))
|
||||
if raw == "" {
|
||||
return ResolvedHelper{}, false, nil
|
||||
}
|
||||
manifest, err := parseHelperManifest(raw)
|
||||
if err != nil {
|
||||
return ResolvedHelper{}, true, err
|
||||
}
|
||||
entry, ok := manifest.Helpers[driver]
|
||||
if !ok {
|
||||
return ResolvedHelper{}, false, nil
|
||||
}
|
||||
command := strings.TrimSpace(entry.Command)
|
||||
if command == "" {
|
||||
return ResolvedHelper{}, true, fmt.Errorf("%s entry in %s is missing command", driver, EnvHelperManifest)
|
||||
}
|
||||
if filepath.IsAbs(command) {
|
||||
if err := validateHelperFile(driver, command); err != nil {
|
||||
return ResolvedHelper{}, true, err
|
||||
}
|
||||
}
|
||||
return ResolvedHelper{
|
||||
Command: command,
|
||||
PrefixArgs: cleanStringSlice(entry.PrefixArgs),
|
||||
Env: manifestEnv(entry),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func parseHelperManifest(raw string) (helperManifest, error) {
|
||||
var manifest helperManifest
|
||||
if err := json.Unmarshal([]byte(raw), &manifest); err != nil {
|
||||
return helperManifest{}, fmt.Errorf("invalid %s JSON: %w", EnvHelperManifest, err)
|
||||
}
|
||||
if manifest.Helpers == nil {
|
||||
manifest.Helpers = map[string]manifestEntry{}
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func validateHelperFile(label string, path string) error {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s helper not found at %s: %w", label, path, err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return fmt.Errorf("%s helper path is a directory: %s", label, path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func adjacentHelper(driver string) (ResolvedHelper, bool, error) {
|
||||
executable, err := executablePath()
|
||||
if err != nil {
|
||||
return ResolvedHelper{}, false, nil
|
||||
}
|
||||
executableDir := filepath.Dir(executable)
|
||||
for _, dir := range []string{
|
||||
executableDir,
|
||||
filepath.Join(executableDir, "helpers"),
|
||||
filepath.Join(executableDir, "helpers", "node_modules", ".bin"),
|
||||
filepath.Join(executableDir, "node_modules", ".bin"),
|
||||
} {
|
||||
for _, name := range helperExecutableNames(driver) {
|
||||
path := filepath.Join(dir, name)
|
||||
if err := validateHelperFile(driver, path); err == nil {
|
||||
return helperCommandForPath(path), true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return ResolvedHelper{}, false, nil
|
||||
}
|
||||
|
||||
func helperExecutableNames(driver string) []string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return []string{driver + ".cmd", driver + ".exe", driver}
|
||||
}
|
||||
return []string{driver}
|
||||
}
|
||||
|
||||
func helperCommandForPath(path string) ResolvedHelper {
|
||||
if runtime.GOOS == "windows" && strings.EqualFold(filepath.Ext(path), ".cmd") {
|
||||
command := os.Getenv("ComSpec")
|
||||
if strings.TrimSpace(command) == "" {
|
||||
command = "cmd.exe"
|
||||
}
|
||||
return ResolvedHelper{
|
||||
Command: command,
|
||||
PrefixArgs: []string{"/d", "/s", "/c", `"` + strings.ReplaceAll(path, `"`, `""`) + `"`},
|
||||
}
|
||||
}
|
||||
return ResolvedHelper{Command: path}
|
||||
}
|
||||
|
||||
func manifestEnv(entry manifestEntry) []string {
|
||||
env := map[string]string{}
|
||||
for key, value := range entry.Env {
|
||||
key = strings.TrimSpace(key)
|
||||
if key != "" {
|
||||
env[key] = value
|
||||
}
|
||||
}
|
||||
pathPrepend := cleanStringSlice(entry.PathPrepend)
|
||||
if len(pathPrepend) > 0 {
|
||||
basePath := env["PATH"]
|
||||
if basePath == "" {
|
||||
basePath = os.Getenv("PATH")
|
||||
}
|
||||
prefix := strings.Join(pathPrepend, string(os.PathListSeparator))
|
||||
if basePath != "" {
|
||||
env["PATH"] = prefix + string(os.PathListSeparator) + basePath
|
||||
} else {
|
||||
env["PATH"] = prefix
|
||||
}
|
||||
}
|
||||
return envMapToList(env)
|
||||
}
|
||||
|
||||
func normalizeEnvKey(key string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.ToUpper(key)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func mergeEnv(base []string, overlay []string) []string {
|
||||
if len(overlay) == 0 {
|
||||
return base
|
||||
}
|
||||
merged := append([]string{}, base...)
|
||||
index := map[string]int{}
|
||||
for i, item := range merged {
|
||||
if key, _, ok := strings.Cut(item, "="); ok {
|
||||
index[normalizeEnvKey(key)] = i
|
||||
}
|
||||
}
|
||||
for _, item := range overlay {
|
||||
key, _, ok := strings.Cut(item, "=")
|
||||
if !ok || key == "" {
|
||||
continue
|
||||
}
|
||||
normalized := normalizeEnvKey(key)
|
||||
if existing, ok := index[normalized]; ok {
|
||||
merged[existing] = item
|
||||
} else {
|
||||
index[normalized] = len(merged)
|
||||
merged = append(merged, item)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func cleanStringSlice(values []string) []string {
|
||||
cleaned := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
cleaned = append(cleaned, value)
|
||||
}
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func envMapToList(env map[string]string) []string {
|
||||
if len(env) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(env))
|
||||
for key := range env {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
values := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
values = append(values, key+"="+env[key])
|
||||
}
|
||||
return values
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package localcontrol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeCommandRunner struct {
|
||||
path string
|
||||
args []string
|
||||
env []string
|
||||
}
|
||||
|
||||
func (runner *fakeCommandRunner) Run(_ context.Context, path string, args []string, env []string, _ time.Duration) (CommandResult, error) {
|
||||
runner.path = path
|
||||
runner.args = append([]string(nil), args...)
|
||||
runner.env = append([]string(nil), env...)
|
||||
return CommandResult{
|
||||
Path: path,
|
||||
Args: append([]string(nil), args...),
|
||||
Stdout: "ok\n",
|
||||
ExitCode: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestBrowserRunUsesConfiguredHelperPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
helper := filepath.Join(dir, "agent-browser")
|
||||
if err := os.WriteFile(helper, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatalf("write helper: %v", err)
|
||||
}
|
||||
runner := &fakeCommandRunner{}
|
||||
browser := NewBrowser(BrowserOptions{
|
||||
Enabled: true,
|
||||
HelperPath: helper,
|
||||
Runner: runner,
|
||||
})
|
||||
|
||||
result, err := browser.Run(context.Background(), "open", "https://example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
if result.Output() != "ok" {
|
||||
t.Fatalf("Output = %q, want ok", result.Output())
|
||||
}
|
||||
if runner.path != helper {
|
||||
t.Fatalf("runner path = %q, want %q", runner.path, helper)
|
||||
}
|
||||
if want := []string{"open", "https://example.com"}; !reflect.DeepEqual(runner.args, want) {
|
||||
t.Fatalf("runner args = %#v, want %#v", runner.args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserRunDisabledFailsBeforeDiscovery(t *testing.T) {
|
||||
browser := NewBrowser(BrowserOptions{Enabled: false, HelperPath: "/does/not/exist"})
|
||||
_, err := browser.Run(context.Background(), "snapshot")
|
||||
if err == nil || !strings.Contains(err.Error(), "disabled") {
|
||||
t.Fatalf("Run error = %v, want disabled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserRunMissingHelperPathIsActionable(t *testing.T) {
|
||||
browser := NewBrowser(BrowserOptions{Enabled: true, HelperPath: filepath.Join(t.TempDir(), "missing")})
|
||||
_, err := browser.Run(context.Background(), "snapshot")
|
||||
if err == nil || !strings.Contains(err.Error(), "helper not found") {
|
||||
t.Fatalf("Run error = %v, want missing helper", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserRunUsesHelperManifest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
binDir := filepath.Join(dir, "node_modules", ".bin")
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir bin: %v", err)
|
||||
}
|
||||
helper := filepath.Join(binDir, "agent-browser")
|
||||
if err := os.WriteFile(helper, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatalf("write helper: %v", err)
|
||||
}
|
||||
t.Setenv(EnvHelperManifest, `{"version":1,"helpers":{"agent-browser":{"command":`+quoteJSON(helper)+`,"pathPrepend":[`+quoteJSON(binDir)+`],"env":{"ZERO_HELPER_TEST":"1"}}}}`)
|
||||
|
||||
runner := &fakeCommandRunner{}
|
||||
browser := NewBrowser(BrowserOptions{
|
||||
Enabled: true,
|
||||
Runner: runner,
|
||||
})
|
||||
|
||||
if _, err := browser.Run(context.Background(), "snapshot"); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
if runner.path != helper {
|
||||
t.Fatalf("runner path = %q, want manifest helper %q", runner.path, helper)
|
||||
}
|
||||
if !envContains(runner.env, "ZERO_HELPER_TEST=1") {
|
||||
t.Fatalf("env = %#v, want ZERO_HELPER_TEST", runner.env)
|
||||
}
|
||||
pathValue := envValue(runner.env, "PATH")
|
||||
if !strings.HasPrefix(pathValue, binDir+string(os.PathListSeparator)) && pathValue != binDir {
|
||||
t.Fatalf("PATH overlay = %q, want prefix %q", pathValue, binDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserRunUsesManifestPrefixArgs(t *testing.T) {
|
||||
runner := &fakeCommandRunner{}
|
||||
t.Setenv(EnvHelperManifest, `{"version":1,"helpers":{"agent-browser":{"command":"cmd.exe","prefixArgs":["/d","/s","/c","C:\\zero\\agent-browser.cmd"]}}}`)
|
||||
browser := NewBrowser(BrowserOptions{
|
||||
Enabled: true,
|
||||
Runner: runner,
|
||||
})
|
||||
|
||||
if _, err := browser.Run(context.Background(), "open", "https://example.com"); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
if runner.path != "cmd.exe" {
|
||||
t.Fatalf("runner path = %q, want cmd.exe", runner.path)
|
||||
}
|
||||
want := []string{"/d", "/s", "/c", `C:\zero\agent-browser.cmd`, "open", "https://example.com"}
|
||||
if !reflect.DeepEqual(runner.args, want) {
|
||||
t.Fatalf("runner args = %#v, want %#v", runner.args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeEnvReplacesPathCaseInsensitivelyOnWindows(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("Windows env keys are case-insensitive")
|
||||
}
|
||||
env := mergeEnv([]string{`Path=C:\Windows`, "ZERO=1"}, []string{`PATH=C:\Zero`})
|
||||
pathCount := 0
|
||||
for _, item := range env {
|
||||
key, value, ok := strings.Cut(item, "=")
|
||||
if !ok || !strings.EqualFold(key, "PATH") {
|
||||
continue
|
||||
}
|
||||
pathCount++
|
||||
if value != `C:\Zero` {
|
||||
t.Fatalf("PATH value = %q, want C:\\Zero", value)
|
||||
}
|
||||
}
|
||||
if pathCount != 1 {
|
||||
t.Fatalf("PATH entries = %d in %#v, want 1", pathCount, env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserRunUsesAdjacentPackagedHelper(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
native := filepath.Join(root, "zero")
|
||||
if err := os.WriteFile(native, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatalf("write native: %v", err)
|
||||
}
|
||||
helpersDir := filepath.Join(root, "helpers")
|
||||
if err := os.MkdirAll(helpersDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir helpers: %v", err)
|
||||
}
|
||||
helper := filepath.Join(helpersDir, "agent-browser")
|
||||
if err := os.WriteFile(helper, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatalf("write helper: %v", err)
|
||||
}
|
||||
oldExecutablePath := executablePath
|
||||
executablePath = func() (string, error) { return native, nil }
|
||||
t.Cleanup(func() { executablePath = oldExecutablePath })
|
||||
|
||||
runner := &fakeCommandRunner{}
|
||||
browser := NewBrowser(BrowserOptions{
|
||||
Enabled: true,
|
||||
Runner: runner,
|
||||
})
|
||||
if _, err := browser.Run(context.Background(), "snapshot"); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
if runner.path != helper {
|
||||
t.Fatalf("runner path = %q, want adjacent helper %q", runner.path, helper)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserRunUsesAdjacentPackagedNodeBinHelper(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
native := filepath.Join(root, "zero")
|
||||
if err := os.WriteFile(native, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatalf("write native: %v", err)
|
||||
}
|
||||
binDir := filepath.Join(root, "helpers", "node_modules", ".bin")
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir helper bin: %v", err)
|
||||
}
|
||||
helper := filepath.Join(binDir, "agent-browser")
|
||||
if err := os.WriteFile(helper, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatalf("write helper: %v", err)
|
||||
}
|
||||
oldExecutablePath := executablePath
|
||||
executablePath = func() (string, error) { return native, nil }
|
||||
t.Cleanup(func() { executablePath = oldExecutablePath })
|
||||
|
||||
runner := &fakeCommandRunner{}
|
||||
browser := NewBrowser(BrowserOptions{
|
||||
Enabled: true,
|
||||
Runner: runner,
|
||||
})
|
||||
if _, err := browser.Run(context.Background(), "snapshot"); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
if runner.path != helper {
|
||||
t.Fatalf("runner path = %q, want packaged node bin helper %q", runner.path, helper)
|
||||
}
|
||||
}
|
||||
|
||||
func quoteJSON(value string) string {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func envContains(env []string, want string) bool {
|
||||
for _, item := range env {
|
||||
if item == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func envValue(env []string, key string) string {
|
||||
for _, item := range env {
|
||||
if got, value, ok := strings.Cut(item, "="); ok && got == key {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,9 +1,33 @@
|
||||
package modelregistry
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const DefaultModelID = "gpt-4.1"
|
||||
|
||||
// FallbackContextWindow is the assumed context window (max input tokens) for a
|
||||
// model that isn't in the curated registry and wasn't resolved from live provider
|
||||
// discovery — e.g. proxy/custom models like the GPT-5 Codex, xAI, or Ollama-cloud
|
||||
// variants. A positive value here is what enables agent-loop compaction (both the
|
||||
// proactive ~80% trigger and the reactive post-overflow recovery) for those models;
|
||||
// without it compaction stays disabled and long sessions overflow. It is only a
|
||||
// backstop: an exact window from the registry or live discovery always wins.
|
||||
const FallbackContextWindow = 200_000
|
||||
|
||||
// AgentContextWindow turns a resolved context window into the value used to enable
|
||||
// agent-loop compaction: the resolved window when known, else FallbackContextWindow
|
||||
// so compaction is enabled even for uncatalogued models. Kept separate from the
|
||||
// raw resolver so display gauges still show nothing (not a guessed denominator)
|
||||
// when the real window is unknown.
|
||||
func AgentContextWindow(resolved int) int {
|
||||
if resolved <= 0 {
|
||||
return FallbackContextWindow
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
const sourceLastVerified = "2026-06-04"
|
||||
|
||||
const (
|
||||
@@ -169,11 +193,44 @@ func (registry Registry) SupportsCapability(pattern string, capability ModelCapa
|
||||
}
|
||||
|
||||
func (registry Registry) ReasoningEfforts(pattern string) []ReasoningEffort {
|
||||
model, ok := registry.Get(pattern)
|
||||
if !ok {
|
||||
if model, ok := registry.Get(pattern); ok {
|
||||
return append([]ReasoningEffort{}, effectiveReasoningEfforts(model)...)
|
||||
}
|
||||
// Unknown model not in the curated catalog — e.g. a GPT-5 / Codex / o-series
|
||||
// variant served via the ChatGPT proxy or a custom OpenAI-compatible endpoint.
|
||||
// Infer from the name so /effort still shows controls for it.
|
||||
return reasoningEffortsForModelName(pattern)
|
||||
}
|
||||
|
||||
// reasoningEffortsForModelName infers reasoning-effort controls from a model name
|
||||
// for known reasoning families not present in the catalog. It returns nil for
|
||||
// non-reasoning models (e.g. GPT-4.1 / GPT-4o), so /effort stays empty there.
|
||||
func reasoningEffortsForModelName(name string) []ReasoningEffort {
|
||||
n := strings.ToLower(strings.TrimSpace(name))
|
||||
if i := strings.IndexByte(n, ':'); i >= 0 { // drop a "provider:" qualifier
|
||||
n = n[i+1:]
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(n, "gpt-5") || strings.HasPrefix(n, "gpt5"):
|
||||
// GPT-5 / Codex (gpt-5.x) add a "minimal" tier below low.
|
||||
return []ReasoningEffort{ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}
|
||||
case strings.Contains(n, "codex") || isOSeriesModelName(n):
|
||||
return []ReasoningEffort{ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return append([]ReasoningEffort{}, model.ReasoningEfforts...)
|
||||
}
|
||||
|
||||
// isOSeriesModelName reports whether name is an OpenAI o-series reasoning model
|
||||
// (o1/o3/o4, optionally with a -mini/-pro suffix), without matching unrelated
|
||||
// names that merely start with the same letters.
|
||||
func isOSeriesModelName(n string) bool {
|
||||
for _, prefix := range []string{"o1", "o3", "o4"} {
|
||||
if n == prefix || strings.HasPrefix(n, prefix+"-") || strings.HasPrefix(n, prefix+"mini") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (registry Registry) RequireProvider(pattern string, provider ProviderKind) (ModelEntry, error) {
|
||||
|
||||
@@ -92,11 +92,10 @@ func CalculateCost(model ModelEntry, usage zeroruntime.Usage) (CostBreakdown, er
|
||||
if uncachedInputTokens < 0 {
|
||||
uncachedInputTokens = 0
|
||||
}
|
||||
billableOutputTokens := outputTokens + reasoningTokens
|
||||
inputCost := costForTokens(uncachedInputTokens, inputRate)
|
||||
cachedInputCost := costForTokens(cachedInputTokens, cachedRate)
|
||||
cacheWriteCost := costForTokens(cacheWriteTokens, cacheWriteRate)
|
||||
outputCost := costForTokens(billableOutputTokens, outputRate)
|
||||
outputCost := costForTokens(outputTokens, outputRate)
|
||||
|
||||
breakdown := CostBreakdown{
|
||||
ModelID: model.ID,
|
||||
|
||||
@@ -94,6 +94,33 @@ func TestRegistryCostIgnoresCachedInputWithoutCachePricing(t *testing.T) {
|
||||
assertClose(t, cost.OutputCost, 0.03)
|
||||
}
|
||||
|
||||
func TestRegistryCostTreatsReasoningAsOutputBreakdown(t *testing.T) {
|
||||
registry, err := DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultRegistry returned error: %v", err)
|
||||
}
|
||||
|
||||
withReasoning, err := registry.EstimateCost("gpt-4.1", zeroruntime.Usage{
|
||||
InputTokens: 1_000,
|
||||
OutputTokens: 1_000,
|
||||
ReasoningTokens: 400,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EstimateCost(withReasoning) returned error: %v", err)
|
||||
}
|
||||
plain, err := registry.EstimateCost("gpt-4.1", zeroruntime.Usage{
|
||||
InputTokens: 1_000,
|
||||
OutputTokens: 1_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EstimateCost(plain) returned error: %v", err)
|
||||
}
|
||||
|
||||
if withReasoning.OutputCost != plain.OutputCost || withReasoning.TotalCost != plain.TotalCost {
|
||||
t.Fatalf("reasoning should be a breakdown of output cost, with=%#v plain=%#v", withReasoning, plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryCostSelectsTierForLongContextPricing(t *testing.T) {
|
||||
registry, err := DefaultRegistry()
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package modelregistry
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestReasoningEffortsFallbackForGPT5AndOSeries(t *testing.T) {
|
||||
reg, err := DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
want []ReasoningEffort
|
||||
}{
|
||||
{"gpt-5", []ReasoningEffort{ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}},
|
||||
{"gpt-5.5", []ReasoningEffort{ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}},
|
||||
{"gpt-5.4-mini", []ReasoningEffort{ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}},
|
||||
{"o3-mini", []ReasoningEffort{ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}},
|
||||
{"gpt-5.3-codex-spark", []ReasoningEffort{ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh}},
|
||||
{"gpt-4.1", nil}, // non-reasoning, registered: stays empty
|
||||
{"gpt-4o-mini", nil},
|
||||
{"ollama/llama3.1", nil},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := reg.ReasoningEfforts(c.name)
|
||||
if len(got) != len(c.want) {
|
||||
t.Fatalf("%s: got %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != c.want[i] {
|
||||
t.Fatalf("%s: got %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,10 @@ const (
|
||||
ReasoningEffortMedium ReasoningEffort = "medium"
|
||||
ReasoningEffortHigh ReasoningEffort = "high"
|
||||
ReasoningEffortXHigh ReasoningEffort = "xhigh"
|
||||
// ReasoningEffortMax is the top tier some providers expose above xhigh (e.g.
|
||||
// newer Anthropic models). Defined so a capability catalog that lists "max"
|
||||
// has a constant to map to; no curated model lists it yet.
|
||||
ReasoningEffortMax ReasoningEffort = "max"
|
||||
)
|
||||
|
||||
type ModelCapability string
|
||||
@@ -406,7 +410,7 @@ func ValidRuntimeProviderKind(provider ProviderKind) bool {
|
||||
|
||||
func ValidReasoningEffort(effort ReasoningEffort) bool {
|
||||
switch effort {
|
||||
case ReasoningEffortNone, ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh, ReasoningEffortXHigh:
|
||||
case ReasoningEffortNone, ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh, ReasoningEffortXHigh, ReasoningEffortMax:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -47,10 +47,13 @@ func (registry Registry) ResolveWithFallback(input string) (ModelEntry, string,
|
||||
|
||||
// EffectiveReasoningEffort returns the effort to use for a model: the requested
|
||||
// value if the model supports it, otherwise the model's default (or first
|
||||
// supported, or none).
|
||||
// supported, or none). It resolves the supported set through
|
||||
// effectiveReasoningEfforts so it sees the same name-based fallback the /effort
|
||||
// picker uses — the two must never disagree about which tiers a model supports.
|
||||
func EffectiveReasoningEffort(model ModelEntry, requested ReasoningEffort) ReasoningEffort {
|
||||
efforts := effectiveReasoningEfforts(model)
|
||||
if requested != "" {
|
||||
for _, effort := range model.ReasoningEfforts {
|
||||
for _, effort := range efforts {
|
||||
if effort == requested {
|
||||
return requested
|
||||
}
|
||||
@@ -59,8 +62,23 @@ func EffectiveReasoningEffort(model ModelEntry, requested ReasoningEffort) Reaso
|
||||
if model.DefaultReasoningEffort != "" {
|
||||
return model.DefaultReasoningEffort
|
||||
}
|
||||
if len(model.ReasoningEfforts) > 0 {
|
||||
return model.ReasoningEfforts[0]
|
||||
if len(efforts) > 0 {
|
||||
return efforts[0]
|
||||
}
|
||||
return ReasoningEffortNone
|
||||
}
|
||||
|
||||
// effectiveReasoningEfforts returns a model's supported reasoning efforts, falling
|
||||
// back to name-based inference (reasoningEffortsForModelName) when the catalog
|
||||
// entry enumerates none. Both the /effort picker (Registry.ReasoningEfforts) and
|
||||
// the run-time resolver (EffectiveReasoningEffort) read efforts through this
|
||||
// single helper, so the picker can never advertise a tier the resolver drops.
|
||||
func effectiveReasoningEfforts(model ModelEntry) []ReasoningEffort {
|
||||
if len(model.ReasoningEfforts) > 0 {
|
||||
return model.ReasoningEfforts
|
||||
}
|
||||
if efforts := reasoningEffortsForModelName(model.ID); len(efforts) > 0 {
|
||||
return efforts
|
||||
}
|
||||
return reasoningEffortsForModelName(model.APIModel)
|
||||
}
|
||||
@@ -79,3 +79,43 @@ func TestEffectiveReasoningEffort(t *testing.T) {
|
||||
t.Errorf("empty effort should use default low, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEffectiveReasoningEffortUsesNameFallback pins that the run-time resolver
|
||||
// honors the same name-based fallback the /effort picker uses. A model that the
|
||||
// catalog enumerates with no efforts but whose name is a known reasoning family
|
||||
// (e.g. a GPT-5 variant served via a proxy) must have its requested effort
|
||||
// honored — not silently coerced to "none" while the picker advertises controls.
|
||||
func TestEffectiveReasoningEffortUsesNameFallback(t *testing.T) {
|
||||
// Registered model, no explicit efforts, GPT-5 api model -> fallback infers
|
||||
// {minimal, low, medium, high}.
|
||||
gpt5 := ModelEntry{ID: "gpt-5-proxy", APIModel: "gpt-5", Provider: ProviderOpenAI}
|
||||
if got := EffectiveReasoningEffort(gpt5, ReasoningEffortHigh); got != ReasoningEffortHigh {
|
||||
t.Errorf("supported (via name fallback) effort = %q; want high", got)
|
||||
}
|
||||
if got := EffectiveReasoningEffort(gpt5, ReasoningEffortMinimal); got != ReasoningEffortMinimal {
|
||||
t.Errorf("minimal (via name fallback) = %q; want minimal", got)
|
||||
}
|
||||
// xhigh is outside the inferred set and there is no declared default, so it
|
||||
// coerces to the first inferred tier rather than to "none".
|
||||
if got := EffectiveReasoningEffort(gpt5, ReasoningEffortXHigh); got != ReasoningEffortMinimal {
|
||||
t.Errorf("unsupported effort on a fallback model = %q; want minimal (first inferred)", got)
|
||||
}
|
||||
|
||||
// Non-reasoning model: name matches nothing, stays "none".
|
||||
gpt4o := ModelEntry{ID: "gpt-4o", APIModel: "gpt-4o", Provider: ProviderOpenAI}
|
||||
if got := EffectiveReasoningEffort(gpt4o, ReasoningEffortHigh); got != ReasoningEffortNone {
|
||||
t.Errorf("non-reasoning model = %q; want none", got)
|
||||
}
|
||||
|
||||
// The picker and the resolver must agree on the supported set for the same id.
|
||||
reg := resolveTestRegistry(t)
|
||||
picker := reg.ReasoningEfforts("gpt-5") // unknown -> name fallback
|
||||
if len(picker) == 0 {
|
||||
t.Fatal("picker should advertise efforts for a gpt-5 name")
|
||||
}
|
||||
for _, tier := range picker {
|
||||
if got := EffectiveReasoningEffort(gpt5, tier); got != tier {
|
||||
t.Errorf("picker advertises %q but resolver returns %q", tier, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ func TestPackageBinPointsToNodeWrapper(t *testing.T) {
|
||||
Scripts map[string]string `json:"scripts"`
|
||||
License string `json:"license"`
|
||||
Files []string `json:"files"`
|
||||
Deps map[string]string `json:"dependencies"`
|
||||
Repository json.RawMessage `json:"repository"`
|
||||
Engines map[string]string `json:"engines"`
|
||||
}
|
||||
@@ -60,6 +61,11 @@ func TestPackageBinPointsToNodeWrapper(t *testing.T) {
|
||||
if pkg.Engines["node"] == "" {
|
||||
t.Fatalf("package.json engines.node is empty; the wrapper and installer require a modern Node")
|
||||
}
|
||||
for _, name := range []string{"agent-browser", "tuistory"} {
|
||||
if pkg.Deps[name] == "" {
|
||||
t.Fatalf("package.json dependencies is missing %q", name)
|
||||
}
|
||||
}
|
||||
wantFiles := map[string]bool{"bin/zero.js": false, "scripts/postinstall.mjs": false}
|
||||
for _, f := range pkg.Files {
|
||||
if _, ok := wantFiles[f]; ok {
|
||||
@@ -134,6 +140,26 @@ func TestPostinstallSkipsUnsupportedPlatform(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostinstallSkipsWindowsArm64(t *testing.T) {
|
||||
// (win32, arm64) resolves to a valid platform/arch but the release matrix has
|
||||
// no windows-arm64 artifact, so the install must skip gracefully (exit 0)
|
||||
// rather than hard-fail on a 404 download.
|
||||
stdout, stderr, err := runPostinstall(t,
|
||||
"ZERO_INSTALL_DRY_RUN=1",
|
||||
"ZERO_INSTALL_PLATFORM=win32",
|
||||
"ZERO_INSTALL_ARCH=arm64",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("windows-arm64 should exit 0, got err=%v stderr=%s", err, stderr)
|
||||
}
|
||||
if strings.TrimSpace(stdout) != "" {
|
||||
t.Fatalf("windows-arm64 should not print a plan, got %q", stdout)
|
||||
}
|
||||
if !strings.Contains(stderr, "no prebuilt binary for windows-arm64") {
|
||||
t.Fatalf("stderr=%q, want the windows-arm64 skip message", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostinstallHonorsSkipEnv(t *testing.T) {
|
||||
stdout, stderr, err := runPostinstall(t, "ZERO_SKIP_DOWNLOAD=1")
|
||||
if err != nil {
|
||||
@@ -218,6 +244,7 @@ func TestNodeWrapperReportsMissingNativeBinary(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
|
||||
defer cancel()
|
||||
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
|
||||
command.Env = append(withoutEnvKey(command.Env, "ZERO_LOCAL_CONTROL_HELPERS"), "ZERO_LOCAL_CONTROL_HELPERS=")
|
||||
output, err := command.CombinedOutput()
|
||||
if ctx.Err() != nil {
|
||||
t.Fatalf("wrapper timed out reporting missing native binary: %v; output: %s", ctx.Err(), output)
|
||||
@@ -261,6 +288,116 @@ func TestNodeWrapperLaunchesNativeBinary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeWrapperPassesLocalControlHelperManifest(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("mock executable fixture uses a POSIX shell script")
|
||||
}
|
||||
node := requireNode(t)
|
||||
wrapperPath := copyWrapperFixture(t)
|
||||
root := filepath.Dir(filepath.Dir(wrapperPath))
|
||||
nativePath := filepath.Join(root, "zero")
|
||||
if err := os.WriteFile(nativePath, []byte("#!/usr/bin/env sh\nprintf '%s\\n' \"$ZERO_LOCAL_CONTROL_HELPERS\"\n"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile native fixture: %v", err)
|
||||
}
|
||||
binDir := filepath.Join(root, "node_modules", ".bin")
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll node_modules/.bin: %v", err)
|
||||
}
|
||||
for _, name := range []string{"agent-browser", "tuistory"} {
|
||||
if err := os.WriteFile(filepath.Join(binDir, name), []byte("#!/usr/bin/env sh\n"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile helper %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
|
||||
defer cancel()
|
||||
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
|
||||
output, err := command.CombinedOutput()
|
||||
if ctx.Err() != nil {
|
||||
t.Fatalf("wrapper timed out launching native binary: %v; output: %s", ctx.Err(), output)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("wrapper returned error: %v; output: %s", err, output)
|
||||
}
|
||||
var manifest struct {
|
||||
Version int `json:"version"`
|
||||
Helpers map[string]struct {
|
||||
Command string `json:"command"`
|
||||
PrefixArgs []string `json:"prefixArgs"`
|
||||
PathPrepend []string `json:"pathPrepend"`
|
||||
} `json:"helpers"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(string(output))), &manifest); err != nil {
|
||||
t.Fatalf("manifest JSON = %q: %v", output, err)
|
||||
}
|
||||
if manifest.Version != 1 {
|
||||
t.Fatalf("manifest version = %d, want 1", manifest.Version)
|
||||
}
|
||||
for _, name := range []string{"agent-browser", "tuistory"} {
|
||||
helper, ok := manifest.Helpers[name]
|
||||
if !ok {
|
||||
t.Fatalf("manifest missing helper %q: %#v", name, manifest.Helpers)
|
||||
}
|
||||
wantCommand := canonicalTestPath(t, filepath.Join(binDir, name))
|
||||
if helper.Command != wantCommand {
|
||||
t.Fatalf("%s command = %q, want %q", name, helper.Command, wantCommand)
|
||||
}
|
||||
wantBinDir := canonicalTestPath(t, binDir)
|
||||
if len(helper.PathPrepend) != 1 || helper.PathPrepend[0] != wantBinDir {
|
||||
t.Fatalf("%s pathPrepend = %#v, want [%q]", name, helper.PathPrepend, wantBinDir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeWrapperClearsInheritedLocalControlHelperManifestWhenNoHelpers(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("mock executable fixture uses a POSIX shell script")
|
||||
}
|
||||
node := requireNode(t)
|
||||
wrapperPath := copyWrapperFixture(t)
|
||||
root := filepath.Dir(filepath.Dir(wrapperPath))
|
||||
nativePath := filepath.Join(root, "zero")
|
||||
if err := os.WriteFile(nativePath, []byte("#!/usr/bin/env sh\nif [ -n \"${ZERO_LOCAL_CONTROL_HELPERS+x}\" ]; then printf 'set\\n'; else printf 'unset\\n'; fi\n"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile native fixture: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
|
||||
defer cancel()
|
||||
command := nodeWrapperCommand(ctx, node, wrapperPath, "--version")
|
||||
command.Env = append(withoutEnvKey(command.Env, "ZERO_LOCAL_CONTROL_HELPERS"), `ZERO_LOCAL_CONTROL_HELPERS={"version":1,"helpers":{"agent-browser":{"command":"stale"}}}`)
|
||||
output, err := command.CombinedOutput()
|
||||
if ctx.Err() != nil {
|
||||
t.Fatalf("wrapper timed out launching native binary: %v; output: %s", ctx.Err(), output)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("wrapper returned error: %v; output: %s", err, output)
|
||||
}
|
||||
if got := strings.TrimSpace(string(output)); got != "unset" {
|
||||
t.Fatalf("ZERO_LOCAL_CONTROL_HELPERS state = %q, want unset", got)
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalTestPath(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
realPath, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
t.Fatalf("EvalSymlinks %s: %v", path, err)
|
||||
}
|
||||
return realPath
|
||||
}
|
||||
|
||||
func withoutEnvKey(env []string, key string) []string {
|
||||
prefix := key + "="
|
||||
out := make([]string, 0, len(env))
|
||||
for _, item := range env {
|
||||
if strings.HasPrefix(item, prefix) {
|
||||
continue
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func copyWrapperFixture(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := repoRoot(t)
|
||||
|
||||
@@ -58,6 +58,11 @@ type Descriptor struct {
|
||||
// OAuthDeviceFlow reports that RFC 8628 device-code login is supported (for
|
||||
// headless / SSH use) in addition to the browser flow.
|
||||
OAuthDeviceFlow bool
|
||||
|
||||
// Recommended marks the provider that should be surfaced first and badged
|
||||
// (★ … (recommended)) in every catalog-ordered list and picker. At most one
|
||||
// descriptor should set this.
|
||||
Recommended bool
|
||||
}
|
||||
|
||||
func RuntimeSupported(descriptor Descriptor) bool {
|
||||
@@ -82,6 +87,11 @@ func RuntimeUnsupportedReason(descriptor Descriptor) string {
|
||||
}
|
||||
|
||||
var descriptors = []Descriptor{
|
||||
// GitLawb OpenGateway — the recommended default. An OpenAI-compatible gateway
|
||||
// that smart-routes by model id across upstream providers (xiaomi-mimo,
|
||||
// minimax, qwen, google, nvidia, z-ai). Flat /v1/chat/completions with a
|
||||
// Bearer ogw_live_… key; listed first and badged in every picker.
|
||||
recommended(openAICompat("gitlawb-opengateway", "GitLawb OpenGateway", "https://opengateway.gitlawb.com/v1", "mimo-v2.5-pro", []string{"GITLAWB_OPENGATEWAY_API_KEY"}, "gitlawb opengateway", "opengateway")),
|
||||
openAI("openai", "OpenAI", "https://api.openai.com/v1", "gpt-4.1", []string{"OPENAI_API_KEY"}),
|
||||
anthropic("anthropic", "Anthropic", "https://api.anthropic.com", "claude-sonnet-4.5", []string{"ANTHROPIC_API_KEY"}),
|
||||
google("google", "Google", "https://generativelanguage.googleapis.com", "gemini-2.5-pro", []string{"GEMINI_API_KEY", "GOOGLE_API_KEY"}, "gemini"),
|
||||
@@ -104,7 +114,7 @@ var descriptors = []Descriptor{
|
||||
// way in; RequiresAuth is forced true so catalog consumers know the provider
|
||||
// needs an interactive login before a request will succeed.
|
||||
func() Descriptor {
|
||||
d := openAICompat("chatgpt", "ChatGPT", "https://chatgpt.com/backend-api/codex", "gpt-5", nil)
|
||||
d := openAICompat("chatgpt", "ChatGPT", "https://chatgpt.com/backend-api/codex", "gpt-5.5", nil)
|
||||
d.RequiresAuth = true
|
||||
return oauthProvider(d, false, false)
|
||||
}(),
|
||||
@@ -124,7 +134,6 @@ var descriptors = []Descriptor{
|
||||
openAICompat("xiaomi-mimo", "Xiaomi MiMo", "https://api.mimo.xiaomi.com/openai/v1", "mimo-vl", []string{"MIMO_API_KEY", "XIAOMI_API_KEY"}, "xiaomi mimo"),
|
||||
openAICompat("bankr", "Bankr", "https://api.bankr.bot/v1", "bankr-large", []string{"BANKR_API_KEY"}),
|
||||
openAICompat("zai", "Z.ai", "https://open.bigmodel.cn/api/paas/v4", "glm-4.5", []string{"ZAI_API_KEY", "ZHIPU_API_KEY"}),
|
||||
openAICompat("gitlawb-opengateway", "GitLawb OpenGateway", "https://gateway.gitlawb.com/v1", "gpt-4.1", []string{"GITLAWB_OPENGATEWAY_API_KEY"}, "gitlawb opengateway"),
|
||||
openAICompat("atomic-chat", "Atomic Chat", "https://api.atomic.chat/v1", "gpt-4.1", []string{"ATOMIC_CHAT_API_KEY"}),
|
||||
// ChatGPT subscription via a local OAuth proxy. A ChatGPT (Plus/Pro) OAuth
|
||||
// token only works against ChatGPT's own backend (which is Cloudflare-gated to
|
||||
@@ -266,6 +275,13 @@ func google(id string, name string, baseURL string, model string, env []string,
|
||||
}
|
||||
}
|
||||
|
||||
// recommended marks a descriptor as the recommended default so list/picker
|
||||
// surfaces sort it first and render the ★ … (recommended) badge.
|
||||
func recommended(descriptor Descriptor) Descriptor {
|
||||
descriptor.Recommended = true
|
||||
return descriptor
|
||||
}
|
||||
|
||||
// oauthProvider marks a descriptor as OAuth-capable. mintsKey => the flow returns
|
||||
// an API key (OpenRouter); deviceFlow => RFC 8628 device code is also supported.
|
||||
func oauthProvider(descriptor Descriptor, mintsKey bool, deviceFlow bool) Descriptor {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
)
|
||||
|
||||
var expectedCatalogIDs = []string{
|
||||
"gitlawb-opengateway",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"google",
|
||||
@@ -33,7 +34,6 @@ var expectedCatalogIDs = []string{
|
||||
"xiaomi-mimo",
|
||||
"bankr",
|
||||
"zai",
|
||||
"gitlawb-opengateway",
|
||||
"atomic-chat",
|
||||
"chatgpt-proxy",
|
||||
"custom-openai-compatible",
|
||||
@@ -61,6 +61,41 @@ func TestAllHasStableUniqueIDs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendedProviderIsFirstAndUnique(t *testing.T) {
|
||||
descriptors := All()
|
||||
if len(descriptors) == 0 {
|
||||
t.Fatal("All() returned no descriptors")
|
||||
}
|
||||
if !descriptors[0].Recommended {
|
||||
t.Fatalf("All()[0] = %q, want it to be the recommended provider", descriptors[0].ID)
|
||||
}
|
||||
if descriptors[0].ID != "gitlawb-opengateway" {
|
||||
t.Fatalf("recommended provider = %q, want %q", descriptors[0].ID, "gitlawb-opengateway")
|
||||
}
|
||||
recommended := 0
|
||||
for _, descriptor := range descriptors {
|
||||
if descriptor.Recommended {
|
||||
recommended++
|
||||
}
|
||||
}
|
||||
if recommended != 1 {
|
||||
t.Fatalf("recommended descriptor count = %d, want exactly 1", recommended)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendedProviderEndpoint(t *testing.T) {
|
||||
descriptor, ok := Get("gitlawb-opengateway")
|
||||
if !ok {
|
||||
t.Fatal("gitlawb-opengateway not found in catalog")
|
||||
}
|
||||
if descriptor.DefaultBaseURL != "https://opengateway.gitlawb.com/v1" {
|
||||
t.Fatalf("OpenGateway base URL = %q, want %q", descriptor.DefaultBaseURL, "https://opengateway.gitlawb.com/v1")
|
||||
}
|
||||
if descriptor.Transport != TransportOpenAICompatible {
|
||||
t.Fatalf("OpenGateway transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogDescriptorsExposeRequiredDefaults(t *testing.T) {
|
||||
for _, descriptor := range All() {
|
||||
if descriptor.ID == "" {
|
||||
@@ -229,7 +264,7 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
|
||||
TransportBedrock: {"bedrock"},
|
||||
TransportVertex: {"vertex"},
|
||||
TransportAnthropicCompat: {"minimax", "custom-anthropic-compatible"},
|
||||
TransportOpenAICompat: {"ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "gitlawb-opengateway", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
|
||||
TransportOpenAICompat: {"gitlawb-opengateway", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
|
||||
}
|
||||
|
||||
for transport, wantIDs := range cases {
|
||||
|
||||
@@ -326,6 +326,9 @@ func healthRequest(ctx context.Context, profile config.ProviderProfile, kind con
|
||||
return nil, false, err
|
||||
}
|
||||
endpoint := baseURL + healthPath(kind)
|
||||
if override, ok := overrideHealthEndpoint(profile, baseURL); ok {
|
||||
endpoint = override
|
||||
}
|
||||
// Loopback is permitted only when the user's OWN configured base_url is loopback
|
||||
// (a local provider like Ollama/LM Studio) — never for a redirect target. This is
|
||||
// the flag returned to the caller so the dial path applies the same policy. (AUDIT-H1)
|
||||
@@ -592,6 +595,22 @@ func blockedAddrReason(addr netip.Addr) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// overrideHealthEndpoint returns a provider-specific connectivity probe URL when
|
||||
// the default {baseURL}+/models path does not exist. GitLawb OpenGateway is a
|
||||
// smart-routing gateway whose flat /v1/models endpoint 404s by design ("Use
|
||||
// /v1/<provider>/<path>"), so probe its public /health endpoint at the host root
|
||||
// instead, which reports real reachability without a per-model call.
|
||||
func overrideHealthEndpoint(profile config.ProviderProfile, baseURL string) (string, bool) {
|
||||
if profile.CatalogID != "gitlawb-opengateway" {
|
||||
return "", false
|
||||
}
|
||||
parsed, err := url.Parse(baseURL)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return "", false
|
||||
}
|
||||
return parsed.Scheme + "://" + parsed.Host + "/health", true
|
||||
}
|
||||
|
||||
func healthPath(kind config.ProviderKind) string {
|
||||
switch kind {
|
||||
case config.ProviderKindAnthropic, config.ProviderKindAnthropicCompat:
|
||||
|
||||
@@ -170,3 +170,20 @@ func TestProbeConnectivityRedactsProviderErrorDetails(t *testing.T) {
|
||||
t.Fatalf("custom header secret leaked in connectivity check: %s", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverrideHealthEndpointOpenGateway(t *testing.T) {
|
||||
got, ok := overrideHealthEndpoint(config.ProviderProfile{CatalogID: "gitlawb-opengateway"}, "https://opengateway.gitlawb.com/v1")
|
||||
if !ok {
|
||||
t.Fatal("expected OpenGateway to override the health endpoint")
|
||||
}
|
||||
if got != "https://opengateway.gitlawb.com/health" {
|
||||
t.Fatalf("OpenGateway health endpoint = %q, want host-root /health", got)
|
||||
}
|
||||
|
||||
if _, ok := overrideHealthEndpoint(config.ProviderProfile{CatalogID: "openai"}, "https://api.openai.com/v1"); ok {
|
||||
t.Fatal("non-OpenGateway providers must not override the health endpoint")
|
||||
}
|
||||
if _, ok := overrideHealthEndpoint(config.ProviderProfile{CatalogID: "gitlawb-opengateway"}, "::not a url"); ok {
|
||||
t.Fatal("an unparseable base URL must not produce an override")
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,12 @@ var curatedModels = map[string][]Model{
|
||||
{ID: "minimax/minimax-m2.1", Description: "agentic coding model"},
|
||||
{ID: "deepseek/deepseek-chat", Description: "coding model"},
|
||||
},
|
||||
"chatgpt": {
|
||||
{ID: "gpt-5.5", Description: "recommended Codex model"},
|
||||
{ID: "gpt-5.4", Description: "strong Codex model"},
|
||||
{ID: "gpt-5.4-mini", Description: "fast Codex model"},
|
||||
{ID: "gpt-5.3-codex-spark", Description: "research preview fast Codex model"},
|
||||
},
|
||||
"deepseek": {
|
||||
{ID: "deepseek-chat", Description: "catalog default"},
|
||||
{ID: "deepseek-reasoner", Description: "reasoning model"},
|
||||
@@ -123,10 +129,18 @@ var curatedModels = map[string][]Model{
|
||||
{ID: "glm-4.6", Description: "latest general model"},
|
||||
{ID: "glm-z1-air", Description: "reasoning model"},
|
||||
},
|
||||
// OpenGateway smart-routes by model id across its upstream providers
|
||||
// (see /health: xiaomi-mimo, minimax, qwen, google, nvidia, z-ai). These are
|
||||
// the curated coding defaults; the gateway accepts any model its upstreams
|
||||
// expose, so users can also type an id the picker doesn't list.
|
||||
"gitlawb-opengateway": {
|
||||
{ID: "gpt-4.1", Description: "catalog default"},
|
||||
{ID: "claude-sonnet-4.5", Description: "coding model"},
|
||||
{ID: "gemini-2.5-pro", Description: "long-context model"},
|
||||
{ID: "mimo-v2.5-pro", Description: "catalog default (Xiaomi MiMo)"},
|
||||
{ID: "mimo-v2.5-pro-ultraspeed", Description: "fast model (Xiaomi MiMo)"},
|
||||
{ID: "MiniMax-M3", Description: "MiniMax model"},
|
||||
{ID: "qwen-plus", Description: "Qwen model"},
|
||||
{ID: "gemini-2.5-pro", Description: "long-context model (Google)"},
|
||||
{ID: "glm-4.6", Description: "Z.ai model"},
|
||||
{ID: "nvidia/llama-3.1-nemotron-70b-instruct", Description: "NVIDIA NIM model"},
|
||||
},
|
||||
"atomic-chat": {
|
||||
{ID: "gpt-4.1", Description: "catalog default"},
|
||||
|
||||
@@ -27,6 +27,11 @@ func TestModelsAreProviderScoped(t *testing.T) {
|
||||
want: []string{"llama-3.3-70b-versatile", "openai/gpt-oss-120b"},
|
||||
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
|
||||
},
|
||||
{
|
||||
provider: "chatgpt",
|
||||
want: []string{"gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark"},
|
||||
notWant: []string{"gpt-5", "gpt-4.1", "openai/gpt-4.1"},
|
||||
},
|
||||
{
|
||||
provider: "mistral",
|
||||
want: []string{"mistral-large-latest", "codestral-latest"},
|
||||
|
||||
@@ -54,7 +54,7 @@ func TestDefaultedOpenGatewayURL(t *testing.T) {
|
||||
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "https://gw.example.com/v1"}, ""); got != "https://gw.example.com/zero/models.json" {
|
||||
t.Fatalf("derived = %q", got)
|
||||
}
|
||||
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "::not a url"}, ""); got != "https://gateway.gitlawb.com/zero/models.json" {
|
||||
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "::not a url"}, ""); got != "https://opengateway.gitlawb.com/zero/models.json" {
|
||||
t.Fatalf("fallback = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 100 of 311 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user