mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1141d258ec | ||
|
|
1b1abd5bf1 | ||
|
|
ffaa3a9a48 | ||
|
|
c3646e3a94 | ||
|
|
b46e952282 | ||
|
|
3c2cd1d491 | ||
|
|
0aa45695a8 | ||
|
|
efe48ade0c | ||
|
|
e52fc3aebb | ||
|
|
5b348056a4 | ||
|
|
bd7cc5665f | ||
|
|
29246e5659 | ||
|
|
33da6b6454 | ||
|
|
a2d902c38f | ||
|
|
990e3c439b | ||
|
|
ac24f64abe | ||
|
|
9047977895 | ||
|
|
f7ee189ba5 | ||
|
|
bfbdbb10f6 | ||
|
|
b2bb6c4b41 | ||
|
|
72a4229c2a | ||
|
|
3671245dbe | ||
|
|
a981120432 | ||
|
|
fbbfdbf3f2 | ||
|
|
09888e64e8 | ||
|
|
f5038151c8 | ||
|
|
c572791edf | ||
|
|
96a851df2d | ||
|
|
ead2b9239e | ||
|
|
17e7be2743 | ||
|
|
cf54e2cb0e | ||
|
|
d3e7b89eb0 | ||
|
|
9e800b4ffc | ||
|
|
7691ff18f9 | ||
|
|
b0a280eaf6 | ||
|
|
1c30f4c117 | ||
|
|
b237e9849a | ||
|
|
b64a9d3de9 | ||
|
|
eed61cf8ec | ||
|
|
ac6addaa83 | ||
|
|
03784268ac | ||
|
|
d739181951 | ||
|
|
ab3d09d78e | ||
|
|
673fce11bd | ||
|
|
f00e83a5ed | ||
|
|
1f92b25e8c | ||
|
|
a4279662b9 | ||
|
|
2cf6fd2334 | ||
|
|
995b9086c7 | ||
|
|
4582727f95 | ||
|
|
b903825139 | ||
|
|
eb608741b0 | ||
|
|
2d62387e08 | ||
|
|
a1b9bbb07c | ||
|
|
4cb1f10952 | ||
|
|
c0fedade8d | ||
|
|
7d66942564 | ||
|
|
29fc29a41d | ||
|
|
7c36805505 | ||
|
|
922a5bab5f |
No files matched your search
@@ -0,0 +1,54 @@
|
||||
name: Bug report
|
||||
description: Report a reproducible problem in Zero
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for filing a bug. Clear, reproducible reports are the most useful way to help the
|
||||
project. Please do **not** report security vulnerabilities here — see SECURITY.md.
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version / branch / commit
|
||||
description: Output of `zero version`, or the branch/commit you built from.
|
||||
placeholder: "zero dev (commit abc1234) / main"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: env
|
||||
attributes:
|
||||
label: OS and environment
|
||||
description: Operating system, terminal, and any relevant environment details.
|
||||
placeholder: "macOS 14 / iTerm2 / Go 1.25.1"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: A minimal reproduction case when possible.
|
||||
placeholder: |
|
||||
1. Run `zero ...`
|
||||
2. ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant logs, screenshots, or error messages
|
||||
description: Redact any secrets (API keys, tokens) before pasting.
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Security vulnerability
|
||||
url: https://github.com/Gitlawb/zero/security/advisories/new
|
||||
about: Report security issues privately via GitHub Security Advisories (see SECURITY.md). Do not file a public issue.
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Feature request
|
||||
description: Propose a new capability or change
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please do not begin implementation until the issue has been reviewed and marked with the
|
||||
`issue-approved` label (see CONTRIBUTING.md).
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem
|
||||
description: The problem you are trying to solve.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Requested behavior
|
||||
description: The behavior or functionality you are requesting.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: why
|
||||
attributes:
|
||||
label: Why it belongs in Zero
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives or workarounds considered
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Examples, screenshots, or prior discussion
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,16 @@
|
||||
## Summary
|
||||
|
||||
<!-- What does this change do, and why? -->
|
||||
|
||||
## Linked issue
|
||||
|
||||
<!-- Per CONTRIBUTING.md, PRs need an approved parent issue with the `issue-approved` label. -->
|
||||
Fixes #
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] The linked issue already has the `issue-approved` label.
|
||||
- [ ] `go build ./...`, `go vet ./...`, and `go test ./...` pass locally.
|
||||
- [ ] `gofmt` clean.
|
||||
- [ ] Tests added/updated for the change (and run under `-race` where relevant).
|
||||
- [ ] UI changes include screenshots or a short recording where possible.
|
||||
@@ -15,6 +15,8 @@ jobs:
|
||||
package:
|
||||
name: Package (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: write # create/upload the GitHub Release on a tag push
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -43,6 +45,21 @@ jobs:
|
||||
- name: Test
|
||||
run: go test ./...
|
||||
|
||||
# zero-release names assets zero-v<package.json version>-… while the release
|
||||
# is published under the pushed git tag. If they disagree, install.sh/install.ps1
|
||||
# and the npm postinstall all build a download URL that 404s. Fail fast so a
|
||||
# forgotten version bump can never publish mis-versioned, un-installable assets.
|
||||
- name: Check tag matches package.json version
|
||||
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
|
||||
shell: bash
|
||||
run: |
|
||||
pkg_version="$(node -p "require('./package.json').version")"
|
||||
if [ "v$pkg_version" != "$GITHUB_REF_NAME" ]; then
|
||||
echo "::error::tag $GITHUB_REF_NAME does not match package.json version v$pkg_version — bump package.json before tagging so the release assets and the npm package agree." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "tag $GITHUB_REF_NAME matches package.json version v$pkg_version."
|
||||
|
||||
- name: Package release artifact
|
||||
run: go run ./cmd/zero-release package
|
||||
|
||||
@@ -55,3 +72,18 @@ jobs:
|
||||
name: zero-${{ runner.os }}-${{ runner.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
# On a `v*` tag push, publish the packaged archive + .sha256 as GitHub Release
|
||||
# assets so the documented `scripts/install.sh` / `install.ps1` path (which reads
|
||||
# /releases/latest) works. Uses the preinstalled `gh` (no new action dependency);
|
||||
# each matrix OS creates the release if absent (best-effort) then uploads its own
|
||||
# assets with --clobber. NOTE: maintainers must push the tag — this never tags.
|
||||
- name: Publish to GitHub Release
|
||||
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
gh release create "$tag" --repo "$GITHUB_REPOSITORY" --title "$tag" --generate-notes 2>/dev/null || true
|
||||
gh release upload "$tag" dist/release/* --repo "$GITHUB_REPOSITORY" --clobber
|
||||
@@ -38,3 +38,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
/zero
|
||||
/zero.exe
|
||||
/snake-game.html
|
||||
|
||||
# Superpowers skill artifacts (brainstorm mockups, specs, plans) — local only
|
||||
.superpowers/
|
||||
docs/superpowers/
|
||||
@@ -1,16 +1,320 @@
|
||||
---
|
||||
description: Use Go-native project commands; Node is only for the npm wrapper.
|
||||
globs: "*.go, *.js, package.json, .github/workflows/*.yml, docs/*.md"
|
||||
description: Extending Zero — how to write AGENTS.md files, custom specialists, skills, hooks, MCP servers, and plugins for the open-source CLI coding agent.
|
||||
globs: "*.go, *.js, *.md, *.json, *.toml, *.yaml, *.yml"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
Default to Go commands in this repository.
|
||||
# Extending Zero
|
||||
|
||||
- Use `go run ./cmd/zero` to launch the local CLI.
|
||||
- Use `go test ./...` for the repository test suite.
|
||||
- Use `go run ./cmd/zero-release build` to build the release-facing binary.
|
||||
- Use `go run ./cmd/zero-release smoke` to smoke-test the built binary.
|
||||
- Use `go run ./cmd/zero-release package` and `go run ./cmd/zero-release verify` for release artifact checks.
|
||||
- Use `go run ./cmd/zero-perf-bench` for performance smoke work.
|
||||
- Do not add Bun or TypeScript build/test dependencies for repository validation.
|
||||
- Use Node only when directly exercising the npm wrapper at `bin/zero.js`.
|
||||
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.
|
||||
|
||||
If you only want to *use* Zero, the [README](README.md) is enough. This page is for the other three jobs:
|
||||
|
||||
1. Tell the agent about *your* project (drop an `AGENTS.md` in your repo).
|
||||
2. Add new specialist sub-agents.
|
||||
3. Wire Zero into the rest of your toolchain (MCP, skills, hooks, plugins).
|
||||
|
||||
## 1. Drop a project `AGENTS.md`
|
||||
|
||||
When Zero starts in a directory, it looks for project-level instructions and injects them into the system prompt. The lookup walks from your current working directory **up to the nearest git root** and reads the first matching file at each level — general rules at the repo root, more specific rules in sub-trees. Files are labeled with their directory in the prompt (e.g. `## Project guidelines (services/api/AGENTS.md)`).
|
||||
|
||||
Accepted file names, in priority order at each level:
|
||||
|
||||
| Path | Notes |
|
||||
| --- | --- |
|
||||
| `./AGENTS.md` | The classic spot — committed to your repo, shared with the team. |
|
||||
| `./ZERO.md` | Brand-specific alias. Same format, lower priority. |
|
||||
| `./.zero/AGENTS.md` | Project-local, hidden, gitignored. Personal notes that stay out of git. |
|
||||
|
||||
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.MD` (uppercase `MD`) — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
|
||||
|
||||
Both files use the same format. YAML frontmatter is optional; the markdown body is loaded as instructions for the agent. Zero reads the file once at session start, so changes take effect on the next `zero` launch — not mid-session.
|
||||
|
||||
```markdown
|
||||
# Project conventions for <your project>
|
||||
|
||||
- Build with `make`, not `go build` directly.
|
||||
- Tests live next to the source file (`foo_test.go` next to `foo.go`).
|
||||
- Run `make lint` before opening a PR.
|
||||
- Never edit files under `third_party/` — those are vendored.
|
||||
```
|
||||
|
||||
Tips:
|
||||
|
||||
- Keep each file under ~8 KiB. Zero caps the **total** across all matched files at 32 KiB; everything past the cap is dropped.
|
||||
- Re-state rules in the imperative voice: "Run `make lint`", not "you should consider running the linter".
|
||||
- Don't put secrets, model IDs, or environment-specific paths in `AGENTS.md`. Use `config.json` for those.
|
||||
- In a monorepo, drop a narrower `AGENTS.md` in each sub-tree (e.g. `services/api/AGENTS.md`). Zero picks those up automatically when you launch from inside the sub-tree.
|
||||
- A YAML frontmatter block (`---\n...\n---`) at the top is preserved verbatim in the injected prompt but is not parsed for `globs:` or `alwaysApply:` scoping today — keep the body self-contained.
|
||||
|
||||
## 2. Custom specialists
|
||||
|
||||
Specialists are Zero's sub-agents. Three scopes, in priority order:
|
||||
|
||||
| Scope | Path | Shared? |
|
||||
| --- | --- | --- |
|
||||
| Built-in | compiled into Zero | yes — `worker`, `explorer`, `code-review` |
|
||||
| User | `~/.config/zero/specialists/*.md` | no — your machine only |
|
||||
| Project | `./.zero/specialists/*.md` | yes — the repo team |
|
||||
|
||||
Project overrides user overrides built-in when names collide.
|
||||
|
||||
A specialist is a markdown manifest with frontmatter and a system prompt:
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Reviews API changes for breaking-change risk and missing tests.
|
||||
tools: read-only,plan
|
||||
---
|
||||
|
||||
You review API changes. For every changed hunk in `internal/api/` or any file
|
||||
that ends in `_api.go`:
|
||||
|
||||
1. Confirm the public signature is backward-compatible, or note the breaking
|
||||
change explicitly with the migration path.
|
||||
2. Confirm a corresponding test exists in `internal/api/*_test.go` and that
|
||||
the new behaviour is exercised.
|
||||
3. Flag any new exported symbol without a doc comment.
|
||||
|
||||
Reply with one JSON object per finding: `{"file", "line", "severity", "message", "fix"}`.
|
||||
```
|
||||
|
||||
CLI management (the prompt is passed inline via `--prompt`):
|
||||
|
||||
```bash
|
||||
zero specialist list
|
||||
zero specialist show api-reviewer
|
||||
zero specialist create api-reviewer \
|
||||
--project \
|
||||
--description "Reviews API changes" \
|
||||
--tools read-only,plan \
|
||||
--prompt "$(cat api-reviewer.md)"
|
||||
zero specialist edit api-reviewer --project
|
||||
zero specialist delete api-reviewer --project
|
||||
zero specialist path # prints the resolved specialists directory
|
||||
```
|
||||
|
||||
The full format spec (frontmatter fields, tool scopes, prompt conventions) is in [`docs/SPECIALISTS.md`](docs/SPECIALISTS.md).
|
||||
|
||||
> **Roadmap.** An in-UI specialist manager (create / edit / delete / preview) is on the backlog. Today you use the `zero specialist` CLI subcommands above.
|
||||
|
||||
## 3. Skills
|
||||
|
||||
Skills are markdown instruction packs the agent can pull in on demand. Each skill is a directory containing a `SKILL.md`. Skills are **user-level only** in this version — there's no project-scoped skill directory yet, so anything you want shared with the team goes in `AGENTS.md` (section 1) or as a hook (section 4).
|
||||
|
||||
Discovery root: `$ZERO_SKILLS_DIR` → `$XDG_DATA_HOME/zero/skills` → `~/.local/share/zero/skills/`. A missing directory is fine — Zero just reports "no skills".
|
||||
|
||||
```
|
||||
~/.local/share/zero/skills/
|
||||
run-benchmarks/
|
||||
SKILL.md
|
||||
write-changelog/
|
||||
SKILL.md
|
||||
```
|
||||
|
||||
`SKILL.md` format:
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Run the project's benchmark suite and summarize the deltas.
|
||||
---
|
||||
|
||||
# Run benchmarks
|
||||
|
||||
1. `make bench` — captures the wall-clock and RSS before and after.
|
||||
2. `benchstat before.txt after.txt` — diffs the two.
|
||||
3. Report any regression > 5% with the function name and the previous value.
|
||||
```
|
||||
|
||||
Only `name` and `description` are recognized in the frontmatter today. The `name` defaults to the directory name. If two skills declare the same name, the one in the lexicographically-first directory wins — duplicates are dropped silently. Plugin-declared skills (section 6) are not yet merged into the loader, so a `skills:` entry inside a plugin's `plugin.json` is not visible to the `skill` tool today.
|
||||
|
||||
The `skill` core tool lets the agent load any discovered skill by name.
|
||||
|
||||
## 4. Hooks
|
||||
|
||||
Hooks fire shell commands on lifecycle events. Configure them in JSON:
|
||||
|
||||
- User: `~/.config/zero/hooks.json`
|
||||
- Project: `./.zero/hooks.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"hooks": [
|
||||
{
|
||||
"id": "block-rm-rf",
|
||||
"event": "beforeTool",
|
||||
"matcher": "bash",
|
||||
"command": "/usr/local/bin/zero-hook-block-rmrf.sh",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"id": "log-session",
|
||||
"event": "sessionStart",
|
||||
"command": "/usr/local/bin/zero-hook-log.sh",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The `args` array (when present) is passed verbatim to `exec.CommandContext`. The actual hook payload — event name, matcher, tool call id, tool name, tool input, tool output, status — is delivered to the command as **JSON on stdin**, not via `${...}` substitution. A typical handler reads stdin and decides what to do:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# /usr/local/bin/zero-hook-block-rmrf.sh
|
||||
set -euo pipefail
|
||||
payload="$(cat)"
|
||||
if printf '%s' "$payload" | grep -q '"input":"[^"]*rm[[:space:]]+-rf'; then
|
||||
echo "refusing rm -rf" >&2
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
Events the agent emits (in dispatch order):
|
||||
|
||||
| Event | Fires when | Matcher allowed? |
|
||||
| --- | --- | --- |
|
||||
| `beforeTool` | A tool is about to run | yes (tool name) |
|
||||
| `afterTool` | A tool just returned | yes (tool name) |
|
||||
| `sessionStart` | A session begins | no |
|
||||
| `sessionEnd` | A session ends | no |
|
||||
| `specialistStart` | A sub-agent is spawned | yes (specialist name) |
|
||||
| `specialistStop` | A sub-agent ends | yes (specialist name) |
|
||||
|
||||
A hook's exit code decides what happens next: `0` continues, non-zero blocks the tool call (`beforeTool`) or surfaces an error (`afterTool`). Hook execution is recorded in the audit log; the audit is reachable from the agent's view of past actions, not from a dedicated `zero doctor` check.
|
||||
|
||||
> **Roadmap.** An in-UI hooks manager is on the backlog. Today you edit the JSON directly.
|
||||
|
||||
## 5. MCP — Model Context Protocol
|
||||
|
||||
Zero is both an **MCP client** (it can call external MCP servers) and an **MCP server** (other agents can call its tools).
|
||||
|
||||
### As a client — configure MCP servers in `config.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"docs": {
|
||||
"type": "stdio",
|
||||
"command": "docs-mcp",
|
||||
"args": ["--port", "7777"]
|
||||
},
|
||||
"github": {
|
||||
"type": "http",
|
||||
"url": "https://api.example.com/mcp",
|
||||
"headers": { "Authorization": "Bearer YOUR_TOKEN_HERE" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Manage via CLI:
|
||||
|
||||
```bash
|
||||
zero mcp add docs --type stdio -- docs-mcp --port 7777
|
||||
zero mcp add github --type http --url https://api.example.com/mcp \
|
||||
--header "Authorization=Bearer YOUR_TOKEN_HERE"
|
||||
zero mcp list
|
||||
zero mcp check docs
|
||||
zero mcp remove github
|
||||
zero mcp oauth login github
|
||||
```
|
||||
|
||||
Servers are merged from user and project configs (project wins on conflicts). Token-bearing values in `config.json` are sent verbatim — there is no `${env:...}` expansion — so prefer one of:
|
||||
|
||||
- A wrapper script that sources the secret and execs the real command.
|
||||
- A `--header` value produced by command substitution (`"Authorization=Bearer $(print-token)"`) in a private shell config that you keep out of git.
|
||||
- A secret manager that injects the env var your MCP server reads on its own (the `command` and `args` then run inside that environment).
|
||||
|
||||
### As a server — expose Zero's tools to another agent
|
||||
|
||||
```bash
|
||||
zero serve --mcp
|
||||
```
|
||||
|
||||
The server speaks MCP over stdio. Configure it from the receiving side as a `stdio` server whose command is `zero serve --mcp`.
|
||||
|
||||
## 6. Plugins
|
||||
|
||||
A plugin is a self-contained directory that bundles tools, hooks, and skills for one capability. Plugins live at:
|
||||
|
||||
- User: `~/.config/zero/plugins/<id>/`
|
||||
- Project: `./.zero/plugins/<id>/`
|
||||
|
||||
Each plugin has a `plugin.json` manifest:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "github-pr-review",
|
||||
"name": "GitHub PR Review",
|
||||
"description": "Adds review tools for GitHub PRs.",
|
||||
"version": "1.0.0",
|
||||
"tools": [
|
||||
{ "name": "list_prs", "command": "./tools/list_prs.sh" }
|
||||
],
|
||||
"hooks": [
|
||||
{ "name": "pre-merge-check", "event": "beforeTool", "command": "./hooks/pre-merge.sh" }
|
||||
],
|
||||
"skills": [
|
||||
{ "path": "./skills/review-checklist/SKILL.md" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Install and manage:
|
||||
|
||||
```bash
|
||||
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
|
||||
zero plugins list
|
||||
zero plugins remove github-pr-review # alias: rm
|
||||
```
|
||||
|
||||
A plugin is enabled by being present in the plugins directory and disabled by removing it (or by the user setting `"enabled": false` in its `plugin.json`). Plugins are not enabled or disabled by a CLI subcommand today.
|
||||
|
||||
Plugin commands run with the plugin directory as their working directory. Use relative paths; the loader resolves them at activation time.
|
||||
|
||||
> **Roadmap.** An in-UI plugins manager (browse, install, enable / disable) is on the backlog. Today you use the `zero plugins` CLI subcommands above. Skills declared inside a plugin's `plugin.json` are not yet merged into the `skill` tool's discovery (see section 3).
|
||||
|
||||
## 7. Configuration locations
|
||||
|
||||
Three layers, applied in order (later layers override earlier ones):
|
||||
|
||||
| Layer | Path | Notes |
|
||||
| --- | --- | --- |
|
||||
| Built-in defaults | compiled in | Lowest priority. |
|
||||
| User config | `~/.config/zero/config.json` | Your machine. Never committed. |
|
||||
| Project config | `./.zero/config.json` | The repo. Committed (or not, your call). |
|
||||
| CLI flags | `--model`, `--mode`, ... | Highest priority, per-invocation. |
|
||||
| Environment | `ZERO_*` | Provider commands, secrets, skills dir override. |
|
||||
|
||||
The user config holds things that should follow the user across projects (default provider, default model, theme). The project config holds things the team agreed on (provider catalog, sandbox policies, model restrictions).
|
||||
|
||||
The sandbox `additionalWriteRoots` key is **ignored in project config** by design — a checked-out repo cannot widen its own sandbox. Set it in the user config or pass `--add-dir` per-invocation.
|
||||
|
||||
## 8. End-to-end example
|
||||
|
||||
A team that wants every contributor's Zero to behave the same way commits:
|
||||
|
||||
- `AGENTS.md` — project conventions, build commands, do-not-edit lists.
|
||||
- `.zero/config.json` — provider catalog, default model, allowed tools.
|
||||
- `.zero/specialists/api-reviewer.md` — the team's PR-review specialist.
|
||||
- `.zero/hooks.json` — block `rm -rf` and `git push --force` on `beforeTool`.
|
||||
- `.zero/plugins/internal-tooling/` — a plugin that adds the team's internal CLI tools to the agent's toolset.
|
||||
|
||||
Each contributor adds only:
|
||||
|
||||
- `~/.config/zero/config.json` — their personal API keys, theme, default mode.
|
||||
- `~/.local/share/zero/skills/` — personal skills they keep across projects.
|
||||
|
||||
That's it. Run `zero` from the repo root and the agent has the team's full instruction set, every contributor's personal setup, and nothing else.
|
||||
|
||||
## 9. Reference
|
||||
|
||||
- [README](README.md) — install, quickstart, command reference.
|
||||
- [docs/SPECIALISTS.md](docs/SPECIALISTS.md) — full specialist manifest spec.
|
||||
- [docs/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.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project are documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
||||
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
|
||||
tagged. Until then, source builds report the version `dev`.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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).
|
||||
- "Accessibility / Appearance" section in the README documenting `NO_COLOR`, `ZERO_THEME`, `/theme`,
|
||||
and `ZERO_NO_FADE`.
|
||||
|
||||
### Changed
|
||||
- Provider connectivity health checks now allow loopback hosts for explicitly user-configured local
|
||||
providers (Ollama / LM Studio), so the keyless local-model path verifies instead of failing with
|
||||
"localhost hosts are blocked". The SSRF guard for fetched/remote URLs is unchanged.
|
||||
- Auth (401/403) errors now show a curated, actionable message pointing at `zero auth` / setup; the
|
||||
raw upstream body is shown only under a verbose/debug flag.
|
||||
- No-provider / missing-key errors now point at `zero setup` and `zero auth`, and distinguish a
|
||||
missing key from a rejected key.
|
||||
- `zero doctor` no longer reports "Overall: pass" when no provider credential is configured, and
|
||||
formats the missing-language-server list for humans (no raw Go `map[...]`).
|
||||
- Raised the `faint`/`faintest` theme tokens (and the light-theme accent) to meet WCAG AA contrast for
|
||||
the content they carry.
|
||||
- `NO_COLOR` is now honored for any non-empty value, per the no-color.org spec.
|
||||
|
||||
### Removed
|
||||
- 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`).
|
||||
@@ -0,0 +1,58 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our community a
|
||||
harassment-free experience for everyone, regardless of age, body size, visible or invisible
|
||||
disability, ethnicity, sex characteristics, gender identity and expression, level of experience,
|
||||
education, socio-economic status, nationality, personal appearance, race, religion, or sexual
|
||||
identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and
|
||||
healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people.
|
||||
- Being respectful of differing opinions, viewpoints, and experiences.
|
||||
- Giving and gracefully accepting constructive feedback.
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes.
|
||||
- Focusing on what is best for the overall community.
|
||||
|
||||
Examples of unacceptable behavior:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or advances of any kind.
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks.
|
||||
- Public or private harassment.
|
||||
- Publishing others' private information, such as a physical or email address, without their
|
||||
explicit permission.
|
||||
- Other conduct which could reasonably be considered inappropriate in a professional setting.
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying and enforcing our standards of acceptable behavior
|
||||
and will take appropriate and fair corrective action in response to any behavior that they deem
|
||||
inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when an individual is
|
||||
officially representing the community in public spaces.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported privately to the
|
||||
project maintainers through the repository's **Security → Report a vulnerability** channel (which
|
||||
delivers a confidential report to maintainers), or by contacting a maintainer directly. All
|
||||
complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
Maintainers are obligated to respect the privacy and security of the reporter of any incident.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at
|
||||
<https://www.contributor-covenant.org/version/2/1/code_of_conduct.html>.
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
@@ -0,0 +1,44 @@
|
||||
# Zero build/test/lint targets. AGENTS.MD says "Build with `make`" and "Run `make
|
||||
# lint` before opening a PR" — these targets back those instructions.
|
||||
.DEFAULT_GOAL := build
|
||||
.PHONY: build build-all test test-race vet fmt fmt-check lint tidy clean help
|
||||
|
||||
# Build the main CLI binary into ./zero.
|
||||
build:
|
||||
go build -o zero ./cmd/zero
|
||||
|
||||
# Build every command in cmd/.
|
||||
build-all:
|
||||
go build ./...
|
||||
|
||||
# Run the full test suite with the race detector (matches CI expectations).
|
||||
test:
|
||||
go test ./... -race -count=1
|
||||
|
||||
# Faster, no race detector.
|
||||
test-quick:
|
||||
go test ./...
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
fmt:
|
||||
gofmt -w $(shell git ls-files '*.go')
|
||||
|
||||
# Fail if any tracked Go file is not gofmt-clean.
|
||||
fmt-check:
|
||||
@out="$$(gofmt -l $$(git ls-files '*.go'))"; \
|
||||
if [ -n "$$out" ]; then echo "gofmt needed:"; echo "$$out"; exit 1; fi
|
||||
|
||||
# Lint = formatting check + vet (no extra tooling required).
|
||||
lint: fmt-check vet
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
clean:
|
||||
rm -f zero
|
||||
go clean ./...
|
||||
|
||||
help:
|
||||
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, tidy, clean"
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
**Any model. Any provider. Your rules.**
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@@ -49,14 +49,23 @@ zero exec -o stream-json < turns.jsonl # programmatic, for scripts & CI
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# run from source (requires Go 1.24+)
|
||||
# run from source (requires Go 1.25+)
|
||||
go run ./cmd/zero
|
||||
|
||||
# or install a release binary
|
||||
scripts/install.sh # Linux / macOS
|
||||
powershell -ExecutionPolicy Bypass -File scripts/install.ps1 # Windows
|
||||
```
|
||||
|
||||
Or, once the first release is published, install the prebuilt binary via npm — the wrapper
|
||||
downloads the right binary for your platform:
|
||||
|
||||
```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`).
|
||||
|
||||
First launch opens a **guided setup wizard** — pick a provider, paste a key, choose a model, done. Or do it non-interactively:
|
||||
|
||||
```bash
|
||||
@@ -86,10 +95,24 @@ Type to chat, **Enter** to send. `/` opens command suggestions, **Shift+Tab** cy
|
||||
| `/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` `/usage` `/config` | health, cost, and config without leaving the chat |
|
||||
| `/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`
|
||||
|
||||
```bash
|
||||
@@ -162,6 +185,8 @@ Bring your own key — or no key at all for local runtimes.
|
||||
|
||||
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 |
|
||||
@@ -170,7 +195,8 @@ The model registry tracks each model's capabilities, context window, and cost
|
||||
| `web_fetch` | fetch docs & references | network |
|
||||
| `update_plan` · `ask_user` | plan & clarify | none |
|
||||
| `write_file` · `edit_file` · `apply_patch` | create & modify | write (gated) |
|
||||
| `bash` | run shell commands | shell (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 |
|
||||
@@ -179,6 +205,48 @@ The model registry tracks each model's capabilities, context window, and cost
|
||||
|
||||
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
|
||||
@@ -204,24 +272,19 @@ still resolve against the workspace only, and network and destructive-shell poli
|
||||
are unchanged. A write denied outside all roots returns an error that suggests
|
||||
`/add-dir`.
|
||||
|
||||
Two extra hardening flags are available in the `sandbox` config block, both **off
|
||||
by default** and safe to leave unset:
|
||||
Two extra hardening/diagnostic flags are available in the `sandbox` config block,
|
||||
both **off by default** and safe to leave unset:
|
||||
|
||||
- `sandbox.blockUnixSockets` (Linux) — on the bubblewrap backend, installs a
|
||||
seccomp filter that denies `AF_UNIX` socket creation in the sandboxed command,
|
||||
closing the Unix-socket channel that filesystem/network isolation leaves open.
|
||||
Degrades gracefully (runs without the filter) when the `zero-seccomp` helper is
|
||||
not installed alongside the binary.
|
||||
- `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_violations>` block
|
||||
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.
|
||||
|
||||
When the network policy is `scoped`, the `web_fetch` and `web_search` tools honor
|
||||
the same allow/deny domain list as sandboxed shell egress (host allowlisting is
|
||||
enforced on the macOS `sandbox-exec` backend; other backends collapse `scoped` to
|
||||
`deny` for these tools).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# Security Policy
|
||||
|
||||
Zero is a terminal coding agent: it reads and edits files in your repository and can run shell
|
||||
commands and call external model/tool endpoints. We take security reports seriously.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Please do not open a public issue for security vulnerabilities.**
|
||||
|
||||
Report privately through GitHub's private vulnerability reporting:
|
||||
|
||||
1. Go to the repository's **Security** tab → **Report a vulnerability** (GitHub Security Advisories).
|
||||
2. Describe the issue, the affected version/commit, reproduction steps, and the impact.
|
||||
|
||||
If you cannot use GitHub Security Advisories, open a regular issue that contains **only** a request to
|
||||
be contacted for a security report (no details), and a maintainer will arrange a private channel.
|
||||
|
||||
Please include, where possible:
|
||||
|
||||
- The version, branch, or commit affected.
|
||||
- A minimal reproduction and the impact (e.g. sandbox escape, secret exposure, RCE).
|
||||
- Any logs or output, with secrets redacted.
|
||||
|
||||
## What to expect
|
||||
|
||||
- We aim to acknowledge a report within a few business days.
|
||||
- We will work with you on a fix and a coordinated disclosure timeline, and credit you in the release
|
||||
notes unless you prefer to remain anonymous.
|
||||
|
||||
## Scope notes
|
||||
|
||||
- Findings that require a malicious local user who already controls the machine running Zero, or that
|
||||
depend on a user explicitly disabling the sandbox (`--skip-permissions-unsafe`), are generally
|
||||
out of scope — but please report anything you are unsure about.
|
||||
- Zero sends no telemetry. Network calls go to the model/tool providers you configure (and, for
|
||||
keyless web search, to the documented Firecrawl endpoint — see the README).
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(sandbox.RunLinuxSandboxHelper(os.Args[1:], os.Stderr))
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
//go:build linux
|
||||
|
||||
// Command zero-seccomp is a tiny exec wrapper for the Linux sandbox: it installs
|
||||
// the Unix-socket-blocking seccomp filter on itself and then execs the real
|
||||
// command, so a bubblewrap-sandboxed process cannot create AF_UNIX sockets (a gap
|
||||
// bubblewrap's filesystem/network isolation leaves open). Wire it by prefixing the
|
||||
// sandboxed command with this binary, e.g. `bwrap ... -- zero-seccomp <command>`.
|
||||
//
|
||||
// It degrades gracefully: if the filter cannot be installed (e.g. the kernel lacks
|
||||
// seccomp), it warns and runs the command anyway, since bubblewrap still provides
|
||||
// the primary isolation.
|
||||
// Command zero-seccomp is retained as a compatibility wrapper for existing Linux
|
||||
// installs and scripts. The main sandbox path now applies the same optional
|
||||
// Unix-socket filter inside zero-linux-sandbox when sandbox.blockUnixSockets is
|
||||
// enabled.
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Fprintln(os.Stderr, "zero-seccomp is only supported on Linux")
|
||||
os.Exit(2)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(sandbox.RunWindowsSandboxCommandRunner(os.Args[1:], os.Stderr))
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(sandbox.RunWindowsSandboxSetup(os.Args[1:], os.Stderr))
|
||||
}
|
||||
@@ -198,5 +198,4 @@ webhook:
|
||||
- **Redaction:** the message, summary, links, and the webhook URL itself are run
|
||||
through ZERO's redaction before being sent or logged, so tokens never leak.
|
||||
- **Egress/proxy:** the notifier uses the default HTTP transport, which honors
|
||||
`HTTP_PROXY`/`HTTPS_PROXY`. In a sandboxed run the scoped-egress proxy is
|
||||
respected, so a webhook host that is not on the allowlist is denied.
|
||||
`HTTP_PROXY`/`HTTPS_PROXY` when a proxy is configured.
|
||||
@@ -1,5 +1,10 @@
|
||||
# Zero Install Scripts
|
||||
|
||||
> **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 release archives are published as:
|
||||
|
||||
- `zero-v<version>-linux-<arch>.tar.gz`
|
||||
|
||||
@@ -23,7 +23,13 @@ go run ./cmd/zero-release smoke
|
||||
|
||||
## Checklist
|
||||
|
||||
- `package.json` has the expected package name, version, and `bin.zero` entry.
|
||||
- `package.json` has the expected package name (`@gitlawb/zero`), version,
|
||||
`bin.zero` entry, and exactly one `scripts` entry — the `postinstall` hook.
|
||||
- `scripts/postinstall.mjs` resolves the correct release asset name/URL per
|
||||
platform (`ZERO_INSTALL_DRY_RUN=1` prints the plan), verifies the downloaded
|
||||
archive's SHA-256, and extracts only the known binary basenames into place.
|
||||
`ZERO_SKIP_DOWNLOAD=1` opts out cleanly (exit 0) and an unsupported
|
||||
platform/arch is a non-fatal skip.
|
||||
- The wrapper binary resolves through the package `bin` entry and
|
||||
`node_modules/.bin` in a package-install smoke test.
|
||||
- The built binary exits 0 for `zero --version` or `zero --help`.
|
||||
|
||||
@@ -8,12 +8,12 @@ redacted before they are written to stdout.
|
||||
|
||||
## Version
|
||||
|
||||
Current schema version: `1`
|
||||
Current schema version: `2`
|
||||
|
||||
Every input and output event must include:
|
||||
|
||||
```json
|
||||
{ "schemaVersion": 1, "type": "..." }
|
||||
{ "schemaVersion": 2, "type": "..." }
|
||||
```
|
||||
|
||||
Output events also include `runId`.
|
||||
@@ -24,27 +24,37 @@ Output events also include `runId`.
|
||||
`--file`.
|
||||
|
||||
```json
|
||||
{ "schemaVersion": 1, "type": "message", "role": "user", "content": "Inspect this repo." }
|
||||
{ "schemaVersion": 1, "type": "prompt", "content": "Return only blockers." }
|
||||
{ "schemaVersion": 2, "type": "message", "role": "user", "content": "Inspect this repo." }
|
||||
{ "schemaVersion": 2, "type": "prompt", "content": "Return only blockers." }
|
||||
```
|
||||
|
||||
Zero combines accepted input event content in order, separated by blank lines.
|
||||
Unknown fields are rejected so protocol clients catch drift early.
|
||||
|
||||
Schema version `2` renamed sandbox permission metadata from `violation` to
|
||||
`block`. Clients should read the optional `block` object on permission and tool
|
||||
events when present.
|
||||
|
||||
## Output Events
|
||||
|
||||
`zero exec --output-format stream-json` emits schema-versioned JSONL events.
|
||||
|
||||
```json
|
||||
{ "schemaVersion": 1, "type": "run_start", "runId": "run_20260603_abc123", "sessionId": "zero_20260603100000_abc123", "cwd": "/repo", "provider": "openai", "model": "gpt-4.1", "apiModel": "gpt-4.1" }
|
||||
{ "schemaVersion": 1, "type": "text", "runId": "run_20260603_abc123", "delta": "..." }
|
||||
{ "schemaVersion": 1, "type": "tool_call", "runId": "run_20260603_abc123", "id": "call_1", "name": "read_file", "args": { "path": "README.md" }, "sideEffect": "read" }
|
||||
{ "schemaVersion": 1, "type": "permission_request", "runId": "run_20260603_abc123", "id": "call_2", "name": "write_file", "action": "prompt", "permission": "prompt", "permissionMode": "ask", "sideEffect": "write", "reason": "Creates or overwrites files." }
|
||||
{ "schemaVersion": 1, "type": "permission_decision", "runId": "run_20260603_abc123", "id": "call_2", "name": "write_file", "action": "allow", "permission": "prompt", "permissionGranted": true, "decisionReason": "approved in TUI" }
|
||||
{ "schemaVersion": 1, "type": "tool_result", "runId": "run_20260603_abc123", "id": "call_1", "status": "ok", "output": "...", "truncated": false }
|
||||
{ "schemaVersion": 1, "type": "usage", "runId": "run_20260603_abc123", "promptTokens": 12, "completionTokens": 8, "totalTokens": 20 }
|
||||
{ "schemaVersion": 1, "type": "final", "runId": "run_20260603_abc123", "text": "..." }
|
||||
{ "schemaVersion": 1, "type": "run_end", "runId": "run_20260603_abc123", "status": "success", "exitCode": 0 }
|
||||
{ "schemaVersion": 2, "type": "run_start", "runId": "run_20260603_abc123", "sessionId": "zero_20260603100000_abc123", "cwd": "/repo", "provider": "openai", "model": "gpt-4.1", "apiModel": "gpt-4.1" }
|
||||
{ "schemaVersion": 2, "type": "text", "runId": "run_20260603_abc123", "delta": "..." }
|
||||
{ "schemaVersion": 2, "type": "tool_call", "runId": "run_20260603_abc123", "id": "call_1", "name": "read_file", "args": { "path": "README.md" }, "sideEffect": "read" }
|
||||
{ "schemaVersion": 2, "type": "permission_request", "runId": "run_20260603_abc123", "id": "call_2", "name": "write_file", "action": "prompt", "permission": "prompt", "permissionMode": "ask", "sideEffect": "write", "reason": "Creates or overwrites files." }
|
||||
{ "schemaVersion": 2, "type": "permission_decision", "runId": "run_20260603_abc123", "id": "call_2", "name": "write_file", "action": "allow", "permission": "prompt", "permissionGranted": true, "decisionReason": "approved in TUI" }
|
||||
{ "schemaVersion": 2, "type": "tool_result", "runId": "run_20260603_abc123", "id": "call_1", "status": "ok", "output": "...", "truncated": false }
|
||||
{ "schemaVersion": 2, "type": "usage", "runId": "run_20260603_abc123", "promptTokens": 12, "completionTokens": 8, "totalTokens": 20 }
|
||||
{ "schemaVersion": 2, "type": "final", "runId": "run_20260603_abc123", "text": "..." }
|
||||
{ "schemaVersion": 2, "type": "run_end", "runId": "run_20260603_abc123", "status": "success", "exitCode": 0 }
|
||||
```
|
||||
|
||||
Permission events may include structured sandbox metadata:
|
||||
|
||||
```json
|
||||
{ "schemaVersion": 2, "type": "permission_request", "runId": "run_20260603_abc123", "id": "call_3", "name": "bash", "action": "prompt", "permission": "prompt", "permissionMode": "ask", "sideEffect": "shell", "reason": "network access requires approval", "risk": { "level": "critical", "categories": ["network"] }, "block": { "code": "network", "toolName": "bash", "action": "prompt", "risk": { "level": "critical", "categories": ["network"] }, "reason": "network access requires approval", "recoverable": true } }
|
||||
```
|
||||
|
||||
Errors are part of the protocol and are followed by `run_end`.
|
||||
@@ -55,6 +65,6 @@ is not pre-approved, Zero may emit `permission_request` followed by a denied
|
||||
allows, denies, or always-allows the request.
|
||||
|
||||
```json
|
||||
{ "schemaVersion": 1, "type": "error", "runId": "run_20260603_abc123", "code": "provider_error", "message": "...", "recoverable": false }
|
||||
{ "schemaVersion": 1, "type": "run_end", "runId": "run_20260603_abc123", "status": "error", "exitCode": 3 }
|
||||
{ "schemaVersion": 2, "type": "error", "runId": "run_20260603_abc123", "code": "provider_error", "message": "...", "recoverable": false }
|
||||
{ "schemaVersion": 2, "type": "run_end", "runId": "run_20260603_abc123", "status": "error", "exitCode": 3 }
|
||||
```
|
||||
@@ -61,7 +61,7 @@ Still needed before Zero feels production-ready:
|
||||
|
||||
| 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 violation rendering, verification/test/git output UX, themes, daily CLI feel | Provider internals, release pipeline, platform adapters |
|
||||
| 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 |
|
||||
|
||||
@@ -110,13 +110,13 @@ Planned PRs:
|
||||
- Vasanth: `feat/tui-sandbox-permissions`
|
||||
- permission prompt UX
|
||||
- allow, deny, and always-allow flows
|
||||
- sandbox violation rendering
|
||||
- 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 violation cases
|
||||
- tests for denial, approval, and block cases
|
||||
- Anandan: `feat/windows-sandbox-platform`
|
||||
- platform sandbox capability checks
|
||||
- stronger Windows behavior or explicit safe fallback
|
||||
@@ -127,7 +127,7 @@ Done when:
|
||||
|
||||
- High-autonomy tool attempts produce a clear prompt, decision, and result in TUI and headless modes.
|
||||
- Denied commands do not run.
|
||||
- Sandbox violations are visible and actionable.
|
||||
- Sandbox blocks are visible and actionable.
|
||||
- Platform fallback is explicit, tested, and not silently unsafe.
|
||||
|
||||
### B. Command UX And Daily CLI
|
||||
@@ -227,7 +227,7 @@ These are the next reviewable slices. Do not use the old migration-era next step
|
||||
|
||||
Recommended first parallel set:
|
||||
|
||||
- Vasanth starts the TUI permission prompt and violation display.
|
||||
- Vasanth starts the TUI permission prompt and block display.
|
||||
- Gnanam defines the permission event/grant contract.
|
||||
- Anandan owns Windows/platform sandbox reporting and tests.
|
||||
|
||||
@@ -236,7 +236,7 @@ Recommended first parallel set:
|
||||
| Command Area | Vasanth | Gnanam | Anandan |
|
||||
| --- | --- | --- | --- |
|
||||
| `exec` | TUI and interactive UX | Runtime execution contract | Package smoke and platform validation |
|
||||
| `sandbox` | Prompt and violation UX | Policy, grants, and events | Platform adapters and Windows behavior |
|
||||
| `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 |
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,444 @@
|
||||
# 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.
|
||||
+52
-14
@@ -37,29 +37,37 @@ before. Tokens are stored 0600 (or the OS keyring with
|
||||
Running `/provider` opens a **"How do you want to connect?"** chooser:
|
||||
|
||||
```text
|
||||
❯ Sign in with OAuth One-click browser login (OpenRouter, xAI)
|
||||
❯ Sign in with OAuth One-click browser login (OpenRouter, xAI, ChatGPT, Hugging Face)
|
||||
Paste an API key / browse providers Any of 20+ providers, local, or a proxy
|
||||
```
|
||||
|
||||
Pick **Sign in with OAuth** → the list of providers that do real OAuth → choose one:
|
||||
|
||||
```text
|
||||
❯ OpenRouter browser sign-in · creates a key
|
||||
xAI (Grok) browser or device code
|
||||
❯ OpenRouter browser sign-in · creates a key
|
||||
xAI (Grok) browser or device code
|
||||
ChatGPT browser (Codex backend, ChatGPT Plus/Pro)
|
||||
Hugging Face browser or device code
|
||||
```
|
||||
|
||||
- **OpenRouter / xAI** are real OAuth: your browser opens to approve → done (no
|
||||
key to paste). OpenRouter mints a key; xAI stores a refreshable token. The same
|
||||
chooser appears in first-run onboarding. (xAI uses an opt-in preset — set
|
||||
- **OpenRouter / xAI / ChatGPT / Hugging Face** are real OAuth: your browser
|
||||
opens to approve → done (no key to paste). OpenRouter mints a key; xAI /
|
||||
ChatGPT / Hugging Face store a refreshable bearer. Hugging Face requires a
|
||||
one-time OAuth-app registration (no secret needed for "public" apps); the
|
||||
preset pre-fills scopes, endpoints, and the OIDC issuer. The same chooser
|
||||
appears in first-run onboarding. (xAI uses an opt-in preset — set
|
||||
`ZERO_OAUTH_ALLOW_PRESETS=1` or your own `ZERO_OAUTH_XAI_*`; see below.)
|
||||
- **Device code (headless / SSH):** for a provider that supports it (xAI), press
|
||||
**d** on the list to get a code to enter on another device instead of opening a
|
||||
browser. On an SSH session or headless Linux box (no `DISPLAY`) device code is
|
||||
used automatically; set `ZERO_OAUTH_DEVICE=1` to force it anywhere. The CLI
|
||||
equivalent is `zero auth login xai --device`.
|
||||
- **ChatGPT / Claude are intentionally not in this list** — they can't do in-app
|
||||
OAuth (see §2). Use *Paste an API key / browse providers* + a local proxy
|
||||
(`chatgpt-proxy` / `custom-anthropic-compatible`), as described below.
|
||||
- **Device code (headless / SSH):** for a provider that supports it (xAI,
|
||||
Hugging Face), press **d** on the list to get a code to enter on another
|
||||
device instead of opening a browser. On an SSH session or headless Linux box
|
||||
(no `DISPLAY`) device code is used automatically; set `ZERO_OAUTH_DEVICE=1`
|
||||
to force it anywhere. The CLI equivalent is
|
||||
`zero auth login <name> --device`.
|
||||
- **ChatGPT / Claude are intentionally not in this list for the proxy path** —
|
||||
use the dedicated `chatgpt-proxy` / `custom-anthropic-compatible` preset
|
||||
(see §2) for subscription-via-proxy. ChatGPT *is* a first-class OAuth
|
||||
provider in this version (routes to the Codex backend) — see "Built-in OAuth
|
||||
providers" below.
|
||||
|
||||
### Built-in OAuth providers
|
||||
|
||||
@@ -78,6 +86,30 @@ Pick **Sign in with OAuth** → the list of providers that do real OAuth → cho
|
||||
fully overridable by `ZERO_OAUTH_XAI_*` (env wins), and it requires a
|
||||
SuperGrok / X Premium+ subscription; the client_id is an undocumented public
|
||||
Grok-CLI client that may change without notice.
|
||||
- **ChatGPT (Codex) — opt-in preset** — `zero auth chatgpt` opens a browser, you
|
||||
approve with your ChatGPT Plus/Pro/Business/Enterprise account, and the bearer is
|
||||
stored. The bearer routes to `https://chatgpt.com/backend-api/codex/responses`
|
||||
(the same endpoint the openai/codex CLI uses), with `originator: codex_cli_rs` and
|
||||
the `chatgpt-account-id` claim injected as headers on every request. The
|
||||
`chatgpt-account-id` is extracted from the OIDC ID token and stored alongside the
|
||||
bearer; if the claim is missing (older ChatGPT accounts, or a rotated
|
||||
authorization server), the Codex backend will 401 and `zero auth status chatgpt`
|
||||
will show the warning. Like xAI, the preset uses the publicly-shipped Codex CLI
|
||||
client identity (`app_EMoamEEZ73f0CkXaXp7hrann`) and is opt-in via
|
||||
`ZERO_OAUTH_ALLOW_PRESETS=1`. As of mid-2026 the Codex backend is
|
||||
Cloudflare-gated: requests from a non-Codex client can still be challenged, and
|
||||
the `chatgpt-proxy` route in §2 is the conservative fallback.
|
||||
- **Hugging Face — opt-in preset, BYO client_id** — `zero auth login huggingface`
|
||||
(or `--device` for headless) opens a Hugging Face OAuth flow. The bearer works on
|
||||
the OpenAI-compatible router at `https://router.huggingface.co/v1` for hundreds
|
||||
of OSS models (Llama, Qwen, DeepSeek, Mistral, etc.). HF does not ship a
|
||||
globally-known client_id, so the preset ships endpoints + scopes + the OIDC
|
||||
issuer pre-filled; you must register a "public" OAuth app (no secret) at
|
||||
<https://huggingface.co/settings/applications/new> and set the resulting
|
||||
`client_id` via `ZERO_OAUTH_HUGGINGFACE_CLIENT_ID`. Enable the preset with
|
||||
`ZERO_OAUTH_ALLOW_PRESETS=1` (or omit it — the BYO client_id path uses
|
||||
`client_credentials = none` and doesn't need the opt-in). Free tier has strict
|
||||
rate limits; Pro removes them.
|
||||
|
||||
Any field of a preset is overridable via `ZERO_OAUTH_<NAME>_*`. For a fully custom
|
||||
OAuth/OIDC provider, set those env vars (see `zero auth --help`) and
|
||||
@@ -95,6 +127,12 @@ We researched this carefully. As of mid-2026, a **subscription** OAuth token doe
|
||||
API), **not** `api.openai.com`. That backend is **Cloudflare bot-protected** —
|
||||
non-browser / headless clients get `cf-mitigated: challenge` → `403`. It also
|
||||
requires mimicking the official Codex client (originator + account-id header).
|
||||
**First-class path (this version):** `zero auth chatgpt` does exactly that
|
||||
mimicking (`originator: codex_cli_rs`, `chatgpt-account-id: <claim>`) and
|
||||
routes requests to the Codex backend, no proxy required — see §1. The
|
||||
`chatgpt-proxy` route below is the conservative fallback when Cloudflare
|
||||
challenges become an issue, and is the only path that works without a
|
||||
browser-based ChatGPT OAuth login.
|
||||
- **Anthropic (Claude):** the Messages API **rejects** subscription OAuth tokens
|
||||
for third-party use unless the request spoofs the Claude Code identity
|
||||
(`anthropic-beta: oauth-2025-04-20`, `claude-cli` UA, and a verbatim
|
||||
|
||||
@@ -8,6 +8,7 @@ require (
|
||||
charm.land/bubbles/v2 v2.1.0
|
||||
charm.land/bubbletea/v2 v2.0.7
|
||||
charm.land/lipgloss/v2 v2.0.4
|
||||
github.com/alecthomas/chroma/v2 v2.27.0
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/charmbracelet/colorprofile v0.4.3
|
||||
github.com/charmbracelet/x/ansi v0.11.7
|
||||
@@ -24,6 +25,7 @@ require (
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
|
||||
github.com/go-quicktest/qt v1.102.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.24 // indirect
|
||||
|
||||
@@ -4,6 +4,12 @@ charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
|
||||
charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs=
|
||||
charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q=
|
||||
charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
|
||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
@@ -26,10 +32,14 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
|
||||
github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// Deps are the ZERO capabilities the ACP Agent drives. The CLI fills these with
|
||||
// real implementations; tests inject fakes (e.g. a canned provider) to drive the
|
||||
// full ACP flow without a live model. Keeping auth/model/keys behind these deps
|
||||
// means the editor only hosts the thread — ZERO owns BYOK and telemetry-free
|
||||
// operation.
|
||||
type Deps struct {
|
||||
ResolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
|
||||
NewProvider func(profile config.ProviderProfile) (zeroruntime.Provider, error)
|
||||
RunAgent func(ctx context.Context, prompt string, provider zeroruntime.Provider, opts agent.Options) (agent.Result, error)
|
||||
// BuildWorkspace builds the SCOPED tool registry and the sandbox engine for a
|
||||
// validated workspace root, so ACP shell tools (bash/exec_command) are confined
|
||||
// exactly like the exec surface — never run unconfined on the host.
|
||||
BuildWorkspace func(workspaceRoot string, resolved config.ResolvedConfig) (*tools.Registry, *sandbox.Engine, error)
|
||||
// ResolveWorkspaceRoot validates + normalizes a client-supplied cwd (must be an
|
||||
// existing directory; never the bare root). It is the file-tool confinement root.
|
||||
ResolveWorkspaceRoot func(cwd string) (string, error)
|
||||
Store *sessions.Store
|
||||
AgentInfo Implementation
|
||||
}
|
||||
|
||||
// Agent is the ACP agent server bound to one JSON-RPC connection (one editor).
|
||||
type Agent struct {
|
||||
conn *Conn
|
||||
deps Deps
|
||||
|
||||
mu sync.Mutex
|
||||
clientCaps ClientCapabilities
|
||||
initialized bool
|
||||
sessions map[string]*acpSession
|
||||
}
|
||||
|
||||
type turnRecord struct {
|
||||
user string
|
||||
assistant string
|
||||
}
|
||||
|
||||
type acpSession struct {
|
||||
id string
|
||||
cwd string
|
||||
|
||||
// turnMu serializes prompt turns for one session: concurrent session/prompt
|
||||
// calls run one at a time so they can't interleave history or clobber the
|
||||
// single cancel slot.
|
||||
turnMu sync.Mutex
|
||||
|
||||
mu sync.Mutex
|
||||
mode agent.PermissionMode
|
||||
model string // override; "" => config default
|
||||
cancel context.CancelFunc
|
||||
history []turnRecord
|
||||
}
|
||||
|
||||
// NewAgent builds the ACP server and registers its method handlers on conn.
|
||||
func NewAgent(conn *Conn, deps Deps) *Agent {
|
||||
a := &Agent{conn: conn, deps: deps, sessions: make(map[string]*acpSession)}
|
||||
conn.Handle(MethodInitialize, a.handleInitialize)
|
||||
conn.Handle(MethodSessionNew, a.handleSessionNew)
|
||||
conn.Handle(MethodSessionLoad, a.handleSessionLoad)
|
||||
conn.Handle(MethodSessionPrompt, a.handleSessionPrompt)
|
||||
conn.Handle(MethodSessionSetMode, a.handleSetMode)
|
||||
conn.Handle(MethodSessionSetConfigOption, a.handleSetConfigOption)
|
||||
conn.Handle(MethodZeroSetModel, a.handleZeroSetModel)
|
||||
conn.HandleNotify(MethodSessionCancel, a.handleCancel)
|
||||
return a
|
||||
}
|
||||
|
||||
// Serve runs the connection read loop until the stream closes or ctx is done.
|
||||
func (a *Agent) Serve(ctx context.Context) error { return a.conn.Serve(ctx) }
|
||||
|
||||
// ---- initialize ----
|
||||
|
||||
func (a *Agent) handleInitialize(_ context.Context, params json.RawMessage) (any, error) {
|
||||
var p InitializeParams
|
||||
if len(params) > 0 {
|
||||
_ = json.Unmarshal(params, &p)
|
||||
}
|
||||
negotiated := ProtocolVersion
|
||||
if p.ProtocolVersion > 0 && p.ProtocolVersion < ProtocolVersion {
|
||||
negotiated = p.ProtocolVersion
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.clientCaps = p.ClientCapabilities
|
||||
a.initialized = true
|
||||
a.mu.Unlock()
|
||||
|
||||
info := a.deps.AgentInfo
|
||||
return InitializeResult{
|
||||
ProtocolVersion: negotiated,
|
||||
AgentCapabilities: AgentCapabilities{
|
||||
// Only advertise what ZERO actually implements: session/load (loadSession)
|
||||
// and image prompts. session/resume + the session-capability sub-object
|
||||
// are intentionally omitted since there is no resume handler yet.
|
||||
LoadSession: true,
|
||||
PromptCapabilities: PromptCapabilities{Image: true},
|
||||
},
|
||||
AgentInfo: &info,
|
||||
// ZERO owns credentials (BYOK) and does not delegate auth to the editor.
|
||||
AuthMethods: []AuthMethod{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---- session lifecycle ----
|
||||
|
||||
func (a *Agent) handleSessionNew(_ context.Context, params json.RawMessage) (any, error) {
|
||||
var p NewSessionParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, RPCError(codeInvalidParams, "invalid session/new params")
|
||||
}
|
||||
root, err := a.deps.ResolveWorkspaceRoot(p.Cwd)
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInvalidParams, err.Error())
|
||||
}
|
||||
meta, err := a.deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: root})
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInternalError, "create session: "+err.Error())
|
||||
}
|
||||
sess := a.registerSession(meta.SessionID, root, nil)
|
||||
return NewSessionResult{
|
||||
SessionID: sess.id,
|
||||
Modes: a.modeState(sess),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleSessionLoad(_ context.Context, params json.RawMessage) (any, error) {
|
||||
var p LoadSessionParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, RPCError(codeInvalidParams, "invalid session/load params")
|
||||
}
|
||||
meta, err := a.deps.Store.Get(p.SessionID)
|
||||
if err != nil || meta == nil {
|
||||
return nil, RPCError(codeInvalidParams, "session not found: "+p.SessionID)
|
||||
}
|
||||
cwdInput := p.Cwd
|
||||
if strings.TrimSpace(cwdInput) == "" {
|
||||
cwdInput = meta.Cwd
|
||||
}
|
||||
root, err := a.deps.ResolveWorkspaceRoot(cwdInput)
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInvalidParams, err.Error())
|
||||
}
|
||||
// Load history BEFORE publishing the session so no concurrent prompt observes
|
||||
// a half-initialized session (registerSession sets history under the lock and
|
||||
// reuses an already-live session rather than orphaning its in-flight turn).
|
||||
history := a.loadHistory(meta.SessionID)
|
||||
sess := a.registerSession(meta.SessionID, root, history)
|
||||
return LoadSessionResult{
|
||||
Modes: a.modeState(sess),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---- prompt turn ----
|
||||
|
||||
func (a *Agent) handleSessionPrompt(ctx context.Context, params json.RawMessage) (any, error) {
|
||||
var p PromptParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, RPCError(codeInvalidParams, "invalid session/prompt params")
|
||||
}
|
||||
sess := a.session(p.SessionID)
|
||||
if sess == nil {
|
||||
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
|
||||
}
|
||||
|
||||
// Serialize turns for this session so two prompts can't interleave history or
|
||||
// fight over the single cancel slot. session/cancel still works concurrently
|
||||
// (it doesn't take turnMu).
|
||||
sess.turnMu.Lock()
|
||||
defer sess.turnMu.Unlock()
|
||||
|
||||
userText := promptText(p.Prompt)
|
||||
images := promptImages(p.Prompt)
|
||||
|
||||
turnCtx, cancel := context.WithCancel(ctx)
|
||||
sess.setCancel(cancel)
|
||||
defer func() {
|
||||
cancel()
|
||||
sess.setCancel(nil)
|
||||
}()
|
||||
|
||||
reason, err := a.runTurn(turnCtx, sess, userText, images)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return PromptResult{StopReason: reason}, nil
|
||||
}
|
||||
|
||||
func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string, images []zeroruntime.ImageBlock) (string, error) {
|
||||
overrides := config.Overrides{}
|
||||
if model := sess.currentModel(); model != "" {
|
||||
overrides.Provider.Model = model
|
||||
}
|
||||
resolved, err := a.deps.ResolveConfig(sess.cwd, overrides)
|
||||
if err != nil {
|
||||
return "", RPCError(codeInternalError, "config: "+err.Error())
|
||||
}
|
||||
provider, err := a.deps.NewProvider(resolved.Provider)
|
||||
if err != nil {
|
||||
return "", RPCError(codeInternalError, "provider: "+err.Error())
|
||||
}
|
||||
// Build the SCOPED registry + sandbox engine for this session's workspace so
|
||||
// shell/file tools are confined to the workspace exactly like the exec surface.
|
||||
registry, sandboxEngine, err := a.deps.BuildWorkspace(sess.cwd, resolved)
|
||||
if err != nil {
|
||||
return "", RPCError(codeInternalError, "workspace: "+err.Error())
|
||||
}
|
||||
note := ¬ifier{conn: a.conn, sessionID: sess.id}
|
||||
|
||||
opts := agent.Options{
|
||||
Cwd: sess.cwd,
|
||||
SessionID: sess.id,
|
||||
ProviderName: resolved.Provider.Name,
|
||||
Model: resolved.Provider.Model,
|
||||
Registry: registry,
|
||||
Sandbox: sandboxEngine,
|
||||
PermissionMode: sess.currentMode(),
|
||||
MaxTurns: resolved.MaxTurns,
|
||||
Images: images,
|
||||
OnText: note.text,
|
||||
OnReasoning: note.thought,
|
||||
OnToolCall: note.toolCall,
|
||||
OnToolResult: func(result agent.ToolResult) {
|
||||
note.toolResult(result)
|
||||
if result.Name == "update_plan" {
|
||||
a.emitPlan(registry, note)
|
||||
}
|
||||
},
|
||||
OnPermissionRequest: func(ctx context.Context, req agent.PermissionRequest) (agent.PermissionDecision, error) {
|
||||
return a.requestPermission(ctx, sess.id, req)
|
||||
},
|
||||
}
|
||||
|
||||
agentPrompt := buildPrompt(sess.snapshotHistory(), userText)
|
||||
result, runErr := a.deps.RunAgent(ctx, agentPrompt, provider, opts)
|
||||
|
||||
reason, stopErr := stopReasonFor(result, runErr)
|
||||
if stopErr != nil {
|
||||
return "", RPCError(codeInternalError, stopErr.Error())
|
||||
}
|
||||
a.persistTurn(sess, userText, result.FinalAnswer)
|
||||
return reason, nil
|
||||
}
|
||||
|
||||
func stopReasonFor(result agent.Result, err error) (string, error) {
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return StopCancelled, nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if result.FinishReason == "length" {
|
||||
return StopMaxTokens, nil
|
||||
}
|
||||
if result.FinishReason == "content_filter" {
|
||||
return StopRefusal, nil
|
||||
}
|
||||
return StopEndTurn, nil
|
||||
}
|
||||
|
||||
// requestPermission forwards a ZERO permission prompt to the client as an ACP
|
||||
// session/request_permission request and maps the outcome back. Failure to reach
|
||||
// the client fails closed to deny.
|
||||
func (a *Agent) requestPermission(ctx context.Context, sessionID string, req agent.PermissionRequest) (agent.PermissionDecision, error) {
|
||||
params := RequestPermissionParams{
|
||||
SessionID: sessionID,
|
||||
ToolCall: permissionToolCall(req),
|
||||
Options: buildPermissionOptions(req),
|
||||
}
|
||||
var result RequestPermissionResult
|
||||
if err := a.conn.Call(ctx, MethodSessionRequestPerm, params, &result); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return agent.PermissionDecision{Action: agent.PermissionDecisionCancel, Reason: "cancelled"}, nil
|
||||
}
|
||||
return agent.PermissionDecision{Action: agent.PermissionDecisionDeny, Reason: "permission request failed: " + err.Error()}, nil
|
||||
}
|
||||
return decisionFromOutcome(result.Outcome, req.AvailableDecisions), nil
|
||||
}
|
||||
|
||||
func (a *Agent) emitPlan(registry *tools.Registry, note *notifier) {
|
||||
t, ok := registry.Get("update_plan")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
planner, ok := t.(interface{ CurrentPlan() []tools.PlanItem })
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
note.plan(planner.CurrentPlan())
|
||||
}
|
||||
|
||||
// ---- mode + model selection ----
|
||||
|
||||
func (a *Agent) handleSetMode(_ context.Context, params json.RawMessage) (any, error) {
|
||||
var p SetSessionModeParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, RPCError(codeInvalidParams, "invalid set_mode params")
|
||||
}
|
||||
sess := a.session(p.SessionID)
|
||||
if sess == nil {
|
||||
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
|
||||
}
|
||||
mode := agent.PermissionMode(p.ModeID)
|
||||
switch mode {
|
||||
case agent.PermissionModeAuto, agent.PermissionModeAsk:
|
||||
sess.setMode(mode)
|
||||
(¬ifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
|
||||
return SetSessionModeResult{}, nil
|
||||
case agent.PermissionModeUnsafe:
|
||||
// Unsafe = run every tool with no prompt. The TUI gates this behind an
|
||||
// explicit --skip-permissions-unsafe operator flag; an editor client must
|
||||
// not be able to grant itself unconfined, no-prompt access over the wire.
|
||||
return nil, RPCError(codeInvalidParams, "mode not permitted over ACP: "+p.ModeID)
|
||||
default:
|
||||
return nil, RPCError(codeInvalidParams, "unknown mode: "+p.ModeID)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) handleSetConfigOption(_ context.Context, params json.RawMessage) (any, error) {
|
||||
var p SetSessionConfigOptionParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, RPCError(codeInvalidParams, "invalid set_config_option params")
|
||||
}
|
||||
sess := a.session(p.SessionID)
|
||||
if sess == nil {
|
||||
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
|
||||
}
|
||||
if p.ConfigID != configIDModel {
|
||||
return nil, RPCError(codeInvalidParams, "unknown config option: "+p.ConfigID)
|
||||
}
|
||||
sess.setModel(p.Value)
|
||||
return SetSessionConfigOptionResult{}, nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleZeroSetModel(_ context.Context, params json.RawMessage) (any, error) {
|
||||
var p ZeroSetModelParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, RPCError(codeInvalidParams, "invalid _zero/set_model params")
|
||||
}
|
||||
sess := a.session(p.SessionID)
|
||||
if sess == nil {
|
||||
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
|
||||
}
|
||||
sess.setModel(p.Model)
|
||||
return ZeroSetModelResult{Model: p.Model}, nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleCancel(_ context.Context, params json.RawMessage) {
|
||||
var p CancelParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return
|
||||
}
|
||||
if sess := a.session(p.SessionID); sess != nil {
|
||||
sess.invokeCancel()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- advertising helpers ----
|
||||
|
||||
func (a *Agent) modeState(s *acpSession) *SessionModeState {
|
||||
// Only auto/ask are offered over ACP; Unsafe is gated to the operator (see
|
||||
// handleSetMode) so a client can't grant itself no-prompt host access.
|
||||
return &SessionModeState{
|
||||
CurrentModeID: string(s.currentMode()),
|
||||
AvailableModes: []SessionMode{
|
||||
{ID: string(agent.PermissionModeAuto), Name: "Auto", Description: "Run safe tools automatically; ask before risky ones."},
|
||||
{ID: string(agent.PermissionModeAsk), Name: "Ask", Description: "Ask before every tool that changes state."},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---- persistence + continuity ----
|
||||
|
||||
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) {
|
||||
if a.deps.Store != nil {
|
||||
_, _ = a.deps.Store.AppendEvent(sess.id, sessions.AppendEventInput{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{"role": "user", "content": user},
|
||||
})
|
||||
if assistant != "" {
|
||||
_, _ = a.deps.Store.AppendEvent(sess.id, sessions.AppendEventInput{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{"role": "assistant", "content": assistant},
|
||||
})
|
||||
}
|
||||
}
|
||||
sess.appendHistory(turnRecord{user: user, assistant: assistant})
|
||||
}
|
||||
|
||||
func (a *Agent) loadHistory(sessionID string) []turnRecord {
|
||||
if a.deps.Store == nil {
|
||||
return nil
|
||||
}
|
||||
events, err := a.deps.Store.ReadEvents(sessionID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var records []turnRecord
|
||||
var pendingUser string
|
||||
havePending := false
|
||||
for _, e := range events {
|
||||
if e.Type != sessions.EventMessage {
|
||||
continue
|
||||
}
|
||||
raw, err := json.Marshal(e.Payload)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var msg struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if json.Unmarshal(raw, &msg) != nil {
|
||||
continue
|
||||
}
|
||||
switch msg.Role {
|
||||
case "user":
|
||||
if havePending {
|
||||
records = append(records, turnRecord{user: pendingUser})
|
||||
}
|
||||
pendingUser = msg.Content
|
||||
havePending = true
|
||||
case "assistant":
|
||||
records = append(records, turnRecord{user: pendingUser, assistant: msg.Content})
|
||||
pendingUser = ""
|
||||
havePending = false
|
||||
}
|
||||
}
|
||||
if havePending {
|
||||
records = append(records, turnRecord{user: pendingUser})
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
// buildPrompt prepends prior conversation as context, since agent.Run drives a
|
||||
// single seeded turn. Mirrors how headless resume folds history into the prompt.
|
||||
func buildPrompt(history []turnRecord, userText string) string {
|
||||
if len(history) == 0 {
|
||||
return userText
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("Conversation so far:\n")
|
||||
for _, t := range history {
|
||||
b.WriteString("User: ")
|
||||
b.WriteString(t.user)
|
||||
b.WriteString("\n")
|
||||
if t.assistant != "" {
|
||||
b.WriteString("Assistant: ")
|
||||
b.WriteString(t.assistant)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
b.WriteString("\n---\nContinue with this request:\n")
|
||||
b.WriteString(userText)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func promptImages(blocks []ContentBlock) []zeroruntime.ImageBlock {
|
||||
var images []zeroruntime.ImageBlock
|
||||
for _, blk := range blocks {
|
||||
if blk.Type != "image" || blk.Data == "" {
|
||||
continue
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(blk.Data)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
images = append(images, zeroruntime.ImageBlock{MediaType: blk.MimeType, Data: data})
|
||||
}
|
||||
return images
|
||||
}
|
||||
|
||||
// ---- session registry + accessors ----
|
||||
|
||||
// registerSession publishes a session under the agent's lock. If one is already
|
||||
// registered for id (e.g. a re-load of an in-flight session) the existing live
|
||||
// session is returned unchanged rather than orphaning its turn or resetting its
|
||||
// mode/model. history is set BEFORE publishing so no concurrent prompt can read a
|
||||
// half-initialized session.
|
||||
func (a *Agent) registerSession(id, cwd string, history []turnRecord) *acpSession {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if existing := a.sessions[id]; existing != nil {
|
||||
return existing
|
||||
}
|
||||
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, history: history}
|
||||
a.sessions[id] = sess
|
||||
return sess
|
||||
}
|
||||
|
||||
func (a *Agent) session(id string) *acpSession {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.sessions[id]
|
||||
}
|
||||
|
||||
func (s *acpSession) setCancel(cancel context.CancelFunc) {
|
||||
s.mu.Lock()
|
||||
s.cancel = cancel
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *acpSession) invokeCancel() {
|
||||
s.mu.Lock()
|
||||
cancel := s.cancel
|
||||
s.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *acpSession) setMode(mode agent.PermissionMode) {
|
||||
s.mu.Lock()
|
||||
s.mode = mode
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *acpSession) currentMode() agent.PermissionMode {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.mode
|
||||
}
|
||||
|
||||
func (s *acpSession) setModel(model string) {
|
||||
s.mu.Lock()
|
||||
s.model = model
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *acpSession) currentModel() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.model
|
||||
}
|
||||
|
||||
func (s *acpSession) appendHistory(rec turnRecord) {
|
||||
s.mu.Lock()
|
||||
s.history = append(s.history, rec)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *acpSession) snapshotHistory() []turnRecord {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]turnRecord(nil), s.history...)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// fakeProvider streams a canned assistant message and ends the turn — enough to
|
||||
// drive the real agent.Run loop without a live model.
|
||||
type fakeProvider struct{ text string }
|
||||
|
||||
func (f fakeProvider) StreamCompletion(_ context.Context, _ zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
ch := make(chan zeroruntime.StreamEvent, 4)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: f.text}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
}()
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func testDeps(t *testing.T) Deps {
|
||||
t.Helper()
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
|
||||
return Deps{
|
||||
ResolveConfig: func(_ string, o config.Overrides) (config.ResolvedConfig, error) {
|
||||
model := "fake-model"
|
||||
if o.Provider.Model != "" {
|
||||
model = o.Provider.Model
|
||||
}
|
||||
return config.ResolvedConfig{
|
||||
Provider: config.ProviderProfile{Name: "fake", Model: model},
|
||||
MaxTurns: 4,
|
||||
}, nil
|
||||
},
|
||||
NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return fakeProvider{text: "Hello from ZERO"}, nil
|
||||
},
|
||||
RunAgent: agent.Run,
|
||||
BuildWorkspace: func(string, config.ResolvedConfig) (*tools.Registry, *sandbox.Engine, error) {
|
||||
r := tools.NewRegistry()
|
||||
r.Register(tools.NewUpdatePlanTool())
|
||||
return r, nil, nil
|
||||
},
|
||||
ResolveWorkspaceRoot: func(cwd string) (string, error) { return cwd, nil },
|
||||
Store: store,
|
||||
AgentInfo: Implementation{Name: "zero", Version: "test"},
|
||||
}
|
||||
}
|
||||
|
||||
// clientHarness wires a client Conn to an Agent over in-memory pipes and collects
|
||||
// session/update text chunks.
|
||||
type clientHarness struct {
|
||||
client *Conn
|
||||
updates chan string
|
||||
stop func()
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T, deps Deps) *clientHarness {
|
||||
t.Helper()
|
||||
ar, bw := io.Pipe() // agent -> client
|
||||
br, aw := io.Pipe() // client -> agent
|
||||
agentConn := NewConn(ar, aw)
|
||||
client := NewConn(br, bw)
|
||||
a := NewAgent(agentConn, deps)
|
||||
|
||||
h := &clientHarness{client: client, updates: make(chan string, 128)}
|
||||
client.HandleNotify(MethodSessionUpdate, func(_ context.Context, params json.RawMessage) {
|
||||
var probe struct {
|
||||
Update struct {
|
||||
SessionUpdate string `json:"sessionUpdate"`
|
||||
Content struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
} `json:"update"`
|
||||
}
|
||||
if json.Unmarshal(params, &probe) != nil {
|
||||
return
|
||||
}
|
||||
if probe.Update.SessionUpdate == UpdateAgentMessageChunk {
|
||||
h.updates <- probe.Update.Content.Text
|
||||
}
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() { _ = a.Serve(ctx) }()
|
||||
go func() { _ = client.Serve(ctx) }()
|
||||
h.stop = func() {
|
||||
cancel()
|
||||
_ = aw.Close()
|
||||
_ = bw.Close()
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func TestACPEndToEndPrompt(t *testing.T) {
|
||||
h := newHarness(t, testDeps(t))
|
||||
defer h.stop()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// initialize
|
||||
var initRes InitializeResult
|
||||
if err := h.client.Call(ctx, MethodInitialize, InitializeParams{ProtocolVersion: ProtocolVersion}, &initRes); err != nil {
|
||||
t.Fatalf("initialize: %v", err)
|
||||
}
|
||||
if initRes.ProtocolVersion != ProtocolVersion {
|
||||
t.Fatalf("protocol version = %d", initRes.ProtocolVersion)
|
||||
}
|
||||
if !initRes.AgentCapabilities.LoadSession || !initRes.AgentCapabilities.PromptCapabilities.Image {
|
||||
t.Fatalf("unexpected capabilities: %+v", initRes.AgentCapabilities)
|
||||
}
|
||||
|
||||
// session/new
|
||||
var newRes NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
if newRes.SessionID == "" {
|
||||
t.Fatal("session/new returned empty sessionId")
|
||||
}
|
||||
if newRes.Modes == nil || newRes.Modes.CurrentModeID != string(agent.PermissionModeAuto) {
|
||||
t.Fatalf("expected auto mode, got %+v", newRes.Modes)
|
||||
}
|
||||
|
||||
// session/prompt
|
||||
var promptRes PromptResult
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: newRes.SessionID,
|
||||
Prompt: []ContentBlock{TextBlock("hi")},
|
||||
}, &promptRes); err != nil {
|
||||
t.Fatalf("session/prompt: %v", err)
|
||||
}
|
||||
if promptRes.StopReason != StopEndTurn {
|
||||
t.Fatalf("stopReason = %q, want %q", promptRes.StopReason, StopEndTurn)
|
||||
}
|
||||
|
||||
// The streamed agent_message_chunk(s) should carry the assistant text.
|
||||
if got := drainText(t, h.updates); !strings.Contains(got, "Hello from ZERO") {
|
||||
t.Fatalf("streamed text = %q, want it to contain the assistant message", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPUnknownSessionPromptErrors(t *testing.T) {
|
||||
h := newHarness(t, testDeps(t))
|
||||
defer h.stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{SessionID: "nope", Prompt: []ContentBlock{TextBlock("x")}}, &PromptResult{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPSetModeUpdatesSession(t *testing.T) {
|
||||
h := newHarness(t, testDeps(t))
|
||||
defer h.stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var newRes NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
// auto/ask are accepted.
|
||||
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeAsk)}, &SetSessionModeResult{}); err != nil {
|
||||
t.Fatalf("set_mode ask: %v", err)
|
||||
}
|
||||
// Unsafe must be rejected over ACP — a client can't self-grant no-prompt host access.
|
||||
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeUnsafe)}, &SetSessionModeResult{}); err == nil {
|
||||
t.Fatal("expected Unsafe mode to be rejected over ACP")
|
||||
}
|
||||
// An unknown mode must be rejected.
|
||||
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: "bogus"}, &SetSessionModeResult{}); err == nil {
|
||||
t.Fatal("expected error for unknown mode")
|
||||
}
|
||||
}
|
||||
|
||||
// TestACPRunTurnWiresSandboxAndScopedRegistry proves the sandbox engine and the
|
||||
// scoped registry from BuildWorkspace actually reach agent.Options — i.e. ACP
|
||||
// shell tools run confined, not unconfined on the host.
|
||||
func TestACPRunTurnWiresSandboxAndScopedRegistry(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
reg := tools.NewRegistry()
|
||||
reg.Register(tools.NewUpdatePlanTool())
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
|
||||
deps.BuildWorkspace = func(string, config.ResolvedConfig) (*tools.Registry, *sandbox.Engine, error) {
|
||||
return reg, engine, nil
|
||||
}
|
||||
var captured agent.Options
|
||||
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
|
||||
captured = opts
|
||||
return agent.Result{FinalAnswer: "ok"}, nil
|
||||
}
|
||||
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var newRes NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{SessionID: newRes.SessionID, Prompt: []ContentBlock{TextBlock("hi")}}, &PromptResult{}); err != nil {
|
||||
t.Fatalf("session/prompt: %v", err)
|
||||
}
|
||||
if captured.Sandbox != engine {
|
||||
t.Fatal("sandbox engine was not wired into agent.Options (shell tools would run unconfined)")
|
||||
}
|
||||
if captured.Registry != reg {
|
||||
t.Fatal("scoped registry was not wired into agent.Options")
|
||||
}
|
||||
}
|
||||
|
||||
// TestACPRejectsInvalidCwd confirms session/new fails when the workspace root
|
||||
// resolver rejects the client cwd (e.g. filesystem root).
|
||||
func TestACPRejectsInvalidCwd(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
deps.ResolveWorkspaceRoot = func(string) (string, error) {
|
||||
return "", fmt.Errorf("cwd must not be the filesystem root")
|
||||
}
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: "/", McpServers: []McpServer{}}, &NewSessionResult{}); err == nil {
|
||||
t.Fatal("expected session/new to reject an invalid cwd")
|
||||
}
|
||||
}
|
||||
|
||||
// drainText collects streamed chunks for a short window and concatenates them.
|
||||
func drainText(t *testing.T, ch <-chan string) string {
|
||||
t.Helper()
|
||||
var b strings.Builder
|
||||
deadline := time.After(2 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case s := <-ch:
|
||||
b.WriteString(s)
|
||||
if strings.Contains(b.String(), "Hello from ZERO") {
|
||||
return b.String()
|
||||
}
|
||||
case <-deadline:
|
||||
return b.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
// Package acp implements the Agent Client Protocol (ACP) surface for ZERO: a
|
||||
// JSON-RPC 2.0 peer spoken over stdio (newline-delimited JSON) so editors such
|
||||
// as Zed, JetBrains, and Neovim can drive ZERO's agent core as a backend.
|
||||
//
|
||||
// The protocol shapes (method names, message fields) are derived solely from the
|
||||
// public, Apache-licensed ACP specification at agentclientprotocol.com and the
|
||||
// agentclientprotocol/agent-client-protocol repository (schema/v1). All logic
|
||||
// here is written originally against ZERO's own interfaces.
|
||||
package acp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// JSON-RPC 2.0 standard error codes.
|
||||
const (
|
||||
codeParseError = -32700
|
||||
codeInvalidRequest = -32600
|
||||
codeMethodNotFound = -32601
|
||||
codeInvalidParams = -32602
|
||||
codeInternalError = -32603
|
||||
)
|
||||
|
||||
// rpcError is a JSON-RPC 2.0 error object.
|
||||
type rpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func (e *rpcError) Error() string {
|
||||
if e == nil {
|
||||
return "<nil rpc error>"
|
||||
}
|
||||
return fmt.Sprintf("jsonrpc error %d: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// RPCError builds a protocol error to return from a handler. Use it when a
|
||||
// handler needs to control the wire-level error code; any other error a handler
|
||||
// returns is reported as a generic internal error.
|
||||
func RPCError(code int, message string) error {
|
||||
return &rpcError{Code: code, Message: message}
|
||||
}
|
||||
|
||||
// rpcMessage is the union of request, response, and notification frames. The
|
||||
// shape is disambiguated by which fields are present (per JSON-RPC 2.0):
|
||||
// - request: method + id
|
||||
// - notification: method, no id
|
||||
// - response: id + (result | error), no method
|
||||
type rpcMessage struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *rpcError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (m rpcMessage) isResponse() bool { return m.Method == "" && len(m.ID) > 0 }
|
||||
func (m rpcMessage) isRequest() bool { return m.Method != "" && len(m.ID) > 0 }
|
||||
func (m rpcMessage) isNotify() bool { return m.Method != "" && len(m.ID) == 0 }
|
||||
|
||||
// HandlerFunc handles an inbound request and returns the result to encode, or an
|
||||
// error. Returning an *rpcError controls the wire code; any other error becomes
|
||||
// a generic internal error.
|
||||
type HandlerFunc func(ctx context.Context, params json.RawMessage) (any, error)
|
||||
|
||||
// NotifyFunc handles an inbound notification (no response is sent).
|
||||
type NotifyFunc func(ctx context.Context, params json.RawMessage)
|
||||
|
||||
// Conn is a JSON-RPC 2.0 peer over a single ndjson stream pair. It both serves
|
||||
// inbound requests/notifications (via registered handlers) and issues outbound
|
||||
// requests/notifications — needed because ACP is bidirectional (the agent calls
|
||||
// the client for session/request_permission, fs/*, terminal/*).
|
||||
type Conn struct {
|
||||
reader *bufio.Reader
|
||||
w io.Writer
|
||||
|
||||
writeMu sync.Mutex // serializes all writes to w
|
||||
|
||||
handlers map[string]HandlerFunc
|
||||
notifiers map[string]NotifyFunc
|
||||
|
||||
mu sync.Mutex
|
||||
nextID int64
|
||||
pending map[int64]chan rpcMessage
|
||||
closed bool
|
||||
|
||||
wg sync.WaitGroup // tracks in-flight inbound handlers
|
||||
}
|
||||
|
||||
// NewConn builds a peer reading ndjson from r and writing ndjson to w.
|
||||
func NewConn(r io.Reader, w io.Writer) *Conn {
|
||||
return &Conn{
|
||||
reader: bufio.NewReader(r),
|
||||
w: w,
|
||||
handlers: make(map[string]HandlerFunc),
|
||||
notifiers: make(map[string]NotifyFunc),
|
||||
pending: make(map[int64]chan rpcMessage),
|
||||
}
|
||||
}
|
||||
|
||||
// Handle registers a request handler for method.
|
||||
func (c *Conn) Handle(method string, fn HandlerFunc) { c.handlers[method] = fn }
|
||||
|
||||
// HandleNotify registers a notification handler for method.
|
||||
func (c *Conn) HandleNotify(method string, fn NotifyFunc) { c.notifiers[method] = fn }
|
||||
|
||||
// Serve runs the read loop until the stream ends, ctx is cancelled, or a fatal
|
||||
// decode error occurs. Inbound requests and notifications are dispatched on
|
||||
// their own goroutines so a long-running handler (e.g. session/prompt) never
|
||||
// blocks the loop from delivering session/cancel or a permission response.
|
||||
func (c *Conn) Serve(ctx context.Context) error {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
// On exit, cancel in-flight handlers (so a blocked outbound Call unblocks via
|
||||
// ctx) and then wait for them to finish writing their responses. Without this,
|
||||
// a finite input stream (e.g. piped ndjson that EOFs right after a request)
|
||||
// would race the dispatch goroutine and drop the response.
|
||||
defer func() {
|
||||
cancel()
|
||||
c.wg.Wait()
|
||||
}()
|
||||
|
||||
for {
|
||||
// One ndjson value per line. Framing per line (rather than streaming the
|
||||
// decoder) keeps a single malformed line from making the whole connection
|
||||
// unrecoverable — we report -32700 and continue.
|
||||
line, err := c.reader.ReadBytes('\n')
|
||||
if len(bytes.TrimSpace(line)) > 0 {
|
||||
c.handleLine(ctx, line)
|
||||
}
|
||||
if err != nil {
|
||||
c.failAllPending(err)
|
||||
if errors.Is(err, io.EOF) || ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleLine decodes and dispatches one ndjson frame. A parse failure replies
|
||||
// with -32700 (id null) and keeps the connection alive.
|
||||
func (c *Conn) handleLine(ctx context.Context, line []byte) {
|
||||
var msg rpcMessage
|
||||
if err := json.Unmarshal(bytes.TrimSpace(line), &msg); err != nil {
|
||||
c.writeError(json.RawMessage("null"), &rpcError{Code: codeParseError, Message: "parse error"})
|
||||
return
|
||||
}
|
||||
if msg.JSONRPC != "" && msg.JSONRPC != "2.0" {
|
||||
if len(msg.ID) > 0 {
|
||||
c.writeError(msg.ID, &rpcError{Code: codeInvalidRequest, Message: "unsupported jsonrpc version"})
|
||||
}
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case msg.isResponse():
|
||||
c.deliver(msg)
|
||||
case msg.isRequest():
|
||||
c.wg.Add(1)
|
||||
go func(m rpcMessage) {
|
||||
defer c.wg.Done()
|
||||
c.dispatchRequest(ctx, m)
|
||||
}(msg)
|
||||
case msg.isNotify():
|
||||
if fn := c.notifiers[msg.Method]; fn != nil {
|
||||
c.wg.Add(1)
|
||||
go func(m rpcMessage) {
|
||||
defer c.wg.Done()
|
||||
fn(ctx, m.Params)
|
||||
}(msg)
|
||||
}
|
||||
default:
|
||||
// Malformed frame; reply only if we can identify a request id.
|
||||
if len(msg.ID) > 0 {
|
||||
c.writeError(msg.ID, &rpcError{Code: codeInvalidRequest, Message: "invalid request"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) dispatchRequest(ctx context.Context, msg rpcMessage) {
|
||||
fn := c.handlers[msg.Method]
|
||||
if fn == nil {
|
||||
c.writeError(msg.ID, &rpcError{Code: codeMethodNotFound, Message: "method not found: " + msg.Method})
|
||||
return
|
||||
}
|
||||
result, err := fn(ctx, msg.Params)
|
||||
if err != nil {
|
||||
var re *rpcError
|
||||
if errors.As(err, &re) {
|
||||
c.writeError(msg.ID, re)
|
||||
} else {
|
||||
c.writeError(msg.ID, &rpcError{Code: codeInternalError, Message: err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.writeResult(msg.ID, result)
|
||||
}
|
||||
|
||||
// Call issues an outbound request and blocks until the response arrives, ctx is
|
||||
// cancelled, or the connection closes.
|
||||
func (c *Conn) Call(ctx context.Context, method string, params any, result any) error {
|
||||
raw, err := marshalParams(params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
return errors.New("acp: connection closed")
|
||||
}
|
||||
c.nextID++
|
||||
id := c.nextID
|
||||
ch := make(chan rpcMessage, 1)
|
||||
c.pending[id] = ch
|
||||
c.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
delete(c.pending, id)
|
||||
c.mu.Unlock()
|
||||
}()
|
||||
|
||||
idRaw, _ := json.Marshal(id)
|
||||
if err := c.write(rpcMessage{JSONRPC: "2.0", ID: idRaw, Method: method, Params: raw}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case resp := <-ch:
|
||||
if resp.Error != nil {
|
||||
return resp.Error
|
||||
}
|
||||
if result != nil && len(resp.Result) > 0 {
|
||||
return json.Unmarshal(resp.Result, result)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Notify issues an outbound notification (no response expected).
|
||||
func (c *Conn) Notify(method string, params any) error {
|
||||
raw, err := marshalParams(params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.write(rpcMessage{JSONRPC: "2.0", Method: method, Params: raw})
|
||||
}
|
||||
|
||||
func (c *Conn) deliver(msg rpcMessage) {
|
||||
var id int64
|
||||
if err := json.Unmarshal(msg.ID, &id); err != nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
ch := c.pending[id]
|
||||
delete(c.pending, id)
|
||||
c.mu.Unlock()
|
||||
if ch == nil {
|
||||
return
|
||||
}
|
||||
// Non-blocking: the pending channel is cap-1 and may have been abandoned (e.g.
|
||||
// a Call cancelled by session/cancel). A duplicate or late response frame must
|
||||
// never block the read-loop goroutine.
|
||||
select {
|
||||
case ch <- msg:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) failAllPending(err error) {
|
||||
c.mu.Lock()
|
||||
c.closed = true
|
||||
pending := c.pending
|
||||
c.pending = make(map[int64]chan rpcMessage)
|
||||
c.mu.Unlock()
|
||||
for _, ch := range pending {
|
||||
select {
|
||||
case ch <- rpcMessage{Error: &rpcError{Code: codeInternalError, Message: err.Error()}}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) writeResult(id json.RawMessage, result any) {
|
||||
raw, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
c.writeError(id, &rpcError{Code: codeInternalError, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
_ = c.write(rpcMessage{JSONRPC: "2.0", ID: id, Result: raw})
|
||||
}
|
||||
|
||||
func (c *Conn) writeError(id json.RawMessage, e *rpcError) {
|
||||
_ = c.write(rpcMessage{JSONRPC: "2.0", ID: id, Error: e})
|
||||
}
|
||||
|
||||
func (c *Conn) write(msg rpcMessage) error {
|
||||
msg.JSONRPC = "2.0"
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
if _, err := c.w.Write(append(data, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// marshalParams encodes params, treating nil as an absent params field.
|
||||
func marshalParams(params any) (json.RawMessage, error) {
|
||||
if params == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if raw, ok := params.(json.RawMessage); ok {
|
||||
return raw, nil
|
||||
}
|
||||
return json.Marshal(params)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// connPair wires two Conns together over in-memory pipes and serves both.
|
||||
func connPair(t *testing.T) (a, b *Conn, stop func()) {
|
||||
t.Helper()
|
||||
ar, bw := io.Pipe() // b -> a
|
||||
br, aw := io.Pipe() // a -> b
|
||||
a = NewConn(ar, aw)
|
||||
b = NewConn(br, bw)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() { _ = a.Serve(ctx) }()
|
||||
go func() { _ = b.Serve(ctx) }()
|
||||
return a, b, func() {
|
||||
cancel()
|
||||
_ = aw.Close()
|
||||
_ = bw.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnRequestResponse(t *testing.T) {
|
||||
a, b, stop := connPair(t)
|
||||
defer stop()
|
||||
|
||||
b.Handle("add", func(_ context.Context, params json.RawMessage) (any, error) {
|
||||
var in struct{ X, Y int }
|
||||
if err := json.Unmarshal(params, &in); err != nil {
|
||||
return nil, RPCError(codeInvalidParams, "bad params")
|
||||
}
|
||||
return map[string]int{"sum": in.X + in.Y}, nil
|
||||
})
|
||||
|
||||
var out struct{ Sum int }
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := a.Call(ctx, "add", map[string]int{"X": 2, "Y": 3}, &out); err != nil {
|
||||
t.Fatalf("call: %v", err)
|
||||
}
|
||||
if out.Sum != 5 {
|
||||
t.Fatalf("sum = %d, want 5", out.Sum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnNotification(t *testing.T) {
|
||||
a, b, stop := connPair(t)
|
||||
defer stop()
|
||||
|
||||
got := make(chan string, 1)
|
||||
b.HandleNotify("ping", func(_ context.Context, params json.RawMessage) {
|
||||
var in struct{ Msg string }
|
||||
_ = json.Unmarshal(params, &in)
|
||||
got <- in.Msg
|
||||
})
|
||||
|
||||
if err := a.Notify("ping", map[string]string{"Msg": "hello"}); err != nil {
|
||||
t.Fatalf("notify: %v", err)
|
||||
}
|
||||
select {
|
||||
case msg := <-got:
|
||||
if msg != "hello" {
|
||||
t.Fatalf("got %q, want hello", msg)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("notification not delivered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnMethodNotFound(t *testing.T) {
|
||||
a, _, stop := connPair(t)
|
||||
defer stop()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
err := a.Call(ctx, "does_not_exist", nil, nil)
|
||||
var re *rpcError
|
||||
if !asRPCError(err, &re) {
|
||||
t.Fatalf("expected rpcError, got %v", err)
|
||||
}
|
||||
if re.Code != codeMethodNotFound {
|
||||
t.Fatalf("code = %d, want %d", re.Code, codeMethodNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnHandlerError(t *testing.T) {
|
||||
a, b, stop := connPair(t)
|
||||
defer stop()
|
||||
b.Handle("boom", func(_ context.Context, _ json.RawMessage) (any, error) {
|
||||
return nil, RPCError(codeInvalidParams, "nope")
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
err := a.Call(ctx, "boom", nil, nil)
|
||||
var re *rpcError
|
||||
if !asRPCError(err, &re) || re.Code != codeInvalidParams {
|
||||
t.Fatalf("expected invalid-params rpcError, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnBidirectionalDuringHandler proves that while one peer is inside a
|
||||
// request handler it can issue an outbound request back to the caller and the
|
||||
// caller answers it — exactly the session/prompt -> session/request_permission
|
||||
// pattern. If the read loop blocked on the handler, this would deadlock.
|
||||
func TestConnBidirectionalDuringHandler(t *testing.T) {
|
||||
a, b, stop := connPair(t)
|
||||
defer stop()
|
||||
|
||||
// a answers an "approve?" callback.
|
||||
a.Handle("approve", func(_ context.Context, _ json.RawMessage) (any, error) {
|
||||
return map[string]bool{"ok": true}, nil
|
||||
})
|
||||
|
||||
// b's "run" handler calls back to a mid-flight.
|
||||
b.Handle("run", func(ctx context.Context, _ json.RawMessage) (any, error) {
|
||||
var approval struct{ OK bool }
|
||||
if err := b.Call(ctx, "approve", nil, &approval); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]bool{"ran": approval.OK}, nil
|
||||
})
|
||||
|
||||
var out struct{ Ran bool }
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := a.Call(ctx, "run", nil, &out); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
if !out.Ran {
|
||||
t.Fatal("expected ran=true via mid-handler callback")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnSurvivesMalformedLine proves a single bad ndjson line yields a -32700
|
||||
// and does NOT tear down the connection — a following valid request still works.
|
||||
func TestConnSurvivesMalformedLine(t *testing.T) {
|
||||
clientR, serverW := io.Pipe() // server -> client
|
||||
serverR, clientW := io.Pipe() // client -> server
|
||||
server := NewConn(serverR, serverW)
|
||||
server.Handle("ping", func(_ context.Context, _ json.RawMessage) (any, error) { return "pong", nil })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer func() {
|
||||
cancel()
|
||||
_ = serverW.Close()
|
||||
_ = clientW.Close()
|
||||
}()
|
||||
go func() { _ = server.Serve(ctx) }()
|
||||
|
||||
go func() {
|
||||
_, _ = clientW.Write([]byte("this is not json\n"))
|
||||
_, _ = clientW.Write([]byte(`{"jsonrpc":"2.0","id":1,"method":"ping"}` + "\n"))
|
||||
}()
|
||||
|
||||
dec := json.NewDecoder(clientR)
|
||||
var sawParseError, sawPong bool
|
||||
for i := 0; i < 2; i++ {
|
||||
var msg struct {
|
||||
Result any `json:"result"`
|
||||
Error *struct {
|
||||
Code int `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- dec.Decode(&msg) }()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("decode response %d: %v", i, err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("timed out waiting for a response")
|
||||
}
|
||||
if msg.Error != nil && msg.Error.Code == codeParseError {
|
||||
sawParseError = true
|
||||
}
|
||||
if r, ok := msg.Result.(string); ok && r == "pong" {
|
||||
sawPong = true
|
||||
}
|
||||
}
|
||||
if !sawParseError {
|
||||
t.Error("expected a -32700 parse error for the malformed line")
|
||||
}
|
||||
if !sawPong {
|
||||
t.Error("expected the valid request to still be answered (connection survived the bad line)")
|
||||
}
|
||||
}
|
||||
|
||||
func asRPCError(err error, target **rpcError) bool {
|
||||
re, ok := err.(*rpcError)
|
||||
if ok {
|
||||
*target = re
|
||||
}
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
)
|
||||
|
||||
// permission.go maps ZERO's permission prompt model onto ACP's
|
||||
// session/request_permission request/option/outcome model. The option id on the
|
||||
// wire carries the ZERO decision action verbatim, so mapping the client's
|
||||
// selection back to a ZERO decision is exact and lossless.
|
||||
|
||||
// buildPermissionOptions turns the decisions ZERO offers for a tool call into ACP
|
||||
// PermissionOptions. Only the actions ZERO actually presented (AvailableDecisions)
|
||||
// are surfaced; the optionId is the ZERO action string for a clean round-trip.
|
||||
func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
|
||||
actions := req.AvailableDecisions
|
||||
if len(actions) == 0 {
|
||||
// Sensible default if ZERO didn't enumerate: allow once / reject.
|
||||
actions = []agent.PermissionDecisionAction{
|
||||
agent.PermissionDecisionAllow,
|
||||
agent.PermissionDecisionDeny,
|
||||
}
|
||||
}
|
||||
options := make([]PermissionOption, 0, len(actions))
|
||||
for _, action := range actions {
|
||||
kind, name := optionKindFor(action)
|
||||
if kind == "" {
|
||||
continue // skip actions that have no clean ACP option (e.g. cancel)
|
||||
}
|
||||
options = append(options, PermissionOption{
|
||||
OptionID: string(action),
|
||||
Name: name,
|
||||
Kind: kind,
|
||||
})
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
// optionKindFor maps a ZERO decision action to an ACP PermissionOptionKind and a
|
||||
// human label. Returns an empty kind for actions that ACP expresses through the
|
||||
// outcome rather than an option (cancel).
|
||||
func optionKindFor(action agent.PermissionDecisionAction) (kind, name string) {
|
||||
switch action {
|
||||
case agent.PermissionDecisionAllow, agent.PermissionDecisionAllowStrict:
|
||||
return PermAllowOnce, "Allow"
|
||||
case agent.PermissionDecisionAllowForSession:
|
||||
return PermAllowAlways, "Allow for this session"
|
||||
case agent.PermissionDecisionAllowPrefix:
|
||||
return PermAllowAlways, "Allow this command for the session"
|
||||
case agent.PermissionDecisionAlwaysAllow:
|
||||
return PermAllowAlways, "Always allow"
|
||||
case agent.PermissionDecisionAlwaysAllowPrefix:
|
||||
return PermAllowAlways, "Always allow this command"
|
||||
case agent.PermissionDecisionDeny:
|
||||
return PermRejectOnce, "Reject"
|
||||
case agent.PermissionDecisionCancel:
|
||||
return "", "" // expressed as outcome=cancelled, not an option
|
||||
default:
|
||||
return "", ""
|
||||
}
|
||||
}
|
||||
|
||||
// decisionFromOutcome maps the client's permission outcome back to a ZERO
|
||||
// decision. A cancelled outcome cancels the run; a selected option id is the ZERO
|
||||
// action verbatim (validated against what was offered); anything unrecognized
|
||||
// fails closed to deny.
|
||||
func decisionFromOutcome(outcome RequestPermissionOutcome, offered []agent.PermissionDecisionAction) agent.PermissionDecision {
|
||||
switch outcome.Outcome {
|
||||
case OutcomeCancelled:
|
||||
return agent.PermissionDecision{Action: agent.PermissionDecisionCancel, Reason: "client cancelled"}
|
||||
case OutcomeSelected:
|
||||
action := agent.PermissionDecisionAction(outcome.OptionID)
|
||||
// Bind to what was actually offered for THIS call: a client must not be able
|
||||
// to return a broader grant (always_allow / allow_for_session) that wasn't
|
||||
// presented. Anything not offered fails closed to deny.
|
||||
if actionOffered(action, offered) {
|
||||
return agent.PermissionDecision{Action: action}
|
||||
}
|
||||
return agent.PermissionDecision{Action: agent.PermissionDecisionDeny, Reason: "permission option was not offered"}
|
||||
default:
|
||||
return agent.PermissionDecision{Action: agent.PermissionDecisionDeny, Reason: "no permission outcome"}
|
||||
}
|
||||
}
|
||||
|
||||
func actionOffered(action agent.PermissionDecisionAction, offered []agent.PermissionDecisionAction) bool {
|
||||
for _, a := range offered {
|
||||
if a == action {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// permissionToolCall builds the ToolCall descriptor embedded in a
|
||||
// session/request_permission request from a ZERO permission request.
|
||||
func permissionToolCall(req agent.PermissionRequest) ToolCallUpdate {
|
||||
args := marshalArgs(req.Args)
|
||||
return ToolCallUpdate{
|
||||
ToolCallID: req.ToolCallID,
|
||||
Title: toolTitle(req.ToolName, string(args)),
|
||||
Kind: toolKindFor(req.ToolName),
|
||||
Status: ToolStatusPending,
|
||||
RawInput: rawInputBytes(args),
|
||||
}
|
||||
}
|
||||
|
||||
func marshalArgs(args map[string]any) []byte {
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func rawInputBytes(data []byte) json.RawMessage {
|
||||
if len(data) == 0 || !json.Valid(data) {
|
||||
return nil
|
||||
}
|
||||
return json.RawMessage(data)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
)
|
||||
|
||||
func TestBuildPermissionOptions(t *testing.T) {
|
||||
req := agent.PermissionRequest{
|
||||
ToolCallID: "tc1",
|
||||
ToolName: "bash",
|
||||
AvailableDecisions: []agent.PermissionDecisionAction{
|
||||
agent.PermissionDecisionAllow,
|
||||
agent.PermissionDecisionAllowForSession,
|
||||
agent.PermissionDecisionAlwaysAllow,
|
||||
agent.PermissionDecisionDeny,
|
||||
agent.PermissionDecisionCancel, // must be dropped (expressed as outcome)
|
||||
},
|
||||
}
|
||||
opts := buildPermissionOptions(req)
|
||||
if len(opts) != 4 {
|
||||
t.Fatalf("expected 4 options (cancel dropped), got %d: %+v", len(opts), opts)
|
||||
}
|
||||
// optionId must carry the ZERO action verbatim for a clean round trip.
|
||||
if opts[0].OptionID != string(agent.PermissionDecisionAllow) || opts[0].Kind != PermAllowOnce {
|
||||
t.Errorf("allow option = %+v", opts[0])
|
||||
}
|
||||
if opts[1].Kind != PermAllowAlways || opts[3].Kind != PermRejectOnce {
|
||||
t.Errorf("kinds = %q, %q", opts[1].Kind, opts[3].Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPermissionOptionsDefault(t *testing.T) {
|
||||
opts := buildPermissionOptions(agent.PermissionRequest{ToolName: "x"})
|
||||
if len(opts) != 2 {
|
||||
t.Fatalf("expected default allow+deny, got %d", len(opts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecisionFromOutcome(t *testing.T) {
|
||||
offered := []agent.PermissionDecisionAction{
|
||||
agent.PermissionDecisionAllow,
|
||||
agent.PermissionDecisionAlwaysAllow,
|
||||
agent.PermissionDecisionDeny,
|
||||
}
|
||||
if d := decisionFromOutcome(RequestPermissionOutcome{Outcome: OutcomeCancelled}, offered); d.Action != agent.PermissionDecisionCancel {
|
||||
t.Errorf("cancelled -> %q, want cancel", d.Action)
|
||||
}
|
||||
if d := decisionFromOutcome(RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: "allow"}, offered); d.Action != agent.PermissionDecisionAllow {
|
||||
t.Errorf("selected allow -> %q", d.Action)
|
||||
}
|
||||
if d := decisionFromOutcome(RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: "always_allow"}, offered); d.Action != agent.PermissionDecisionAlwaysAllow {
|
||||
t.Errorf("selected always_allow -> %q", d.Action)
|
||||
}
|
||||
// Unknown option fails closed to deny.
|
||||
if d := decisionFromOutcome(RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: "bogus"}, offered); d.Action != agent.PermissionDecisionDeny {
|
||||
t.Errorf("unknown option -> %q, want deny", d.Action)
|
||||
}
|
||||
// Missing/empty outcome fails closed to deny.
|
||||
if d := decisionFromOutcome(RequestPermissionOutcome{}, offered); d.Action != agent.PermissionDecisionDeny {
|
||||
t.Errorf("empty outcome -> %q, want deny", d.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionToolCall(t *testing.T) {
|
||||
tc := permissionToolCall(agent.PermissionRequest{
|
||||
ToolCallID: "tc9",
|
||||
ToolName: "read_file",
|
||||
Args: map[string]any{"path": "a.go"},
|
||||
})
|
||||
if tc.ToolCallID != "tc9" || tc.Kind != ToolKindRead || tc.Status != ToolStatusPending {
|
||||
t.Fatalf("unexpected toolCall: %+v", tc)
|
||||
}
|
||||
if tc.Title != "read_file a.go" {
|
||||
t.Errorf("title = %q", tc.Title)
|
||||
}
|
||||
if len(tc.RawInput) == 0 {
|
||||
t.Error("expected rawInput from args")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// translate.go maps ZERO's agent events onto ACP session/update payloads. The
|
||||
// mapping functions are pure (no I/O) so they can be unit-tested directly; the
|
||||
// notifier wires them to a live JSON-RPC connection.
|
||||
|
||||
func agentMessageChunk(delta string) ContentChunk {
|
||||
return ContentChunk{SessionUpdate: UpdateAgentMessageChunk, Content: TextBlock(delta)}
|
||||
}
|
||||
|
||||
func agentThoughtChunk(delta string) ContentChunk {
|
||||
return ContentChunk{SessionUpdate: UpdateAgentThoughtChunk, Content: TextBlock(delta)}
|
||||
}
|
||||
|
||||
// toolKindFor maps a ZERO tool name to the closest ACP ToolKind so editors can
|
||||
// pick an icon/affordance. Unknown tools fall back to "other".
|
||||
func toolKindFor(name string) string {
|
||||
switch name {
|
||||
case "read_file", "read_minified_file", "list_directory":
|
||||
return ToolKindRead
|
||||
case "glob", "grep":
|
||||
return ToolKindSearch
|
||||
case "write_file", "edit_file", "apply_patch":
|
||||
return ToolKindEdit
|
||||
case "bash", "exec_command", "write_stdin":
|
||||
return ToolKindExecute
|
||||
case "web_fetch", "web_search":
|
||||
return ToolKindFetch
|
||||
case "update_plan":
|
||||
return ToolKindThink
|
||||
default:
|
||||
return ToolKindOther
|
||||
}
|
||||
}
|
||||
|
||||
// toolTitle builds a concise human title, e.g. "read_file src/main.go".
|
||||
func toolTitle(name, rawArgs string) string {
|
||||
if hint := primaryArgHint(rawArgs); hint != "" {
|
||||
return name + " " + hint
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// primaryArgHint extracts the most relevant argument (path/pattern/command) from
|
||||
// raw JSON arguments. Best-effort; returns "" when it can't parse.
|
||||
func primaryArgHint(rawArgs string) string {
|
||||
if strings.TrimSpace(rawArgs) == "" {
|
||||
return ""
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(rawArgs), &m); err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, key := range []string{"path", "file_path", "pattern", "query", "command", "url", "cwd"} {
|
||||
if v, ok := m[key].(string); ok && strings.TrimSpace(v) != "" {
|
||||
return truncateHint(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func truncateHint(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
const max = 60
|
||||
if len(s) > max {
|
||||
return s[:max] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// rawInput returns the tool arguments as a raw JSON object when they parse, else
|
||||
// nil (so a malformed/empty arg string never produces invalid JSON on the wire).
|
||||
func rawInput(args string) json.RawMessage {
|
||||
if strings.TrimSpace(args) == "" || !json.Valid([]byte(args)) {
|
||||
return nil
|
||||
}
|
||||
return json.RawMessage(args)
|
||||
}
|
||||
|
||||
// toolCallStart maps an advertised ZERO tool call to the initial ACP "tool_call"
|
||||
// update (status in_progress — ZERO executes immediately after advertising).
|
||||
func toolCallStart(call agent.ToolCall) ToolCallUpdate {
|
||||
return ToolCallUpdate{
|
||||
SessionUpdate: UpdateToolCall,
|
||||
ToolCallID: call.ID,
|
||||
Title: toolTitle(call.Name, call.Arguments),
|
||||
Kind: toolKindFor(call.Name),
|
||||
Status: ToolStatusInProgress,
|
||||
RawInput: rawInput(call.Arguments),
|
||||
}
|
||||
}
|
||||
|
||||
// toolCallResult maps a finished ZERO tool result to a "tool_call_update".
|
||||
func toolCallResult(result agent.ToolResult) ToolCallUpdate {
|
||||
status := ToolStatusCompleted
|
||||
if result.Status == tools.StatusError {
|
||||
status = ToolStatusFailed
|
||||
}
|
||||
upd := ToolCallUpdate{
|
||||
SessionUpdate: UpdateToolCallUpdate,
|
||||
ToolCallID: result.ToolCallID,
|
||||
Status: status,
|
||||
}
|
||||
if content := toolResultContent(result); len(content) > 0 {
|
||||
upd.Content = content
|
||||
}
|
||||
if locs := toolResultLocations(result); len(locs) > 0 {
|
||||
upd.Locations = locs
|
||||
}
|
||||
return upd
|
||||
}
|
||||
|
||||
func toolResultContent(result agent.ToolResult) []ToolCallContent {
|
||||
text := strings.TrimRight(result.Output, "\n")
|
||||
if text == "" {
|
||||
text = result.Display.Summary
|
||||
}
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
return []ToolCallContent{ToolContent(TextBlock(text))}
|
||||
}
|
||||
|
||||
func toolResultLocations(result agent.ToolResult) []ToolCallLocation {
|
||||
locs := make([]ToolCallLocation, 0, len(result.ChangedFiles))
|
||||
for _, f := range result.ChangedFiles {
|
||||
if strings.TrimSpace(f) == "" {
|
||||
continue
|
||||
}
|
||||
locs = append(locs, ToolCallLocation{Path: f})
|
||||
}
|
||||
return locs
|
||||
}
|
||||
|
||||
// planUpdate maps ZERO's plan items to an ACP "plan" update.
|
||||
func planUpdate(items []tools.PlanItem) PlanUpdate {
|
||||
entries := make([]PlanEntry, 0, len(items))
|
||||
for _, it := range items {
|
||||
entries = append(entries, PlanEntry{
|
||||
Content: it.Content,
|
||||
Priority: PlanPriorityMedium,
|
||||
Status: planStatusToACP(it.Status),
|
||||
})
|
||||
}
|
||||
return PlanUpdate{SessionUpdate: UpdatePlan, Entries: entries}
|
||||
}
|
||||
|
||||
// planStatusToACP maps ZERO's plan status (pending/in_progress/completed/failed)
|
||||
// to ACP's PlanEntryStatus (which has no "failed"; a failed step is terminal, so
|
||||
// it maps to completed).
|
||||
func planStatusToACP(s string) string {
|
||||
switch s {
|
||||
case "in_progress":
|
||||
return PlanStatusInProgress
|
||||
case "completed", "failed":
|
||||
return PlanStatusCompleted
|
||||
default:
|
||||
return PlanStatusPending
|
||||
}
|
||||
}
|
||||
|
||||
// promptText concatenates the text content blocks of an inbound prompt.
|
||||
func promptText(blocks []ContentBlock) string {
|
||||
var b strings.Builder
|
||||
for _, blk := range blocks {
|
||||
if blk.Type == "text" {
|
||||
b.WriteString(blk.Text)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// notifier sends translated updates over a connection for one session.
|
||||
type notifier struct {
|
||||
conn *Conn
|
||||
sessionID string
|
||||
}
|
||||
|
||||
func (n *notifier) send(update any) {
|
||||
_ = n.conn.Notify(MethodSessionUpdate, SessionNotification{SessionID: n.sessionID, Update: update})
|
||||
}
|
||||
|
||||
func (n *notifier) text(delta string) {
|
||||
if delta != "" {
|
||||
n.send(agentMessageChunk(delta))
|
||||
}
|
||||
}
|
||||
|
||||
func (n *notifier) thought(delta string) {
|
||||
if delta != "" {
|
||||
n.send(agentThoughtChunk(delta))
|
||||
}
|
||||
}
|
||||
|
||||
func (n *notifier) toolCall(call agent.ToolCall) { n.send(toolCallStart(call)) }
|
||||
func (n *notifier) toolResult(result agent.ToolResult) { n.send(toolCallResult(result)) }
|
||||
|
||||
func (n *notifier) plan(items []tools.PlanItem) {
|
||||
if len(items) > 0 {
|
||||
n.send(planUpdate(items))
|
||||
}
|
||||
}
|
||||
|
||||
func (n *notifier) currentMode(modeID string) {
|
||||
n.send(CurrentModeUpdate{SessionUpdate: UpdateCurrentMode, CurrentModeID: modeID})
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
func TestAgentMessageAndThoughtChunks(t *testing.T) {
|
||||
m := agentMessageChunk("hello")
|
||||
if m.SessionUpdate != UpdateAgentMessageChunk || m.Content.Type != "text" || m.Content.Text != "hello" {
|
||||
t.Fatalf("unexpected message chunk: %+v", m)
|
||||
}
|
||||
th := agentThoughtChunk("thinking")
|
||||
if th.SessionUpdate != UpdateAgentThoughtChunk || th.Content.Text != "thinking" {
|
||||
t.Fatalf("unexpected thought chunk: %+v", th)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolKindFor(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"read_file": ToolKindRead,
|
||||
"list_directory": ToolKindRead,
|
||||
"grep": ToolKindSearch,
|
||||
"glob": ToolKindSearch,
|
||||
"edit_file": ToolKindEdit,
|
||||
"apply_patch": ToolKindEdit,
|
||||
"bash": ToolKindExecute,
|
||||
"exec_command": ToolKindExecute,
|
||||
"web_fetch": ToolKindFetch,
|
||||
"update_plan": ToolKindThink,
|
||||
"some_mcp_tool": ToolKindOther,
|
||||
}
|
||||
for name, want := range cases {
|
||||
if got := toolKindFor(name); got != want {
|
||||
t.Errorf("toolKindFor(%q) = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolTitleAndHint(t *testing.T) {
|
||||
if got := toolTitle("read_file", `{"path":"src/main.go"}`); got != "read_file src/main.go" {
|
||||
t.Errorf("title = %q", got)
|
||||
}
|
||||
if got := toolTitle("bash", `{"command":"go test ./..."}`); got != "bash go test ./..." {
|
||||
t.Errorf("title = %q", got)
|
||||
}
|
||||
if got := toolTitle("mystery", `not json`); got != "mystery" {
|
||||
t.Errorf("malformed args should yield bare name, got %q", got)
|
||||
}
|
||||
if got := toolTitle("noargs", ``); got != "noargs" {
|
||||
t.Errorf("empty args should yield bare name, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallStart(t *testing.T) {
|
||||
upd := toolCallStart(agent.ToolCall{ID: "tc1", Name: "read_file", Arguments: `{"path":"a.go"}`})
|
||||
if upd.SessionUpdate != UpdateToolCall {
|
||||
t.Fatalf("sessionUpdate = %q", upd.SessionUpdate)
|
||||
}
|
||||
if upd.ToolCallID != "tc1" || upd.Status != ToolStatusInProgress || upd.Kind != ToolKindRead {
|
||||
t.Fatalf("unexpected start: %+v", upd)
|
||||
}
|
||||
if string(upd.RawInput) != `{"path":"a.go"}` {
|
||||
t.Fatalf("rawInput = %s", upd.RawInput)
|
||||
}
|
||||
// Malformed args must not produce invalid JSON on the wire.
|
||||
if got := toolCallStart(agent.ToolCall{ID: "x", Name: "bash", Arguments: "broken"}); got.RawInput != nil {
|
||||
t.Fatalf("malformed args should drop rawInput, got %s", got.RawInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallResult(t *testing.T) {
|
||||
ok := toolCallResult(agent.ToolResult{
|
||||
ToolCallID: "tc1",
|
||||
Name: "edit_file",
|
||||
Status: tools.StatusOK,
|
||||
Output: "applied\n",
|
||||
ChangedFiles: []string{"a.go", ""},
|
||||
})
|
||||
if ok.SessionUpdate != UpdateToolCallUpdate || ok.Status != ToolStatusCompleted {
|
||||
t.Fatalf("unexpected ok result: %+v", ok)
|
||||
}
|
||||
if len(ok.Content) != 1 || ok.Content[0].Type != "content" || ok.Content[0].Content.Text != "applied" {
|
||||
t.Fatalf("unexpected content: %+v", ok.Content)
|
||||
}
|
||||
if len(ok.Locations) != 1 || ok.Locations[0].Path != "a.go" {
|
||||
t.Fatalf("blank changed files should be dropped, got %+v", ok.Locations)
|
||||
}
|
||||
|
||||
failed := toolCallResult(agent.ToolResult{ToolCallID: "tc2", Status: tools.StatusError, Output: "boom"})
|
||||
if failed.Status != ToolStatusFailed {
|
||||
t.Fatalf("error result should be failed, got %q", failed.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanUpdateAndStatus(t *testing.T) {
|
||||
upd := planUpdate([]tools.PlanItem{
|
||||
{Content: "step a", Status: "completed"},
|
||||
{Content: "step b", Status: "in_progress"},
|
||||
{Content: "step c", Status: "failed"},
|
||||
{Content: "step d", Status: "weird"},
|
||||
})
|
||||
if upd.SessionUpdate != UpdatePlan || len(upd.Entries) != 4 {
|
||||
t.Fatalf("unexpected plan: %+v", upd)
|
||||
}
|
||||
want := []string{PlanStatusCompleted, PlanStatusInProgress, PlanStatusCompleted, PlanStatusPending}
|
||||
for i, w := range want {
|
||||
if upd.Entries[i].Status != w {
|
||||
t.Errorf("entry %d status = %q, want %q", i, upd.Entries[i].Status, w)
|
||||
}
|
||||
if upd.Entries[i].Priority != PlanPriorityMedium {
|
||||
t.Errorf("entry %d priority = %q", i, upd.Entries[i].Priority)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptText(t *testing.T) {
|
||||
got := promptText([]ContentBlock{
|
||||
TextBlock("hello "),
|
||||
ImageBlock("base64", "image/png"),
|
||||
TextBlock("world"),
|
||||
})
|
||||
if got != "hello world" {
|
||||
t.Fatalf("promptText = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
package acp
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// ProtocolVersion is the ACP protocol version ZERO speaks. Wire compatibility is
|
||||
// negotiated during initialize; v1 is the current stable version.
|
||||
const ProtocolVersion = 1
|
||||
|
||||
// Method names exactly as they appear on the wire (public ACP spec).
|
||||
const (
|
||||
MethodInitialize = "initialize"
|
||||
MethodAuthenticate = "authenticate"
|
||||
MethodSessionNew = "session/new"
|
||||
MethodSessionLoad = "session/load"
|
||||
MethodSessionPrompt = "session/prompt"
|
||||
MethodSessionCancel = "session/cancel" // notification
|
||||
MethodSessionUpdate = "session/update" // notification (agent -> client)
|
||||
MethodSessionSetMode = "session/set_mode"
|
||||
MethodSessionSetConfigOption = "session/set_config_option"
|
||||
MethodSessionRequestPerm = "session/request_permission" // agent -> client
|
||||
MethodFSReadTextFile = "fs/read_text_file" // agent -> client
|
||||
MethodFSWriteTextFile = "fs/write_text_file" // agent -> client
|
||||
|
||||
// Vendor-prefixed ZERO extensions (clients that don't support them ignore the
|
||||
// method and degrade cleanly, per the spec's _-prefixed convention).
|
||||
MethodZeroSetModel = "_zero/set_model"
|
||||
)
|
||||
|
||||
// SessionUpdate discriminator values (the "sessionUpdate" field).
|
||||
const (
|
||||
UpdateAgentMessageChunk = "agent_message_chunk"
|
||||
UpdateAgentThoughtChunk = "agent_thought_chunk"
|
||||
UpdateUserMessageChunk = "user_message_chunk"
|
||||
UpdateToolCall = "tool_call"
|
||||
UpdateToolCallUpdate = "tool_call_update"
|
||||
UpdatePlan = "plan"
|
||||
UpdateAvailableCommands = "available_commands_update"
|
||||
UpdateCurrentMode = "current_mode_update"
|
||||
)
|
||||
|
||||
// ---- initialize ----
|
||||
|
||||
type Implementation struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
type FileSystemCapabilities struct {
|
||||
ReadTextFile bool `json:"readTextFile"`
|
||||
WriteTextFile bool `json:"writeTextFile"`
|
||||
}
|
||||
|
||||
type ClientCapabilities struct {
|
||||
FS FileSystemCapabilities `json:"fs"`
|
||||
Terminal bool `json:"terminal"`
|
||||
}
|
||||
|
||||
type PromptCapabilities struct {
|
||||
Image bool `json:"image"`
|
||||
Audio bool `json:"audio"`
|
||||
EmbeddedContext bool `json:"embeddedContext"`
|
||||
}
|
||||
|
||||
type AgentCapabilities struct {
|
||||
LoadSession bool `json:"loadSession"`
|
||||
PromptCapabilities PromptCapabilities `json:"promptCapabilities"`
|
||||
}
|
||||
|
||||
type AuthMethod struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type InitializeParams struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
ClientCapabilities ClientCapabilities `json:"clientCapabilities"`
|
||||
ClientInfo *Implementation `json:"clientInfo,omitempty"`
|
||||
}
|
||||
|
||||
type InitializeResult struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
AgentCapabilities AgentCapabilities `json:"agentCapabilities"`
|
||||
AgentInfo *Implementation `json:"agentInfo,omitempty"`
|
||||
AuthMethods []AuthMethod `json:"authMethods"`
|
||||
}
|
||||
|
||||
// ---- content blocks ----
|
||||
|
||||
// ContentBlock is the polymorphic content type. ZERO emits "text" (and "image"
|
||||
// on tool content); it parses "text", "image", and "resource"/"resource_link"
|
||||
// from inbound prompts. A single struct with omitempty fields covers both
|
||||
// directions since the field names do not collide across the variants ZERO uses.
|
||||
type ContentBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Data string `json:"data,omitempty"` // image/audio: base64
|
||||
MimeType string `json:"mimeType,omitempty"` // image/audio
|
||||
URI string `json:"uri,omitempty"` // resource_link
|
||||
Name string `json:"name,omitempty"` // resource_link
|
||||
Resource json.RawMessage `json:"resource,omitempty"` // embedded resource
|
||||
}
|
||||
|
||||
func TextBlock(text string) ContentBlock { return ContentBlock{Type: "text", Text: text} }
|
||||
|
||||
func ImageBlock(base64Data, mimeType string) ContentBlock {
|
||||
return ContentBlock{Type: "image", Data: base64Data, MimeType: mimeType}
|
||||
}
|
||||
|
||||
// ---- sessions ----
|
||||
|
||||
// McpServer mirrors the editor-provided MCP server entry. ZERO owns its own MCP
|
||||
// configuration (BYOK), so these are accepted for spec compliance; ZERO's
|
||||
// configured servers remain authoritative.
|
||||
type McpServer struct {
|
||||
Name string `json:"name"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Env json.RawMessage `json:"env,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
type NewSessionParams struct {
|
||||
Cwd string `json:"cwd"`
|
||||
McpServers []McpServer `json:"mcpServers"`
|
||||
AdditionalDirectories []string `json:"additionalDirectories,omitempty"`
|
||||
}
|
||||
|
||||
type NewSessionResult struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
ConfigOptions []SessionConfigOption `json:"configOptions,omitempty"`
|
||||
Modes *SessionModeState `json:"modes,omitempty"`
|
||||
}
|
||||
|
||||
type LoadSessionParams struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Cwd string `json:"cwd"`
|
||||
McpServers []McpServer `json:"mcpServers"`
|
||||
AdditionalDirectories []string `json:"additionalDirectories,omitempty"`
|
||||
}
|
||||
|
||||
type LoadSessionResult struct {
|
||||
ConfigOptions []SessionConfigOption `json:"configOptions,omitempty"`
|
||||
Modes *SessionModeState `json:"modes,omitempty"`
|
||||
}
|
||||
|
||||
// ---- prompt turn ----
|
||||
|
||||
type PromptParams struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Prompt []ContentBlock `json:"prompt"`
|
||||
}
|
||||
|
||||
// StopReason values (why a prompt turn ended).
|
||||
const (
|
||||
StopEndTurn = "end_turn"
|
||||
StopMaxTokens = "max_tokens"
|
||||
StopRefusal = "refusal"
|
||||
StopCancelled = "cancelled"
|
||||
)
|
||||
|
||||
type PromptResult struct {
|
||||
StopReason string `json:"stopReason"`
|
||||
}
|
||||
|
||||
type CancelParams struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
}
|
||||
|
||||
// ---- session/update notification ----
|
||||
|
||||
type SessionNotification struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Update any `json:"update"`
|
||||
}
|
||||
|
||||
// AgentMessageChunk / AgentThoughtChunk / UserMessageChunk all carry a single
|
||||
// ContentBlock under "content"; the variant is set via SessionUpdate.
|
||||
type ContentChunk struct {
|
||||
SessionUpdate string `json:"sessionUpdate"`
|
||||
Content ContentBlock `json:"content"`
|
||||
}
|
||||
|
||||
// ToolKind classifies a tool call for client rendering.
|
||||
const (
|
||||
ToolKindRead = "read"
|
||||
ToolKindEdit = "edit"
|
||||
ToolKindDelete = "delete"
|
||||
ToolKindMove = "move"
|
||||
ToolKindSearch = "search"
|
||||
ToolKindExecute = "execute"
|
||||
ToolKindThink = "think"
|
||||
ToolKindFetch = "fetch"
|
||||
ToolKindOther = "other"
|
||||
)
|
||||
|
||||
// ToolCallStatus values.
|
||||
const (
|
||||
ToolStatusPending = "pending"
|
||||
ToolStatusInProgress = "in_progress"
|
||||
ToolStatusCompleted = "completed"
|
||||
ToolStatusFailed = "failed"
|
||||
)
|
||||
|
||||
// ToolCallUpdate is used for both the initial "tool_call" and subsequent
|
||||
// "tool_call_update" notifications (distinguished by SessionUpdate). It also
|
||||
// appears inside session/request_permission.
|
||||
type ToolCallUpdate struct {
|
||||
SessionUpdate string `json:"sessionUpdate,omitempty"`
|
||||
ToolCallID string `json:"toolCallId"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
RawInput json.RawMessage `json:"rawInput,omitempty"`
|
||||
Content []ToolCallContent `json:"content,omitempty"`
|
||||
Locations []ToolCallLocation `json:"locations,omitempty"`
|
||||
}
|
||||
|
||||
// ToolCallContent is a tool call's rendered output. ZERO emits "content" (a
|
||||
// text/image block) and "diff" (a file change); "terminal" is part of the spec
|
||||
// but unused because ZERO executes locally.
|
||||
type ToolCallContent struct {
|
||||
Type string `json:"type"`
|
||||
// type == "content"
|
||||
Content *ContentBlock `json:"content,omitempty"`
|
||||
// type == "diff"
|
||||
Path string `json:"path,omitempty"`
|
||||
OldText string `json:"oldText,omitempty"`
|
||||
NewText string `json:"newText,omitempty"`
|
||||
}
|
||||
|
||||
func ToolContent(block ContentBlock) ToolCallContent {
|
||||
return ToolCallContent{Type: "content", Content: &block}
|
||||
}
|
||||
|
||||
func ToolDiff(path, oldText, newText string) ToolCallContent {
|
||||
return ToolCallContent{Type: "diff", Path: path, OldText: oldText, NewText: newText}
|
||||
}
|
||||
|
||||
type ToolCallLocation struct {
|
||||
Path string `json:"path"`
|
||||
Line *int `json:"line,omitempty"`
|
||||
}
|
||||
|
||||
// ---- plan ----
|
||||
|
||||
const (
|
||||
PlanStatusPending = "pending"
|
||||
PlanStatusInProgress = "in_progress"
|
||||
PlanStatusCompleted = "completed"
|
||||
|
||||
PlanPriorityHigh = "high"
|
||||
PlanPriorityMedium = "medium"
|
||||
PlanPriorityLow = "low"
|
||||
)
|
||||
|
||||
type PlanEntry struct {
|
||||
Content string `json:"content"`
|
||||
Priority string `json:"priority"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type PlanUpdate struct {
|
||||
SessionUpdate string `json:"sessionUpdate"`
|
||||
Entries []PlanEntry `json:"entries"`
|
||||
}
|
||||
|
||||
type AvailableCommand struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type AvailableCommandsUpdate struct {
|
||||
SessionUpdate string `json:"sessionUpdate"`
|
||||
AvailableCommands []AvailableCommand `json:"availableCommands"`
|
||||
}
|
||||
|
||||
type CurrentModeUpdate struct {
|
||||
SessionUpdate string `json:"sessionUpdate"`
|
||||
CurrentModeID string `json:"currentModeId"`
|
||||
}
|
||||
|
||||
// ---- permissions ----
|
||||
|
||||
const (
|
||||
PermAllowOnce = "allow_once"
|
||||
PermAllowAlways = "allow_always"
|
||||
PermRejectOnce = "reject_once"
|
||||
PermRejectAlways = "reject_always"
|
||||
)
|
||||
|
||||
type PermissionOption struct {
|
||||
OptionID string `json:"optionId"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
|
||||
type RequestPermissionParams struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
ToolCall ToolCallUpdate `json:"toolCall"`
|
||||
Options []PermissionOption `json:"options"`
|
||||
}
|
||||
|
||||
// RequestPermissionOutcome is a tagged union: {"outcome":"cancelled"} or
|
||||
// {"outcome":"selected","optionId":"..."}.
|
||||
type RequestPermissionOutcome struct {
|
||||
Outcome string `json:"outcome"`
|
||||
OptionID string `json:"optionId,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
OutcomeSelected = "selected"
|
||||
OutcomeCancelled = "cancelled"
|
||||
)
|
||||
|
||||
type RequestPermissionResult struct {
|
||||
Outcome RequestPermissionOutcome `json:"outcome"`
|
||||
}
|
||||
|
||||
// ---- session modes ----
|
||||
|
||||
type SessionMode struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type SessionModeState struct {
|
||||
CurrentModeID string `json:"currentModeId"`
|
||||
AvailableModes []SessionMode `json:"availableModes"`
|
||||
}
|
||||
|
||||
type SetSessionModeParams struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
ModeID string `json:"modeId"`
|
||||
}
|
||||
|
||||
type SetSessionModeResult struct{}
|
||||
|
||||
// ---- session config options (model selection) ----
|
||||
|
||||
type SessionConfigOptionValue struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type SessionConfigOption struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Value string `json:"value"`
|
||||
Values []SessionConfigOptionValue `json:"values,omitempty"`
|
||||
}
|
||||
|
||||
type SetSessionConfigOptionParams struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
ConfigID string `json:"configId"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type SetSessionConfigOptionResult struct {
|
||||
ConfigOptions []SessionConfigOption `json:"configOptions"`
|
||||
}
|
||||
|
||||
// ---- vendor: _zero/set_model ----
|
||||
|
||||
type ZeroSetModelParams struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
type ZeroSetModelResult struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// configIDModel is the SessionConfigOption id ZERO uses to expose model choice
|
||||
// through the standard session/set_config_option method.
|
||||
const configIDModel = "model"
|
||||
@@ -0,0 +1,162 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"mvdan.cc/sh/v3/syntax"
|
||||
)
|
||||
|
||||
func proposedCommandPrefix(toolName string, args map[string]any) []string {
|
||||
if !isShellCommandTool(toolName) {
|
||||
return nil
|
||||
}
|
||||
command, ok := firstStringArg(args, "command", "cmd", "script", "shell")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
tokens, ok := safeShellCommandTokens(command)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if requested, ok := requestedPrefixRule(args); ok {
|
||||
if safeRequestedPrefix(requested, tokens) {
|
||||
return requested
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !sandbox.ValidCommandPrefix(tokens) {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), tokens...)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
command, ok := firstStringArg(args, "command", "cmd", "script", "shell")
|
||||
if !ok {
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
}
|
||||
tokens, ok := safeShellCommandTokens(command)
|
||||
if !ok {
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
}
|
||||
if grant, ok := options.Sandbox.LookupCommandPrefix(toolName, tokens); ok {
|
||||
return grant, true, false
|
||||
}
|
||||
if grant, ok := options.Sandbox.LookupCommandPrefixForSession(toolName, tokens); ok {
|
||||
return grant, true, true
|
||||
}
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
}
|
||||
|
||||
func isShellCommandTool(toolName string) bool {
|
||||
return toolName == "bash" || toolName == "exec_command"
|
||||
}
|
||||
|
||||
func firstStringArg(args map[string]any, names ...string) (string, bool) {
|
||||
for _, name := range names {
|
||||
if raw, ok := args[name].(string); ok {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value != "" {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func requestedPrefixRule(args map[string]any) ([]string, bool) {
|
||||
raw, ok := args["prefix_rule"]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
switch value := raw.(type) {
|
||||
case []string:
|
||||
return cleanPrefixRule(value), true
|
||||
case []any:
|
||||
out := make([]string, 0, len(value))
|
||||
for _, item := range value {
|
||||
part, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, true
|
||||
}
|
||||
out = append(out, part)
|
||||
}
|
||||
return cleanPrefixRule(out), true
|
||||
default:
|
||||
return nil, true
|
||||
}
|
||||
}
|
||||
|
||||
func cleanPrefixRule(prefix []string) []string {
|
||||
cleaned := make([]string, 0, len(prefix))
|
||||
for _, part := range prefix {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
return nil
|
||||
}
|
||||
cleaned = append(cleaned, part)
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func safeRequestedPrefix(prefix []string, command []string) bool {
|
||||
if len(prefix) == 0 || !sandbox.ValidCommandPrefix(prefix) || len(prefix) > len(command) {
|
||||
return false
|
||||
}
|
||||
for index := range prefix {
|
||||
if prefix[index] != command[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func safeShellCommandTokens(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 {
|
||||
return nil, false
|
||||
}
|
||||
stmt := file.Stmts[0]
|
||||
if stmt.Negated || stmt.Background || stmt.Coprocess || stmt.Disown || stmt.Semicolon.IsValid() || len(stmt.Redirs) > 0 {
|
||||
return nil, false
|
||||
}
|
||||
call, ok := stmt.Cmd.(*syntax.CallExpr)
|
||||
if !ok || len(call.Assigns) > 0 || len(call.Args) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
tokens := make([]string, 0, len(call.Args))
|
||||
for _, word := range call.Args {
|
||||
if len(word.Parts) != 1 {
|
||||
return nil, false
|
||||
}
|
||||
lit, ok := word.Parts[0].(*syntax.Lit)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
tokens = append(tokens, lit.Value)
|
||||
}
|
||||
if !sandbox.ValidCommandPrefix(tokens) {
|
||||
return nil, false
|
||||
}
|
||||
return tokens, true
|
||||
}
|
||||
|
||||
func equalStringSlices(left []string, right []string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index] != right[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{"command": "go test ./..."})
|
||||
want := []string{"go", "test", "./..."}
|
||||
if !equalStringSlices(got, want) {
|
||||
t.Fatalf("prefix = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixSupportsExecCommand(t *testing.T) {
|
||||
got := proposedCommandPrefix("exec_command", map[string]any{"cmd": "go test ./..."})
|
||||
want := []string{"go", "test", "./..."}
|
||||
if !equalStringSlices(got, want) {
|
||||
t.Fatalf("prefix = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixHonorsValidatedRequestedPrefix(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{
|
||||
"command": "git status --short",
|
||||
"prefix_rule": []any{"git", "status"},
|
||||
})
|
||||
want := []string{"git", "status"}
|
||||
if !equalStringSlices(got, want) {
|
||||
t.Fatalf("prefix = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixRejectsUnsafeRequestedPrefix(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{
|
||||
"command": "git status --short",
|
||||
"prefix_rule": []any{"git"},
|
||||
})
|
||||
if got != nil {
|
||||
t.Fatalf("broad requested prefix should be rejected, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixRejectsUnsafeShellForms(t *testing.T) {
|
||||
cases := []string{
|
||||
"echo hi && echo bye",
|
||||
"cat < in > out",
|
||||
"FOO=bar go test",
|
||||
"echo $(whoami)",
|
||||
"cat *.go",
|
||||
"bash -lc go test",
|
||||
}
|
||||
for _, command := range cases {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
if got := proposedCommandPrefix("bash", map[string]any{"command": command}); got != nil {
|
||||
t.Fatalf("unsafe command got prefix %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixRejectsUnsafeLaunchers(t *testing.T) {
|
||||
cases := []string{
|
||||
"find . -type f",
|
||||
"xargs rm -rf /tmp/x",
|
||||
"timeout 5 go test ./...",
|
||||
"nice go test ./...",
|
||||
"nohup go test ./...",
|
||||
"watch ls",
|
||||
"ssh host ls",
|
||||
"make test",
|
||||
"npm run dev",
|
||||
"command git status",
|
||||
"eval echo hi",
|
||||
"exec echo hi",
|
||||
"python script.py",
|
||||
"node script.js",
|
||||
"./script.sh --flag",
|
||||
"/tmp/script.sh --flag",
|
||||
}
|
||||
for _, command := range cases {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
if got := proposedCommandPrefix("bash", map[string]any{"command": command}); got != nil {
|
||||
t.Fatalf("unsafe launcher got prefix %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixRejectsRequestedUnsafeLauncherPrefix(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{
|
||||
"command": "find . -type f",
|
||||
"prefix_rule": []any{"find", "."},
|
||||
})
|
||||
if got != nil {
|
||||
t.Fatalf("unsafe requested launcher prefix should be rejected, got %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -77,17 +77,38 @@ type CompactionResult struct {
|
||||
// ~1k tokens is a representative mid-range cost across providers.
|
||||
const imageTokenEstimate = 1000
|
||||
|
||||
// estimateTokens is a cheap, dependency-free token estimate (~4 chars/token)
|
||||
// ApproxTextTokens estimates the token count of text WITHOUT a real tokenizer.
|
||||
// The BPE tokenizers zero targets fold a run's leading space into the following
|
||||
// token rather than emitting whitespace as its own token, so naive len/4
|
||||
// overcounts real text by ~15-20% (measured: a 24.8k-char prompt counted 5.2k
|
||||
// real tokens where len/4 said 6.2k). Counting NON-whitespace bytes / 4 tracks
|
||||
// the provider's actual count closely (validated against live usage) while
|
||||
// staying allocation- and dependency-free. zero still receives the exact count
|
||||
// back as usage on every request; this estimate is only for the pre-request
|
||||
// context budget preview and the compaction threshold.
|
||||
func ApproxTextTokens(value string) int {
|
||||
nonSpace := 0
|
||||
for i := 0; i < len(value); i++ {
|
||||
switch value[i] {
|
||||
case ' ', '\t', '\n', '\r', '\f', '\v':
|
||||
default:
|
||||
nonSpace++
|
||||
}
|
||||
}
|
||||
return nonSpace / 4
|
||||
}
|
||||
|
||||
// estimateTokens is a cheap, dependency-free token estimate (see ApproxTextTokens)
|
||||
// across message content plus tool call names/arguments and a flat per-image
|
||||
// cost. It deliberately uses no real tokenizer; it only needs to be monotonic
|
||||
// and roughly proportional so the loop can decide when to compact.
|
||||
func estimateTokens(messages []zeroruntime.Message) int {
|
||||
total := 0
|
||||
for _, message := range messages {
|
||||
total += len(message.Content) / 4
|
||||
total += ApproxTextTokens(message.Content)
|
||||
for _, call := range message.ToolCalls {
|
||||
total += len(call.Name) / 4
|
||||
total += len(call.Arguments) / 4
|
||||
total += ApproxTextTokens(call.Name)
|
||||
total += ApproxTextTokens(call.Arguments)
|
||||
total += 4 // small per-call overhead
|
||||
}
|
||||
total += len(message.Images) * imageTokenEstimate
|
||||
@@ -104,11 +125,11 @@ func estimateTokens(messages []zeroruntime.Message) int {
|
||||
func estimateToolDefTokens(tools []zeroruntime.ToolDefinition) int {
|
||||
total := 0
|
||||
for _, tool := range tools {
|
||||
total += len(tool.Name) / 4
|
||||
total += len(tool.Description) / 4
|
||||
total += ApproxTextTokens(tool.Name)
|
||||
total += ApproxTextTokens(tool.Description)
|
||||
if len(tool.Parameters) > 0 {
|
||||
if encoded, err := json.Marshal(tool.Parameters); err == nil {
|
||||
total += len(encoded) / 4
|
||||
total += ApproxTextTokens(string(encoded))
|
||||
}
|
||||
}
|
||||
total += 4 // per-tool overhead
|
||||
@@ -319,6 +340,19 @@ func (state *compactionState) maybeCompact(
|
||||
return messages
|
||||
}
|
||||
|
||||
// CHEAP FIRST STAGE: reclaim context at zero token/latency cost by pruning
|
||||
// the bodies of old, large tool results (the model has already acted on
|
||||
// them). If that brings us back under threshold, skip the paid summarizer
|
||||
// entirely and preserve recent turns verbatim.
|
||||
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
|
||||
messages = pruned
|
||||
size = estimateTokens(messages) + toolTokens
|
||||
if size <= state.threshold {
|
||||
state.lowWaterMark = size
|
||||
return messages
|
||||
}
|
||||
}
|
||||
|
||||
compacted, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: state.preserveLast,
|
||||
Summarize: summarizeClosure(ctx, provider, state.onUsage),
|
||||
|
||||
@@ -48,16 +48,16 @@ func MeasureContext(messages []zeroruntime.Message, tools []zeroruntime.ToolDefi
|
||||
}
|
||||
|
||||
// estimateToolTokens approximates the token footprint of advertised tool
|
||||
// definitions (name + description + JSON schema), matching estimateTokens'
|
||||
// ~4-chars/token heuristic so all categories use the same scale.
|
||||
// definitions (name + description + JSON schema), using the same ApproxTextTokens
|
||||
// heuristic as estimateTokens so all categories share one scale.
|
||||
func estimateToolTokens(tools []zeroruntime.ToolDefinition) int {
|
||||
total := 0
|
||||
for _, tool := range tools {
|
||||
total += len(tool.Name) / 4
|
||||
total += len(tool.Description) / 4
|
||||
total += ApproxTextTokens(tool.Name)
|
||||
total += ApproxTextTokens(tool.Description)
|
||||
if len(tool.Parameters) > 0 {
|
||||
if encoded, err := json.Marshal(tool.Parameters); err == nil {
|
||||
total += len(encoded) / 4
|
||||
total += ApproxTextTokens(string(encoded))
|
||||
}
|
||||
}
|
||||
total += 4 // per-tool overhead
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// richMCPTool mimics a real MCP tool's schema (Exa/Firecrawl-style search): an
|
||||
// object with several typed, described properties. Real MCP tools are this size
|
||||
// or larger, which is why shipping their full schemas every turn is expensive.
|
||||
type richMCPTool struct{ name string }
|
||||
|
||||
func (t richMCPTool) Name() string { return t.name }
|
||||
func (t richMCPTool) Description() string {
|
||||
return "Search the web and return ranked, structured results with titles, URLs, snippets, and optional cleaned full-page content. Supports natural-language and keyword queries with domain and date filtering."
|
||||
}
|
||||
func (t richMCPTool) Parameters() tools.Schema {
|
||||
return tools.Schema{
|
||||
Type: "object",
|
||||
AdditionalProperties: false,
|
||||
Required: []string{"query"},
|
||||
Properties: map[string]tools.PropertySchema{
|
||||
"query": {Type: "string", Description: "The search query to run against the web index. Supports natural-language and keyword queries."},
|
||||
"num_results": {Type: "integer", Description: "Maximum number of results to return (1-100). Defaults to 10."},
|
||||
"include_domains": {Type: "array", Description: "Only return results from these domains.", Items: &tools.PropertySchema{Type: "string"}},
|
||||
"exclude_domains": {Type: "array", Description: "Never return results from these domains.", Items: &tools.PropertySchema{Type: "string"}},
|
||||
"start_published_date": {Type: "string", Description: "ISO-8601 date; only results published on or after this date are returned."},
|
||||
"end_published_date": {Type: "string", Description: "ISO-8601 date; only results published on or before this date are returned."},
|
||||
"category": {Type: "string", Description: "Restrict results to a content category.", Enum: []string{"news", "research paper", "company", "tweet", "pdf"}},
|
||||
"include_text": {Type: "boolean", Description: "Include cleaned full-page text for each result (slower, larger payload)."},
|
||||
},
|
||||
}
|
||||
}
|
||||
func (t richMCPTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectNetwork, Permission: tools.PermissionAllow}
|
||||
}
|
||||
func (t richMCPTool) Run(_ context.Context, _ map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK}
|
||||
}
|
||||
func (t richMCPTool) Deferred() bool { return true }
|
||||
|
||||
// TestDeferralTokenSavingsMeasurement quantifies the win from the lowered
|
||||
// deferThreshold default: with a typical small MCP server (6 tools), threshold 10
|
||||
// (the OLD default) kept deferral OFF so all six full schemas shipped every turn,
|
||||
// while threshold 3 (the NEW default) collapses them to compact reminder lines.
|
||||
func TestDeferralTokenSavingsMeasurement(t *testing.T) {
|
||||
const mcpToolCount = 6
|
||||
registry := tools.NewRegistry()
|
||||
for i := 0; i < mcpToolCount; i++ {
|
||||
registry.Register(richMCPTool{name: fmt.Sprintf("mcp__websearch__search_v%d", i)})
|
||||
}
|
||||
registry.Register(fakeToolSearchTool{})
|
||||
|
||||
measure := func(threshold int) (int, int) {
|
||||
defs, reminder := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: threshold}, map[string]bool{})
|
||||
encoded, err := json.Marshal(defs)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal tool defs: %v", err)
|
||||
}
|
||||
bytes := len(encoded) + len(reminder)
|
||||
return bytes, bytes / 4 // ~tokens (approx 4 chars/token)
|
||||
}
|
||||
|
||||
eagerBytes, eagerTok := measure(10) // 6 < 10 -> inactive -> all schemas eager
|
||||
deferBytes, deferTok := measure(3) // 6 >= 3 -> active -> compact reminder
|
||||
savedPct := 100 * float64(eagerBytes-deferBytes) / float64(eagerBytes)
|
||||
|
||||
t.Logf("MCP tools in set: %d", mcpToolCount)
|
||||
t.Logf("threshold 10 (OLD, eager schemas): %d bytes (~%d tokens) per turn", eagerBytes, eagerTok)
|
||||
t.Logf("threshold 3 (NEW, deferred lines): %d bytes (~%d tokens) per turn", deferBytes, deferTok)
|
||||
t.Logf("SAVED: ~%d tokens per turn (%.0f%% smaller tool payload)", eagerTok-deferTok, savedPct)
|
||||
|
||||
if deferBytes >= eagerBytes {
|
||||
t.Fatalf("deferral should shrink the tool payload: eager=%d defer=%d", eagerBytes, deferBytes)
|
||||
}
|
||||
if savedPct < 50 {
|
||||
t.Fatalf("expected a large reduction, got only %.0f%%", savedPct)
|
||||
}
|
||||
}
|
||||
@@ -30,12 +30,12 @@ func TestDeniedPermissionResultCategories(t *testing.T) {
|
||||
if plain.DenialReason != DenialPermissionDenied {
|
||||
t.Fatalf("plain denial = %q, want %q", plain.DenialReason, DenialPermissionDenied)
|
||||
}
|
||||
// Sandbox-violation-driven denial.
|
||||
// Sandbox-block-driven denial.
|
||||
sandboxDenied := deniedPermissionResult(ToolCall{ID: "c2", Name: "bash"}, "outside workspace", PermissionEvent{
|
||||
ToolName: "bash",
|
||||
Violation: &sandbox.Violation{Code: "outside_workspace"},
|
||||
ToolName: "bash",
|
||||
Block: &sandbox.Block{Code: "outside_workspace"},
|
||||
})
|
||||
if sandboxDenied.DenialReason != DenialSandboxViolation {
|
||||
t.Fatalf("sandbox denial = %q, want %q", sandboxDenied.DenialReason, DenialSandboxViolation)
|
||||
if sandboxDenied.DenialReason != DenialSandboxBlock {
|
||||
t.Fatalf("sandbox denial = %q, want %q", sandboxDenied.DenialReason, DenialSandboxBlock)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// argsPermTool implements tools.ArgsPermissioner; plainTool does not.
|
||||
type argsPermTool struct{ perm tools.Permission }
|
||||
|
||||
func (argsPermTool) Name() string { return "args" }
|
||||
func (argsPermTool) Description() string { return "" }
|
||||
func (argsPermTool) Parameters() tools.Schema { return tools.Schema{} }
|
||||
func (argsPermTool) Safety() tools.Safety { return tools.Safety{Permission: tools.PermissionPrompt} }
|
||||
func (argsPermTool) Run(context.Context, map[string]any) tools.Result { return tools.Result{} }
|
||||
func (t argsPermTool) PermissionForArgs(map[string]any) tools.Permission { return t.perm }
|
||||
|
||||
type plainTool struct{ perm tools.Permission }
|
||||
|
||||
func (plainTool) Name() string { return "plain" }
|
||||
func (plainTool) Description() string { return "" }
|
||||
func (plainTool) Parameters() tools.Schema { return tools.Schema{} }
|
||||
func (t plainTool) Safety() tools.Safety { return tools.Safety{Permission: t.perm} }
|
||||
func (plainTool) Run(context.Context, map[string]any) tools.Result { return tools.Result{} }
|
||||
|
||||
func TestEffectivePermission(t *testing.T) {
|
||||
// A tool implementing ArgsPermissioner refines its permission per call.
|
||||
if got := effectivePermission(argsPermTool{perm: tools.PermissionAllow}, nil); got != tools.PermissionAllow {
|
||||
t.Fatalf("args-aware tool: got %q, want allow", got)
|
||||
}
|
||||
if got := effectivePermission(argsPermTool{perm: tools.PermissionPrompt}, nil); got != tools.PermissionPrompt {
|
||||
t.Fatalf("args-aware tool: got %q, want prompt", got)
|
||||
}
|
||||
// A tool WITHOUT the interface falls back to its static Safety().Permission —
|
||||
// the args-aware path must never change a normal tool's behavior.
|
||||
if got := effectivePermission(plainTool{perm: tools.PermissionAllow}, nil); got != tools.PermissionAllow {
|
||||
t.Fatalf("plain tool: got %q, want its static allow", got)
|
||||
}
|
||||
if got := effectivePermission(plainTool{perm: tools.PermissionPrompt}, nil); got != tools.PermissionPrompt {
|
||||
t.Fatalf("plain tool: got %q, want its static prompt", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsImageRejectionError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "400 with image mention",
|
||||
err: errors.New("HTTP 400: image content is not supported by this model"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "400 with multimodal mention",
|
||||
err: errors.New("400 Bad Request: model does not support multimodal input"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "400 with vision mention",
|
||||
err: errors.New("error 400: this model does not have vision capabilities"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "400 with unsupported content type",
|
||||
err: errors.New("400: unsupported content type"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "generic 400 no image keywords",
|
||||
err: errors.New("400 Bad Request: invalid model id"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "500 server error",
|
||||
err: errors.New("500 Internal Server Error"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "nil error",
|
||||
err: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "context limit error",
|
||||
err: errors.New("context length exceeded"),
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isImageRejectionError(tt.err); got != tt.want {
|
||||
t.Errorf("isImageRejectionError(%v) = %v, want %v", tt.err, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+737
-69
File diff suppressed because it is too large.
Load diff
+902
-21
File diff suppressed because it is too large.
Load diff
@@ -1,8 +1,11 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func TestPermissionScope(t *testing.T) {
|
||||
@@ -21,6 +24,7 @@ func TestPermissionScope(t *testing.T) {
|
||||
{name: "non-string path ignored", tool: "write_file", args: map[string]any{"path": 42}, want: ""},
|
||||
{name: "path wins over cwd", tool: "x", args: map[string]any{"cwd": "a", "path": "b"}, want: "b"},
|
||||
{name: "whitespace path is no scope", tool: "write_file", args: map[string]any{"path": " "}, want: ""},
|
||||
{name: "web fetch host", tool: "web_fetch", args: map[string]any{"url": "https://Example.COM:443/a"}, want: "example.com"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -41,3 +45,32 @@ func TestPermissionScopeTruncatesLongPaths(t *testing.T) {
|
||||
t.Fatalf("truncated scope should end with an ellipsis: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistPermissionGrantScopesWebFetchToHost(t *testing.T) {
|
||||
store, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: t.TempDir(),
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Store: store,
|
||||
})
|
||||
|
||||
grant, err := persistPermissionGrant("web_fetch", map[string]any{"url": "https://Example.COM:443/docs"}, "trust this host", Options{
|
||||
Sandbox: engine,
|
||||
Autonomy: "medium",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if grant.ScopeKind != sandbox.ScopeHost || grant.Scope != "example.com" {
|
||||
t.Fatalf("grant = %#v, want host-scoped example.com", grant)
|
||||
}
|
||||
if lookup, err := store.Lookup("web_fetch", "example.com"); err != nil || !lookup.Matched {
|
||||
t.Fatalf("expected exact host lookup to match: lookup=%#v err=%v", lookup, err)
|
||||
}
|
||||
if lookup, err := store.Lookup("web_fetch", "api.example.com"); err != nil || lookup.Matched {
|
||||
t.Fatalf("expected subdomain lookup to re-prompt: lookup=%#v err=%v", lookup, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// Stale tool-output pruning reclaims context at ZERO token/latency cost before
|
||||
// the loop falls back to the paid LLM summarizer. A long, dump-heavy session
|
||||
// accumulates large read_file/grep/glob/bash tool results that the model has
|
||||
// long since acted on; their bodies are dead weight. Pruning replaces those
|
||||
// older bodies with a compact placeholder (keeping the tool message + its
|
||||
// ToolCallID so provider replay stays valid and the model knows what was
|
||||
// there), preserving recent turns exactly — no paraphrase loss.
|
||||
const (
|
||||
// pruneProtectRecentTokens is the trailing window of tool output kept
|
||||
// verbatim — the model is most likely still using it.
|
||||
pruneProtectRecentTokens = 40000
|
||||
// pruneMinReclaimTokens gates the whole pass: only prune when the reclaimable
|
||||
// (older, large) tool output exceeds this, so a short session is untouched.
|
||||
pruneMinReclaimTokens = 20000
|
||||
// pruneMinBodyTokens skips small tool results — replacing a tiny body saves
|
||||
// nothing and just loses information.
|
||||
pruneMinBodyTokens = 200
|
||||
)
|
||||
|
||||
// prunedPlaceholder is the body a pruned tool result is replaced with. It names
|
||||
// the tool and the original size so the model can re-fetch if it needs to.
|
||||
func prunedPlaceholder(toolName string, originalTokens int) string {
|
||||
if toolName == "" {
|
||||
toolName = "tool"
|
||||
}
|
||||
return fmt.Sprintf("[pruned %s output (~%d tokens) to reclaim context — re-run the tool if you need it again]", toolName, originalTokens)
|
||||
}
|
||||
|
||||
// pruneStaleToolOutput walks messages newest-first, protects the last
|
||||
// preserveLast messages and a trailing pruneProtectRecentTokens of tool output,
|
||||
// then replaces the body of older large tool results with a placeholder. It
|
||||
// returns the (possibly) rewritten slice and the number of tokens reclaimed.
|
||||
// When nothing meets the bar it returns the input unchanged with reclaimed=0.
|
||||
//
|
||||
// It never drops a message, never touches non-tool messages, and never prunes
|
||||
// an already-pruned body — so it is idempotent and safe to run every turn.
|
||||
func pruneStaleToolOutput(messages []zeroruntime.Message, preserveLast int) ([]zeroruntime.Message, int) {
|
||||
if len(messages) == 0 {
|
||||
return messages, 0
|
||||
}
|
||||
if preserveLast < 0 {
|
||||
preserveLast = 0
|
||||
}
|
||||
|
||||
// Index → original token cost of each prunable tool body, scanning newest
|
||||
// first and skipping the protected suffix.
|
||||
type candidate struct {
|
||||
index int
|
||||
tokens int
|
||||
}
|
||||
var candidates []candidate
|
||||
recentToolTokens := 0
|
||||
reclaimable := 0
|
||||
protectUntil := len(messages) - preserveLast // indices >= this are protected
|
||||
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
msg := messages[i]
|
||||
if msg.Role != zeroruntime.MessageRoleTool {
|
||||
continue
|
||||
}
|
||||
bodyTokens := ApproxTextTokens(msg.Content)
|
||||
if i >= protectUntil {
|
||||
recentToolTokens += bodyTokens
|
||||
continue
|
||||
}
|
||||
// Still within the trailing-output protection window?
|
||||
if recentToolTokens < pruneProtectRecentTokens {
|
||||
recentToolTokens += bodyTokens
|
||||
continue
|
||||
}
|
||||
if bodyTokens < pruneMinBodyTokens || isPrunedPlaceholder(msg.Content) {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, candidate{index: i, tokens: bodyTokens})
|
||||
reclaimable += bodyTokens
|
||||
}
|
||||
|
||||
if reclaimable < pruneMinReclaimTokens || len(candidates) == 0 {
|
||||
return messages, 0
|
||||
}
|
||||
|
||||
// Copy-on-write so we never mutate the caller's slice in place.
|
||||
out := make([]zeroruntime.Message, len(messages))
|
||||
copy(out, messages)
|
||||
reclaimed := 0
|
||||
for _, c := range candidates {
|
||||
toolName := toolNameForResult(out, c.index)
|
||||
newBody := prunedPlaceholder(toolName, c.tokens)
|
||||
reclaimed += c.tokens - ApproxTextTokens(newBody)
|
||||
out[c.index].Content = newBody
|
||||
}
|
||||
return out, reclaimed
|
||||
}
|
||||
|
||||
// toolNameForResult finds the tool name for the tool-result message at index by
|
||||
// matching its ToolCallID against the assistant tool_call that produced it.
|
||||
func toolNameForResult(messages []zeroruntime.Message, index int) string {
|
||||
id := messages[index].ToolCallID
|
||||
if id == "" {
|
||||
return ""
|
||||
}
|
||||
for j := index - 1; j >= 0; j-- {
|
||||
for _, call := range messages[j].ToolCalls {
|
||||
if call.ID == id {
|
||||
return call.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isPrunedPlaceholder(content string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(content), "[pruned ")
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// bigToolResult builds a tool-result message with approximately tokens worth of
|
||||
// body, paired to a tool call id. ApproxTextTokens counts non-space chars / 4,
|
||||
// so 4*tokens non-space chars yields ~tokens.
|
||||
func bigToolResult(id string, tokens int) zeroruntime.Message {
|
||||
return zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
ToolCallID: id,
|
||||
Content: strings.Repeat("x", tokens*4),
|
||||
}
|
||||
}
|
||||
|
||||
func toolCallMsg(id, name string) zeroruntime.Message {
|
||||
return zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleAssistant,
|
||||
ToolCalls: []zeroruntime.ToolCall{{ID: id, Name: name}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneSkipsSmallSessions(t *testing.T) {
|
||||
// Total reclaimable is under the gate → no pruning.
|
||||
msgs := []zeroruntime.Message{
|
||||
toolCallMsg("c1", "read_file"),
|
||||
bigToolResult("c1", 5000),
|
||||
}
|
||||
out, reclaimed := pruneStaleToolOutput(msgs, 4)
|
||||
if reclaimed != 0 {
|
||||
t.Fatalf("small session should reclaim nothing, got %d", reclaimed)
|
||||
}
|
||||
if out[1].Content != msgs[1].Content {
|
||||
t.Fatal("content must be untouched when under the gate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrunePreservesRecentAndPrunesOld(t *testing.T) {
|
||||
// One huge OLD result (beyond the protect window) + recent small ones.
|
||||
msgs := []zeroruntime.Message{
|
||||
toolCallMsg("old", "grep"),
|
||||
bigToolResult("old", 60000), // old, big → prunable
|
||||
// Filler to push "old" past the recent-protection window:
|
||||
toolCallMsg("mid", "read_file"),
|
||||
bigToolResult("mid", 45000), // protected by the 40k recent window
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "thinking"},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "next"},
|
||||
}
|
||||
out, reclaimed := pruneStaleToolOutput(msgs, 2)
|
||||
if reclaimed <= 0 {
|
||||
t.Fatalf("expected the old 60k tool result to be pruned, reclaimed=%d", reclaimed)
|
||||
}
|
||||
if !isPrunedPlaceholder(out[1].Content) {
|
||||
t.Fatalf("old result should be replaced with a placeholder, got %q", out[1].Content[:40])
|
||||
}
|
||||
// The placeholder names the tool, and the message + id survive (provider replay).
|
||||
if !strings.Contains(out[1].Content, "grep") {
|
||||
t.Fatalf("placeholder should name the tool, got %q", out[1].Content)
|
||||
}
|
||||
if out[1].ToolCallID != "old" || out[1].Role != zeroruntime.MessageRoleTool {
|
||||
t.Fatal("pruned message must keep its role and ToolCallID for replay")
|
||||
}
|
||||
// The recent big result stays verbatim.
|
||||
if isPrunedPlaceholder(out[3].Content) {
|
||||
t.Fatal("a result inside the recent-protection window must not be pruned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneIsIdempotent(t *testing.T) {
|
||||
msgs := []zeroruntime.Message{
|
||||
toolCallMsg("old", "bash"),
|
||||
bigToolResult("old", 60000),
|
||||
toolCallMsg("mid", "read_file"),
|
||||
bigToolResult("mid", 45000),
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "go"},
|
||||
}
|
||||
once, r1 := pruneStaleToolOutput(msgs, 1)
|
||||
if r1 == 0 {
|
||||
t.Fatal("first pass should prune")
|
||||
}
|
||||
_, r2 := pruneStaleToolOutput(once, 1)
|
||||
if r2 != 0 {
|
||||
t.Fatalf("second pass should be a no-op (already pruned), reclaimed=%d", r2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneNeverTouchesNonToolMessages(t *testing.T) {
|
||||
msgs := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleUser, Content: strings.Repeat("u ", 60000)},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: strings.Repeat("a ", 60000)},
|
||||
}
|
||||
out, reclaimed := pruneStaleToolOutput(msgs, 0)
|
||||
if reclaimed != 0 {
|
||||
t.Fatalf("non-tool messages must never be pruned, reclaimed=%d", reclaimed)
|
||||
}
|
||||
if out[0].Content != msgs[0].Content || out[1].Content != msgs[1].Content {
|
||||
t.Fatal("non-tool content changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneDoesNotMutateInput(t *testing.T) {
|
||||
msgs := []zeroruntime.Message{
|
||||
toolCallMsg("old", "grep"),
|
||||
bigToolResult("old", 60000),
|
||||
toolCallMsg("mid", "read_file"),
|
||||
bigToolResult("mid", 45000),
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "go"},
|
||||
}
|
||||
original := msgs[1].Content
|
||||
_, reclaimed := pruneStaleToolOutput(msgs, 1)
|
||||
if reclaimed == 0 {
|
||||
t.Fatal("expected a prune")
|
||||
}
|
||||
if msgs[1].Content != original {
|
||||
t.Fatal("pruneStaleToolOutput must not mutate the caller's slice")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// Mid-stream reconnect: a long autonomous task (a big refactor, a swarm member,
|
||||
// a headless/cron run) should survive a single transient upstream hiccup
|
||||
// instead of dying and re-burning every token on a restart. When the initial
|
||||
// StreamCompletion connect fails with a disconnect-shaped error — before any
|
||||
// content has been forwarded — re-issue the same request with backoff a few
|
||||
// times. We retry ONLY the connect (not a partially-consumed stream), so no
|
||||
// already-forwarded OnText is ever duplicated.
|
||||
const (
|
||||
maxStreamReconnects = 2
|
||||
streamReconnectBase = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// reconnectNotifier is called before each retry with the 1-based attempt number
|
||||
// and the max, so the caller can surface a "Reconnecting N/max" notice. Nil is
|
||||
// fine.
|
||||
type reconnectNotifier func(attempt, max int)
|
||||
|
||||
// reconnectNoticeFor builds a notifier that surfaces reconnect attempts through
|
||||
// OnReasoning — a non-content channel that is never folded into the answer
|
||||
// text, so the user sees "Reconnecting…" without corrupting streamed output.
|
||||
// Returns nil when there is no reasoning sink (the reconnect still happens
|
||||
// silently).
|
||||
func reconnectNoticeFor(options Options) reconnectNotifier {
|
||||
if options.OnReasoning == nil {
|
||||
return nil
|
||||
}
|
||||
return func(attempt, max int) {
|
||||
options.OnReasoning(fmt.Sprintf("\n[connection lost — reconnecting %d/%d…]\n", attempt, max))
|
||||
}
|
||||
}
|
||||
|
||||
// streamWithReconnect issues request via provider.StreamCompletion and, on a
|
||||
// transient disconnect error, retries the connect up to maxStreamReconnects
|
||||
// times with exponential backoff. It returns the live stream on success, or the
|
||||
// last error. A context-cancellation, a non-disconnect error, or a context
|
||||
// already past its deadline is returned immediately (no retry) — those have
|
||||
// their own handling (compaction for context-limit, image-rejection, etc.).
|
||||
func streamWithReconnect(ctx context.Context, provider Provider, request zeroruntime.CompletionRequest, notify reconnectNotifier) (<-chan zeroruntime.StreamEvent, error) {
|
||||
stream, err := provider.StreamCompletion(ctx, request)
|
||||
if err == nil {
|
||||
return stream, nil
|
||||
}
|
||||
for attempt := 1; attempt <= maxStreamReconnects; attempt++ {
|
||||
if !shouldReconnect(ctx, err) {
|
||||
return nil, err
|
||||
}
|
||||
if notify != nil {
|
||||
notify(attempt, maxStreamReconnects)
|
||||
}
|
||||
if waitErr := sleepWithContext(ctx, backoffFor(attempt)); waitErr != nil {
|
||||
return nil, err // ctx cancelled while waiting; surface the original error
|
||||
}
|
||||
stream, err = provider.StreamCompletion(ctx, request)
|
||||
if err == nil {
|
||||
return stream, nil
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// shouldReconnect reports whether err is a transient disconnect worth retrying.
|
||||
// It excludes context cancellation/expiry (caller is shutting down) and
|
||||
// context-limit errors (the compactor recovers those), so the reconnect path
|
||||
// never fights the existing handlers.
|
||||
func shouldReconnect(ctx context.Context, err error) bool {
|
||||
if err == nil || ctx.Err() != nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
if isContextLimitError(msg) || isImageRejectionError(err) {
|
||||
return false
|
||||
}
|
||||
for _, needle := range []string{
|
||||
"eof",
|
||||
"connection reset",
|
||||
"connection refused",
|
||||
"broken pipe",
|
||||
"connection closed",
|
||||
"timeout",
|
||||
"timed out",
|
||||
"temporarily unavailable",
|
||||
"i/o timeout",
|
||||
"503",
|
||||
"502",
|
||||
"server closed",
|
||||
"unexpected end",
|
||||
} {
|
||||
if strings.Contains(msg, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func backoffFor(attempt int) time.Duration {
|
||||
d := streamReconnectBase
|
||||
for i := 1; i < attempt; i++ {
|
||||
d *= 2
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func sleepWithContext(ctx context.Context, d time.Duration) error {
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// flakyProvider fails the first failBefore connect attempts with failErr, then
|
||||
// succeeds with a single-text stream.
|
||||
type flakyProvider struct {
|
||||
calls int32
|
||||
failBefore int32
|
||||
failErr error
|
||||
}
|
||||
|
||||
func (p *flakyProvider) StreamCompletion(_ context.Context, _ zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
n := atomic.AddInt32(&p.calls, 1)
|
||||
if n <= p.failBefore {
|
||||
return nil, p.failErr
|
||||
}
|
||||
ch := make(chan zeroruntime.StreamEvent, 1)
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func TestStreamWithReconnectRecoversFromTransientDisconnect(t *testing.T) {
|
||||
p := &flakyProvider{failBefore: 1, failErr: errors.New("unexpected EOF")}
|
||||
stream, err := streamWithReconnect(context.Background(), p, zeroruntime.CompletionRequest{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("expected reconnect to recover, got %v", err)
|
||||
}
|
||||
if stream == nil {
|
||||
t.Fatal("expected a live stream after reconnect")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("expected 2 connect attempts (1 fail + 1 success), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamWithReconnectGivesUpAfterMax(t *testing.T) {
|
||||
// Always fails with a disconnect error → exhausts retries and returns it.
|
||||
p := &flakyProvider{failBefore: 99, failErr: errors.New("connection reset by peer")}
|
||||
_, err := streamWithReconnect(context.Background(), p, zeroruntime.CompletionRequest{}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error after exhausting reconnects")
|
||||
}
|
||||
// 1 initial + maxStreamReconnects retries.
|
||||
if got := atomic.LoadInt32(&p.calls); got != int32(1+maxStreamReconnects) {
|
||||
t.Fatalf("expected %d attempts, got %d", 1+maxStreamReconnects, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamWithReconnectDoesNotRetryNonDisconnect(t *testing.T) {
|
||||
// A context-limit error is the compactor's job, not the reconnect's — return
|
||||
// immediately without retrying.
|
||||
p := &flakyProvider{failBefore: 99, failErr: errors.New("context length exceeded")}
|
||||
_, err := streamWithReconnect(context.Background(), p, zeroruntime.CompletionRequest{}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected the original error")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 1 {
|
||||
t.Fatalf("context-limit error must not be retried, got %d attempts", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamWithReconnectStopsOnContextCancel(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
p := &flakyProvider{failBefore: 99, failErr: errors.New("i/o timeout")}
|
||||
_, err := streamWithReconnect(ctx, p, zeroruntime.CompletionRequest{}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
// Cancelled ctx → no retry beyond the first attempt.
|
||||
if got := atomic.LoadInt32(&p.calls); got != 1 {
|
||||
t.Fatalf("cancelled context must not retry, got %d attempts", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldReconnectClassification(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
disconnects := []string{
|
||||
"unexpected EOF", "connection reset by peer", "broken pipe",
|
||||
"i/o timeout", "503 Service Unavailable", "server closed the connection",
|
||||
}
|
||||
for _, m := range disconnects {
|
||||
if !shouldReconnect(ctx, errors.New(m)) {
|
||||
t.Errorf("expected reconnect for %q", m)
|
||||
}
|
||||
}
|
||||
notDisconnects := []string{
|
||||
"context length exceeded", "invalid api key", "model not found",
|
||||
"400 bad request: unsupported parameter",
|
||||
}
|
||||
for _, m := range notDisconnects {
|
||||
if shouldReconnect(ctx, errors.New(m)) {
|
||||
t.Errorf("did NOT expect reconnect for %q", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackoffGrows(t *testing.T) {
|
||||
if backoffFor(1) != streamReconnectBase {
|
||||
t.Fatalf("attempt 1 backoff = %v, want %v", backoffFor(1), streamReconnectBase)
|
||||
}
|
||||
if backoffFor(2) != 2*streamReconnectBase {
|
||||
t.Fatalf("attempt 2 backoff = %v, want %v", backoffFor(2), 2*streamReconnectBase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconnectNoticeRoutesThroughReasoning(t *testing.T) {
|
||||
var got string
|
||||
notify := reconnectNoticeFor(Options{OnReasoning: func(s string) { got += s }})
|
||||
if notify == nil {
|
||||
t.Fatal("expected a notifier when OnReasoning is set")
|
||||
}
|
||||
notify(1, 2)
|
||||
if got == "" || !contains(got, "reconnecting 1/2") {
|
||||
t.Fatalf("notice = %q, want a reconnecting message", got)
|
||||
}
|
||||
if reconnectNoticeFor(Options{}) != nil {
|
||||
t.Fatal("expected nil notifier when OnReasoning is unset")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (indexFold(s, sub) >= 0)
|
||||
}
|
||||
|
||||
func indexFold(s, sub string) int {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
match := true
|
||||
for j := 0; j < len(sub); j++ {
|
||||
a, b := s[i+j], sub[j]
|
||||
if a >= 'A' && a <= 'Z' {
|
||||
a += 'a' - 'A'
|
||||
}
|
||||
if b >= 'A' && b <= 'Z' {
|
||||
b += 'a' - 'A'
|
||||
}
|
||||
if a != b {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
var _ = time.Second
|
||||
@@ -0,0 +1,210 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestRequestPermissionsTurnGrantAllowsLaterToolAndCleansUp(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
target := filepath.Join(outside, "vegetables.txt")
|
||||
scope, err := sandbox.NewScope(workspace, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewScope: %v", err)
|
||||
}
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: workspace,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
Scope: scope,
|
||||
})
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewRequestPermissionsTool())
|
||||
registry.Register(tools.NewScopedWriteFileTool(workspace, scope))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "grant-1", ToolName: tools.RequestPermissionsToolName},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "grant-1", ArgumentsFragment: `{"reason":"Need to write outside the workspace.","permissions":{"file_system":{"write":[` + quoteJSONString(target) + `]}}}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "grant-1"},
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "write-1", ToolName: "write_file"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "write-1", ArgumentsFragment: `{"path":` + quoteJSONString(target) + `,"content":"carrot\n","overwrite":true}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "write-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var requests []PermissionRequest
|
||||
var results []ToolResult
|
||||
normalizedProfile, err := sandbox.NormalizeRequestPermissionProfile(sandbox.RequestPermissionProfile{
|
||||
FileSystem: &sandbox.FileSystemPermissions{Write: []string{target}},
|
||||
}, workspace)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectedGrantProfile, err := sandbox.RequestPermissionGrantProfile(normalizedProfile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectedWriteRoot := expectedGrantProfile.FileSystem.Write[0]
|
||||
|
||||
result, err := Run(context.Background(), "write the file", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAuto,
|
||||
Autonomy: "medium",
|
||||
Cwd: workspace,
|
||||
Sandbox: engine,
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
requests = append(requests, request)
|
||||
return PermissionDecision{Action: PermissionDecisionAllow, Reason: "ok"}, nil
|
||||
},
|
||||
OnToolResult: func(result ToolResult) {
|
||||
results = append(results, result)
|
||||
},
|
||||
MaxTurns: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want done", result.FinalAnswer)
|
||||
}
|
||||
if len(requests) != 1 || requests[0].ToolName != tools.RequestPermissionsToolName {
|
||||
t.Fatalf("permission requests = %#v, want one request_permissions prompt", requests)
|
||||
}
|
||||
if !strings.Contains(requests[0].Scope, "write "+expectedWriteRoot) || strings.Contains(requests[0].Scope, target) {
|
||||
t.Fatalf("permission request scope = %q, want widened directory root %q and not file %q", requests[0].Scope, expectedWriteRoot, target)
|
||||
}
|
||||
if len(results) != 2 || results[0].Status != tools.StatusOK || results[1].Status != tools.StatusOK {
|
||||
t.Fatalf("tool results = %#v, want request_permissions and write_file ok", results)
|
||||
}
|
||||
var grantResponse sandbox.RequestPermissionsResponse
|
||||
if err := json.Unmarshal([]byte(results[0].Output), &grantResponse); err != nil {
|
||||
t.Fatalf("unmarshal grant response %q: %v", results[0].Output, err)
|
||||
}
|
||||
if grantResponse.Permissions.FileSystem == nil ||
|
||||
len(grantResponse.Permissions.FileSystem.Write) != 1 ||
|
||||
grantResponse.Permissions.FileSystem.Write[0] != expectedWriteRoot {
|
||||
t.Fatalf("grant response permissions = %#v, want write root %q", grantResponse.Permissions, expectedWriteRoot)
|
||||
}
|
||||
content, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "carrot\n" {
|
||||
t.Fatalf("target content = %q", content)
|
||||
}
|
||||
|
||||
decision := engine.Evaluate(context.Background(), sandbox.Request{
|
||||
WorkspaceRoot: workspace,
|
||||
ToolName: "write_file",
|
||||
SideEffect: sandbox.SideEffectWrite,
|
||||
Permission: sandbox.PermissionPrompt,
|
||||
PermissionMode: sandbox.PermissionModeAuto,
|
||||
Args: map[string]any{"path": target},
|
||||
})
|
||||
if decision.Action != sandbox.ActionPrompt {
|
||||
t.Fatalf("turn grant should be cleaned up after Run, got decision %#v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestPermissionsDenyReturnsEmptyGrantAndContinues(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewRequestPermissionsTool())
|
||||
provider := requestPermissionsOnlyProvider(`{"reason":"Need network.","permissions":{"network":{"enabled":true}}}`, "done")
|
||||
var results []ToolResult
|
||||
|
||||
result, err := Run(context.Background(), "request network", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAuto,
|
||||
Cwd: workspace,
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: workspace, Policy: sandbox.DefaultPolicy()}),
|
||||
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
|
||||
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "no"}, nil
|
||||
},
|
||||
OnToolResult: func(result ToolResult) { results = append(results, result) },
|
||||
MaxTurns: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want done", result.FinalAnswer)
|
||||
}
|
||||
if len(results) != 1 || results[0].Status != tools.StatusOK {
|
||||
t.Fatalf("request_permissions denial should be an ok tool result, got %#v", results)
|
||||
}
|
||||
var response sandbox.RequestPermissionsResponse
|
||||
if err := json.Unmarshal([]byte(results[0].Output), &response); err != nil {
|
||||
t.Fatalf("unmarshal response %q: %v", results[0].Output, err)
|
||||
}
|
||||
if !response.Permissions.Empty() || response.Scope != sandbox.PermissionGrantScopeTurn {
|
||||
t.Fatalf("deny response = %#v, want empty turn grant", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestPermissionsStrictReviewResponse(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewRequestPermissionsTool())
|
||||
provider := requestPermissionsOnlyProvider(`{"reason":"Need read access.","permissions":{"file_system":{"read":[`+quoteJSONString(workspace)+`]}}}`, "done")
|
||||
var output string
|
||||
|
||||
_, err := Run(context.Background(), "request read", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAuto,
|
||||
Cwd: workspace,
|
||||
Sandbox: sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: workspace, Policy: sandbox.DefaultPolicy()}),
|
||||
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
|
||||
return PermissionDecision{Action: PermissionDecisionAllowStrict, Reason: "review commands"}, nil
|
||||
},
|
||||
OnToolResult: func(result ToolResult) { output = result.Output },
|
||||
MaxTurns: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var response sandbox.RequestPermissionsResponse
|
||||
if err := json.Unmarshal([]byte(output), &response); err != nil {
|
||||
t.Fatalf("unmarshal response %q: %v", output, err)
|
||||
}
|
||||
if !response.StrictAutoReview || response.Scope != sandbox.PermissionGrantScopeTurn {
|
||||
t.Fatalf("strict response = %#v, want strict turn grant", response)
|
||||
}
|
||||
if response.Permissions.FileSystem == nil || len(response.Permissions.FileSystem.Read) != 1 {
|
||||
t.Fatalf("strict response permissions = %#v, want read permission", response.Permissions)
|
||||
}
|
||||
}
|
||||
|
||||
func requestPermissionsOnlyProvider(arguments string, finalAnswer string) *mockProvider {
|
||||
if !strings.HasPrefix(arguments, "{") {
|
||||
arguments = "{}"
|
||||
}
|
||||
return &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "grant-1", ToolName: tools.RequestPermissionsToolName},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "grant-1", ArgumentsFragment: arguments},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "grant-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: finalAnswer},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResponseStyleContext(t *testing.T) {
|
||||
for _, blank := range []string{"", "balanced", "BALANCED", "garbage", " "} {
|
||||
if got := responseStyleContext(Options{ResponseStyle: blank}); got != "" {
|
||||
t.Errorf("ResponseStyle %q should add nothing, got %q", blank, got)
|
||||
}
|
||||
}
|
||||
for _, style := range []string{"concise", "explanatory", "review", "Concise", "REVIEW"} {
|
||||
got := responseStyleContext(Options{ResponseStyle: style})
|
||||
if got == "" {
|
||||
t.Fatalf("style %q produced no directive", style)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(got), strings.ToLower(style)) {
|
||||
t.Errorf("style %q directive should name the style, got %q", style, got)
|
||||
}
|
||||
}
|
||||
// It lands in the assembled prompt for a real style, and not for balanced.
|
||||
if !strings.Contains(buildSystemPrompt(Options{ResponseStyle: "concise"}), "Response style: concise") {
|
||||
t.Error("buildSystemPrompt should inject the concise directive")
|
||||
}
|
||||
if strings.Contains(buildSystemPrompt(Options{ResponseStyle: "balanced"}), "Response style:") {
|
||||
t.Error("balanced must not inject a style section (prompt stays byte-identical)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSpecialistDelegationContext(t *testing.T) {
|
||||
if got := specialistDelegationContext(Options{}); got != "" {
|
||||
t.Fatalf("no specialists should yield an empty section, got %q", got)
|
||||
}
|
||||
got := specialistDelegationContext(Options{Specialists: []SpecialistInfo{
|
||||
{Name: "explorer", WhenToUse: "Read-only codebase exploration."},
|
||||
{Name: " ", WhenToUse: "nameless, should be skipped"},
|
||||
{Name: "worker"},
|
||||
}})
|
||||
if !strings.Contains(got, "<specialists>") || !strings.Contains(got, "</specialists>") {
|
||||
t.Fatalf("missing specialists block: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "- explorer: Read-only codebase exploration.") {
|
||||
t.Fatalf("missing explorer line: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "- worker\n") {
|
||||
t.Fatalf("worker (no description) line missing: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "nameless") {
|
||||
t.Fatalf("nameless entry should be skipped: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemPromptIncludesDelegationOnlyWithSpecialists(t *testing.T) {
|
||||
with := buildSystemPrompt(Options{Specialists: []SpecialistInfo{
|
||||
{Name: "explorer", WhenToUse: "Explore."},
|
||||
}})
|
||||
if !strings.Contains(with, "<specialists>") || !strings.Contains(with, "Task tool") {
|
||||
t.Fatalf("expected delegation guidance in system prompt: %q", with)
|
||||
}
|
||||
// Default (no specialists) must reproduce the prior prompt: no delegation block.
|
||||
without := buildSystemPrompt(Options{})
|
||||
if strings.Contains(without, "<specialists>") {
|
||||
t.Fatalf("delegation block must not appear without specialists")
|
||||
}
|
||||
}
|
||||
+288
-16
@@ -31,13 +31,20 @@ var confirmationPolicy string
|
||||
const fallbackSystemPrompt = "You are Zero, a terminal coding agent. Help with the current workspace and use tools when needed."
|
||||
|
||||
// projectContextFiles are workspace docs injected into the system prompt so the
|
||||
// agent honors project-specific conventions (mirrors AGENTS.md / CLAUDE.md). The
|
||||
// first one found wins.
|
||||
// agent honors project-specific conventions (mirrors AGENTS.md / CLAUDE.md).
|
||||
// The first match at each directory level wins; the loader walks the chain
|
||||
// from the git root down to the cwd and injects the matches in
|
||||
// general-to-specific order.
|
||||
var projectContextFiles = []string{"AGENTS.md", "ZERO.md", ".zero/AGENTS.md"}
|
||||
|
||||
// maxProjectContextBytes caps how much of a project doc is injected so a large
|
||||
// guidelines file can't blow the context budget.
|
||||
const maxProjectContextBytes = 8 << 10 // 8 KiB
|
||||
// maxProjectContextBytes caps how much of a single project doc is injected so
|
||||
// a large guidelines file can't blow the context budget.
|
||||
const maxProjectContextBytes = 8 << 10 // 8 KiB per file
|
||||
|
||||
// maxProjectContextTotalBytes caps the total bytes across all matched
|
||||
// project guideline files. With path-walking, multiple files can match (root
|
||||
// + sub-tree), so a per-file cap alone is not enough.
|
||||
const maxProjectContextTotalBytes = 32 << 10 // 32 KiB total
|
||||
|
||||
// maxRepoMapContextBytes keeps the repository map useful but compact enough to
|
||||
// remain stable across normal agent turns.
|
||||
@@ -67,18 +74,96 @@ func buildSystemPrompt(options Options) string {
|
||||
if session := sessionRuntimeContext(options); session != "" {
|
||||
sections = append(sections, session)
|
||||
}
|
||||
if prefixes := approvedCommandPrefixContext(options); prefixes != "" {
|
||||
sections = append(sections, prefixes)
|
||||
}
|
||||
if seed := workspaceSeedContext(options.Cwd); seed != "" {
|
||||
sections = append(sections, seed)
|
||||
}
|
||||
if ws := workspaceContext(options.Cwd); ws != "" {
|
||||
sections = append(sections, ws)
|
||||
}
|
||||
if delegation := specialistDelegationContext(options); delegation != "" {
|
||||
sections = append(sections, delegation)
|
||||
}
|
||||
if style := responseStyleContext(options); style != "" {
|
||||
sections = append(sections, style)
|
||||
}
|
||||
if policy := strings.TrimSpace(confirmationPolicy); policy != "" {
|
||||
sections = append(sections, policy)
|
||||
}
|
||||
return strings.Join(sections, "\n\n")
|
||||
}
|
||||
|
||||
// responseStyleContext renders the operator-selected reply style (TUI /style) as
|
||||
// a system-prompt directive so the choice actually shapes responses. "balanced"
|
||||
// (the default) and unknown/empty values add nothing, keeping the prompt
|
||||
// byte-identical to the pre-style behavior.
|
||||
func responseStyleContext(options Options) string {
|
||||
switch strings.ToLower(strings.TrimSpace(options.ResponseStyle)) {
|
||||
case "concise":
|
||||
return "Response style: concise. Lead with the result and keep answers short and direct; omit preamble, restating the question, and any explanation not needed to be correct."
|
||||
case "explanatory":
|
||||
return "Response style: explanatory. Explain the reasoning behind your answers and changes, surface relevant trade-offs, and add brief context a learner would want — while staying on task and not padding."
|
||||
case "review":
|
||||
return "Response style: review. Work like a critical reviewer: call out risks, edge cases, and unstated assumptions, flag anything questionable, and prefer precise, evidence-backed statements over reassurance."
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func approvedCommandPrefixContext(options Options) string {
|
||||
if options.Sandbox == nil {
|
||||
return ""
|
||||
}
|
||||
prefixes := options.Sandbox.ApprovedCommandPrefixes()
|
||||
if len(prefixes) == 0 {
|
||||
return ""
|
||||
}
|
||||
lines := make([]string, 0, len(prefixes))
|
||||
for _, grant := range prefixes {
|
||||
if len(grant.Prefix) == 0 {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, "- "+grant.ToolName+": "+strings.Join(grant.Prefix, " "))
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "## Approved Command Prefixes\n\nThe following command prefixes have already been approved and do not need another permission prompt:\n" + strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// specialistDelegationContext nudges the orchestrator to offload read-heavy or
|
||||
// parallelizable work to a specialist sub-agent via the Task tool, keeping large
|
||||
// tool outputs out of the main context. It renders only when specialists are
|
||||
// known (which is only where the Task tool is actually registered), so a run with
|
||||
// no delegatable specialists produces the previous prompt unchanged.
|
||||
func specialistDelegationContext(options Options) string {
|
||||
if len(options.Specialists) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("<specialists>\n")
|
||||
b.WriteString("Delegate focused or read-heavy work to a specialist sub-agent with the Task tool instead of doing it inline. ")
|
||||
b.WriteString("When a request matches a specialist's purpose, delegate to it proactively — you do not need the user to ask first. ")
|
||||
b.WriteString("This keeps large tool outputs — searches, file dumps, multi-step exploration — out of your own context, so you stay fast and token-efficient. ")
|
||||
b.WriteString("Prefer delegating codebase search and exploration; for independent subtasks, launch several specialists in parallel. Handle small, direct edits yourself.\n")
|
||||
b.WriteString("Available specialists (call Task with the matching name when the task fits its purpose):\n")
|
||||
for _, info := range options.Specialists {
|
||||
name := strings.TrimSpace(info.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if desc := strings.TrimSpace(info.WhenToUse); desc != "" {
|
||||
b.WriteString("- " + name + ": " + desc + "\n")
|
||||
} else {
|
||||
b.WriteString("- " + name + "\n")
|
||||
}
|
||||
}
|
||||
b.WriteString("</specialists>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func sessionRuntimeContext(options Options) string {
|
||||
provider := strings.TrimSpace(options.ProviderName)
|
||||
model := strings.TrimSpace(options.Model)
|
||||
@@ -112,17 +197,45 @@ func workspaceContext(cwd string) string {
|
||||
b.WriteString("Working directory: " + cwd + "\n")
|
||||
b.WriteString("Operating system: " + runtime.GOOS + "\n")
|
||||
if runtime.GOOS == "windows" {
|
||||
b.WriteString("Shell syntax: Windows cmd.exe syntax for bash tool; prefer the cwd argument instead of cd when changing directories.\n")
|
||||
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
|
||||
} else {
|
||||
b.WriteString("Shell syntax: /bin/sh syntax for bash tool; prefer the cwd argument instead of cd when changing directories.\n")
|
||||
b.WriteString("Shell syntax: /bin/sh syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
|
||||
}
|
||||
if branch := gitBranchForPrompt(cwd); branch != "" {
|
||||
b.WriteString("Git branch: " + branch + "\n")
|
||||
}
|
||||
b.WriteString("</environment>")
|
||||
|
||||
for _, name := range projectContextFiles {
|
||||
data, err := os.ReadFile(filepath.Join(cwd, name))
|
||||
b.WriteString(projectGuidelines(cwd, findProjectGitRoot(cwd)))
|
||||
if repoMap := repoMapContext(cwd); repoMap != "" {
|
||||
b.WriteString("\n\n## Repo map\n\n" + repoMap)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// projectGuidelines walks the directory chain from the git root to cwd
|
||||
// (inclusive), finds the first matching project context file at each level
|
||||
// (case-insensitive basename match), reads it, and returns the joined
|
||||
// content in general-to-specific order — the most general file (at the git
|
||||
// root) appears first, the most specific (at cwd) last. Each file is capped
|
||||
// at maxProjectContextBytes; the total across all files is capped at
|
||||
// maxProjectContextTotalBytes. Returns "" when no file matches.
|
||||
func projectGuidelines(cwd, gitRoot string) string {
|
||||
dirs := projectGuidelineDirs(cwd, gitRoot)
|
||||
if len(dirs) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
totalUsed := 0
|
||||
for _, dir := range dirs {
|
||||
if totalUsed >= maxProjectContextTotalBytes {
|
||||
break
|
||||
}
|
||||
match := findProjectContextFile(dir)
|
||||
if match == "" {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(match)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -130,22 +243,181 @@ func workspaceContext(cwd string) string {
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
if len(content) > maxProjectContextBytes {
|
||||
cut := maxProjectContextBytes
|
||||
// Per-file cap is the lesser of the configured per-file limit and
|
||||
// whatever remains of the total budget, so a single file can never
|
||||
// blow past either bound.
|
||||
limit := maxProjectContextBytes
|
||||
if remaining := maxProjectContextTotalBytes - totalUsed; remaining < limit {
|
||||
limit = remaining
|
||||
}
|
||||
if len(content) > limit {
|
||||
cut := limit
|
||||
for cut > 0 && !utf8.RuneStart(content[cut]) {
|
||||
cut--
|
||||
}
|
||||
content = content[:cut] + "\n… (truncated)"
|
||||
}
|
||||
b.WriteString("\n\n## Project guidelines (" + name + ")\n\n" + content)
|
||||
break // first match wins
|
||||
}
|
||||
if repoMap := repoMapContext(cwd); repoMap != "" {
|
||||
b.WriteString("\n\n## Repo map\n\n" + repoMap)
|
||||
label := projectGuidelineLabel(match, gitRoot)
|
||||
b.WriteString("\n\n## Project guidelines (" + label + ")\n\n" + content)
|
||||
totalUsed += len(content)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// projectGuidelineDirs returns the directory chain from gitRoot to cwd
|
||||
// (inclusive), in root-to-leaf order. If gitRoot is empty or unreachable
|
||||
// from cwd, the chain collapses to [cwd].
|
||||
func projectGuidelineDirs(cwd, gitRoot string) []string {
|
||||
cwd = strings.TrimSpace(cwd)
|
||||
if cwd == "" {
|
||||
return nil
|
||||
}
|
||||
gitRoot = strings.TrimSpace(gitRoot)
|
||||
if gitRoot == "" {
|
||||
return []string{cwd}
|
||||
}
|
||||
rel, err := filepath.Rel(gitRoot, cwd)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
||||
return []string{cwd}
|
||||
}
|
||||
if rel == "." {
|
||||
return []string{gitRoot}
|
||||
}
|
||||
dirs := []string{gitRoot}
|
||||
cur := gitRoot
|
||||
for _, seg := range strings.Split(rel, string(filepath.Separator)) {
|
||||
if seg == "" || seg == "." {
|
||||
continue
|
||||
}
|
||||
cur = filepath.Join(cur, seg)
|
||||
dirs = append(dirs, cur)
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
// projectGuidelineLabel renders the path of match as a short label relative
|
||||
// to gitRoot. When gitRoot is empty, the basename is returned.
|
||||
func projectGuidelineLabel(match, gitRoot string) string {
|
||||
if gitRoot == "" {
|
||||
return filepath.Base(match)
|
||||
}
|
||||
rel, err := filepath.Rel(gitRoot, match)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return filepath.Base(match)
|
||||
}
|
||||
return rel
|
||||
}
|
||||
|
||||
// findProjectContextFile returns the first matching project guideline file
|
||||
// in dir. Lookup is case-insensitive on the basename and on the actual
|
||||
// filename returned, so a git-tracked file like AGENTS.MD (uppercase MD)
|
||||
// still resolves to its true filename on case-sensitive filesystems — the
|
||||
// returned path is what should appear in the project guidelines label.
|
||||
// Returns "" when nothing matches.
|
||||
func findProjectContextFile(dir string) string {
|
||||
for _, name := range projectContextFiles {
|
||||
baseLower := strings.ToLower(filepath.Base(name))
|
||||
// Walk to the file's parent through dir with case-insensitive segment
|
||||
// matching, then find a regular file with the same lowercased
|
||||
// basename. This works on both case-sensitive and case-insensitive
|
||||
// filesystems and always returns the on-disk filename.
|
||||
parent, ok := resolveDirCaseInsensitive(filepath.Dir(filepath.Join(dir, name)), dir)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
entries, err := os.ReadDir(parent)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.ToLower(e.Name()) == baseLower {
|
||||
return filepath.Join(parent, e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveDirCaseInsensitive walks from anchor to target, matching each
|
||||
// segment case-insensitively. Returns the resolved absolute path and true on
|
||||
// success, or "" and false if any segment cannot be found.
|
||||
func resolveDirCaseInsensitive(target, anchor string) (string, bool) {
|
||||
if target == "" || target == "." {
|
||||
return anchor, true
|
||||
}
|
||||
rel, err := filepath.Rel(anchor, target)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if rel == "." {
|
||||
return anchor, true
|
||||
}
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
||||
return "", false
|
||||
}
|
||||
parts := strings.Split(rel, string(filepath.Separator))
|
||||
cur := anchor
|
||||
for _, p := range parts {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
entries, err := os.ReadDir(cur)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
found := false
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && strings.EqualFold(e.Name(), p) {
|
||||
cur = filepath.Join(cur, e.Name())
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return cur, true
|
||||
}
|
||||
|
||||
// findProjectGitRoot returns the nearest ancestor of cwd that contains a
|
||||
// .git entry (file or directory). Returns "" when no git root is found, so
|
||||
// the caller can fall back to cwd-only lookup.
|
||||
func findProjectGitRoot(cwd string) string {
|
||||
cwd = strings.TrimSpace(cwd)
|
||||
if cwd == "" {
|
||||
return ""
|
||||
}
|
||||
cur := cwd
|
||||
for {
|
||||
if hasGitMetadata(cur) {
|
||||
return cur
|
||||
}
|
||||
parent := filepath.Dir(cur)
|
||||
if parent == cur {
|
||||
return ""
|
||||
}
|
||||
cur = parent
|
||||
}
|
||||
}
|
||||
|
||||
func hasGitMetadata(dir string) bool {
|
||||
gitPath := filepath.Join(dir, ".git")
|
||||
info, err := os.Stat(gitPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if info.IsDir() {
|
||||
_, err := os.Stat(filepath.Join(gitPath, "HEAD"))
|
||||
return err == nil
|
||||
}
|
||||
data, err := os.ReadFile(gitPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(strings.TrimSpace(string(data)), "gitdir: ")
|
||||
}
|
||||
|
||||
func workspaceSeedContext(cwd string) string {
|
||||
cwd = strings.TrimSpace(cwd)
|
||||
if cwd == "" {
|
||||
|
||||
@@ -67,8 +67,20 @@ work.
|
||||
and explain the outcome.
|
||||
- Run independent, read-only lookups together when you can, rather than one at a
|
||||
time, to move faster.
|
||||
- bash is for commands that have no native tool (build, test, git, package
|
||||
managers). It is not a substitute for the file tools.
|
||||
- exec_command is for commands that have no native tool (build, test, git,
|
||||
package managers). If a command needs to keep running, run it in the
|
||||
foreground with exec_command and use write_stdin to poll or interrupt it;
|
||||
do not rely on `nohup`, `disown`, or backgrounding to keep it alive.
|
||||
- Use exec_command with `tty: true` for interactive terminal-style commands that
|
||||
need stdin beyond Ctrl-C. `/ps` and `/stop` are user-facing TUI commands; when
|
||||
you need to clean up a running foreground command yourself, use write_stdin.
|
||||
- write_stdin with empty input polls an existing exec_command session, and
|
||||
`\u0003` interrupts it. Sending other stdin bytes may require approval because
|
||||
it can drive the running process beyond the original command. Non-tty sessions
|
||||
accept only empty polling and interrupt/stop input; start with `tty: true`
|
||||
when you need to send literal stdin such as prompts, menu choices, or REPL text.
|
||||
- bash is the legacy one-shot shell tool. Prefer exec_command for new shell
|
||||
work, especially for local dev servers.
|
||||
- Treat tool output as ground truth. If a command fails, read the error, form a
|
||||
hypothesis, and address the root cause; do not retry the same call blindly.
|
||||
- When a web search or fetch tool is available, search the web before answering
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
@@ -71,6 +73,22 @@ func TestBuildSystemPromptOmitsWorkspaceSeedWithoutCwd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptIncludesApprovedCommandPrefixes(t *testing.T) {
|
||||
store, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewGrantStore returned error: %v", err)
|
||||
}
|
||||
if _, err := store.GrantCommandPrefix(sandbox.CommandPrefixInput{ToolName: "bash", Prefix: []string{"git", "status"}}); err != nil {
|
||||
t.Fatalf("GrantCommandPrefix returned error: %v", err)
|
||||
}
|
||||
prompt := buildSystemPrompt(Options{Sandbox: sandbox.NewEngine(sandbox.EngineOptions{Store: store})})
|
||||
for _, want := range []string{"Approved Command Prefixes", "bash: git status"} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("system prompt missing %q:\n%s", want, prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeSystemPromptTestFile(t *testing.T, root, rel, contents string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(root, filepath.FromSlash(rel))
|
||||
@@ -95,3 +113,179 @@ func systemPromptTestBlock(t *testing.T, prompt, start, end string) string {
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptInjectsProjectGuidelinesCaseInsensitive(t *testing.T) {
|
||||
// Git tracks AGENTS.MD (uppercase MD) on a case-sensitive filesystem; the
|
||||
// loader must still resolve it when the cwd lookup uses lowercase.
|
||||
cwd := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.MD"), []byte("Always run `make lint`."), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prompt := buildSystemPrompt(Options{Cwd: cwd})
|
||||
if !strings.Contains(prompt, "## Project guidelines (AGENTS.MD)") {
|
||||
t.Fatalf("expected case-insensitive AGENTS.MD resolution, got:\n%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "make lint") {
|
||||
t.Fatalf("expected AGENTS.MD content injected, got:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptProjectGuidelinesPathWalkingMonorepo(t *testing.T) {
|
||||
// Simulate a monorepo: root + sub-tree each have their own AGENTS.md.
|
||||
// The user launches Zero from the sub-tree, so both files should be
|
||||
// injected in general-to-specific order (root first, cwd last).
|
||||
root := t.TempDir()
|
||||
leaf := filepath.Join(root, "services", "api")
|
||||
if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, ".git", "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(leaf, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "AGENTS.md"), []byte("Repo-wide: prefer Go."), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(leaf, "AGENTS.md"), []byte("API: follow REST conventions."), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prompt := buildSystemPrompt(Options{Cwd: leaf})
|
||||
normalized := filepath.ToSlash(prompt)
|
||||
rootLabel := "Project guidelines (AGENTS.md)"
|
||||
leafLabel := "Project guidelines (services/api/AGENTS.md)"
|
||||
rootBlock := systemPromptTestBlock(t, normalized, rootLabel, leafLabel)
|
||||
leafBlock := systemPromptTestBlock(t, normalized, leafLabel, "## Repo map")
|
||||
if !strings.Contains(rootBlock, "Repo-wide: prefer Go.") {
|
||||
t.Fatalf("expected root AGENTS.md in general-to-specific slot, got:\n%s", rootBlock)
|
||||
}
|
||||
if !strings.Contains(leafBlock, "API: follow REST conventions.") {
|
||||
t.Fatalf("expected leaf AGENTS.md in specific slot, got:\n%s", leafBlock)
|
||||
}
|
||||
// Root must appear before leaf in the prompt.
|
||||
rootIdx := strings.Index(normalized, "## "+rootLabel)
|
||||
leafIdx := strings.Index(normalized, "## "+leafLabel)
|
||||
if rootIdx < 0 || leafIdx < 0 || rootIdx > leafIdx {
|
||||
t.Fatalf("expected root (general) before leaf (specific) in prompt, got root=%d leaf=%d", rootIdx, leafIdx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptProjectGuidelinesZeroFallback(t *testing.T) {
|
||||
// ZERO.md is the second-priority name at each level; the loader picks it
|
||||
// when no AGENTS.md is present.
|
||||
cwd := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(cwd, "ZERO.md"), []byte("Brand-specific rule."), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prompt := buildSystemPrompt(Options{Cwd: cwd})
|
||||
if !strings.Contains(prompt, "## Project guidelines (ZERO.md)") {
|
||||
t.Fatalf("expected ZERO.md fallback to be injected, got:\n%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "Brand-specific rule.") {
|
||||
t.Fatalf("expected ZERO.md content, got:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptProjectGuidelinesProjectLocalFallback(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
dot := filepath.Join(cwd, ".zero")
|
||||
if err := os.MkdirAll(dot, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dot, "AGENTS.md"), []byte("Personal: use dark theme."), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prompt := buildSystemPrompt(Options{Cwd: cwd})
|
||||
// Without a git root, the label collapses to the basename; the test
|
||||
// confirms the .zero/AGENTS.md file's content is the one injected, not
|
||||
// any other file.
|
||||
if !strings.Contains(prompt, "Personal: use dark theme.") {
|
||||
t.Fatalf("expected .zero/AGENTS.md content, got:\n%s", prompt)
|
||||
}
|
||||
// The project guidelines block must be present (regardless of label).
|
||||
if !strings.Contains(prompt, "## Project guidelines (") {
|
||||
t.Fatalf("expected a project guidelines block, got:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptProjectGuidelinesTruncatesAtTotalCap(t *testing.T) {
|
||||
// Root file fits in the per-file cap; leaf file is bigger than what's
|
||||
// left of the total budget, so it must be truncated. The truncation
|
||||
// marker must appear in the prompt and the full untruncated payload
|
||||
// must not.
|
||||
root := t.TempDir()
|
||||
leaf := filepath.Join(root, "sub")
|
||||
if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, ".git", "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(leaf, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rootContent := strings.Repeat("r", maxProjectContextBytes) // exactly per-file cap
|
||||
leafContent := strings.Repeat("L", maxProjectContextTotalBytes+1) // over the total cap
|
||||
if err := os.WriteFile(filepath.Join(root, "AGENTS.md"), []byte(rootContent), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(leaf, "AGENTS.md"), []byte(leafContent), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prompt := buildSystemPrompt(Options{Cwd: leaf})
|
||||
if !strings.Contains(prompt, "truncated") {
|
||||
t.Fatalf("expected truncation marker on second file, prompt length=%d", len(prompt))
|
||||
}
|
||||
// Sanity: the leaf file's full payload must NOT be present untruncated.
|
||||
if strings.Contains(prompt, strings.Repeat("L", maxProjectContextTotalBytes-1)) {
|
||||
t.Fatalf("leaf file appears untruncated; total cap is not enforced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectGuidelineDirsOrdersRootToLeaf(t *testing.T) {
|
||||
root := filepath.Join("r")
|
||||
leaf := filepath.Join(root, "a", "b")
|
||||
got := projectGuidelineDirs(leaf, root)
|
||||
want := []string{root, filepath.Join(root, "a"), leaf}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("projectGuidelineDirs = %v, want %v", got, want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("projectGuidelineDirs[%d] = %q, want %q (full %v)", i, got[i], want[i], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectGuidelineDirsCollapsesToCwdWithoutGitRoot(t *testing.T) {
|
||||
got := projectGuidelineDirs(filepath.Join("some", "path"), "")
|
||||
if len(got) != 1 || got[0] != filepath.Join("some", "path") {
|
||||
t.Fatalf("projectGuidelineDirs = %v, want [some/path]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindProjectContextFileCaseInsensitiveBasename(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.MD"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := findProjectContextFile(cwd)
|
||||
if filepath.Base(got) != "AGENTS.MD" {
|
||||
t.Fatalf("findProjectContextFile = %q, want basename AGENTS.MD", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindProjectGitRootIgnoresEmptyGitDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
leaf := filepath.Join(root, "child")
|
||||
if err := os.MkdirAll(leaf, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := findProjectGitRoot(leaf); got != "" {
|
||||
t.Fatalf("findProjectGitRoot = %q, want empty for invalid .git directory", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestApproxTextTokens(t *testing.T) {
|
||||
if got := ApproxTextTokens(""); got != 0 {
|
||||
t.Fatalf("empty = %d, want 0", got)
|
||||
}
|
||||
// Whitespace is folded out (BPE merges it into the adjacent token):
|
||||
// "aaaa bbbb" has 8 non-space bytes -> 2 tokens, where naive len/4 says 3.
|
||||
if got := ApproxTextTokens("aaaa bbbb"); got != 2 {
|
||||
t.Fatalf("ApproxTextTokens = %d, want 2 (8 non-space / 4)", got)
|
||||
}
|
||||
// It must never exceed naive len/4 — it only removes whitespace — and on
|
||||
// whitespace-bearing text it should be strictly smaller.
|
||||
cases := []string{"hello world", "func main() {\n\treturn\n}", "a\tb\nc d e f"}
|
||||
for _, s := range cases {
|
||||
approx, naive := ApproxTextTokens(s), len(s)/4
|
||||
if approx > naive {
|
||||
t.Fatalf("ApproxTextTokens(%q)=%d exceeded len/4=%d", s, approx, naive)
|
||||
}
|
||||
}
|
||||
// Pure non-whitespace matches len/4 exactly (nothing to fold).
|
||||
if got := ApproxTextTokens("abcdefgh"); got != 2 {
|
||||
t.Fatalf("ApproxTextTokens(no spaces) = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
+85
-39
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/streamjson"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
@@ -35,12 +36,18 @@ const (
|
||||
PermissionActionAllow PermissionAction = "allow"
|
||||
PermissionActionPrompt PermissionAction = "prompt"
|
||||
PermissionActionDeny PermissionAction = "deny"
|
||||
PermissionActionCancel PermissionAction = "cancel"
|
||||
)
|
||||
|
||||
const (
|
||||
PermissionDecisionAllow PermissionDecisionAction = "allow"
|
||||
PermissionDecisionDeny PermissionDecisionAction = "deny"
|
||||
PermissionDecisionAlwaysAllow PermissionDecisionAction = "always_allow"
|
||||
PermissionDecisionAllow PermissionDecisionAction = "allow"
|
||||
PermissionDecisionAllowStrict PermissionDecisionAction = "allow_with_strict_auto_review"
|
||||
PermissionDecisionAllowForSession PermissionDecisionAction = "allow_for_session"
|
||||
PermissionDecisionAllowPrefix PermissionDecisionAction = "allow_prefix_for_session"
|
||||
PermissionDecisionAlwaysAllowPrefix PermissionDecisionAction = "always_allow_prefix"
|
||||
PermissionDecisionDeny PermissionDecisionAction = "deny"
|
||||
PermissionDecisionAlwaysAllow PermissionDecisionAction = "always_allow"
|
||||
PermissionDecisionCancel PermissionDecisionAction = "cancel"
|
||||
)
|
||||
|
||||
type ToolResult struct {
|
||||
@@ -73,25 +80,28 @@ const (
|
||||
DenialNone DenialCategory = ""
|
||||
DenialFiltered DenialCategory = "filtered" // tool not enabled for this run
|
||||
DenialPermissionDenied DenialCategory = "permission_denied" // approval declined
|
||||
DenialSandboxViolation DenialCategory = "sandbox_violation" // blocked by the sandbox
|
||||
DenialApprovalCanceled DenialCategory = "approval_canceled" // approval canceled and run aborted
|
||||
DenialSandboxBlock DenialCategory = "sandbox_block" // blocked by the sandbox
|
||||
DenialHookBlocked DenialCategory = "hook_blocked" // vetoed by a beforeTool hook
|
||||
)
|
||||
|
||||
type PermissionRequest struct {
|
||||
ToolCallID string `json:"toolCallId"`
|
||||
ToolName string `json:"name"`
|
||||
Action PermissionAction `json:"action"`
|
||||
Permission string `json:"permission"`
|
||||
PermissionMode PermissionMode `json:"permissionMode"`
|
||||
Autonomy string `json:"autonomy,omitempty"`
|
||||
SideEffect string `json:"sideEffect"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Risk sandbox.Risk `json:"risk"`
|
||||
Args map[string]any `json:"args,omitempty"`
|
||||
Violation *sandbox.Violation `json:"violation,omitempty"`
|
||||
GrantMatched bool `json:"grantMatched,omitempty"`
|
||||
Grant *sandbox.Grant `json:"grant,omitempty"`
|
||||
ToolCallID string `json:"toolCallId"`
|
||||
ToolName string `json:"name"`
|
||||
Action PermissionAction `json:"action"`
|
||||
Permission string `json:"permission"`
|
||||
PermissionMode PermissionMode `json:"permissionMode"`
|
||||
Autonomy string `json:"autonomy,omitempty"`
|
||||
SideEffect string `json:"sideEffect"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Risk sandbox.Risk `json:"risk"`
|
||||
Args map[string]any `json:"args,omitempty"`
|
||||
Block *sandbox.Block `json:"block,omitempty"`
|
||||
GrantMatched bool `json:"grantMatched,omitempty"`
|
||||
Grant *sandbox.Grant `json:"grant,omitempty"`
|
||||
CommandPrefix []string `json:"commandPrefix,omitempty"`
|
||||
AvailableDecisions []PermissionDecisionAction `json:"availableDecisions,omitempty"`
|
||||
}
|
||||
|
||||
type PermissionDecision struct {
|
||||
@@ -100,21 +110,23 @@ type PermissionDecision struct {
|
||||
}
|
||||
|
||||
type PermissionEvent struct {
|
||||
ToolCallID string `json:"toolCallId"`
|
||||
ToolName string `json:"name"`
|
||||
Action PermissionAction `json:"action"`
|
||||
Permission string `json:"permission"`
|
||||
PermissionGranted bool `json:"permissionGranted,omitempty"`
|
||||
PermissionMode PermissionMode `json:"permissionMode"`
|
||||
Autonomy string `json:"autonomy,omitempty"`
|
||||
SideEffect string `json:"sideEffect"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
DecisionReason string `json:"decisionReason,omitempty"`
|
||||
Risk sandbox.Risk `json:"risk"`
|
||||
Violation *sandbox.Violation `json:"violation,omitempty"`
|
||||
GrantMatched bool `json:"grantMatched,omitempty"`
|
||||
Grant *sandbox.Grant `json:"grant,omitempty"`
|
||||
ToolCallID string `json:"toolCallId"`
|
||||
ToolName string `json:"name"`
|
||||
Action PermissionAction `json:"action"`
|
||||
DecisionAction PermissionDecisionAction `json:"decisionAction,omitempty"`
|
||||
Permission string `json:"permission"`
|
||||
PermissionGranted bool `json:"permissionGranted,omitempty"`
|
||||
PermissionMode PermissionMode `json:"permissionMode"`
|
||||
Autonomy string `json:"autonomy,omitempty"`
|
||||
SideEffect string `json:"sideEffect"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
DecisionReason string `json:"decisionReason,omitempty"`
|
||||
Risk sandbox.Risk `json:"risk"`
|
||||
Block *sandbox.Block `json:"block,omitempty"`
|
||||
GrantMatched bool `json:"grantMatched,omitempty"`
|
||||
Grant *sandbox.Grant `json:"grant,omitempty"`
|
||||
CommandPrefix []string `json:"commandPrefix,omitempty"`
|
||||
}
|
||||
|
||||
// AskUserQuestion is one clarifying question the agent wants answered.
|
||||
@@ -136,6 +148,15 @@ type AskUserResponse struct {
|
||||
Answers []string `json:"answers"`
|
||||
}
|
||||
|
||||
// SpecialistInfo is a one-line summary of a delegatable sub-agent (its name and
|
||||
// when-to-use description) surfaced to the orchestrator's system prompt so it can
|
||||
// route work to the right specialist. It is plain data so the agent package needs
|
||||
// no dependency on internal/specialist.
|
||||
type SpecialistInfo struct {
|
||||
Name string
|
||||
WhenToUse string
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
MaxTurns int
|
||||
// DeferThreshold activates deferred MCP-tool loading: when the number of
|
||||
@@ -144,6 +165,13 @@ type Options struct {
|
||||
// 0 (or below the eligible count) keeps every tool eager — byte-identical to
|
||||
// the pre-deferral behavior.
|
||||
DeferThreshold int
|
||||
// Specialists lists the sub-agents the orchestrator may delegate to via the
|
||||
// Task tool; when non-empty the system prompt gains a delegation section that
|
||||
// names them and nudges the model to offload read-heavy work (search,
|
||||
// exploration) so verbose tool output stays out of the main context. It is
|
||||
// populated only where the Task tool is actually registered, so an empty slice
|
||||
// (the default) reproduces the previous prompt byte-for-byte.
|
||||
Specialists []SpecialistInfo
|
||||
// Specialist/sub-agent metadata is carried through exec now and consumed by
|
||||
// the specialist runtime in later slices.
|
||||
SessionID string
|
||||
@@ -157,6 +185,11 @@ type Options struct {
|
||||
ReasoningEffort string
|
||||
Cwd string
|
||||
SystemPrompt string
|
||||
// ResponseStyle is the operator-selected reply style from the TUI /style
|
||||
// command (e.g. "concise", "explanatory", "review"). It is rendered into the
|
||||
// system prompt as a short directive. Empty or "balanced" adds nothing — the
|
||||
// prompt is then byte-identical to the pre-style behavior.
|
||||
ResponseStyle string
|
||||
// Images are optional image attachments to seed onto the initial user turn.
|
||||
// nil for text-only runs (the seeded message then carries no images, exactly
|
||||
// as before).
|
||||
@@ -180,17 +213,28 @@ type Options struct {
|
||||
// Hooks, when set, runs configured beforeTool (blocking) and afterTool
|
||||
// (advisory) commands around each tool call. nil disables hooks entirely; a
|
||||
// dispatcher built from an empty config is also a safe no-op.
|
||||
Hooks *hooks.Dispatcher
|
||||
EnabledTools []string
|
||||
DisabledTools []string
|
||||
OnText func(string)
|
||||
OnReasoning func(string)
|
||||
OnToolCall func(ToolCall)
|
||||
Hooks *hooks.Dispatcher
|
||||
EnabledTools []string
|
||||
DisabledTools []string
|
||||
OnText func(string)
|
||||
OnReasoning func(string)
|
||||
OnToolCall func(ToolCall)
|
||||
// OnToolCallStart / OnToolCallDelta stream a tool call's arguments LIVE as the
|
||||
// model generates them — OnToolCallStart on open (id, tool name), then
|
||||
// OnToolCallDelta for each argument fragment. A surface can render the
|
||||
// in-progress call (e.g. a file being written) instead of waiting for
|
||||
// OnToolCall, which only fires once the whole call has accumulated. nil no-ops.
|
||||
OnToolCallStart func(id, name string)
|
||||
OnToolCallDelta func(id, fragment string)
|
||||
OnPermissionRequest func(context.Context, PermissionRequest) (PermissionDecision, error)
|
||||
OnPermission func(PermissionEvent)
|
||||
OnAskUser func(context.Context, AskUserRequest) (AskUserResponse, error)
|
||||
OnToolResult func(ToolResult)
|
||||
OnUsage func(Usage)
|
||||
// OnToolProgress, when set, is called with each stream-json event a
|
||||
// specialist child process emits while running. The toolCallID identifies
|
||||
// which Task tool call the progress belongs to. nil is a no-op.
|
||||
OnToolProgress func(toolCallID string, event streamjson.Event)
|
||||
// OnContext, when set, is called once per turn with the per-category context
|
||||
// budget of the request about to be sent, so a surface (TUI/CLI) can show
|
||||
// context utilization. Opt-in like the other callbacks; nil is a no-op.
|
||||
@@ -208,6 +252,8 @@ type Options struct {
|
||||
// ceiling and the autonomy gate. nil disables it entirely (the loop is
|
||||
// byte-identical to before). One instance per run — it holds attempt state.
|
||||
SelfCorrect *SelfCorrector
|
||||
|
||||
runPermissions *permissionRunState
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Package agentinit builds the bootstrap prompt for the guided /init flow that
|
||||
// generates a project AGENTS.md. It seeds the agent with structured repo facts
|
||||
// from internal/repoinfo so the agent starts from what is already known and
|
||||
// only investigates the gaps, then writes a concise high-signal AGENTS.md.
|
||||
package agentinit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/repoinfo"
|
||||
)
|
||||
|
||||
// BuildPrompt returns the bootstrap prompt for `zero init` / `/init`. It runs
|
||||
// repoinfo.Collect(cwd) and embeds the result as a fact block; collection
|
||||
// failures are non-fatal (the agent investigates from scratch). now is
|
||||
// injectable for tests; zero falls back to time.Now.
|
||||
func BuildPrompt(ctx context.Context, cwd string, now time.Time) string {
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
facts := collectFacts(ctx, cwd, now)
|
||||
return promptHeader + facts + promptInstructions
|
||||
}
|
||||
|
||||
func collectFacts(ctx context.Context, cwd string, now time.Time) string {
|
||||
info, err := repoinfo.Collect(ctx, repoinfo.Options{Cwd: cwd, Now: now})
|
||||
if err != nil {
|
||||
return "Pre-computed repo facts: unavailable (" + err.Error() + "). Investigate the repository yourself.\n"
|
||||
}
|
||||
return FormatFacts(info)
|
||||
}
|
||||
|
||||
// FormatFacts renders a repoinfo.Info as a compact, model-readable fact block.
|
||||
// Exported so the headless command can show the same summary.
|
||||
func FormatFacts(info repoinfo.Info) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Pre-computed repository facts (from a local scan — verify and fill gaps):\n")
|
||||
|
||||
if info.PrimaryLanguage != "" {
|
||||
b.WriteString(fmt.Sprintf("- Primary language: %s (of %d detected)\n", info.PrimaryLanguage, info.LanguageCount))
|
||||
}
|
||||
if langs := topLanguages(info.Languages, 5); langs != "" {
|
||||
b.WriteString("- Languages: " + langs + "\n")
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("- Size: ~%d files, ~%d LOC, max dir depth %d\n", info.FileCount, info.LOCEstimate, info.MaxDepth))
|
||||
if info.WorkspaceType != "" && info.WorkspaceType != "none" {
|
||||
b.WriteString(fmt.Sprintf("- Workspace: %s (%d packages)\n", info.WorkspaceType, info.WorkspacePackageCount))
|
||||
}
|
||||
if len(info.BuildTools) > 0 {
|
||||
b.WriteString("- Build tools: " + strings.Join(info.BuildTools, ", ") + "\n")
|
||||
}
|
||||
if len(info.TestTools) > 0 {
|
||||
b.WriteString("- Test tools: " + strings.Join(info.TestTools, ", ") + "\n")
|
||||
}
|
||||
if len(info.CICD) > 0 {
|
||||
b.WriteString("- CI/CD: " + strings.Join(info.CICD, ", ") + "\n")
|
||||
}
|
||||
if info.HasGit {
|
||||
git := "- Git: yes"
|
||||
if info.Branch != "" {
|
||||
git += ", branch " + info.Branch
|
||||
}
|
||||
if info.Contributors90d != nil {
|
||||
git += fmt.Sprintf(", %d contributors (90d)", *info.Contributors90d)
|
||||
}
|
||||
b.WriteString(git + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func topLanguages(langs []repoinfo.LangStat, n int) string {
|
||||
parts := make([]string, 0, n)
|
||||
for i, l := range langs {
|
||||
if i >= n {
|
||||
break
|
||||
}
|
||||
parts = append(parts, l.Name)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
const promptHeader = `Generate an AGENTS.md for this repository so future agent runs start with the right context.
|
||||
|
||||
`
|
||||
|
||||
const promptInstructions = `
|
||||
Now investigate the repository to fill in what the facts above don't cover, then write a concise, high-signal AGENTS.md at the repo root. Steps:
|
||||
|
||||
1. Use the pre-computed facts as your starting point — don't re-derive what's already stated; investigate the GAPS (conventions, how to build/test/lint, architecture, what to avoid).
|
||||
2. Read the key entry points, the build/test config, and any existing README or CONTRIBUTING for project-specific rules.
|
||||
3. Write AGENTS.md with these sections, omitting any that don't apply:
|
||||
- A one-line description of what the project is.
|
||||
- Build / test / lint commands (the exact commands a contributor runs).
|
||||
- Project conventions (naming, file layout, idioms the code follows).
|
||||
- Things to avoid (vendored dirs, generated files, gotchas).
|
||||
4. Keep it tight — aim for under ~60 lines. Prefer imperative, specific rules ("Run make test", not "you could test"). No secrets, no environment-specific paths.
|
||||
5. If a critical fact is genuinely ambiguous (e.g. two plausible test commands), ask the user ONE batched question; otherwise just write the file.
|
||||
|
||||
When done, briefly summarize what you wrote.`
|
||||
@@ -0,0 +1,71 @@
|
||||
package agentinit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/repoinfo"
|
||||
)
|
||||
|
||||
func intPtr(n int) *int { return &n }
|
||||
|
||||
func TestFormatFactsRendersKnownFields(t *testing.T) {
|
||||
info := repoinfo.Info{
|
||||
PrimaryLanguage: "Go",
|
||||
LanguageCount: 3,
|
||||
Languages: []repoinfo.LangStat{
|
||||
{Name: "Go"}, {Name: "TypeScript"}, {Name: "Shell"},
|
||||
},
|
||||
FileCount: 820,
|
||||
LOCEstimate: 210000,
|
||||
MaxDepth: 6,
|
||||
WorkspaceType: "go-modules",
|
||||
BuildTools: []string{"go", "make"},
|
||||
TestTools: []string{"go test"},
|
||||
CICD: []string{"github-actions"},
|
||||
HasGit: true,
|
||||
Branch: "main",
|
||||
Contributors90d: intPtr(4),
|
||||
}
|
||||
got := FormatFacts(info)
|
||||
for _, want := range []string{
|
||||
"Primary language: Go (of 3 detected)",
|
||||
"Languages: Go, TypeScript, Shell",
|
||||
"~820 files, ~210000 LOC",
|
||||
"Build tools: go, make",
|
||||
"Test tools: go test",
|
||||
"CI/CD: github-actions",
|
||||
"Git: yes, branch main, 4 contributors",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("FormatFacts missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatFactsOmitsEmpty(t *testing.T) {
|
||||
got := FormatFacts(repoinfo.Info{FileCount: 1})
|
||||
for _, unwanted := range []string{"Primary language", "Build tools", "CI/CD", "Git:", "Workspace"} {
|
||||
if strings.Contains(got, unwanted) {
|
||||
t.Fatalf("FormatFacts should omit %q for an empty Info, got:\n%s", unwanted, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPromptEmbedsFactsAndInstructions(t *testing.T) {
|
||||
// BuildPrompt runs repoinfo.Collect on a real dir; t.TempDir() is not a git
|
||||
// repo, so collection fails soft and the prompt explains it will investigate.
|
||||
prompt := BuildPrompt(context.Background(), t.TempDir(), time.Now())
|
||||
if !strings.Contains(prompt, "Generate an AGENTS.md") {
|
||||
t.Fatal("prompt should state the goal")
|
||||
}
|
||||
if !strings.Contains(prompt, "write a concise") && !strings.Contains(prompt, "AGENTS.md at the repo root") {
|
||||
t.Fatalf("prompt should include the write instructions, got:\n%s", prompt)
|
||||
}
|
||||
// Non-git temp dir → facts unavailable branch.
|
||||
if !strings.Contains(prompt, "unavailable") && !strings.Contains(prompt, "Pre-computed repository facts") {
|
||||
t.Fatalf("prompt should include a facts block (or its unavailable fallback), got:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/acp"
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
const acpUsage = `zero acp — serve the Agent Client Protocol (ACP) over stdio
|
||||
|
||||
Editors that speak ACP (Zed, JetBrains, Neovim, ...) spawn this command and drive
|
||||
ZERO as a backend over JSON-RPC 2.0 on stdin/stdout. ZERO keeps your provider,
|
||||
model, and API keys (BYOK); the editor only hosts the conversation thread.
|
||||
|
||||
Usage:
|
||||
zero acp
|
||||
|
||||
Not meant to be run interactively — point your editor's ACP / external-agent
|
||||
setting at "zero acp".`
|
||||
|
||||
// runACP serves ACP over stdio so an editor can drive ZERO's agent core. It
|
||||
// speaks JSON-RPC 2.0 (newline-delimited JSON) on stdin/stdout; stderr stays free
|
||||
// for human-readable diagnostics. The session lifecycle maps onto ZERO's own
|
||||
// session store, and provider/model/keys remain owned by ZERO.
|
||||
func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
for _, arg := range args {
|
||||
switch arg {
|
||||
case "-h", "--help", "help":
|
||||
fmt.Fprintln(stdout, acpUsage)
|
||||
return exitSuccess
|
||||
default:
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unknown acp flag %q", arg))
|
||||
}
|
||||
}
|
||||
|
||||
conn := acp.NewConn(deps.stdin, stdout)
|
||||
acp.NewAgent(conn, acp.Deps{
|
||||
ResolveConfig: deps.resolveConfig,
|
||||
NewProvider: deps.newProvider,
|
||||
RunAgent: agent.Run,
|
||||
// Build the SCOPED registry + sandbox engine per workspace, exactly like the
|
||||
// exec surface, so ACP shell/file tools are confined — never run unconfined.
|
||||
BuildWorkspace: func(workspaceRoot string, resolved config.ResolvedConfig) (*tools.Registry, *sandbox.Engine, error) {
|
||||
scope, err := sandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
engine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, scope, false)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return newCoreRegistryScoped(workspaceRoot, scope), engine, nil
|
||||
},
|
||||
ResolveWorkspaceRoot: acpWorkspaceRootResolver(deps),
|
||||
Store: deps.newSessionStore(),
|
||||
AgentInfo: acp.Implementation{Name: "zero", Version: version},
|
||||
})
|
||||
|
||||
ctx, stop := signalContext()
|
||||
defer stop()
|
||||
if err := conn.Serve(ctx); err != nil && ctx.Err() == nil {
|
||||
return writeAppError(stderr, "acp: "+err.Error(), exitCrash)
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// acpWorkspaceRootResolver validates a client-supplied cwd into a confinement
|
||||
// root. It reuses exec's resolveWorkspaceRoot (abs+clean, must be an existing
|
||||
// dir) and additionally rejects the filesystem root and the home directory — an
|
||||
// editor must not be able to point ZERO's file/shell tools at the whole disk.
|
||||
func acpWorkspaceRootResolver(deps appDeps) func(string) (string, error) {
|
||||
return func(cwd string) (string, error) {
|
||||
root, err := resolveWorkspaceRoot(cwd, deps)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if root == filepath.Dir(root) {
|
||||
return "", fmt.Errorf("cwd must not be the filesystem root: %s", root)
|
||||
}
|
||||
if home, herr := os.UserHomeDir(); herr == nil && home != "" && filepath.Clean(home) == root {
|
||||
return "", fmt.Errorf("cwd must not be the home directory: %s", root)
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
}
|
||||
+267
-43
@@ -6,7 +6,10 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -18,7 +21,9 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/observability"
|
||||
"github.com/Gitlawb/zero/internal/plugins"
|
||||
"github.com/Gitlawb/zero/internal/providerhealth"
|
||||
"github.com/Gitlawb/zero/internal/provideronboarding"
|
||||
"github.com/Gitlawb/zero/internal/providers"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/selfverify"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
@@ -37,39 +42,42 @@ import (
|
||||
var version = "dev"
|
||||
|
||||
type appDeps struct {
|
||||
getwd func() (string, error)
|
||||
stdin io.Reader
|
||||
userConfigPath func() (string, error)
|
||||
resolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
|
||||
resolveMCPConfig func(workspaceRoot string) (config.MCPConfig, error)
|
||||
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
|
||||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
newSessionStore func() *sessions.Store
|
||||
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
|
||||
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
|
||||
skillsDir func() string
|
||||
pluginsDir func() string
|
||||
toolsDir func() string
|
||||
newMCPStore func() (*mcp.PermissionStore, error)
|
||||
newMCPTokenStore func() (*mcp.TokenStore, error)
|
||||
newSandboxStore func() (*sandbox.GrantStore, error)
|
||||
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
|
||||
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
|
||||
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
|
||||
detectVerifyPlan func(string) (verify.Plan, error)
|
||||
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
|
||||
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
|
||||
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
|
||||
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
|
||||
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
|
||||
runTUI func(context.Context, tui.Options) int
|
||||
runEditor func(string) error
|
||||
checkUpdate func(context.Context, update.Options) (update.Result, error)
|
||||
now func() time.Time
|
||||
getwd func() (string, error)
|
||||
stdin io.Reader
|
||||
userConfigPath func() (string, error)
|
||||
resolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
|
||||
resolveMCPConfig func(workspaceRoot string) (config.MCPConfig, error)
|
||||
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
|
||||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
|
||||
newSessionStore func() *sessions.Store
|
||||
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
|
||||
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
|
||||
skillsDir func() string
|
||||
pluginsDir func() string
|
||||
toolsDir func() string
|
||||
newMCPStore func() (*mcp.PermissionStore, error)
|
||||
newMCPTokenStore func() (*mcp.TokenStore, error)
|
||||
newSandboxStore func() (*sandbox.GrantStore, error)
|
||||
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
|
||||
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
|
||||
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
|
||||
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
|
||||
detectVerifyPlan func(string) (verify.Plan, error)
|
||||
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
|
||||
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
|
||||
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
|
||||
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
|
||||
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
|
||||
runTUI func(context.Context, tui.Options) int
|
||||
runEditor func(string) error
|
||||
checkUpdate func(context.Context, update.Options) (update.Result, error)
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type mcpToolRuntime interface {
|
||||
Close() error
|
||||
Skipped() []mcp.SkippedServer
|
||||
}
|
||||
|
||||
type noopMCPRuntime struct{}
|
||||
@@ -78,6 +86,10 @@ func (noopMCPRuntime) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (noopMCPRuntime) Skipped() []mcp.SkippedServer {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run executes the minimal Go CLI surface. It returns an exit code so tests can
|
||||
// exercise command behavior without terminating the test process.
|
||||
func Run(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
@@ -111,6 +123,7 @@ func defaultAppDeps() appDeps {
|
||||
})
|
||||
},
|
||||
probeProviderHealth: providerhealth.Probe,
|
||||
detectLocalRuntimes: provideronboarding.DetectLocalRuntimes,
|
||||
newSessionStore: func() *sessions.Store {
|
||||
return sessions.NewStore(sessions.StoreOptions{})
|
||||
},
|
||||
@@ -133,6 +146,12 @@ func defaultAppDeps() appDeps {
|
||||
return sandbox.NewGrantStore(sandbox.StoreOptions{})
|
||||
},
|
||||
selectSandboxBackend: sandbox.SelectBackend,
|
||||
runSandboxSetupHelper: func(path string, args []string, stdout io.Writer, stderr io.Writer) error {
|
||||
cmd := exec.Command(path, args...)
|
||||
cmd.Stdout = stdout
|
||||
cmd.Stderr = stderr
|
||||
return cmd.Run()
|
||||
},
|
||||
registerMCPTools: func(ctx context.Context, registry *tools.Registry, cfg config.MCPConfig, options mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return mcp.RegisterTools(ctx, registry, cfg, options)
|
||||
},
|
||||
@@ -173,6 +192,21 @@ func defaultUserPluginsDir() string {
|
||||
}
|
||||
|
||||
func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) (exitCode int) {
|
||||
// Self-dispatch as the Windows sandbox helper. When no standalone helper .exe
|
||||
// is shipped (dev / plain `go build`), the sandbox launches the running zero
|
||||
// binary with one of these hidden subcommands instead of a separate
|
||||
// executable (see resolveWindowsSandboxHelper). Routed before crash-recover,
|
||||
// dep-fill, and --add-dir splitting so it can never collide with a real
|
||||
// subcommand; the "__" prefix is unreachable by normal invocation.
|
||||
if len(args) > 0 {
|
||||
switch args[0] {
|
||||
case sandbox.WindowsCommandRunnerSubcommand:
|
||||
return sandbox.RunWindowsSandboxCommandRunner(args[1:], stderr)
|
||||
case sandbox.WindowsSandboxSetupSubcommand:
|
||||
return sandbox.RunWindowsSandboxSetup(args[1:], stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert an unexpected panic anywhere in the CLI into a saved crash report and
|
||||
// a brief notice, rather than a raw stack trace dumped at the user.
|
||||
defer observability.Recover(observability.DefaultCrashDir(), "cli", stderr, &exitCode)
|
||||
@@ -182,9 +216,21 @@ 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, args, err := splitLeadingThemeFlag(args)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
moreDirs, args, err := splitLeadingAddDirFlags(args)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
addDirs = append(addDirs, moreDirs...)
|
||||
|
||||
if len(args) == 0 {
|
||||
return runInteractiveTUI(stderr, deps, agent.PermissionModeAsk, addDirs)
|
||||
return runInteractiveTUI(stderr, deps, agent.PermissionModeAsk, addDirs, theme)
|
||||
}
|
||||
|
||||
// --add-dir grants an extra write root, and only the interactive TUI and
|
||||
@@ -218,18 +264,48 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
// --theme may appear here too; extract it before the stray-arg checks so it is
|
||||
// not rejected as an unexpected positional, then re-split --add-dir after it.
|
||||
skipTheme, rest, err := splitLeadingThemeFlag(rest)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
evenMoreDirs, rest, err := splitLeadingAddDirFlags(rest)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
moreDirs = append(moreDirs, evenMoreDirs...)
|
||||
// A misplaced --add-dir anywhere in the remainder is the more specific error,
|
||||
// so check for it across all of rest before rejecting stray args.
|
||||
for _, arg := range rest {
|
||||
if arg == "--add-dir" || strings.HasPrefix(arg, "--add-dir=") {
|
||||
return writeAppError(stderr, "--add-dir must come before any other arguments (it may precede or follow --skip-permissions-unsafe)", 1)
|
||||
}
|
||||
}
|
||||
return runInteractiveTUI(stderr, deps, agent.PermissionModeUnsafe, append(append([]string{}, addDirs...), moreDirs...))
|
||||
// This path launches the interactive TUI, which takes no positional prompt or
|
||||
// subcommand. Reject any remaining trailing arg loudly instead of silently
|
||||
// dropping it, so `zero --skip-permissions-unsafe "fix bug"` doesn't appear to
|
||||
// hang in the TUI with the prompt discarded. (AUDIT-L3)
|
||||
for _, arg := range rest {
|
||||
if strings.TrimSpace(arg) != "" {
|
||||
return writeAppError(stderr, "--skip-permissions-unsafe launches the interactive TUI and takes no prompt or subcommand; for a one-shot unsafe run use `zero exec --skip-permissions-unsafe -p \"...\"`", 1)
|
||||
}
|
||||
}
|
||||
return runInteractiveTUI(stderr, deps, agent.PermissionModeUnsafe, append(append([]string{}, addDirs...), moreDirs...), skipTheme)
|
||||
case "-h", "--help", "help":
|
||||
if err := writeHelp(stdout); err != nil {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
case "-v", "--version", "version":
|
||||
for _, a := range args[1:] {
|
||||
if a == "-h" || a == "--help" {
|
||||
if _, err := fmt.Fprintln(stdout, "Usage: zero version\n\nPrint the Zero CLI version. Takes no flags."); err != nil {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintf(stdout, "zero %s\n", version); err != nil {
|
||||
return 1
|
||||
}
|
||||
@@ -239,7 +315,10 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
return writePromptRequired(stderr)
|
||||
}
|
||||
// Forward leading --add-dir occurrences so exec's own parser collects them.
|
||||
execArgs := append(addDirFlagArgs(addDirs), "--prompt", args[1])
|
||||
// Use the inline --prompt=<value> form so a prompt whose first character is a
|
||||
// dash (e.g. `zero -p "-foo"`) is taken verbatim instead of being mistaken for
|
||||
// a flag and rejected with "--prompt requires a value" (matches the cron path).
|
||||
execArgs := append(addDirFlagArgs(addDirs), "--prompt="+args[1])
|
||||
execArgs = append(execArgs, args[2:]...)
|
||||
return runExec(execArgs, stdout, stderr, deps)
|
||||
case "exec":
|
||||
@@ -267,6 +346,8 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
return runSessions(args[1:], stdout, stderr, deps)
|
||||
case "spec":
|
||||
return runSpec(args[1:], stdout, stderr, deps)
|
||||
case "init":
|
||||
return runInit(args[1:], stdout, stderr, deps)
|
||||
case "specialists", "specialist":
|
||||
return runSpecialists(args[1:], stdout, stderr, deps)
|
||||
case "plugins", "plugin":
|
||||
@@ -303,6 +384,8 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
return runRepoInfo(args[1:], stdout, stderr, deps)
|
||||
case "serve":
|
||||
return runServe(args[1:], stdout, stderr, deps)
|
||||
case "acp":
|
||||
return runACP(args[1:], stdout, stderr, deps)
|
||||
default:
|
||||
if _, err := fmt.Fprintf(stderr, "unknown command %q\n", args[0]); err != nil {
|
||||
return 1
|
||||
@@ -337,6 +420,9 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
if deps.probeProviderHealth == nil {
|
||||
deps.probeProviderHealth = defaults.probeProviderHealth
|
||||
}
|
||||
if deps.detectLocalRuntimes == nil {
|
||||
deps.detectLocalRuntimes = defaults.detectLocalRuntimes
|
||||
}
|
||||
if deps.newSessionStore == nil {
|
||||
deps.newSessionStore = defaults.newSessionStore
|
||||
}
|
||||
@@ -367,6 +453,9 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
if deps.selectSandboxBackend == nil {
|
||||
deps.selectSandboxBackend = defaults.selectSandboxBackend
|
||||
}
|
||||
if deps.runSandboxSetupHelper == nil {
|
||||
deps.runSandboxSetupHelper = defaults.runSandboxSetupHelper
|
||||
}
|
||||
if deps.registerMCPTools == nil {
|
||||
deps.registerMCPTools = defaults.registerMCPTools
|
||||
}
|
||||
@@ -406,11 +495,11 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
return deps
|
||||
}
|
||||
|
||||
func runInteractiveTUI(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string) int {
|
||||
return runInteractiveTUIWithSetup(stderr, deps, permissionMode, addDirs, false)
|
||||
func runInteractiveTUI(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string, theme string) int {
|
||||
return runInteractiveTUIWithSetup(stderr, deps, permissionMode, addDirs, theme, false)
|
||||
}
|
||||
|
||||
func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string, forceSetup bool) int {
|
||||
func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string, theme string, forceSetup bool) int {
|
||||
workspaceRoot, err := deps.getwd()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), 1)
|
||||
@@ -464,7 +553,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
}
|
||||
|
||||
registry := newCoreRegistryScoped(workspaceRoot, scope)
|
||||
specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot)
|
||||
specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot, resolved.Swarm.MaxTeamSize)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1)
|
||||
}
|
||||
@@ -495,6 +584,12 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
defer closeMCPRuntime(stderr, mcpRuntime)
|
||||
// A server that could not be reached or validated is skipped, not fatal (one
|
||||
// bad MCP server must not abort startup) — surface each so a missing tool set is
|
||||
// explained rather than silently absent.
|
||||
for _, skipped := range mcpRuntime.Skipped() {
|
||||
fmt.Fprintf(stderr, "warning: MCP server %s unavailable, skipped: %s\n", skipped.Name, redaction.ErrorMessage(skipped.Err, redaction.Options{}))
|
||||
}
|
||||
// Make local plugins live: register their declared tools into the registry and
|
||||
// collect their hooks + skill roots for the dispatcher and skill tool below.
|
||||
// Done after specialist + MCP registration so plugin tools are part of the
|
||||
@@ -525,16 +620,18 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to initialize sandbox grants: "+err.Error(), 1)
|
||||
}
|
||||
sandboxBackend := deps.selectSandboxBackend(sandbox.BackendOptions{})
|
||||
sandboxEngine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox),
|
||||
Store: sandboxStore,
|
||||
Backend: deps.selectSandboxBackend(sandbox.BackendOptions{}),
|
||||
Backend: sandboxBackend,
|
||||
Scope: scope,
|
||||
})
|
||||
lastKnownMCPConfig := mcpConfig
|
||||
return deps.runTUI(context.Background(), tui.Options{
|
||||
Cwd: workspaceRoot,
|
||||
Theme: theme,
|
||||
UserConfigPath: userConfigPath,
|
||||
DoctorUserConfigPath: doctorUserConfigPath,
|
||||
ProjectConfigPath: projectConfigPath,
|
||||
@@ -570,15 +667,17 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
ExitCode: exitCode,
|
||||
}
|
||||
},
|
||||
SandboxSetupCommand: tuiSandboxSetupCommand(sandboxBackend, deps),
|
||||
AgentOptions: agent.Options{
|
||||
MaxTurns: resolved.MaxTurns,
|
||||
Registry: registry,
|
||||
PermissionMode: permissionMode,
|
||||
Autonomy: string(sandbox.AutonomyLow),
|
||||
Autonomy: "low",
|
||||
Sandbox: sandboxEngine,
|
||||
FileTracker: tools.NewFileTracker(),
|
||||
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
|
||||
DeferThreshold: resolved.Tools.DeferThreshold,
|
||||
Specialists: specialistRuntime.specialists,
|
||||
},
|
||||
PermissionMode: permissionMode,
|
||||
Notify: resolved.Notify,
|
||||
@@ -594,6 +693,21 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
})
|
||||
}
|
||||
|
||||
func tuiSandboxSetupCommand(backend sandbox.Backend, deps appDeps) func(context.Context) tui.SandboxSetupCommandResult {
|
||||
if backend.Platform != "windows" || backend.Name != sandbox.BackendWindowsRestrictedToken || !backend.Available || backend.Executable == "" {
|
||||
return nil
|
||||
}
|
||||
return func(context.Context) tui.SandboxSetupCommandResult {
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runSandboxSetup(nil, &stdout, &stderr, deps)
|
||||
return tui.SandboxSetupCommandResult{
|
||||
Output: strings.TrimSpace(stdout.String()),
|
||||
Error: strings.TrimSpace(stderr.String()),
|
||||
ExitCode: exitCode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildProvider(resolved config.ResolvedConfig, deps appDeps) (zeroruntime.Provider, error) {
|
||||
if !config.HasProviderProfile(resolved.Provider) {
|
||||
return nil, nil
|
||||
@@ -619,9 +733,22 @@ func newCoreRegistryScoped(workspaceRoot string, scope tools.PathScope) *tools.R
|
||||
type agentToolRuntime struct {
|
||||
specialist *specialist.Runtime
|
||||
swarm *swarm.Swarm
|
||||
// specialists summarizes the registered specialists (name + description) for
|
||||
// the orchestrator's system-prompt delegation section. Populated alongside the
|
||||
// tools, so it is present exactly when the Task tool is.
|
||||
specialists []agent.SpecialistInfo
|
||||
}
|
||||
|
||||
func registerSpecialistTools(registry *tools.Registry, workspaceRoot string) (*agentToolRuntime, error) {
|
||||
// specialistInfos returns the runtime's specialist summaries, nil-safe so the
|
||||
// exec path can call it whether or not specialist tools were registered.
|
||||
func (r *agentToolRuntime) specialistInfos() []agent.SpecialistInfo {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return r.specialists
|
||||
}
|
||||
|
||||
func registerSpecialistTools(registry *tools.Registry, workspaceRoot string, maxTeamSize int) (*agentToolRuntime, error) {
|
||||
paths, err := specialist.DefaultPaths(workspaceRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -634,16 +761,41 @@ func registerSpecialistTools(registry *tools.Registry, workspaceRoot string) (*a
|
||||
// The swarm reuses the same specialist executor to launch each member, so
|
||||
// every member runs under the orchestrator's sandbox + policy. Mailbox state
|
||||
// lives under the workspace so its files fall within the sandbox write rules.
|
||||
// MaxTeamSize (0 => the swarm's default of 8) caps concurrent members per team.
|
||||
sw, err := swarm.New(swarm.Options{
|
||||
BaseDir: filepath.Join(workspaceRoot, ".zero", "swarm"),
|
||||
Launcher: swarm.NewSpecialistLauncher(executor),
|
||||
BaseDir: filepath.Join(workspaceRoot, ".zero", "swarm"),
|
||||
Launcher: swarm.NewSpecialistLauncher(executor),
|
||||
MaxTeamSize: maxTeamSize,
|
||||
})
|
||||
if err != nil {
|
||||
runtime.Close()
|
||||
return nil, err
|
||||
}
|
||||
swarm.RegisterTools(registry, sw)
|
||||
return &agentToolRuntime{specialist: runtime, swarm: sw}, nil
|
||||
return &agentToolRuntime{specialist: runtime, swarm: sw, specialists: specialistSummaries(paths)}, nil
|
||||
}
|
||||
|
||||
// specialistSummaries loads the available specialists (built-ins + user/project
|
||||
// profiles) and returns their name + description for the orchestrator's
|
||||
// delegation prompt. A load error yields no summaries (the prompt simply omits
|
||||
// the delegation section) rather than failing the run.
|
||||
func specialistSummaries(paths specialist.Paths) []agent.SpecialistInfo {
|
||||
result, err := specialist.Load(specialist.LoadOptions{Paths: paths})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
summaries := make([]agent.SpecialistInfo, 0, len(result.Specialists))
|
||||
for _, manifest := range result.Specialists {
|
||||
name := strings.TrimSpace(manifest.Metadata.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
summaries = append(summaries, agent.SpecialistInfo{
|
||||
Name: name,
|
||||
WhenToUse: strings.TrimSpace(manifest.Metadata.Description),
|
||||
})
|
||||
}
|
||||
return summaries
|
||||
}
|
||||
|
||||
func shouldRegisterExecSpecialistTools(options execOptions) bool {
|
||||
@@ -653,7 +805,13 @@ func shouldRegisterExecSpecialistTools(options execOptions) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(options.tag), specialist.SessionTagSpecialist) {
|
||||
return false
|
||||
}
|
||||
return options.skipPermissionsUnsafe || strings.EqualFold(strings.TrimSpace(options.autonomy), "high")
|
||||
// Register at medium/high (and unsafe). The Task tool's permission gate
|
||||
// auto-approves only read-only specialist spawns and prompts for write-capable
|
||||
// ones, so a non-trivial exec can auto-delegate exploration safely (a headless
|
||||
// write spawn still gets denied at the prompt). Default "low" stays clean — no
|
||||
// specialist tooling or swarm runtime for trivial/CI one-shots.
|
||||
autonomy := strings.ToLower(strings.TrimSpace(options.autonomy))
|
||||
return options.skipPermissionsUnsafe || autonomy == "high" || autonomy == "medium"
|
||||
}
|
||||
|
||||
func closeMCPRuntime(stderr io.Writer, runtime mcpToolRuntime) {
|
||||
@@ -728,6 +886,7 @@ Commands:
|
||||
cron Schedule agent jobs (foreground, file-backed)
|
||||
repo-info Characterize the current repository (local git only)
|
||||
serve Run Zero protocol servers
|
||||
acp Serve the Agent Client Protocol over stdio (editor backend)
|
||||
help Show this help
|
||||
version Print version
|
||||
|
||||
@@ -792,6 +951,71 @@ 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.
|
||||
func splitLeadingThemeFlag(args []string) (string, []string, error) {
|
||||
theme := ""
|
||||
for len(args) > 0 {
|
||||
var value string
|
||||
switch {
|
||||
case args[0] == "--theme":
|
||||
if len(args) < 2 {
|
||||
return "", nil, errors.New("--theme requires a value (auto, dark, or light)")
|
||||
}
|
||||
value = strings.TrimSpace(args[1])
|
||||
args = args[2:]
|
||||
case strings.HasPrefix(args[0], "--theme="):
|
||||
value = strings.TrimSpace(strings.TrimPrefix(args[0], "--theme="))
|
||||
args = args[1:]
|
||||
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)
|
||||
}
|
||||
}
|
||||
return theme, args, nil
|
||||
}
|
||||
|
||||
// profileHasCredential reports whether the profile can authenticate: a direct API
|
||||
// key, an auth-header value, or an APIKeyEnv whose environment variable is set. The
|
||||
// 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) != "" {
|
||||
return true
|
||||
}
|
||||
if env := strings.TrimSpace(profile.APIKeyEnv); env != "" {
|
||||
return strings.TrimSpace(os.Getenv(env)) != ""
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// baseURLIsLoopback reports whether a provider base_url points at a loopback host
|
||||
// (a keyless local provider like Ollama/LM Studio), which needs no API key.
|
||||
func baseURLIsLoopback(baseURL string) bool {
|
||||
u := strings.TrimSpace(baseURL)
|
||||
if u == "" {
|
||||
return false
|
||||
}
|
||||
parsed, err := url.Parse(u)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return false
|
||||
}
|
||||
host := strings.TrimSuffix(strings.ToLower(parsed.Hostname()), ".")
|
||||
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
|
||||
return true
|
||||
}
|
||||
if addr, err := netip.ParseAddr(host); err == nil {
|
||||
return addr.IsLoopback()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writePromptRequired(stderr io.Writer) int {
|
||||
if _, err := fmt.Fprintln(stderr, "[zero] Prompt required. Use `zero exec \"prompt\"` or `zero exec --file prompt.txt`."); err != nil {
|
||||
return 1
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/tui"
|
||||
"github.com/Gitlawb/zero/internal/update"
|
||||
@@ -112,6 +111,9 @@ func TestRunNoArgsLaunchesSetupTUIWithNilProviderWhenNoProviderConfigured(t *tes
|
||||
userConfigPath: func() (string, error) {
|
||||
return userConfigPath, nil
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
launchedOptions = options
|
||||
return 7
|
||||
@@ -446,6 +448,9 @@ func TestRunNoArgsLaunchesTUIWithResolvedProviderMetadata(t *testing.T) {
|
||||
userConfigPath: func() (string, error) {
|
||||
return userConfigPath, nil
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
launchedOptions = options
|
||||
return 0
|
||||
@@ -557,6 +562,29 @@ func TestRunSkipPermissionsUnsafeLaunchesTUIInUnsafeMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkipPermissionsUnsafeRejectsTrailingPrompt(t *testing.T) {
|
||||
// AUDIT-L3: a trailing prompt must be rejected loudly, not silently dropped
|
||||
// while the interactive TUI launches.
|
||||
var stdout, stderr bytes.Buffer
|
||||
launched := false
|
||||
exitCode := runWithDeps([]string{"--skip-permissions-unsafe", "fix the bug"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return t.TempDir(), nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{MaxTurns: 3}, nil
|
||||
},
|
||||
runTUI: func(context.Context, tui.Options) int { launched = true; return 0 },
|
||||
})
|
||||
if exitCode == 0 {
|
||||
t.Fatal("a trailing prompt after --skip-permissions-unsafe must be rejected, not silently dropped")
|
||||
}
|
||||
if launched {
|
||||
t.Fatal("the TUI must not launch when trailing args are rejected")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "takes no prompt") {
|
||||
t.Fatalf("error should explain the no-prompt contract, got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAddDirWiresExtraWriteRootIntoTUISandboxScope(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
@@ -845,6 +873,9 @@ func TestRunSetupNoArgsForcesSetupTUI(t *testing.T) {
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return &cliFakeProvider{}, nil
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
launchedOptions = options
|
||||
return 0
|
||||
@@ -1305,8 +1336,8 @@ func assertAgentOptions(t *testing.T, options tui.Options, maxTurns int, permiss
|
||||
if options.AgentOptions.PermissionMode != permissionMode {
|
||||
t.Fatalf("AgentOptions.PermissionMode = %q, want %q", options.AgentOptions.PermissionMode, permissionMode)
|
||||
}
|
||||
if options.AgentOptions.Autonomy != string(sandbox.AutonomyLow) {
|
||||
t.Fatalf("AgentOptions.Autonomy = %q, want %q", options.AgentOptions.Autonomy, sandbox.AutonomyLow)
|
||||
if options.AgentOptions.Autonomy != "low" {
|
||||
t.Fatalf("AgentOptions.Autonomy = %q, want %q", options.AgentOptions.Autonomy, "low")
|
||||
}
|
||||
if options.AgentOptions.Sandbox == nil {
|
||||
t.Fatal("AgentOptions.Sandbox = nil, want sandbox engine")
|
||||
@@ -1318,3 +1349,54 @@ func assertAgentOptions(t *testing.T, options tui.Options, maxTurns int, permiss
|
||||
t.Fatalf("PermissionMode = %q, want %q", options.PermissionMode, permissionMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunThemeFlagPopulatesTUIOptions(t *testing.T) {
|
||||
// The --theme flag must reach tui.Options.Theme (resolveThemeMode prefers it
|
||||
// over ZERO_THEME). Previously Options.Theme was read but never set by the CLI.
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{[]string{"--theme", "light"}, "light"},
|
||||
{[]string{"--theme=dark"}, "dark"},
|
||||
{[]string{"--theme", "auto"}, "auto"},
|
||||
} {
|
||||
var stdout, stderr bytes.Buffer
|
||||
var got tui.Options
|
||||
launched := false
|
||||
exit := runWithDeps(tc.args, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return t.TempDir(), nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{MaxTurns: 3}, nil
|
||||
},
|
||||
runTUI: func(_ context.Context, o tui.Options) int { launched = true; got = o; return 0 },
|
||||
})
|
||||
if exit != 0 || !launched {
|
||||
t.Fatalf("%v: exit=%d launched=%v stderr=%s", tc.args, exit, launched, stderr.String())
|
||||
}
|
||||
if got.Theme != tc.want {
|
||||
t.Fatalf("%v: Options.Theme = %q, want %q", tc.args, got.Theme, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunThemeFlagRejectsBadValue(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
launched := false
|
||||
exit := runWithDeps([]string{"--theme", "solarized"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return t.TempDir(), nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{MaxTurns: 3}, nil
|
||||
},
|
||||
runTUI: func(context.Context, tui.Options) int { launched = true; return 0 },
|
||||
})
|
||||
if exit == 0 {
|
||||
t.Fatal("an invalid --theme value must be rejected, not silently ignored")
|
||||
}
|
||||
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") {
|
||||
t.Fatalf("expected a clear --theme error, got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,8 @@ func runAuth(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
return runAuthRefresh(args[1:], stdout, stderr, deps)
|
||||
case "openrouter":
|
||||
return runAuthOpenRouter(args[1:], stdout, stderr, deps)
|
||||
case "chatgpt":
|
||||
return runAuthChatGPT(args[1:], stdout, stderr, deps)
|
||||
default:
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unknown auth subcommand %q", args[0]))
|
||||
}
|
||||
@@ -74,6 +76,98 @@ func runAuthOpenRouter(args []string, stdout io.Writer, stderr io.Writer, _ appD
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// runAuthChatGPT runs the ChatGPT (Codex) browser PKCE login, persists the
|
||||
// bearer + chatgpt-account-id claim under the "chatgpt" provider key, and
|
||||
// prints a status block. The bearer routes to chatgpt.com/backend-api/codex
|
||||
// for ChatGPT Plus/Pro/Business/Enterprise subscribers; a successful login
|
||||
// makes the agent use the chatgpt catalog entry with the OAuth bearer.
|
||||
func runAuthChatGPT(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
for _, a := range args {
|
||||
if a == "-h" || a == "--help" || a == "help" {
|
||||
_ = writeAuthHelp(stdout)
|
||||
return exitSuccess
|
||||
}
|
||||
}
|
||||
if len(args) > 0 {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("zero auth chatgpt takes no arguments (got %q)", args[0]))
|
||||
}
|
||||
|
||||
// Build the same env map the oauth engine reads so the chatgpt preset is
|
||||
// opted into (the preset is off by default to keep third-party OAuth
|
||||
// client identities out of the default credential path). The env is
|
||||
// layered: process env first, then ZERO_OAUTH_ALLOW_PRESETS=1.
|
||||
env := map[string]string{}
|
||||
for _, kv := range os.Environ() {
|
||||
if eq := strings.IndexByte(kv, '='); eq > 0 {
|
||||
env[kv[:eq]] = kv[eq+1:]
|
||||
}
|
||||
}
|
||||
env["ZERO_OAUTH_ALLOW_PRESETS"] = "1"
|
||||
|
||||
token, err := provideroauth.ChatGPTLogin(context.Background(), provideroauth.ChatGPTOptions{
|
||||
Env: env,
|
||||
HTTPClient: &http.Client{Timeout: 60 * time.Second},
|
||||
Out: stdout,
|
||||
// Don't auto-open a browser — print the URL to stdout and let the
|
||||
// user click it. (Same posture as runAuthOpenRouter; the headless
|
||||
// sandbox context makes launching a browser a worse default than
|
||||
// printing the URL.)
|
||||
OpenBrowser: func(string) error { return nil },
|
||||
})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
|
||||
// Persist via the oauth manager's store so the same path
|
||||
// zero auth status / zero auth refresh / TokenResolver use is hit.
|
||||
// We bypass Manager.Login because the account-id extraction happens
|
||||
// inside provideroauth.ChatGPTLogin; the manager would not pick up
|
||||
// the customized Token.Account field.
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{Now: deps.now})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
if err := store.Save(oauth.ProviderKey("chatgpt"), token); err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
statuses, err := oauthFormatChatGPTStatus(token)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
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 {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// oauthFormatChatGPTStatus formats the saved ChatGPT token into the same
|
||||
// shape `zero auth status` prints, so the user sees a consistent view.
|
||||
func oauthFormatChatGPTStatus(token oauth.Token) (string, error) {
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
statuses, err := store.Status("provider:chatgpt")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(statuses) == 0 {
|
||||
// Fallback: the token was just saved but the status query came up
|
||||
// empty (e.g. an OS keyring backend that doesn't enumerate). The
|
||||
// user still has a successful login; tell them what was saved
|
||||
// without the formatted status block.
|
||||
accountLine := ""
|
||||
if strings.TrimSpace(token.Account) != "" {
|
||||
accountLine = fmt.Sprintf("ChatGPT account id: %s\n", token.Account)
|
||||
}
|
||||
return fmt.Sprintf("ChatGPT login complete.\n%s", accountLine), nil
|
||||
}
|
||||
return oauth.FormatStatuses(statuses), nil
|
||||
}
|
||||
|
||||
// authArgs is the parsed form of an auth subcommand's arguments.
|
||||
type authArgs struct {
|
||||
positional []string
|
||||
@@ -373,6 +467,7 @@ Commands:
|
||||
status [provider] Show login presence/expiry (never the token)
|
||||
refresh <provider> [--watch] Force a token refresh (--watch keeps it fresh)
|
||||
openrouter Log in to OpenRouter in the browser; mints an API key
|
||||
chatgpt Log in to ChatGPT in the browser (Codex backend, ChatGPT Plus/Pro)
|
||||
|
||||
A provider is any OAuth 2.0 / OIDC server. "openrouter" ('zero auth openrouter')
|
||||
works out of the box. "xai" ('zero auth login xai') uses a built-in preset that is
|
||||
|
||||
@@ -78,6 +78,9 @@ func runProviders(args []string, stdout io.Writer, stderr io.Writer, deps appDep
|
||||
if command == "setup" {
|
||||
return runProvidersSetup(args, stdout, stderr, deps)
|
||||
}
|
||||
if command == "detect" {
|
||||
return runProvidersDetect(args, stdout, stderr, deps)
|
||||
}
|
||||
if command != "list" && command != "current" && command != "catalog" {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unknown providers command %q", command))
|
||||
}
|
||||
@@ -451,8 +454,10 @@ func writeProvidersHelp(w io.Writer) error {
|
||||
zero providers check [name] [flags]
|
||||
zero providers use <name> [flags]
|
||||
zero providers setup <catalog-id> [flags]
|
||||
zero providers detect [flags]
|
||||
|
||||
Inspects resolved provider profiles and provider catalog descriptors without printing secrets.
|
||||
Detect probes for running local runtimes (Ollama, LM Studio) and prints adopt commands plus per-provider next steps.
|
||||
|
||||
Flags:
|
||||
--json Print JSON summary
|
||||
|
||||
@@ -38,11 +38,11 @@ func runCronWith(store *cron.Store, now func() time.Time, args []string, stdout
|
||||
case "list", "ls":
|
||||
return cronList(store, now, stdout, stderr)
|
||||
case "rm", "remove":
|
||||
return cronSimple(store, rest, stderr, func(id string) error { return store.Remove(id) }, "removed")
|
||||
return cronSimple(store, rest, stdout, stderr, func(id string) error { return store.Remove(id) }, "Removed")
|
||||
case "pause":
|
||||
return cronSetStatus(store, rest, stderr, cron.StatusPaused)
|
||||
return cronSetStatus(store, rest, stdout, stderr, cron.StatusPaused, "Paused")
|
||||
case "resume":
|
||||
return cronResume(store, now, rest, stderr)
|
||||
return cronResume(store, now, rest, stdout, stderr)
|
||||
case "run":
|
||||
return cronRun(store, now, rest, stdout, stderr, Run)
|
||||
default:
|
||||
@@ -195,7 +195,7 @@ func cronList(store *cron.Store, now func() time.Time, stdout io.Writer, stderr
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func cronSimple(store *cron.Store, args []string, stderr io.Writer, fn func(string) error, verb string) int {
|
||||
func cronSimple(store *cron.Store, args []string, stdout io.Writer, stderr io.Writer, fn func(string) error, verb string) int {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(stderr, "An id is required.")
|
||||
return exitUsage
|
||||
@@ -204,10 +204,11 @@ func cronSimple(store *cron.Store, args []string, stderr io.Writer, fn func(stri
|
||||
fmt.Fprintln(stderr, err.Error())
|
||||
return exitUsage
|
||||
}
|
||||
fmt.Fprintf(stdout, "%s cron job %s.\n", verb, args[0])
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func cronSetStatus(store *cron.Store, args []string, stderr io.Writer, status string) int {
|
||||
func cronSetStatus(store *cron.Store, args []string, stdout io.Writer, stderr io.Writer, status string, verb string) int {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(stderr, "An id is required.")
|
||||
return exitUsage
|
||||
@@ -222,11 +223,12 @@ func cronSetStatus(store *cron.Store, args []string, stderr io.Writer, status st
|
||||
fmt.Fprintln(stderr, err.Error())
|
||||
return exitCrash
|
||||
}
|
||||
fmt.Fprintf(stdout, "%s cron job %s.\n", verb, job.ID)
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// cronResume sets a job active and recomputes NextRunAt.
|
||||
func cronResume(store *cron.Store, now func() time.Time, args []string, stderr io.Writer) int {
|
||||
func cronResume(store *cron.Store, now func() time.Time, args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(stderr, "An id is required.")
|
||||
return exitUsage
|
||||
@@ -254,6 +256,7 @@ func cronResume(store *cron.Store, now func() time.Time, args []string, stderr i
|
||||
fmt.Fprintln(stderr, err.Error())
|
||||
return exitCrash
|
||||
}
|
||||
fmt.Fprintf(stdout, "Resumed cron job %s (next run %s).\n", job.ID, next.Format(time.RFC3339))
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/cron"
|
||||
)
|
||||
|
||||
func TestFireJobClaimPreventsDoubleFire(t *testing.T) {
|
||||
now := time.Date(2026, 6, 9, 9, 0, 0, 0, time.UTC)
|
||||
nowFn := func() time.Time { return now }
|
||||
store := cron.NewStore(cron.StoreOptions{RootDir: t.TempDir(), Now: nowFn})
|
||||
due, _ := store.Add(cron.Job{Expr: "0 9 * * *", Prompt: "fire me", Status: cron.StatusActive, NextRunAt: now.Add(-time.Minute)})
|
||||
|
||||
fx := &fakeExec{}
|
||||
// Two schedulers fire the same due job concurrently; the atomic claim must let
|
||||
// exactly one through, so the job never double-fires (M9).
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
job, _ := store.Get(due.ID)
|
||||
var out, errb bytes.Buffer
|
||||
fireJob(store, nowFn, job, &out, &errb, fx.run)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if len(fx.calls) != 1 {
|
||||
t.Fatalf("claim must let exactly one scheduler fire the due job, got %d", len(fx.calls))
|
||||
}
|
||||
// And NextRunAt is advanced to the next slot (tomorrow 09:00), not left due.
|
||||
d, _ := store.Get(due.ID)
|
||||
if !d.NextRunAt.After(now) {
|
||||
t.Fatalf("NextRunAt must advance past now, got %s", d.NextRunAt)
|
||||
}
|
||||
}
|
||||
+93
-20
@@ -134,6 +134,24 @@ func reconcileOverdue(store *cron.Store, now func() time.Time, ids []string, std
|
||||
// previous fire has already returned before the next tick — no overlap.
|
||||
func fireJob(store *cron.Store, now func() time.Time, job cron.Job, stdout io.Writer, stderr io.Writer, exec execRunner) {
|
||||
fired := now()
|
||||
// Atomically claim this fire before running it so concurrent schedulers (or
|
||||
// --once overlapping the forever loop) can't both execute the same due job: the
|
||||
// winner advances NextRunAt under the per-job lock, and a loser sees the
|
||||
// advanced time and skips. The post-exec advance recomputes the same
|
||||
// NextRunAt (sched.Next(fired) is deterministic), so the claim serializes the
|
||||
// fire decision without changing any persisted schedule state (M9).
|
||||
claimed, claimErr := claimFire(store, fired, job.ID)
|
||||
if claimErr != nil {
|
||||
if errors.Is(claimErr, cron.ErrJobNotFound) {
|
||||
return // removed before we could claim
|
||||
}
|
||||
fmt.Fprintf(stderr, "warning: could not claim job %s: %v\n", job.ID, claimErr)
|
||||
return
|
||||
}
|
||||
if !claimed {
|
||||
return // another scheduler already claimed this slot, or the job was paused
|
||||
}
|
||||
|
||||
args := []string{"exec", "--output-format", "stream-json", "--session-title", "cron:" + job.ID}
|
||||
if job.Cwd != "" {
|
||||
args = append(args, "--cwd", job.Cwd)
|
||||
@@ -169,39 +187,94 @@ func fireJob(store *cron.Store, now func() time.Time, job cron.Job, stdout io.Wr
|
||||
if rec.Error == "" {
|
||||
rec.Error = "invalid schedule; job paused: " + perr.Error()
|
||||
}
|
||||
} else if nxt := sched.Next(fired); nxt.IsZero() {
|
||||
} else if nxt := sched.Next(fired.Truncate(time.Minute)); nxt.IsZero() {
|
||||
job.Status = cron.StatusPaused
|
||||
if rec.Error == "" {
|
||||
rec.Error = "schedule no longer fires; job paused"
|
||||
}
|
||||
} else {
|
||||
// Minute-aligned input keeps this advance identical to claimFire's and lets
|
||||
// the DST fall-back collapse guard engage (AUDIT-M4).
|
||||
job.NextRunAt = nxt
|
||||
}
|
||||
// Re-read before persisting: the job may have been paused or removed while it
|
||||
// was executing, and this in-memory copy is stale from tick start. Without
|
||||
// this, store.Update would clobber an external pause back to active. (A single
|
||||
// scheduler is the supported model; this narrows but does not fully close the
|
||||
// read-modify-write window — full atomicity needs file locking.)
|
||||
current, err := store.Get(job.ID)
|
||||
switch {
|
||||
case errors.Is(err, cron.ErrJobNotFound):
|
||||
// Genuinely removed mid-run: don't recreate it by persisting.
|
||||
// Re-read and persist the advanced state ATOMICALLY under the per-job lock. The
|
||||
// job may have been paused or removed while it executed, and this in-memory copy
|
||||
// is stale from tick start. Mutate closes the read-modify-write window a single
|
||||
// scheduler alone could only narrow (Store.Mutate took a cross-process lock): a
|
||||
// concurrent scheduler or an external pause/remove landing here can no longer be
|
||||
// clobbered.
|
||||
persisted, err := store.Mutate(job.ID, func(current cron.Job, readErr error) (cron.Job, error) {
|
||||
if readErr != nil {
|
||||
// A transient read failure (IO/permission) is NOT removal — persist the
|
||||
// computed next state anyway so the schedule advances and the job does not
|
||||
// re-fire next tick.
|
||||
fmt.Fprintf(stderr, "warning: could not re-read job %s before persist: %v\n", job.ID, readErr)
|
||||
return job, nil
|
||||
}
|
||||
if current.Status == cron.StatusPaused {
|
||||
// Honor an external pause that landed while the job ran.
|
||||
job.Status = cron.StatusPaused
|
||||
}
|
||||
return job, nil
|
||||
})
|
||||
if errors.Is(err, cron.ErrJobNotFound) {
|
||||
// Genuinely removed mid-run: don't recreate it (no run record, no persist).
|
||||
fmt.Fprintf(stdout, "fired %s -> exit %d (job removed during run)\n", job.ID, code)
|
||||
return
|
||||
case err != nil:
|
||||
// A transient read failure (IO/permission) is NOT removal — warn but still
|
||||
// record the run and persist the computed next state below.
|
||||
fmt.Fprintf(stderr, "warning: could not re-read job %s before persist: %v\n", job.ID, err)
|
||||
case current.Status == cron.StatusPaused:
|
||||
job.Status = cron.StatusPaused
|
||||
}
|
||||
if err := store.AppendRun(job.ID, rec); err != nil {
|
||||
fmt.Fprintf(stderr, "warning: failed to record run for %s: %v\n", job.ID, err)
|
||||
// Record the fire. AppendRun takes the per-job lock and bails if the job's
|
||||
// metadata is gone, so a Remove landing here cannot resurrect a deleted job's
|
||||
// directory with an orphaned runs.jsonl.
|
||||
if aerr := store.AppendRun(job.ID, rec); aerr != nil {
|
||||
fmt.Fprintf(stderr, "warning: failed to record run for %s: %v\n", job.ID, aerr)
|
||||
}
|
||||
if err := store.Update(job); err != nil {
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "warning: failed to persist job state for %s: %v\n", job.ID, err)
|
||||
persisted = job
|
||||
}
|
||||
fmt.Fprintf(stdout, "fired %s -> exit %d (next: %s)\n", job.ID, code, formatCronTime(job.NextRunAt))
|
||||
fmt.Fprintf(stdout, "fired %s -> exit %d (next: %s)\n", job.ID, code, formatCronTime(persisted.NextRunAt))
|
||||
}
|
||||
|
||||
// claimFire atomically claims a due job's fire under the per-job lock and reports
|
||||
// whether THIS caller should run it. It returns false only when the job was paused
|
||||
// externally or another scheduler already advanced NextRunAt past `fired` (so a
|
||||
// valid recurring job never double-fires). A valid due job has its NextRunAt
|
||||
// advanced here to claim the slot; an invalid or exhausted schedule is left for the
|
||||
// caller to fire-once and pause (preserving existing single-scheduler behavior).
|
||||
func claimFire(store *cron.Store, fired time.Time, id string) (bool, error) {
|
||||
claimed := true
|
||||
_, err := store.Mutate(id, func(current cron.Job, readErr error) (cron.Job, error) {
|
||||
if readErr != nil {
|
||||
return current, readErr
|
||||
}
|
||||
if current.Status != cron.StatusActive {
|
||||
claimed = false // paused/other — not ours to fire
|
||||
return current, nil
|
||||
}
|
||||
if current.NextRunAt.After(fired) {
|
||||
claimed = false // another scheduler already advanced this slot
|
||||
return current, nil
|
||||
}
|
||||
// Advance to the next slot to claim the fire. Compute it from the minute-
|
||||
// aligned fire instant so the schedule's DST fall-back collapse guard (which
|
||||
// only engages for a minute-aligned `after`) actually fires, instead of
|
||||
// double-firing the repeated wall-clock hour (AUDIT-M4).
|
||||
if sched, perr := cron.Parse(current.Expr); perr == nil {
|
||||
if nxt := sched.Next(fired.Truncate(time.Minute)); !nxt.IsZero() {
|
||||
current.NextRunAt = nxt // advance to claim; concurrent schedulers now skip
|
||||
return current, nil
|
||||
}
|
||||
}
|
||||
// Unparseable or unadvanceable schedule (an impossible spec whose Next is
|
||||
// zero): pause the job inside this same locked claim so a concurrent scheduler
|
||||
// sees a non-active job and does NOT also fire it. The winner still fires once
|
||||
// and fireJob's post-exec keeps it paused. Without this, both callers leave
|
||||
// NextRunAt unchanged, both see the job due, and both fire the same slot
|
||||
// (AUDIT-M5).
|
||||
current.Status = cron.StatusPaused
|
||||
return current, nil
|
||||
})
|
||||
return claimed, err
|
||||
}
|
||||
|
||||
func cronTruncate(s string, max int) string {
|
||||
|
||||
@@ -80,6 +80,59 @@ func TestCronRunOnceSkipsOverdueWithoutCatchUp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimFireRefusesSecondClaimOnUnadvanceableJob(t *testing.T) {
|
||||
// AUDIT-M5: two concurrent schedulers must not BOTH claim the same fire of a
|
||||
// job whose schedule cannot advance. The first claim pauses it inside the lock;
|
||||
// the second sees a non-active job and is refused.
|
||||
now := time.Date(2026, 6, 9, 9, 0, 0, 0, time.UTC)
|
||||
store := cron.NewStore(cron.StoreOptions{RootDir: t.TempDir(), Now: func() time.Time { return now }})
|
||||
job, err := store.Add(cron.Job{Expr: "0 0 30 2 *", Prompt: "x", Status: cron.StatusActive, NextRunAt: now.Add(-time.Minute)})
|
||||
if err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
c1, err1 := claimFire(store, now, job.ID)
|
||||
c2, err2 := claimFire(store, now, job.ID)
|
||||
if err1 != nil || err2 != nil {
|
||||
t.Fatalf("claimFire errs: %v %v", err1, err2)
|
||||
}
|
||||
if !c1 {
|
||||
t.Fatal("first claim should win")
|
||||
}
|
||||
if c2 {
|
||||
t.Fatal("second claim must be refused for an unadvanceable job (double-grant)")
|
||||
}
|
||||
d, _ := store.Get(job.ID)
|
||||
if d.Status != cron.StatusPaused {
|
||||
t.Fatalf("unadvanceable job must be paused by the winning claim, got %q", d.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronFireDoesNotDoubleFireOnDSTFallBack(t *testing.T) {
|
||||
// AUDIT-M4: a daily job in the repeated wall-clock hour must advance past the
|
||||
// fall-back repeat, not back to the same-day occurrence one absolute hour later.
|
||||
loc, err := time.LoadLocation("America/New_York")
|
||||
if err != nil {
|
||||
t.Skipf("tzdata unavailable: %v", err)
|
||||
}
|
||||
store := cron.NewStore(cron.StoreOptions{RootDir: t.TempDir()})
|
||||
firstRun := time.Date(2026, 11, 1, 1, 30, 0, 0, loc) // 01:30 EDT, before fall-back
|
||||
job, err := store.Add(cron.Job{Expr: "30 1 * * *", Prompt: "x", Status: cron.StatusActive, NextRunAt: firstRun})
|
||||
if err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
// Fire at 01:30:15 EDT with a sub-minute instant, exactly like the real scheduler.
|
||||
firedNow := time.Date(2026, 11, 1, 1, 30, 15, 0, loc)
|
||||
fx := &fakeExec{}
|
||||
var out, errb bytes.Buffer
|
||||
cronRun(store, func() time.Time { return firedNow }, []string{"--once"}, &out, &errb, fx.run)
|
||||
d, _ := store.Get(job.ID)
|
||||
// The fall-back repeat (01:30 EST) is ~1 absolute hour after firedNow; the next
|
||||
// real daily slot is ~23h later. A gap > 90m proves we skipped the repeat.
|
||||
if gap := d.NextRunAt.Sub(firedNow); gap <= 90*time.Minute {
|
||||
t.Fatalf("DST fall-back double-fire: NextRunAt advanced only %s (to the repeated hour), want the next day", gap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronRunPausesUnadvanceableJob(t *testing.T) {
|
||||
now := time.Date(2026, 6, 9, 9, 0, 0, 0, time.UTC)
|
||||
store := cron.NewStore(cron.StoreOptions{RootDir: t.TempDir(), Now: func() time.Time { return now }})
|
||||
|
||||
+111
-52
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/specmode"
|
||||
"github.com/Gitlawb/zero/internal/streamjson"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/usage"
|
||||
"github.com/Gitlawb/zero/internal/worktrees"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
@@ -104,6 +105,16 @@ type execOptions struct {
|
||||
// additional write roots for this run. Unioned with
|
||||
// config.SandboxConfig.AdditionalWriteRoots at scope construction time.
|
||||
addDirs []string
|
||||
// sandboxMode is the raw --sandbox flag value ("" = default enforce, "off"/
|
||||
// "disabled" = container-trusted). Resolved (with the ZERO_SANDBOX env
|
||||
// fallback) into a disable decision in runExec. Off by default; the disabled
|
||||
// path is an EXPLICIT, loudly-warned opt-in meant ONLY for use inside an
|
||||
// already-isolated container (CI / benchmark).
|
||||
sandboxMode string
|
||||
// deadlineRaw is the raw --deadline flag value (a Go duration like "900s").
|
||||
// Resolved (with the ZERO_DEADLINE env fallback) into a wall-clock budget for
|
||||
// the run. Empty = no deadline (current behavior).
|
||||
deadlineRaw string
|
||||
}
|
||||
|
||||
type execUsageError struct {
|
||||
@@ -164,8 +175,16 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
}
|
||||
var specialistRuntime *agentToolRuntime
|
||||
if shouldRegisterExecSpecialistTools(options) {
|
||||
// Specialist tools register before the full config resolve below (so
|
||||
// --list-tools stays offline). swarm.maxTeamSize is not affected by
|
||||
// overrides, so an empty-overrides resolve yields the same value; a resolve
|
||||
// error falls back to the swarm's built-in default (0 => 8).
|
||||
maxTeamSize := 0
|
||||
if swarmCfg, cfgErr := deps.resolveConfig(workspaceRoot, config.Overrides{}); cfgErr == nil {
|
||||
maxTeamSize = swarmCfg.Swarm.MaxTeamSize
|
||||
}
|
||||
var err error
|
||||
specialistRuntime, err = registerSpecialistTools(registry, workspaceRoot)
|
||||
specialistRuntime, err = registerSpecialistTools(registry, workspaceRoot, maxTeamSize)
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "specialist_error", err.Error())
|
||||
}
|
||||
@@ -249,7 +268,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
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. Set OPENAI_MODEL/OPENAI_API_KEY or add .zero/config.json.")
|
||||
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.")
|
||||
}
|
||||
// Activate deferred MCP-tool loading for this run only when the VISIBLE
|
||||
// deferred-eligible count meets the resolved threshold; below threshold this
|
||||
@@ -304,7 +323,8 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
for _, tool := range tools.CoreToolsScoped(workspaceRoot, execScope) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
sandboxEngine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, execScope)
|
||||
sandboxOff := resolveSandboxOff(options.sandboxMode)
|
||||
sandboxEngine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, execScope, sandboxOff)
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "sandbox_error", err.Error())
|
||||
}
|
||||
@@ -449,6 +469,12 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
if sandboxOff {
|
||||
writer.warning("Sandbox disabled (container-trusted mode): filesystem and network are unrestricted. Use only inside an isolated container.")
|
||||
if writer.err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
|
||||
sessionRecorder := execSessionRecorder{prepared: preparedSession}
|
||||
// Surface a best-effort session-recording failure once, on every exit path.
|
||||
@@ -466,6 +492,15 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
// cleanly (the loop honors context cancellation) instead of being killed.
|
||||
runCtx, stopSignals := signalContext()
|
||||
defer stopSignals()
|
||||
// --deadline / ZERO_DEADLINE bound the run by wall-clock time (Terminal-Bench
|
||||
// budgets by time, not turns). When the deadline fires, runCtx is cancelled,
|
||||
// the loop stops, and the run_end below reports status "deadline" with the
|
||||
// trajectory already flushed. Empty = no deadline (unchanged behavior).
|
||||
if runDeadline := resolveDeadline(options.deadlineRaw); runDeadline > 0 {
|
||||
var cancelDeadline context.CancelFunc
|
||||
runCtx, cancelDeadline = context.WithTimeout(runCtx, runDeadline)
|
||||
defer cancelDeadline()
|
||||
}
|
||||
// --self-correct opts the run into the post-edit verify-and-correct loop. Off
|
||||
// by default the corrector is nil, leaving the agent loop byte-identical. When
|
||||
// on we verify with both the workspace test plan and LSP diagnostics over the
|
||||
@@ -478,6 +513,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
MaxTurns: resolved.MaxTurns,
|
||||
ContextWindow: modelContextWindow(modelRegistry, resolved.Provider.Model),
|
||||
DeferThreshold: effectiveDeferThreshold,
|
||||
Specialists: specialistRuntime.specialistInfos(),
|
||||
SessionID: preparedSession.Session.SessionID,
|
||||
CallingSessionID: options.callingSessionID,
|
||||
CallingToolUseID: options.callingToolUseID,
|
||||
@@ -535,18 +571,13 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
}
|
||||
sessionRecorder.append(sessions.EventToolResult, payload)
|
||||
},
|
||||
OnUsage: func(usage agent.Usage) {
|
||||
writer.usage(usage)
|
||||
payload := map[string]any{
|
||||
"promptTokens": usage.EffectiveInputTokens(),
|
||||
"completionTokens": usage.EffectiveOutputTokens(),
|
||||
"totalTokens": usage.TotalTokens(),
|
||||
}
|
||||
OnUsage: func(u agent.Usage) {
|
||||
writer.usage(u)
|
||||
payload := usage.EventUsagePayload(u)
|
||||
// Attribute usage to a specific model ONLY when escalation is enabled:
|
||||
// the model in force can change mid-run only under --allow-escalation, so
|
||||
// the "model" key is meaningful exclusively then. Omitting it otherwise
|
||||
// keeps a non-escalation run's persisted usage payload byte-identical to
|
||||
// before this feature (the origin/main guarantee).
|
||||
// keeps a non-escalation run's persisted usage payload compact.
|
||||
if options.allowEscalation {
|
||||
payload["model"] = currentModel
|
||||
}
|
||||
@@ -560,11 +591,18 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
if err != nil {
|
||||
// A Ctrl+C / SIGTERM cancellation is a clean shutdown, not a provider error.
|
||||
if errors.Is(err, context.Canceled) || runCtx.Err() != nil {
|
||||
sessionRecorder.append(sessions.EventError, map[string]any{"message": "interrupted"})
|
||||
// Distinguish a wall-clock --deadline stop (status "deadline") from a
|
||||
// Ctrl+C / SIGTERM interrupt; both are clean shutdowns, not errors, and
|
||||
// the trajectory is flushed before exit either way.
|
||||
cancelStatus, cancelMsg := "interrupted", "run cancelled by signal"
|
||||
if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
|
||||
cancelStatus, cancelMsg = "deadline", "run stopped: wall-clock deadline reached"
|
||||
}
|
||||
sessionRecorder.append(sessions.EventError, map[string]any{"message": cancelStatus})
|
||||
switch options.outputFormat {
|
||||
case execOutputStreamJSON:
|
||||
writer.errorEvent("interrupted", "run cancelled by signal", false)
|
||||
writer.runEnd("interrupted", exitInterrupted)
|
||||
writer.errorEvent(cancelStatus, cancelMsg, false)
|
||||
writer.runEnd(cancelStatus, exitInterrupted)
|
||||
if writer.err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
@@ -683,12 +721,63 @@ func registerToolSearchIfEligible(registry *tools.Registry, deferThreshold int,
|
||||
registry.Register(tools.NewToolSearchTool(registry))
|
||||
}
|
||||
|
||||
func buildExecSandboxEngine(workspaceRoot string, resolved config.ResolvedConfig, deps appDeps, scope *sandbox.Scope) (*sandbox.Engine, error) {
|
||||
// resolveSandboxOff decides whether to disable confinement for an exec run.
|
||||
// Precedence: the --sandbox flag value wins; otherwise the ZERO_SANDBOX env var.
|
||||
// Recognized "off" values: off / disabled / none / 0 (case-insensitive). Anything
|
||||
// else, including empty, keeps the default enforcing sandbox.
|
||||
func resolveSandboxOff(flagValue string) bool {
|
||||
value := strings.TrimSpace(flagValue)
|
||||
if value == "" {
|
||||
value = strings.TrimSpace(os.Getenv("ZERO_SANDBOX"))
|
||||
}
|
||||
switch strings.ToLower(value) {
|
||||
case "off", "disabled", "none", "0":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// resolveDeadline parses the run's wall-clock budget. Precedence: the --deadline
|
||||
// flag wins; otherwise the ZERO_DEADLINE env var. Empty = no deadline. An
|
||||
// unparseable or non-positive value is treated as no deadline.
|
||||
func resolveDeadline(flagValue string) time.Duration {
|
||||
value := strings.TrimSpace(flagValue)
|
||||
if value == "" {
|
||||
value = strings.TrimSpace(os.Getenv("ZERO_DEADLINE"))
|
||||
}
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
d, err := time.ParseDuration(value)
|
||||
if err != nil || d <= 0 {
|
||||
return 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// execSandboxPolicy builds the sandbox policy for an exec run. By default it is
|
||||
// the configured, enforcing policy (DefaultPolicy + config overlay). With
|
||||
// disableSandbox it becomes container-trusted: Mode=disabled, Network=allow,
|
||||
// EnforceWorkspace=false — flipping all three gates so both the in-process
|
||||
// Evaluate path and the OS wrapping are permissive. Pure, so the default-unchanged
|
||||
// and off-mode behavior are unit-tested directly.
|
||||
func execSandboxPolicy(cfg config.SandboxConfig, disableSandbox bool) sandbox.Policy {
|
||||
policy := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), cfg)
|
||||
if disableSandbox {
|
||||
policy.Mode = sandbox.ModeDisabled
|
||||
policy.Network = sandbox.NetworkAllow
|
||||
policy.EnforceWorkspace = false
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
func buildExecSandboxEngine(workspaceRoot string, resolved config.ResolvedConfig, deps appDeps, scope *sandbox.Scope, disableSandbox bool) (*sandbox.Engine, error) {
|
||||
store, err := deps.newSandboxStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
policy := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox)
|
||||
policy := execSandboxPolicy(resolved.Sandbox, disableSandbox)
|
||||
backend := deps.selectSandboxBackend(sandbox.BackendOptions{})
|
||||
return sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
@@ -699,54 +788,24 @@ func buildExecSandboxEngine(workspaceRoot string, resolved config.ResolvedConfig
|
||||
}), nil
|
||||
}
|
||||
|
||||
// applyConfiguredAutonomyCeiling overlays a config-sourced autonomy ceiling onto
|
||||
// a policy. An empty ceiling is a no-op so the default High ceiling is preserved
|
||||
// (backward compatible).
|
||||
//
|
||||
// A non-empty but unrecognised value FAILS CLOSED: it clamps to the most
|
||||
// restrictive ceiling (AutonomyLow) rather than leaving the policy unchanged.
|
||||
// config.Resolve already rejects invalid values, so this branch is defense in
|
||||
// depth — it guarantees that even a direct/programmatic caller passing a typo
|
||||
// can never WIDEN the ceiling back to the High default.
|
||||
// applyConfiguredSandboxPolicy overlays every config-sourced sandbox knob onto
|
||||
// the default policy (the autonomy ceiling and the network mode), plus the
|
||||
// ZERO_SANDBOX_AUTO_ALLOW_BASH environment opt-in.
|
||||
// the default policy.
|
||||
func applyConfiguredSandboxPolicy(policy sandbox.Policy, cfg config.SandboxConfig) sandbox.Policy {
|
||||
policy = applyConfiguredAutonomyCeiling(policy, cfg.MaxAutonomy)
|
||||
if network := strings.TrimSpace(cfg.Network); network != "" {
|
||||
switch sandbox.NetworkMode(network) {
|
||||
case sandbox.NetworkAllow, sandbox.NetworkDeny:
|
||||
policy.Network = sandbox.NetworkMode(network)
|
||||
}
|
||||
}
|
||||
// Opt-in hardening flags: only ever turn a feature ON from config, never off,
|
||||
// so a programmatic default can't be silently disabled by an omitted key.
|
||||
// Opt-in hardening/diagnostic flags: only ever turn a feature ON from
|
||||
// config, never off, so a programmatic default can't be silently disabled by
|
||||
// an omitted key.
|
||||
if cfg.BlockUnixSockets {
|
||||
policy.BlockUnixSockets = true
|
||||
}
|
||||
if cfg.MonitorDenials {
|
||||
policy.MonitorDenials = true
|
||||
}
|
||||
// ZERO_SANDBOX_AUTO_ALLOW_BASH opts into auto-allowing a *sandboxed* shell
|
||||
// command without a prompt — the active sandbox is the safety boundary, and
|
||||
// the engine only honors this when the shell sandbox is actually active. Like
|
||||
// the flags above it only ever turns the opt-in ON; unset/blank stays off
|
||||
// (prompt), and an explicit config opt-in is preserved.
|
||||
policy = sandbox.ApplyAutoAllowBashEnv(policy)
|
||||
return policy
|
||||
}
|
||||
|
||||
func applyConfiguredAutonomyCeiling(policy sandbox.Policy, maxAutonomy string) sandbox.Policy {
|
||||
trimmed := strings.TrimSpace(maxAutonomy)
|
||||
if trimmed == "" {
|
||||
return policy
|
||||
}
|
||||
normalized, err := sandbox.NormalizeAutonomy(sandbox.Autonomy(trimmed))
|
||||
if err != nil {
|
||||
policy.MaxAutonomy = sandbox.AutonomyLow
|
||||
return policy
|
||||
}
|
||||
policy.MaxAutonomy = normalized
|
||||
return policy
|
||||
}
|
||||
|
||||
@@ -1072,7 +1131,7 @@ func sessionPermissionEventType(event agent.PermissionEvent) sessions.EventType
|
||||
if event.Action == agent.PermissionActionPrompt {
|
||||
return sessions.EventPermissionRequest
|
||||
}
|
||||
if event.Action == agent.PermissionActionAllow || event.Action == agent.PermissionActionDeny {
|
||||
if event.Action == agent.PermissionActionAllow || event.Action == agent.PermissionActionDeny || event.Action == agent.PermissionActionCancel {
|
||||
return sessions.EventPermissionDecision
|
||||
}
|
||||
return sessions.EventPermission
|
||||
|
||||
@@ -52,6 +52,24 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--auto="):
|
||||
options.autonomy = strings.TrimSpace(strings.TrimPrefix(arg, "--auto="))
|
||||
case arg == "--sandbox":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.sandboxMode = strings.TrimSpace(value)
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--sandbox="):
|
||||
options.sandboxMode = strings.TrimSpace(strings.TrimPrefix(arg, "--sandbox="))
|
||||
case arg == "--deadline":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.deadlineRaw = strings.TrimSpace(value)
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--deadline="):
|
||||
options.deadlineRaw = strings.TrimSpace(strings.TrimPrefix(arg, "--deadline="))
|
||||
case arg == "--enabled-tools":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
@@ -233,7 +251,7 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
|
||||
return options, false, err
|
||||
}
|
||||
options.outputFormat = format
|
||||
case arg == "--prompt":
|
||||
case arg == "-p" || arg == "--prompt":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
@@ -242,6 +260,8 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--prompt="):
|
||||
options.promptParts = append(options.promptParts, strings.TrimSpace(strings.TrimPrefix(arg, "--prompt=")))
|
||||
case strings.HasPrefix(arg, "-p="):
|
||||
options.promptParts = append(options.promptParts, strings.TrimSpace(strings.TrimPrefix(arg, "-p=")))
|
||||
case arg == "--resume":
|
||||
if index+1 < len(args) && !strings.HasPrefix(args[index+1], "-") && strings.TrimSpace(args[index+1]) != "" {
|
||||
options.resume = strings.TrimSpace(args[index+1])
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestParseExecArgsInlinePromptKeepsLeadingDash guards the M19 fix: the top-level
|
||||
// `zero -p "<prompt>"` path forwards the prompt as the inline `--prompt=<value>`
|
||||
// form precisely so a prompt whose first character is a dash is taken verbatim
|
||||
// instead of being mistaken for a flag and rejected.
|
||||
func TestParseExecArgsInlinePromptKeepsLeadingDash(t *testing.T) {
|
||||
opts, early, err := parseExecArgs([]string{"--prompt=-foo bar"})
|
||||
if err != nil {
|
||||
t.Fatalf("inline dash-leading prompt rejected: %v", err)
|
||||
}
|
||||
if early {
|
||||
t.Fatal("a normal prompt parse must not request early exit")
|
||||
}
|
||||
if got := strings.TrimSpace(strings.Join(opts.promptParts, " ")); got != "-foo bar" {
|
||||
t.Fatalf("inline prompt = %q, want %q", got, "-foo bar")
|
||||
}
|
||||
}
|
||||
@@ -357,7 +357,7 @@ func TestRunExecStreamJSONEmitsAndRecordsPermissionEvents(t *testing.T) {
|
||||
toolCallID: "call_write",
|
||||
toolName: "write_file",
|
||||
arguments: `{"path":"notes.txt","content":"hello"}`,
|
||||
answer: "write denied",
|
||||
answer: "write allowed",
|
||||
}, nil
|
||||
},
|
||||
newSandboxStore: func() (*sandbox.GrantStore, error) {
|
||||
@@ -374,16 +374,22 @@ func TestRunExecStreamJSONEmitsAndRecordsPermissionEvents(t *testing.T) {
|
||||
|
||||
events := decodeJSONLines(t, stdout.String())
|
||||
eventTypes := jsonEventTypes(events)
|
||||
if !slices.Contains(eventTypes, "permission_request") {
|
||||
t.Fatalf("expected permission_request event in %v; output %q", eventTypes, stdout.String())
|
||||
if slices.Contains(eventTypes, "permission_request") {
|
||||
t.Fatalf("workspace write should not request permission in %v; output %q", eventTypes, stdout.String())
|
||||
}
|
||||
permissionEvent := findJSONEvent(t, events, "permission_request")
|
||||
if permissionEvent["id"] != "call_write" || permissionEvent["name"] != "write_file" || permissionEvent["action"] != "prompt" {
|
||||
permissionEvent := findJSONEvent(t, events, "permission_decision")
|
||||
if permissionEvent["id"] != "call_write" || permissionEvent["name"] != "write_file" || permissionEvent["action"] != "allow" {
|
||||
t.Fatalf("unexpected permission event: %#v", permissionEvent)
|
||||
}
|
||||
if permissionEvent["permission"] != "prompt" || permissionEvent["permissionMode"] != "auto" || permissionEvent["sideEffect"] != "write" {
|
||||
t.Fatalf("unexpected permission metadata: %#v", permissionEvent)
|
||||
}
|
||||
if permissionEvent["permissionGranted"] == true {
|
||||
t.Fatalf("workspace allow should not be recorded as user-granted permission: %#v", permissionEvent)
|
||||
}
|
||||
if permissionEvent["reason"] != "workspace write is allowed" {
|
||||
t.Fatalf("unexpected workspace permission reason: %#v", permissionEvent)
|
||||
}
|
||||
risk, ok := permissionEvent["risk"].(map[string]any)
|
||||
if !ok || risk["level"] == "" {
|
||||
t.Fatalf("expected permission risk payload, got %#v", permissionEvent)
|
||||
@@ -400,12 +406,12 @@ func TestRunExecStreamJSONEmitsAndRecordsPermissionEvents(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents returned error: %v", err)
|
||||
}
|
||||
permissionRecord := findSessionEvent(t, recorded, sessions.EventPermissionRequest)
|
||||
permissionRecord := findSessionEvent(t, recorded, sessions.EventPermissionDecision)
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(permissionRecord.Payload, &payload); err != nil {
|
||||
t.Fatalf("decode permission payload: %v", err)
|
||||
}
|
||||
if payload["toolCallId"] != "call_write" || payload["name"] != "write_file" || payload["action"] != "prompt" {
|
||||
if payload["toolCallId"] != "call_write" || payload["name"] != "write_file" || payload["action"] != "allow" {
|
||||
t.Fatalf("unexpected recorded permission payload: %#v", payload)
|
||||
}
|
||||
|
||||
@@ -655,7 +661,7 @@ func TestRunExecReadsStreamJSONPromptFromStdin(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps([]string{"exec", "--input-format", "stream-json", "--output-format", "stream-json"}, &stdout, &stderr, appDeps{
|
||||
stdin: strings.NewReader(`{"schemaVersion":1,"type":"prompt","content":"from stdin"}` + "\n"),
|
||||
stdin: strings.NewReader(`{"schemaVersion":2,"type":"prompt","content":"from stdin"}` + "\n"),
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
@@ -715,6 +721,10 @@ func (fn closeFunc) Close() error {
|
||||
return fn()
|
||||
}
|
||||
|
||||
func (fn closeFunc) Skipped() []mcp.SkippedServer {
|
||||
return nil
|
||||
}
|
||||
|
||||
type toolCallingExecProvider struct {
|
||||
toolCallID string
|
||||
toolName string
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// TestExecSandboxPolicyDefaultUnchanged is the load-bearing guard for real users:
|
||||
// WITHOUT --sandbox off / ZERO_SANDBOX, the exec policy MUST be the default
|
||||
// enforcing one (workspace-confined, network denied). If this ever changes, the
|
||||
// container-trusted off-switch has leaked into the default.
|
||||
func TestExecSandboxPolicyDefaultUnchanged(t *testing.T) {
|
||||
policy := execSandboxPolicy(config.SandboxConfig{}, false)
|
||||
if policy.Mode != sandbox.ModeEnforce {
|
||||
t.Errorf("Mode = %q, want %q (default must not change)", policy.Mode, sandbox.ModeEnforce)
|
||||
}
|
||||
if policy.Network != sandbox.NetworkDeny {
|
||||
t.Errorf("Network = %q, want %q (default must not change)", policy.Network, sandbox.NetworkDeny)
|
||||
}
|
||||
if !policy.EnforceWorkspace {
|
||||
t.Error("EnforceWorkspace = false, want true (default must not change)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecSandboxPolicyOff: with the off-switch, all three confinement gates drop
|
||||
// so writes-anywhere and network both work inside an isolated container.
|
||||
func TestExecSandboxPolicyOff(t *testing.T) {
|
||||
policy := execSandboxPolicy(config.SandboxConfig{}, true)
|
||||
if policy.Mode != sandbox.ModeDisabled {
|
||||
t.Errorf("Mode = %q, want %q", policy.Mode, sandbox.ModeDisabled)
|
||||
}
|
||||
if policy.Network != sandbox.NetworkAllow {
|
||||
t.Errorf("Network = %q, want %q", policy.Network, sandbox.NetworkAllow)
|
||||
}
|
||||
if policy.EnforceWorkspace {
|
||||
t.Error("EnforceWorkspace = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSandboxOff(t *testing.T) {
|
||||
cases := []struct {
|
||||
flag, env string
|
||||
want bool
|
||||
}{
|
||||
{"", "", false},
|
||||
{"off", "", true},
|
||||
{"disabled", "", true},
|
||||
{"none", "", true},
|
||||
{"0", "", true},
|
||||
{"OFF", "", true}, // case-insensitive
|
||||
{"enforce", "", false},
|
||||
{"", "off", true}, // env fallback
|
||||
{"", "disabled", true},
|
||||
{"", "1", false}, // unrecognized env value
|
||||
{"enforce", "off", false}, // flag wins over env
|
||||
{"off", "enforce", true}, // flag wins over env
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Setenv("ZERO_SANDBOX", c.env)
|
||||
if got := resolveSandboxOff(c.flag); got != c.want {
|
||||
t.Errorf("resolveSandboxOff(flag=%q, env=%q) = %v, want %v", c.flag, c.env, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDeadline(t *testing.T) {
|
||||
cases := []struct {
|
||||
flag, env string
|
||||
want time.Duration
|
||||
}{
|
||||
{"", "", 0},
|
||||
{"900s", "", 15 * time.Minute},
|
||||
{"15m", "", 15 * time.Minute},
|
||||
{"", "300s", 5 * time.Minute}, // env fallback
|
||||
{"60s", "300s", time.Minute}, // flag wins over env
|
||||
{"bogus", "", 0}, // unparseable -> no deadline
|
||||
{"-5s", "", 0}, // non-positive -> no deadline
|
||||
{"0s", "", 0}, // zero -> no deadline
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Setenv("ZERO_DEADLINE", c.env)
|
||||
if got := resolveDeadline(c.flag); got != c.want {
|
||||
t.Errorf("resolveDeadline(flag=%q, env=%q) = %v, want %v", c.flag, c.env, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseExecArgsSandboxAndDeadline: both --flag value and --flag=value spellings
|
||||
// thread through to the options.
|
||||
func TestParseExecArgsSandboxAndDeadline(t *testing.T) {
|
||||
opts, _, err := parseExecArgs([]string{"--sandbox", "off", "--deadline", "900s", "do the task"})
|
||||
if err != nil {
|
||||
t.Fatalf("parseExecArgs: %v", err)
|
||||
}
|
||||
if opts.sandboxMode != "off" {
|
||||
t.Errorf("sandboxMode = %q, want off", opts.sandboxMode)
|
||||
}
|
||||
if opts.deadlineRaw != "900s" {
|
||||
t.Errorf("deadlineRaw = %q, want 900s", opts.deadlineRaw)
|
||||
}
|
||||
|
||||
opts2, _, err := parseExecArgs([]string{"--sandbox=disabled", "--deadline=15m", "x"})
|
||||
if err != nil {
|
||||
t.Fatalf("parseExecArgs inline: %v", err)
|
||||
}
|
||||
if opts2.sandboxMode != "disabled" || opts2.deadlineRaw != "15m" {
|
||||
t.Errorf("inline parse: sandboxMode=%q deadlineRaw=%q, want disabled/15m", opts2.sandboxMode, opts2.deadlineRaw)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/specmode"
|
||||
"github.com/Gitlawb/zero/internal/streamjson"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/usage"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
@@ -152,13 +153,9 @@ func runExecSpecDraft(run execSpecDraftRun) int {
|
||||
}
|
||||
sessionRecorder.append(sessions.EventToolResult, payload)
|
||||
},
|
||||
OnUsage: func(usage agent.Usage) {
|
||||
writer.usage(usage)
|
||||
sessionRecorder.append(sessions.EventUsage, map[string]any{
|
||||
"promptTokens": usage.EffectiveInputTokens(),
|
||||
"completionTokens": usage.EffectiveOutputTokens(),
|
||||
"totalTokens": usage.TotalTokens(),
|
||||
})
|
||||
OnUsage: func(u agent.Usage) {
|
||||
writer.usage(u)
|
||||
sessionRecorder.append(sessions.EventUsage, usage.EventUsagePayload(u))
|
||||
},
|
||||
})
|
||||
run.notifier.Notify(notify.Completion, notify.DefaultMessage(notify.Completion))
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestRunExecStreamJSONImageReachesAgent(t *testing.T) {
|
||||
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
|
||||
}
|
||||
encoded := base64.StdEncoding.EncodeToString(png)
|
||||
input := `{"schemaVersion":1,"type":"message","role":"user","content":"describe this","images":[{"mediaType":"image/png","data":"` + encoded + `"}]}` + "\n"
|
||||
input := `{"schemaVersion":2,"type":"message","role":"user","content":"describe this","images":[{"mediaType":"image/png","data":"` + encoded + `"}]}` + "\n"
|
||||
|
||||
provider := &capturingImageProvider{}
|
||||
|
||||
@@ -120,7 +120,7 @@ func TestRunExecStreamJSONImageOnlyMessageProceeds(t *testing.T) {
|
||||
}
|
||||
encoded := base64.StdEncoding.EncodeToString(png)
|
||||
// Empty content, one image: the image-only turn.
|
||||
input := `{"schemaVersion":1,"type":"message","role":"user","content":"","images":[{"mediaType":"image/png","data":"` + encoded + `"}]}` + "\n"
|
||||
input := `{"schemaVersion":2,"type":"message","role":"user","content":"","images":[{"mediaType":"image/png","data":"` + encoded + `"}]}` + "\n"
|
||||
|
||||
provider := &capturingImageProvider{}
|
||||
|
||||
|
||||
@@ -197,8 +197,8 @@ func (writer *execEventWriter) permission(event agent.PermissionEvent) {
|
||||
"decision_reason": event.DecisionReason,
|
||||
"risk": event.Risk,
|
||||
}
|
||||
if event.Violation != nil {
|
||||
payload["violation"] = event.Violation
|
||||
if event.Block != nil {
|
||||
payload["block"] = event.Block
|
||||
}
|
||||
if event.GrantMatched {
|
||||
payload["grant_matched"] = true
|
||||
@@ -227,7 +227,7 @@ func (writer *execEventWriter) permission(event agent.PermissionEvent) {
|
||||
Reason: event.Reason,
|
||||
DecisionReason: event.DecisionReason,
|
||||
Risk: &risk,
|
||||
Violation: event.Violation,
|
||||
Block: event.Block,
|
||||
GrantMatched: event.GrantMatched,
|
||||
Grant: event.Grant,
|
||||
})
|
||||
@@ -242,7 +242,7 @@ func streamJSONPermissionEventType(event agent.PermissionEvent) streamjson.Event
|
||||
if event.Action == agent.PermissionActionPrompt {
|
||||
return streamjson.EventPermissionRequest
|
||||
}
|
||||
if event.Action == agent.PermissionActionAllow || event.Action == agent.PermissionActionDeny {
|
||||
if event.Action == agent.PermissionActionAllow || event.Action == agent.PermissionActionDeny || event.Action == agent.PermissionActionCancel {
|
||||
return streamjson.EventPermissionDecision
|
||||
}
|
||||
return streamjson.EventPermission
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agentinit"
|
||||
)
|
||||
|
||||
// runInit implements `zero init`: investigate the repo and generate an
|
||||
// AGENTS.md. It builds a bootstrap prompt seeded with repo facts and forwards
|
||||
// it to the normal `exec` machinery (provider, tools, sandbox, AGENTS.md write
|
||||
// path are all the standard ones). Any flags the user passes (--model, -w,
|
||||
// --add-dir, …) are forwarded to exec unchanged; the built prompt is appended
|
||||
// as the trailing positional, so a user-supplied prompt is not expected.
|
||||
func runInit(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
if wantsHelp(args) {
|
||||
_, _ = io.WriteString(stdout, initHelp)
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// Resolve the workspace the same way exec will, so the seeded facts describe
|
||||
// the right repo. A failure here is non-fatal — BuildPrompt degrades to
|
||||
// "investigate yourself".
|
||||
cwd := initCwdFromArgs(args)
|
||||
workspaceRoot, err := resolveWorkspaceRoot(cwd, deps)
|
||||
if err != nil {
|
||||
workspaceRoot = cwd
|
||||
}
|
||||
prompt := agentinit.BuildPrompt(context.Background(), workspaceRoot, time.Now())
|
||||
|
||||
// Forward the user's flags, then the built prompt as the trailing positional.
|
||||
execArgs := append(append([]string{}, args...), prompt)
|
||||
return runExec(execArgs, stdout, stderr, deps)
|
||||
}
|
||||
|
||||
// initCwdFromArgs extracts a -C/--cwd value if present, else "" (exec defaults
|
||||
// to the process cwd). Kept minimal — exec owns the real flag parsing.
|
||||
func initCwdFromArgs(args []string) string {
|
||||
for i, a := range args {
|
||||
switch {
|
||||
case a == "-C" || a == "--cwd":
|
||||
if i+1 < len(args) {
|
||||
return args[i+1]
|
||||
}
|
||||
case strings.HasPrefix(a, "--cwd="):
|
||||
return strings.TrimPrefix(a, "--cwd=")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func wantsHelp(args []string) bool {
|
||||
for _, a := range args {
|
||||
if a == "-h" || a == "--help" || a == "help" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const initHelp = `zero init — investigate the repository and generate an AGENTS.md.
|
||||
|
||||
Runs an agent turn seeded with a local repo scan (languages, build/test tools,
|
||||
CI, workspace layout), investigates the gaps, and writes a concise AGENTS.md at
|
||||
the repo root so future runs start with project context.
|
||||
|
||||
Usage:
|
||||
zero init [exec flags]
|
||||
|
||||
Accepts the same flags as 'zero exec' (e.g. --model, -w/--worktree, --add-dir).
|
||||
`
|
||||
@@ -697,11 +697,20 @@ func (cfg *mcpWritableConfig) setServerDisabled(name string, disabled bool) (boo
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
if !legacyFound {
|
||||
switch {
|
||||
case legacyFound:
|
||||
raw = legacyRaw
|
||||
found = true
|
||||
case config.IsDefaultMCPServer(name):
|
||||
// A built-in default server isn't written to the file until the user
|
||||
// overrides it. Treat it as present with an empty base so disabling it
|
||||
// writes a minimal {"disabled":true} entry that merges over the default —
|
||||
// letting `zero mcp disable <default>` work even though it lives in code.
|
||||
raw = nil
|
||||
found = true
|
||||
default:
|
||||
return false, false, nil
|
||||
}
|
||||
raw = legacyRaw
|
||||
found = true
|
||||
}
|
||||
var server map[string]json.RawMessage
|
||||
if len(raw) > 0 && string(raw) != "null" {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
// `zero mcp disable firecrawl` must work even though firecrawl is a built-in
|
||||
// default that is not written to the user's config file until overridden.
|
||||
func TestRunMCPDisableSeededFirecrawlDefault(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
// A config with no firecrawl entry — the default lives in code, not the file.
|
||||
writeMCPCommandRawConfig(t, configPath, `{"activeProvider":"fast"}`)
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runWithDeps([]string{"mcp", "disable", "firecrawl", "--json"}, &out, &errBuf, appDeps{
|
||||
userConfigPath: func() (string, error) { return configPath, nil },
|
||||
})
|
||||
if code != exitSuccess {
|
||||
t.Fatalf("disable exit=%d stderr=%s", code, errBuf.String())
|
||||
}
|
||||
var payload struct {
|
||||
ServerName string `json:"serverName"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Changed bool `json:"changed"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode disable JSON: %v\n%s", err, out.String())
|
||||
}
|
||||
if payload.ServerName != "firecrawl" || !payload.Disabled || !payload.Changed {
|
||||
t.Fatalf("disable payload = %#v, want firecrawl disabled+changed", payload)
|
||||
}
|
||||
|
||||
// End-to-end: resolving that config now turns the default off.
|
||||
cfg, err := config.ResolveMCP(config.ResolveOptions{UserConfigPath: configPath})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
if !cfg.Servers["firecrawl"].Disabled {
|
||||
t.Fatal("expected `mcp disable firecrawl` to turn the seeded default off in the resolved config")
|
||||
}
|
||||
}
|
||||
@@ -46,8 +46,10 @@ func runDoctor(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
}
|
||||
|
||||
var provider config.ProviderProfile
|
||||
var sandboxConfig config.SandboxConfig
|
||||
if resolved, resolveErr := deps.resolveConfig(workspaceRoot, config.Overrides{}); resolveErr == nil {
|
||||
provider = resolved.Provider
|
||||
sandboxConfig = resolved.Sandbox
|
||||
}
|
||||
var health *providerhealth.Result
|
||||
if options.connectivity && config.HasProviderProfile(provider) {
|
||||
@@ -67,6 +69,8 @@ func runDoctor(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
UserConfig: userConfig,
|
||||
ProjectConfig: projectConfig,
|
||||
Provider: provider,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Sandbox: sandboxConfig,
|
||||
Connectivity: options.connectivity,
|
||||
ProviderHealth: health,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/provideronboarding"
|
||||
)
|
||||
|
||||
type providerDetectOptions struct {
|
||||
json bool
|
||||
}
|
||||
|
||||
// providerDetectAction is the JSON/text shape of a single suggested next step.
|
||||
type providerDetectAction struct {
|
||||
Label string `json:"label"`
|
||||
Command string `json:"command"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
type providerDetectRuntime struct {
|
||||
CatalogID string `json:"catalogID"`
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"baseURL"`
|
||||
Models []string `json:"models,omitempty"`
|
||||
Action providerDetectAction `json:"action"`
|
||||
}
|
||||
|
||||
type providerDetectProvider struct {
|
||||
Name string `json:"name"`
|
||||
Active bool `json:"active"`
|
||||
Actions []providerDetectAction `json:"actions,omitempty"`
|
||||
}
|
||||
|
||||
type providerDetectReport struct {
|
||||
DetectedRuntimes []providerDetectRuntime `json:"detectedRuntimes"`
|
||||
Providers []providerDetectProvider `json:"providers"`
|
||||
}
|
||||
|
||||
// runProvidersDetect probes the machine for running local, OpenAI-compatible
|
||||
// model runtimes (Ollama, LM Studio) and prints a no-key adopt command for each
|
||||
// one it finds, followed by the next-step actions for every already-configured
|
||||
// provider. It is the onboarding-advice surface — "what can I do right now?" —
|
||||
// and never errors on a machine with nothing running locally (it just reports an
|
||||
// empty detected list).
|
||||
func runProvidersDetect(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseProviderDetectArgs(args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeProvidersHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
return exitCode
|
||||
}
|
||||
|
||||
ctx, stop := signalContext()
|
||||
defer stop()
|
||||
detected := deps.detectLocalRuntimes(ctx, provideronboarding.LocalDetectOptions{})
|
||||
|
||||
report := buildProviderDetectReport(resolved, detected)
|
||||
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, report); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, formatProviderDetectReport(report)); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func parseProviderDetectArgs(args []string) (providerDetectOptions, bool, error) {
|
||||
options := providerDetectOptions{}
|
||||
for _, arg := range args {
|
||||
switch {
|
||||
case arg == "-h" || arg == "--help" || arg == "help":
|
||||
return options, true, nil
|
||||
case arg == "--json":
|
||||
options.json = true
|
||||
default:
|
||||
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
|
||||
}
|
||||
}
|
||||
return options, false, nil
|
||||
}
|
||||
|
||||
func buildProviderDetectReport(resolved config.ResolvedConfig, detected []provideronboarding.DetectedLocalRuntime) providerDetectReport {
|
||||
report := providerDetectReport{
|
||||
DetectedRuntimes: make([]providerDetectRuntime, 0, len(detected)),
|
||||
Providers: make([]providerDetectProvider, 0, len(resolved.Providers)),
|
||||
}
|
||||
for _, runtime := range detected {
|
||||
report.DetectedRuntimes = append(report.DetectedRuntimes, providerDetectRuntime{
|
||||
CatalogID: runtime.CatalogID,
|
||||
Name: runtime.Name,
|
||||
BaseURL: runtime.BaseURL,
|
||||
Models: runtime.Models,
|
||||
Action: providerDetectActionFrom(runtime.SetupAction()),
|
||||
})
|
||||
}
|
||||
for _, profile := range resolved.Providers {
|
||||
active := strings.TrimSpace(profile.Name) != "" && profile.Name == resolved.ActiveProvider
|
||||
state := provideronboarding.ProviderState{Profile: profile, Active: active}
|
||||
actions := state.Actions()
|
||||
entry := providerDetectProvider{
|
||||
Name: profile.Name,
|
||||
Active: active,
|
||||
Actions: make([]providerDetectAction, 0, len(actions)),
|
||||
}
|
||||
for _, action := range actions {
|
||||
entry.Actions = append(entry.Actions, providerDetectActionFrom(action))
|
||||
}
|
||||
report.Providers = append(report.Providers, entry)
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func providerDetectActionFrom(action provideronboarding.Action) providerDetectAction {
|
||||
return providerDetectAction{Label: action.Label, Command: action.Command, Detail: action.Detail}
|
||||
}
|
||||
|
||||
func formatProviderDetectReport(report providerDetectReport) string {
|
||||
lines := []string{"Detected local runtimes:"}
|
||||
if len(report.DetectedRuntimes) == 0 {
|
||||
lines = append(lines, " (none running on default ports)")
|
||||
}
|
||||
for _, runtime := range report.DetectedRuntimes {
|
||||
name := strings.TrimSpace(runtime.Name)
|
||||
if name == "" {
|
||||
name = runtime.CatalogID
|
||||
}
|
||||
lines = append(lines, " "+name+" — "+runtime.BaseURL)
|
||||
if len(runtime.Models) > 0 {
|
||||
lines = append(lines, " models: "+strings.Join(runtime.Models, ", "))
|
||||
}
|
||||
if command := strings.TrimSpace(runtime.Action.Command); command != "" {
|
||||
lines = append(lines, " "+runtime.Action.Label+": "+command)
|
||||
}
|
||||
}
|
||||
|
||||
lines = append(lines, "", "Configured providers:")
|
||||
if len(report.Providers) == 0 {
|
||||
lines = append(lines, " (none configured — run `zero setup`)")
|
||||
}
|
||||
for _, provider := range report.Providers {
|
||||
name := strings.TrimSpace(provider.Name)
|
||||
if name == "" {
|
||||
name = "(unnamed)"
|
||||
}
|
||||
marker := ""
|
||||
if provider.Active {
|
||||
marker = " (active)"
|
||||
}
|
||||
lines = append(lines, " "+name+marker)
|
||||
if len(provider.Actions) == 0 {
|
||||
lines = append(lines, " (ready)")
|
||||
}
|
||||
for _, action := range provider.Actions {
|
||||
line := " " + action.Label
|
||||
if command := strings.TrimSpace(action.Command); command != "" {
|
||||
line += ": " + command
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/provideronboarding"
|
||||
)
|
||||
|
||||
func TestRunProvidersDetectSurfacesLocalRuntimeAndProviderActions(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
deps := commandCenterDeps(t)
|
||||
deps.detectLocalRuntimes = func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime {
|
||||
return []provideronboarding.DetectedLocalRuntime{{
|
||||
LocalRuntime: provideronboarding.LocalRuntime{CatalogID: "ollama", Name: "Ollama Local", BaseURL: "http://localhost:11434/v1"},
|
||||
Reachable: true,
|
||||
Models: []string{"llama3.1"},
|
||||
}}
|
||||
}
|
||||
deps.resolveConfig = func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
// Configured-but-inactive provider with no credential: should yield the full
|
||||
// "Use provider" / "Check provider" / "Set API key" action set.
|
||||
profile := config.ProviderProfile{
|
||||
Name: "openai",
|
||||
ProviderKind: config.ProviderKindOpenAI,
|
||||
Model: "gpt-4o",
|
||||
APIKeyEnv: "OPENAI_API_KEY",
|
||||
}
|
||||
return config.ResolvedConfig{ActiveProvider: "other", Providers: []config.ProviderProfile{profile}}, nil
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "detect"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"Detected local runtimes:",
|
||||
"Ollama Local — http://localhost:11434/v1",
|
||||
"models: llama3.1",
|
||||
"zero providers add ollama", // SetupAction's no-key adopt command
|
||||
"Configured providers:",
|
||||
"openai",
|
||||
"Use provider",
|
||||
"Check provider",
|
||||
"Set API key",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("detect output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersDetectJSONNoRuntimesActiveProvider(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
deps := commandCenterDeps(t)
|
||||
deps.detectLocalRuntimes = func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime {
|
||||
return nil // nothing running locally
|
||||
}
|
||||
deps.resolveConfig = func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
// Active provider with an inline key: only "Check provider" remains.
|
||||
profile := config.ProviderProfile{
|
||||
Name: "local",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://api.example.com/v1",
|
||||
APIKey: "sk-secret",
|
||||
Model: "custom",
|
||||
}
|
||||
return config.ResolvedConfig{ActiveProvider: "local", Provider: profile, Providers: []config.ProviderProfile{profile}}, nil
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "detect", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), "sk-secret") {
|
||||
t.Fatalf("detect JSON leaked the API key: %s", stdout.String())
|
||||
}
|
||||
var payload struct {
|
||||
DetectedRuntimes []struct {
|
||||
CatalogID string `json:"catalogID"`
|
||||
} `json:"detectedRuntimes"`
|
||||
Providers []struct {
|
||||
Name string `json:"name"`
|
||||
Active bool `json:"active"`
|
||||
Actions []struct {
|
||||
Label string `json:"label"`
|
||||
} `json:"actions"`
|
||||
} `json:"providers"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("detect JSON did not decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(payload.DetectedRuntimes) != 0 {
|
||||
t.Fatalf("expected no detected runtimes, got %#v", payload.DetectedRuntimes)
|
||||
}
|
||||
if len(payload.Providers) != 1 || payload.Providers[0].Name != "local" || !payload.Providers[0].Active {
|
||||
t.Fatalf("expected one active provider 'local', got %#v", payload.Providers)
|
||||
}
|
||||
if len(payload.Providers[0].Actions) != 1 || payload.Providers[0].Actions[0].Label != "Check provider" {
|
||||
t.Fatalf("expected only a Check action for an active keyed provider, got %#v", payload.Providers[0].Actions)
|
||||
}
|
||||
}
|
||||
+155
-37
@@ -14,7 +14,6 @@ type sandboxCommandOptions struct {
|
||||
json bool
|
||||
confirm bool
|
||||
effective bool
|
||||
autonomy string
|
||||
reason string
|
||||
path string
|
||||
}
|
||||
@@ -31,6 +30,10 @@ func runSandbox(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
return exitSuccess
|
||||
case "policy":
|
||||
return runSandboxPolicy(args[1:], stdout, stderr, deps)
|
||||
case "setup":
|
||||
return runSandboxSetup(args[1:], stdout, stderr, deps)
|
||||
case "check":
|
||||
return runSandboxCheck(args[1:], stdout, stderr, deps)
|
||||
case "grants":
|
||||
return runSandboxGrants(args[1:], stdout, stderr, deps)
|
||||
default:
|
||||
@@ -58,9 +61,7 @@ func runSandboxPolicy(args []string, stdout io.Writer, stderr io.Writer, deps ap
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
// Surface config resolution failures instead of silently falling back to
|
||||
// DefaultPolicy() (High): an unresolvable config (e.g. an invalid
|
||||
// sandbox.maxAutonomy that now errors at resolve time) would otherwise
|
||||
// misreport the trust posture as the permissive default.
|
||||
// DefaultPolicy(), which would otherwise misreport the active sandbox posture.
|
||||
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitProvider)
|
||||
@@ -112,13 +113,116 @@ func runSandboxPolicy(args []string, stdout io.Writer, stderr io.Writer, deps ap
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func runSandboxSetup(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseSandboxCommandOptions(args, sandboxCommandFlags{})
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeSandboxSetupHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
workspaceRoot, err := resolveWorkspaceRoot("", deps)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitProvider)
|
||||
}
|
||||
policy := applyConfiguredSandboxPolicy(zeroSandbox.DefaultPolicy(), resolved.Sandbox)
|
||||
backend := deps.selectSandboxBackend(zeroSandbox.BackendOptions{})
|
||||
if backend.Platform != "windows" {
|
||||
message := "No native sandbox setup is required for " + displayPlatform(backend.Platform)
|
||||
return writeSandboxSetupResult(stdout, options.json, sandboxSetupResult{
|
||||
Platform: displayPlatform(backend.Platform),
|
||||
Backend: backend.Name,
|
||||
Ran: false,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
if backend.Name != zeroSandbox.BackendWindowsRestrictedToken || !backend.Available || backend.Executable == "" {
|
||||
message := "Windows sandbox setup helper is not available"
|
||||
if strings.TrimSpace(backend.Message) != "" {
|
||||
message += ": " + backend.Message
|
||||
}
|
||||
return writeAppError(stderr, message, exitProvider)
|
||||
}
|
||||
scope, err := zeroSandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
profile := zeroSandbox.PermissionProfileFromPolicy(workspaceRoot, policy, scope)
|
||||
// Resolve the setup helper the same way the runner is resolved: a standalone
|
||||
// .exe when shipped (release), else self-dispatch via the running zero binary
|
||||
// (dev / plain build). This mirrors the backend's command-runner resolution
|
||||
// so `zero sandbox setup` works in every layout, not just release.
|
||||
setupHelper := zeroSandbox.ResolveWindowsSandboxSetupHelper(nil)
|
||||
if !setupHelper.Available() {
|
||||
return writeAppError(stderr, "Windows sandbox setup helper is not available", exitProvider)
|
||||
}
|
||||
setupArgs, err := zeroSandbox.BuildWindowsSandboxSetupArgs(zeroSandbox.WindowsSandboxSetupArgsOptions{
|
||||
CommandCWD: workspaceRoot,
|
||||
WorkspaceRoots: []string{workspaceRoot},
|
||||
PermissionProfile: profile,
|
||||
})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
setupArgs = append(append([]string{}, setupHelper.ArgsPrefix...), setupArgs...)
|
||||
if err := deps.runSandboxSetupHelper(setupHelper.Name, setupArgs, stdout, stderr); err != nil {
|
||||
return writeAppError(stderr, "Windows sandbox setup failed: "+err.Error(), exitProvider)
|
||||
}
|
||||
return writeSandboxSetupResult(stdout, options.json, sandboxSetupResult{
|
||||
Platform: "windows",
|
||||
Backend: backend.Name,
|
||||
Helper: setupHelper.Name,
|
||||
Ran: true,
|
||||
Message: "Windows sandbox setup complete",
|
||||
})
|
||||
}
|
||||
|
||||
type sandboxSetupResult struct {
|
||||
Platform string `json:"platform"`
|
||||
Backend zeroSandbox.BackendName `json:"backend"`
|
||||
Helper string `json:"helper,omitempty"`
|
||||
Ran bool `json:"ran"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func writeSandboxSetupResult(stdout io.Writer, asJSON bool, result sandboxSetupResult) int {
|
||||
if asJSON {
|
||||
if err := writePrettyJSON(stdout, result); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, result.Message); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if result.Helper != "" {
|
||||
if _, err := fmt.Fprintln(stdout, "helper: "+result.Helper); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func displayPlatform(platform string) string {
|
||||
if strings.TrimSpace(platform) == "" {
|
||||
return "this platform"
|
||||
}
|
||||
return platform
|
||||
}
|
||||
|
||||
// sandboxGuards is the resolved set of pre-execution safety guards the engine
|
||||
// applies for the effective policy. There is no layered/merged config today, so
|
||||
// the "effective" view is the fully-resolved DefaultPolicy plus the platform
|
||||
// backend and the always-on static guards.
|
||||
type sandboxGuards struct {
|
||||
InteractiveCommand bool `json:"interactiveCommand"`
|
||||
DestructiveShell bool `json:"destructiveShell"`
|
||||
Network bool `json:"network"`
|
||||
Workspace bool `json:"workspace"`
|
||||
}
|
||||
@@ -128,7 +232,6 @@ func resolveSandboxGuards(policy zeroSandbox.Policy) sandboxGuards {
|
||||
// Interactive-command detection is a static pre-exec guard that always
|
||||
// runs in the bash tool regardless of policy toggles.
|
||||
InteractiveCommand: true,
|
||||
DestructiveShell: policy.DenyDestructiveShell,
|
||||
Network: policy.Network == zeroSandbox.NetworkDeny,
|
||||
Workspace: policy.EnforceWorkspace,
|
||||
}
|
||||
@@ -188,17 +291,20 @@ func formatEffectiveSandboxPolicy(workspaceRoot string, policy zeroSandbox.Polic
|
||||
lines = append(lines, "write_roots_error: "+writeRootsErr.Error())
|
||||
}
|
||||
lines = append(lines,
|
||||
"deny_destructive_shell: "+fmt.Sprintf("%t", policy.DenyDestructiveShell),
|
||||
"allow_policy_only_runner: "+fmt.Sprintf("%t", policy.AllowPolicyOnlyRunner),
|
||||
"max_autonomy: "+string(policy.MaxAutonomy),
|
||||
"backend: "+string(backend.Name),
|
||||
"target_backend: "+string(plan.TargetBackend),
|
||||
"support_level: "+string(plan.SupportLevel),
|
||||
"command_wrapped: "+fmt.Sprintf("%t", plan.CommandWrapped),
|
||||
"enforcement_level: "+string(plan.EnforcementLevel),
|
||||
"requires_platform_sandbox: "+fmt.Sprintf("%t", plan.RequiresPlatformSandbox),
|
||||
"interactive_command_guard: "+enabledLabel(guards.InteractiveCommand),
|
||||
"destructive_shell_guard: "+enabledLabel(guards.DestructiveShell),
|
||||
"network_guard: "+enabledLabel(guards.Network),
|
||||
"workspace_guard: "+enabledLabel(guards.Workspace),
|
||||
"grants: "+grantsPath,
|
||||
)
|
||||
if plan.DowngradeReason != "" {
|
||||
lines = append(lines, "downgrade_reason: "+plan.DowngradeReason)
|
||||
}
|
||||
if backend.Platform != "" {
|
||||
lines = append(lines, "backend_platform: "+backend.Platform)
|
||||
}
|
||||
@@ -247,15 +353,20 @@ func runSandboxGrants(args []string, stdout io.Writer, stderr io.Writer, deps ap
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
prefixes, err := store.ListCommandPrefixes()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, struct {
|
||||
Grants []zeroSandbox.Grant `json:"grants"`
|
||||
}{Grants: grants}); err != nil {
|
||||
Grants []zeroSandbox.Grant `json:"grants"`
|
||||
CommandPrefixes []zeroSandbox.CommandPrefixGrant `json:"commandPrefixes,omitempty"`
|
||||
}{Grants: grants, CommandPrefixes: prefixes}); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, zeroSandbox.FormatGrantList(grants)); err != nil {
|
||||
if _, err := fmt.Fprintln(stdout, zeroSandbox.FormatGrantListWithCommandPrefixes(grants, prefixes)); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
@@ -282,7 +393,7 @@ func runSandboxGrantSet(command string, args []string, stdout io.Writer, stderr
|
||||
return exitSuccess
|
||||
}
|
||||
if len(positional) != 1 {
|
||||
return writeExecUsageError(stderr, "usage: zero sandbox grants "+command+" <tool> [--path file] [--auto low|medium|high] [--reason text] [--json]")
|
||||
return writeExecUsageError(stderr, "usage: zero sandbox grants "+command+" <tool> [--path file] [--reason text] [--json]")
|
||||
}
|
||||
decision := zeroSandbox.GrantAllow
|
||||
if command == "deny" {
|
||||
@@ -293,10 +404,9 @@ func runSandboxGrantSet(command string, args []string, stdout io.Writer, stderr
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
input := zeroSandbox.GrantInput{
|
||||
ToolName: positional[0],
|
||||
Decision: decision,
|
||||
MaxAutonomy: zeroSandbox.Autonomy(options.autonomy),
|
||||
Reason: options.reason,
|
||||
ToolName: positional[0],
|
||||
Decision: decision,
|
||||
Reason: options.reason,
|
||||
}
|
||||
if options.path != "" {
|
||||
// --path persists an exact-file grant. Resolve to an absolute path so it
|
||||
@@ -320,7 +430,7 @@ func runSandboxGrantSet(command string, args []string, stdout io.Writer, stderr
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintf(stdout, "Sandbox grant saved: %s [%s/%s]\n", grant.ToolName, grant.Decision, grant.MaxAutonomy); err != nil {
|
||||
if _, err := fmt.Fprintf(stdout, "Sandbox grant saved: %s [%s]\n", grant.ToolName, grant.Decision); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
@@ -411,7 +521,7 @@ type sandboxCommandFlags struct {
|
||||
}
|
||||
|
||||
func parseSandboxCommandOptions(args []string, flags sandboxCommandFlags) (sandboxCommandOptions, bool, error) {
|
||||
options := sandboxCommandOptions{autonomy: string(zeroSandbox.AutonomyLow)}
|
||||
options := sandboxCommandOptions{}
|
||||
for index := 0; index < len(args); index++ {
|
||||
arg := args[index]
|
||||
switch {
|
||||
@@ -433,7 +543,7 @@ func parseSandboxCommandOptions(args []string, flags sandboxCommandFlags) (sandb
|
||||
}
|
||||
|
||||
func parseSandboxPositionalOptions(args []string) (sandboxCommandOptions, []string, bool, error) {
|
||||
options := sandboxCommandOptions{autonomy: string(zeroSandbox.AutonomyLow)}
|
||||
options := sandboxCommandOptions{}
|
||||
positional := []string{}
|
||||
for index := 0; index < len(args); index++ {
|
||||
arg := args[index]
|
||||
@@ -442,15 +552,6 @@ func parseSandboxPositionalOptions(args []string) (sandboxCommandOptions, []stri
|
||||
return options, positional, true, nil
|
||||
case arg == "--json":
|
||||
options.json = true
|
||||
case arg == "--auto":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, positional, false, err
|
||||
}
|
||||
options.autonomy = value
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--auto="):
|
||||
options.autonomy = strings.TrimSpace(strings.TrimPrefix(arg, "--auto="))
|
||||
case arg == "--reason":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
@@ -485,9 +586,6 @@ func parseSandboxPositionalOptions(args []string) (sandboxCommandOptions, []stri
|
||||
positional = append(positional, strings.TrimSpace(arg))
|
||||
}
|
||||
}
|
||||
if _, err := zeroSandbox.NormalizeAutonomy(zeroSandbox.Autonomy(options.autonomy)); err != nil {
|
||||
return options, positional, false, execUsageError{err.Error()}
|
||||
}
|
||||
return options, positional, false, nil
|
||||
}
|
||||
|
||||
@@ -497,9 +595,15 @@ func formatSandboxPolicy(workspaceRoot string, policy zeroSandbox.Policy, backen
|
||||
"root: " + workspaceRoot,
|
||||
"mode: " + string(policy.Mode),
|
||||
"network: " + string(policy.Network),
|
||||
"max_autonomy: " + string(policy.MaxAutonomy),
|
||||
"backend: " + string(backend.Name),
|
||||
"target_backend: " + string(plan.TargetBackend),
|
||||
"support_level: " + string(plan.SupportLevel),
|
||||
"command_wrapped: " + fmt.Sprintf("%t", plan.CommandWrapped),
|
||||
"enforcement_level: " + string(plan.EnforcementLevel),
|
||||
"requires_platform_sandbox: " + fmt.Sprintf("%t", plan.RequiresPlatformSandbox),
|
||||
}
|
||||
if plan.DowngradeReason != "" {
|
||||
lines = append(lines, "downgrade_reason: "+plan.DowngradeReason)
|
||||
}
|
||||
if backend.Platform != "" {
|
||||
lines = append(lines, "backend_platform: "+backend.Platform)
|
||||
@@ -526,6 +630,8 @@ func writeSandboxHelp(w io.Writer) error {
|
||||
|
||||
Commands:
|
||||
policy Inspect active sandbox policy and platform backend
|
||||
setup Run native platform sandbox setup
|
||||
check Evaluate the sandbox decision for a hypothetical tool action
|
||||
grants Manage persistent sandbox grants
|
||||
|
||||
`)
|
||||
@@ -544,14 +650,27 @@ Flags:
|
||||
return err
|
||||
}
|
||||
|
||||
func writeSandboxSetupHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero sandbox setup [flags]
|
||||
|
||||
Runs native platform sandbox setup when the selected backend requires it.
|
||||
|
||||
Flags:
|
||||
--json Print JSON output
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeSandboxGrantsHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero sandbox grants <command>
|
||||
|
||||
Commands:
|
||||
list List persistent sandbox grants
|
||||
allow Persistently allow a tool up to an autonomy level
|
||||
deny Persistently deny a tool up to an autonomy level
|
||||
allow Persistently allow a tool
|
||||
deny Persistently deny a tool
|
||||
revoke Revoke a tool grant
|
||||
clear Clear all sandbox grants
|
||||
`)
|
||||
@@ -564,7 +683,6 @@ func writeSandboxGrantSetHelp(w io.Writer) error {
|
||||
zero sandbox grants deny <tool> [flags]
|
||||
|
||||
Flags:
|
||||
--auto <level> Maximum autonomy covered by the grant
|
||||
--reason <text> Human-readable reason for the grant
|
||||
--path <path> Scope the grant to one exact file (default: tool-wide)
|
||||
--json Print JSON output
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/zerocommands"
|
||||
)
|
||||
|
||||
type sandboxCheckOptions struct {
|
||||
tool string
|
||||
sideEffect string
|
||||
path string
|
||||
reason string
|
||||
json bool
|
||||
}
|
||||
|
||||
// sandboxCheckReport is the combined snapshot `zero sandbox check` emits: the
|
||||
// active plan (policy + backend + restrictions), the decision the engine would
|
||||
// make for the described tool action, and any persistent grant that matches the
|
||||
// tool. It is the production consumer of the zerocommands sandbox-snapshot
|
||||
// contract, giving operators and CI a stable, redacted JSON view of "what would
|
||||
// the sandbox do for this action?".
|
||||
type sandboxCheckReport struct {
|
||||
Tool string `json:"tool"`
|
||||
Plan zerocommands.SandboxPlanSnapshot `json:"plan"`
|
||||
Decision zerocommands.SandboxDecisionSnapshot `json:"decision"`
|
||||
Grant zerocommands.SandboxGrantMatchSnapshot `json:"grant"`
|
||||
}
|
||||
|
||||
func runSandboxCheck(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseSandboxCheckArgs(args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeSandboxCheckHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if strings.TrimSpace(options.tool) == "" {
|
||||
return writeExecUsageError(stderr, "tool name is required: zero sandbox check <tool> [flags]")
|
||||
}
|
||||
|
||||
workspaceRoot, err := resolveWorkspaceRoot("", deps)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
store, err := deps.newSandboxStore()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitProvider)
|
||||
}
|
||||
policy := applyConfiguredSandboxPolicy(zeroSandbox.DefaultPolicy(), resolved.Sandbox)
|
||||
backend := deps.selectSandboxBackend(zeroSandbox.BackendOptions{})
|
||||
plan := backend.BuildPlan(workspaceRoot, policy)
|
||||
|
||||
sideEffect, err := parseSandboxCheckSideEffect(options.sideEffect)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
requestArgs := map[string]any{}
|
||||
scopeAbs := workspaceRoot
|
||||
if path := strings.TrimSpace(options.path); path != "" {
|
||||
requestArgs["path"] = path
|
||||
if filepath.IsAbs(path) {
|
||||
scopeAbs = filepath.Clean(path)
|
||||
} else {
|
||||
scopeAbs = filepath.Join(workspaceRoot, path)
|
||||
}
|
||||
}
|
||||
|
||||
// Build the engine with the SAME write scope the real entrypoints use
|
||||
// (workspace root + configured additional write roots) so the check's
|
||||
// decision matches actual enforcement and a stale extra-root config surfaces
|
||||
// here too, instead of silently falling back to a workspace-only scope.
|
||||
scope, err := zeroSandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitProvider)
|
||||
}
|
||||
engine := zeroSandbox.NewEngine(zeroSandbox.EngineOptions{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Policy: policy,
|
||||
Store: store,
|
||||
Backend: backend,
|
||||
Scope: scope,
|
||||
})
|
||||
ctx, stop := signalContext()
|
||||
defer stop()
|
||||
decision := engine.Evaluate(ctx, zeroSandbox.Request{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
ToolName: options.tool,
|
||||
SideEffect: sideEffect,
|
||||
PermissionMode: zeroSandbox.PermissionModeAsk,
|
||||
Args: requestArgs,
|
||||
Reason: strings.TrimSpace(options.reason),
|
||||
})
|
||||
|
||||
lookup, err := store.Lookup(options.tool, scopeAbs)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
|
||||
report := sandboxCheckReport{
|
||||
Tool: strings.TrimSpace(options.tool),
|
||||
Plan: zerocommands.SandboxPlanSnapshotFromPlan(plan),
|
||||
Decision: zerocommands.SandboxDecisionSnapshotFromDecision(decision),
|
||||
Grant: zerocommands.SandboxGrantMatchSnapshotFromLookup(options.tool, lookup),
|
||||
}
|
||||
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, report); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, formatSandboxCheckReport(report)); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func parseSandboxCheckArgs(args []string) (sandboxCheckOptions, bool, error) {
|
||||
options := sandboxCheckOptions{}
|
||||
for index := 0; index < len(args); index++ {
|
||||
arg := args[index]
|
||||
switch {
|
||||
case arg == "-h" || arg == "--help" || arg == "help":
|
||||
return options, true, nil
|
||||
case arg == "--json":
|
||||
options.json = true
|
||||
case arg == "--side-effect" || arg == "--path" || arg == "--reason":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
assignSandboxCheckFlag(&options, arg, value)
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--side-effect="):
|
||||
value, err := requiredInlineFlagValue(arg, "--side-effect")
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.sideEffect = value
|
||||
case strings.HasPrefix(arg, "--path="):
|
||||
value, err := requiredInlineFlagValue(arg, "--path")
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.path = value
|
||||
case strings.HasPrefix(arg, "--reason="):
|
||||
value, err := requiredInlineFlagValue(arg, "--reason")
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.reason = value
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
|
||||
default:
|
||||
if options.tool != "" {
|
||||
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
|
||||
}
|
||||
options.tool = arg
|
||||
}
|
||||
}
|
||||
return options, false, nil
|
||||
}
|
||||
|
||||
func assignSandboxCheckFlag(options *sandboxCheckOptions, flag string, value string) {
|
||||
switch flag {
|
||||
case "--side-effect":
|
||||
options.sideEffect = value
|
||||
case "--path":
|
||||
options.path = value
|
||||
case "--reason":
|
||||
options.reason = value
|
||||
}
|
||||
}
|
||||
|
||||
// parseSandboxCheckSideEffect validates --side-effect against the closed set the
|
||||
// help advertises (empty defaults to read), so a typo returns a usage error
|
||||
// instead of being passed to the engine as an unknown side effect.
|
||||
func parseSandboxCheckSideEffect(value string) (zeroSandbox.SideEffect, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "read":
|
||||
return zeroSandbox.SideEffectRead, nil
|
||||
case "write":
|
||||
return zeroSandbox.SideEffectWrite, nil
|
||||
case "shell":
|
||||
return zeroSandbox.SideEffectShell, nil
|
||||
case "network":
|
||||
return zeroSandbox.SideEffectNetwork, nil
|
||||
case "none":
|
||||
return zeroSandbox.SideEffectNone, nil
|
||||
default:
|
||||
return "", execUsageError{fmt.Sprintf("invalid --side-effect %q: expected read, write, shell, network, or none", value)}
|
||||
}
|
||||
}
|
||||
|
||||
func formatSandboxCheckReport(report sandboxCheckReport) string {
|
||||
lines := []string{
|
||||
"Sandbox check: " + report.Tool,
|
||||
"decision: " + report.Decision.Action,
|
||||
}
|
||||
if reason := strings.TrimSpace(report.Decision.Reason); reason != "" {
|
||||
lines = append(lines, "reason: "+reason)
|
||||
}
|
||||
risk := report.Decision.Risk
|
||||
riskLine := "risk: " + risk.Level
|
||||
if len(risk.Categories) > 0 {
|
||||
riskLine += " [" + strings.Join(risk.Categories, ", ") + "]"
|
||||
}
|
||||
lines = append(lines, riskLine)
|
||||
if block := report.Decision.Block; block != nil {
|
||||
line := "block: [" + block.Code + "] " + block.Reason
|
||||
if block.Path != "" {
|
||||
line += " (path: " + block.Path + ")"
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
if report.Grant.Matched && report.Grant.Grant != nil {
|
||||
lines = append(lines, "grant: matched "+report.Grant.Grant.Decision)
|
||||
} else {
|
||||
lines = append(lines, "grant: no grant matched this action")
|
||||
}
|
||||
lines = append(lines,
|
||||
"policy: mode="+report.Plan.Policy.EffectiveMode+" network="+report.Plan.Policy.Network+fmt.Sprintf(" enforceWorkspace=%t", report.Plan.Policy.EnforceWorkspace),
|
||||
"backend: "+report.Plan.Backend.Name+fmt.Sprintf(" (available=%t)", report.Plan.Backend.Available),
|
||||
)
|
||||
if len(report.Plan.Restrictions) > 0 {
|
||||
lines = append(lines, "restrictions:")
|
||||
for _, restriction := range report.Plan.Restrictions {
|
||||
lines = append(lines, " - "+restriction)
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func writeSandboxCheckHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero sandbox check <tool> [flags]
|
||||
|
||||
Evaluates the sandbox decision for a hypothetical tool action against the
|
||||
resolved policy, and prints the plan, the decision (allow/prompt/deny with risk
|
||||
and any block), and any persistent grant that matches the tool. Secrets in
|
||||
grant reasons are redacted.
|
||||
|
||||
Flags:
|
||||
--side-effect <kind> read | write | shell | network | none (default read)
|
||||
--path <path> Target path for the action (read/write checks)
|
||||
--reason <text> Reason recorded with the request
|
||||
--json Print the machine-readable snapshot
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func sandboxCheckDeps(t *testing.T) (appDeps, string) {
|
||||
t.Helper()
|
||||
store := newSandboxTestStore(t)
|
||||
root := t.TempDir()
|
||||
return appDeps{
|
||||
getwd: func() (string, error) { return root, nil },
|
||||
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{}, nil
|
||||
},
|
||||
selectSandboxBackend: func(sandbox.BackendOptions) sandbox.Backend {
|
||||
return sandbox.Backend{Name: sandbox.BackendUnavailable, Platform: "windows", Fallback: true}
|
||||
},
|
||||
}, root
|
||||
}
|
||||
|
||||
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")
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"sandbox", "check", "write_file", "--side-effect", "write", "--path", outside, "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("check exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Tool string `json:"tool"`
|
||||
Plan struct {
|
||||
Policy struct {
|
||||
EffectiveMode string `json:"effectiveMode"`
|
||||
} `json:"policy"`
|
||||
Backend struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"backend"`
|
||||
} `json:"plan"`
|
||||
Decision struct {
|
||||
Action string `json:"action"`
|
||||
Risk struct {
|
||||
Level string `json:"level"`
|
||||
} `json:"risk"`
|
||||
Block *struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"block"`
|
||||
} `json:"decision"`
|
||||
Grant struct {
|
||||
ToolName string `json:"toolName"`
|
||||
Matched bool `json:"matched"`
|
||||
} `json:"grant"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode check JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.Tool != "write_file" {
|
||||
t.Fatalf("tool = %q", payload.Tool)
|
||||
}
|
||||
if payload.Plan.Policy.EffectiveMode != string(sandbox.ModeEnforce) {
|
||||
t.Fatalf("effectiveMode = %q, want enforce", payload.Plan.Policy.EffectiveMode)
|
||||
}
|
||||
if payload.Plan.Backend.Name != string(sandbox.BackendUnavailable) {
|
||||
t.Fatalf("backend = %q", payload.Plan.Backend.Name)
|
||||
}
|
||||
if payload.Decision.Action != string(sandbox.ActionPrompt) {
|
||||
t.Fatalf("expected prompt for out-of-workspace write, got %q\n%s", payload.Decision.Action, stdout.String())
|
||||
}
|
||||
if payload.Decision.Block == nil {
|
||||
t.Fatalf("expected a block for the out-of-workspace write")
|
||||
}
|
||||
if payload.Grant.ToolName != "write_file" || payload.Grant.Matched {
|
||||
t.Fatalf("expected an unmatched grant for write_file, got %#v", payload.Grant)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSandboxCheckTextRendersDecisionAndPlan(t *testing.T) {
|
||||
deps, _ := sandboxCheckDeps(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"sandbox", "check", "read_file", "--side-effect", "read", "--path", "notes.txt"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("check exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"Sandbox check: read_file",
|
||||
"decision: ",
|
||||
"risk: ",
|
||||
"policy: mode=enforce",
|
||||
"backend: unavailable",
|
||||
"grant: no grant matched this action",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("check text missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSandboxCheckRequiresTool(t *testing.T) {
|
||||
deps, _ := sandboxCheckDeps(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"sandbox", "check"}, &stdout, &stderr, deps)
|
||||
if exitCode == exitSuccess {
|
||||
t.Fatalf("expected a usage error when no tool is given, got success: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "tool name is required") {
|
||||
t.Fatalf("expected 'tool name is required', got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSandboxCheckRejectsInvalidFlags(t *testing.T) {
|
||||
deps, _ := sandboxCheckDeps(t)
|
||||
for _, args := range [][]string{
|
||||
{"sandbox", "check", "read_file", "--side-effect", "wrtie"},
|
||||
} {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps(args, &stdout, &stderr, deps); code == exitSuccess {
|
||||
t.Fatalf("args %v: expected a usage error, got success: %s", args, stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "invalid --") {
|
||||
t.Fatalf("args %v: expected an 'invalid --…' usage error, got %q", args, stderr.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSandboxCheckMatchedGrantRedactsReason(t *testing.T) {
|
||||
store := newSandboxTestStore(t)
|
||||
root := t.TempDir()
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return root, nil },
|
||||
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{}, nil
|
||||
},
|
||||
selectSandboxBackend: func(sandbox.BackendOptions) sandbox.Backend {
|
||||
return sandbox.Backend{Name: sandbox.BackendUnavailable}
|
||||
},
|
||||
}
|
||||
// Seed a tool-wide allow grant whose reason embeds a secret; the matched-grant
|
||||
// snapshot must redact it.
|
||||
if _, err := store.Grant(sandbox.GrantInput{
|
||||
ToolName: "read_file",
|
||||
Decision: sandbox.GrantAllow,
|
||||
Reason: "approved with sk-test-secret1234567890",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed grant: %v", err)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"sandbox", "check", "read_file", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("check exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), "sk-test-secret1234567890") {
|
||||
t.Fatalf("grant reason leaked a secret into the snapshot:\n%s", stdout.String())
|
||||
}
|
||||
var payload struct {
|
||||
Grant struct {
|
||||
Matched bool `json:"matched"`
|
||||
Grant *struct {
|
||||
Reason string `json:"reason"`
|
||||
} `json:"grant"`
|
||||
} `json:"grant"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode check JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if !payload.Grant.Matched || payload.Grant.Grant == nil {
|
||||
t.Fatalf("expected a matched grant, got %#v", payload.Grant)
|
||||
}
|
||||
if !strings.Contains(payload.Grant.Grant.Reason, "REDACTED") {
|
||||
t.Fatalf("expected the reason to be redacted, got %q", payload.Grant.Grant.Reason)
|
||||
}
|
||||
}
|
||||
+300
-118
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
@@ -20,7 +22,7 @@ func TestRunSandboxGrantsAllowListDenyRevokeAndClear(t *testing.T) {
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"sandbox", "grants", "allow", "write_file", "--auto", "medium", "--reason", "workspace edits", "--json"}, &stdout, &stderr, deps)
|
||||
exitCode := runWithDeps([]string{"sandbox", "grants", "allow", "write_file", "--reason", "workspace edits", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("allow exit = %d, stderr %q", exitCode, stderr.String())
|
||||
}
|
||||
@@ -30,13 +32,13 @@ func TestRunSandboxGrantsAllowListDenyRevokeAndClear(t *testing.T) {
|
||||
if err := json.Unmarshal(stdout.Bytes(), &allowPayload); err != nil {
|
||||
t.Fatalf("decode allow JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if allowPayload.Grant.ToolName != "write_file" || allowPayload.Grant.Decision != sandbox.GrantAllow || allowPayload.Grant.MaxAutonomy != sandbox.AutonomyMedium {
|
||||
if allowPayload.Grant.ToolName != "write_file" || allowPayload.Grant.Decision != sandbox.GrantAllow {
|
||||
t.Fatalf("unexpected allow payload: %#v", allowPayload)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
exitCode = runWithDeps([]string{"sandbox", "grants", "deny", "bash", "--auto=high", "--reason=network blocked"}, &stdout, &stderr, deps)
|
||||
exitCode = runWithDeps([]string{"sandbox", "grants", "deny", "bash", "--reason=network blocked"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("deny exit = %d, stderr %q", exitCode, stderr.String())
|
||||
}
|
||||
@@ -103,6 +105,41 @@ func TestRunSandboxGrantsAllowListDenyRevokeAndClear(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSandboxGrantsListIncludesCommandPrefixes(t *testing.T) {
|
||||
store := newSandboxTestStore(t)
|
||||
if _, err := store.GrantCommandPrefix(sandbox.CommandPrefixInput{ToolName: "bash", Prefix: []string{"git", "status"}, Reason: "status checks"}); err != nil {
|
||||
t.Fatalf("GrantCommandPrefix: %v", err)
|
||||
}
|
||||
deps := appDeps{newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil }}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"sandbox", "grants", "list", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("list json exit = %d, stderr %q", exitCode, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
CommandPrefixes []sandbox.CommandPrefixGrant `json:"commandPrefixes"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode list JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(payload.CommandPrefixes) != 1 || !reflect.DeepEqual(payload.CommandPrefixes[0].Prefix, []string{"git", "status"}) {
|
||||
t.Fatalf("unexpected commandPrefixes payload: %#v", payload.CommandPrefixes)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
exitCode = runWithDeps([]string{"sandbox", "grants", "list"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("list text exit = %d, stderr %q", exitCode, stderr.String())
|
||||
}
|
||||
for _, want := range []string{"bash", "`git status`", "command-prefix", "status checks"} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("list text = %q, missing %q", stdout.String(), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSandboxGrantsCreateAndRevokeByPath(t *testing.T) {
|
||||
store := newSandboxTestStore(t)
|
||||
deps := appDeps{newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil }}
|
||||
@@ -160,7 +197,7 @@ func TestRunSandboxGrantsRejectsEmptyPath(t *testing.T) {
|
||||
// Seed a tool-wide grant first so a buggy "revoke all for tool" or "allow
|
||||
// tool-wide" from a rejected call would actually change the store and be caught
|
||||
// (a revoke-all on an empty store is a silent no-op).
|
||||
if _, err := store.Grant(sandbox.GrantInput{ToolName: "write_file", Decision: sandbox.GrantAllow, MaxAutonomy: sandbox.AutonomyHigh}); err != nil {
|
||||
if _, err := store.Grant(sandbox.GrantInput{ToolName: "write_file", Decision: sandbox.GrantAllow}); err != nil {
|
||||
t.Fatalf("seed grant: %v", err)
|
||||
}
|
||||
|
||||
@@ -215,10 +252,10 @@ func TestRunSandboxPolicyInspectTextAndJSON(t *testing.T) {
|
||||
},
|
||||
selectSandboxBackend: func(options sandbox.BackendOptions) sandbox.Backend {
|
||||
return sandbox.Backend{
|
||||
Name: sandbox.BackendPolicyOnly,
|
||||
Name: sandbox.BackendUnavailable,
|
||||
Platform: "windows",
|
||||
Fallback: true,
|
||||
Message: "policy-only fallback: Windows native sandbox adapter is not implemented",
|
||||
Message: "Windows sandbox command runner is not available",
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -242,24 +279,31 @@ func TestRunSandboxPolicyInspectTextAndJSON(t *testing.T) {
|
||||
Policy sandbox.Policy `json:"policy"`
|
||||
Backend sandbox.Backend `json:"backend"`
|
||||
Plan struct {
|
||||
SupportLevel string `json:"supportLevel"`
|
||||
Capabilities []sandbox.BackendCapability `json:"capabilities"`
|
||||
Restrictions []string `json:"restrictions"`
|
||||
Warnings []string `json:"warnings"`
|
||||
TargetBackend sandbox.BackendName `json:"targetBackend"`
|
||||
CommandWrapped bool `json:"commandWrapped"`
|
||||
EnforcementLevel sandbox.EnforcementLevel `json:"enforcementLevel"`
|
||||
DowngradeReason string `json:"downgradeReason"`
|
||||
SupportLevel string `json:"supportLevel"`
|
||||
Capabilities []sandbox.BackendCapability `json:"capabilities"`
|
||||
Restrictions []string `json:"restrictions"`
|
||||
Warnings []string `json:"warnings"`
|
||||
} `json:"plan"`
|
||||
Grants string `json:"grantsPath"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode policy JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.Policy.Mode != sandbox.ModeEnforce || payload.Backend.Name != sandbox.BackendPolicyOnly || payload.Grants == "" {
|
||||
if payload.Policy.Mode != sandbox.ModeEnforce || payload.Backend.Name != sandbox.BackendUnavailable || payload.Grants == "" {
|
||||
t.Fatalf("unexpected policy JSON: %#v", payload)
|
||||
}
|
||||
if payload.Backend.Platform != "windows" || !payload.Backend.Fallback || payload.Backend.NativeIsolation || payload.Backend.CommandWrapping {
|
||||
t.Fatalf("unexpected backend capability JSON: %#v", payload.Backend)
|
||||
}
|
||||
if payload.Plan.SupportLevel != string(sandbox.BackendSupportPolicyOnly) {
|
||||
t.Fatalf("support level = %q, want policy-only", payload.Plan.SupportLevel)
|
||||
if payload.Plan.SupportLevel != string(sandbox.BackendSupportUnavailable) {
|
||||
t.Fatalf("support level = %q, want unavailable", payload.Plan.SupportLevel)
|
||||
}
|
||||
if payload.Plan.TargetBackend != sandbox.BackendWindowsRestrictedToken || payload.Plan.CommandWrapped || payload.Plan.EnforcementLevel != sandbox.EnforcementDegraded || payload.Plan.DowngradeReason == "" {
|
||||
t.Fatalf("unexpected manager baseline fields: %#v", payload.Plan)
|
||||
}
|
||||
if sandboxPolicyCapabilityStatus(payload.Plan.Capabilities, "native_process_isolation") != sandbox.CapabilityUnavailable {
|
||||
t.Fatalf("expected native isolation unavailable, got %#v", payload.Plan.Capabilities)
|
||||
@@ -267,20 +311,24 @@ func TestRunSandboxPolicyInspectTextAndJSON(t *testing.T) {
|
||||
if !sandboxPolicyRestrictionContains(payload.Plan.Restrictions, "native process isolation unavailable on windows") {
|
||||
t.Fatalf("expected JSON plan to document Windows fallback, got %#v", payload.Plan.Restrictions)
|
||||
}
|
||||
if !sandboxPolicyRestrictionContains(payload.Plan.Warnings, "Windows native sandbox adapter is not implemented") {
|
||||
if !sandboxPolicyRestrictionContains(payload.Plan.Warnings, "Windows sandbox command runner is not available") {
|
||||
t.Fatalf("expected JSON warnings to document Windows fallback, got %#v", payload.Plan.Warnings)
|
||||
}
|
||||
} else {
|
||||
output := stdout.String()
|
||||
for _, want := range []string{
|
||||
"Zero sandbox policy",
|
||||
"backend: policy-only",
|
||||
"support_level: policy-only",
|
||||
"backend: unavailable",
|
||||
"target_backend: windows-restricted-token",
|
||||
"support_level: unavailable",
|
||||
"command_wrapped: false",
|
||||
"enforcement_level: degraded",
|
||||
"downgrade_reason: Windows sandbox command runner is not available",
|
||||
"backend_fallback: true",
|
||||
"backend_command_wrapping: false",
|
||||
"backend_native_isolation: false",
|
||||
"backend_platform: windows",
|
||||
"Windows native sandbox adapter is not implemented",
|
||||
"Windows sandbox command runner is not available",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("expected policy text to contain %q, got %q", want, output)
|
||||
@@ -291,6 +339,213 @@ func TestRunSandboxPolicyInspectTextAndJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHiddenWindowsSandboxSubcommandsSelfDispatch(t *testing.T) {
|
||||
// The runner/setup sentinels are routed before normal CLI parsing, straight
|
||||
// into the sandbox helper mains — never the unknown-command path. With no
|
||||
// helper-specific flags they fail their own arg validation (non-zero), but
|
||||
// crucially the message is the helper's, proving the dispatch.
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
arg string
|
||||
want string
|
||||
}{
|
||||
{"runner", sandbox.WindowsCommandRunnerSubcommand, sandbox.WindowsSandboxCommandRunnerName},
|
||||
{"setup", sandbox.WindowsSandboxSetupSubcommand, sandbox.WindowsSandboxSetupName},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
exit := runWithDeps([]string{tc.arg}, &stdout, &stderr, appDeps{})
|
||||
if exit == 0 {
|
||||
t.Fatalf("expected non-zero exit from the helper's own arg validation, got 0")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), tc.want) {
|
||||
t.Fatalf("stderr = %q, want the %s helper's message (proves self-dispatch)", stderr.String(), tc.want)
|
||||
}
|
||||
if strings.Contains(stderr.String(), "unknown command") {
|
||||
t.Fatalf("sentinel %q fell through to normal CLI dispatch: %q", tc.arg, stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSandboxSetupRunsWindowsHelper(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
runnerDir := t.TempDir()
|
||||
runnerPath := filepath.Join(runnerDir, sandbox.WindowsSandboxCommandRunnerName)
|
||||
var gotPath string
|
||||
var gotArgs []string
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return workspace, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{
|
||||
Sandbox: config.SandboxConfig{Network: "allow"},
|
||||
}, nil
|
||||
},
|
||||
selectSandboxBackend: func(options sandbox.BackendOptions) sandbox.Backend {
|
||||
return sandbox.Backend{
|
||||
Name: sandbox.BackendWindowsRestrictedToken,
|
||||
Available: true,
|
||||
Platform: "windows",
|
||||
Executable: runnerPath,
|
||||
CommandWrapping: true,
|
||||
NativeIsolation: true,
|
||||
}
|
||||
},
|
||||
runSandboxSetupHelper: func(path string, args []string, stdout io.Writer, stderr io.Writer) error {
|
||||
gotPath = path
|
||||
gotArgs = append([]string{}, args...)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"sandbox", "setup", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("setup exit = %d, stderr %q", exitCode, stderr.String())
|
||||
}
|
||||
// The setup helper is now resolved independently of backend.Executable: an
|
||||
// adjacent zero-windows-sandbox-setup.exe in release, else self-dispatch via
|
||||
// the running binary. Under `go test` no sibling helper exists, so it
|
||||
// self-dispatches: path is the running binary and the first arg is the hidden
|
||||
// setup subcommand, followed by the real setup args.
|
||||
wantPath, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Executable: %v", err)
|
||||
}
|
||||
if gotPath != wantPath {
|
||||
t.Fatalf("setup helper path = %q, want self-dispatch binary %q", gotPath, wantPath)
|
||||
}
|
||||
if len(gotArgs) == 0 || gotArgs[0] != sandbox.WindowsSandboxSetupSubcommand {
|
||||
t.Fatalf("setup args = %#v, want leading %q subcommand", gotArgs, sandbox.WindowsSandboxSetupSubcommand)
|
||||
}
|
||||
config, err := sandbox.ParseWindowsSandboxSetupArgs(gotArgs[1:])
|
||||
if err != nil {
|
||||
t.Fatalf("ParseWindowsSandboxSetupArgs: %v", err)
|
||||
}
|
||||
if config.CommandCWD != workspace || len(config.WorkspaceRoots) != 1 || config.WorkspaceRoots[0] != workspace {
|
||||
t.Fatalf("setup args config = %#v, want workspace cwd/root", config)
|
||||
}
|
||||
if config.PermissionProfile.Network.Mode != sandbox.NetworkAllow {
|
||||
t.Fatalf("setup network profile = %#v, want allow", config.PermissionProfile.Network)
|
||||
}
|
||||
var payload sandboxSetupResult
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode setup JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if !payload.Ran || payload.Helper != wantPath || payload.Backend != sandbox.BackendWindowsRestrictedToken {
|
||||
t.Fatalf("setup payload = %#v, want ran Windows helper", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSandboxSetupNoopsForNonWindowsBackend(t *testing.T) {
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return t.TempDir(), nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{}, nil
|
||||
},
|
||||
selectSandboxBackend: func(options sandbox.BackendOptions) sandbox.Backend {
|
||||
return sandbox.Backend{Name: sandbox.BackendLinuxBwrap, Platform: "linux", Available: true}
|
||||
},
|
||||
runSandboxSetupHelper: func(string, []string, io.Writer, io.Writer) error {
|
||||
t.Fatal("setup helper should not run for non-Windows backend")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"sandbox", "setup"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("setup exit = %d, stderr %q", exitCode, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "No native sandbox setup is required for linux") {
|
||||
t.Fatalf("setup output = %q, want non-Windows no-op message", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUISandboxSetupCommandGatedToWindowsNativeBackend(t *testing.T) {
|
||||
if got := tuiSandboxSetupCommand(sandbox.Backend{Name: sandbox.BackendLinuxBwrap, Platform: "linux", Available: true}, appDeps{}); got != nil {
|
||||
t.Fatal("linux backend should not enable the TUI sandbox setup command")
|
||||
}
|
||||
if got := tuiSandboxSetupCommand(sandbox.Backend{Name: sandbox.BackendUnavailable, Platform: "windows", Available: false}, appDeps{}); got != nil {
|
||||
t.Fatal("unavailable Windows backend should not enable the TUI sandbox setup command")
|
||||
}
|
||||
got := tuiSandboxSetupCommand(sandbox.Backend{
|
||||
Name: sandbox.BackendWindowsRestrictedToken,
|
||||
Platform: "windows",
|
||||
Available: true,
|
||||
Executable: filepath.Join(t.TempDir(), sandbox.WindowsSandboxCommandRunnerName),
|
||||
CommandWrapping: true,
|
||||
NativeIsolation: true,
|
||||
}, appDeps{})
|
||||
if got == nil {
|
||||
t.Fatal("native Windows backend should enable the TUI sandbox setup command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) {
|
||||
store := newSandboxTestStore(t)
|
||||
workspace := t.TempDir()
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return workspace, nil },
|
||||
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{}, nil
|
||||
},
|
||||
selectSandboxBackend: func(options sandbox.BackendOptions) sandbox.Backend {
|
||||
return sandbox.Backend{
|
||||
Name: sandbox.BackendUnavailable,
|
||||
Platform: "windows",
|
||||
Fallback: true,
|
||||
Message: "Windows sandbox command runner is not available",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"sandbox", "policy", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("policy exit = %d, stderr %q", exitCode, stderr.String())
|
||||
}
|
||||
|
||||
got := stdout.String()
|
||||
got = replacePathToken(got, workspace, "$WORKSPACE")
|
||||
got = replacePathToken(got, store.FilePath(), "$GRANTS")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonValuesEqual(t *testing.T, wantBytes []byte, gotBytes []byte) bool {
|
||||
t.Helper()
|
||||
var wantValue any
|
||||
if err := json.Unmarshal(wantBytes, &wantValue); err != nil {
|
||||
t.Fatalf("decode wanted JSON: %v", err)
|
||||
}
|
||||
var gotValue any
|
||||
if err := json.Unmarshal(gotBytes, &gotValue); err != nil {
|
||||
t.Fatalf("decode got JSON: %v\n%s", err, string(gotBytes))
|
||||
}
|
||||
return reflect.DeepEqual(wantValue, gotValue)
|
||||
}
|
||||
|
||||
func replacePathToken(value string, path string, token string) string {
|
||||
replace := func(value string, path string) string {
|
||||
value = strings.ReplaceAll(value, path, token)
|
||||
if encoded, err := json.Marshal(path); err == nil && len(encoded) >= 2 {
|
||||
value = strings.ReplaceAll(value, string(encoded[1:len(encoded)-1]), token)
|
||||
}
|
||||
return value
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(path); err == nil && resolved != path {
|
||||
value = replace(value, resolved)
|
||||
}
|
||||
return replace(value, path)
|
||||
}
|
||||
|
||||
func TestRunSandboxPolicyEffectiveTextAndJSON(t *testing.T) {
|
||||
store := newSandboxTestStore(t)
|
||||
deps := appDeps{
|
||||
@@ -301,10 +556,10 @@ func TestRunSandboxPolicyEffectiveTextAndJSON(t *testing.T) {
|
||||
},
|
||||
selectSandboxBackend: func(options sandbox.BackendOptions) sandbox.Backend {
|
||||
return sandbox.Backend{
|
||||
Name: sandbox.BackendPolicyOnly,
|
||||
Name: sandbox.BackendUnavailable,
|
||||
Platform: "darwin",
|
||||
Fallback: true,
|
||||
Message: "policy-only fallback",
|
||||
Message: "native sandbox unavailable",
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -321,9 +576,8 @@ func TestRunSandboxPolicyEffectiveTextAndJSON(t *testing.T) {
|
||||
"mode: enforce",
|
||||
"network: deny",
|
||||
"enforce_workspace: true",
|
||||
"deny_destructive_shell: true",
|
||||
"interactive_command_guard: enabled",
|
||||
"support_level: policy-only",
|
||||
"support_level: unavailable",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("effective text missing %q, got %q", want, output)
|
||||
@@ -339,10 +593,9 @@ func TestRunSandboxPolicyEffectiveTextAndJSON(t *testing.T) {
|
||||
}
|
||||
var payload struct {
|
||||
Policy struct {
|
||||
Mode string `json:"mode"`
|
||||
Network string `json:"network"`
|
||||
EnforceWorkspace bool `json:"enforceWorkspace"`
|
||||
DenyDestructiveShell bool `json:"denyDestructiveShell"`
|
||||
Mode string `json:"mode"`
|
||||
Network string `json:"network"`
|
||||
EnforceWorkspace bool `json:"enforceWorkspace"`
|
||||
} `json:"policy"`
|
||||
Backend struct {
|
||||
Name string `json:"name"`
|
||||
@@ -352,7 +605,6 @@ func TestRunSandboxPolicyEffectiveTextAndJSON(t *testing.T) {
|
||||
} `json:"plan"`
|
||||
Guards struct {
|
||||
InteractiveCommand bool `json:"interactiveCommand"`
|
||||
DestructiveShell bool `json:"destructiveShell"`
|
||||
Network bool `json:"network"`
|
||||
Workspace bool `json:"workspace"`
|
||||
} `json:"guards"`
|
||||
@@ -364,13 +616,13 @@ func TestRunSandboxPolicyEffectiveTextAndJSON(t *testing.T) {
|
||||
if payload.Policy.Mode != "enforce" || payload.Policy.Network != "deny" {
|
||||
t.Fatalf("unexpected effective policy: %#v", payload.Policy)
|
||||
}
|
||||
if !payload.Policy.EnforceWorkspace || !payload.Policy.DenyDestructiveShell {
|
||||
t.Fatalf("expected workspace + destructive guards enabled: %#v", payload.Policy)
|
||||
if !payload.Policy.EnforceWorkspace {
|
||||
t.Fatalf("expected workspace guard enabled: %#v", payload.Policy)
|
||||
}
|
||||
if !payload.Guards.InteractiveCommand || !payload.Guards.DestructiveShell {
|
||||
if !payload.Guards.InteractiveCommand {
|
||||
t.Fatalf("expected guards reported: %#v", payload.Guards)
|
||||
}
|
||||
if payload.Plan.SupportLevel != string(sandbox.BackendSupportPolicyOnly) || payload.GrantsPath == "" {
|
||||
if payload.Plan.SupportLevel != string(sandbox.BackendSupportUnavailable) || payload.GrantsPath == "" {
|
||||
t.Fatalf("unexpected effective plan/grants: %#v %q", payload.Plan, payload.GrantsPath)
|
||||
}
|
||||
})
|
||||
@@ -534,37 +786,12 @@ func TestRunSandboxHelpDoesNotOpenStore(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSandboxPolicyAppliesConfiguredCeiling(t *testing.T) {
|
||||
func TestRunSandboxPolicyTextOmitsMaxAutonomy(t *testing.T) {
|
||||
store := newSandboxTestStore(t)
|
||||
deps := appDeps{
|
||||
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{Sandbox: config.SandboxConfig{MaxAutonomy: "medium"}}, nil
|
||||
},
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"sandbox", "policy", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("policy exit = %d, stderr %q", exitCode, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Policy sandbox.Policy `json:"policy"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode policy JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.Policy.MaxAutonomy != sandbox.AutonomyMedium {
|
||||
t.Fatalf("policy.MaxAutonomy = %q, want medium", payload.Policy.MaxAutonomy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSandboxPolicyTextShowsMaxAutonomy(t *testing.T) {
|
||||
store := newSandboxTestStore(t)
|
||||
deps := appDeps{
|
||||
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{Sandbox: config.SandboxConfig{MaxAutonomy: "medium"}}, nil
|
||||
return config.ResolvedConfig{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -572,8 +799,8 @@ func TestRunSandboxPolicyTextShowsMaxAutonomy(t *testing.T) {
|
||||
if code := runWithDeps([]string{"sandbox", "policy"}, &stdout, &stderr, deps); code != exitSuccess {
|
||||
t.Fatalf("policy exit = %d, stderr %q", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "max_autonomy: medium") {
|
||||
t.Fatalf("policy text missing max_autonomy line:\n%s", stdout.String())
|
||||
if strings.Contains(stdout.String(), "max_autonomy") {
|
||||
t.Fatalf("policy text should omit max_autonomy:\n%s", stdout.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
@@ -581,8 +808,8 @@ func TestRunSandboxPolicyTextShowsMaxAutonomy(t *testing.T) {
|
||||
if code := runWithDeps([]string{"sandbox", "policy", "--effective"}, &stdout, &stderr, deps); code != exitSuccess {
|
||||
t.Fatalf("effective policy exit = %d, stderr %q", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "max_autonomy: medium") {
|
||||
t.Fatalf("effective policy text missing max_autonomy line:\n%s", stdout.String())
|
||||
if strings.Contains(stdout.String(), "max_autonomy") {
|
||||
t.Fatalf("effective policy text should omit max_autonomy:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -592,7 +819,7 @@ func TestRunSandboxPolicySurfacesResolveConfigError(t *testing.T) {
|
||||
getwd: func() (string, error) { return t.TempDir(), nil },
|
||||
newSandboxStore: func() (*sandbox.GrantStore, error) { return store, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{}, fmt.Errorf("invalid sandbox.maxAutonomy %q", "moderate")
|
||||
return config.ResolvedConfig{}, fmt.Errorf("invalid sandbox.network %q", "maybe")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -601,7 +828,7 @@ func TestRunSandboxPolicySurfacesResolveConfigError(t *testing.T) {
|
||||
if exitCode != exitProvider {
|
||||
t.Fatalf("policy exit = %d, want provider exit %d (resolve error surfaced, not silent DefaultPolicy fallback)", exitCode, exitProvider)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "invalid sandbox.maxAutonomy") {
|
||||
if !strings.Contains(stderr.String(), "invalid sandbox.network") {
|
||||
t.Fatalf("expected surfaced resolve error in stderr, got %q", stderr.String())
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
@@ -609,69 +836,24 @@ func TestRunSandboxPolicySurfacesResolveConfigError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConfiguredAutonomyCeiling(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
maxAutonomy string
|
||||
want sandbox.Autonomy
|
||||
}{
|
||||
// Empty is a no-op: the default High ceiling is preserved.
|
||||
{name: "empty keeps default high", maxAutonomy: "", want: sandbox.AutonomyHigh},
|
||||
{name: "whitespace keeps default high", maxAutonomy: " ", want: sandbox.AutonomyHigh},
|
||||
{name: "valid low", maxAutonomy: "low", want: sandbox.AutonomyLow},
|
||||
{name: "valid medium", maxAutonomy: "medium", want: sandbox.AutonomyMedium},
|
||||
{name: "valid high", maxAutonomy: "high", want: sandbox.AutonomyHigh},
|
||||
{name: "case-insensitive medium", maxAutonomy: "MEDIUM", want: sandbox.AutonomyMedium},
|
||||
// Fail-closed: an invalid non-empty value clamps to the most restrictive
|
||||
// ceiling instead of leaving the High default in place.
|
||||
{name: "invalid banana clamps to low", maxAutonomy: "banana", want: sandbox.AutonomyLow},
|
||||
{name: "invalid moderate clamps to low", maxAutonomy: "moderate", want: sandbox.AutonomyLow},
|
||||
{name: "invalid med clamps to low", maxAutonomy: "med", want: sandbox.AutonomyLow},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if base := sandbox.DefaultPolicy(); base.MaxAutonomy != sandbox.AutonomyHigh {
|
||||
t.Fatalf("precondition: DefaultPolicy().MaxAutonomy = %q, want high", base.MaxAutonomy)
|
||||
}
|
||||
policy := applyConfiguredAutonomyCeiling(sandbox.DefaultPolicy(), tc.maxAutonomy)
|
||||
if policy.MaxAutonomy != tc.want {
|
||||
t.Fatalf("applyConfiguredAutonomyCeiling(_, %q).MaxAutonomy = %q, want %q", tc.maxAutonomy, policy.MaxAutonomy, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConfiguredSandboxPolicyHardeningFlags(t *testing.T) {
|
||||
func TestApplyConfiguredSandboxPolicyDiagnosticsFlags(t *testing.T) {
|
||||
base := sandbox.DefaultPolicy()
|
||||
if base.BlockUnixSockets || base.MonitorDenials {
|
||||
t.Fatalf("precondition: hardening flags must default off, got block=%v monitor=%v", base.BlockUnixSockets, base.MonitorDenials)
|
||||
t.Fatalf("precondition: diagnostic flags must default off")
|
||||
}
|
||||
|
||||
// Omitted keys leave the (off) defaults untouched.
|
||||
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{}); got.BlockUnixSockets || got.MonitorDenials {
|
||||
t.Fatalf("empty config must not enable hardening flags, got block=%v monitor=%v", got.BlockUnixSockets, got.MonitorDenials)
|
||||
t.Fatalf("empty config must not enable diagnostic flags: %#v", got)
|
||||
}
|
||||
|
||||
// Each flag opts in independently.
|
||||
got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{BlockUnixSockets: true, MonitorDenials: true})
|
||||
if !got.BlockUnixSockets {
|
||||
t.Fatal("BlockUnixSockets config not applied to policy")
|
||||
}
|
||||
if !got.MonitorDenials {
|
||||
t.Fatal("MonitorDenials config not applied to policy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConfiguredSandboxPolicyHonorsAutoAllowBashEnv(t *testing.T) {
|
||||
// Unset/blank: the opt-in stays off so a sandboxed bash command still prompts.
|
||||
t.Setenv(sandbox.EnvAutoAllowBash, "")
|
||||
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{}); got.AutoAllowBashWhenSandboxed {
|
||||
t.Fatal("auto-allow-bash must be off when ZERO_SANDBOX_AUTO_ALLOW_BASH is unset")
|
||||
}
|
||||
// Truthy: the env opt-in is wired through the central policy builder.
|
||||
t.Setenv(sandbox.EnvAutoAllowBash, "true")
|
||||
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{}); !got.AutoAllowBashWhenSandboxed {
|
||||
t.Fatal("ZERO_SANDBOX_AUTO_ALLOW_BASH=true must enable AutoAllowBashWhenSandboxed")
|
||||
// The flags opt in.
|
||||
got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{
|
||||
BlockUnixSockets: true,
|
||||
MonitorDenials: true,
|
||||
})
|
||||
if !got.BlockUnixSockets || !got.MonitorDenials {
|
||||
t.Fatalf("diagnostic config not applied to policy: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-1
@@ -41,7 +41,7 @@ func runSetup(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) i
|
||||
return exitSuccess
|
||||
}
|
||||
if strings.TrimSpace(options.catalogID) == "" {
|
||||
return runInteractiveTUIWithSetup(stderr, deps, "", nil, true)
|
||||
return runInteractiveTUIWithSetup(stderr, deps, "", nil, "", true)
|
||||
}
|
||||
|
||||
result, err := saveSetupProvider(deps, tui.SetupSelection{
|
||||
@@ -111,6 +111,20 @@ func verifySetupProvider(deps appDeps, profile config.ProviderProfile) (setupVer
|
||||
if deps.probeProviderHealth == nil {
|
||||
return setupVerification{Ran: false, Summary: "probe unavailable; skipped"}, nil
|
||||
}
|
||||
// Distinguish "no key configured" from "key rejected": probing a remote provider
|
||||
// with no credential yields a generic "the provider rejected the API key", which
|
||||
// misleads a user who simply hasn't exported a key yet. Keyless local providers
|
||||
// (loopback base_url) legitimately need no key, so they still probe. A profile may
|
||||
// carry its key indirectly via APIKeyEnv (the setup result isn't env-resolved yet),
|
||||
// so treat a populated env var as having a credential. (AUDIT-M1)
|
||||
if !profileHasCredential(profile) && !baseURLIsLoopback(profile.BaseURL) {
|
||||
name := strings.TrimSpace(profile.Name)
|
||||
if name == "" {
|
||||
name = "this provider"
|
||||
}
|
||||
return setupVerification{Ran: true, OK: false, Summary: "no api key"},
|
||||
fmt.Errorf("no API key found for %s — set its API key (export the provider's API key env var, or pass --api-key-env) or run `zero auth`, then re-run setup", name)
|
||||
}
|
||||
ctx, stop := signalContext()
|
||||
defer stop()
|
||||
result := deps.probeProviderHealth(ctx, providerhealth.Options{
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/providerhealth"
|
||||
"github.com/Gitlawb/zero/internal/tui"
|
||||
)
|
||||
|
||||
@@ -219,3 +222,37 @@ func TestSaveSetupProviderCLIOptionsOverrideCustomEndpointSelection(t *testing.T
|
||||
t.Fatalf("stored provider = %#v, want CLI name/baseURL and selection model", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifySetupProviderDistinguishesMissingFromRejectedKey(t *testing.T) {
|
||||
// AUDIT-M1: verifying a remote provider with no key must say "no API key found",
|
||||
// not probe and report "the provider rejected the API key". The probe must not run.
|
||||
probed := false
|
||||
deps := appDeps{probeProviderHealth: func(context.Context, providerhealth.Options) providerhealth.Result {
|
||||
probed = true
|
||||
return providerhealth.Result{}
|
||||
}}
|
||||
_, err := verifySetupProvider(deps, config.ProviderProfile{
|
||||
Name: "openai",
|
||||
ProviderKind: config.ProviderKindOpenAI,
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
Model: "gpt-4.1",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "no API key found") {
|
||||
t.Fatalf("missing key should report 'no API key found', got %v", err)
|
||||
}
|
||||
if probed {
|
||||
t.Fatal("must not probe the endpoint when no key is configured")
|
||||
}
|
||||
|
||||
// A keyless LOCAL provider still probes (no key needed).
|
||||
probed = false
|
||||
_, _ = verifySetupProvider(deps, config.ProviderProfile{
|
||||
Name: "ollama",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "http://localhost:11434/v1",
|
||||
Model: "llama3",
|
||||
})
|
||||
if !probed {
|
||||
t.Fatal("a keyless local provider should still be probed")
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ func TestFormatSetupCompleteIncludesTryThisExample(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunSetupVerifyReportsClassifiedProbeFailure(t *testing.T) {
|
||||
t.Setenv("OPENAI_API_KEY", "sk-test") // a key is present, so --verify reaches the probe (not the no-key short-circuit)
|
||||
var stdout, stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
probed := false
|
||||
@@ -68,6 +69,7 @@ func TestRunSetupVerifyReportsClassifiedProbeFailure(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunSetupVerifySucceedsAndConfirmsWorking(t *testing.T) {
|
||||
t.Setenv("OPENAI_API_KEY", "sk-test") // a key is present, so --verify reaches the probe (not the no-key short-circuit)
|
||||
var stdout, stderr bytes.Buffer
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
deps := appDeps{
|
||||
|
||||
@@ -325,6 +325,11 @@ func runSpecialistEdit(paths specialist.Paths, name string, options specialistOp
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
for _, b := range specialist.Builtins() {
|
||||
if b.Metadata.Name == name {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("cannot edit builtin specialist %q: builtins are read-only. Create a user or project specialist of the same name to override it.", name))
|
||||
}
|
||||
}
|
||||
return writeExecUsageError(stderr, "specialist not found: "+name)
|
||||
}
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/specialist"
|
||||
)
|
||||
|
||||
// TestSpecialistSummariesIncludesBuiltins confirms the orchestrator delegation
|
||||
// prompt is fed the built-in specialists (the wiring behind auto-delegation).
|
||||
func TestSpecialistSummariesIncludesBuiltins(t *testing.T) {
|
||||
paths, err := specialist.DefaultPaths(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultPaths: %v", err)
|
||||
}
|
||||
descByName := map[string]string{}
|
||||
for _, info := range specialistSummaries(paths) {
|
||||
descByName[info.Name] = info.WhenToUse
|
||||
}
|
||||
for _, name := range []string{"worker", "explorer", "code-review"} {
|
||||
if descByName[name] == "" {
|
||||
t.Fatalf("specialistSummaries missing built-in %q (or empty description); got %v", name, descByName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"policy": {
|
||||
"mode": "enforce",
|
||||
"network": "deny",
|
||||
"enforceWorkspace": true
|
||||
},
|
||||
"backend": {
|
||||
"name": "unavailable",
|
||||
"available": false,
|
||||
"platform": "windows",
|
||||
"fallback": true,
|
||||
"commandWrapping": false,
|
||||
"nativeIsolation": false,
|
||||
"message": "Windows sandbox command runner is not available"
|
||||
},
|
||||
"plan": {
|
||||
"backend": {
|
||||
"name": "unavailable",
|
||||
"available": false,
|
||||
"platform": "windows",
|
||||
"fallback": true,
|
||||
"commandWrapping": false,
|
||||
"nativeIsolation": false,
|
||||
"message": "Windows sandbox command runner is not available"
|
||||
},
|
||||
"targetBackend": "windows-restricted-token",
|
||||
"workspaceRoot": "$WORKSPACE",
|
||||
"policy": {
|
||||
"mode": "enforce",
|
||||
"network": "deny",
|
||||
"enforceWorkspace": true
|
||||
},
|
||||
"permissionProfile": {
|
||||
"fileSystem": {
|
||||
"kind": "restricted",
|
||||
"readRoots": [
|
||||
"$WORKSPACE"
|
||||
],
|
||||
"writeRoots": [
|
||||
{
|
||||
"root": "$WORKSPACE",
|
||||
"protectedMetadataNames": [
|
||||
".git",
|
||||
".zero",
|
||||
".agents"
|
||||
]
|
||||
}
|
||||
],
|
||||
"includePlatformRoots": true,
|
||||
"allowTemp": true
|
||||
},
|
||||
"network": {
|
||||
"mode": "deny"
|
||||
}
|
||||
},
|
||||
"commandWrapped": false,
|
||||
"enforcementLevel": "degraded",
|
||||
"downgradeReason": "Windows sandbox command runner is not available",
|
||||
"supportLevel": "unavailable",
|
||||
"requiresPlatformSandbox": true,
|
||||
"capabilities": [
|
||||
{
|
||||
"key": "permission_review",
|
||||
"status": "preflight",
|
||||
"detail": "tool requests are reviewed before execution"
|
||||
},
|
||||
{
|
||||
"key": "workspace_write_guard",
|
||||
"status": "preflight",
|
||||
"detail": "filesystem writes are checked against the workspace root before execution"
|
||||
},
|
||||
{
|
||||
"key": "network_guard",
|
||||
"status": "preflight",
|
||||
"detail": "network-capable tool requests are denied before execution"
|
||||
},
|
||||
{
|
||||
"key": "native_process_isolation",
|
||||
"status": "unavailable",
|
||||
"detail": "Windows sandbox command runner is not available"
|
||||
},
|
||||
{
|
||||
"key": "command_wrapping",
|
||||
"status": "unavailable",
|
||||
"detail": "shell commands are not wrapped by a native platform sandbox"
|
||||
}
|
||||
],
|
||||
"restrictions": [
|
||||
"filesystem writes must stay inside workspace",
|
||||
"network access denied unless a future adapter grants it explicitly",
|
||||
"native process isolation unavailable on windows",
|
||||
"shell commands are not wrapped by a native platform sandbox"
|
||||
],
|
||||
"warnings": [
|
||||
"Windows sandbox command runner is not available",
|
||||
"shell commands are not wrapped by a native platform sandbox"
|
||||
]
|
||||
},
|
||||
"grantsPath": "$GRANTS"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEnvKeyPreservesExistingCompatibleProviderKind(t *testing.T) {
|
||||
// A same-named openai-compatible provider (a proxy/gateway) must keep its kind
|
||||
// when the user exports OPENAI_API_KEY (credentials only, no baseURL). H2.
|
||||
cfg := &FileConfig{
|
||||
ActiveProvider: "openai",
|
||||
Providers: []ProviderProfile{{
|
||||
Name: "openai",
|
||||
ProviderKind: ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://gateway.example/v1",
|
||||
Model: "gpt-4o",
|
||||
}},
|
||||
}
|
||||
applyProviderEnv(cfg, ProviderKindOpenAI, envProfile{Name: "openai", APIKey: "sk-from-env"})
|
||||
p := cfg.Providers[0]
|
||||
if p.ProviderKind != ProviderKindOpenAICompatible {
|
||||
t.Errorf("transport kind clobbered: got %q, want %q", p.ProviderKind, ProviderKindOpenAICompatible)
|
||||
}
|
||||
if p.APIKey != "sk-from-env" {
|
||||
t.Errorf("env api key not applied: %q", p.APIKey)
|
||||
}
|
||||
if p.BaseURL != "https://gateway.example/v1" {
|
||||
t.Errorf("existing baseURL lost: %q", p.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvKeyCreatesStandardProviderWhenAbsent(t *testing.T) {
|
||||
// With no same-named provider, the env key still creates a standard provider.
|
||||
cfg := &FileConfig{}
|
||||
applyProviderEnv(cfg, ProviderKindOpenAI, envProfile{Name: "openai", APIKey: "sk-x"})
|
||||
if len(cfg.Providers) != 1 || cfg.Providers[0].ProviderKind != ProviderKindOpenAI {
|
||||
t.Fatalf("absent provider should be created as standard openai, got %+v", cfg.Providers)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package config
|
||||
|
||||
import "strings"
|
||||
|
||||
// DefaultMCPServers returns the MCP servers Zero ships ENABLED by default so web
|
||||
// search and scraping work out of the box with no setup and no API key. They are
|
||||
// seeded before user/project config is merged (see ResolveMCP), so a user can
|
||||
// override any field — for example point firecrawl at a self-hosted instance, or
|
||||
// add an API-key header to lift the free-tier limit — or disable it entirely with
|
||||
// `zero mcp disable <name>` (which writes `"disabled": true`).
|
||||
//
|
||||
// Keyless Firecrawl routes requests through firecrawl.dev (1,000 free credits per
|
||||
// month, no account). Self-host Firecrawl (AGPL-3.0) for unlimited and private
|
||||
// use. Zero only calls it over the network, so Firecrawl's license never reaches
|
||||
// into Zero's own code.
|
||||
func DefaultMCPServers() map[string]MCPServerConfig {
|
||||
return map[string]MCPServerConfig{
|
||||
"firecrawl": {
|
||||
Type: "http",
|
||||
URL: "https://mcp.firecrawl.dev/v2/mcp",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// IsDefaultMCPServer reports whether name is one of Zero's built-in default MCP
|
||||
// servers. The config commands use it so a default can be disabled/enabled even
|
||||
// though it is not written to the user's config file until overridden.
|
||||
func IsDefaultMCPServer(name string) bool {
|
||||
_, ok := DefaultMCPServers()[strings.TrimSpace(name)]
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsDefaultMCPServer(t *testing.T) {
|
||||
if !IsDefaultMCPServer("firecrawl") {
|
||||
t.Fatal("firecrawl should be a built-in default")
|
||||
}
|
||||
if IsDefaultMCPServer(" firecrawl ") == false {
|
||||
t.Fatal("IsDefaultMCPServer should trim whitespace")
|
||||
}
|
||||
if IsDefaultMCPServer("not-a-default") {
|
||||
t.Fatal("unknown server should not be a default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPSeedsEnabledFirecrawlDefault(t *testing.T) {
|
||||
cfg, err := ResolveMCP(ResolveOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
firecrawl, ok := cfg.Servers["firecrawl"]
|
||||
if !ok {
|
||||
t.Fatal("expected the firecrawl default to be seeded with no user config")
|
||||
}
|
||||
if firecrawl.Type != "http" || firecrawl.URL != "https://mcp.firecrawl.dev/v2/mcp" {
|
||||
t.Fatalf("unexpected firecrawl default: %#v", firecrawl)
|
||||
}
|
||||
if firecrawl.Disabled {
|
||||
t.Fatal("the firecrawl default must be enabled out of the box")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPUserCanDisableDefault(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":true}}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
if !cfg.Servers["firecrawl"].Disabled {
|
||||
t.Fatal("a user must be able to disable the default by writing over it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPUserCanOverrideDefaultURLKeepingOtherFields(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
// Point firecrawl at a self-hosted instance; the default's Type must survive.
|
||||
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"url":"http://localhost:3002/mcp"}}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
firecrawl := cfg.Servers["firecrawl"]
|
||||
if firecrawl.URL != "http://localhost:3002/mcp" {
|
||||
t.Fatalf("user override of the default URL did not apply: %#v", firecrawl)
|
||||
}
|
||||
if firecrawl.Type != "http" {
|
||||
t.Fatalf("override should keep the default's other fields (type), got %#v", firecrawl)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
// A single unresolvable, NON-active provider (e.g. one whose catalogID preset this
|
||||
// build doesn't ship) must not crash resolution — it's dropped, the rest survive.
|
||||
func TestNormalizeProvidersSkipsUnresolvableNonActive(t *testing.T) {
|
||||
good := ProviderProfile{Name: "good", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://api.example.com/v1", Model: "m"}
|
||||
bad := ProviderProfile{Name: "badcat", CatalogID: "definitely-not-a-real-catalog-xyz", Model: "m"}
|
||||
|
||||
got, active, err := normalizeProviders([]ProviderProfile{good, bad}, "good")
|
||||
if err != nil {
|
||||
t.Fatalf("a bad NON-active provider must not fail resolution, got: %v", err)
|
||||
}
|
||||
if active.Name != "good" {
|
||||
t.Fatalf("active = %q, want good", active.Name)
|
||||
}
|
||||
if len(got) != 1 || got[0].Name != "good" {
|
||||
t.Fatalf("the unresolvable provider should be dropped, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// But an unresolvable ACTIVE provider IS fatal — the run can't proceed without it.
|
||||
func TestNormalizeProvidersFatalWhenActiveUnresolvable(t *testing.T) {
|
||||
bad := ProviderProfile{Name: "badcat", CatalogID: "definitely-not-a-real-catalog-xyz", Model: "m"}
|
||||
if _, _, err := normalizeProviders([]ProviderProfile{bad}, "badcat"); err == nil {
|
||||
t.Fatal("an unresolvable ACTIVE provider must be a fatal error")
|
||||
}
|
||||
}
|
||||
+66
-23
@@ -15,7 +15,16 @@ import (
|
||||
|
||||
const defaultMaxTurns = 30
|
||||
|
||||
const defaultDeferThreshold = 10
|
||||
// defaultDeferThreshold is the number of deferred-eligible (MCP) tools at which
|
||||
// Zero collapses their full JSON schemas into compact `tool_search` reminder
|
||||
// lines instead of advertising every schema on every turn. MCP tool schemas run
|
||||
// 300-600 tokens each, so eagerly shipping even a small server's toolset wastes
|
||||
// thousands of input tokens per message. Kept low so a typical single-server set
|
||||
// (Exa-style, a handful of tools) defers and stays cheap; the only cost is one
|
||||
// `tool_search` round-trip before a deferred tool's first use, after which it is
|
||||
// loaded for the rest of the run. Override per-config with tools.deferThreshold
|
||||
// (set 0 to always advertise every schema, e.g. for a model without tool_search).
|
||||
const defaultDeferThreshold = 3
|
||||
|
||||
func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
cfg := FileConfig{
|
||||
@@ -58,15 +67,10 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
return ResolvedConfig{}, fmt.Errorf("invalid tools.deferThreshold %d: must be >= 0", cfg.Tools.DeferThreshold)
|
||||
}
|
||||
|
||||
if maxAutonomy := strings.TrimSpace(cfg.Sandbox.MaxAutonomy); maxAutonomy != "" {
|
||||
// Fail loud on an invalid ceiling. An unvalidated typo (e.g. "moderate")
|
||||
// would otherwise survive Resolve untouched, reach the sandbox bridge,
|
||||
// fail to normalize there, and silently leave the default High ceiling in
|
||||
// place — fail-open on a security boundary. Reject it here instead.
|
||||
if _, err := sandbox.NormalizeAutonomy(sandbox.Autonomy(maxAutonomy)); err != nil {
|
||||
return ResolvedConfig{}, fmt.Errorf("invalid sandbox.maxAutonomy %q: expected low, medium, or high", maxAutonomy)
|
||||
}
|
||||
if cfg.Swarm.MaxTeamSize < 0 {
|
||||
return ResolvedConfig{}, fmt.Errorf("invalid swarm.maxTeamSize %d: must be >= 0 (0 uses the default)", cfg.Swarm.MaxTeamSize)
|
||||
}
|
||||
|
||||
if network := strings.TrimSpace(cfg.Sandbox.Network); network != "" {
|
||||
switch sandbox.NetworkMode(network) {
|
||||
case sandbox.NetworkAllow, sandbox.NetworkDeny:
|
||||
@@ -103,12 +107,16 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
Sandbox: cfg.Sandbox,
|
||||
Notify: cfg.Notify,
|
||||
Tools: cfg.Tools,
|
||||
Swarm: cfg.Swarm,
|
||||
Preferences: cfg.Preferences,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ResolveMCP(options ResolveOptions) (MCPConfig, error) {
|
||||
cfg := FileConfig{}
|
||||
// Seed Zero's built-in default MCP servers (e.g. keyless Firecrawl for free,
|
||||
// no-setup web search/scrape) BEFORE merging user/project config, so the user
|
||||
// can override any field or disable a default by writing over it.
|
||||
cfg := FileConfig{MCP: MCPConfig{Servers: DefaultMCPServers()}}
|
||||
|
||||
for _, path := range []string{options.UserConfigPath, options.ProjectConfigPath} {
|
||||
if path == "" {
|
||||
@@ -148,13 +156,16 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
|
||||
mergeProvider(dst, provider)
|
||||
}
|
||||
mergeMCPConfig(&dst.MCP, src.MCP)
|
||||
if maxAutonomy := strings.TrimSpace(src.Sandbox.MaxAutonomy); maxAutonomy != "" {
|
||||
dst.Sandbox.MaxAutonomy = maxAutonomy
|
||||
}
|
||||
if network := strings.TrimSpace(src.Sandbox.Network); network != "" {
|
||||
dst.Sandbox.Network = network
|
||||
}
|
||||
dst.Sandbox.AdditionalWriteRoots = unionStrings(dst.Sandbox.AdditionalWriteRoots, src.Sandbox.AdditionalWriteRoots)
|
||||
if src.Sandbox.BlockUnixSockets {
|
||||
dst.Sandbox.BlockUnixSockets = true
|
||||
}
|
||||
if src.Sandbox.MonitorDenials {
|
||||
dst.Sandbox.MonitorDenials = true
|
||||
}
|
||||
if mode := strings.TrimSpace(src.Notify.Mode); mode != "" {
|
||||
dst.Notify.Mode = mode
|
||||
}
|
||||
@@ -165,6 +176,9 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
|
||||
dst.Tools.DeferThreshold = src.Tools.DeferThreshold
|
||||
dst.Tools.deferThresholdSet = true
|
||||
}
|
||||
if src.Swarm.MaxTeamSize != 0 {
|
||||
dst.Swarm.MaxTeamSize = src.Swarm.MaxTeamSize
|
||||
}
|
||||
if src.Preferences.FavoriteModels != nil {
|
||||
dst.Preferences.FavoriteModels = normalizeFavoriteModels(src.Preferences.FavoriteModels)
|
||||
}
|
||||
@@ -189,12 +203,15 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
// config: a cloned repo's .zero/config.json must not be able to grant
|
||||
// itself write access outside the workspace. Global config and CLI flags
|
||||
// are the only config sources for write roots.
|
||||
if maxAutonomy := strings.TrimSpace(src.Sandbox.MaxAutonomy); maxAutonomy != "" {
|
||||
dst.Sandbox.MaxAutonomy = maxAutonomy
|
||||
}
|
||||
if network := strings.TrimSpace(src.Sandbox.Network); network != "" {
|
||||
dst.Sandbox.Network = network
|
||||
}
|
||||
if src.Sandbox.BlockUnixSockets {
|
||||
dst.Sandbox.BlockUnixSockets = true
|
||||
}
|
||||
if src.Sandbox.MonitorDenials {
|
||||
dst.Sandbox.MonitorDenials = true
|
||||
}
|
||||
if mode := strings.TrimSpace(src.Notify.Mode); mode != "" {
|
||||
dst.Notify.Mode = mode
|
||||
}
|
||||
@@ -205,6 +222,9 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
dst.Tools.DeferThreshold = src.Tools.DeferThreshold
|
||||
dst.Tools.deferThresholdSet = true
|
||||
}
|
||||
if src.Swarm.MaxTeamSize != 0 {
|
||||
dst.Swarm.MaxTeamSize = src.Swarm.MaxTeamSize
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -395,6 +415,11 @@ func mergeProfile(base ProviderProfile, next ProviderProfile) ProviderProfile {
|
||||
if next.Description != "" {
|
||||
base.Description = next.Description
|
||||
}
|
||||
// A nil *bool means "unset"; only an explicit value overrides. Without this the
|
||||
// parseThinkTags setting was silently dropped on every profile merge (L17).
|
||||
if next.ParseThinkTags != nil {
|
||||
base.ParseThinkTags = next.ParseThinkTags
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
@@ -404,10 +429,6 @@ func applyEnv(cfg *FileConfig, env map[string]string) {
|
||||
cfg.ActiveProvider = activeProvider
|
||||
}
|
||||
|
||||
if maxAutonomy := strings.TrimSpace(envValue(env, "ZERO_SANDBOX_MAX_AUTONOMY")); maxAutonomy != "" {
|
||||
cfg.Sandbox.MaxAutonomy = maxAutonomy
|
||||
}
|
||||
|
||||
applyProviderEnv(cfg, ProviderKindOpenAI, envProfile{
|
||||
Name: string(ProviderKindOpenAI),
|
||||
APIKey: envValue(env, "OPENAI_API_KEY"),
|
||||
@@ -456,6 +477,18 @@ func applyProviderEnv(cfg *FileConfig, providerKind ProviderKind, env envProfile
|
||||
if providerKind == ProviderKindOpenAI && baseURL != "" && !isOfficialOpenAIBaseURL(baseURL) {
|
||||
profile.ProviderKind = ProviderKindOpenAICompatible
|
||||
}
|
||||
// When the env supplies only credentials (no baseURL) for a provider name that
|
||||
// already exists, don't force the standard transport kind — a same-named
|
||||
// openai-compatible/anthropic-compatible proxy or gateway must keep its kind.
|
||||
// An empty kind makes mergeProfile preserve the existing provider's kind (H2).
|
||||
if baseURL == "" {
|
||||
for _, existing := range cfg.Providers {
|
||||
if strings.TrimSpace(existing.Name) == profile.Name {
|
||||
profile.ProviderKind = ""
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
mergeProvider(cfg, profile)
|
||||
}
|
||||
|
||||
@@ -506,8 +539,11 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
|
||||
if overrides.MaxTurns > 0 {
|
||||
cfg.MaxTurns = overrides.MaxTurns
|
||||
}
|
||||
if maxAutonomy := strings.TrimSpace(overrides.Sandbox.MaxAutonomy); maxAutonomy != "" {
|
||||
cfg.Sandbox.MaxAutonomy = maxAutonomy
|
||||
if overrides.Sandbox.BlockUnixSockets {
|
||||
cfg.Sandbox.BlockUnixSockets = true
|
||||
}
|
||||
if overrides.Sandbox.MonitorDenials {
|
||||
cfg.Sandbox.MonitorDenials = true
|
||||
}
|
||||
if mode := strings.TrimSpace(overrides.Notify.Mode); mode != "" {
|
||||
cfg.Notify.Mode = mode
|
||||
@@ -640,7 +676,14 @@ func normalizeProvidersWithOptions(providers []ProviderProfile, activeName strin
|
||||
for _, provider := range providers {
|
||||
next, err := normalizeProvider(provider, env, options)
|
||||
if err != nil {
|
||||
return nil, ProviderProfile{}, err
|
||||
// One unresolvable provider (e.g. a profile referencing a provider preset
|
||||
// this build doesn't ship) must NOT brick the whole app — drop it and keep
|
||||
// the rest. Only the ACTIVE provider failing is fatal, since the run can't
|
||||
// proceed without it.
|
||||
if strings.TrimSpace(provider.Name) == activeName {
|
||||
return nil, ProviderProfile{}, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
normalized = append(normalized, next)
|
||||
if next.Name == activeName {
|
||||
|
||||
@@ -1249,9 +1249,9 @@ func TestResolveRedactsSecretsFromErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSandboxMaxAutonomyFromFile(t *testing.T) {
|
||||
func TestResolveSandboxBlockUnixSocketsFromFile(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"sandbox": {"maxAutonomy": "medium"},
|
||||
"sandbox": {"blockUnixSockets": true},
|
||||
"providers": [{
|
||||
"name": "openai",
|
||||
"provider": "openai",
|
||||
@@ -1264,83 +1264,8 @@ func TestResolveSandboxMaxAutonomyFromFile(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if resolved.Sandbox.MaxAutonomy != "medium" {
|
||||
t.Fatalf("resolved.Sandbox.MaxAutonomy = %q, want medium", resolved.Sandbox.MaxAutonomy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSandboxMaxAutonomyPrecedence(t *testing.T) {
|
||||
userPath := writeConfig(t, `{
|
||||
"sandbox": {"maxAutonomy": "low"},
|
||||
"providers": [{"name": "openai", "provider": "openai", "api_key": "sk-user", "model": "gpt-user"}]
|
||||
}`)
|
||||
projectPath := writeConfig(t, `{
|
||||
"sandbox": {"maxAutonomy": "medium"},
|
||||
"providers": [{"name": "openai", "provider": "openai", "api_key": "sk-proj", "model": "gpt-proj"}]
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
UserConfigPath: userPath,
|
||||
ProjectConfigPath: projectPath,
|
||||
Env: map[string]string{"ZERO_SANDBOX_MAX_AUTONOMY": "high"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if resolved.Sandbox.MaxAutonomy != "high" {
|
||||
t.Fatalf("resolved.Sandbox.MaxAutonomy = %q, want high (env overrides project overrides user)", resolved.Sandbox.MaxAutonomy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSandboxMaxAutonomyOverride(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"sandbox": {"maxAutonomy": "low"},
|
||||
"providers": [{"name": "openai", "provider": "openai", "api_key": "sk", "model": "gpt"}]
|
||||
}`)
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
ProjectConfigPath: path,
|
||||
Overrides: Overrides{Sandbox: SandboxConfig{MaxAutonomy: "high"}},
|
||||
Env: map[string]string{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if resolved.Sandbox.MaxAutonomy != "high" {
|
||||
t.Fatalf("resolved.Sandbox.MaxAutonomy = %q, want high (override wins)", resolved.Sandbox.MaxAutonomy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSandboxMaxAutonomyInvalidFailsLoud(t *testing.T) {
|
||||
// A typo'd ceiling must be rejected at resolve time rather than silently
|
||||
// surviving (which would fail-open and keep the default High ceiling).
|
||||
for _, value := range []string{"moderate", "med", "HIGHEST"} {
|
||||
path := writeConfig(t, `{
|
||||
"sandbox": {"maxAutonomy": "`+value+`"},
|
||||
"providers": [{"name": "openai", "provider": "openai", "api_key": "sk", "model": "gpt"}]
|
||||
}`)
|
||||
_, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
|
||||
if err == nil {
|
||||
t.Fatalf("Resolve() error = nil for invalid maxAutonomy %q, want failure", value)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid sandbox.maxAutonomy") {
|
||||
t.Fatalf("Resolve() error = %v, want invalid sandbox.maxAutonomy for %q", err, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSandboxMaxAutonomyValidResolves(t *testing.T) {
|
||||
for _, value := range []string{"low", "medium", "high", "HIGH", " medium "} {
|
||||
path := writeConfig(t, `{
|
||||
"sandbox": {"maxAutonomy": "`+value+`"},
|
||||
"providers": [{"name": "openai", "provider": "openai", "api_key": "sk", "model": "gpt"}]
|
||||
}`)
|
||||
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v for valid maxAutonomy %q", err, value)
|
||||
}
|
||||
if strings.TrimSpace(resolved.Sandbox.MaxAutonomy) == "" {
|
||||
t.Fatalf("resolved.Sandbox.MaxAutonomy empty for valid input %q", value)
|
||||
}
|
||||
if !resolved.Sandbox.BlockUnixSockets {
|
||||
t.Fatal("resolved.Sandbox.BlockUnixSockets = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1417,6 +1342,15 @@ func TestResolveDefaultsDeferThresholdWhenUnset(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The default must stay low so a typical single MCP server's toolset defers
|
||||
// instead of shipping every full schema each turn (the token-economy fix). This
|
||||
// guards against a regression silently bumping it back up.
|
||||
func TestDefaultDeferThresholdStaysLowForTokenEconomy(t *testing.T) {
|
||||
if defaultDeferThreshold <= 0 || defaultDeferThreshold > 4 {
|
||||
t.Fatalf("defaultDeferThreshold = %d, want a small positive value (<=4) so small MCP sets defer", defaultDeferThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDeferThresholdFromFile(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "p",
|
||||
@@ -1552,6 +1486,20 @@ func TestResolveRejectsNegativeDeferThreshold(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeProfilePreservesParseThinkTags(t *testing.T) {
|
||||
yes := true
|
||||
// next supplies the setting; a nil base must inherit it (it was being dropped).
|
||||
merged := mergeProfile(ProviderProfile{Name: "p"}, ProviderProfile{ParseThinkTags: &yes})
|
||||
if merged.ParseThinkTags == nil || !*merged.ParseThinkTags {
|
||||
t.Fatalf("mergeProfile dropped ParseThinkTags from next: %v", merged.ParseThinkTags)
|
||||
}
|
||||
// A nil next must not clobber an explicit base value.
|
||||
keep := mergeProfile(ProviderProfile{ParseThinkTags: &yes}, ProviderProfile{})
|
||||
if keep.ParseThinkTags == nil || !*keep.ParseThinkTags {
|
||||
t.Fatalf("mergeProfile clobbered base ParseThinkTags with a nil next: %v", keep.ParseThinkTags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConfigUnionsSandboxAdditionalWriteRoots(t *testing.T) {
|
||||
dst := FileConfig{}
|
||||
dst.Sandbox.AdditionalWriteRoots = []string{"/global/one"}
|
||||
|
||||
Loaded 100 of 465 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user