mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6d43c62d9 |
No files matched your search
@@ -1,54 +0,0 @@
|
||||
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
|
||||
@@ -1,5 +0,0 @@
|
||||
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.
|
||||
@@ -1,41 +0,0 @@
|
||||
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
|
||||
@@ -1,16 +0,0 @@
|
||||
## 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,20 +15,13 @@ 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:
|
||||
# `zero-release package` builds + smoke-tests natively (host==target), so each
|
||||
# target platform needs its own native runner. These five cover the platforms
|
||||
# install.sh / install.ps1 / the npm wrapper resolve.
|
||||
os:
|
||||
- ubuntu-latest # linux-x64
|
||||
- ubuntu-24.04-arm # linux-arm64
|
||||
- macos-latest # macos-arm64 (Apple Silicon)
|
||||
- macos-15-intel # macos-x64 (Intel)
|
||||
- windows-latest # windows-x64
|
||||
- ubuntu-latest
|
||||
- macos-latest
|
||||
- windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -50,23 +43,6 @@ 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.
|
||||
# Matches on the ref (not event_name) because release-please dispatches this
|
||||
# workflow at the tag ref — its GITHUB_TOKEN-created tag cannot fire `push`.
|
||||
- name: Check tag matches package.json version
|
||||
if: ${{ 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
|
||||
|
||||
@@ -79,106 +55,3 @@ jobs:
|
||||
name: zero-${{ runner.os }}-${{ runner.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
# On a `v*` tag ref (pushed manually, or dispatched at the tag by the
|
||||
# release-please workflow), 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 — release-please normally creates it first) then uploads its
|
||||
# own assets with --clobber. Tags come from merging the release-please PR.
|
||||
- name: Publish to GitHub Release
|
||||
if: ${{ 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
|
||||
|
||||
# Publishes the npm wrapper (@gitlawb/zero) once every platform's release
|
||||
# assets are live — the package's postinstall downloads its binary from the
|
||||
# GitHub Release, so publishing before the assets exist would ship a package
|
||||
# that fails to install.
|
||||
#
|
||||
# Auth is npm OIDC trusted publishing: no NPM_TOKEN secret. The package's
|
||||
# npmjs.com settings must list this repo + workflow file as a trusted
|
||||
# publisher, and provenance is then generated automatically. Note npm only
|
||||
# allows configuring a trusted publisher on an EXISTING package, so the very
|
||||
# first @gitlawb/zero publish is a one-time manual `npm publish` bootstrap.
|
||||
publish-npm:
|
||||
name: Publish npm package
|
||||
needs: package
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
runs-on: ubuntu-latest
|
||||
# Manual approval gate: this environment carries a required-reviewer rule
|
||||
# (Settings -> Environments -> npm-publish), so the job pauses until a
|
||||
# maintainer approves it in the Actions UI before anything reaches npm.
|
||||
environment:
|
||||
name: npm-publish
|
||||
url: https://www.npmjs.com/package/@gitlawb/zero
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # OIDC token exchange for npm trusted publishing + provenance
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
# No registry-url here on purpose: setup-node's registry-url writes an
|
||||
# .npmrc line referencing $NODE_AUTH_TOKEN, and npm hard-fails on the
|
||||
# unset env var. Trusted publishing needs no token at all.
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
# OIDC trusted publishing needs npm >= 11.5.1. Use the npm bundled with
|
||||
# the pinned Node toolchain (Node 24 ships >= 11.5.1) rather than pulling
|
||||
# npm@latest from the registry inside the publish path — an unpinned
|
||||
# install there would be avoidable supply-chain exposure. Fail fast if a
|
||||
# future runner/toolchain change ever regresses the bundled npm.
|
||||
- name: Check npm supports trusted publishing
|
||||
run: |
|
||||
npm --version | node -e '
|
||||
const version = require("fs").readFileSync(0, "utf8").trim();
|
||||
const [major, minor, patch] = version.split(".").map(Number);
|
||||
const ok =
|
||||
major > 11 ||
|
||||
(major === 11 && (minor > 5 || (minor === 5 && patch >= 1)));
|
||||
if (!ok) {
|
||||
console.error(
|
||||
`::error::bundled npm ${version} is too old for OIDC trusted ` +
|
||||
`publishing (needs >= 11.5.1) — bump node-version in this job.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`npm ${version} supports trusted publishing.`);
|
||||
'
|
||||
|
||||
# Probe the exact URLs postinstall will build (internal/release/release.go
|
||||
# asset-name scheme) as an anonymous client. This also blocks publishing
|
||||
# while the repo is still private — private release assets 404 without
|
||||
# auth, exactly as they would for an installing user.
|
||||
- name: Verify release assets are downloadable
|
||||
shell: bash
|
||||
run: |
|
||||
version="$(node -p "require('./package.json').version")"
|
||||
base="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${version}"
|
||||
for asset in \
|
||||
"zero-v${version}-linux-x64.tar.gz" \
|
||||
"zero-v${version}-linux-arm64.tar.gz" \
|
||||
"zero-v${version}-macos-x64.tar.gz" \
|
||||
"zero-v${version}-macos-arm64.tar.gz" \
|
||||
"zero-v${version}-windows-x64.zip"; do
|
||||
for url in "${base}/${asset}" "${base}/${asset}.sha256"; do
|
||||
if ! curl -fsSLI -o /dev/null "$url"; then
|
||||
echo "::error::${url} is not publicly downloadable — npm postinstall would fail. Is the repo public and are all assets uploaded?" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
done
|
||||
echo "all release assets for v${version} are publicly downloadable."
|
||||
|
||||
- name: Publish to npm
|
||||
run: npm publish --access public
|
||||
@@ -1,35 +0,0 @@
|
||||
name: Release Please
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: write # push the release-PR branch, create the tag + GitHub Release
|
||||
pull-requests: write # open/update the release PR
|
||||
issues: write # create/apply release-tracking labels on the release PR
|
||||
actions: write # dispatch release-artifacts.yml once a release is cut
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
name: Release PR / tag
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Run release-please
|
||||
id: release
|
||||
uses: googleapis/release-please-action@a02a34c4d625f9be7cb89156071d8567266a2445 # v4.2.0
|
||||
|
||||
# Tags created with the default GITHUB_TOKEN never fire `on: push: tags`
|
||||
# workflows (GitHub suppresses workflows triggered by workflow-created
|
||||
# events), so release-artifacts.yml would not run for a release-please
|
||||
# tag. workflow_dispatch invoked through the API is exempt from that
|
||||
# suppression — dispatch the artifacts build at the new tag explicitly.
|
||||
- name: Build and publish release artifacts
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh workflow run release-artifacts.yml \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--ref "${{ steps.release.outputs.tag_name }}"
|
||||
@@ -4,14 +4,12 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- action.yml
|
||||
- docs/GITHUB_ACTION.md
|
||||
- .github/workflows/zero-action-smoke.yml
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- action.yml
|
||||
- docs/GITHUB_ACTION.md
|
||||
- .github/workflows/zero-action-smoke.yml
|
||||
|
||||
jobs:
|
||||
@@ -31,8 +29,7 @@ jobs:
|
||||
import sys, yaml
|
||||
|
||||
with open("action.yml", "r", encoding="utf-8") as handle:
|
||||
action_raw = handle.read()
|
||||
doc = yaml.safe_load(action_raw)
|
||||
doc = yaml.safe_load(handle)
|
||||
|
||||
assert isinstance(doc, dict), "action.yml must be a mapping"
|
||||
assert doc.get("name"), "action must declare a name"
|
||||
@@ -68,19 +65,6 @@ jobs:
|
||||
assert "--skip-permissions-unsafe" not in serialized, \
|
||||
"action must never pass --skip-permissions-unsafe"
|
||||
|
||||
run_zero = next((step for step in steps if step.get("name") == "Run ZERO"), None)
|
||||
assert run_zero is not None, "action.yml must contain a Run ZERO step"
|
||||
run_zero_script = run_zero.get("run") or ""
|
||||
assert 'export "${ZERO_INPUT_API_KEY_ENV}=${ZERO_INPUT_API_KEY}"' in run_zero_script, \
|
||||
"Run ZERO must export the provider key for the current step"
|
||||
assert "later steps inherit" not in action_raw.lower(), \
|
||||
"provider keys must not be documented as inherited by later steps"
|
||||
for line in run_zero_script.splitlines():
|
||||
if "GITHUB_ENV" in line and (
|
||||
"ZERO_INPUT_API_KEY" in line or "ZERO_INPUT_API_KEY_ENV" in line
|
||||
):
|
||||
raise AssertionError("provider key must not be persisted to GITHUB_ENV")
|
||||
|
||||
print(f"action.yml OK: {len(inputs)} inputs, {len(outputs)} outputs, {len(steps)} steps")
|
||||
PY
|
||||
|
||||
|
||||
@@ -38,7 +38,3 @@ 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 +0,0 @@
|
||||
{".":"0.1.0"}
|
||||
@@ -6,7 +6,7 @@ alwaysApply: false
|
||||
|
||||
# Extending Zero
|
||||
|
||||
Zero is an open-source terminal coding agent. Out of the box it does the obvious things — read, edit, run, search — but the design point of the project is that **every surface is configurable**. This document is the user-facing guide for that configuration.
|
||||
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:
|
||||
|
||||
@@ -26,7 +26,7 @@ Accepted file names, in priority order at each level:
|
||||
| `./ZERO.md` | Brand-specific alias. Same format, lower priority. |
|
||||
| `./.zero/AGENTS.md` | Project-local, hidden, gitignored. Personal notes that stay out of git. |
|
||||
|
||||
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.md` — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
|
||||
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.MD` (uppercase `MD`) — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
|
||||
|
||||
Both files use the same format. YAML frontmatter is optional; the markdown body is loaded as instructions for the agent. Zero reads the file once at session start, so changes take effect on the next `zero` launch — not mid-session.
|
||||
|
||||
@@ -315,5 +315,6 @@ That's it. Run `zero` from the repo root and the agent has the team's full instr
|
||||
|
||||
- [README](README.md) — install, quickstart, command reference.
|
||||
- [docs/SPECIALISTS.md](docs/SPECIALISTS.md) — full specialist manifest spec.
|
||||
- [docs/PRD.md](docs/PRD.md) — product requirements, full feature surface.
|
||||
- [docs/STREAM_JSON_PROTOCOL.md](docs/STREAM_JSON_PROTOCOL.md) — `zero exec` I/O contract.
|
||||
- [docs/INSTALL.md](docs/INSTALL.md) — install from source or release.
|
||||
@@ -1,56 +0,0 @@
|
||||
# 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`.
|
||||
|
||||
## 0.1.0 (2026-07-02)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* publish zero to npm via release-please ([#367](https://github.com/Gitlawb/zero/issues/367)) ([8eccc26](https://github.com/Gitlawb/zero/commit/8eccc2669887bc38d35bc16a315c888e4d9ec43a))
|
||||
* **tui:** FILES sidebar panel with click-to-select and file drill-in ([#365](https://github.com/Gitlawb/zero/issues/365)) ([142c548](https://github.com/Gitlawb/zero/commit/142c548c89a8652ce300e64ddf1228ee36df7606))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** propagate credentials to every provider-build surface and pin children to the live provider ([#366](https://github.com/Gitlawb/zero/issues/366)) ([6e0a665](https://github.com/Gitlawb/zero/commit/6e0a665118fe0e09c4b07d482dd18f86045acd2b))
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- `SECURITY.md` with a private vulnerability-reporting path, `CODE_OF_CONDUCT.md`, this changelog, and
|
||||
GitHub issue/PR templates.
|
||||
- Interactive `/theme` picker: bare `/theme` opens a popup that live-previews each palette as you move
|
||||
and applies on select (Esc reverts).
|
||||
- Ten built-in color themes alongside the `dark`/`light` built-ins — `dracula`, `nord`, `gruvbox`,
|
||||
`tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, and
|
||||
`solarized-light` — selectable via `/theme <name>`, `--theme <name>`, or `ZERO_THEME`. Every palette
|
||||
is contrast-audited to WCAG AA. The built-in light theme was reworked for legibility.
|
||||
- `--theme <name>` flag for the TUI, accepting `auto` or any registered theme (previously only the
|
||||
`ZERO_THEME` env var existed).
|
||||
- "Accessibility / Appearance" section in the README documenting `NO_COLOR`, `ZERO_THEME`, `/theme`,
|
||||
and `ZERO_NO_FADE`.
|
||||
|
||||
### 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 public-release docs claims.
|
||||
@@ -1,58 +0,0 @@
|
||||
# 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
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Gitlawb
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,44 +0,0 @@
|
||||
# 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"
|
||||
@@ -1,335 +1,334 @@
|
||||
<p align="center">
|
||||
<img src="docs/assets/zero-logo.png" alt="Zero" width="385">
|
||||
</p>
|
||||
<div align="center">
|
||||
|
||||
<p align="center"><strong>A terminal coding agent you own.</strong></p>
|
||||
```
|
||||
███████╗ ███████╗ ██████╗ ██████╗
|
||||
╚══███╔╝ ██╔════╝ ██╔══██╗ ██╔═══██╗
|
||||
███╔╝ █████╗ ██████╔╝ ██║ ██║
|
||||
███╔╝ ██╔══╝ ██╔══██╗ ██║ ██║
|
||||
███████╗ ███████╗ ██║ ██║ ╚██████╔╝
|
||||
╚══════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═════╝
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||
<img alt="Go 1.25+" src="https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<br>
|
||||
<strong>English</strong> | <a href="README_ZH.md">中文</a>
|
||||
</p>
|
||||
### The terminal coding agent you fully own.
|
||||
|
||||
Zero is an AI coding agent for your local terminal. It can inspect a repository,
|
||||
edit files, run commands, use browser/terminal helpers, and keep durable local
|
||||
sessions while you choose the model and the permission level.
|
||||
**Any model. Any provider. Your rules.**
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
Zero is an AI coding agent that lives in your terminal. It runs a full agentic loop — reading, searching, editing, and executing in your repo — against **whatever model you choose**: frontier APIs, fast cloud inference, or a local model on your own machine. One interface, no vendor lock-in, no telemetry phoning home.
|
||||
|
||||
```bash
|
||||
zero
|
||||
zero exec "fix the failing test in ./pkg"
|
||||
zero exec --output-format stream-json < turns.jsonl
|
||||
zero # interactive TUI
|
||||
zero exec "fix the failing test in ./pkg" # headless one-shot
|
||||
zero exec -o stream-json < turns.jsonl # programmatic, for scripts & CI
|
||||
```
|
||||
|
||||
> Zero treats the **model as a swappable, per-task choice** and **never mutates your system without a permission decision**.
|
||||
|
||||
## Why Zero
|
||||
|
||||
- **Use the model you want.** Bring OpenAI, Anthropic, Gemini, Groq, OpenRouter,
|
||||
DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, Ollama, LM Studio, or any
|
||||
OpenAI-/Anthropic-compatible endpoint.
|
||||
- **Stay in control.** File writes, shell commands, network access, and
|
||||
out-of-workspace writes go through Zero's permission and sandbox policy.
|
||||
- **Works in the terminal.** The TUI has model/provider pickers, image input,
|
||||
slash commands, live plan/tool rendering, scrollback, themes, and resume/fork
|
||||
support.
|
||||
- **Works without the TUI.** `zero exec` is scriptable, supports text/JSON/
|
||||
stream-JSON I/O, isolated worktrees, spec-first runs, and meaningful exit
|
||||
codes for CI.
|
||||
- **Keeps context local.** Sessions are stored on disk, searchable, resumable,
|
||||
and never uploaded as telemetry by Zero.
|
||||
- **Extensible when you need it.** Use MCP servers, skills, plugins, hooks, and
|
||||
specialist subagents from the same CLI.
|
||||
- 🔌 **25+ providers, one interface** — OpenAI, Anthropic, Gemini, Ollama (local & cloud), LM Studio, OpenRouter, Groq, DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, and any OpenAI- or Anthropic-compatible endpoint. Switch mid-session with `/model`.
|
||||
- 🖥️ **A TUI that feels premium** — truecolor Bubble Tea interface with a first-run setup wizard, searchable live model picker, scrollback, themes, image input, and slash commands for everything.
|
||||
- 🤖 **Headless & scriptable** — `zero exec` with `text` / `json` / `stream-json` I/O, session resume & fork, isolated `--worktree` runs, and meaningful exit codes. Built for CI.
|
||||
- 🧠 **Subagents** — delegate to built-in `worker`, `explorer`, and `code-review` specialists (or generate your own) that run as real background tasks, even out-of-process.
|
||||
- 📋 **Spec mode** — have the agent draft a spec first, review and approve it, *then* let it build. No more runaway sessions.
|
||||
- 📈 **Mid-run model escalation** — start cheap, and let the agent request a stronger model only when it hits a wall (`--allow-escalation`).
|
||||
- 🗺️ **Repo intelligence** — deterministic repo maps, workspace indexing, and context-budget reports keep the agent grounded in *your* codebase, not hallucinations.
|
||||
- ⏰ **Scheduled agents** — `zero cron` runs file-backed, dependency-free agent jobs on a schedule.
|
||||
- 🛡️ **Safe by default** — permission-gated mutations, autonomy ceilings, sandbox policy (writes stay inside the workspace unless you grant extra directories with `--add-dir` / `/add-dir`), and secret redaction everywhere. Unsafe mode is an explicit, loudly-labeled opt-in.
|
||||
- 💾 **Durable sessions** — append-only local event store with full-text search, resume, fork, and rewind. Your history never leaves your disk.
|
||||
- 🧩 **Extensible** — skills, plugins, hooks, and MCP (Zero is both an MCP client *and* an MCP server).
|
||||
|
||||
## Install
|
||||
|
||||
### npm
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
npm install -g @gitlawb/zero
|
||||
zero
|
||||
```
|
||||
|
||||
The npm package installs a small wrapper plus the matching Zero binary for your
|
||||
platform from GitHub Releases. It supports Linux, macOS, and Windows on x64 and
|
||||
arm64.
|
||||
|
||||
### Bun
|
||||
|
||||
Bun does not run dependency lifecycle scripts by default, so the `postinstall`
|
||||
that fetches the Zero binary is skipped and the first run fails with
|
||||
`No native binary found next to the npm wrapper`.
|
||||
|
||||
The simplest fix is to trust the package after installing, which runs the
|
||||
blocked postinstall. This works for project and global installs:
|
||||
|
||||
```bash
|
||||
# project install
|
||||
bun add @gitlawb/zero
|
||||
bun pm trust @gitlawb/zero
|
||||
|
||||
# global install
|
||||
bun add -g @gitlawb/zero
|
||||
bun pm -g trust @gitlawb/zero
|
||||
```
|
||||
|
||||
Alternatives: allow the postinstall up front by adding
|
||||
`"trustedDependencies": ["@gitlawb/zero"]` to your project's package.json
|
||||
before `bun add`, or run the installer manually
|
||||
(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`) on Bun versions
|
||||
that do not have `bun pm trust`.
|
||||
|
||||
### Install scripts
|
||||
|
||||
Linux/macOS:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
### From source
|
||||
|
||||
Source builds require Go 1.25+.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
cd zero
|
||||
# run from source (requires Go 1.24+)
|
||||
go run ./cmd/zero
|
||||
|
||||
# or install a release binary
|
||||
scripts/install.sh # Linux / macOS
|
||||
powershell -ExecutionPolicy Bypass -File scripts/install.ps1 # Windows
|
||||
```
|
||||
|
||||
Release installers and the npm wrapper require published GitHub Release assets.
|
||||
If you are testing before the first public release, build from source:
|
||||
First launch opens a **guided setup wizard** — pick a provider, paste a key, choose a model, done. Or do it non-interactively:
|
||||
|
||||
```bash
|
||||
go build -o zero ./cmd/zero
|
||||
export OPENAI_API_KEY=sk-... # or ANTHROPIC_API_KEY, GEMINI_API_KEY, GROQ_API_KEY, ...
|
||||
zero setup # guided first-run provider setup
|
||||
zero doctor # verify config, keys, and connectivity
|
||||
```
|
||||
|
||||
On Linux, build the sandbox helper too if you want native sandboxing:
|
||||
Local models need no key at all:
|
||||
|
||||
```bash
|
||||
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
|
||||
go build -o zero-seccomp ./cmd/zero-seccomp # optional compatibility wrapper
|
||||
```
|
||||
|
||||
Put `zero` and `zero-linux-sandbox` in the same directory on `PATH`
|
||||
(`~/.local/bin` is a good default). macOS does not need an extra helper binary.
|
||||
Windows source builds can use the main `zero.exe` as their sandbox helper; release
|
||||
archives still ship standalone Windows helper executables.
|
||||
|
||||
More install details: [docs/INSTALL.md](docs/INSTALL.md).
|
||||
|
||||
## First Run
|
||||
|
||||
Start the TUI:
|
||||
|
||||
```bash
|
||||
zero
|
||||
```
|
||||
|
||||
The setup wizard helps you pick a provider and model. You can also configure
|
||||
providers from the command line:
|
||||
|
||||
```bash
|
||||
zero setup
|
||||
# Ollama or LM Studio running locally? Zero finds them.
|
||||
zero providers list
|
||||
zero models list
|
||||
zero doctor
|
||||
```
|
||||
|
||||
For API providers, set the matching environment variable before setup or enter
|
||||
the key in the wizard:
|
||||
## The TUI
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=sk-...
|
||||
export ANTHROPIC_API_KEY=...
|
||||
export GEMINI_API_KEY=...
|
||||
export LONGCAT_API_KEY=...
|
||||
```
|
||||
Type to chat, **Enter** to send. `/` opens command suggestions, **Shift+Tab** cycles permission modes, **Ctrl+C** exits.
|
||||
|
||||
To configure Meituan LongCat (LongCat-2.0) directly, run:
|
||||
|
||||
```bash
|
||||
zero providers setup longcat --set-active
|
||||
```
|
||||
|
||||
For local models, run Ollama or LM Studio and then use `zero setup` or
|
||||
`zero providers detect`.
|
||||
|
||||
## Daily Use
|
||||
|
||||
### Interactive TUI
|
||||
|
||||
```bash
|
||||
zero
|
||||
```
|
||||
|
||||
Useful controls:
|
||||
|
||||
| Control | Action |
|
||||
| | |
|
||||
|---|---|
|
||||
| `Enter` | send the prompt |
|
||||
| `/` | open slash-command suggestions |
|
||||
| `Shift+Tab` | cycle permission mode |
|
||||
| `Ctrl+B` | show/hide the sidebar |
|
||||
| `Ctrl+C` | cancel or exit |
|
||||
| `/model` `/provider` | switch model or provider mid-session (searchable picker) |
|
||||
| `/spec` `/plan` | spec-mode drafting and live plan view |
|
||||
| `/image` | attach images for vision models |
|
||||
| `/resume` `/rewind` | time-travel across sessions |
|
||||
| `/compact` `/context` | manage the context window |
|
||||
| `/permissions` `/tools` | inspect what the agent can touch |
|
||||
| `/add-dir` | grant an extra write directory for the session, or list current write roots |
|
||||
| `/theme` `/style` | make it yours |
|
||||
| `/doctor` `/usage` `/config` | health, cost, and config without leaving the chat |
|
||||
|
||||
Common slash commands:
|
||||
Turn-completion notifications (terminal bell / OSC-9) ping you when the agent finishes or needs input — go make coffee.
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `/model`, `/provider` | switch the active model/provider |
|
||||
| `/spec`, `/plan` | draft and review a plan before building |
|
||||
| `/image` | attach an image for vision-capable models |
|
||||
| `/resume`, `/rewind` | continue or roll back local sessions |
|
||||
| `/compact`, `/context` | manage context usage |
|
||||
| `/permissions`, `/tools` | inspect available tools and policy |
|
||||
| `/add-dir` | allow an extra write directory for this session |
|
||||
| `/theme`, `/doctor`, `/config` | adjust appearance and inspect setup |
|
||||
|
||||
### Headless `exec`
|
||||
## Headless `exec`
|
||||
|
||||
```bash
|
||||
zero exec "explain internal/agent/loop.go"
|
||||
zero exec --model claude-sonnet-4.5 "refactor the config loader"
|
||||
# one-shot
|
||||
zero exec "explain internal/agent/loop.go and suggest one improvement"
|
||||
|
||||
# pick a model and mode preset per task
|
||||
zero exec --model claude-sonnet-4.5 --mode deep "refactor the session store"
|
||||
|
||||
# spec-first: draft → review → approve → build
|
||||
zero exec --use-spec "add rate limiting to the API client"
|
||||
zero exec --worktree "try the migration in an isolated worktree"
|
||||
zero exec --resume
|
||||
zero exec --fork <session-id> "try the other approach"
|
||||
```
|
||||
|
||||
Programmatic use:
|
||||
# run in an isolated git worktree, escalate model only if needed
|
||||
zero exec -w --allow-escalation "migrate the config loader to v2"
|
||||
|
||||
```bash
|
||||
# multi-turn programmatic I/O over stdio
|
||||
zero exec --input-format stream-json --output-format stream-json < turns.jsonl
|
||||
|
||||
# resume or fork any previous session
|
||||
zero exec --resume # latest
|
||||
zero exec --fork <session-id> "now try the other approach"
|
||||
```
|
||||
|
||||
The stream-JSON contract is documented in
|
||||
[docs/STREAM_JSON_PROTOCOL.md](docs/STREAM_JSON_PROTOCOL.md).
|
||||
Key flags: `-m/--model` · `--mode <smart|deep|fast|large|precise>` · `--image` · `--use-spec` · `--auto <low|medium|high>` · `--enabled-tools/--disabled-tools` · `-w/--worktree` · `--add-dir <path>` (repeatable) · `--resume/--fork` · `--allow-escalation` · `--notify` · `-o <text|json|stream-json>`.
|
||||
|
||||
## Safety Model
|
||||
stdout carries **only** program output; logs go to stderr. Full contract in [`docs/STREAM_JSON_PROTOCOL.md`](docs/STREAM_JSON_PROTOCOL.md).
|
||||
|
||||
Zero is designed to make side effects visible.
|
||||
## Commands
|
||||
|
||||
- Workspace reads are allowed by default.
|
||||
- File writes are limited to the workspace unless you grant another directory.
|
||||
- Shell commands, network access, destructive commands, and elevated actions are
|
||||
permission-gated.
|
||||
- `--add-dir <path>` and `/add-dir <path>` grant additional write roots without
|
||||
giving the agent the whole filesystem.
|
||||
- Unsafe/autonomous modes are explicit opt-ins.
|
||||
- Secrets are redacted from tool output and logs where Zero controls the surface.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
zero --add-dir ../docs-site
|
||||
zero exec --add-dir ../shared "update both repos"
|
||||
```
|
||||
|
||||
Sandbox behavior can be inspected with:
|
||||
|
||||
```bash
|
||||
zero sandbox policy
|
||||
zero sandbox grants list
|
||||
```
|
||||
|
||||
## Web And Local Control
|
||||
|
||||
Zero includes local file/search/edit/shell tools, `web_fetch` for public URLs,
|
||||
and MCP support for additional tools.
|
||||
|
||||
For local dev servers, use shell commands such as `curl` through `exec_command`
|
||||
so the normal sandbox and permission policy applies. Long-running commands stay
|
||||
attached to a background terminal session and can be listed or stopped from the
|
||||
TUI.
|
||||
|
||||
The npm package also includes browser and terminal helper packages used by local
|
||||
browser/terminal tools. Source builds can use the same helpers when they are on
|
||||
`PATH` or configured in Zero's local-control settings.
|
||||
|
||||
## Common Commands
|
||||
|
||||
```text
|
||||
zero interactive TUI
|
||||
zero exec one-shot or scripted agent run
|
||||
zero setup first-run provider setup
|
||||
zero auth OAuth/login helpers for supported providers
|
||||
zero models model registry and capabilities
|
||||
zero providers provider profiles and detection
|
||||
zero doctor setup, key, and connectivity checks
|
||||
zero context context-budget report
|
||||
zero repo-map deterministic repository map
|
||||
zero repo-info local repository summary
|
||||
zero search | find search local session history
|
||||
zero sessions inspect, resume, fork, and rewind sessions
|
||||
zero spec manage spec-mode drafts
|
||||
zero specialist manage specialist subagents
|
||||
zero skills manage markdown instruction skills
|
||||
zero plugins manage plugins
|
||||
zero hooks manage lifecycle hooks
|
||||
zero mcp manage MCP servers and tools
|
||||
zero serve --mcp expose Zero tools over MCP stdio
|
||||
zero sandbox inspect sandbox policy and grants
|
||||
zero worktrees prepare isolated git worktrees
|
||||
zero verify detect and run local verification checks
|
||||
zero changes inspect and commit local git changes
|
||||
zero exec one-shot / scripted agent runs
|
||||
zero setup guided first-run provider setup
|
||||
zero models model registry (capabilities, context, cost)
|
||||
zero providers provider profiles + 25-provider catalog
|
||||
zero doctor config, key, and connectivity health checks
|
||||
zero context workspace context-budget report
|
||||
zero repo-map deterministic repository map for agent context
|
||||
zero repo-info local (network-free) repository characterizer
|
||||
zero search | find full-text search over local session history
|
||||
zero sessions session lineage inspection
|
||||
zero spec review & approve saved spec-mode drafts
|
||||
zero specialist manage subagent profiles
|
||||
zero skills markdown instruction skills
|
||||
zero plugins plugin manifests
|
||||
zero hooks lifecycle hook configuration
|
||||
zero mcp MCP client settings
|
||||
zero serve --mcp expose Zero's tools over MCP stdio
|
||||
zero sandbox sandbox policy & persistent grants
|
||||
zero worktrees isolated git worktrees for agent runs
|
||||
zero verify detect & run local verification checks
|
||||
zero changes inspect & commit local git changes
|
||||
zero usage token usage and estimated cost
|
||||
zero cron scheduled agent jobs
|
||||
zero cron scheduled agent jobs (file-backed, dep-free)
|
||||
zero update check for newer releases
|
||||
```
|
||||
|
||||
## Appearance And Accessibility
|
||||
## Providers
|
||||
|
||||
| Control | Effect |
|
||||
Bring your own key — or no key at all for local runtimes.
|
||||
|
||||
| Tier | Providers |
|
||||
|---|---|
|
||||
| `NO_COLOR=<anything>` | disables color output |
|
||||
| `ZERO_THEME=<name>` | selects the startup theme (`auto`, `dark`, `light`, or a color theme like `dracula`, `nord`, `gruvbox`, `tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, `solarized-light`) |
|
||||
| `--theme <name>` | selects the TUI theme from the CLI (same names) |
|
||||
| `/theme` | opens the theme picker inside the TUI (live preview; `/theme <name>` switches directly) |
|
||||
| `ZERO_NO_FADE=1` | disables streaming fade animation |
|
||||
| **Frontier APIs** | OpenAI · Anthropic · Google Gemini |
|
||||
| **Fast cloud inference** | Groq · OpenRouter · Together AI · DeepSeek · Mistral · xAI · NVIDIA NIM |
|
||||
| **Local — no key, no cloud** | Ollama · LM Studio |
|
||||
| **More clouds** | Ollama Cloud · DashScope (Qwen) · Moonshot (Kimi) · MiniMax · Z.ai · Venice · GitHub Models · and more |
|
||||
| **Enterprise (catalog)** | Amazon Bedrock · Vertex AI *(adapters in progress)* |
|
||||
| **Anything else** | any OpenAI-compatible or Anthropic-compatible endpoint |
|
||||
|
||||
Meaning does not rely on color alone; diffs, permissions, and statuses also use
|
||||
text or glyph markers.
|
||||
The model registry tracks each model's capabilities, context window, and cost — and the live model picker discovers what your provider actually serves.
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Purpose | Side effect |
|
||||
|---|---|---|
|
||||
| `read_file` · `list_directory` · `grep` · `glob` | explore & search | read |
|
||||
| `web_fetch` | fetch docs & references | network |
|
||||
| `update_plan` · `ask_user` | plan & clarify | none |
|
||||
| `write_file` · `edit_file` · `apply_patch` | create & modify | write (gated) |
|
||||
| `bash` | run shell commands | shell (gated) |
|
||||
| `Task` · `TaskOutput` · `TaskStop` | delegate to specialist subagents | per-tool gating |
|
||||
| `GenerateSpecialist` | create new subagent manifests | write (gated) |
|
||||
| `skill` | load markdown instruction skills | read |
|
||||
| `tool_search` | lazily load deferred tools (large MCP sets stay cheap) | none |
|
||||
| `escalate_model` | request a stronger model mid-run | gated by `--allow-escalation` |
|
||||
|
||||
Every mutating tool routes through the permission policy **before** any side effect.
|
||||
|
||||
### Web search & scraping (free, no API key)
|
||||
|
||||
`web_fetch` is built in and always available: it pulls a single URL into clean
|
||||
markdown locally, with no third party. For **search**, JS-rendered scraping,
|
||||
whole-site crawls, PDF→markdown, and structured extraction, Zero ships the
|
||||
[Firecrawl](https://firecrawl.dev) **keyless** MCP server **enabled by default**, so
|
||||
web search and scraping work out of the box with **no setup and no API key** (1,000
|
||||
free credits/month). It is a normal MCP server, so you stay in control:
|
||||
|
||||
```bash
|
||||
zero mcp tools list # see the firecrawl_* tools it exposes
|
||||
zero mcp disable firecrawl # turn it off — web_fetch stays as the local floor
|
||||
```
|
||||
|
||||
- **Privacy / disclosure:** keyless requests route through `firecrawl.dev`. If you
|
||||
prefer nothing leaves your machine, `zero mcp disable firecrawl` and rely on
|
||||
`web_fetch`, or self-host below.
|
||||
- **Self-host (unlimited, private):** Firecrawl is open source (AGPL-3.0). Run your
|
||||
own instance and override the default URL — Zero only *calls* it over the network,
|
||||
so the AGPL never reaches into Zero's own code:
|
||||
```jsonc
|
||||
// config.json
|
||||
{ "mcp": { "servers": { "firecrawl": { "type": "http", "url": "http://localhost:3002/v2/mcp" } } } }
|
||||
```
|
||||
- **Bring your own key (higher limits):** add an auth header over the default:
|
||||
```bash
|
||||
zero mcp add firecrawl --type http --url https://mcp.firecrawl.dev/v2/mcp \
|
||||
--header "Authorization: Bearer fc-your-key"
|
||||
```
|
||||
|
||||
An MCP server that can't be reached at startup (e.g. Firecrawl on an offline
|
||||
machine) is **skipped with a warning, not fatal** — Zero still launches and the
|
||||
rest of its tools, including `web_fetch`, keep working.
|
||||
|
||||
### Extra write directories (`--add-dir`)
|
||||
|
||||
Zero confines writes to the workspace by default. To let the agent write somewhere
|
||||
else, pass the repeatable `--add-dir` flag — it works for both the interactive TUI
|
||||
and `zero exec`:
|
||||
|
||||
```bash
|
||||
zero --add-dir ~/Desktop/scratch # launch the TUI with an extra write root
|
||||
zero exec --add-dir ../sibling-repo "update both repos"
|
||||
```
|
||||
|
||||
In the TUI, `/add-dir <path>` grants a directory mid-session (session-only), and a
|
||||
bare `/add-dir` lists the current write roots. To persist extra roots across
|
||||
sessions, set `sandbox.additionalWriteRoots` in the **global** user config
|
||||
(`~/.config/zero/config.json`); the key is deliberately ignored in project config
|
||||
so a checked-out repo can't widen its own sandbox. Flag and config sources merge
|
||||
as a union.
|
||||
|
||||
Granted roots must already exist (the filesystem root is rejected), symlinks are
|
||||
resolved when the grant is made, and the same per-root symlink-traversal checks
|
||||
that protect the workspace apply to each extra root. Relative paths in tool calls
|
||||
still resolve against the workspace only, and network and destructive-shell policy
|
||||
are unchanged. A write denied outside all roots returns an error that suggests
|
||||
`/add-dir`.
|
||||
|
||||
Two extra hardening/diagnostic flags are available in the `sandbox` config block,
|
||||
both **off by default** and safe to leave unset:
|
||||
|
||||
- `sandbox.blockUnixSockets` (Linux) — asks the Linux sandbox helper to install a
|
||||
best-effort seccomp filter that denies `AF_UNIX` socket creation in the
|
||||
sandboxed command. Linux packages also continue to ship `zero-seccomp` as a
|
||||
compatibility wrapper for existing direct uses; new sandbox plans do not depend
|
||||
on that external wrapper.
|
||||
- `sandbox.monitorDenials` (macOS) — tails the unified log for this run's seatbelt
|
||||
denials and appends them to a command's stderr as a `<sandbox_violations>` 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
|
||||
|
||||
```
|
||||
TUI (Bubble Tea) headless exec MCP server cron runner
|
||||
└──────────────────────┬──────────────────────┘
|
||||
surface-agnostic agent core
|
||||
(loop · typed event stream · tool registry)
|
||||
┌──────────┬──────────┬───────────┬───────────┬────────────┬──────────┐
|
||||
providers tools sessions specialist repo intel permissions
|
||||
+ catalog registry + search + background + workspace + sandbox
|
||||
+ registry + rewind tasks index + redaction
|
||||
```
|
||||
|
||||
- **Surface-agnostic core** — the agent loop streams text + tool calls and emits one typed event stream consumed identically by the TUI, `exec`, the MCP server, and cron.
|
||||
- **Edges are interfaces** — `Provider`, `Tool`, `SessionStore`, and the permission policy are swappable.
|
||||
- **Model is data** — capabilities, cost, and routing live in the registry, never hard-coded.
|
||||
- **Pure Go** — one static binary per platform; the npm wrapper just delegates to it.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
cmd/
|
||||
zero/ production CLI entrypoint
|
||||
zero-release/ release builder + smoke tests
|
||||
zero-perf-bench/ performance benchmarks
|
||||
zero-pr-review/ deterministic PR review helper
|
||||
internal/
|
||||
agent/ zeroruntime/ agent loop & runtime orchestration
|
||||
cli/ command surface (exec, doctor, cron, ...)
|
||||
tui/ Bubble Tea terminal interface
|
||||
providers/ providercatalog/ providermodelcatalog/
|
||||
modelregistry/ capabilities, context windows, cost
|
||||
tools/ read/write/edit/bash/grep/glob/patch/...
|
||||
specialist/ background/ subagents + out-of-process tasks
|
||||
sessions/ search/ append-only store, full-text search
|
||||
repomap/ repoinfo/ workspaceindex/ contextreport/
|
||||
specmode/ cron/ skills/ plugins/ hooks/ mcp/
|
||||
sandbox/ redaction/ secrets/ safety surfaces
|
||||
doctor/ providerhealth/ verify/ selfverify/
|
||||
docs/ PRD, protocols, install/update/perf
|
||||
scripts/ installers
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go run ./cmd/zero-release build
|
||||
go run ./cmd/zero-release smoke
|
||||
go run ./cmd/zero-perf-bench
|
||||
```
|
||||
go test ./... # full test suite (200+ test files)
|
||||
go run ./cmd/zero-release build # compile the release binary
|
||||
go run ./cmd/zero-release smoke # smoke-test it
|
||||
go run ./cmd/zero-perf-bench # perf benchmarks (docs/PERFORMANCE.md)
|
||||
|
||||
Cross-compile examples:
|
||||
|
||||
```bash
|
||||
# cross-compile
|
||||
go run ./cmd/zero-release build --goos linux --goarch amd64
|
||||
go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero.exe
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Install](docs/INSTALL.md)
|
||||
- [Update flow](docs/UPDATE.md)
|
||||
- [Stream-JSON protocol](docs/STREAM_JSON_PROTOCOL.md)
|
||||
- [Specialists](docs/SPECIALISTS.md)
|
||||
- [GitHub Action](docs/GITHUB_ACTION.md)
|
||||
- [Benchmarks](docs/BENCHMARK.md)
|
||||
- [Performance](docs/PERFORMANCE.md)
|
||||
- [Agent evals](docs/AGENT_EVALS.md)
|
||||
- [Product Requirements (PRD)](docs/PRD.md) — vision, full feature spec, roadmap
|
||||
- [Stream-JSON protocol](docs/STREAM_JSON_PROTOCOL.md) — headless I/O contract
|
||||
- [Specialists](docs/SPECIALISTS.md) — subagent manifests, Task tools, background state
|
||||
- [Install](docs/INSTALL.md) · [Update](docs/UPDATE.md) · [Performance](docs/PERFORMANCE.md)
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md), run the
|
||||
relevant tests, and open a focused pull request.
|
||||
|
||||
Security reports should follow [SECURITY.md](SECURITY.md).
|
||||
Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). Run `go test ./...` and the relevant build or smoke command before opening a PR.
|
||||
|
||||
## License
|
||||
|
||||
Zero is released under the [MIT License](LICENSE).
|
||||
License is being finalized; a `LICENSE` file will be added before the public release.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<sub><b>Zero</b> — one terminal · every model</sub>
|
||||
</div>
|
||||
-297
@@ -1,297 +0,0 @@
|
||||
<p align="center">
|
||||
<img src="docs/assets/zero-logo.png" alt="Zero" width="385">
|
||||
</p>
|
||||
|
||||
<p align="center"><strong>一个独属于你的终端编码智能体。</strong></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||
<img alt="Go 1.25+" src="https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<br>
|
||||
<a href="README.md">English</a> | <strong>中文</strong>
|
||||
</p>
|
||||
|
||||
Zero 是一个用于本地终端的 AI 编码智能体。它可以检查仓库、编辑文件、运行命令、使用浏览器/终端辅助工具,并在你选择模型和权限级别的同时保持持久的本地会话。
|
||||
|
||||
```bash
|
||||
zero
|
||||
zero exec "修复 ./pkg 中失败的测试"
|
||||
zero exec --output-format stream-json < turns.jsonl
|
||||
```
|
||||
|
||||
## 为什么选择 Zero
|
||||
|
||||
- **使用你想要的模型。** 支持 OpenAI、Anthropic、Gemini、Groq、OpenRouter、DeepSeek、Mistral、xAI、Qwen、Kimi、GitHub Models、Ollama、LM Studio,或任何 OpenAI/Anthropic 兼容端点。
|
||||
- **保持控制权。** 文件写入、Shell 命令、网络访问和工作区外写入都经过 Zero 的权限和沙箱策略。
|
||||
- **在终端中工作。** TUI 具有模型/提供商选择器、图片输入、斜杠命令、实时计划/工具渲染、回滚滚动、主题以及恢复/分叉支持。
|
||||
- **无 TUI 也能工作。** `zero exec` 可脚本化,支持文本/JSON/stream-JSON I/O、隔离的工作树、规范优先运行,以及用于 CI 的有意义的退出码。
|
||||
- **保持上下文本地化。** 会话存储在磁盘上,可搜索、可恢复,且 Zero 不会作为遥测数据上传。
|
||||
- **可扩展。** 使用 MCP 服务器、技能、插件、钩子和来自同一 CLI 的专业子智能体。
|
||||
|
||||
## 安装
|
||||
|
||||
### npm
|
||||
|
||||
```bash
|
||||
npm install -g @gitlawb/zero
|
||||
zero
|
||||
```
|
||||
|
||||
npm 包安装一个小型包装器以及与你平台匹配的 Zero 二进制文件(从 GitHub Releases 获取)。支持 Linux、macOS 和 Windows 的 x64 和 arm64。
|
||||
|
||||
### Bun
|
||||
|
||||
Bun 默认不运行依赖的生命周期脚本,因此获取 Zero 二进制文件的 `postinstall` 会被跳过,首次运行会失败并显示 `No native binary found next to the npm wrapper`。
|
||||
|
||||
最简单的解决方法是安装后信任该包,这会运行被阻止的 postinstall。项目安装和全局安装均适用:
|
||||
|
||||
```bash
|
||||
# 项目安装
|
||||
bun add @gitlawb/zero
|
||||
bun pm trust @gitlawb/zero
|
||||
|
||||
# 全局安装
|
||||
bun add -g @gitlawb/zero
|
||||
bun pm -g trust @gitlawb/zero
|
||||
```
|
||||
|
||||
其他方式:在 `bun add` 之前将 `"trustedDependencies": ["@gitlawb/zero"]` 添加到项目的 package.json 以提前允许 postinstall;或在不支持 `bun pm trust` 的旧版 Bun 上手动运行安装程序(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`)。
|
||||
|
||||
### 安装脚本
|
||||
|
||||
Linux/macOS:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
### 从源码构建
|
||||
|
||||
源码构建需要 Go 1.25+。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
cd zero
|
||||
go run ./cmd/zero
|
||||
```
|
||||
|
||||
发布安装器和 npm 包需要已发布的 GitHub Release 资源。如果你在首次公开发布之前进行测试,请从源码构建:
|
||||
|
||||
```bash
|
||||
go build -o zero ./cmd/zero
|
||||
```
|
||||
|
||||
在 Linux 上,如果你需要原生沙箱,还需要构建沙箱辅助程序:
|
||||
|
||||
```bash
|
||||
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
|
||||
go build -o zero-seccomp ./cmd/zero-seccomp # 可选的兼容性包装器
|
||||
```
|
||||
|
||||
将 `zero` 和 `zero-linux-sandbox` 放在 `PATH` 上的同一目录中(`~/.local/bin` 是一个好的默认选择)。macOS 不需要额外的辅助二进制文件。Windows 源码构建可以使用主 `zero.exe` 作为沙箱辅助程序;发布包仍然附带独立的 Windows 辅助可执行文件。
|
||||
|
||||
更多安装细节:[docs/INSTALL.md](docs/INSTALL.md)。
|
||||
|
||||
## 首次运行
|
||||
|
||||
启动 TUI:
|
||||
|
||||
```bash
|
||||
zero
|
||||
```
|
||||
|
||||
设置向导帮助你选择提供商和模型。你也可以从命令行配置提供商:
|
||||
|
||||
```bash
|
||||
zero setup
|
||||
zero providers list
|
||||
zero models list
|
||||
zero doctor
|
||||
```
|
||||
|
||||
对于 API 提供商,在设置之前设置匹配的环境变量或在向导中输入密钥:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=sk-...
|
||||
export ANTHROPIC_API_KEY=...
|
||||
export GEMINI_API_KEY=...
|
||||
export LONGCAT_API_KEY=...
|
||||
```
|
||||
|
||||
要直接配置美团 LongCat(LongCat-2.0),运行:
|
||||
|
||||
```bash
|
||||
zero providers setup longcat --set-active
|
||||
```
|
||||
|
||||
对于本地模型,运行 Ollama 或 LM Studio,然后使用 `zero setup` 或 `zero providers detect`。
|
||||
|
||||
## 日常使用
|
||||
|
||||
### 交互式 TUI
|
||||
|
||||
```bash
|
||||
zero
|
||||
```
|
||||
|
||||
常用控制:
|
||||
|
||||
| 控制 | 操作 |
|
||||
|---|---|
|
||||
| `Enter` | 发送提示 |
|
||||
| `/` | 打开斜杠命令建议 |
|
||||
| `Shift+Tab` | 切换权限模式 |
|
||||
| `Ctrl+B` | 显示/隐藏侧边栏 |
|
||||
| `Ctrl+C` | 取消或退出 |
|
||||
|
||||
常用斜杠命令:
|
||||
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `/model`、`/provider` | 切换活动模型/提供商 |
|
||||
| `/spec`、`/plan` | 在构建之前起草和审查计划 |
|
||||
| `/image` | 为视觉模型附加图片 |
|
||||
| `/resume`、`/rewind` | 继续或回滚本地会话 |
|
||||
| `/compact`、`/context` | 管理上下文使用 |
|
||||
| `/permissions`、`/tools` | 检查可用工具和策略 |
|
||||
| `/add-dir` | 为此会话授予额外的写入目录 |
|
||||
| `/theme`、`/doctor`、`/config` | 调整外观和检查设置 |
|
||||
|
||||
### 无头 `exec` 模式
|
||||
|
||||
```bash
|
||||
zero exec "解释 internal/agent/loop.go"
|
||||
zero exec --model claude-sonnet-4.5 "重构配置加载器"
|
||||
zero exec --use-spec "为 API 客户端添加速率限制"
|
||||
zero exec --worktree "在隔离的工作树中尝试迁移"
|
||||
zero exec --resume
|
||||
zero exec --fork <session-id> "尝试另一种方法"
|
||||
```
|
||||
|
||||
编程使用:
|
||||
|
||||
```bash
|
||||
zero exec --input-format stream-json --output-format stream-json < turns.jsonl
|
||||
```
|
||||
|
||||
Stream-JSON 协议文档在 [docs/STREAM_JSON_PROTOCOL.md](docs/STREAM_JSON_PROTOCOL.md)。
|
||||
|
||||
## 安全模型
|
||||
|
||||
Zero 旨在使副作用可见。
|
||||
|
||||
- 工作区读取默认允许。
|
||||
- 文件写入限制在工作区内,除非你授予其他目录。
|
||||
- Shell 命令、网络访问、破坏性命令和提权操作需要权限授权。
|
||||
- `--add-dir <path>` 和 `/add-dir <path>` 授予额外的写入根目录,而不会给智能体整个文件系统。
|
||||
- 不安全/自主模式是显式选择加入。
|
||||
- 在 Zero 控制的界面上,密钥会从工具输出和日志中被脱敏。
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
zero --add-dir ../docs-site
|
||||
zero exec --add-dir ../shared "更新两个仓库"
|
||||
```
|
||||
|
||||
沙箱行为可以通过以下方式检查:
|
||||
|
||||
```bash
|
||||
zero sandbox policy
|
||||
zero sandbox grants list
|
||||
```
|
||||
|
||||
## Web 和本地控制
|
||||
|
||||
Zero 包含本地文件/搜索/编辑/Shell 工具、用于公共 URL 的 `web_fetch`,以及用于额外工具的 MCP 支持。
|
||||
|
||||
对于本地开发服务器,使用 Shell 命令(如通过 `exec_command` 的 `curl`),这样正常的沙箱和权限策略就会生效。长时间运行的命令保持附加到后台终端会话,可以从 TUI 中列出或停止。
|
||||
|
||||
npm 包还包含本地浏览器/终端工具使用的浏览器和终端辅助包。源码构建可以在它们位于 `PATH` 上或在 Zero 的本地控制设置中配置时使用相同的辅助工具。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```text
|
||||
zero 交互式 TUI
|
||||
zero exec 一次性或脚本化智能体运行
|
||||
zero setup 首次运行提供商设置
|
||||
zero auth 支持提供商的 OAuth/登录辅助
|
||||
zero models 模型注册表和能力
|
||||
zero providers 提供商配置和检测
|
||||
zero doctor 设置、密钥和连接检查
|
||||
zero context 上下文预算报告
|
||||
zero repo-map 确定性仓库映射
|
||||
zero repo-info 本地仓库摘要
|
||||
zero search | find 搜索本地会话历史
|
||||
zero sessions 检查、恢复、分叉和回滚会话
|
||||
zero spec 管理规范模式草稿
|
||||
zero specialist 管理专业子智能体
|
||||
zero skills 管理 Markdown 指令技能
|
||||
zero plugins 管理插件
|
||||
zero hooks 管理生命周期钩子
|
||||
zero mcp 管理 MCP 服务器和工具
|
||||
zero serve --mcp 通过 MCP stdio 暴露 Zero 工具
|
||||
zero sandbox 检查沙箱策略和授权
|
||||
zero worktrees 准备隔离的 Git 工作树
|
||||
zero verify 检测和运行本地验证检查
|
||||
zero changes 检查和提交本地 Git 变更
|
||||
zero usage Token 使用量和估算成本
|
||||
zero cron 定时智能体任务
|
||||
zero update 检查更新版本
|
||||
```
|
||||
|
||||
## 外观和无障碍
|
||||
|
||||
| 控制 | 效果 |
|
||||
|---|---|
|
||||
| `NO_COLOR=<任意值>` | 禁用颜色输出 |
|
||||
| `ZERO_THEME=<名称>` | 选择启动主题(`auto`、`dark`、`light`,或颜色主题如 `dracula`、`nord`、`gruvbox`、`tokyo-night`、`catppuccin`、`one-dark`、`solarized-dark`、`rose-pine`、`everforest`、`solarized-light`) |
|
||||
| `--theme <名称>` | 从 CLI 选择 TUI 主题(相同名称) |
|
||||
| `/theme` | 在 TUI 中打开主题选择器(实时预览;`/theme <名称>` 直接切换) |
|
||||
| `ZERO_NO_FADE=1` | 禁用流式淡入动画 |
|
||||
|
||||
含义不仅仅依赖于颜色;差异、权限和状态也使用文本或标记符号。
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go run ./cmd/zero-release build
|
||||
go run ./cmd/zero-release smoke
|
||||
go run ./cmd/zero-perf-bench
|
||||
```
|
||||
|
||||
交叉编译示例:
|
||||
|
||||
```bash
|
||||
go run ./cmd/zero-release build --goos linux --goarch amd64
|
||||
go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero.exe
|
||||
```
|
||||
|
||||
## 文档
|
||||
|
||||
- [安装](docs/INSTALL.md)
|
||||
- [更新流程](docs/UPDATE.md)
|
||||
- [Stream-JSON 协议](docs/STREAM_JSON_PROTOCOL.md)
|
||||
- [专家](docs/SPECIALISTS.md)
|
||||
- [GitHub Action](docs/GITHUB_ACTION.md)
|
||||
- [基准测试](docs/BENCHMARK.md)
|
||||
- [性能](docs/PERFORMANCE.md)
|
||||
- [智能体评估](docs/AGENT_EVALS.md)
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎贡献。阅读 [CONTRIBUTING.md](CONTRIBUTING.md),运行相关测试,然后提交一个聚焦的拉取请求。
|
||||
|
||||
安全报告应遵循 [SECURITY.md](SECURITY.md)。
|
||||
|
||||
## 许可证
|
||||
|
||||
Zero 基于 [MIT 许可证](LICENSE) 发布。
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
# 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).
|
||||
+5
-4
@@ -27,14 +27,14 @@ inputs:
|
||||
api-key:
|
||||
description: >-
|
||||
The provider API key. Pass it from a repository or organization secret;
|
||||
it is exported only for the ZERO step and never written to the log.
|
||||
it is exported into the step environment and never written to the log.
|
||||
required: false
|
||||
default: ""
|
||||
api-key-env:
|
||||
description: >-
|
||||
Name of the environment variable the chosen provider reads its key from
|
||||
(for example OPENAI_API_KEY or ANTHROPIC_API_KEY). When set together with
|
||||
api-key, that variable is exported only for the ZERO step. Leave the action
|
||||
api-key, that variable is exported for the run. Leave the action
|
||||
provider-agnostic by passing the env name your provider expects rather
|
||||
than hardcoding one here.
|
||||
required: false
|
||||
@@ -192,9 +192,10 @@ runs:
|
||||
echo "::error::api-key-env must be a valid environment variable name." >&2
|
||||
exit 2
|
||||
fi
|
||||
# Export in-process for this Run ZERO step so zero can read it; do
|
||||
# not persist provider credentials to later job steps.
|
||||
# Export in-process so the zero invocation in this same run block sees
|
||||
# it, and append to GITHUB_ENV so any later steps inherit it too.
|
||||
export "${ZERO_INPUT_API_KEY_ENV}=${ZERO_INPUT_API_KEY}"
|
||||
printf '%s=%s\n' "${ZERO_INPUT_API_KEY_ENV}" "${ZERO_INPUT_API_KEY}" >> "${GITHUB_ENV}"
|
||||
fi
|
||||
if [ -n "${ZERO_INPUT_PROVIDER}" ]; then
|
||||
export ZERO_PROVIDER="${ZERO_INPUT_PROVIDER}"
|
||||
|
||||
+1
-70
@@ -9,87 +9,18 @@ function zeroBinaryName(platform = process.platform) {
|
||||
return platform === 'win32' ? 'zero.exe' : 'zero';
|
||||
}
|
||||
|
||||
function helperShimNames(name, platform = process.platform) {
|
||||
if (platform === 'win32') {
|
||||
return [`${name}.cmd`, `${name}.exe`, name];
|
||||
}
|
||||
return [name];
|
||||
}
|
||||
|
||||
function commandForShim(path, platform = process.platform) {
|
||||
if (platform === 'win32' && path.toLowerCase().endsWith('.cmd')) {
|
||||
return {
|
||||
command: process.env.ComSpec || 'cmd.exe',
|
||||
prefixArgs: ['/d', '/s', '/c', `"${path.replace(/"/g, '""')}"`],
|
||||
};
|
||||
}
|
||||
return { command: path, prefixArgs: [] };
|
||||
}
|
||||
|
||||
function resolveHelper(packageRoot, name) {
|
||||
const binDir = join(packageRoot, 'node_modules', '.bin');
|
||||
for (const shimName of helperShimNames(name)) {
|
||||
const candidate = join(binDir, shimName);
|
||||
if (!existsSync(candidate)) continue;
|
||||
return {
|
||||
...commandForShim(candidate),
|
||||
pathPrepend: [binDir],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function localControlHelperManifest(packageRoot) {
|
||||
const helpers = {};
|
||||
for (const name of ['agent-browser', 'tuistory']) {
|
||||
const helper = resolveHelper(packageRoot, name);
|
||||
if (helper) helpers[name] = helper;
|
||||
}
|
||||
if (Object.keys(helpers).length === 0) return '';
|
||||
return JSON.stringify({ version: 1, helpers });
|
||||
}
|
||||
|
||||
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const nativePath = join(packageRoot, zeroBinaryName());
|
||||
const localControlHelpers = localControlHelperManifest(packageRoot);
|
||||
|
||||
if (!existsSync(nativePath)) {
|
||||
const postinstallScript = join(packageRoot, 'scripts', 'postinstall.mjs');
|
||||
const ranByBun = process.execPath.includes('bun') || !!process.versions?.bun;
|
||||
console.error(
|
||||
'[zero] No native binary found next to the npm wrapper.\n' +
|
||||
'The platform binary is fetched at install time by a postinstall script,\n' +
|
||||
'which did not run (or was skipped) for this install.\n' +
|
||||
'\n' +
|
||||
'Fix it now by running the installer manually:\n' +
|
||||
` node "${postinstallScript}"\n` +
|
||||
'\n' +
|
||||
(ranByBun
|
||||
? 'You installed with Bun, which does not run dependency lifecycle scripts\n' +
|
||||
'by default. Trust the package to run the blocked postinstall:\n' +
|
||||
' bun pm trust @gitlawb/zero (project install)\n' +
|
||||
' bun pm -g trust @gitlawb/zero (global install)\n' +
|
||||
'On Bun versions without `bun pm trust`, add\n' +
|
||||
' "trustedDependencies": ["@gitlawb/zero"]\n' +
|
||||
'to your project package.json and reinstall.\n' +
|
||||
'\n'
|
||||
: '') +
|
||||
'If that fails, build from source: https://github.com/Gitlawb/zero\n' +
|
||||
'(go run ./cmd/zero, requires Go 1.25+).',
|
||||
'[zero] No native binary found next to the npm wrapper. Reinstall the zero package or run `go run ./cmd/zero-release build` from the repository.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const env = { ...process.env };
|
||||
if (localControlHelpers) {
|
||||
env.ZERO_LOCAL_CONTROL_HELPERS = localControlHelpers;
|
||||
} else {
|
||||
delete env.ZERO_LOCAL_CONTROL_HELPERS;
|
||||
}
|
||||
|
||||
const child = spawnSync(nativePath, process.argv.slice(2), {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
});
|
||||
|
||||
if (child.error) {
|
||||
|
||||
+9
-10
@@ -41,9 +41,8 @@ jobs:
|
||||
> provider-neutral: `api-key-env` is the environment variable name the chosen
|
||||
> provider reads its key from (for example `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`,
|
||||
> `OPENROUTER_API_KEY`), and `api-key` is the secret value. The action exports
|
||||
> that variable only for the ZERO step and never prints it. If your repository
|
||||
> already commits a `.zero/config.json` with an active provider, you can omit
|
||||
> `provider`.
|
||||
> that variable for the run and never prints it. If your repository already
|
||||
> commits a `.zero/config.json` with an active provider, you can omit `provider`.
|
||||
|
||||
## Inputs
|
||||
|
||||
@@ -52,8 +51,8 @@ jobs:
|
||||
| `prompt` | one of `prompt`/`prompt-file` | `""` | The instruction for ZERO to execute. |
|
||||
| `prompt-file` | one of `prompt`/`prompt-file` | `""` | Path (relative to `working-directory`) to a file whose contents are the prompt. |
|
||||
| `provider` | no | `""` | Provider id to activate (e.g. `openai`, `anthropic`, `gemini`, `ollama`, or any compatible endpoint). |
|
||||
| `api-key` | no | `""` | The provider API key. Pass from a secret; exported only for the ZERO step and never logged. |
|
||||
| `api-key-env` | no | `""` | Env var name the provider reads its key from (e.g. `OPENAI_API_KEY`). Exported only for the ZERO step when used with `api-key`. |
|
||||
| `api-key` | no | `""` | The provider API key. Pass from a secret; never logged. |
|
||||
| `api-key-env` | no | `""` | Env var name the provider reads its key from (e.g. `OPENAI_API_KEY`). Exported with `api-key`. |
|
||||
| `model` | no | `""` | Model id. Defaults to the resolved provider's default. |
|
||||
| `mode` | no | `""` | Run mode (`zero exec --mode`), e.g. `smart`, `deep`, `fast`. |
|
||||
| `auto` | no | `low` | Autonomy ceiling (`zero exec --auto`): `low`, `medium`, or `high`. Conservative by default. |
|
||||
@@ -163,10 +162,9 @@ jobs:
|
||||
## Security notes
|
||||
|
||||
- **Secrets are passed as Action secrets and never logged.** The action exports
|
||||
`api-key` (under `api-key-env`) only for the ZERO step. `slack-webhook-url`
|
||||
is passed only to the Slack post step. They are never echoed or persisted to
|
||||
later workflow steps, and ZERO redacts secret-shaped strings from its own
|
||||
output.
|
||||
`api-key` (under `api-key-env`) and `slack-webhook-url` into the step
|
||||
environment only. They are never echoed, and ZERO redacts secret-shaped
|
||||
strings from its own output.
|
||||
- **The sandbox is always active.** This action never passes
|
||||
`--skip-permissions-unsafe`, so writes stay inside the checked-out repository
|
||||
(plus any roots you grant with `add-dir`). Unsafe mode is never enabled
|
||||
@@ -200,4 +198,5 @@ 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` when a proxy is configured.
|
||||
`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.
|
||||
+31
-148
@@ -1,81 +1,27 @@
|
||||
# Installing Zero
|
||||
# Zero Install Scripts
|
||||
|
||||
Zero is distributed as:
|
||||
Zero release archives are published as:
|
||||
|
||||
- an npm package, `@gitlawb/zero`
|
||||
- release archives on GitHub Releases
|
||||
- source builds with Go 1.25+
|
||||
- `zero-v<version>-linux-<arch>.tar.gz`
|
||||
- `zero-v<version>-macos-<arch>.tar.gz`
|
||||
- `zero-v<version>-windows-<arch>.zip`
|
||||
|
||||
The npm package and install scripts download a platform-specific release archive.
|
||||
They require a published GitHub Release for the requested version.
|
||||
Each archive must have a matching `.sha256` file. The install scripts download both files and verify the checksum before copying the binary.
|
||||
|
||||
## npm
|
||||
Maintainers can verify the release directory before upload:
|
||||
|
||||
```bash
|
||||
npm install -g @gitlawb/zero
|
||||
zero
|
||||
go run ./cmd/zero-release package
|
||||
go run ./cmd/zero-release verify
|
||||
```
|
||||
|
||||
The package supports Linux, macOS, and Windows on x64 and arm64. It installs the
|
||||
`zero` command and downloads the matching release binary during `postinstall`.
|
||||
`verify:release` requires every archive in `dist/release` to have a same-directory `.sha256` file whose contents are:
|
||||
|
||||
Requirements:
|
||||
|
||||
- Node.js 18+
|
||||
- network access to npm and GitHub Releases
|
||||
|
||||
## Bun
|
||||
|
||||
Bun is "default-secure" and does not run lifecycle scripts of installed
|
||||
dependencies (only the installing project's own scripts), so the `postinstall`
|
||||
that fetches the Zero binary is silently skipped. The first run then fails with
|
||||
`No native binary found next to the npm wrapper`.
|
||||
|
||||
The simplest fix is to trust the package after installing, which runs the
|
||||
blocked postinstall. This works for project and global installs:
|
||||
|
||||
```bash
|
||||
# project install
|
||||
bun add @gitlawb/zero
|
||||
bun pm trust @gitlawb/zero
|
||||
|
||||
# global install
|
||||
bun add -g @gitlawb/zero
|
||||
bun pm -g trust @gitlawb/zero
|
||||
```text
|
||||
<sha256> <archive-name>
|
||||
```
|
||||
|
||||
`bun pm untrusted` (or `bun pm -g untrusted`) lists the blocked postinstalls if
|
||||
you want to inspect before trusting.
|
||||
|
||||
Alternatively, allow the postinstall to run at install time by adding the
|
||||
package to your project's `trustedDependencies` before installing:
|
||||
|
||||
```json
|
||||
{
|
||||
"trustedDependencies": ["@gitlawb/zero"]
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
bun add @gitlawb/zero
|
||||
```
|
||||
|
||||
On Bun versions that do not have `bun pm trust`, run the installer manually
|
||||
after installing:
|
||||
|
||||
```bash
|
||||
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
|
||||
```
|
||||
|
||||
Reference: <https://bun.sh/docs/pm/lifecycle>
|
||||
|
||||
## Linux And macOS Script
|
||||
|
||||
Install the latest release:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
|
||||
```
|
||||
## Linux And macOS
|
||||
|
||||
From a checkout:
|
||||
|
||||
@@ -87,14 +33,18 @@ Install a specific version:
|
||||
|
||||
```bash
|
||||
ZERO_VERSION=0.1.0 scripts/install.sh
|
||||
scripts/install.sh --version 0.1.0
|
||||
```
|
||||
|
||||
Install somewhere else:
|
||||
Install to a custom directory:
|
||||
|
||||
```bash
|
||||
ZERO_INSTALL_DIR="$HOME/bin" scripts/install.sh
|
||||
scripts/install.sh --install-dir "$HOME/bin"
|
||||
```
|
||||
|
||||
Install from a fork or internal repository:
|
||||
|
||||
```bash
|
||||
scripts/install.sh --repo owner/repo
|
||||
```
|
||||
|
||||
Defaults:
|
||||
@@ -103,15 +53,9 @@ Defaults:
|
||||
- Version: latest GitHub release
|
||||
- Install path: `~/.local/bin/zero`
|
||||
|
||||
Requirements: Bash, `curl` or `wget`, `tar`, and `shasum` or `sha256sum`.
|
||||
Requirements: `curl` or `wget`, `tar`, and `shasum` or `sha256sum`.
|
||||
|
||||
## Windows PowerShell Script
|
||||
|
||||
Install the latest release:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
|
||||
```
|
||||
## Windows
|
||||
|
||||
From a checkout:
|
||||
|
||||
@@ -125,91 +69,30 @@ Install a specific version:
|
||||
powershell -ExecutionPolicy Bypass -File scripts/install.ps1 -Version 0.1.0
|
||||
```
|
||||
|
||||
Install somewhere else:
|
||||
Install to a custom directory:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File scripts/install.ps1 -InstallDir "$env:USERPROFILE\bin"
|
||||
```
|
||||
|
||||
Install from a fork or internal repository:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File scripts/install.ps1 -Repository owner/repo
|
||||
```
|
||||
|
||||
Defaults:
|
||||
|
||||
- Repository: `Gitlawb/zero`
|
||||
- Version: latest GitHub release
|
||||
- Install path: `%LOCALAPPDATA%\zero\bin\zero.exe`
|
||||
|
||||
## From Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
cd zero
|
||||
go run ./cmd/zero
|
||||
```
|
||||
|
||||
Build a local binary:
|
||||
|
||||
```bash
|
||||
go build -o zero ./cmd/zero
|
||||
```
|
||||
|
||||
Source builds require Go 1.25+.
|
||||
|
||||
### Sandbox Helpers For Source Builds
|
||||
|
||||
Release archives include the platform sandbox helpers. If you build directly
|
||||
from source, build the helpers you need:
|
||||
|
||||
Linux:
|
||||
|
||||
```bash
|
||||
go build -o zero ./cmd/zero
|
||||
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
|
||||
go build -o zero-seccomp ./cmd/zero-seccomp
|
||||
```
|
||||
|
||||
Put `zero` and `zero-linux-sandbox` in the same directory on `PATH`, for example
|
||||
`~/.local/bin`. `zero-seccomp` is kept as a compatibility wrapper; the sandbox
|
||||
helper applies the Unix-socket filter itself when that sandbox option is enabled.
|
||||
Linux native sandboxing also requires Bubblewrap to be installed.
|
||||
|
||||
macOS uses the system sandbox and does not need an extra helper binary.
|
||||
|
||||
Windows source builds can use the main `zero.exe` as the command runner and setup
|
||||
helper through Zero's built-in self-dispatch path. If you want a release-style
|
||||
layout anyway, build the standalone helper executables next to `zero.exe`:
|
||||
|
||||
```powershell
|
||||
go build -o zero.exe ./cmd/zero
|
||||
go build -o zero-windows-command-runner.exe ./cmd/zero-windows-command-runner
|
||||
go build -o zero-windows-sandbox-setup.exe ./cmd/zero-windows-sandbox-setup
|
||||
```
|
||||
|
||||
## Release Archive Format
|
||||
|
||||
Release archives are named:
|
||||
|
||||
- `zero-v<version>-linux-<arch>.tar.gz`
|
||||
- `zero-v<version>-macos-<arch>.tar.gz`
|
||||
- `zero-v<version>-windows-<arch>.zip`
|
||||
|
||||
Supported targets:
|
||||
|
||||
- `linux-x64`
|
||||
- `linux-arm64`
|
||||
- `macos-x64`
|
||||
- `macos-arm64`
|
||||
- `windows-x64`
|
||||
- `windows-arm64`
|
||||
|
||||
Each archive must have a matching `.sha256` file. The install scripts download
|
||||
both files, verify the checksum, and then copy the binary into the install
|
||||
directory.
|
||||
|
||||
## Updating
|
||||
|
||||
Check for a newer release:
|
||||
Check whether a newer release exists:
|
||||
|
||||
```bash
|
||||
zero update --check
|
||||
```
|
||||
|
||||
Then reinstall with npm or rerun the install script for the version you want.
|
||||
For M2, updates are check-only. Re-run the installer to replace the local binary after reviewing the release.
|
||||
@@ -0,0 +1,87 @@
|
||||
# M1 Headless Exec PRD
|
||||
|
||||
Status: Draft
|
||||
Owner: Vasanth
|
||||
Milestone: M1
|
||||
Scope: `zero exec` CLI surface
|
||||
|
||||
## Goal
|
||||
|
||||
Ship the first production-shaped headless command for Zero:
|
||||
|
||||
```sh
|
||||
zero exec "inspect this repo and summarize the next fix"
|
||||
```
|
||||
|
||||
This command should be useful for local scripts, CI smoke runs, and later VS Code integration without pulling in the full future protocol, session, MCP, sandbox, or plugin roadmap.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No daemon mode.
|
||||
- No JSON-RPC input protocol.
|
||||
- No MCP, plugin, marketplace, or subagent support.
|
||||
- No session resume/fork/search.
|
||||
- No new provider adapters in this slice.
|
||||
- No architecture rewrite of the agent loop.
|
||||
|
||||
## User Stories
|
||||
|
||||
1. As a CLI user, I can run one prompt headlessly and receive the assistant answer on stdout.
|
||||
2. As a script author, I can choose `text` or JSONL output.
|
||||
3. As a reviewer, I can see tool activity without corrupting text stdout.
|
||||
4. As a model tester, I can override the configured model for one run.
|
||||
5. As a cautious user, prompt-gated tools stay disabled unless I pass an explicit unsafe flag.
|
||||
|
||||
## CLI Contract
|
||||
|
||||
```sh
|
||||
zero exec [prompt...] [options]
|
||||
```
|
||||
|
||||
Options for M1:
|
||||
|
||||
- `-f, --file <path>`: read the prompt from a file.
|
||||
- `-m, --model <model>`: override the configured model for this run.
|
||||
- `-C, --cwd <path>`: run from another working directory.
|
||||
- `-o, --output-format <text|json>`: choose stdout format.
|
||||
- `--skip-permissions-unsafe`: grant prompt-gated tools for this run.
|
||||
|
||||
The existing `zero -p "prompt"` shortcut remains as a compatibility path and should call the same runner.
|
||||
|
||||
## Output Contract
|
||||
|
||||
Text mode:
|
||||
|
||||
- stdout contains assistant text only.
|
||||
- stderr contains tool rows, warnings, and errors.
|
||||
|
||||
JSON mode:
|
||||
|
||||
- stdout is newline-delimited JSON events.
|
||||
- Required event types for this slice: `run_start`, `text`, `tool_call`, `tool_result`, `final`, `warning`, `error`, `done`.
|
||||
|
||||
## Exit Codes
|
||||
|
||||
- `0`: success
|
||||
- `1`: unexpected crash
|
||||
- `2`: usage error
|
||||
- `3`: provider/config/runtime error
|
||||
- `4`: reserved for future tool failure policy
|
||||
- `5`: reserved for future permission-denied policy
|
||||
|
||||
## Permission Behavior
|
||||
|
||||
Default headless mode advertises only tools marked `permission: "allow"`.
|
||||
|
||||
`--skip-permissions-unsafe` advertises tools that are not marked `deny` and grants prompt-gated tools through `ToolRegistry.run`. It must not bypass schema validation or direct tool execution safeguards.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `go test ./...` passes.
|
||||
- `go run ./cmd/zero-release build` passes.
|
||||
- `go run ./cmd/zero-release smoke` passes.
|
||||
- `zero exec --help` documents the M1 flags.
|
||||
- Usage errors do not initialize providers.
|
||||
- Provider failures return exit code `3`.
|
||||
- Normal mode does not advertise `bash`, `write_file`, `edit_file`, or `apply_patch`.
|
||||
- Unsafe mode still uses the registry execution path.
|
||||
@@ -23,13 +23,7 @@ go run ./cmd/zero-release smoke
|
||||
|
||||
## Checklist
|
||||
|
||||
- `package.json` has the expected package name (`@gitlawb/zero`), version,
|
||||
`bin.zero` entry, and exactly one `scripts` entry — the `postinstall` hook.
|
||||
- `scripts/postinstall.mjs` resolves the correct release asset name/URL per
|
||||
platform (`ZERO_INSTALL_DRY_RUN=1` prints the plan), verifies the downloaded
|
||||
archive's SHA-256, and extracts only the known binary basenames into place.
|
||||
`ZERO_SKIP_DOWNLOAD=1` opts out cleanly (exit 0) and an unsupported
|
||||
platform/arch is a non-fatal skip.
|
||||
- `package.json` has the expected package name, version, and `bin.zero` entry.
|
||||
- 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`.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Zero Performance Benchmarks
|
||||
|
||||
The performance harness tracks three release-facing signals:
|
||||
The M2 performance harness tracks three release-facing signals:
|
||||
|
||||
- Cold start: process startup time for `zero --version`.
|
||||
- Binary first output: time from spawning the built `zero --version` command to
|
||||
|
||||
+602
@@ -0,0 +1,602 @@
|
||||
# Zero — Product Requirements Document (v1.0)
|
||||
|
||||
**Status:** DRAFT v1.0
|
||||
**Target:** Compete with Claude Code + Cursor + OpenCode
|
||||
**Timeline:** 6-12 months
|
||||
**Stack:** Go native core + npm distribution wrapper
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision
|
||||
|
||||
**Zero is a terminal-first, editor-aware, multi-provider AI coding platform.**
|
||||
|
||||
It combines the best of:
|
||||
- **Claude Code** — terminal-native agentic coding
|
||||
- **Cursor** — IDE integration + smart editing
|
||||
- **OpenCode** — multi-provider + headless
|
||||
|
||||
**One Go-native agent core. Three surfaces. Zero lock-in.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Surfaces
|
||||
|
||||
### 2.1 Terminal (TUI) — Primary
|
||||
- Interactive coding agent in terminal
|
||||
- Streaming, append-style (like Claude Code)
|
||||
- Bubble Tea + Lip Gloss-based
|
||||
- Full keyboard control
|
||||
- Mouse support
|
||||
|
||||
### 2.2 Editor (VS Code Extension)
|
||||
- Same agent core, different UI
|
||||
- Inline suggestions + chat panel
|
||||
- File editing with diff view
|
||||
- Diagnostics integration
|
||||
- LSP-aware
|
||||
|
||||
### 2.3 Headless (CLI/CI)
|
||||
- `zero exec "prompt"` for scripts
|
||||
- `zero serve` for daemon mode
|
||||
- JSON-RPC over stdio/WebSocket
|
||||
- MCP server support
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ ZERO PLATFORM │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ TUI │ │ VSCode │ │ Headless │ │
|
||||
│ │ Surface │ │ Surface │ │ Surface │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||
│ │ │ │ │
|
||||
│ └─────────────┼─────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────▼──────┐ │
|
||||
│ │ Agent Core │ │
|
||||
│ │ (ReAct) │ │
|
||||
│ └──────┬──────┘ │
|
||||
│ │ │
|
||||
│ ┌──────┬──────┬────┴────┬──────┬──────┐ │
|
||||
│ │Tools │Perms │Sessions │Provi │Memory│ │
|
||||
│ └──────┴──────┴─────────┴──────┴──────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Layering Rules:**
|
||||
- Agent Core never imports a surface
|
||||
- Surfaces are thin shells (I/O + rendering)
|
||||
- All state persists to local files
|
||||
- No cloud dependency
|
||||
|
||||
---
|
||||
|
||||
## 4. Functional Requirements (24 Features)
|
||||
|
||||
### F1 — Agent Core (P0)
|
||||
- ReAct loop: think → tool → result → think
|
||||
- Stream text + tool calls + thinking
|
||||
- Parallel tool execution where safe
|
||||
- Bound turns (max 100)
|
||||
- Clean interrupt (Esc/Ctrl-C)
|
||||
- Event stream: `text | tool_call | tool_result | thinking | usage | plan_update | error | turn_end`
|
||||
|
||||
### F2 — Tools (P0)
|
||||
|
||||
**Core (8):**
|
||||
- `read_file` — read with line ranges, images
|
||||
- `write_file` — create/overwrite
|
||||
- `edit_file` — exact-string edit (uniqueness guard)
|
||||
- `bash` — run shell command
|
||||
- `grep` — ripgrep content search
|
||||
- `glob` — file pattern matching
|
||||
- `apply_patch` — multi-hunk patches
|
||||
- `update_plan` — todo list
|
||||
|
||||
**Advanced (4):**
|
||||
- `web_fetch` / `web_search` — web access
|
||||
- `task` — spawn subagent
|
||||
- `structured_output` — JSON-Schema validated
|
||||
- `notebook_edit` — Jupyter notebooks
|
||||
|
||||
**Tool Requirements:**
|
||||
- Go typed parameter schemas -> JSON Schema
|
||||
- Side-effect class: `read | write | shell | network | out_of_workspace`
|
||||
- Structured results: `{ status, output, truncated?, meta? }`
|
||||
- Atomic execution
|
||||
- Permission gating
|
||||
|
||||
### F3 — Providers & Model Registry (P0)
|
||||
- OpenAI-compatible (works with OpenAI, OpenRouter, Ollama, etc.)
|
||||
- Anthropic (Claude)
|
||||
- Google Gemini
|
||||
- Azure OpenAI
|
||||
- AWS Bedrock
|
||||
- xAI (Grok)
|
||||
|
||||
**Model Registry:**
|
||||
|
||||
```go
|
||||
type ModelEntry struct {
|
||||
ID string
|
||||
Name string
|
||||
Provider string
|
||||
APIProviders []string
|
||||
ContextLimits ContextLimits
|
||||
ReasoningEffort ReasoningEffort
|
||||
Capabilities ModelCapabilities
|
||||
Cost ModelCost
|
||||
Tier string
|
||||
MatchPatterns []string
|
||||
}
|
||||
```
|
||||
|
||||
- Per-task model selection (`-m`)
|
||||
- Provider rotation/failover
|
||||
- Cost tracking
|
||||
- "Spec model" for planning
|
||||
- Fallback model on failure
|
||||
|
||||
### F4 — Terminal UI (P0)
|
||||
- **Layout:** header (cwd · git · model) → tool rows → diff → response → input → footer
|
||||
- **Streaming:** append-only transcript model with live tail updates
|
||||
- **Markdown:** full CommonMark + syntax highlighting
|
||||
- **Tool rows:** collapsible with one-line summaries
|
||||
- **Input:** `/` commands, `@` files, `!` bash, `Esc` interrupt
|
||||
- **Robustness:** resize, wide-char, ANSI, non-TTY fallback
|
||||
- **Splash:** ZERO wordmark
|
||||
- **Themes:** light/dark + custom
|
||||
|
||||
### F5 — Sessions (P0)
|
||||
- Persist to `~/.local/share/zero/sessions/<id>/`
|
||||
- `meta.json` + `events.jsonl` + `messages.jsonl` + `checkpoints/`
|
||||
- `--resume [id]` (default: latest)
|
||||
- `--fork <id>` (copy with lineage)
|
||||
- Search: `zero search <query>` over messages/tools/results
|
||||
- Atomic writes (write-temp-then-rename)
|
||||
- Schema versioning
|
||||
- Export to Markdown/JSON
|
||||
|
||||
### F6 — Configuration (P0)
|
||||
- Layered: defaults → user → project → env → CLI
|
||||
- `~/.config/zero/config.json` (user)
|
||||
- `./.zero/config.json` (project)
|
||||
- Runtime overlay (`--settings <path>`)
|
||||
- Feature flags for staged rollout
|
||||
- Validation on load
|
||||
|
||||
### F7 — MCP (Model Context Protocol) (P0)
|
||||
- Connect to stdio/HTTP/SSE servers
|
||||
- Surface MCP tools into registry
|
||||
- `zero mcp add/remove/list`
|
||||
- Per-server and per-tool permissions
|
||||
- Persistent grants with revoke/clear
|
||||
- MCP server mode (zero as MCP server)
|
||||
|
||||
### F8 — Headless / Programmatic (P0)
|
||||
- `zero exec [prompt]` — one-shot
|
||||
- `zero serve` — daemon mode
|
||||
- `--input-format stream-json` — line-delimited events
|
||||
- `--input-format stream-jsonrpc` — JSON-RPC protocol
|
||||
- `-o text|json` output formats
|
||||
- Exit codes: `0=success, 1=crash, 2=usage, 3=provider, 4=tool, 5=permission, 6=budget`
|
||||
|
||||
### F9 — Permissions & Autonomy (P0)
|
||||
- **Side-effect classes:** read, write, shell, network, out_of_workspace
|
||||
- **Autonomy levels:** low (ask), medium (smart), high (auto)
|
||||
- `--skip-permissions-unsafe` (loud warning)
|
||||
- TUI: inline prompts (allow/deny/always)
|
||||
- Headless: default-deny unless granted
|
||||
- Persistent grants with list/revoke/clear
|
||||
- Audit log in `events.jsonl`
|
||||
|
||||
**Permission Matrix:**
|
||||
|
||||
| Tool Class | low | medium | high |
|
||||
|------------|-----|--------|------|
|
||||
| Read | allow | allow | allow |
|
||||
| Workspace write | prompt | allow | allow |
|
||||
| Shell | prompt | prompt | allow |
|
||||
| Network | prompt | allow | allow |
|
||||
| Out-of-workspace | deny | prompt | prompt |
|
||||
|
||||
### F10 — Observability (P0)
|
||||
- Structured logging (stdout/stderr split)
|
||||
- Secret redaction (API keys, tokens, passwords)
|
||||
- No vendor telemetry (local only)
|
||||
- `zero doctor` — health check command
|
||||
- Crash reports (opt-in, local)
|
||||
- Performance metrics (TTFT, tokens/sec)
|
||||
|
||||
### F11 — Subagents (P1)
|
||||
- `task` tool spawns scoped subagent
|
||||
- Built-in agents: code-review, security-review, refactor, test-gen
|
||||
- Recursion depth limit
|
||||
- Parent session linkage
|
||||
- Shared/isolated budgets
|
||||
|
||||
### F12 — Plugins (P1)
|
||||
- User/project-scoped plugins
|
||||
- Commands, tools, prompts, hooks
|
||||
- Plugin manifest: `plugin.json`
|
||||
- `zero plugin install <path|url>`
|
||||
- No marketplace in v1 (manual install)
|
||||
|
||||
### F13 — Distribution (P0)
|
||||
- **Single binary** via Go cross-compilation
|
||||
- npm package as a thin installer/launcher for the platform binary
|
||||
- Cross-platform: Linux, macOS, Windows
|
||||
- `zero update [--check]` self-update
|
||||
- Checksum verification
|
||||
- Rollback on failure
|
||||
|
||||
### F14 — VS Code Extension (P1)
|
||||
- Same agent core via stream protocol
|
||||
- Inline chat panel
|
||||
- Diff view in editor
|
||||
- Diagnostics integration
|
||||
- LSP awareness
|
||||
- File watching
|
||||
|
||||
### F15 — Memory & Context (P1)
|
||||
- `AGENTS.md` hierarchical memory
|
||||
- `@import` for modular rules
|
||||
- Auto-generated from `init` command
|
||||
- Project conventions learning
|
||||
- Skills system (reusable instruction packs)
|
||||
- Context budgeting (token tracking)
|
||||
- Auto-compaction when full
|
||||
|
||||
### F16 — Hooks System (P1)
|
||||
- Lifecycle hooks: `PreToolUse`, `PostToolUse`, `SessionStart`, `SessionEnd`
|
||||
- User-defined shell commands
|
||||
- JSON output for conditional flow
|
||||
- `.zero/hooks/` directory
|
||||
|
||||
### F17 — Slash Commands (P0)
|
||||
|
||||
**Built-in (21):**
|
||||
- Session: `/clear`, `/compact`, `/context`, `/add-dir`, `/rewind`, `/resume`, `/exit`
|
||||
- Model: `/model`, `/effort`, `/style`, `/permissions`
|
||||
- Project: `/init`, `/memory`, `/skills`
|
||||
- Extensibility: `/mcp`, `/hooks`, `/agents`
|
||||
- Meta: `/help`, `/config`, `/doctor`, `/feedback`
|
||||
|
||||
**Custom:** `./.zero/commands/*.md`
|
||||
|
||||
### F18 — Git Integration (P1)
|
||||
- Worktree isolation per task
|
||||
- Auto-commit with messages
|
||||
- Branch management
|
||||
- PR creation
|
||||
- Diff review
|
||||
- Conflict resolution
|
||||
|
||||
### F19 — Testing & Verification (P1)
|
||||
- `run_tests` tool
|
||||
- Self-verification loop
|
||||
- Test runner integration
|
||||
- Coverage reporting
|
||||
- Diagnostics (typecheck, lint)
|
||||
- LSP integration
|
||||
|
||||
### F20 — Web Access (P1)
|
||||
- `web_fetch` — fetch URL content
|
||||
- `web_search` — search engine
|
||||
- Provider/permission gated
|
||||
- Rate limiting
|
||||
- Caching
|
||||
|
||||
### F21 — Sandbox (P2)
|
||||
- Linux: bubblewrap
|
||||
- macOS: sandbox-exec
|
||||
- Windows: AppContainer
|
||||
- Filesystem policies (workspace write confinement + user-granted extra roots via `--add-dir`)
|
||||
- Network policies
|
||||
- Unix socket controls
|
||||
|
||||
### F22 — Multi-modal (P2)
|
||||
- Image input (screenshots, diagrams)
|
||||
- PDF parsing
|
||||
- Voice input (optional)
|
||||
- File attachments
|
||||
- Paste support
|
||||
|
||||
### F23 — Telemetry (P2)
|
||||
- Local-only metrics
|
||||
- Token usage tracking
|
||||
- Cost reports
|
||||
- Performance profiling
|
||||
- Crash reports (opt-in)
|
||||
- NO vendor telemetry
|
||||
|
||||
### F24 — Advanced Features (P2)
|
||||
- Code review automation
|
||||
- Auto-refactor
|
||||
- Dependency updates
|
||||
- Security scanning
|
||||
- Documentation generation
|
||||
|
||||
---
|
||||
|
||||
## 5. Data Models
|
||||
|
||||
### 5.1 Headless Event Stream
|
||||
|
||||
```json
|
||||
{ "type": "run_start", "runId": "r1", "sessionId": "s1", "cwd": "/repo", "model": "..." }
|
||||
{ "type": "text", "delta": "..." }
|
||||
{ "type": "tool_call", "id": "t1", "name": "bash", "args": {...}, "sideEffect": "shell" }
|
||||
{ "type": "permission", "id": "t1", "decision": "needs_approval", "reason": "shell" }
|
||||
{ "type": "tool_result", "id": "t1", "status": "ok", "output": "...", "truncated": false }
|
||||
{ "type": "usage", "promptTokens": 1234, "completionTokens": 567, "costUsd": 0.012 }
|
||||
{ "type": "plan_update", "plan": [{ "id": "1", "content": "...", "status": "in_progress" }] }
|
||||
{ "type": "turn_end", "stopReason": "end_turn" }
|
||||
{ "type": "error", "code": "rate_limit", "message": "...", "recoverable": true }
|
||||
{ "type": "run_end", "status": "success", "exitCode": 0 }
|
||||
```
|
||||
|
||||
### 5.2 Session On Disk
|
||||
|
||||
```
|
||||
~/.local/share/zero/sessions/<id>/
|
||||
meta.json # { schemaVersion, id, parentId?, title, cwd, model, createdAt, updatedAt, tokens, cost }
|
||||
events.jsonl # append-only AgentEvent stream
|
||||
messages.jsonl # provider-facing messages
|
||||
checkpoints/ # snapshots for restore/fork/compact
|
||||
```
|
||||
|
||||
### 5.3 Tool Contract
|
||||
|
||||
```go
|
||||
type Tool interface {
|
||||
Name() string
|
||||
Description() string
|
||||
Parameters() JSONSchema
|
||||
SideEffect() SideEffect
|
||||
Execute(ctx ToolContext, args map[string]any) ToolResult
|
||||
}
|
||||
|
||||
type ToolResult struct {
|
||||
Status string
|
||||
Output string
|
||||
Truncated bool
|
||||
Meta map[string]any
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 Config Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "claude-sonnet-4-6",
|
||||
"providers": {
|
||||
"anthropic": { "apiKey": "env:AN..._KEY" },
|
||||
"openai": { "apiKey": "env:OP..._KEY" }
|
||||
},
|
||||
"permissions": {
|
||||
"autonomy": "medium",
|
||||
"grants": [{ "tool": "bash", "pattern": "git status", "decision": "allow" }]
|
||||
},
|
||||
"ui": { "theme": "dark", "syntaxHighlighting": true }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Non-Functional Requirements
|
||||
|
||||
### Performance
|
||||
- TTFT < 500ms after provider's first byte
|
||||
- No full-transcript repaint per token
|
||||
- Cold start < 300ms (warm cache)
|
||||
- Tool execution < 100ms overhead
|
||||
- Session resume < 1s
|
||||
|
||||
### Reliability
|
||||
- Crash-free TUI under resize, wide-char, non-TTY
|
||||
- Clean interrupt (no half-written state)
|
||||
- Atomic session writes
|
||||
- Recovery from malformed JSONL
|
||||
- Provider failover
|
||||
|
||||
### Security
|
||||
- No mutation without permission decision (hard invariant)
|
||||
- API keys never logged
|
||||
- Secret redaction everywhere
|
||||
- Headless stdout free of logs
|
||||
- Workspace boundary by default
|
||||
- Sandboxing for untrusted code
|
||||
|
||||
### Portability
|
||||
- Linux, macOS, Windows
|
||||
- Any OpenAI-compatible endpoint
|
||||
- x86_64, ARM64
|
||||
- Single binary distribution
|
||||
|
||||
### Testability
|
||||
- Mock provider for unit tests
|
||||
- Fake MCP server/client
|
||||
- Fake terminal/PTY
|
||||
- Sandbox simulator
|
||||
- Deterministic event replay
|
||||
|
||||
---
|
||||
|
||||
## 7. Milestones
|
||||
|
||||
### M0 — Foundation (Weeks 1-2)
|
||||
- Go module setup
|
||||
- Agent core skeleton
|
||||
- 8 core tools (read, write, edit, bash, grep, glob, apply_patch, update_plan)
|
||||
- OpenAI-compatible provider
|
||||
- Basic Bubble Tea TUI
|
||||
- Permission gate (read vs mutate)
|
||||
- Session persistence
|
||||
|
||||
**Exit:** `zero` runs, can read/edit files, basic chat works.
|
||||
|
||||
### M1 — Multi-Provider (Weeks 3-4)
|
||||
- Anthropic provider
|
||||
- Gemini provider
|
||||
- Model registry
|
||||
- Per-task model selection
|
||||
- Provider rotation
|
||||
- Cost tracking
|
||||
- Headless `exec` mode
|
||||
|
||||
**Exit:** Switch between Claude/GPT/Gemini, see cost, run headless.
|
||||
|
||||
### M2 — Polish (Weeks 5-8)
|
||||
- Advanced TUI features
|
||||
- Full markdown + syntax highlighting
|
||||
- Tool result truncation/pagination
|
||||
- Auto-compaction
|
||||
- Context budgeting
|
||||
- 20 slash commands
|
||||
- Doctor command
|
||||
- Search command
|
||||
|
||||
**Exit:** Production-ready TUI, smooth streaming, all commands work.
|
||||
|
||||
### M3 — Distribution (Weeks 9-10)
|
||||
- Single binary build
|
||||
- Self-update
|
||||
- Cross-platform CI
|
||||
- VS Code extension MVP
|
||||
- Documentation site
|
||||
|
||||
**Exit:** `zero` ships as one binary, works on Win/Mac/Linux.
|
||||
|
||||
### M4 — Extensibility (Weeks 11-14)
|
||||
- MCP client + permissions
|
||||
- Hooks system
|
||||
- Skills system
|
||||
- Memory (AGENTS.md)
|
||||
- Plugin loading
|
||||
- Subagents (built-in: review, security)
|
||||
|
||||
**Exit:** Plugins work, MCP servers connect, memory persists.
|
||||
|
||||
### M5 — Advanced (Weeks 15-20)
|
||||
- Web fetch/search
|
||||
- Git worktrees
|
||||
- Self-verification
|
||||
- Diagnostics (LSP, typecheck)
|
||||
- Testing integration
|
||||
- Code review automation
|
||||
|
||||
**Exit:** Zero can review code, run tests, manage git.
|
||||
|
||||
### M6 — Sandbox & Security (Weeks 21-24)
|
||||
- Linux sandbox (bubblewrap)
|
||||
- macOS sandbox
|
||||
- Windows AppContainer
|
||||
- Network policies
|
||||
- Filesystem policies
|
||||
|
||||
**Exit:** Untrusted code runs safely in sandbox.
|
||||
|
||||
### Beyond v1.0
|
||||
- Multi-modal (images, PDFs)
|
||||
- Voice input
|
||||
- Cloud sync (optional)
|
||||
- Team features
|
||||
- Marketplace (if demand)
|
||||
|
||||
---
|
||||
|
||||
## 8. Risks & Mitigations
|
||||
|
||||
| # | Risk | Mitigation |
|
||||
|---|------|------------|
|
||||
| R1 | Scope creep | Stick to milestones, defer features |
|
||||
| R2 | Provider API changes | Abstract via interface, test with mocks |
|
||||
| R3 | TUI complexity | Use Bubble Tea/Lip Gloss, follow mature terminal agent UX patterns |
|
||||
| R4 | Performance | Benchmark early, optimize streaming |
|
||||
| R5 | Security vulnerabilities | Permission gate from day 1, security review |
|
||||
| R6 | Cross-platform bugs | CI on Win/Mac/Linux from M0 |
|
||||
| R7 | Breaking changes | Schema versioning, migration tools |
|
||||
|
||||
---
|
||||
|
||||
## 9. Success Metrics
|
||||
|
||||
### v1.0 (6 months)
|
||||
- 5+ providers supported
|
||||
- 12+ tools
|
||||
- 100% test coverage on core
|
||||
- < 300ms cold start
|
||||
- Single binary < 50MB
|
||||
- Works on Win/Mac/Linux
|
||||
- 1000+ GitHub stars
|
||||
- 50+ contributors
|
||||
|
||||
### v2.0 (12 months)
|
||||
- VS Code extension published
|
||||
- MCP server mode
|
||||
- Plugin ecosystem (20+ plugins)
|
||||
- 10K+ active users
|
||||
- Compete with Claude Code on benchmarks
|
||||
|
||||
---
|
||||
|
||||
## 10. Out of Scope (v1.0)
|
||||
|
||||
- Cloud sync / hosted backend
|
||||
- Relay / remote SSH execution
|
||||
- Multi-agent mission mode
|
||||
- Plugin marketplace
|
||||
- Auto-generated wikis
|
||||
- Git authorship notes
|
||||
- Vendor telemetry
|
||||
- Voice input
|
||||
- Mobile support
|
||||
|
||||
---
|
||||
|
||||
## 11. Open Decisions
|
||||
|
||||
1. **TUI library:** Bubble Tea + Lip Gloss — **Decide: Go-native TUI** (single-binary friendly, mature terminal ecosystem)
|
||||
2. **Storage:** JSONL files vs SQLite — **Decide: JSONL** (inspectable, simple)
|
||||
3. **Package structure:** Single vs monorepo — **Decide: Single** until M4
|
||||
4. **Provider abstraction:** Direct API vs wrapper — **Decide: Direct** (portability)
|
||||
5. **Patch strategy:** apply_patch vs edit_file — **Decide: Both** (apply_patch for bulk, edit_file for small)
|
||||
|
||||
---
|
||||
|
||||
## 12. Team & Timeline
|
||||
|
||||
**Team (3 people):**
|
||||
- You (lead + TUI + tools)
|
||||
- Backend dev (providers + core)
|
||||
- Designer (VS Code extension + docs)
|
||||
|
||||
**Timeline:**
|
||||
- M0-M1: 4 weeks (foundation)
|
||||
- M2-M3: 6 weeks (polish + distribution)
|
||||
- M4-M5: 8 weeks (extensibility)
|
||||
- M6: 4 weeks (sandbox)
|
||||
- **Total v1.0: 6 months**
|
||||
|
||||
---
|
||||
|
||||
## 13. Why This Will Work
|
||||
|
||||
1. **Proven patterns** — Based on clean-room analysis of existing terminal coding-agent UX patterns
|
||||
2. **Multi-provider** — Unique advantage vs Claude Code/Cursor
|
||||
3. **Open source** — Community contributions
|
||||
4. **Go** — Native binary, fast startup, strong concurrency, cross-platform CLI ergonomics
|
||||
5. **npm distribution wrapper** — Keeps JavaScript ecosystem installation while the runtime stays native
|
||||
6. **Clear milestones** — Ship early, iterate
|
||||
|
||||
---
|
||||
|
||||
**This is the real PRD. 13 sections, 24 features, 6 months to v1.0.**
|
||||
@@ -1,23 +0,0 @@
|
||||
# Zero Documentation
|
||||
|
||||
This directory contains public documentation for using, extending, and releasing
|
||||
Zero.
|
||||
|
||||
## User Docs
|
||||
|
||||
- [Install](INSTALL.md)
|
||||
- [Update flow](UPDATE.md)
|
||||
- [OAuth logins and subscription-backed providers](oauth-subscriptions.md)
|
||||
|
||||
## Automation And Integrations
|
||||
|
||||
- [Stream-JSON protocol](STREAM_JSON_PROTOCOL.md)
|
||||
- [GitHub Action](GITHUB_ACTION.md)
|
||||
- [Specialists](SPECIALISTS.md)
|
||||
|
||||
## Maintainer Docs
|
||||
|
||||
- [npm wrapper smoke checklist](NPM_WRAPPER_SMOKE.md)
|
||||
- [Benchmarks](BENCHMARK.md)
|
||||
- [Performance benchmarks](PERFORMANCE.md)
|
||||
- [Offline agent evals](AGENT_EVALS.md)
|
||||
@@ -8,12 +8,12 @@ redacted before they are written to stdout.
|
||||
|
||||
## Version
|
||||
|
||||
Current schema version: `2`
|
||||
Current schema version: `1`
|
||||
|
||||
Every input and output event must include:
|
||||
|
||||
```json
|
||||
{ "schemaVersion": 2, "type": "..." }
|
||||
{ "schemaVersion": 1, "type": "..." }
|
||||
```
|
||||
|
||||
Output events also include `runId`.
|
||||
@@ -24,37 +24,27 @@ Output events also include `runId`.
|
||||
`--file`.
|
||||
|
||||
```json
|
||||
{ "schemaVersion": 2, "type": "message", "role": "user", "content": "Inspect this repo." }
|
||||
{ "schemaVersion": 2, "type": "prompt", "content": "Return only blockers." }
|
||||
{ "schemaVersion": 1, "type": "message", "role": "user", "content": "Inspect this repo." }
|
||||
{ "schemaVersion": 1, "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": 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 } }
|
||||
{ "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 }
|
||||
```
|
||||
|
||||
Errors are part of the protocol and are followed by `run_end`.
|
||||
@@ -65,6 +55,6 @@ is not pre-approved, Zero may emit `permission_request` followed by a denied
|
||||
allows, denies, or always-allows the request.
|
||||
|
||||
```json
|
||||
{ "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 }
|
||||
{ "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 }
|
||||
```
|
||||
+21
-27
@@ -1,44 +1,38 @@
|
||||
# Update Flow
|
||||
# Zero Update Flow
|
||||
|
||||
`zero update --check` checks the latest GitHub release and compares it with the
|
||||
local CLI version.
|
||||
`zero update --check` checks the latest GitHub release for `Gitlawb/zero` and compares it with the local CLI version.
|
||||
|
||||
```bash
|
||||
zero update --check
|
||||
zero update --check --json
|
||||
zero update --check --repo Gitlawb/zero
|
||||
zero update --check --endpoint https://api.github.com/repos/Gitlawb/zero/releases/latest
|
||||
zero update --check --timeout 3s
|
||||
zero update --check --target windows-x64
|
||||
```
|
||||
|
||||
The command is intentionally check-only:
|
||||
For M2 this command is intentionally check-only:
|
||||
|
||||
- It does not replace the running binary.
|
||||
- It exits with code `0` when the check succeeds, even when an update is
|
||||
available.
|
||||
- It exits with code `0` when the check succeeds, even when an update is available.
|
||||
- It exits with code `1` when the release check cannot be completed.
|
||||
- `--json` prints the same result in a machine-readable format for scripts and
|
||||
CI.
|
||||
- `--json` prints the same result in a machine-readable format for scripts and CI.
|
||||
- `--repo <owner/repo>` checks a different GitHub repository when no endpoint is provided.
|
||||
- `--endpoint <url|owner/repo>` checks a specific release API URL or repository slug.
|
||||
- `--timeout <duration>` overrides the default release check timeout.
|
||||
- `--target <platform-arch>` validates release metadata for another supported target from the current machine.
|
||||
- Release checks time out after 5 seconds by default.
|
||||
- It validates that the latest release includes the expected archive and matching `.sha256` asset for the selected platform target.
|
||||
|
||||
Useful flags:
|
||||
Supported targets are `linux-x64`, `linux-arm64`, `macos-x64`, `macos-arm64`, `windows-x64`, and `windows-arm64`. Without `--target`, Zero checks the current platform.
|
||||
|
||||
| Flag | Purpose |
|
||||
|---|---|
|
||||
| `--repo <owner/repo>` | Check another GitHub repository. |
|
||||
| `--endpoint <url|owner/repo>` | Check a specific release API URL or repository slug. |
|
||||
| `--timeout <duration>` | Override the default release check timeout. |
|
||||
| `--target <platform-arch>` | Validate release metadata for another supported target. |
|
||||
The release endpoint resolves in this order:
|
||||
|
||||
Supported targets are `linux-x64`, `linux-arm64`, `macos-x64`, `macos-arm64`,
|
||||
`windows-x64`, and `windows-arm64`. Without `--target`, Zero checks the current
|
||||
platform.
|
||||
- `--endpoint` from the CLI, or `Options.Endpoint` when calling `update.Check` from code.
|
||||
- `ZERO_UPDATE_RELEASE_URL` from the environment.
|
||||
- `--repo` from the CLI, or `Options.Repository` when calling `update.Check` from code.
|
||||
- `https://api.github.com/repos/Gitlawb/zero/releases/latest`.
|
||||
|
||||
Endpoint resolution order:
|
||||
`--endpoint`, `Options.Endpoint`, and `ZERO_UPDATE_RELEASE_URL` may be a full URL or an `owner/repo` slug.
|
||||
|
||||
1. `--endpoint`
|
||||
2. `ZERO_UPDATE_RELEASE_URL`
|
||||
3. `--repo`
|
||||
4. `https://api.github.com/repos/Gitlawb/zero/releases/latest`
|
||||
|
||||
Installer scripts download the matching release asset for the local platform and
|
||||
verify its `.sha256` file. If Zero is already installed, run `zero update --check`
|
||||
before reinstalling.
|
||||
Installer scripts download the matching release asset for the local platform and verify its `.sha256` file. If Zero is already installed, use this command before re-running the installer.
|
||||
@@ -0,0 +1,286 @@
|
||||
# Zero WorkSplit PRD v3: Go-Native Product Ownership Plan
|
||||
|
||||
Status: Draft v3.0
|
||||
Date: 2026-06-06
|
||||
Timeline: Active sprint plan toward v0.1, then v1.0 hardening
|
||||
|
||||
Team:
|
||||
|
||||
- Vasanth
|
||||
- Gnanam
|
||||
- Anandan
|
||||
|
||||
## 1. Product Goal
|
||||
|
||||
Zero is a terminal-first AI coding agent built in Go.
|
||||
|
||||
The goal is not just to remove the old TypeScript runtime. The goal is to make Zero feel like a mature daily-driver CLI agent:
|
||||
|
||||
- fast startup
|
||||
- strong command UX
|
||||
- safe autonomy
|
||||
- clear permission handling
|
||||
- useful local tools
|
||||
- reliable provider/runtime behavior
|
||||
- installable, updateable, and testable on real developer machines
|
||||
|
||||
This document replaces the older migration-era work split. The Go runtime is now the main product baseline, so future work should focus on product depth rather than repeating the old M0 migration plan.
|
||||
|
||||
## 2. Current Baseline On Main
|
||||
|
||||
The current main branch is Go-native for the runtime and CLI.
|
||||
|
||||
Implemented baseline:
|
||||
|
||||
- `cmd/zero` is the primary CLI entrypoint.
|
||||
- `cmd/zero-pr-review` exists for PR automation workflows.
|
||||
- Core runtime packages live under `internal/`.
|
||||
- Current CLI surface includes `exec`, `config`, `models`, `providers`, `doctor`, `search`, `sessions`, `plugins`, `hooks`, `mcp`, `sandbox`, `update`, `worktrees`, `verify`, `changes`, `serve`, `help`, and `version`.
|
||||
- Core areas exist for agent flow, provider/model registry, tools, TUI, sessions, search, sandbox, self-verification, stream-json, usage, redaction, hooks, MCP, plugins, update, worktrees, and git/change handling.
|
||||
- TypeScript is no longer part of the repository baseline. The npm package keeps a small Node wrapper that delegates to the Go binary.
|
||||
|
||||
Still needed before Zero feels production-ready:
|
||||
|
||||
- Permission and sandbox UX must become clearer and harder to misuse.
|
||||
- TUI command flows need polish beyond basic rendering.
|
||||
- Platform behavior, especially Windows sandbox/update/install paths, needs stronger product ownership.
|
||||
- Review automation should produce useful, repeatable output for maintainers.
|
||||
- Release trust needs checksums, install smoke tests, and update verification.
|
||||
|
||||
## 3. Ownership Rules
|
||||
|
||||
- Every feature has one DRI.
|
||||
- UI, runtime, and platform work should be split by contract, not by vague file ownership.
|
||||
- Every PR must expose user-runnable behavior or a concrete contract consumed by another owner.
|
||||
- Docs, CI, and website work can support a product PR, but they should not be the whole lane for any owner.
|
||||
- Anandan must own platform product work, not only docs/site/CI.
|
||||
- Shared contracts should be agreed before large UI or integration work lands.
|
||||
- Keep PRs large enough to move the product forward, but still reviewable.
|
||||
|
||||
## 4. Team Roles
|
||||
|
||||
| Owner | Primary Role | Owns | Should Not Own Alone |
|
||||
| --- | --- | --- | --- |
|
||||
| Vasanth | Product UX and TUI owner | Bubble Tea TUI, startup screen, command palette, slash command UX, tool rendering, permission prompts, sandbox violation rendering, verification/test/git output UX, themes, daily CLI feel | Provider internals, release pipeline, platform adapters |
|
||||
| Gnanam | Runtime core owner | Provider/model registry, provider factory, agent/runtime protocols, stream-json, sessions/search backends, config/doctor/usage/redaction, MCP/hooks/plugins backend, sandbox policy/grants, permission event contract | TUI composition, release packaging, platform-specific install trust |
|
||||
| Anandan | Platform product owner | Install/update/release trust, binary/package smoke tests, platform sandbox adapters, Windows behavior, PR/review automation CLI, CI as product verification, performance and release checks | TUI rendering, provider semantics, model registry ownership |
|
||||
|
||||
## 5. Anandan Platform Product Rule
|
||||
|
||||
Anandan should not be assigned only docs/site/CI forever. His lane must produce platform behavior that users and maintainers can run.
|
||||
|
||||
Accepted Anandan work:
|
||||
|
||||
- `zero sandbox policy` improvements and platform-specific sandbox behavior.
|
||||
- Windows sandbox adapter behavior and clear fallback reporting.
|
||||
- `zero update` verification, release metadata validation, and install smoke tests.
|
||||
- Package and binary checksums.
|
||||
- PR automation CLI workflows that reviewers can run locally or in CI.
|
||||
- CI jobs that prove real Zero commands work after installation.
|
||||
|
||||
Not accepted as standalone Anandan work:
|
||||
|
||||
- Docs-only updates.
|
||||
- Website-only updates.
|
||||
- Future VS Code notes with no current CLI behavior.
|
||||
- CI matrix changes that do not prove a user-facing command or release flow.
|
||||
- Release notes or checklists without executable validation.
|
||||
|
||||
Docs and CI are still useful, but they must attach to real platform product behavior.
|
||||
|
||||
## 6. Shared Contracts
|
||||
|
||||
| Contract | DRI | Consumers | Purpose |
|
||||
| --- | --- | --- | --- |
|
||||
| Provider stream events | Gnanam | Vasanth, Anandan | Lets TUI and automation render model output, tool calls, and errors consistently. |
|
||||
| Permission and sandbox events | Gnanam | Vasanth, Anandan | Lets TUI ask for decisions and platform code enforce them without bypasses. |
|
||||
| Session event schema | Gnanam | Vasanth, Anandan | Lets TUI, resume, search, and automation share one session model. |
|
||||
| Command metadata registry | Vasanth | Gnanam, Anandan | Lets slash commands, help, shell completions, and docs use one command source. |
|
||||
| Platform/release contract | Anandan | Vasanth, Gnanam | Defines install, update, checksum, smoke, and platform capability results. |
|
||||
| Stream-json protocol | Gnanam | Anandan | Lets CI and PR automation consume Zero output without scraping text UI. |
|
||||
|
||||
## 7. Active Workstreams
|
||||
|
||||
### A. Safe Autonomy
|
||||
|
||||
Top priority. Zero must be able to run tools confidently while showing users what is happening and asking when risk increases.
|
||||
|
||||
Planned PRs:
|
||||
|
||||
- Vasanth: `feat/tui-sandbox-permissions`
|
||||
- permission prompt UX
|
||||
- allow, deny, and always-allow flows
|
||||
- sandbox violation 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
|
||||
- Anandan: `feat/windows-sandbox-platform`
|
||||
- platform sandbox capability checks
|
||||
- stronger Windows behavior or explicit safe fallback
|
||||
- `zero sandbox policy --json` output useful enough for CI and support
|
||||
- platform tests where possible
|
||||
|
||||
Done when:
|
||||
|
||||
- High-autonomy tool attempts produce a clear prompt, decision, and result in TUI and headless modes.
|
||||
- Denied commands do not run.
|
||||
- Sandbox violations are visible and actionable.
|
||||
- Platform fallback is explicit, tested, and not silently unsafe.
|
||||
|
||||
### B. Command UX And Daily CLI
|
||||
|
||||
The CLI should feel like a product, not a collection of backend commands.
|
||||
|
||||
Planned PRs:
|
||||
|
||||
- Vasanth: `feat/tui-command-polish`
|
||||
- `/model`, `/provider`, `/context`, `/doctor`, `/verify`, `/resume`, and `/compact` become clear interactive flows
|
||||
- command results render consistently
|
||||
- startup screen stays minimal until the user asks for panels
|
||||
- Gnanam: `feat/runtime-command-contracts`
|
||||
- stable command data APIs
|
||||
- provider/model/session/config result structs
|
||||
- error types suitable for TUI rendering
|
||||
- Anandan: `feat/install-update-user-flow`
|
||||
- `zero update` and install flows are user-runnable
|
||||
- package smoke command verifies installed binary behavior
|
||||
- CI proves the packaged command starts and reports version/help
|
||||
|
||||
Done when:
|
||||
|
||||
- A new user can run Zero, inspect config/provider/model state, and understand the next action without reading source.
|
||||
|
||||
### C. Verification And Review Automation
|
||||
|
||||
Zero should help maintainers verify changes and review PRs with repeatable local commands.
|
||||
|
||||
Planned PRs:
|
||||
|
||||
- Vasanth: `feat/tui-verification-results`
|
||||
- render verify/test/git/review results in TUI
|
||||
- show pass/fail/blocked states clearly
|
||||
- Gnanam: `feat/runtime-self-verify-contract`
|
||||
- structured verification results
|
||||
- stable test/change event model
|
||||
- redacted logs for model and automation use
|
||||
- Anandan: `feat/pr-review-cli-workflow`
|
||||
- make `cmd/zero-pr-review` or `zero review` useful for changed files, validations, and summary output
|
||||
- support local and CI invocation
|
||||
- avoid brittle text scraping where stream-json can be used
|
||||
|
||||
Done when:
|
||||
|
||||
- A maintainer can run one local command before review and get useful changed-file, validation, and risk output.
|
||||
|
||||
### D. Release Trust And Distribution
|
||||
|
||||
This is Anandan's main product lane after platform sandbox work.
|
||||
|
||||
Planned PRs:
|
||||
|
||||
- Anandan: `feat/release-trust`
|
||||
- release metadata validation
|
||||
- checksums
|
||||
- install smoke tests
|
||||
- update verification
|
||||
- clear error output when release artifacts are invalid
|
||||
- Vasanth:
|
||||
- consume release/update results in CLI UX where needed
|
||||
- Gnanam:
|
||||
- ensure config, errors, and logs are structured and redacted
|
||||
|
||||
Done when:
|
||||
|
||||
- Users can install, run, verify, and update Zero with confidence.
|
||||
- Maintainers can prove release artifacts work before publishing.
|
||||
|
||||
### E. Extensibility UX
|
||||
|
||||
This comes after safe autonomy and daily CLI polish.
|
||||
|
||||
Planned PRs:
|
||||
|
||||
- Vasanth:
|
||||
- `/mcp`, `/hooks`, `/plugins`, and related UI flows
|
||||
- Gnanam:
|
||||
- harden backend contracts and lifecycle behavior
|
||||
- Anandan:
|
||||
- automation and docs only after executable behavior exists
|
||||
|
||||
Done when:
|
||||
|
||||
- Extensibility feels discoverable and safe from the terminal.
|
||||
|
||||
## 8. Immediate Next PRs
|
||||
|
||||
These are the next reviewable slices. Do not use the old migration-era next steps.
|
||||
|
||||
1. Vasanth: `feat/tui-sandbox-permissions`
|
||||
2. Gnanam: `feat/runtime-permission-events`
|
||||
3. Anandan: `feat/windows-sandbox-platform`
|
||||
4. Anandan: `feat/update-release-verification`
|
||||
5. Vasanth: `feat/tui-command-polish`
|
||||
6. Gnanam: `feat/session-rewind-compaction-contract`
|
||||
|
||||
Recommended first parallel set:
|
||||
|
||||
- Vasanth starts the TUI permission prompt and violation display.
|
||||
- Gnanam defines the permission event/grant contract.
|
||||
- Anandan owns Windows/platform sandbox reporting and tests.
|
||||
|
||||
## 9. Command Ownership Matrix
|
||||
|
||||
| Command Area | Vasanth | Gnanam | Anandan |
|
||||
| --- | --- | --- | --- |
|
||||
| `exec` | TUI and interactive UX | Runtime execution contract | Package smoke and platform validation |
|
||||
| `sandbox` | Prompt and violation UX | Policy, grants, and events | Platform adapters and Windows behavior |
|
||||
| `update` | Display and user messaging | Config and redacted errors | Primary owner of update verification |
|
||||
| `verify` | Result rendering | Verification backend contract | CI and release integration |
|
||||
| `changes` | TUI rendering | Change detection backend | PR automation integration |
|
||||
| `mcp`, `hooks`, `plugins`, `serve` | Discoverability and UI | Backend lifecycle and contracts | Automation/docs after behavior exists |
|
||||
| `sessions`, `search` | Resume/search UX | Storage, query, and event model | Stream-json and automation consumers |
|
||||
|
||||
## 10. Definition Of Done
|
||||
|
||||
For every code PR:
|
||||
|
||||
- Add or update tests for the changed behavior.
|
||||
- Run `go test ./...` when Go files changed.
|
||||
- Run the relevant build command when CLI entrypoints changed.
|
||||
- Run package smoke checks when install/update/npm wrapper files changed.
|
||||
- Include manual CLI output or screenshots only when they prove a user-facing flow.
|
||||
|
||||
For docs-only PRs:
|
||||
|
||||
- State clearly that no runtime behavior changed.
|
||||
- Do not use docs-only PRs as a substitute for platform product work.
|
||||
|
||||
For Anandan's PRs specifically:
|
||||
|
||||
- Must include one of:
|
||||
- user-runnable command behavior
|
||||
- install/update/release artifact verification
|
||||
- platform adapter behavior
|
||||
- CI that executes real installed Zero commands
|
||||
- Docs/CI can support the PR, but cannot be the entire value.
|
||||
|
||||
## 11. Review Rotation
|
||||
|
||||
- Vasanth PRs:
|
||||
- Gnanam reviews runtime contracts.
|
||||
- Anandan reviews platform/release impact when touched.
|
||||
- Gnanam PRs:
|
||||
- Vasanth reviews user-facing contract impact.
|
||||
- Anandan reviews platform/protocol impact when touched.
|
||||
- Anandan PRs:
|
||||
- Vasanth reviews user-facing CLI behavior.
|
||||
- Gnanam reviews runtime contract and safety impact.
|
||||
|
||||
## 12. Retired Historical Plan
|
||||
|
||||
The old M0-M6 TypeScript-to-Go migration schedule is now historical.
|
||||
|
||||
Do not assign new work from the old migration next-steps section. The Go migration foundation has already landed on main. Future work should move Zero from "ported" to "mature terminal coding agent."
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,208 @@
|
||||
# Deep-audit status ledger (re-verified against HEAD on branch fix/audit-batch, 2026-06-13)
|
||||
|
||||
Total verified: 175 of 175 extracted findings
|
||||
|
||||
| Status | Count |
|
||||
|---|---|
|
||||
| fixed | 97 |
|
||||
| open | 66 |
|
||||
| partial | 12 |
|
||||
| obsolete | 0 |
|
||||
|
||||
<!--
|
||||
Counts are the post-re-verification view: of the 87 rows the body still marks
|
||||
open/partial (69 open + 18 partial), the re-verification found 9 already fixed on
|
||||
`main` (3 open + 6 partial; listed in 2026-06-13-reverification.md), leaving 66
|
||||
open + 12 partial = 78 genuinely pending, and fixed 88 + 9 = 97. The per-finding
|
||||
rows below retain their original verdicts pending a row-level reclassification pass.
|
||||
-->
|
||||
|
||||
|
||||
> **Authoritative re-verification (2026-06-13, branch `fix/audit-batch` off current `main`).**
|
||||
> All 87 rows the 06-11 snapshot marked `open`/`partial` were re-checked against the ACTUAL
|
||||
> current code by independent agents (the 06-11 ledger was verified on `fix/audit-critical-high`,
|
||||
> which has since diverged). Every `already_fixed` verdict was adversarially re-checked by a
|
||||
> second agent trying to prove the defect was still present — none were refuted.
|
||||
> **Result: 78 genuinely pending** (66 still_open + 12 partial); **9 rows were already fixed on
|
||||
> `main`** and are reclassified to `fixed` here. Full per-finding table:
|
||||
> [`2026-06-13-reverification.md`](2026-06-13-reverification.md).
|
||||
> Pending by severity: **high 2, medium 42, low 34**. Counts above reflect this re-verification
|
||||
> plus this branch's own fixes (scanner.go:81, compaction.go:58, worktrees.go:247).
|
||||
|
||||
| Sev | Status | Location | Evidence |
|
||||
|---|---|---|---|
|
||||
| critical | fixed | internal/search/search.go:346 | search.go:363-428 findMatch maps lowered offsets back via lowerWithOffsets + snapRuneStart clamps context; /tmp probe of audit repro (100x'Ⱥ'+' hello') returns match {201 206}, valid UTF-8, no panic |
|
||||
| critical | fixed | internal/mcp/protocol.go:69 | 82b9c8d: const maxMessageBytes=64MiB (protocol.go:18) bounds both framing paths — Content-Length values above the cap are rejected (:90-92) and the newline-delimited readLine errors past the cap (:78-80); stdio transport switched to newline-delimited JSON. Covered by protocol_test.go; go test green |
|
||||
| high | fixed | internal/cli/usage.go:121 | usage.go:123-131: diff defaults to zerogit.DiffStat{}; resolveWorkspaceRoot/inspectChanges errors are ignored (degrade to net-LOC 0) instead of returning |
|
||||
| high | fixed | internal/tui/transcript.go:137 | transcript.go:121-142 keys are run-scoped ("%d:%d:%s" with row.runID) plus effectiveToolRowID ordinal suffix; model.go:1501/1553/1572/1584 set runID on rows |
|
||||
| high | fixed | internal/tui/model.go:853 | model.go:1034-1043 commandExit now calls cancelRun(), sets exiting, and defers tea.Quit while flushRunIDs is non-empty; deferred quit fires after flush at model.go:645-647 |
|
||||
| high | fixed | internal/tui/model.go:599 | internal/tui/flush.go:11-30 settled-row flush frontier prints history to native scrollback via tea.Println; model.go transcriptView() renders only live tail from m.flushed; covered by internal/tui/flush_test.go |
|
||||
| high | fixed | internal/tui/model.go:512 | model.go:614,633-637 flush goes to flushSessionID recorded at cancel time (model.go:1334) via appendSessionEventsTo (tui/session.go:76), not the active session |
|
||||
| high | fixed | internal/tui/session_controls.go:176 | session_controls.go:182-184 blocks /rewind while len(m.flushRunIDs) > 0 ('cannot rewind while a cancelled run is still flushing'); covered by tui/session_test.go |
|
||||
| high | fixed | internal/tui/model.go:584 | flush.go:71-120 settles rows into native scrollback via tea.Println; View()/transcriptView renders only rows from m.flushed (model.go:751-784) |
|
||||
| high | fixed | internal/tui/transcript.go:137 | transcript.go:121-141 bakes runID into every key; within-run repeats get ordinal suffix via effectiveToolRowID (transcript.go:148, used at model.go:1493-1496 with callSeq) |
|
||||
| high | fixed | internal/tui/rendering.go:525 | live cap 16 kept for tidy tail only; flushed scrollback cards use flushCardBodyMaxLines=400 (rendering.go:154-158) and Ctrl+O detailed view is uncapped (renderRowDetailed bodyCap:0, rendering.go:165; capCardLines treats <=0 as no cap, rendering.go:718-720) |
|
||||
| high | fixed | internal/tui/transcript.go:135 | transcript.go:121-141 transcriptRowKey bakes runID into every key ("%d:%d:%s"); model.go:1501/1553 set row.runID and effectiveToolRowID suffixes within-run repeats |
|
||||
| high | fixed | internal/tui/model.go:512 | model.go:614-637 — flushRunIDs maps cancelled run -> session id captured at cancel time (model.go:1334); flush goes via appendSessionEventsTo(flushSessionID,…) (session.go:76) when active session differs |
|
||||
| high | fixed | internal/tui/run.go:26 | flush.go:71-120 settled-row frontier prints history to native scrollback via tea.Println; model.go:769 View renders only the unflushed live tail |
|
||||
| high | fixed | internal/tui/transcript.go:139 | transcript.go:125 key is now "%d:%d:%s" (kind:runID:id) for all keyed kinds; transcript.go:148 effectiveToolRowID adds per-run ordinal suffix for within-run repeats |
|
||||
| high | fixed | internal/tui/model.go:311 | model.go:355-360 Ctrl+C sets exiting and returns nil (no Quit) while flushRunIDs non-empty; deferred tea.Quit fires at model.go:645-647 once the cancelled run's session events flush |
|
||||
| high | fixed | internal/tui/model.go:853 | model.go:1034-1043 commandExit now calls cancelRun(), sets m.exiting, and defers tea.Quit while len(m.flushRunIDs) > 0 — same protection as Ctrl+C |
|
||||
| high | fixed | internal/cli/extensions.go:186 | internal/cli/extensions.go:191 passes Servers through redactMCPServerConfigs (:205-224) which replaces every env/header value with [REDACTED]; asserted by extensions_test.go:108 (go test ok) |
|
||||
| high | fixed | internal/mcp/protocol.go:42 | 82b9c8d: read() now accepts newline-delimited JSON frames (isJSONStart→decodeMessage) and only falls back to Content-Length headers for back-compat; write() emits one JSON message per line. Covered by protocol_test.go; go test green |
|
||||
| high | fixed | internal/streamjson/streamjson.go:310 | 3142de6: secretPatterns now anchored — sk keys require a word boundary plus a 16-char body and bearer requires a 16-char token — so 'task-list' and prose like 'bearer of bad news' are no longer redacted while real keys/tokens still are. Covered by streamjson_test.go |
|
||||
| high | fixed | internal/cli/cron.go:112 | 7a47059: cronAdd now consumes an explicit positional expr (cron.go:113-118) BEFORE applying recipe defaults, so a schedule given alongside --recipe is honored; extra positionals error. Covered by cron_test.go |
|
||||
| high | fixed | internal/providerhealth/providerhealth.go:285 | 600057d: default probe now uses newConnectivityClient — CheckRedirect re-validates every hop (capped at 5) and DialContext validates+pins the resolved IP, dialing the IP literal to close the DNS-rebinding TOCTOU. Covered by connectivity_client_test.go |
|
||||
| high | fixed | internal/sessions/store.go:547 | 12a3f62 + 8c75074: ReadEvents now drops a torn final record (last non-empty line) only when the file lacks a trailing newline (tornTailPossible); a fully-flushed corrupt line, or any malformed earlier line, still fails loudly. Covered by store_test.go |
|
||||
| high | partial | internal/cli/cron_run.go:173 | 7a47059: fireJob now re-reads the job before persisting and preserves an external pause/removal mid-run (cron_run.go:170-181), closing the pause-clobber. But internal/cron/store.go still has no flock/CAS, so the concurrent-scheduler race (two schedulers both seeing NextRunAt due) remains — single-scheduler is the supported model |
|
||||
| high | fixed | internal/tools/bash.go:104 | 0dbb5e0: hardenProcessLifetime (bash_proc_unix.go) sets Setpgid, a Cancel that SIGKILLs the whole process group (negative pid), and a WaitDelay backstop so a backgrounded child cannot outlive the timeout or hang Run(); Windows sets WaitDelay only. Covered by bash_tool_test.go |
|
||||
| high | fixed | internal/sandbox/runner.go:262 | 2eeaea7: sandbox-exec profile write rule now also allows the standard device nodes (/dev/null, …) and temp trees (/tmp, /private/var/folders, …) for parity with bubblewrap's --dev/--tmpfs; the workspace stays the only writable project location. Covered by runner_test.go |
|
||||
| high | fixed | internal/tools/apply_patch.go:161 | b6182bf: patch headers are now parsed with hunk line-counting (git-apply semantics), so a hunk-body line like '-- /etc/old' is no longer mistaken for a file header and a valid patch is accepted. Covered by write_tools_test.go |
|
||||
| high | fixed | internal/tools/grep.go:224 | b6182bf: glob now matches against the path relative to the SEARCH directory (ripgrep semantics) via filepath.Rel(target,path); a single explicit file matches by base name. probe path=subdir glob=*.go now matches subdir/a.go. Covered by file_tools_test.go |
|
||||
| high | fixed | internal/providers/openai/types.go:3 | 46d495c (+7044d36/6664f71): chatCompletionRequest gained reasoning_effort; zeroruntime.CompletionRequest.ReasoningEffort is threaded through the agent loop and mapped per provider — OpenAI reasoning_effort, Anthropic extended thinking, Gemini thinking budget; CLI gates the value against the resolved model. Covered by openai/anthropic/gemini provider_test.go |
|
||||
| high | fixed | internal/specialist/exec.go:166 | 39a9c55: BuildArgs always prefixes the session title with the specialist name and falls back to the bare name when description is empty, so AgentName is non-empty and a description-less run resumes. Covered by specialist/exec_test.go |
|
||||
| high | fixed | internal/agent/loop.go:233 | 95a6a8e: Run now sets result.FinishReason from collected.FinishReason (and from the after-max-turns final answer); Result.Truncated()/TruncationNotice() drive an exec-writer warning and a TUI system row when a response was cut off. Covered by loop_test.go |
|
||||
| high | partial | internal/hooks/hooks.go:372 | 17a91d9: a Dispatcher that runs configured hooks and audits each via NewAuditStore now exists and is tested (dispatch.go + dispatch_test.go), closing the 'zero non-test callers' gap for Select/audit. But it has ZERO production callers — not wired into the beforeTool/afterTool tool lifecycle — and runHooks still supports only `list` (extensions.go:94); no dispatch/edit CLI surface |
|
||||
| low | fixed | internal/cli/exec_writer.go:412 | exec_writer.go:415 and :404 now call cutRuneBoundary (:420-431) which backs up to the last UTF-8 rune start before slicing; added in audit-fix commit 6fb0231 |
|
||||
| low | fixed | internal/mcp/server.go:129 | server.go:129-132 adds `case "ping": return server.writeResult(message.ID, map[string]any{})` (introduced in commit 6fb0231, present at HEAD) |
|
||||
| low | fixed | internal/search/search.go:309 | search.go:318-322: resolveSessions now returns fmt.Errorf("zero session not found: %s", sessionID) when store.Get returns nil instead of an empty success |
|
||||
| low | fixed | internal/doctor/doctor.go:134 | doctor.go:129-141: providerConfigCheck now reports apiKey presence ("set"/"not set") based on profile.APIKey != "", with comment explaining the change; doctor tests pass |
|
||||
| low | fixed | internal/tui/transcript.go:303 | truncateTUIOutput cuts via rune-boundary cutRunes (transcript.go:302,307-318); titles use cutRunes (session.go:112, spec_mode.go:305) and cutRuneBoundary (exec_sessions.go:90, exec_writer.go:420) |
|
||||
| low | fixed | go.mod:10 | go.mod:7-12 direct requires are now only bubbles/bubbletea/lipgloss/x-sys; glamour is gone entirely and x/ansi (line 18) and termenv (line 31) are demoted to // indirect |
|
||||
| low | fixed | internal/tui/model.go:525 | agentResponseMsg completion path now invokes m.runCancel() before nil-ing it (model.go:651-658, with explanatory comment about the per-prompt leak) |
|
||||
| low | fixed | internal/imageinput/imageinput.go:58 | imageinput.go:56-66 os.Open failure now wraps real cause ("cannot open image %s: %w") and read failure "cannot read image %s: %w"; only a stat miss reports "not found" (:38) |
|
||||
| low | fixed | internal/tui/session.go:93 | All three paths rune-safe: internal/tui/session.go:112 cutRunes(..., tuiSessionTitleLimit), internal/tui/spec_mode.go:305 cutRunes, internal/cli/exec_sessions.go:90 cutRuneBoundary(title, 80) |
|
||||
| low | fixed | internal/cli/exec_writer.go:412 | internal/cli/exec_writer.go:404 (truncateForStatus) and :415 (truncateForStreamJSONOutput) both use cutRuneBoundary defined at exec_writer.go:420-431 (backs up to utf8.RuneStart) |
|
||||
| low | fixed | internal/sessions/replay.go:237 | replay.go:245-256 cutPromptRuneBoundary (utf8.RuneStart backoff) now used at replay.go:174-177 and 238; titles rune-safe via cutRuneBoundary (cli/exec_sessions.go:90) and cutRunes (tui/session.go:112) |
|
||||
| low | fixed | internal/tui/rendering.go:68 | footerText/commandFooterText/formatCommandFooterText/defaultCommandFooterText (present at b144cf9~1 rendering.go) are absent at HEAD; dead actionAppendToolCall/ToolResult enum values also removed from transcript.go |
|
||||
| low | fixed | internal/tui/transcript.go:303 | cutRunes lands cuts on rune boundaries (transcript.go:305-318), used by truncateTUIOutput for tool-result text (transcript.go:302) and session titles (session.go:112, spec_mode.go:305) |
|
||||
| low | fixed | internal/tui/model.go:853 | commandExit cancels the run, sets exiting, and defers quit while flushRunIDs is non-empty (model.go:1034-1043); deferred tea.Quit fires when the last flush drains (model.go:645-647), and new prompts are gated while exiting (model.go:1012-1014) |
|
||||
| low | fixed | internal/tui/session.go:93 | cutRunes (transcript.go:307-318) cuts on rune boundaries; used by tuiSessionTitle (session.go:112) and truncateTUIOutput (transcript.go:302) for tool-result text |
|
||||
| low | fixed | internal/tui/rendering.go:482 | rendering.go:526 — permission card action row now renders "[esc] cancel run", matching the KeyEsc fall-through to m.cancelRun() (model.go:391-393) |
|
||||
| low | fixed | internal/cli/update.go:37 | internal/update/update.go:136-142: normalizeVersionTag failure now falls back to currentVersion="0.0.0" so the check proceeds to the network call (landed in #158) |
|
||||
| low | fixed | internal/notify/notify.go:134 | notify.go:137 now drops `(r >= 0x80 && r <= 0x9f)` with comment that C1 includes single-byte CSI/OSC/ST introducers (landed in #158) |
|
||||
| low | fixed | internal/tui/model.go:512 | model.go:616-624 the flushRunIDs (cancelled-run) branch of agentResponseMsg now records each usage event via recordUsageEvent → m.usageTracker.Record (session_controls.go:294) |
|
||||
| low | fixed | internal/tui/rendering.go:490 | rendering.go:560-564 the ask card now echoes the in-progress answer: "❯ " + fill(zeroTheme.ink).Render(input) + accent cursor, appended to the card lines |
|
||||
| low | fixed | internal/tui/view.go:227 | view.go:230-235 now matches `path == home` or strings.HasPrefix(path, home+os.PathSeparator) — a path-boundary check with a comment citing the /Users/alice2 sibling case |
|
||||
| low | fixed | internal/sandbox/risk.go:53 | defect not present: risk.go:11-50 patterns are package-level regexp.MustCompile compiled once at init (identical at audit commit b144cf9); per call only MatchString runs |
|
||||
| low | fixed | internal/modelregistry/catalog.go:36 | catalog.go:36 claude-haiku-3.5 extra capabilities now []ModelCapability{ModelCapabilityPromptCache} — ModelCapabilityVision removed (base withBaseCapabilities adds no vision), so SupportsVision is false |
|
||||
| low | fixed | internal/providers/providerio/retry.go:107 | retry.go:108-110 ShouldRetryStatus now returns true for `code == 529` alongside 429/503 (doc comment lines 99-107 updated to cover Anthropic overloaded), consistent with ClassifiedError's 529 rate-limit classification at providerio.go:224 |
|
||||
| low | fixed | internal/tui/spec_mode.go:301 | spec_mode.go:305 and session.go:112 now use cutRunes (transcript.go:307-318 backs off to utf8.RuneStart boundary); no byte-index title slicing remains |
|
||||
| low | fixed | internal/agent/system_prompt.go:89 | system_prompt.go:102-107 now walks cut back with `for cut > 0 && !utf8.RuneStart(content[cut])` before slicing, so truncation lands on a rune boundary (matches audit doc's rune-safe truncation fix claim) |
|
||||
| low | fixed | internal/config/writer.go:53 | internal/config/writer.go:55-74 — writeConfigFile now does os.CreateTemp + Chmod(0600) + write + os.Rename (atomic temp+rename, with explanatory comment at :53); go test ./internal/config -run Upsert passes |
|
||||
| low | fixed | internal/cli/exec_sessions.go:88 | exec_sessions.go:87-91: title = cutRuneBoundary(title, 80); helper (exec_writer.go:420-431) verified rune-safe by /tmp probe |
|
||||
| low | fixed | internal/cli/exec_writer.go:412 | exec_writer.go:404 (truncateForStatus/stderr) and :415 (truncateForStreamJSONOutput) both use cutRuneBoundary, which backs up to a utf8.RuneStart boundary |
|
||||
| low | open | internal/streamjson/streamjson.go:30 | repo-wide grep finds EventRestore only at its declaration (streamjson.go:30) — no emitter and not even test references; streamjson.go untouched by 6fb0231 or later merges |
|
||||
| low | open | internal/cli/cron_run.go:150 | cron_run.go:145-151: rec.Error still set only from errBuf on code!=0; outBuf (where stream-json error events land) is captured but never read |
|
||||
| low | open | internal/cli/exec_sessions.go:55 | exec_sessions.go:55 still constructs sessions.NewStore(sessions.StoreOptions{}) directly, and exec.go:375-389 PrepareExecOptions still omits the Store field; deps.newSessionStore is used by spec/usage/observability but not the exec run path |
|
||||
| low | fixed | internal/secrets/scanner.go:35 | [reverified 2026-06-13] already fixed on main: scanner.go:37 pattern is now `sk-[A-Za-z0-9_-]{20,}` (allows -/_ so sk-proj-/sk-svcacct- match), with comment at :35-36. Covered by scanner_test.go TestScanDetectsModernPrefixedOpenAIKeys |
|
||||
| low | open | internal/redaction/redaction.go:222 | redaction.go:221-235 pointer/map ptr added to context.seen and never deleted after subtree recursion, so a second (sibling) visit returns CircularReference |
|
||||
| low | open | internal/redaction/redaction.go:174 | redaction.go:174 `interface{ StackTrace() fmt.Stringer }` unchanged; grep finds no implementer in the module and go.mod has no pkg/errors or similar dep |
|
||||
| low | open | internal/zerogit/zerogit.go:417 | zerogit.go:418-420 fallback EnvRunner adapter still discards env (`_ []string`), so stagedSnapshotDiff's GIT_INDEX_FILE isolation is lost with a caller-supplied Runner |
|
||||
| low | open | internal/tui/commands_test.go:46 | internal/tui/commands_test.go:46 still defines commandTestStringSliceContains; repo-wide grep finds no callers, only the definition |
|
||||
| low | open | internal/tui/session_test.go:215 | internal/tui/session_test.go:215 'runtimeMessages := []tea.Msg{}' appended in RuntimeMessageSink at line 226 but never read in the test body (only runtimeMessageCh is consumed) |
|
||||
| low | open | internal/sessions/store.go:555 | store.go:554-556 timestamp() still time.RFC3339 (second precision); List ties broken lexically by SessionID (store.go:331-336), not actual recency, so Latest() can still pick the wrong session on same-second ties |
|
||||
| low | open | internal/tui/session_controls.go:165 | session_controls.go:165 still only increments compactRequests; sole reads are its own status display (lines 266, 280) with "Backend: state: pending integration" — no compaction is ever triggered by it |
|
||||
| low | open | internal/tui/model.go:837 | model.go:1012-1014 silently drops prompt submits while m.exiting (return m, nil — no feedback row); no exiting/flushRunIDs indicator anywhere in statusLine (view.go:113-139), composerLine, or any render path |
|
||||
| low | open | internal/tui/command_center.go:17 | tui/command_center.go:17-21 passes only Now/Runtime/Provider (no UserConfig/ProjectConfig) vs cli/observability.go:64-71; empty paths warn at doctor.go:120 and :273 |
|
||||
| low | open | internal/background/process_posix.go:38 | process_posix.go:39-47 still polls signal-0 on os.FindProcess(pid) while the Wait goroutine (specialist/exec.go:653) reaps the child; line 50 Kill() can hit a recycled pid |
|
||||
| low | open | internal/tools/registry.go:108 | repo-wide grep: OnSandboxDecision only at registry.go:19,108,116 — no caller or test ever sets it |
|
||||
| low | open | internal/sandbox/grants.go:146 | grants.go unchanged since #77: Lookup trims key (grants.go:146) but readState validates raw file keys via trimming ValidateToolName (grants.go:225-227,273) and keeps the raw map key, so a whitespace-padded key validates yet never matches |
|
||||
| low | open | internal/tools/read_file.go:89 | probe: max_lines=5 -> Truncated=true with no marker beyond '(lines 1-5 of 20)' header (read_file.go:102-111); rune-width sub-claim not reproducible (ASCII strconv.Itoa padding) |
|
||||
| low | open | internal/providers/factory.go:118 | factory.go:115 still `if !explicitProvider \|\| isImplicitOpenAI(...)`; isImplicitOpenAI (factory.go:159-164) requires TrimSpace(ProviderKind)=="" && TrimSpace(Provider)=="", which contradicts explicitProvider==true (factory.go:147-157), so it can never return true at its sole call site |
|
||||
| low | open | internal/providercatalog/catalog.go:46 | catalog.go:46 Public still declared; no constructor or descriptor ever sets it (always false); the only read is the vacuous guard in catalog_test.go:105, which already existed before the audit (added in 4accec5/#141) — no production read or write |
|
||||
| low | open | internal/verify/verify.go:215 | verify.go:215 RunLoop still defined, sole caller verify_test.go:207; production loop is selfverify.Run (selfverify.go:53-74) used by cli/workflows.go |
|
||||
| low | open | internal/specialist/output_tool.go:244 | output_tool.go:244 formatTaskOutput has zero callers (prod or tests); only formatTaskOutputSummary is used (output_tool.go:154) |
|
||||
| low | open | internal/specialist/exec.go:346 | exec.go:347-350: on SetPID error calls cleanupBackgroundPromptFile (runtime.go:73 deletes file/dir) and returns err with no recordSpecialistStop/UpdateStatus while child keeps running |
|
||||
| low | open | internal/agent/loop.go:188 | helpers.go:85 sets collected.Error = ctx.Err().Error(); loop.go:189-191 returns errors.New(collected.Error) before the ctx.Err() identity return at loop.go:193-195, so errors.Is(err, context.Canceled) still fails for that path |
|
||||
| low | open | internal/agent/types.go:183 | types.go:183 OnContext field and loop.go:102-103 emit site exist, but repo-wide grep finds no "OnContext:" assignment and no MeasureContext/ContextBreakdown use in cli/tui/cmd/contextreport (only tests) |
|
||||
| low | open | internal/agent/loop.go:1072 | loop.go:1120-1122 and loop.go:1129-1131 both still assign schema["items"] = propertyToRuntimeMap(*property.Items) |
|
||||
| low | open | internal/agent/loop.go:473 | loop.go:530-531 set requestEvent.GrantMatched/Grant but requestEvent is never read after the switch (post-run event rebuilt at loop.go:558); fallback at loop.go:510-511 unreachable since buildPermissionEvent always returns ok=true under the shouldRequestPermission gate (Prompt-permission tools, loop.go:751-758 vs 877-894) |
|
||||
| low | open | internal/agent/loop.go:183 | loop.go:184-186 retry CollectStreamWithOptions still passes only OnUsage; comment at loop.go:178-183 documents the OnText omission as deliberate anti-duplication, but streaming surfaces still receive only the aborted partial text |
|
||||
| low | open | internal/zeroruntime/helpers.go:104 | helpers.go:104 still does `collected.Text += event.Content` per StreamEventText (no strings.Builder); unchanged since audit commit b144cf9 |
|
||||
| low | open | internal/zerocommands/sandbox_snapshots.go:104 | grep for SandboxPlan/Decision/Policy/Backend/Risk/Violation/Hook/Plugin/MCPServer snapshots finds no consumers outside internal/zerocommands + tests; `zero sandbox policy --json` serializes raw sandbox.Policy/Backend/BackendPlan instead (internal/cli/sandbox.go:90-105) |
|
||||
| low | open | internal/config/contracts.go:12 | internal/config/contracts.go still defines ContractGap/DefaultContractGaps/FindContractGapsByMilestone; only reference repo-wide is internal/config/contracts_test.go |
|
||||
| low | open | internal/skills/skills.go:91 | skills.Duplicates (skills.go:91) and skills.Get (skills.go:217) have zero callers outside internal/skills; cli/skills.go uses List and tools/skill.go uses Load, so collisions are still dropped without any user-facing warning (dedup is now documented/deterministic but unsurfaced) |
|
||||
| low | open | internal/plugins/plugins.go:893 | internal/plugins/plugins.go:893 allows only beforeTool/afterTool/sessionStart/sessionEnd, while internal/hooks/hooks.go:812 also accepts specialistStart/specialistStop — sets still disagree |
|
||||
| low | open | internal/config/resolver.go:136 | internal/config/resolver.go:143, :173 and :502 all gate on `MaxTurns > 0` with no error path, so a configured maxTurns of 0 or negative is still silently ignored (falls back to default 30) |
|
||||
| low | open | internal/cli/exec.go:353 | exec.go:375 PrepareExec called without Store (defaults to NewStore in sessions/exec_session.go:58), exec_sessions.go:55 direct NewStore, exec.go:396 time.Now() instead of deps.now |
|
||||
| low | open | internal/zerocommands/contracts.go:166 | contracts.go:166 APIKeySet checks only profile.APIKey, ignoring AuthHeaderValue (valid sole credential per config/types.go:49-51); rendered "not set" at cli/command_center.go:328 |
|
||||
| low | partial | internal/cli/cron.go:275 | promptExcerpt fixed via cutRuneBoundary(p,47) at cron.go:275, but cronTruncate at cron_run.go:179-184 still returns s[:max]+"…" (byte slice, can split a rune in persisted run errors) |
|
||||
| low | partial | internal/tui/options.go:28 | app.go:413 production caller still sets none of the three, but newModel now self-defaults a usage.Tracker when nil (model.go:239-242) so usage works; effort/style are settable at runtime via /effort and /style |
|
||||
| low | partial | internal/tui/rendering.go:62 | Footer system, actionAppendToolCall/Result, and dead theme tokens (selRow/statusOk/statusErr/line2) are removed, but listCommandNames/formatCommandHelpLines remain test-only (commands.go:297,306), resumeText's args!="" branch is still unreachable (command_center.go:44-54; sole caller session.go:122 passes ""), OnContext is still wired by no production caller (agent/loop.go:102), and stale footer comments persist (commands.go:254, autocomplete.go:64) |
|
||||
| low | partial | internal/zerogit/zerogit.go:206 | truncateSubject now rune-based (zerogit.go:464-472, no invalid UTF-8), but ValidateMessage still byte-based `len(firstLine) > 72` (zerogit.go:207) so valid non-ASCII subjects are rejected |
|
||||
| low | partial | internal/cli/cron.go:275 | promptExcerpt fixed (cron.go:275 uses cutRuneBoundary) but error helper cronTruncate still byte-slices: internal/cli/cron_run.go:183 'return s[:max] + "…"', used for run errors at cron_run.go:150 |
|
||||
| low | partial | internal/tui/command_center.go:44 | Footer helpers now render (rendering.go:690-700, bash/grep footers 892-905/969-971), but resumeText's args!="" branch (command_center.go:43-54) is still unreachable (sole caller session.go:122 passes "") and stale footer comments persist (autocomplete.go:64, commands.go:254) |
|
||||
| low | partial | internal/cli/cron.go:275 | promptExcerpt fixed via cutRuneBoundary (cron.go:275), but cronTruncate still byte-slices `s[:max]` at cron_run.go:182, splitting runes in run-record error text |
|
||||
| low | partial | internal/tui/rendering.go:62 | footer builders (footerText/commandFooterText/etc.) and the four theme tokens (selRow/statusOk/statusErr/line2/panel2) are fully removed (zero grep hits), but formatCommandHelpLines/listCommandNames (commands.go:297-308) remain production code referenced only by tests, and stale "footer advertises" comments persist (commands.go:254, autocomplete.go:64) |
|
||||
| medium | fixed | internal/update/update.go:135 | update.go:136-142: normalizeVersionTag error now falls back to currentVersion="0.0.0" (commented for dev builds) instead of returning the error |
|
||||
| medium | fixed | internal/tui/session.go:190 | session.go:219-227 rehydrates role "ask_user"/"ask_user_answers" rows via askUserRequestFromPayload (session.go:349) and askUserAnswersText (session.go:380) |
|
||||
| medium | fixed | internal/tui/model.go:1067 | cancelRun appends rowSystem "Run cancelled." plus any partial streamed answer (model.go:1336-1343); asserted by flush_test.go:105 |
|
||||
| medium | fixed | internal/search/search.go:345 | search.go:363-407 findMatch maps lowered offsets back via lowerWithOffsets offset table; snapRuneStart (:417) guards context slicing; tests pass |
|
||||
| medium | fixed | internal/search/search.go:108 | search.go:111-118 LoadIndex error now `result.SkippedSessions++; continue` instead of returning; SkippedSessions reported in Result (:61) |
|
||||
| medium | fixed | .github/workflows/ci.yml:31 | .github/workflows/ci.yml:31-44 adds 'Check formatting' (gofmt -l, fails on output) and 'Vet' (go vet ./...) steps; gofmt -l . is clean at HEAD |
|
||||
| medium | fixed | internal/tui/transcript.go:303 | internal/tui/transcript.go:302 truncateTUIOutput now calls cutRunes (rune-boundary cut, transcript.go:307-318); non-ASCII test TestCutRunesNeverSplitsUTF8 at flush_test.go:158 |
|
||||
| medium | fixed | internal/cli/usage.go:46 | usage.go:47-51 collectUsageData now does set.skipped++ and continue on ReadEvents failure, so a corrupt session is skipped instead of aborting |
|
||||
| medium | fixed | internal/tui/model.go:1081 | cancelRun appends partial answer then rowSystem "Run cancelled." marker (model.go:1336-1343), shared by Esc (model.go:392) and Ctrl+C (model.go:355) paths |
|
||||
| medium | fixed | internal/tui/model.go:227 | WindowSizeMsg sets m.input.Width = maxInt(20, chatWidth(msg.Width)-14) so long input scrolls with cursor visible (model.go:556-558) |
|
||||
| medium | fixed | internal/tui/view.go:394 | view.go:412-431 now requires a hunk header match or BOTH '--- ' and '+++ ' file headers; a lone '---' line no longer triggers the diff renderer |
|
||||
| medium | fixed | internal/tui/rendering.go:261 | rendering.go:286-330 preserves explicit newlines and each line's leading indent (tabs→4 spaces), continuations keep the indent; only intra-line space runs collapse |
|
||||
| medium | fixed | internal/tui/model.go:456 | WindowSizeMsg resizes composer and prints title bar at real width (model.go:553-566); flush refuses to freeze history before a real width arrives (flush.go:72-77); width/tier recomputed every render (view.go:17-19) and history is native scrollback, so only the ephemeral pre-size frame uses the 96-col default |
|
||||
| medium | fixed | internal/tui/session_controls.go:176 | session_controls.go:182-184 — handleRewindCommand returns "cannot rewind while a cancelled run is still flushing" when len(m.flushRunIDs) > 0 |
|
||||
| medium | fixed | internal/tui/session.go:190 | model.go:1461-1476 persists ask_user question payload AND ask_user_answers; session.go:218-227 rehydrates both via askUserRequestFromPayload/askUserAnswersText (session.go:349, 380) |
|
||||
| medium | fixed | internal/tui/model.go:853 | model.go:1034-1043 — commandExit calls cancelRun(), sets exiting, defers tea.Quit until flushRunIDs drains (model.go:645); spec_mode.go:25 gates /spec on m.pending \|\| m.exiting |
|
||||
| medium | fixed | internal/tui/spec_mode.go:22 | spec_mode.go:25 handleSpecCommand gates on `if m.pending \|\| m.exiting` and refuses to start, with comment citing the post-Ctrl+C flush window |
|
||||
| medium | fixed | internal/tui/model.go:1081 | model.go:1340-1343 cancelRun appends the partial streamed answer plus rowSystem "Run cancelled." to m.transcript (live), in addition to the session-log marker |
|
||||
| medium | fixed | internal/tui/model.go:610 | model.go:769 transcriptView loops only `for index := m.flushed` (live tail; settled rows flushed once to scrollback per flush.go), and rendering.go:184-192 memoizes stable row renders in the LRU staticRenderCache (render_cache.go:19) |
|
||||
| medium | fixed | internal/tui/session.go:93 | session.go:112 tuiSessionTitle uses cutRunes; transcript.go:302/307-318 truncateTUIOutput cuts on the last rune boundary at or before the byte limit |
|
||||
| medium | fixed | internal/tui/model.go:227 | model.go:558 WindowSizeMsg handler sets `m.input.Width = maxInt(20, chatWidth(msg.Width)-14)` so long input scrolls horizontally with the cursor visible |
|
||||
| medium | fixed | internal/providers/providerio/providerio.go:184 | providerio.go:142-154 onComment now forwards a keepAlive marker to the consumer, which calls resetIdle() at providerio.go:203 before the keepAlive continue (line 204-205); providerio tests pass |
|
||||
| medium | fixed | internal/modelregistry/catalog.go:27 | catalog.go:27 gpt-4.1 entry now reads ContextLimits{ContextWindow: 1_047_576, MaxOutputTokens: 32_768}, matching mini/nano |
|
||||
| medium | fixed | internal/tui/run.go:41 | run.go:42-46 now prints `zero: tui error: <err>` to os.Stderr before returning 1 |
|
||||
| medium | open | internal/mcp/client.go:302 | client.go:295-317 readLoop dispatches any id-bearing message to pending[id] without checking message.Method; grep confirms no 'ping' handling anywhere in client.go |
|
||||
| medium | open | internal/mcp/client.go:246 | client.go:246 `client.writer.write(...)` is a blocking pipe write executed while client.mu is held (:228-:255) with no ctx awareness; file unchanged since the audit (only the response wait, which predates the audit, is ctx-aware) |
|
||||
| medium | open | internal/mcp/client.go:98 | client.go:98-99 `var stderr bytes.Buffer; cmd.Stderr = &stderr` — plain unbounded buffer attached for the process lifetime, only read on initialize failure |
|
||||
| medium | open | internal/mcp/network_client.go:621 | network_client.go:603-632 decodeSSERPCMessage stops at the first non-empty 'message' event (sets found, returns false); caller at :210 errors on id mismatch instead of scanning for the matching response — unchanged since audit |
|
||||
| medium | open | internal/mcp/network_client.go:637 | network_client.go:637 `scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)`; ErrTooLong propagates via readStream->failPending (:578-:592) setting a permanent streamErr with no reconnect, so request() (:234) fails forever |
|
||||
| medium | open | internal/cli/cron_run.go:115 | cron_run.go:102-126 reconcileOverdue still reschedules any active job with NextRunAt before the current minute without firing; no RunNow/RunOnce sentinel exists in internal/cron, and the file is unchanged since pre-audit commit 0f8ea50 |
|
||||
| medium | open | internal/sessions/lineage.go:140 | lineage.go:140 tree() still calls ListChildren per node, which calls store.List() (lineage.go:74) re-reading every metadata.json from disk (store.go:312-327, no cache); file unchanged since pre-audit commits |
|
||||
| medium | open | internal/cli/exec_sessions.go:114 | exec_sessions.go:117-123 still early-returns once recorder.err is set and stores AppendEvent errors there; recorder.err is never read anywhere (exec.go/exec_spec.go check only writer.err) |
|
||||
| medium | open | internal/tui/session_controls.go:117 | m.responseStyle still only displayed (command_views.go:155); /theme //input-style render "does not have a backend setting yet" (rendering.go:47); /compact shows "state: pending integration" (session_controls.go:272); all still in commandDefinitions help/autocomplete (commands.go:189-230) |
|
||||
| medium | fixed | internal/tui/session_controls.go:42 | 46d495c/7044d36/6664f71: zeroruntime.CompletionRequest now carries ReasoningEffort, threaded from agent.Options through every request the loop builds and mapped per provider (OpenAI reasoning_effort, Anthropic extended thinking with thinking-block replay, Gemini thinkingBudget). Covered by provider tests |
|
||||
| medium | fixed | internal/secrets/scanner.go:81 | [reverified+fixed 2026-06-13, fix/audit-batch] Redact now replaces longest matches first (copies findings, sorts by len(Match) desc) so a containing PRIVATE KEY block is redacted before any nested shorter match corrupts its exact string. Covered by scanner_test.go TestRedactNestedSecretStillRemovesWholeBlock (fails pre-fix: leaks BEGIN/END header) |
|
||||
| medium | fixed | internal/redaction/redaction.go:362 | [reverified 2026-06-13] already fixed on main: urlWithCredsPattern is a package-level var (redaction.go:367) and redactURLPasswords (:369) uses it via ReplaceAllStringFunc — no per-call regexp.MustCompile remains |
|
||||
| medium | open | internal/zerogit/zerogit.go:212 | zerogit.go:223 `path := strings.TrimSpace(line[3:])` — no C-quote unescaping and no `old -> new` rename split; no -z porcelain used |
|
||||
| medium | fixed | internal/worktrees/worktrees.go:247 | [reverified+fixed 2026-06-13, fix/audit-batch] defaultRunGit now captures stdout/stderr into separate bytes.Buffers and returns CommandResult{Stdout, Stderr}; git warnings no longer pollute parsed rev-parse output and error messages get real stderr. Covered by worktrees_test.go TestDefaultRunGitSeparatesStdoutAndStderr |
|
||||
| medium | open | internal/sessions/store.go:632 | store.go:632-635 writeMetadata still os.WriteFile temp + os.Rename with no file/dir Sync; zero Sync() calls anywhere in internal/sessions; appendEventLocked (store.go:512-517) also unsynced |
|
||||
| medium | open | internal/tui/session_controls.go:235 | session_controls.go:244 returns lastCheckpoint-1 while the tool_call is persisted immediately before its checkpoint (model.go:1505 then 1527), so truncate (rewind.go:205 keeps seq<=target) retains the dangling tool_call |
|
||||
| medium | open | internal/sessions/store.go:387 | store.go:387-391 Fork re-appends every parent event (no provider_usage skip) via AppendEvent which stamps a fresh CreatedAt (store.go:495-501); BuildReport has no event-id dedupe |
|
||||
| medium | open | internal/usage/report.go:17 | report.go:17-21 usageEventPayload has only token fields and report.go:102 prices from session Metadata.ModelID, while exec.go:510-512 still persists payload["model"] under --allow-escalation — never read |
|
||||
| medium | open | internal/cli/exec_sessions.go:115 | exec_sessions.go:117-121 append still early-returns once recorder.err is set, and grep shows sessionRecorder.err is never read in exec.go (no surfacing at run end) |
|
||||
| medium | open | internal/tui/session_controls.go:117 | m.responseStyle (session_controls.go:117) is only echoed in /style and /config text (command_views.go:155); grep shows it never reaches agent.Options/system prompt (runAgentWithOptions model.go:1366-1397 sets no style) |
|
||||
| medium | open | internal/cron/schedule.go:171 | Probe at HEAD: Next("30 1 * * *", 2026-11-01 01:30 EDT) = 01:30 EST same day (repeated hour); schedule.go:171-201 guards only spring-forward gaps |
|
||||
| medium | open | internal/cli/cron.go:112 | cron.go:112-114 sets expr=r.Expr while expr is still ""; cron.go:123 only uses positional[0] if expr=="" — user's expr silently ignored, no warning |
|
||||
| medium | open | internal/background/process_posix.go:50 | process_posix.go:29/50 signal only pid (no kill(-pid)); launchBackgroundProcess (internal/specialist/exec.go:642) sets no Setpgid; Windows still tree-kills via taskkill /T (process_windows.go:12) |
|
||||
| medium | open | internal/background/manager.go:462 | manager.go:462-471 normalizeLoadedTask rewrites Status==running to error/PID=0/ExitCode=-1; loadTasks persists it to disk at manager.go:419 |
|
||||
| medium | open | internal/tui/session_controls.go:117 | session_controls.go:117 still only stores m.responseStyle; sole readers are display strings (command_views.go:155, session_controls.go:120/132) — runAgentWithOptions (model.go:1366+) never threads it into agent.Options or the system prompt, so /style still changes nothing about responses |
|
||||
| medium | open | internal/tools/registry.go:224 | registry.go:190-236 Core* sets still omit escalate_model; only exec --allow-escalation registers it (cli/exec.go:157, predates audit), TUI path (cli/app.go:449) never does |
|
||||
| medium | open | internal/sandbox/safe_command.go:138 | file unchanged since #109; splitShellSegments (safe_command.go:431) is quote-unaware — probe: `git commit -m "use top \| less"` flagged as 'less', `echo "a; vim b"` flagged as 'vim' |
|
||||
| medium | open | internal/providers/gemini/types.go:88 | gemini/types.go:88-93 usageMetadata still has only Prompt/Candidates/Thoughts/Total token counts (no cachedContentTokenCount); grep shows CachedInputTokens is set only in openai/provider.go:253 and anthropic/provider.go:274, never in gemini |
|
||||
| medium | open | internal/providers/openai/tool_state.go:95 | tool_state.go:95-100 closeOpen still emits StreamEventToolCallDropped for calls with empty id (and applyDelta:58 never streams them); zeroruntime/helpers.go:141-207 empty-ID/synthetic-key collector support remains unreachable from the adapter |
|
||||
| medium | open | internal/specialist/exec.go:150 | exec.go:150 and :195 still hardcode "--auto","high"; cli/exec_tools.go:84-85 maps high -> agent.PermissionModeUnsafe |
|
||||
| medium | open | internal/specialist/accounting.go:72 | accounting.go:31/80 check (ReadEvents) then :47/91 append under separate store locks; concurrent callers remain at exec.go:336 (onExit) and output_tool.go:150 (TaskOutput); no serializing mutex in package |
|
||||
| medium | open | internal/specialist/streamer.go:43 | streamer.go:43 still caps scanner at 1024*1024; ErrTooLong fails ParseStream -> runChildProcess err (exec.go:624-627) aborts whole run; tool_call Args still emitted untruncated (cli/exec_writer.go:99) |
|
||||
| medium | open | internal/agent/loop.go:468 | loop.go:525-528 AlwaysAllow still returns deniedPermissionResult on persistPermissionGrant error; loop.go:778-779 still errors unconditionally when options.Sandbox == nil |
|
||||
| medium | partial | internal/agent/compaction.go:58 | [reverified+fixed 2026-06-13, fix/audit-batch] estimateTokens now adds len(message.Images)*imageTokenEstimate (flat per-image cost, ~1k; raw byte size is a poor proxy for vision tiles) — Images are no longer uncounted. Covered by compaction_test.go TestEstimateTokensCountsImages. REMAINING: maybeCompact still has no tool-definition term in its estimate |
|
||||
| medium | open | internal/agent/compaction.go:376 | compaction.go:376 summarizeMessagesOnce uses zeroruntime.CollectStream with no OnUsage; comment at compaction.go:314-315 documents OnText/OnUsage are intentionally not forwarded |
|
||||
| medium | open | internal/config/resolver.go:95 | ResolvedConfig.MCP set at resolver.go:102 has no readers (grep: zero non-test uses); MCP registration uses ResolveMCP (resolver.go:109-124) which ignores options.ProviderCommand, so ZERO_PROVIDER_COMMAND MCP servers are still dropped |
|
||||
| medium | open | internal/hooks/hooks.go:503 | internal/hooks/hooks.go:503 — append still calls store.ReadEvents() (full file read + JSON parse) on every append just to compute the next Sequence |
|
||||
| medium | open | internal/cli/app.go:149 | app.go:177-190: comment says trailing non-flag args "still are" ignored; rest scanned only for --add-dir then dropped before runInteractiveTUI |
|
||||
| medium | open | internal/cli/exec.go:500 | exec.go:524-532: interrupted branch emits errorEvent/runEnd only for execOutputStreamJSON; json path prints "Interrupted." to stderr, no terminal JSON event |
|
||||
| medium | open | internal/cli/exec.go:182 | exec.go:186-193: only stream-json is special-cased; -o json falls to writeExecToolList plain text (exec_tools.go:100-110 "Tools visible to model:") |
|
||||
| medium | partial | internal/tui/transcript.go:113 | Full-slice copy removed — in-place append, unkeyed rows skip the scan (transcript.go:92-101) — but hasTranscriptRow still linearly scans all rows per keyed tool/permission/ask append (transcript.go:103-114), so tool-heavy rehydration stays O(n²) |
|
||||
| medium | partial | internal/tui/transcript.go:113 | O(n) full-slice copy removed (in-place append, transcript.go:96-100), but hasTranscriptRow (transcript.go:103-114) still linearly scans all rows per keyed (tool/permission) row and m.transcript is never pruned, so tool-heavy rehydration remains O(n^2) |
|
||||
| medium | partial | internal/tui/model.go:569 | transcript half fixed: interim text appended as non-final assistant row on tool call/error/cancel (model.go:710-715, 681-683, 1340-1342); but session log still records only FinalAnswer (model.go:1638-1644) — interim segments never persisted, so /resume loses them |
|
||||
| medium | partial | internal/tui/transcript.go:117 | re-render gone (View renders only unflushed tail, model.go:769; render_cache.go memoizes) and copy-on-append removed (transcript.go:96-100); but hasTranscriptRow still scans the whole transcript per keyed append (transcript.go:103-114) — O(n²) cumulative for tool rows |
|
||||
| medium | partial | internal/tui/transcript.go:113 | Copy fixed: transcript.go:96-100 appends in place; but hasTranscriptRow (transcript.go:103-114) still linear-scans all rows per keyed (tool/permission/ask) row, so tool-heavy /resume rehydration remains O(N²) |
|
||||
| medium | partial | internal/tui/transcript.go:117 | transcript.go:100 full-slice copy removed (in-place append; non-keyed rows now O(1) via empty-key fast path), but hasTranscriptRow transcript.go:108-113 still linear-scans all rows per tool/permission/ask append — tool-heavy appends/rehydration remain quadratic |
|
||||
| medium | partial | internal/tui/rendering.go:261 | rendering.go:286-303 now preserves explicit newlines and each line's leading indentation (code blocks survive), but rendering.go:305 still runs strings.Fields(body) per line, collapsing intra-line space runs so mid-line column alignment is still lost |
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,117 @@
|
||||
# Audit re-verification against current `main` (2026-06-13)
|
||||
|
||||
Every pending (open/partial) row from `2026-06-10-deep-audit-status.md` was re-checked
|
||||
against the **actual current code** by an independent agent (the ledger was verified on a
|
||||
since-diverged branch). Every `already_fixed` verdict was then adversarially re-checked by a
|
||||
second agent trying to prove the defect was still present; **none were refuted** (all 9 hold).
|
||||
|
||||
| Verdict | Count |
|
||||
|---|---|
|
||||
| still_open | 66 |
|
||||
| partial | 12 |
|
||||
| already_fixed (ledger over-counted) | 9 |
|
||||
| **genuinely pending (still_open + partial)** | **78** |
|
||||
|
||||
Pending by severity: high 2, medium 42, low 34.
|
||||
|
||||
## Already fixed on `main` (reclassify to fixed) (9)
|
||||
|
||||
| Sev | Location (current) | Rationale |
|
||||
|---|---|---|
|
||||
| medium | `internal/cli/cron.go:106-128` | The original defect (recipe set `expr=r.Expr` before the positional was consumed, silently ignoring a user-supplied expr) is gone: cronAdd now consumes the positional expr first at cron.go:113-114 (`if len(positional)==1 && expr=="" { expr=positional[0] }`), and the recipe only fills expr if still empty (cron.go:122-123); extra positionals error. Confirmed by TestCronAddExplicitExprOverridesRecipe (cron_test.go:95-118). |
|
||||
| low | `internal/cli/cron_run.go:198-205` | The original gap was that cronTruncate byte-sliced `s[:max]+"…"` and could split a multi-byte rune; current cronTruncate (cron_run.go:204) now returns `cutRuneBoundary(s, max) + "…"`, and cutRuneBoundary (exec_writer.go:420-431) backs up to a utf8.RuneStart boundary, so the rune-split defect is gone. promptExcerpt (cron.go:278) was already rune-safe. |
|
||||
| low | `internal/cli/cron_run.go:198-205` | Duplicate of the same underlying issue: the cited byte-slice `return s[:max] + "…"` for run errors (used at cron_run.go:151) no longer exists; cronTruncate now calls cutRuneBoundary(s, max) which lands on a UTF-8 rune boundary, so persisted run-error text can no longer be split mid-rune. |
|
||||
| low | `internal/cli/cron_run.go:198-205` | Duplicate of the same underlying issue: cronTruncate no longer byte-slices `s[:max]`; it uses the rune-safe cutRuneBoundary helper (cron_run.go:204), with a comment explaining the cut lands on a UTF-8 rune boundary, closing the run-record invalid-UTF-8 gap. |
|
||||
| low | `internal/redaction/redaction.go:217-231 (pointer), :232-252 (map)` | The pointer case now does context.seen[ptr]=struct{}{} (line 225) then delete(context.seen, ptr) after redactReflect returns (line 230); the map case mirrors this (line 240 add, line 251 delete). Comments at lines 226-228 explicitly state the pointer is dropped after recursion so a shared non-cyclic reference reached via a sibling branch is not mistaken for a cycle, exactly the originally-described defect, which is gone. |
|
||||
| low | `internal/sandbox/grants.go:392-406` | readState now rebuilds a new `normalized` map keyed by `key := strings.TrimSpace(name)` (grants.go:394, stored at :403) instead of retaining the raw padded file key, and Lookup indexes with `strings.TrimSpace(toolName)` (grants.go:172), so a whitespace-padded file key both validates AND matches. The ledger's premise ('unchanged since #77') is stale — this normalization was added in commit 3f3de1a (#170). |
|
||||
| low | `internal/tui/session_controls.go:196-241` | handleCompactCommand no longer just increments compactRequests: when sessionCompactor or hasSessionBackedCompactor() is available it sets compactInFlight and returns tea.Batch(m.runCompact(), ...), and runCompact()->compactActiveSession() actually calls sessionStore.PlanCompaction/RecordCompaction (replay.go:145/181). The 'pending integration' status string is gone (only a negative-assertion test references it), and sessionStore is always non-nil (model.go:272-274), so real compaction is triggered. |
|
||||
| low | `internal/zerocommands/contracts.go:169` | ProviderSnapshotFromProfile now sets APIKeySet via `strings.TrimSpace(profile.APIKey) != "" \|\| strings.TrimSpace(profile.AuthHeaderValue) != ""` (line 169) with a comment (lines 166-168) explaining auth-header-only profiles must not render as 'not set'; config/types.go:51 confirms AuthHeaderValue is a valid sole credential, and command_center.go:317/334 render apiKeyState(provider.APIKeySet) off this fixed boolean. |
|
||||
| low | `internal/zerogit/zerogit.go:209` | The remaining gap in the partial finding (ValidateMessage still byte-based `len(firstLine) > 72`) is gone: line 209 now uses `utf8.RuneCountInString(firstLine) > 72` with an explanatory comment, and truncateSubject (line 466) is already rune-based, so valid non-ASCII subjects are no longer rejected. |
|
||||
|
||||
## Genuinely partial (a real gap remains) (12)
|
||||
|
||||
| Sev | Location (current) | Rationale |
|
||||
|---|---|---|
|
||||
| high | `internal/cli/cron_run.go:176-188 (re-read fix) + internal/cron/store.go:170-178 (Update, no lock)` | fireJob does re-read via store.Get and preserves an external pause (current.Status == StatusPaused at cron_run.go:186-187) and handles ErrJobNotFound/transient errors, closing the pause-clobber; but cron/store.go Update still just calls writeJob (temp+rename) with no flock/CAS anywhere in the package, so the concurrent-scheduler read-modify-write race remains exactly as the ledger noted. |
|
||||
| high | `internal/hooks/dispatch.go:112 (Dispatch); wired at internal/agent/loop.go:584,620 and constructed at internal/cli/hook_dispatch.go:51` | The primary gap is closed: the Dispatcher now has production callers and IS wired into the tool lifecycle - constructed in app.go/exec.go/exec_spec.go, assigned to agent Options.Hooks, and invoked at loop.go:584 (beforeTool, with veto via blockedByHookResult) and loop.go:620 (afterTool feedback). But the original finding's CLI-surface gap remains: runHooks in internal/cli/extensions.go:88-135 still supports only `list`/help with no dispatch/edit subcommand. |
|
||||
| medium | `internal/agent/compaction.go:280 (maybeCompact) / 83-96 (estimateTokens)` | The image-counting fix is present (estimateTokens line 92 adds len(message.Images)*imageTokenEstimate), but the remaining gap stands: maybeCompact's trigger uses estimateTokens(messages), which has no tool-definition term, and is called at loop.go:85 with no tools (the exposed tool defs are built afterward at loop.go:87). The tool-definition estimator estimateToolTokens exists only in the separate MeasureContext reporting path (context_measurement.go:53), not in the compaction trigger. |
|
||||
| medium | `internal/tui/rendering.go:292-336 (gap at :311)` | wrapPlainText now preserves explicit newlines (split on \n, :297) and each line's leading indentation (TrimLeft + reconstructed indent, :302-305), so code blocks survive. But the word loop at :311 still iterates strings.Fields(body), which collapses intra-line space runs, so mid-line column alignment within a line is still lost exactly as the finding describes. |
|
||||
| medium | `internal/tui/transcript.go:103-114` | The full-slice copy is genuinely gone (in-place append at transcript.go:100 with a comment about the old O(n²)), but hasTranscriptRow (transcript.go:108-112) still linear-scans every existing row computing transcriptRowKey per keyed tool/permission/ask append, and m.transcript is never pruned, so rehydration via appendTranscriptRow (session.go:145-146) remains O(n²). |
|
||||
| medium | `internal/tui/transcript.go:103-114` | Duplicate of the same finding: copy-on-append removed (transcript.go:96-100) but hasTranscriptRow (transcript.go:108-112) still linearly scans all rows per keyed row and the transcript is never bounded, so tool-heavy rehydration stays O(n²). |
|
||||
| medium | `internal/tui/transcript.go:103-114` | Duplicate of the transcript.go:113 finding: append is in-place (transcript.go:100), but hasTranscriptRow (transcript.go:108-112) still linear-scans all rows per keyed (tool/permission/ask) row, so tool-heavy /resume rehydration remains O(n²). |
|
||||
| medium | `internal/tui/transcript.go:103-114` | View re-render and copy-on-append are fixed (in-place append at transcript.go:100, empty-key fast path at transcript.go:105-107), but hasTranscriptRow (transcript.go:108-112) still scans the whole transcript per keyed append — O(n²) cumulative for tool rows during rehydration. |
|
||||
| medium | `internal/tui/transcript.go:103-114` | Duplicate of the transcript.go:117 finding: the full-slice copy is removed and non-keyed rows are O(1) via the empty-key fast path (transcript.go:105-107), but hasTranscriptRow (transcript.go:108-112) still linear-scans all rows per tool/permission/ask append, so those appends/rehydration remain quadratic. |
|
||||
| low | `internal/agent/loop.go:553-580, 611-616` | The main "requestEvent never read after the switch" claim is now fixed (requestEvent is passed to emitDeniedPermission/deniedPermissionResult on the denial paths at 571-572,577-578), but requestEvent.GrantMatched/Grant set at 574-575 in the AlwaysAllow success case remain dead writes (the post-run event at 611-616 is rebuilt fresh) and the fallbackPermissionEvent at 555 is still unreachable because shouldRequestPermission only fires for PermissionPrompt tools, for which buildPermissionEvent never returns ok=false (false only at 1071/1076 for non-Prompt/Allow). |
|
||||
| low | `internal/tui/commands.go:297,306; internal/tui/command_center.go:43-55; internal/agent/loop.go:105-106; internal/tui/commands.go:254` | The main fix landed: footer builders, theme tokens (selRow/statusOk/statusErr/line2/panel2), and actionAppendToolCall/Result are all gone (zero grep hits). But the cited gaps remain: listCommandNames (commands.go:297) and formatCommandHelpLines (commands.go:306) are referenced only by test files; resumeText's args!="" branch (command_center.go:45-55) is still unreachable since its sole non-test caller session.go:122 passes ""; OnContext (agent/loop.go:105-106) is emitted but has no production assignment; and the stale footer comment persists at commands.go:254. |
|
||||
| low | `internal/tui/commands.go:297,306; internal/tui/command_center.go:43-55; internal/agent/loop.go:105-106; internal/tui/commands.go:254` | Duplicate of the other rendering.go:62 row; same underlying code. Footer builders and the theme tokens are fully removed, but formatCommandHelpLines/listCommandNames (commands.go:297-308) remain production-defined yet referenced only by tests, and a stale 'footer advertises' comment persists at commands.go:254 (the autocomplete.go:64 stale comment cited in the older row is now gone, but commands.go:254 still has one). |
|
||||
|
||||
## Genuinely still open (66)
|
||||
|
||||
| Sev | Location (current) | Rationale |
|
||||
|---|---|---|
|
||||
| medium | `internal/agent/compaction.go:422 (summarizeMessagesOnce)` | summarizeMessagesOnce still calls plain zeroruntime.CollectStream (compaction.go:422), which is CollectStreamWithOptions with empty CollectOptions{} (helpers.go:78), so no OnUsage callback fires; the collected Usage is discarded and the deliberate non-forwarding is now documented at summarizeClosure (compaction.go:359-361). Compaction-call token usage is still not surfaced/tracked. |
|
||||
| medium | `internal/agent/loop.go:567-573, 928-931` | The AlwaysAllow case still calls deniedPermissionResult/emitDeniedPermission when persistPermissionGrant errors (loop.go:569-573), and persistPermissionGrant (now at 928-931) still errors unconditionally when options.Sandbox == nil, so choosing AlwaysAllow without a configured sandbox denies the already-approved call; both sub-claims persist (only relocated from the stale 778-779 reference). |
|
||||
| medium | `internal/background/manager.go:462-471 (and persistTaskLocked call at :419)` | normalizeLoadedTask still unconditionally rewrites task.Status==StatusRunning to StatusError, sets PID=0 and ExitCode=-1, and marks changed=true; loadTasks then persists this to disk via persistTaskLocked at line 419, with no process-liveness check or reconciliation. The defect is present essentially as the auditor described. |
|
||||
| medium | `internal/background/process_posix.go:29,50` | SIGTERM (line 29) and Kill() (line 50) still target only the single pid with no kill(-pid)/negative-pid group signal, and launchBackgroundProcess (specialist/exec.go:650) sets no SysProcAttr{Setpgid:true} (grep finds zero process-group setup), while Windows still tree-kills via taskkill /T /F (process_windows.go:12). All three claims hold. |
|
||||
| medium | `internal/cli/app.go:200-220` | In the `--skip-permissions-unsafe` case, the code re-splits args[1:] via splitLeadingAddDirFlags and only iterates `rest` to reject a misplaced --add-dir (lines 215-219); it then calls runInteractiveTUI with merged addDirs only, never forwarding `rest`, so any trailing non-flag args are still silently dropped. The in-code comment at lines 208-209 explicitly confirms this ('were ignored on this path... and still are'), matching the original finding verbatim. |
|
||||
| medium | `internal/cli/cron_run.go:103-128` | reconcileOverdue still reschedules any strictly-overdue active job (NextRunAt before nowMin) to sched.Next without firing it (lines 116-126); grep confirms no RunNow/RunOnce/FireNow sentinel exists in internal/cron, so the only way to fire backlog is the --catch-up flag that skips reconcileOverdue entirely. |
|
||||
| medium | `internal/cli/exec.go:191-199` | The --list-tools branch special-cases only execOutputStreamJSON (exec.go:192-194); -o json (execOutputJSON) falls through to writeExecToolList, which calls formatExecToolList and prints the plain-text "Tools visible to model:" listing (exec_tools.go:100-110) rather than any JSON output. |
|
||||
| medium | `internal/cli/exec.go:531-545` | The interrupted branch still emits writer.errorEvent/runEnd only when outputFormat == execOutputStreamJSON (exec.go:535-540); for -o json it just does fmt.Fprintln(stderr, "Interrupted.") at exec.go:542 and returns exitInterrupted, producing no terminal JSON event on stdout (unlike the provider-error path which writes type:error/type:done for json). |
|
||||
| medium | `internal/cli/exec_sessions.go:116-124` | execSessionRecorder.append still early-returns when recorder.err != nil (line 117) and latches AppendEvent failures into recorder.err (line 120); a package-wide grep shows recorder.err/sessionRecorder.err is read only inside append itself, never by exec.go or exec_spec.go (which check only writer.err), so persistence errors are silently swallowed at run end. |
|
||||
| medium | `internal/cli/exec_sessions.go:116-124` | Duplicate of the same recorder.err defect: append (lines 116-124) early-returns once recorder.err is set and stores AppendEvent errors there, but sessionRecorder.err is never read in exec.go or exec_spec.go, so a failed session append is never surfaced to the user at run end. |
|
||||
| medium | `internal/config/resolver.go:102, 110-124` | ResolvedConfig.MCP is still set at resolver.go:102 but has zero readers: every `.MCP` access in internal/cli is the config.MCPConfig type or the separate resolveMCPConfig/ResolveMCP path, and no resolveConfig caller reads resolved.MCP. MCP registration runs through ResolveMCP (resolver.go:110-124), which only merges config-file paths and Overrides and never calls LoadProviderCommand/touches options.ProviderCommand (asserted intentional by TestResolveMCPDoesNotRunProviderCommand), so ZERO_PROVIDER_COMMAND MCP servers are still dropped. |
|
||||
| medium | `internal/cron/schedule.go:171-201` | Empirically confirmed: on the 2026-11-01 US fall-back day Next("30 1 * * *") returns 01:30 EDT and then 01:30 EST again, firing the daily job twice during the repeated wall-clock hour. The Next loop and its forward-progress guard (schedule.go:182-198) only handle spring-forward non-existent instants and contain no de-duplication for the ambiguous repeated fall-back hour. |
|
||||
| medium | `internal/hooks/hooks.go:503` | AuditStore.append still calls store.ReadEvents() at line 503, which os.ReadFile's the entire JSONL audit file and json.Unmarshal's every line on every single append, solely to scan for the highest existing Sequence (lines 507-513); no cached/in-memory sequence counter was introduced, so the O(n) full re-read per append is unchanged. |
|
||||
| medium | `internal/mcp/client.go:246` | client.writer.write(...) at line 246 still executes while client.mu is held (locked at :228, unlocked at :255), and writer.write (protocol.go:148-163) does a blocking Write+Flush on the stdin pipe with no ctx awareness; only the response wait at :257 is ctx-aware, unchanged from the audit. |
|
||||
| medium | `internal/mcp/client.go:295-318` | readLoop still dispatches any id-bearing message to pending[id] (lines 302-316) with no check of message.Method; grep finds no 'ping' or Method handling anywhere in client.go, so server-initiated requests are still ignored/misrouted exactly as described. |
|
||||
| medium | `internal/mcp/client.go:98-99` | Still 'var stderr bytes.Buffer; cmd.Stderr = &stderr' — a plain unbounded buffer (no LimitReader/cap), never reset after startup, attached for the whole process lifetime and only read on initialize failure at line 114. |
|
||||
| medium | `internal/mcp/network_client.go:612-642 (decodeSSERPCMessage), caller at :219` | decodeSSERPCMessage still decodes the first non-empty 'message' SSE event, sets found=true and returns false (stops scanning) without checking the message ID; the synchronous caller networkClient.request at line 219 does `if !rpcIDMatches(message.ID, id)` and errors on mismatch instead of scanning for the matching response. Defect unchanged (caller line is now :219, was the stale :210). |
|
||||
| medium | `internal/mcp/network_client.go:646 (scanSSEEvents), failPending at :587-593, request gate at :243` | scanSSEEvents still calls scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) with the 1MiB cap; a bufio.Scanner ErrTooLong flows through readStream into failPending (:593) which sets a permanent client.streamErr, and request() returns that streamErr forever (:243-246) with no reconnect path (openStream is only called once at connect, :77). |
|
||||
| medium | `internal/providers/gemini/types.go:103-108` | The usageMetadata struct still declares only PromptTokenCount, CandidatesTokenCount, ThoughtsTokenCount, and TotalTokenCount with no cachedContentTokenCount field; provider.go:225-228 reads only those into input/output/reasoning tokens and emitDone (provider.go:282-286) builds TokenUsage without CachedInputTokens, which is set only in openai/provider.go:253 and anthropic/provider.go:330. |
|
||||
| medium | `internal/providers/openai/tool_state.go:95-101 (and applyDelta:58)` | applyDelta still returns early when call.id=="" (line 58), and closeOpen still only emits StreamEventToolCallDropped for empty-id/empty-name calls (lines 95-99) instead of streaming them with a synthetic key; every Start/Delta/End the adapter emits carries call.id, so the zeroruntime collector's empty-ID/synthetic-key support (helpers.go start/delta/end at ~173-216) is never reached from the OpenAI adapter. The provider_test still asserts such calls are dropped and never started, confirming the behavior is unchanged, and tool_state.go's last commit (4d2ce6b) predates the 2026-06-10 audit. |
|
||||
| medium | `internal/sandbox/safe_command.go:431` | splitShellSegments still uses a strings.NewReplacer that blindly turns \|, ;, &&, \|\|, $(, ), backtick into segment boundaries with zero quote awareness; a live probe confirms `git commit -m "use top \| less"` is flagged as 'less' (interactive=true) and `echo "a; vim b"` is flagged as 'vim', exactly as the original finding described. The file is unchanged since commit #109 and contains no quote-state parsing. |
|
||||
| medium | `internal/sessions/lineage.go:140` | tree() (lineage.go:128-154) still calls store.ListChildren(sessionID) at lineage.go:140 once per node, and ListChildren calls store.List() at lineage.go:74; List() (store.go:312-338) re-reads the whole sessions dir via os.ReadDir+Get->readMetadata (store.go:674-684, a fresh os.ReadFile+json.Unmarshal with no cache), so an N-node tree re-reads every metadata.json N times. The defect is present exactly as described. |
|
||||
| medium | `internal/sessions/store.go:428-432 (Fork copy loop), 526-567 (appendEventLocked); internal/usage/report.go:74-119 (BuildReport)` | Fork's loop at store.go:428-431 re-appends every parent event (no provider_usage/EventUsage skip) via AppendEvent, which stamps a fresh CreatedAt/ID in appendEventLocked (537-543), and BuildReport (report.go:74-119) aggregates all EventUsage events with no event-id dedupe, so forked usage events are double-counted. |
|
||||
| medium | `internal/sessions/store.go:686-701 (writeMetadata), 549-559 (appendEventLocked write path)` | writeMetadata still does os.WriteFile to a temp file + os.Rename (store.go:693-696) with no file or directory fsync, and a repo-wide grep finds zero Sync() calls anywhere in internal/sessions/; appendEventLocked's append write (549-557) is likewise unsynced. |
|
||||
| medium | `internal/specialist/accounting.go:80-91 (appendSpecialistUsageRollup) and :31-47 (recordSpecialistStop)` | The dedup is still a non-atomic check-then-append: specialistEventExists calls store.ReadEvents (which acquires NO lock, store.go:569) and only the later AppendEvent takes the per-session lock for its own write, so concurrent callers (exec.go:343 onExit and output_tool.go:150 TaskOutput) can both pass the existence check and each append a duplicate event. No serializing mutex spans the check and append in the specialist package, and the store's lockSession/appendEventLocked machinery is not used by the accounting path. |
|
||||
| medium | `internal/specialist/exec.go:150 (and :202)` | Both BuildArgs (:150) and BuildResumeArgs (:202) still hardcode args append "--auto","high", and internal/cli/exec_tools.go:84-85 maps "high" -> agent.PermissionModeUnsafe, so specialist children always run in unsafe permission mode. |
|
||||
| medium | `internal/specialist/streamer.go:43` | ParseStream still calls scanner.Buffer(make([]byte,0,64*1024), 1024*1024), so a line over 1MB yields bufio.ErrTooLong; exec.go:631-634 returns that error from runChildProcess aborting the whole run, and the producer at cli/exec_writer.go:99 (parseToolCallArgs) still emits tool_call Args with no length cap. All three elements of the original finding are unchanged. |
|
||||
| medium | `internal/tools/registry.go:198-245` | The Core* tool sets (CoreReadOnlyTools/CoreWriteTools/CoreShellTools at :224/CoreNetworkTools/CoreTools) still omit escalate_model; it is registered only under exec --allow-escalation (cli/exec.go:156-157), while the TUI registry built by newCoreRegistryScoped (cli/app.go:436,531-537) never registers it — app.go only mentions escalate_model in help text at :733. |
|
||||
| medium | `internal/tui/model.go:2022-2028 (session log) vs model.go:854/802/1721 (transcript)` | Interim narration is appended to the live transcript on tool call (model.go:852-857), error (801-803), and cancel (1720-1722), but the session log only persists result.FinalAnswer via EventMessage (model.go:2022-2028); onText (1781-1786) and sendAgentText (2064-2069) record no session event, so the gap the ledger flagged as 'partial' is unchanged — interim segments are still never persisted and /resume loses them. |
|
||||
| medium | `internal/tui/session_controls.go:148-177; internal/tui/model.go:1746-1778` | m.responseStyle is still only stored by handleStyleCommand (session_controls.go:156) and echoed in display strings (session_controls.go:159/171, command_views.go:275); runAgentWithOptions threads ReasoningEffort/SystemPrompt/PermissionMode into options but never references responseStyle, and agent.Options has no style field (no non-display ResponseStyle reader exists), so /style still changes nothing about responses. |
|
||||
| medium | `internal/tui/session_controls.go:148-177; internal/tui/model.go:1746-1778` | Duplicate of the responseStyle finding: m.responseStyle is set at session_controls.go:156 but runAgentWithOptions (model.go:1746-1778) never assigns it into agent.Options or the system prompt; the only readers are display strings (command_views.go:275, session_controls.go:159/171), so the style preference is inert. |
|
||||
| medium | `internal/tui/session_controls.go:148-177; internal/tui/model.go:1746-1778` | Duplicate of the responseStyle finding verified once: handleStyleCommand only stores m.responseStyle and grep shows no non-display consumer; runAgentWithOptions sets ReasoningEffort/SystemPrompt/etc. on agent.Options but never the style, so /style remains cosmetic. |
|
||||
| medium | `internal/tui/session_controls.go:318 (resolveRewindTarget); internal/sessions/rewind.go:205` | resolveRewindTarget still returns lastCheckpoint-1, while OnToolCall appends EventToolCall then the EventSessionCheckpoint right after it (model.go:1886-1911) so the tool_call gets the immediately-preceding sequence; TruncateEvents keeps ev.Sequence<=target (rewind.go:205), so the tool_call (seq==target) is retained while its checkpoint (seq>target) is dropped, leaving the dangling tool_call described in the finding. |
|
||||
| medium | `internal/usage/report.go:17-21 (struct) and :102 (pricing); writer at internal/cli/exec.go:521-524` | usageEventPayload (report.go:17-21) still has only PromptTokens/CompletionTokens/TotalTokens with no model field, and BuildReport at report.go:102 still prices via modelBySession[event.SessionID] from session Metadata.ModelID; meanwhile exec.go:521-522 still persists payload["model"]=currentModel under --allow-escalation, and a repo-wide grep confirms no consumer reads that per-event model key (the only usageEventPayload reader silently drops it on unmarshal). |
|
||||
| medium | `internal/zerogit/zerogit.go:225` | parseStatus still does `path := strings.TrimSpace(line[3:])` with no C-quote unescaping and no `old -> new` rename split, and the caller at line 119 runs `status --short --untracked-files=all` without `-z`/porcelain, so quoted or renamed paths are still mis-parsed exactly as described. |
|
||||
| low | `internal/agent/loop.go:1305-1316` | propertyToRuntimeMap still assigns schema["items"] = propertyToRuntimeMap(*property.Items) twice for the same property.Items != nil guard (lines 1305-1307 and 1314-1316); the second assignment is a redundant duplicate writing an identical value. |
|
||||
| low | `internal/agent/loop.go:189-191` | The reactive-retry CollectStreamWithOptions call still passes only OnUsage and omits OnText (comment at 183-188 documents this as deliberate anti-duplication), so streaming surfaces still receive only the aborted partial text on the retried turn exactly as the finding describes. |
|
||||
| low | `internal/agent/loop.go:194-201` | When ctx is canceled, internal/zeroruntime/helpers.go:89 sets collected.Error = ctx.Err().Error(); loop.go:194-197 returns errors.New(collected.Error) before the ctx.Err() identity return at 198-201, so the returned error no longer wraps the sentinel and errors.Is(err, context.Canceled) still fails on that path. |
|
||||
| low | `internal/agent/types.go:196 (field); internal/agent/loop.go:105-106 (emit site); internal/agent/context_measurement.go:31 (MeasureContext/ContextBreakdown)` | The OnContext callback field (types.go:196) and its emit site (loop.go:105-106 calling MeasureContext) still exist, but a repo-wide grep finds ZERO 'OnContext:' assignments anywhere (test or prod) and no use of MeasureContext/ContextBreakdown outside internal/agent tests; cli/tui/cmd and the contextreport package never reference them (contextreport only calls agent.BuildSystemPromptPreview), so the callback remains permanently a no-op exactly as the original finding described. |
|
||||
| low | `internal/background/process_posix.go:38-50` | terminateProcess still does os.FindProcess(pid) (line 24) and polls liveness via signal-0 in processAlive (lines 39-47, 59-61), while launchBackgroundProcess (specialist/exec.go:659-673) still reaps the child in a concurrent command.Wait() goroutine, so the PID can be reaped and recycled before process.Kill() at line 50 fires. Defect unchanged. |
|
||||
| low | `internal/cli/cron_run.go:146-152` | rec.Error is still set only from errBuf (cronTruncate(strings.TrimSpace(errBuf.String()),500) when code!=0); outBuf is declared and passed to exec at line 146-147 but never read anywhere in fireJob, so stream-json error events on stdout are still discarded. |
|
||||
| low | `internal/cli/exec.go:383-398 (PrepareExec call), exec.go:404 (time.Now), internal/cli/exec_sessions.go:55 (direct NewStore)` | All three sub-claims hold: the sessions.PrepareExec call at exec.go:383-398 omits the Store field so PrepareExec falls back to NewStore(StoreOptions{}) at exec_session.go:58-61; preflightExecSession still does sessions.NewStore(StoreOptions{}) directly at exec_sessions.go:55; and runExec uses streamjson.CreateRunID(time.Now()) at exec.go:404 instead of the injectable deps.now (which is available and used at exec.go:142/186). |
|
||||
| low | `internal/cli/exec_sessions.go:55` | preflightExecSession still calls sessions.NewStore(sessions.StoreOptions{}) directly at line 55, and exec.go's PrepareExec call (exec.go:383-397) omits the Store field so sessions/exec_session.go:58-60 defaults to NewStore again; deps.newSessionStore is used by spec/usage/observability/sessions paths but never by the exec run path in exec.go. |
|
||||
| low | `internal/config/contracts.go:5,12,65` | ContractGap (line 5), DefaultContractGaps (line 12), and FindContractGapsByMilestone (line 65) are all still defined, and a repo-wide grep for these symbols (plus ContractOwnerRuntime) finds references ONLY in internal/config/contracts_test.go. No production caller exists, so the dead-code defect persists exactly as described. |
|
||||
| low | `internal/config/resolver.go:144,177,506` | All three MaxTurns sites (mergeConfig :144, mergeProjectConfig :177, applyOverrides :506) still gate purely on `if src/overrides.MaxTurns > 0` with no error path, and Resolve() has no MaxTurns validation, so a configured 0 or negative maxTurns is silently dropped and falls back to defaultMaxTurns=30 (resolver.go:16,22). A negative-value error path exists for tools.deferThreshold (:57-59) but none was added for MaxTurns. |
|
||||
| low | `internal/plugins/plugins.go:894` | parseHookEvent's switch (plugins.go:894) still accepts only HookBeforeTool/HookAfterTool/HookSessionStart/HookSessionEnd and the HookEvent consts (plugins.go:39-42) omit specialist events entirely, whereas hooks.go parseEvent (hooks.go:812) accepts those four plus EventSpecialistStart/EventSpecialistStop, so the two validation sets still disagree exactly as described. |
|
||||
| low | `internal/providercatalog/catalog.go:46` | The `Public bool` field is still declared on Descriptor (catalog.go:46) but is never assigned anywhere in the codebase: there are zero `Public:` struct-literal assignments and zero `.Public =` writes. The only read remains the vacuous guard at catalog_test.go:106 (`if descriptor.Public && !descriptor.RequiresAuth`), which is dead code since Public is always false. |
|
||||
| low | `internal/providers/factory.go:115` | At the sole call site (line 115) the `\|\|` short-circuits so isImplicitOpenAI is only evaluated when explicitProvider==true, which (per explicitProviderKind at 147-157) requires ProviderKind!="" or Provider!=""; but isImplicitOpenAI (159-164) requires both ProviderKind=="" and Provider=="", so it can never return true there — the term remains dead code exactly as described. |
|
||||
| low | `internal/redaction/redaction.go:174` | Line 174 still declares `var stackTracer interface{ StackTrace() fmt.Stringer }`; a repo-wide grep finds StackTrace only at redaction.go:174 and :176 (no implementer in the module), and go.mod has no pkg/errors-style dependency, so this errors.As branch is dead code that can never match exactly as the finding described. |
|
||||
| low | `internal/sessions/store.go:615-617 (timestamp), 331-336 (List), 340-346 (Latest)` | timestamp() at store.go:616 still formats with time.RFC3339 (second precision), and List() at 332-334 breaks UpdatedAt ties lexically by SessionID, so Latest() (line 345 returns sessions[0]) can still pick the wrong session on same-second ties exactly as described. |
|
||||
| low | `internal/skills/skills.go:91 (Duplicates); consumers: internal/cli/skills.go:62, internal/plugins/activate.go:376` | skills.Duplicates and skills.Get still have zero callers outside internal/skills (Get has zero callers anywhere, even tests), and the duplicate-collision data is never surfaced: cli/skills.go runSkillsList calls skills.List with no warning, and the skill tool discards the dups at activate.go:376 with `merged, _ := MergedSkillsLoaded(...)`. Although plugins.mergeSkills now computes a parallel DuplicateName slice that MergedSkills returns, every call site drops it, so shadowed/dropped skills are still resolved silently with no user-facing warning, exactly the defect described. |
|
||||
| low | `internal/specialist/exec.go:353-358` | On manager.SetPID failure the code only calls executor.cleanupBackgroundPromptFile and returns the error; it does not call recordSpecialistStop or manager.UpdateStatus(StatusError) while the already-launched child keeps running, unlike the launch-error path at :347-352 which does record a stop. |
|
||||
| low | `internal/specialist/output_tool.go:244` | formatTaskOutput is still dead code: a codebase-wide grep finds only its own definition at :244 and no callers in prod or tests; the only used formatter is formatTaskOutputSummary, called from readOutput at :154. |
|
||||
| low | `internal/streamjson/streamjson.go:30` | Repo-wide grep for EventRestore (and the literal "restore" EventType) returns only the declaration at streamjson.go:30 — no emitter and no test reference, whereas the sibling EventCheckpoint is emitted at cli/exec_writer.go:119 and referenced in streamjson_test.go:435. The constant remains dead exactly as the original finding described. |
|
||||
| low | `internal/tools/read_file.go:98-121` | The truncation path is unchanged from main (empty git diff): when max_lines truncates, it sets Result.Truncated=true (line 120) but the only in-output signal is the '(lines 1-5 of 20)' range header (line 114) — no distinct truncation marker is appended to Output (line 119). The rune-width sub-claim remains non-reproducible because line-number padding uses ASCII digits via strconv.Itoa (lines 105-109), exactly as the original finding concluded. |
|
||||
| low | `internal/tools/registry.go:19` | Repo-wide grep finds OnSandboxDecision only at registry.go:19 (declaration), :113 (nil check), and :121 (invocation) — zero struct-literal (OnSandboxDecision:) or dot-assignment (.OnSandboxDecision =) sites anywhere in non-test or test code, so the field is still never set by any caller. |
|
||||
| low | `internal/tui/command_center.go:45-55` | resumeText's args!="" branch (command_center.go:45-55) is still dead: the only production caller, handleResumeCommand at session.go:121-122, passes "" (the non-empty case is handled separately via resolveResumeSession at session.go:125), so the branch remains unreachable; one stale 'footer advertises' comment also persists at commands.go:254 (the autocomplete.go one is gone). |
|
||||
| low | `internal/tui/command_center.go:17-24` | doctorText() still constructs doctor.Run(doctor.Options{Now, Runtime:"go", Provider}) with no UserConfig/ProjectConfig (command_center.go:18-22), unlike the CLI path at observability.go:64-71; so configFilesCheck (doctor.go:111-123) gets empty paths and emits the StatusWarn 'No explicit Zero config files were inspected.' exactly as the finding describes. |
|
||||
| low | `internal/tui/commands_test.go:46` | The function commandTestStringSliceContains is still defined at line 46, and a repo-wide grep across all *.go files returns only the definition itself with zero call sites, matching the original dead-helper finding exactly. |
|
||||
| low | `internal/tui/model.go:1363-1365` | handleSubmit still drops a prompt while m.exiting with a bare `return m, nil` and no feedback row, and no render path surfaces an exiting/flushRunIDs indicator: statusLine (view.go:137-156) shows only provider+usage, and composerLine's hint switch (model.go:1130-1134) only shows 'esc stop' when m.pending. |
|
||||
| low | `internal/tui/options.go:34,39-40 (UsageTracker/ReasoningEffort/ResponseStyle); internal/cli/app.go:484-516; internal/tui/model.go:277-279` | The exact residual gap the ledger evidence describes is still present verbatim: the production caller's tui.Options{...} block in app.go (484-516) sets none of UsageTracker, ReasoningEffort, or ResponseStyle. The mitigations cited also still hold — newModel self-defaults a usage.Tracker when options.UsageTracker is nil (model.go:277-279) and effort/style remain runtime-settable via /effort (session_controls.go:81) and /style (session_controls.go:156) — so the code matches the partial state described, with the same unset-fields gap unchanged. |
|
||||
| low | `internal/tui/session_test.go:215` | The local slice `runtimeMessages := []tea.Msg{}` (line 215) is still only appended to in the RuntimeMessageSink closure (line 226) and never read or asserted anywhere in the test body; the test exclusively consumes the channel runtimeMessageCh via receiveRuntimeMessage, leaving the slice write-only exactly as the finding described. |
|
||||
| low | `internal/verify/verify.go:215` | RunLoop is still defined at verify.go:215 and a repo-wide grep finds only two references: the definition and its sole caller verify_test.go:207. The production retry loop is selfverify.Run (selfverify.go:53), which re-implements the attempt loop calling verify.Run directly (selfverify.go:75) and is the one wired into cli/workflows.go, so RunLoop plus LoopOptions/LoopReport remain dead production code. |
|
||||
| low | `internal/zerocommands/sandbox_snapshots.go:82-104 (SandboxPlanSnapshot/SandboxDecisionSnapshot); raw serialization at internal/cli/sandbox.go:92-94,146-148` | Grep confirms SandboxPlanSnapshot/Decision/Policy/Backend/Risk/Violation snapshots are referenced only inside internal/zerocommands and its tests (no production consumer; the only new consumers in tui/command_views.go are SandboxGrantSnapshots and ProviderSnapshot, which are different types). `zero sandbox policy --json` in internal/cli/sandbox.go still builds its payload from raw zeroSandbox.Policy/Backend/BackendPlan (lines 92-94 and 146-148) instead of the snapshot constructors, exactly as the original evidence described. |
|
||||
| low | `internal/zerogit/zerogit.go:419-423` | The fallback EnvRunner adapter at lines 420-422 is still `func(ctx, dir, _ []string, args ...string)` which discards the env slice and calls `runGit(ctx, dir, args...)`, so a caller-supplied Runner with no RunGitEnv still loses stagedSnapshotDiff's GIT_INDEX_FILE isolation. |
|
||||
| low | `internal/agent/loop.go:194-201 (origin internal/zeroruntime/helpers.go:89)` | helpers.go:89 still sets collected.Error = ctx.Err().Error() (stringifying the cancellation, losing identity), and loop.go:194-196 returns errors.New(collected.Error) BEFORE the identity-preserving `return result, ctx.Err()` at loop.go:198-200, so on the stream-cancellation path the returned error is a plain string error and errors.Is(err, context.Canceled) still fails. |
|
||||
@@ -0,0 +1,281 @@
|
||||
# ZERO · Lime TUI rebuild — staged prompt
|
||||
|
||||
You are working in a fresh checkout of `github.com/Gitlawb/zero` (Go 1.24,
|
||||
Bubble Tea v1.3.10, Lip Gloss, Bubbles v1.0.0, Glamour — all in go.mod; add NO
|
||||
new dependencies).
|
||||
|
||||
Mission: **remove both existing TUI designs entirely** — the default cyan
|
||||
shell (`internal/tui/theme.go` palette + the centered splash in `startup.go`)
|
||||
AND the whole `zeroline` skin system — and replace them with ONE design:
|
||||
**Lime**, the near-black, lime-accent chat surface prototyped in
|
||||
`docs/design/zero_tui_lime.html` (commit the prototype there first; it is the
|
||||
visual source of truth). The prototype is a web mock — this spec is the
|
||||
authoritative terminal translation of it. Behavior must not regress: every
|
||||
slash command, prompt flow, overlay, and safety property that works today
|
||||
must work after.
|
||||
|
||||
This is a fresh clone: file names below were verified against a recent
|
||||
snapshot, but ALWAYS recon before editing. If a path or symbol has moved,
|
||||
adapt — do not blindly apply.
|
||||
|
||||
**Gate (run after EVERY stage, commit + tag before continuing):**
|
||||
```
|
||||
go vet ./...
|
||||
go test ./...
|
||||
go run ./cmd/zero-release build
|
||||
```
|
||||
Stage 4 additionally runs `go run ./cmd/zero-release smoke`. Never tag a red
|
||||
tree. AGENTS.MD applies: Go-native commands only.
|
||||
|
||||
---
|
||||
|
||||
## Design spec — Lime
|
||||
|
||||
### Palette — `internal/tui/theme.go` becomes the ONLY place colors exist
|
||||
| Token | Hex | Use |
|
||||
|------------|-----------|-----|
|
||||
| bg | `#070708` | canvas (terminal default bg; do not paint full-bleed) |
|
||||
| panel | `#0e0e10` | card backgrounds (tool/diff/read/grep/sessions rows) |
|
||||
| panel2 | `#121215` | card header rows, picker rows |
|
||||
| panel3 | `#17171b` | selected/hovered row bg |
|
||||
| line | `#242429` | default borders, rules, status separators |
|
||||
| line2 | `#2e2e34` | emphasized borders |
|
||||
| ink | `#ececee` | primary text |
|
||||
| muted | `#8b8b93` | secondary text, assistant interim prose |
|
||||
| faint | `#5b5b63` | hints, metadata |
|
||||
| faintest | `#3a3a40` | line numbers, separators, tool args |
|
||||
| accent | `#caff3f` | brand lime: prompts, badge, spinner, focus, final rail |
|
||||
| green | `#5dd1a4` | success, diff add sign, ✓ |
|
||||
| red | `#ff7a7a` | errors, diff del sign, ✗, deny |
|
||||
| amber | `#ffc25c` | permission surfaces, warnings, auto badge |
|
||||
| blue | `#7db4ff` | grep file locations, local-model dot |
|
||||
| addBg | `#15201d` | diff added-line bg (solid stand-in for green @9% over panel) |
|
||||
| delBg | `#241819` | diff deleted-line bg (solid stand-in for red @9%) |
|
||||
| permBg | `#1c1915` | permission card bg (amber @6% over panel) |
|
||||
| selBg | `#1d2114` | selected picker/suggestion row (accent @8%) |
|
||||
| addInk | `#bdeed7` | added-line text |
|
||||
| delInk | `#f2c4c4` | deleted-line text |
|
||||
| onAccent | `#000000` | text on accent or amber fills |
|
||||
|
||||
Terminals cannot alpha-blend or glow — the four solid tints above ARE the
|
||||
translation of the prototype's rgba/glow effects; do not attempt gradients,
|
||||
shadows, or blur. Truecolor hex throughout; lipgloss downsamples on limited
|
||||
terminals (keep the existing explanatory comment style in theme.go).
|
||||
|
||||
Keep the `tuiTheme` single-value pattern but rebuild the field set around
|
||||
these tokens (style per role: badge, userPrompt, sayText, finalRail, toolName,
|
||||
toolTarget, toolArg, autoTag, statusOk/Err, diffAdd/Del/Meta, permBadge,
|
||||
permRisk, grepLoc, bashPrompt, stGroup, etc.). No hex outside theme.go.
|
||||
|
||||
### Layout — single-column chat surface (matches the existing architecture)
|
||||
```
|
||||
zero / ~/dev/zero anthropic/claude-sonnet-4-5 · 200K
|
||||
────────────────────────────────────────────────────────────────────────
|
||||
❯ add a --version flag to the cli (user)
|
||||
I'll add a --version flag…▌ (interim, muted)
|
||||
╭ grep internal/cli flag\.|RegisterFlag ⠋ ──────────╮
|
||||
│ internal/cli/root.go:41 fs := flag.NewFlagSet("zero"… │
|
||||
╰ 3 matches ───────────────────────────────────────────────────────╯
|
||||
╭ edit internal/cli/root.go auto ✓ ────────────╮
|
||||
│ internal/cli/root.go new file +6 −1 │
|
||||
│ 41 │ fs.BoolVar(&o.Version, "version", false, … (addBg) │
|
||||
╰──────────────────────────────────────────────────────────────────╯
|
||||
│ Done — the CLI now prints its version. (final: accent rail)
|
||||
● done · 2 tools · 8.4s (done line)
|
||||
|
||||
❯ describe a task for zero… (composer)
|
||||
────────────────────────────────────────────────────────────────────────
|
||||
● anthropic │ claude-sonnet-4-5 │ │ 12.4K tok │ interactive │ ⏵⏵ auto
|
||||
```
|
||||
Render order stays: title line → rule → transcript → composer → rule →
|
||||
status line. NO alt-screen change, NO panes, NO mouse — keep `run.go`
|
||||
exactly as-is including its no-mouse-capture comment.
|
||||
|
||||
### Title bar (replaces `headerBar`)
|
||||
Left: badge — `lipgloss` style bg=accent fg=onAccent bold, content ` 0 ` —
|
||||
then `zero` (ink, bold, lowercase), ` / ` (faintest), cwd via existing
|
||||
`shortenPath` (muted), git branch (faint) when present. Right:
|
||||
`provider/model` (ink) + ` · ctx` (faint) where ctx comes from
|
||||
`modelregistry.ContextLimits.ContextWindow` formatted like `200K`. Reuse the
|
||||
existing width-candidate fallback pattern (`startupHeaderLine`) so segments
|
||||
drop gracefully: full → drop ctx → drop cwd → badge+model only. Below it,
|
||||
one rule line in `line`.
|
||||
|
||||
### Empty state (DELETE the splash entirely)
|
||||
Remove `startupView`, `startupHeader`, `commandChips`, the figlet
|
||||
`zeroLogoLines`, gap math, and the `showSplash` field/branch. The app boots
|
||||
into the chat surface; when the transcript is empty, the stream area shows,
|
||||
vertically centered:
|
||||
- a 5–6 line block-art `0` in accent (derive the glyph from the `O` columns
|
||||
of the old figlet constant before deleting it),
|
||||
- tagline (muted): `a std-lib-first coding agent · bring your own key · no lock-in`,
|
||||
- hint (faint): `running zero against <provider/model>` with the model in ink,
|
||||
- three suggestion chips, one per line: `❯` (accent bold) + suggestion (ink)
|
||||
inside a `line`-bordered rounded box; pressing `1`–`3` while the composer
|
||||
is empty inserts that suggestion. Define suggestions as a string slice in
|
||||
the tui package (e.g. add a --version flag / explain internal/agent/loop.go /
|
||||
fix the failing test in internal/tools).
|
||||
|
||||
### Stream blocks (restyle `transcript.go` / `rendering.go` rows)
|
||||
- **user** (`rowUser`): `❯ ` accent bold + ink text. Blank line above turns.
|
||||
- **assistant interim** (`rowAssistant` while streaming): muted text, wrapped
|
||||
to `min(width-4, 74)` columns, trailing `▌` cursor in accent while the
|
||||
turn is pending (drives off existing pending state — no new ticker).
|
||||
- **final answer** (last assistant row of a turn): a `│` accent rail gutter
|
||||
on every wrapped line + ink text, same 74-col measure. If distinguishing
|
||||
interim vs final is not already possible from row data, mark the row at
|
||||
append time — do not re-parse text.
|
||||
- **done line** (end of a turn): `●` green (red on error) + faint
|
||||
`done · N tools · Xs` with faintest `·` separators, derived from data the
|
||||
model already has (tool rows this turn + existing usage/timing if present;
|
||||
omit segments that don't exist rather than inventing counters).
|
||||
- **system / error notes** (`rowSystem`/`rowError` equivalents): one-line
|
||||
bordered note — system: faint on panel with `line` border; denial/error:
|
||||
red text, red-tinted border. Keep content unchanged.
|
||||
|
||||
### Tool cards (replaces `titledCard`/tool rows for `rowToolCall`+`rowToolResult`)
|
||||
Rounded border card on panel bg. Head row on panel2 with a bottom rule:
|
||||
status glyph + tool name (ink bold) + target (muted, middle-truncated) +
|
||||
one-line arg via the existing `argHint` (faintest, right side) + optional
|
||||
`auto` tag (amber text in amber-bordered mini box) when the call was
|
||||
auto-approved by mode or a stored grant. Status glyph: running =
|
||||
`bubbles/spinner` MiniDot in accent · ok = `✓` green · error = `✗` red.
|
||||
Border tint follows state: line → accent-ish `#5a6b2e` while running →
|
||||
red-mix on error (define both as theme tokens). Bodies by result shape,
|
||||
reusing the existing detection where present:
|
||||
- **diff** (`looksLikeDiff`): head row = path (ink) + `NEW FILE` tag (green
|
||||
on addBg) when applicable + right-aligned `+N` green / `−N` red counts;
|
||||
body lines = 4-col right-aligned line number (faintest) + sign col
|
||||
(green/red) + text — added rows on addBg in addInk, deleted on delBg in
|
||||
delInk, context muted. Keep the 16-line cap + `… N more lines` footer.
|
||||
- **read**: same gutter, muted text, head shows path + `Lstart–Lend` (faint).
|
||||
- **bash**: cmd row `❯` accent bold + command (ink) above a rule; output
|
||||
muted (stderr-ish in delInk), indented 2; footer `exit 0` green /
|
||||
`exit N` red when an exit code is in the result.
|
||||
- **grep**: rows `file:line` (blue) + text (muted, truncated); footer faint
|
||||
`N matches`.
|
||||
- Anything else: current generic output rendering, restyled to muted on panel.
|
||||
|
||||
### Permission card (restyle the existing `pendingPermission` prompt — keys unchanged)
|
||||
Card with amber-mixed border on permBg. Top row: `PERMISSION` badge (onAccent
|
||||
on amber, bold) + `risk: <low|medium|high>` (amber) from the request's
|
||||
existing risk data. Body: `<tool>` (amber bold) + target (ink) + reason
|
||||
(muted). Action row of bordered key-chips: `[a] allow once` (onAccent on
|
||||
accent), `[y] always` (ink, accent border), `[d] deny` (ink, red on hover is
|
||||
web-only — just red-bordered), `[esc]` hint faint. After resolution collapse
|
||||
to one faint line: `allowed once · <tool>` green / `always · <tool>` green /
|
||||
`denied · <tool>` red. Ask-user and spec-review prompts get the same card
|
||||
language with `line` borders. Do NOT alter `handlePermissionKey`,
|
||||
`nextPermissionMode`, or grant-store semantics.
|
||||
|
||||
### Composer + status line
|
||||
Composer: `❯` accent bold + the existing `bubbles/textinput`, borderless
|
||||
(remove the bordered input block), placeholder `describe a task for zero…`,
|
||||
switching to `running… ctrl+c to interrupt` while pending; right-aligned
|
||||
faint hint `run ↵` (idle, only when input is non-empty) / `esc stop`
|
||||
(pending). Rules above composer and above status line in `line`.
|
||||
Status line groups separated by ` │ ` (line color): `●` accent + provider ·
|
||||
model id · flexible gap · `<X.XK> tok` (existing usage segment data,
|
||||
keep cost when priced) · `interactive` · permission mode (reuse
|
||||
`modeLabel()`: `⏵⏵ auto-approve` green / `ask` amber / `unsafe` red) ·
|
||||
optional green `always: <n> grants` when the sandbox grant store exposes a
|
||||
cheap count (omit otherwise — do not add plumbing).
|
||||
|
||||
### Overlays (restyle in place, identical key handling)
|
||||
- Autocomplete: rows on panel, selected row on selBg with accent `❯` marker,
|
||||
name ink / desc faint.
|
||||
- Picker (`/model`, `/provider`, etc.): bordered panel; rows = provider dot
|
||||
(accent remote, blue local) + id (ink) + right meta `ctx · KEY_ENV`
|
||||
(faint/faintest) when the registry/provider catalog already exposes them;
|
||||
selected row selBg. Title row + `↑/↓ · ⏎ · esc` hints faint.
|
||||
- Sessions: `/resume` (alias `/sessions`) keeps its existing flow; restyle
|
||||
its list as stacked cards — id (accent) + age (faint) top row, title (ink),
|
||||
meta line (faint with faintest dots). No new persistence behavior.
|
||||
|
||||
### Explicitly OUT of scope
|
||||
The prototype's `text/json` toggle visualizes the headless
|
||||
`--output-format stream-json` CLI mode (see docs/STREAM_JSON_PROTOCOL.md) —
|
||||
not a TUI feature. The TweaksPanel in the HTML is a design harness — ignore
|
||||
it. No mouse support, no model dropdown (keyboard picker is the translation).
|
||||
|
||||
---
|
||||
|
||||
## Adaptive requirements (treat as acceptance criteria)
|
||||
- Width tiers, re-evaluated on every `WindowSizeMsg`:
|
||||
**≥100** full spec · **80–99** drop tool-arg column, header ctx, status
|
||||
`interactive` group · **60–79** drop diff/read line-number gutters, badge
|
||||
renders as `0` without padding, status keeps provider+tokens+mode only ·
|
||||
**<58** (current `minStartupWidth`) single-segment header, cards lose
|
||||
side borders (keep top/bottom rules), composer + mode line only.
|
||||
- Text measure: say/final wrap at `min(width-4, 74)`; paths middle-truncate
|
||||
(`internal/…/root.go`); never emit a styled line wider than the terminal
|
||||
(extend the existing `fitStyledLine`/`truncateRunes` helpers — reuse, don't
|
||||
fork).
|
||||
- Height: when the terminal is shorter than the frame, transcript trims from
|
||||
the top (existing inline behavior) — verify no overlap of composer/status.
|
||||
- Color: nothing may rely on truecolor to be legible — check every pairing
|
||||
at 256 colors mentally; the four tint tokens must remain darker than ink.
|
||||
- Tests for the tier logic: table-driven across widths {58, 70, 80, 100, 120}
|
||||
asserting which segments render, using the existing ANSI-stripping helpers.
|
||||
|
||||
---
|
||||
|
||||
## Stages
|
||||
|
||||
### Stage 0 — recon + delete the `zeroline` design system *(tag: `tui-lime-s0`)*
|
||||
Recon first: `grep -rni zeroline --include='*.go' .` and read
|
||||
`internal/tui/options.go`, `internal/cli/app.go`. Then remove completely:
|
||||
`internal/zeroline/` (whole package + tests), `internal/tui/zeroline_view.go`
|
||||
(+ test), `internal/cli/zeroline.go`, the `case "zeroline":` dispatch and the
|
||||
zeroline help-text line in `internal/cli/app.go`, the skin parameter on
|
||||
`runInteractiveTUIWithSkin` (rename to `runInteractiveTUI`), the `Skin`,
|
||||
`ThemeVariant`, `ThemeDark` fields in `tui.Options`, the model's `skin`
|
||||
field and every `m.skin == "zeroline"` branch (View dispatch, `/theme`
|
||||
special-case, update paths, `picker.go`, `image_attach.go`). Update or
|
||||
delete affected tests; the zeroline grep must return zero hits. Gate, tag.
|
||||
|
||||
### Stage 1 — Lime palette on the existing layout *(tag: `tui-lime-s1`)*
|
||||
Rewrite `theme.go` to the token table. Restyle every current render site
|
||||
(header, transcript rows, titledCard/diffCard, overlays, status line,
|
||||
prompts) to the new tokens WITHOUT changing structure yet — this isolates
|
||||
the string-assertion churn in `model_test.go` / `session_test.go` /
|
||||
`command_polish_test.go` from the layout work. Prefer asserting plain
|
||||
content via the ANSI-stripping helpers over styled bytes. Gate, tag.
|
||||
|
||||
### Stage 2 — chat-surface components *(tag: `tui-lime-s2`)*
|
||||
Title bar + rule, empty state replacing the splash (splash code deleted),
|
||||
stream blocks (user/interim/final/done/notes), tool cards with all four
|
||||
result bodies, MiniDot spinner, composer + status line redesign. Add
|
||||
rendering tests per block type with synthetic rows. Gate, tag.
|
||||
|
||||
### Stage 3 — interactive surfaces *(tag: `tui-lime-s3`)*
|
||||
Permission card + resolved collapse + auto tag, ask-user and spec-review
|
||||
cards, autocomplete/picker restyle with model-row metadata, `/resume`
|
||||
sessions card list, suggestion-chip `1–3` insertion. Key handling and
|
||||
semantics byte-identical to before — assert that in tests where they
|
||||
already exist. Gate, tag.
|
||||
|
||||
### Stage 4 — adaptive polish + docs *(tag: `tui-lime-s4`)*
|
||||
Implement + test the width tiers, sweep remaining render sites (spec mode,
|
||||
command center, doctor output if styled) for stray old-palette styles,
|
||||
update `internal/cli/app.go` help text and README's TUI section, manual run
|
||||
`go run ./cmd/zero` at 58/80/100/120 cols (no wrapped rules, no orphan ANSI,
|
||||
prompts render correctly). Full gate + `go run ./cmd/zero-release smoke`.
|
||||
Gate, tag.
|
||||
|
||||
---
|
||||
|
||||
## Hard constraints
|
||||
- No new module dependencies; charmbracelet stack only. No mouse capture.
|
||||
- Zero behavior change to permissions, grants, ask-user, spec review,
|
||||
sessions, slash commands, autocomplete, image attach, compaction, rewind.
|
||||
- All colors in `theme.go`; renderers consume named styles only.
|
||||
- Reuse existing helpers (`fitStyledLine`, `truncateRunes`, `argHint`,
|
||||
`shortenPath`, header candidates) instead of writing parallel ones.
|
||||
- Comment style matches the repo: explain WHY at non-obvious decisions.
|
||||
|
||||
## Definition of done
|
||||
One design in the tree (no zeroline references, no splash, no cyan tokens),
|
||||
the Lime chat surface matching this spec at ≥100 cols and degrading per the
|
||||
width tiers below that, every pre-existing flow keyboard-reachable and
|
||||
visually consistent, `go test ./...` + release build + smoke fully green.
|
||||
File diff suppressed because it is too large.
Load diff
+15
-53
@@ -37,37 +37,29 @@ 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, ChatGPT, Hugging Face)
|
||||
❯ Sign in with OAuth One-click browser login (OpenRouter, xAI)
|
||||
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
|
||||
ChatGPT browser (Codex backend, ChatGPT Plus/Pro)
|
||||
Hugging Face browser or device code
|
||||
❯ OpenRouter browser sign-in · creates a key
|
||||
xAI (Grok) browser or device code
|
||||
```
|
||||
|
||||
- **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
|
||||
- **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
|
||||
`ZERO_OAUTH_ALLOW_PRESETS=1` or your own `ZERO_OAUTH_XAI_*`; see 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.
|
||||
- **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.
|
||||
|
||||
### Built-in OAuth providers
|
||||
|
||||
@@ -86,30 +78,6 @@ 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
|
||||
@@ -127,12 +95,6 @@ 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
|
||||
@@ -170,7 +132,7 @@ preset (no API key — the proxy authenticates):
|
||||
"name": "chatgpt",
|
||||
"catalogID": "chatgpt-proxy", // OpenAI-compatible, local, no key
|
||||
"baseURL": "http://localhost:10531/v1", // override for your proxy's port
|
||||
"model": "gpt-5.5" // whatever model your proxy serves
|
||||
"model": "gpt-5" // whatever model your proxy serves
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,304 @@
|
||||
# Agent Eval Benchmark Harness Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a local, offline-testable benchmark harness that copies eval fixtures, runs an agent command per task, scores the resulting workspace, and reports aggregate results.
|
||||
|
||||
**Architecture:** Keep `internal/agenteval.Runner` as the scoring primitive. Add a `Harness` layer that materializes fixture workspaces into a work directory, initializes a clean Git baseline, invokes an injectable `AgentRunner`, then calls `Runner.Run`. The CLI exposes this as `zero eval bench`, while `zero eval run` remains the lower-level scorer for an already-mutated workspace.
|
||||
|
||||
**Tech Stack:** Go 1.24 standard library, existing `internal/agenteval`, existing `internal/cli`, Git CLI for workspace baselines.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Create `internal/agenteval/materialize.go`: fixture path resolution, directory copy, Git init/baseline helpers.
|
||||
- Create `internal/agenteval/materialize_test.go`: fixture copy and Git baseline tests.
|
||||
- Create `internal/agenteval/agent_command.go`: command-template based agent runner with `{prompt}`, `{workspace}`, `{task_id}` placeholders.
|
||||
- Create `internal/agenteval/agent_command_test.go`: placeholder substitution, cwd, exit/error behavior.
|
||||
- Create `internal/agenteval/benchmark.go`: benchmark orchestration and aggregate report.
|
||||
- Create `internal/agenteval/benchmark_test.go`: end-to-end harness with fake agent runner and sample fixtures.
|
||||
- Modify `internal/cli/agent_eval.go`: parse `zero eval bench` and wire `Harness`.
|
||||
- Modify `internal/cli/agent_eval_test.go`: bench parsing, JSON/text output, report writing.
|
||||
- Modify `docs/AGENT_EVALS.md`: document `bench` mode and real local command examples.
|
||||
|
||||
## Task 1: Fixture Materializer
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/agenteval/materialize.go`
|
||||
- Create: `internal/agenteval/materialize_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
```go
|
||||
func TestMaterializeTaskCopiesFixtureAndInitializesGit(t *testing.T) {
|
||||
suitePath := filepath.Join("testdata", "sample_suite.json")
|
||||
suite, err := LoadSuite(suitePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := suite.Tasks[0]
|
||||
workRoot := t.TempDir()
|
||||
|
||||
workspace, err := Materializer{}.MaterializeTask(context.Background(), suitePath, task, MaterializeInput{WorkRoot: workRoot})
|
||||
if err != nil {
|
||||
t.Fatalf("MaterializeTask: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(workspace.Path, "go.mod")); err != nil {
|
||||
t.Fatalf("fixture was not copied: %v", err)
|
||||
}
|
||||
if output, err := exec.Command("git", "-C", workspace.Path, "status", "--porcelain").CombinedOutput(); err != nil || strings.TrimSpace(string(output)) != "" {
|
||||
t.Fatalf("workspace baseline is dirty: err=%v output=%s", err, output)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/agenteval -run TestMaterializeTask -count=1`
|
||||
Expected: FAIL because `Materializer` is undefined.
|
||||
|
||||
- [ ] **Step 3: Implement minimal materializer**
|
||||
|
||||
Create a `Materializer` with:
|
||||
- `MaterializeTask(ctx, suitePath string, task Task, input MaterializeInput) (Workspace, error)`
|
||||
- `MaterializeInput{WorkRoot string}`
|
||||
- `Workspace{Path string, TaskID string, FixturePath string}`
|
||||
- Resolve relative `task.WorkspaceFixture` from `filepath.Dir(suitePath)`.
|
||||
- Copy directories recursively using stdlib only.
|
||||
- Skip `.git` while copying.
|
||||
- Run `git init`, `git add .`, and `git commit -m "baseline"` with local user config/env.
|
||||
- Return clear errors for missing fixture, absolute fixture escapes, and empty work root.
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
go test ./internal/agenteval -run 'TestMaterializeTask|TestMaterializer' -count=1
|
||||
```
|
||||
|
||||
## Task 2: Agent Command Runner
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/agenteval/agent_command.go`
|
||||
- Create: `internal/agenteval/agent_command_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
```go
|
||||
func TestCommandAgentRunnerExpandsPlaceholders(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
script := filepath.Join(dir, "agent.bat")
|
||||
if err := os.WriteFile(script, []byte("@echo %1|%2|%3> out.txt\r\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runner := CommandAgentRunner{Command: []string{script, "{task_id}", "{workspace}", "{prompt}"}}
|
||||
result := runner.Run(context.Background(), AgentRunInput{TaskID: "task-a", WorkspacePath: dir, Prompt: "fix bug"})
|
||||
if result.ExitCode != 0 || result.Error != "" {
|
||||
t.Fatalf("Run = %#v", result)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, "out.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), "task-a") || !strings.Contains(string(data), dir) || !strings.Contains(string(data), "fix bug") {
|
||||
t.Fatalf("placeholders not expanded: %q", data)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/agenteval -run TestCommandAgentRunner -count=1`
|
||||
Expected: FAIL because `CommandAgentRunner` is undefined.
|
||||
|
||||
- [ ] **Step 3: Implement runner**
|
||||
|
||||
Add:
|
||||
- `type AgentRunInput struct { TaskID, Prompt, WorkspacePath string }`
|
||||
- `type AgentRunResult struct { ExitCode int; Stdout, Stderr, Error string }`
|
||||
- `type AgentRunner interface { Run(context.Context, AgentRunInput) AgentRunResult }`
|
||||
- `type CommandAgentRunner struct { Command []string }`
|
||||
- `Run` executes without shell interpolation, with `cmd.Dir = WorkspacePath`.
|
||||
- Replace `{prompt}`, `{workspace}`, `{task_id}` in every arg.
|
||||
- Empty command returns `ExitCode:-1` and `Error:"agent command is required"`.
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
|
||||
Run: `go test ./internal/agenteval -run TestCommandAgentRunner -count=1`
|
||||
|
||||
## Task 3: Benchmark Harness
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/agenteval/benchmark.go`
|
||||
- Create: `internal/agenteval/benchmark_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
```go
|
||||
func TestHarnessRunsTaskFromFixtureAndScoresResult(t *testing.T) {
|
||||
suitePath := filepath.Join("testdata", "sample_suite.json")
|
||||
suite, err := LoadSuite(suitePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
harness := Harness{
|
||||
Materializer: Materializer{},
|
||||
Agent: agentRunnerFunc(func(ctx context.Context, input AgentRunInput) AgentRunResult {
|
||||
target := filepath.Join(input.WorkspacePath, "docs", "STREAM_JSON_PROTOCOL.md")
|
||||
err := os.WriteFile(target, []byte("updated"), 0o644)
|
||||
if err != nil {
|
||||
return AgentRunResult{ExitCode: -1, Error: err.Error()}
|
||||
}
|
||||
return AgentRunResult{ExitCode: 0}
|
||||
}),
|
||||
Runner: Runner{},
|
||||
}
|
||||
report := harness.Run(context.Background(), suitePath, suite, BenchmarkInput{
|
||||
TaskID: "document-stream-json-verify-events",
|
||||
WorkRoot: t.TempDir(),
|
||||
})
|
||||
if report.Summary.TotalTasks != 1 || report.Summary.PassedTasks != 1 || !report.OK {
|
||||
t.Fatalf("report = %#v", report)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/agenteval -run TestHarness -count=1`
|
||||
Expected: FAIL because `Harness` is undefined.
|
||||
|
||||
- [ ] **Step 3: Implement harness**
|
||||
|
||||
Add:
|
||||
- `BenchmarkInput{TaskID, WorkRoot string; KeepWorkspaces bool}`
|
||||
- `BenchmarkReport{Contract, SuiteID, OK, Summary, Tasks}`
|
||||
- `BenchmarkTaskReport{TaskID, WorkspacePath, FixturePath, Agent AgentRunResult, Report Report}`
|
||||
- `BenchmarkSummary{TotalTasks, PassedTasks, FailedTasks, BlockedTasks, ErrorTasks int}`
|
||||
- `Harness{Materializer Materializer; Agent AgentRunner; Runner Runner}`
|
||||
- If no `Agent`, produce blocked task reports with message `agent command is required`.
|
||||
- Select one task when `TaskID` set, otherwise all tasks.
|
||||
- For each task: materialize, run agent, if agent fails mark blocked; otherwise score with `Runner.Run`.
|
||||
- Remove each materialized task workspace after scoring unless `BenchmarkInput.KeepWorkspaces` is true.
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
|
||||
Run: `go test ./internal/agenteval -run 'TestHarness|TestBenchmark' -count=1`
|
||||
|
||||
## Task 4: CLI `zero eval bench`
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/cli/agent_eval.go`
|
||||
- Modify: `internal/cli/agent_eval_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
```go
|
||||
func TestRunEvalBenchJSONModePassesHarnessOptions(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{
|
||||
"eval", "bench",
|
||||
"--suite", "evals/context.json",
|
||||
"--task", "edit-reader",
|
||||
"--work-root", "D:\\tmp\\zero-evals",
|
||||
"--agent-command", "zero", "exec", "{prompt}",
|
||||
"--json",
|
||||
}, &stdout, &stderr, appDeps{
|
||||
runAgentEval: func(ctx context.Context, options agentEvalOptions) (agentEvalReport, error) {
|
||||
if options.Mode != "bench" || options.TaskID != "edit-reader" || options.WorkRoot == "" || len(options.AgentCommand) != 3 {
|
||||
t.Fatalf("unexpected bench options: %#v", options)
|
||||
}
|
||||
return agentEvalReport{Suite: "quality-context", Status: "pass", OK: true, Total: 1, Passed: 1}, nil
|
||||
},
|
||||
})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./internal/cli -run TestRunEvalBench -count=1`
|
||||
Expected: FAIL because bench mode is not parsed.
|
||||
|
||||
- [ ] **Step 3: Implement CLI**
|
||||
|
||||
Add parser support:
|
||||
- Modes: `validate`, `run`, `bench`
|
||||
- Bench flags: `--work-root <path>`, `--agent-command <argv...>`, `--keep-workspaces`
|
||||
- `--task`, `--report-dir`, `--json` work in bench mode.
|
||||
- `--workspace` remains run-only.
|
||||
- Default work root for bench: temp dir with prefix `zero-eval-`.
|
||||
- Default `AgentCommand` empty -> harness report blocked rather than usage error.
|
||||
- Convert benchmark aggregate to existing CLI report shape.
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
go test ./internal/cli -run 'TestRunEvalBench|TestRunEvalRun' -count=1
|
||||
```
|
||||
|
||||
## Task 5: Docs and Manual Examples
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/AGENT_EVALS.md`
|
||||
|
||||
- [ ] **Step 1: Document modes**
|
||||
|
||||
Add a `bench` subsection:
|
||||
- `validate`: schema only
|
||||
- `run`: score an already-mutated worktree
|
||||
- `bench`: copy fixture, run agent command, score result
|
||||
|
||||
- [ ] **Step 2: Include examples**
|
||||
|
||||
Document:
|
||||
```bash
|
||||
go run ./cmd/zero eval bench \
|
||||
--suite internal/agenteval/testdata/sample_suite.json \
|
||||
--task document-stream-json-verify-events \
|
||||
--work-root /tmp/zero-evals \
|
||||
--agent-command zero exec --cwd {workspace} {prompt}
|
||||
```
|
||||
|
||||
Also document a deterministic fake-agent example for local testing.
|
||||
|
||||
- [ ] **Step 3: Verify**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
go run ./cmd/zero eval --suite internal/agenteval/testdata/sample_suite.json
|
||||
go test ./...
|
||||
```
|
||||
|
||||
## Task 6: Integration Validation
|
||||
|
||||
**Files:**
|
||||
- Modify as needed only in files above.
|
||||
|
||||
- [ ] **Step 1: Run focused validation**
|
||||
|
||||
```bash
|
||||
go test -count=1 ./internal/agenteval ./internal/cli
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run fixture validation**
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
from `internal/agenteval/testdata/fixtures/zero-mini`.
|
||||
|
||||
- [ ] **Step 3: Run full repo validation**
|
||||
|
||||
```bash
|
||||
gofmt -l internal/agenteval internal/cli
|
||||
git diff --check
|
||||
go test ./...
|
||||
go run ./cmd/zero-release build
|
||||
go run ./cmd/zero-release smoke
|
||||
```
|
||||
@@ -0,0 +1,181 @@
|
||||
# Agent Eval D-Level Upgrades Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Upgrade the local agent eval harness from a smoke benchmark into a quality signal that can compare models, score traces, catch context mistakes, and preserve regression artifacts.
|
||||
|
||||
**Architecture:** Keep the existing `internal/agenteval` harness as the core. Add small schema fields to `Task`, score them through `Score` and `Harness.Run`, then expose model matrix and full benchmark report data through `zero eval bench`. Avoid provider calls or UI work.
|
||||
|
||||
**Tech Stack:** Go 1.24, standard library JSON/process/git helpers, existing `go test ./...` validation.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Suite Schema And Rubric Checks
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/agenteval/suite.go`
|
||||
- Modify: `internal/agenteval/score.go`
|
||||
- Test: `internal/agenteval/agenteval_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing schema/rubric tests**
|
||||
|
||||
Add tests that load and normalize `tags`, `difficulty`, and `forbiddenChangedFiles`, reject malformed forbidden paths, and fail scoring when a forbidden file is touched.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify failure**
|
||||
|
||||
Run: `go test ./internal/agenteval -run "LoadSuite|Validate|Forbidden" -count=1`
|
||||
|
||||
- [ ] **Step 3: Implement minimal schema and scoring**
|
||||
|
||||
Add these fields:
|
||||
|
||||
```go
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Difficulty string `json:"difficulty,omitempty"`
|
||||
ForbiddenChangedFiles []string `json:"forbiddenChangedFiles,omitempty"`
|
||||
```
|
||||
|
||||
Normalize file lists, validate duplicate/malformed entries, and add a `forbidden_changed_files` result.
|
||||
|
||||
- [ ] **Step 4: Verify green**
|
||||
|
||||
Run: `go test ./internal/agenteval -count=1`
|
||||
|
||||
### Task 2: Agent Trace Scoring
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/agenteval/trace.go`
|
||||
- Test: `internal/agenteval/trace_test.go`
|
||||
- Modify: `internal/agenteval/benchmark.go`
|
||||
|
||||
- [ ] **Step 1: Write failing trace tests**
|
||||
|
||||
Cover JSONL events such as:
|
||||
|
||||
```json
|
||||
{"type":"tool","name":"read_file"}
|
||||
{"event":"verify","name":"go-test"}
|
||||
```
|
||||
|
||||
Require deterministic event keys like `tool:read_file` and `verify:go-test`.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify failure**
|
||||
|
||||
Run: `go test ./internal/agenteval -run Trace -count=1`
|
||||
|
||||
- [ ] **Step 3: Implement parser and benchmark scoring**
|
||||
|
||||
Parse agent stdout line by line, ignore non-JSON noise, and append a `trace` result when a task declares `requiredTraceEvents`.
|
||||
|
||||
- [ ] **Step 4: Verify green**
|
||||
|
||||
Run: `go test ./internal/agenteval -count=1`
|
||||
|
||||
### Task 3: Context Quality Checks
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/agenteval/context_checks.go`
|
||||
- Test: `internal/agenteval/context_checks_test.go`
|
||||
- Modify: `internal/agenteval/benchmark.go`
|
||||
|
||||
- [ ] **Step 1: Write failing context tests**
|
||||
|
||||
Cover a task that declares:
|
||||
|
||||
```json
|
||||
"contextChecks": {
|
||||
"requiredFiles": ["docs/STREAM_JSON_PROTOCOL.md"],
|
||||
"forbiddenFiles": ["node_modules/cache.txt"]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify failure**
|
||||
|
||||
Run: `go test ./internal/agenteval -run Context -count=1`
|
||||
|
||||
- [ ] **Step 3: Implement fixture/workspace context checks**
|
||||
|
||||
Check required files exist under the materialized workspace and forbidden files do not. Append a `context` result before summary finalization.
|
||||
|
||||
- [ ] **Step 4: Verify green**
|
||||
|
||||
Run: `go test ./internal/agenteval -count=1`
|
||||
|
||||
### Task 4: Model Matrix Benchmarking
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/agenteval/agent_command.go`
|
||||
- Modify: `internal/agenteval/benchmark.go`
|
||||
- Test: `internal/agenteval/agent_command_test.go`
|
||||
- Test: `internal/agenteval/benchmark_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing model-matrix tests**
|
||||
|
||||
Assert `{model}` expands in agent command argv and `BenchmarkInput{Models: []string{"a","b"}}` runs every task for each model.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify failure**
|
||||
|
||||
Run: `go test ./internal/agenteval -run "Model|Harness" -count=1`
|
||||
|
||||
- [ ] **Step 3: Implement model propagation**
|
||||
|
||||
Add `Model` to `AgentRunInput` and `BenchmarkTaskReport`; add `Models []string` to `BenchmarkInput`; loop task/model pairs deterministically.
|
||||
|
||||
- [ ] **Step 4: Verify green**
|
||||
|
||||
Run: `go test ./internal/agenteval -count=1`
|
||||
|
||||
### Task 5: CLI Flags And Regression Artifacts
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/cli/agent_eval.go`
|
||||
- Test: `internal/cli/agent_eval_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing CLI tests**
|
||||
|
||||
Cover repeated `--model`, comma-separated `--models`, help text, and report-dir JSON containing nested benchmark detail.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify failure**
|
||||
|
||||
Run: `go test ./internal/cli -run Eval -count=1`
|
||||
|
||||
- [ ] **Step 3: Implement CLI wiring**
|
||||
|
||||
Pass models into `agenteval.BenchmarkInput`, include benchmark detail in `agentEvalReport`, and prefix failure IDs with model when present.
|
||||
|
||||
- [ ] **Step 4: Verify green**
|
||||
|
||||
Run: `go test ./internal/cli -count=1`
|
||||
|
||||
### Task 6: Suite Expansion And Docs
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/agenteval/testdata/sample_suite.json`
|
||||
- Modify: `docs/AGENT_EVALS.md`
|
||||
|
||||
- [ ] **Step 1: Write failing suite expectation**
|
||||
|
||||
Update the sample suite test to require richer coverage count and metadata fields.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify failure**
|
||||
|
||||
Run: `go test ./internal/agenteval -run SampleSuite -count=1`
|
||||
|
||||
- [ ] **Step 3: Expand the fixture suite and docs**
|
||||
|
||||
Add more tasks using the existing `zero-mini` fixture, plus examples for `--model`, `{model}`, `requiredTraceEvents`, `contextChecks`, and report artifacts.
|
||||
|
||||
- [ ] **Step 4: Verify green**
|
||||
|
||||
Run: `go test ./internal/agenteval ./internal/cli -count=1`
|
||||
|
||||
### Final Verification
|
||||
|
||||
- [ ] Run `gofmt -l internal/agenteval internal/cli`
|
||||
- [ ] Run `git diff --check`
|
||||
- [ ] Run `go vet ./...`
|
||||
- [ ] Run `go test ./...`
|
||||
- [ ] Run `go run ./cmd/zero eval --suite internal/agenteval/testdata/sample_suite.json`
|
||||
- [ ] Run `go run ./cmd/zero eval bench --suite internal/agenteval/testdata/sample_suite.json --task document-stream-json-verify-events --model test-model --json --agent-command powershell -NoProfile -Command "& { param(`$ws) Set-Content -LiteralPath (Join-Path `$ws 'docs/STREAM_JSON_PROTOCOL.md') -Value updated; Write-Output '{\"type\":\"tool\",\"name\":\"read_file\"}' }" "{workspace}"`
|
||||
- [ ] Run `go run ./cmd/zero-release build`
|
||||
- [ ] Commit and push to `feat/agent-eval-harness`
|
||||
@@ -0,0 +1,175 @@
|
||||
# Additional write roots (`--add-dir` / `/add-dir`)
|
||||
|
||||
**Date:** 2026-06-10
|
||||
**Status:** Approved
|
||||
|
||||
## Problem
|
||||
|
||||
Zero's sandbox confines all writes to the workspace root, with no escape hatch.
|
||||
`EnforceWorkspace` is hardcoded `true` in `DefaultPolicy()`
|
||||
(`internal/sandbox/types.go`) and `SandboxConfig` exposes only `maxAutonomy`
|
||||
and `network`. When a user asks the agent to save output outside the workspace
|
||||
(e.g. "save this to ~/Desktop/dev/untitled folder"), every layer denies it:
|
||||
|
||||
1. **Policy engine** — `Engine.Evaluate` → `validateWorkspacePaths`
|
||||
(`internal/sandbox/paths.go`) denies tool requests whose paths fall outside
|
||||
the workspace root.
|
||||
2. **macOS seatbelt** — `sandboxExecProfile` (`internal/sandbox/runner.go`)
|
||||
emits `(allow file-write* (subpath "<workspaceRoot>"))`, so shell commands
|
||||
fail with `Operation not permitted`.
|
||||
3. **Linux bubblewrap** — `bubblewrapCommandPlan` binds only the workspace
|
||||
read-write; system paths are read-only binds.
|
||||
4. **File tools** — `resolveWorkspacePath` / `recheckWorkspaceWriteTarget`
|
||||
(`internal/tools/workspace.go`) confine `write_file`, `edit`, `read_file`,
|
||||
`list_directory`, `glob`, `grep`, and the bash tool's `cwd`.
|
||||
|
||||
The agent's only recourse is workarounds (zip in workspace, ask the user to
|
||||
move it), and the denial surfaces as a raw OS error with no guidance.
|
||||
|
||||
## Goal
|
||||
|
||||
Let users grant zero write access to specific directories outside the
|
||||
workspace — at launch (flag/config) and mid-session (TUI command) — without
|
||||
weakening the sandbox elsewhere. Decided UX: **launch flag + runtime command**
|
||||
(prompt-on-denial may layer on later).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No interactive prompt-on-denial flow in this iteration.
|
||||
- No persistence of `/add-dir` grants across sessions (config is the durable
|
||||
surface).
|
||||
- No widening of network or destructive-shell policy.
|
||||
- No `enforceWorkspace: false` config knob.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Core type: `sandbox.Scope`
|
||||
|
||||
New concurrency-safe type in `internal/sandbox` (e.g. `scope.go`):
|
||||
|
||||
```go
|
||||
type Scope struct {
|
||||
mu sync.RWMutex
|
||||
workspaceRoot string // normalized at construction
|
||||
extraRoots []string // normalized, deduplicated
|
||||
}
|
||||
|
||||
func NewScope(workspaceRoot string, extras []string) (*Scope, error)
|
||||
func (s *Scope) Roots() []string // workspace first, then extras
|
||||
func (s *Scope) Add(path string) (string, error) // returns normalized root
|
||||
func (s *Scope) validate(path string) *pathViolation // package-internal
|
||||
```
|
||||
|
||||
`Add` normalization and guardrails:
|
||||
|
||||
- Expand a leading `~` to the user home directory.
|
||||
- Make absolute, `filepath.Clean`, then `filepath.EvalSymlinks`.
|
||||
- The path must exist and be a directory (no auto-create).
|
||||
- Reject the filesystem root `/` (and Windows volume roots) — granting it
|
||||
would disable confinement entirely.
|
||||
- Adding the workspace root or a path inside any existing root is a
|
||||
no-op (idempotent success, not an error).
|
||||
|
||||
`Validate` generalizes today's `validateWorkspacePath`: a path is allowed if
|
||||
it validates under **any** root, including the per-segment symlink-traversal
|
||||
check against that root. The existing single-root function remains as the
|
||||
per-root helper.
|
||||
|
||||
One `*Scope` is constructed at startup and shared by the engine, the command
|
||||
runner, and the tool registry, so a mid-session `Add` is immediately visible
|
||||
to all layers.
|
||||
|
||||
### 2. Policy engine
|
||||
|
||||
- `EngineOptions` gains `Scope *Scope`; `NewEngine` builds a workspace-only
|
||||
scope when nil (back-compat for tests/embedders).
|
||||
- `Evaluate`'s workspace check (engine.go) calls `scope.Validate` per
|
||||
requested path instead of `validateWorkspacePaths(root, request)`.
|
||||
- Risk classification (`risk.go`) treats paths inside any scope root as
|
||||
in-workspace, so writes to granted roots are not classified
|
||||
`out_of_workspace`/critical.
|
||||
- Denial reason becomes actionable:
|
||||
`"<path> is outside the workspace (use /add-dir or --add-dir to allow writes there)"`.
|
||||
|
||||
### 3. OS runners
|
||||
|
||||
- `sandboxExecProfile` emits one `(subpath "...")` per scope root inside the
|
||||
single `(allow file-write* ...)` rule.
|
||||
- `bubblewrapCommandPlan` adds `--bind <root> <root>` (read-write, real path)
|
||||
for each extra root. The workspace keeps its `/workspace` remount; extra
|
||||
roots keep their host paths so absolute paths in commands work unchanged.
|
||||
- `resolveCommandDir` accepts a command `cwd` inside any scope root.
|
||||
- Profiles are built per command plan from `scope.Roots()`, so commands run
|
||||
after `/add-dir` get the widened profile with no restart.
|
||||
|
||||
### 4. File tools
|
||||
|
||||
- New narrow interface in `internal/tools`:
|
||||
`type PathScope interface { Roots() []string }` (satisfied by
|
||||
`*sandbox.Scope`) to avoid a hard dependency direction problem.
|
||||
- `resolveWorkspacePath` and `recheckWorkspaceWriteTarget` gain scope-aware
|
||||
variants: absolute paths are resolved against the first root that contains
|
||||
them; **relative paths still resolve against the workspace root only**.
|
||||
- Tool constructors (`CoreReadOnlyTools`, `CoreWriteTools`, `CoreShellTools`,
|
||||
`CoreTools` in `registry.go`) accept the scope (workspace-only default kept
|
||||
via a convenience wrapper so existing call sites/tests keep compiling).
|
||||
- Result: `write_file`, `edit`, `read_file`, `list_directory`, `glob`, `grep`,
|
||||
and bash `cwd` all work consistently inside extra roots. Write access
|
||||
implies read access within a granted root.
|
||||
|
||||
### 5. Config, CLI, TUI
|
||||
|
||||
- **Config:** `SandboxConfig` gains
|
||||
`AdditionalWriteRoots []string \`json:"additionalWriteRoots,omitempty"\``.
|
||||
The key is honored from the **global user config**
|
||||
(`os.UserConfigDir()/zero/config.json`) so a grant can apply to every
|
||||
project, and from CLI flags/overrides. It is **deliberately excluded from
|
||||
the project config allowlist** (`mergeProjectConfig`): a repo-controlled
|
||||
`.zero/config.json` must not be able to widen write access outside the
|
||||
workspace. Sources are merged as a union (append + dedupe), not replace.
|
||||
- **CLI:** repeatable `--add-dir <path>` flag on `zero` (TUI) and `zero exec`.
|
||||
Effective set = union(flag, config), normalized through `Scope` at startup;
|
||||
an invalid root fails fast with a clear error.
|
||||
- **TUI:** new `/add-dir` command (`internal/tui/commands.go`):
|
||||
- `/add-dir` (bare) — lists current write roots.
|
||||
- `/add-dir <path>` — calls `scope.Add`; on success appends a system line:
|
||||
`write access added: <path> (this session only)`; on failure shows the
|
||||
normalization error.
|
||||
- **Observability:** `zero sandbox` status output and sandbox snapshots
|
||||
(`internal/zerocommands/sandbox_snapshots.go`) list extra roots.
|
||||
|
||||
### 6. Security invariants
|
||||
|
||||
- Extra roots only widen *file-write* (and file-tool read) access under the
|
||||
named directories; network and destructive-shell policy are untouched.
|
||||
- Symlink-traversal protection applies per root exactly as it does for the
|
||||
workspace today.
|
||||
- `/` and volume roots are rejected; roots are symlink-resolved before being
|
||||
trusted so a symlinked grant cannot silently point elsewhere later.
|
||||
- Runtime grants are session-only; durable grants require editing config.
|
||||
|
||||
### 7. Testing
|
||||
|
||||
- **Scope unit tests:** normalization (`~`, relative, symlink), rejection of
|
||||
`/`, missing dirs, files; idempotent adds; multi-root `Validate` including
|
||||
symlink-traversal escapes from one root into another denied area.
|
||||
- **Runner tests:** seatbelt profile string contains one `subpath` per root;
|
||||
bubblewrap args contain the extra `--bind`s; `resolveCommandDir` accepts
|
||||
extra-root cwd (extend `runner_test.go`).
|
||||
- **Engine tests:** out-of-workspace write allowed when inside an extra root;
|
||||
still denied (with the new actionable message) when outside all roots.
|
||||
- **Tool tests:** `write_file`/`edit`/`read_file` in an extra root succeed;
|
||||
relative paths never resolve against extra roots (extend
|
||||
`file_tools_test.go`).
|
||||
- **TUI tests:** `parseCommand` recognizes `/add-dir`; handler list/add/error
|
||||
paths.
|
||||
- **Config/CLI tests:** flag-config union, fail-fast on invalid root.
|
||||
- Platform-gated integration tests follow the existing sandbox test patterns.
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. `sandbox.Scope` + tests.
|
||||
2. Engine + risk + runner integration (flag-less, scope seeded empty).
|
||||
3. File-tool scope plumbing.
|
||||
4. Config key + CLI flags.
|
||||
5. TUI `/add-dir` command + status output.
|
||||
@@ -0,0 +1,107 @@
|
||||
# Permission-UX Phase 1 — scope-enforced grants
|
||||
|
||||
Status: approved (2026-06-11). Branch: `feat/permission-ux` (continues Phase 0, commit `742ff48`).
|
||||
|
||||
## Problem
|
||||
|
||||
Phase 0 derives a human-readable *scope* for a tool call (the file, directory, or
|
||||
cwd it touches) and shows it on the permission card and the persisted decision
|
||||
row. But the grant that an "always allow" writes is still **tool-wide**:
|
||||
|
||||
- `persistPermissionGrant` (agent/loop.go) stores only `ToolName`.
|
||||
- the grant file is keyed by tool name: `state.Grants[toolName]`.
|
||||
- enforcement (`engine.Decide` → `store.Lookup(toolName, autonomy)`, engine.go:100)
|
||||
matches on tool name alone.
|
||||
|
||||
So clicking "always allow" on a write to `src/main.go` silently authorizes *every*
|
||||
future write — the blind-yes the Phase-0 card was written to warn against.
|
||||
|
||||
## Goal
|
||||
|
||||
Persist and enforce the scope so an "always" grant covers exactly what the card
|
||||
showed: that file, or that directory subtree — nothing else.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Match semantics:** a `file` scope re-matches only its exact path; a `dir`
|
||||
scope matches that directory and any descendant; an empty scope (a call with no
|
||||
path-like arg, e.g. `bash` with no `cwd`) stays tool-wide. A tool-wide *request*
|
||||
is **not** covered by a narrower grant — it re-prompts (fail-safe).
|
||||
2. **Path anchoring:** scopes are normalized to an absolute, cleaned path at grant
|
||||
time, anchored to the workspace root. Matching compares absolute paths, so
|
||||
`./src/main.go` == `src/main.go` == `/proj/src/main.go`, and a grant made in
|
||||
`/proj-a` never matches `/proj-b`. The card still displays the short relative form.
|
||||
3. **Storage:** `grants` becomes `map[toolName][]Grant`, one record per
|
||||
(scope, decision). Schema bumps `1 → 2`; a v1 file is migrated by reading each
|
||||
entry as a single tool-wide grant, so existing grants keep working unchanged.
|
||||
4. **Precedence:** among grants that cover a request, a covering **deny wins**
|
||||
(regardless of autonomy); otherwise the most-specific covering **allow**
|
||||
(file > dir > tool-wide; ties broken by longer path, then newest) whose
|
||||
`MaxAutonomy` satisfies the request.
|
||||
|
||||
## Design
|
||||
|
||||
### Scope derivation — single source of truth (`internal/sandbox/grant_scope.go`)
|
||||
|
||||
`DeriveScope(toolName, args) (raw string, kind ScopeKind)` is the one place that
|
||||
knows which args are path-like, reusing Phase 0's key priority (most specific
|
||||
first): `path`,`file` → `file`; `directory`,`dir`,`cwd` → `dir`. Empty / `.` →
|
||||
tool-wide. `resolveScopeAbs(raw, workspaceRoot)` cleans and absolutizes (relative
|
||||
anchored to the workspace root; falls back to `filepath.Abs` when the root is
|
||||
empty). `agent.permissionScope` (Phase-0 display) is refactored to call
|
||||
`DeriveScope`, so the displayed scope and the stored/matched scope can never diverge.
|
||||
|
||||
`ScopeKind`: `""` (tool-wide) | `"file"` | `"dir"`.
|
||||
|
||||
### Data model (`internal/sandbox/grants.go`)
|
||||
|
||||
`Grant` gains `Scope` (absolute path, `omitempty`) and `ScopeKind` (`omitempty`).
|
||||
`grantFile.Grants` becomes `map[string][]Grant`; `grantSchemaVersion` = 2.
|
||||
`GrantInput` gains `Scope` (raw) + `ScopeKind`.
|
||||
|
||||
### Grant creation
|
||||
|
||||
The agent derives `(raw, kind)` via `DeriveScope` and passes them in `GrantInput`.
|
||||
`engine.Grant` resolves `Scope` to absolute using its configured `workspaceRoot`
|
||||
(the engine already holds one — engine.go:33) before delegating to `store.Grant`,
|
||||
which appends to the tool's slice (replacing a same-scope entry). CLI grants
|
||||
(`zero sandbox grants allow|deny <tool>`) leave the scope empty → tool-wide, as today.
|
||||
|
||||
### Matching (`engine.Decide` → `store.Lookup`)
|
||||
|
||||
`Decide` derives the request's absolute scope from `request.Args` +
|
||||
`request.WorkspaceRoot` (already populated at engine.go:63) and passes it to a
|
||||
scope-aware `Lookup(toolName, reqScopeAbs, autonomy)`. `grantCovers` implements the
|
||||
match semantics above; `Lookup` applies the deny-wins / most-specific-allow
|
||||
precedence and the per-grant autonomy check. The surrounding allow/deny/ceiling
|
||||
flow (engine.go:99-126) is unchanged.
|
||||
|
||||
### Migration & a fixed bug
|
||||
|
||||
`readState` peeks at `schemaVersion`: v1 (`map[string]Grant`) entries become single
|
||||
tool-wide grants; v2 reads `map[string][]Grant`; anything else still errors with
|
||||
"unsupported schemaVersion". Keys are canonicalized (trimmed) on read so a
|
||||
whitespace-padded tool name matches at lookup — closing audit finding #90
|
||||
(grants.go:146). `writeState` always emits v2.
|
||||
|
||||
### CLI surface
|
||||
|
||||
`List()` flattens to `[]Grant` (sorted by tool, then scope). `FormatGrantList`
|
||||
shows the scope (`*` for tool-wide). `Revoke(toolName)` removes all of a tool's
|
||||
grants and returns the count. Per-scope revoke is out of scope.
|
||||
|
||||
## Testing
|
||||
|
||||
- `grant_scope_test` / `grants_test`: `DeriveScope` kinds; `resolveScopeAbs` (relative
|
||||
anchored, `./` equivalence, absolute passthrough, cross-project non-match);
|
||||
`grantCovers` matrix (tool-wide/file/dir × equal/descendant/sibling/parent/
|
||||
empty-request); deny-precedence; per-grant + policy autonomy; v1→v2 migration
|
||||
round-trip; whitespace-key canonicalization; `FormatGrantList` scope rendering.
|
||||
- `engine_test`: covered scoped request auto-allows; uncovered sibling re-prompts;
|
||||
dir deny blocks a subtree request; legacy tool-wide grant still allows.
|
||||
- `agent`: `persistPermissionGrant` forwards scope+kind; `TestPermissionScope`
|
||||
stays green (display unchanged).
|
||||
|
||||
## Out of scope
|
||||
|
||||
Card UI changes (Phase 0), per-scope revoke, glob/pattern scopes.
|
||||
@@ -8,12 +8,9 @@ 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/aymanbagabas/go-udiff v0.4.1
|
||||
github.com/charmbracelet/colorprofile v0.4.3
|
||||
github.com/charmbracelet/x/ansi v0.11.7
|
||||
github.com/charmbracelet/x/term v0.2.2
|
||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
|
||||
golang.org/x/sys v0.46.0
|
||||
mvdan.cc/sh/v3 v3.13.1
|
||||
@@ -22,11 +19,11 @@ require (
|
||||
require (
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 // indirect
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
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,12 +4,6 @@ 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=
|
||||
@@ -32,14 +26,10 @@ 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=
|
||||
|
||||
@@ -1,561 +0,0 @@
|
||||
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...)
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
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})
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
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"
|
||||
@@ -1,228 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func enabledSelfCorrector(t *testing.T) *SelfCorrector {
|
||||
t.Helper()
|
||||
// checker/verifier nil → no LSP / project-test pass runs; we only need a
|
||||
// non-nil SelfCorrector to enable the task-grounded acceptance gate.
|
||||
return NewSelfCorrector(t.TempDir(), nil, nil, SelfCorrectConfig{Enabled: true})
|
||||
}
|
||||
|
||||
// BUG #2(a): a final message that admits the model guessed / could not meet the
|
||||
// objective must be reported as INCOMPLETE, never success — and immediately
|
||||
// (an admitted guess is not worth re-prompting).
|
||||
func TestAcceptanceSelfReportAdmissionIsIncomplete(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn("I couldn't analyze the board image, so I wrote the common opening move e2e4 as my best guess."),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "write the best chess move", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
SelfCorrect: enabledSelfCorrector(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Incomplete {
|
||||
t.Fatalf("admitted guess must be Incomplete, got success; final=%q", result.FinalAnswer)
|
||||
}
|
||||
if !strings.Contains(result.IncompleteReason, "objective was not met") {
|
||||
t.Fatalf("IncompleteReason = %q, want it to cite the admission", result.IncompleteReason)
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("an admission should downgrade immediately (1 turn), got %d", len(provider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
// BUG #2(a): the inability detector must generalize over the verb the model uses
|
||||
// (so re-phrasing can't defeat it) while NOT misreading success-y negations
|
||||
// ("could not find any issues") as admissions.
|
||||
func TestSelfReportedIncompletionMatching(t *testing.T) {
|
||||
cases := []struct {
|
||||
text string
|
||||
admit bool
|
||||
}{
|
||||
// real chess re-run wording that slipped a fixed phrase list:
|
||||
{"…which I cannot do without proper image analysis capabilities.", true},
|
||||
{"I cannot visually analyze the chess board image.", true},
|
||||
{"I could not recover the original values, so this is my best guess.", true},
|
||||
{"I was unable to complete the optimization.", true},
|
||||
{"The result may not be correct.", true},
|
||||
// multi-occurrence: an early guarded use ("could not find any") must NOT mask a
|
||||
// later genuine admission with the same stem (review #5):
|
||||
{"I could not find any examples in the codebase, so I could not implement the feature.", true},
|
||||
// genuine successes that must NOT be flagged:
|
||||
{"I implemented the function and all tests pass.", false},
|
||||
{"I verified the fix; I could not find any remaining issues.", false},
|
||||
{"I cannot reproduce the bug, so the fix holds.", false},
|
||||
{"Done — the output matches the required format and values.", false},
|
||||
// behavior descriptions that must NOT be flagged — these phrases describe
|
||||
// implemented behavior, not an admission (review #1, removed from the list):
|
||||
{"The parser will fall back to UTF-8 when no encoding is set.", false},
|
||||
{"I replaced the placeholder value with the computed key.", false},
|
||||
{"The retry uses exponential backoff as a fallback.", false},
|
||||
{"Without proper error handling the function would crash, so I added it.", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := selfReportedIncompletion(c.text) != ""
|
||||
if got != c.admit {
|
||||
t.Errorf("selfReportedIncompletion(%q) admitted=%v, want %v", c.text, got, c.admit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BUG #2(a): a "cannot analyze / cannot determine" admission (present tense) must
|
||||
// be caught even when the model also stamps a bogus "PASS" based on a shape check.
|
||||
// Mirrors the chess-best-move final ("I cannot analyze the position… PASS: I wrote
|
||||
// the move and verified by reading it back").
|
||||
func TestAcceptanceCannotPhrasingIsIncomplete(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn("I cannot analyze the specific position from the image, so I cannot determine the best move. PASS: I wrote e2e4 to the file and verified it by reading it back."),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "write the best move", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
SelfCorrect: enabledSelfCorrector(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Incomplete {
|
||||
t.Fatalf("a 'cannot analyze/determine' admission must be Incomplete despite the bogus PASS; final=%q", result.FinalAnswer)
|
||||
}
|
||||
}
|
||||
|
||||
// A pending/in_progress update_plan item is an AMBIGUOUS signal (the model may have
|
||||
// finished without re-marking the step), so it must NOT by itself force INCOMPLETE.
|
||||
// The gate nudges, but if the model keeps claiming completion with no continuation
|
||||
// cue and no admission, the run finalizes as SUCCESS — trusting the completion claim
|
||||
// over stale plan bookkeeping. (Only a continuation cue or self-report admission
|
||||
// forces INCOMPLETE; see TestCompletionGate* and TestAcceptance*Admission*.)
|
||||
func TestPendingPlanAloneDoesNotForceIncomplete(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
done := "All set." // confident; no cue, no admission — only the plan is stale
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
planTurn("completed", "in_progress"),
|
||||
textTurn(done), textTurn(done), textTurn(done), textTurn(done), textTurn(done),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "do the multi-step task", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
SelfCorrect: enabledSelfCorrector(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("pending-plan alone must NOT force Incomplete (stale bookkeeping != unfinished); reason=%q", result.IncompleteReason)
|
||||
}
|
||||
if result.FinalAnswer != done {
|
||||
t.Fatalf("final answer = %q, want the completion claim %q", result.FinalAnswer, done)
|
||||
}
|
||||
if !someRequestContains(provider.requests, continueNudgeMarker) {
|
||||
t.Fatalf("expected at least one continue-nudge before accepting completion")
|
||||
}
|
||||
}
|
||||
|
||||
// BUG #2(b): when self-correct is on, the run must pass a one-time task-grounded
|
||||
// acceptance check before success. A model that, when challenged, confirms its
|
||||
// work meets the criterion (no admission) then finalizes as success — no regression.
|
||||
func TestAcceptanceGroundedCompletionSucceeds(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn("I implemented the function."),
|
||||
textTurn("PASS. I ran the verification and it passes; the output meets the task requirement."),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "implement the function", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
SelfCorrect: enabledSelfCorrector(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("a verified completion must succeed, got Incomplete (%q)", result.IncompleteReason)
|
||||
}
|
||||
if result.FinalAnswer != "PASS. I ran the verification and it passes; the output meets the task requirement." {
|
||||
t.Fatalf("final answer = %q", result.FinalAnswer)
|
||||
}
|
||||
// Exactly one acceptance pass: the first "done" turn was challenged, the second confirmed.
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected 2 turns (1 acceptance challenge + 1 confirmation), got %d", len(provider.requests))
|
||||
}
|
||||
if !someRequestContains(provider.requests, acceptanceNudgeMarker) {
|
||||
t.Fatalf("expected the task-grounded acceptance check to be demanded")
|
||||
}
|
||||
}
|
||||
|
||||
// With the gate OFF (interactive/TUI default), even an admission is accepted as the
|
||||
// final answer exactly as before — proving no behavior change for non-headless callers.
|
||||
func TestAcceptanceGateOffLeavesAdmissionAsSuccess(t *testing.T) {
|
||||
admission := "I couldn't determine the value, so this is a guess."
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn(admission),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "do a thing", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
// RequireCompletionSignal false, SelfCorrect nil — the legacy path.
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("legacy path must never set Incomplete; final=%q", result.FinalAnswer)
|
||||
}
|
||||
if result.FinalAnswer != admission {
|
||||
t.Fatalf("final answer = %q, want the text verbatim (legacy behavior)", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("legacy path must not re-prompt; got %d turns", len(provider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
// The acceptance check is scoped to runs with self-correct on: a headless run
|
||||
// WITHOUT a SelfCorrector accepts a confident completion immediately (the (b) gate
|
||||
// is inert), so enabling RequireCompletionSignal alone adds no acceptance turn.
|
||||
func TestAcceptanceGateRequiresSelfCorrect(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn("Done. Implemented and it works."),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "implement it", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
// SelfCorrect deliberately nil → acceptance gate (b) must not fire.
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("confident completion without self-correct must succeed; reason=%q", result.IncompleteReason)
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("acceptance gate must not fire without self-correct; got %d turns", len(provider.requests))
|
||||
}
|
||||
if someRequestContains(provider.requests, acceptanceNudgeMarker) {
|
||||
t.Fatalf("acceptance nudge must not be injected when self-correct is off")
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// A shell call that sets sandbox_permissions: with_additional_permissions but
|
||||
// omits (or malforms) additional_permissions can never succeed: the same
|
||||
// validation runs again at grant time regardless of the user's decision. It
|
||||
// must be rejected as a plain tool error BEFORE any permission prompt, not
|
||||
// surfaced as a confusing "approved but denied" prompt outcome.
|
||||
func TestExecuteToolCallRejectsMissingAdditionalPermissionsBeforePrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
|
||||
promptCalls := 0
|
||||
result, err := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{
|
||||
ID: "c1",
|
||||
Name: "bash",
|
||||
Arguments: `{"command":"echo hi","sandbox_permissions":"with_additional_permissions"}`,
|
||||
},
|
||||
PermissionModeAuto,
|
||||
Options{
|
||||
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
promptCalls++
|
||||
return PermissionDecision{Action: PermissionDecisionAllow}, nil
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("status = %q, want error", result.Status)
|
||||
}
|
||||
if promptCalls != 0 {
|
||||
t.Fatalf("OnPermissionRequest called %d times, want 0 (must reject before prompting)", promptCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// The mismatched-flag case (additional_permissions present without the
|
||||
// sandbox_permissions flag) must be rejected the same way.
|
||||
func TestExecuteToolCallRejectsAdditionalPermissionsWithoutFlagBeforePrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
|
||||
promptCalls := 0
|
||||
result, err := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{
|
||||
ID: "c1",
|
||||
Name: "bash",
|
||||
Arguments: `{"command":"echo hi","additional_permissions":{"network":{"enabled":true}}}`,
|
||||
},
|
||||
PermissionModeAuto,
|
||||
Options{
|
||||
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
promptCalls++
|
||||
return PermissionDecision{Action: PermissionDecisionAllow}, nil
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("status = %q, want error", result.Status)
|
||||
}
|
||||
if promptCalls != 0 {
|
||||
t.Fatalf("OnPermissionRequest called %d times, want 0 (must reject before prompting)", promptCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// The rejection message must show a concrete, correctly-shaped example: a
|
||||
// model that gets this wrong once (attaching sandbox_permissions out of habit
|
||||
// from an earlier call, without a valid payload) needs enough in the error to
|
||||
// self-correct on retry instead of repeating the identical mistake.
|
||||
func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
|
||||
result, err := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{
|
||||
ID: "c1",
|
||||
Name: "bash",
|
||||
Arguments: `{"command":"ping example.com","sandbox_permissions":"with_additional_permissions"}`,
|
||||
},
|
||||
PermissionModeAuto,
|
||||
Options{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if !strings.Contains(result.Output, `{"network": {"enabled": true}}`) {
|
||||
t.Fatalf("output = %q, want a concrete additional_permissions example", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "omit sandbox_permissions") {
|
||||
t.Fatalf("output = %q, want guidance that the flag can simply be omitted", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// A well-formed additional_permissions payload must still reach the normal
|
||||
// permission prompt: this fix only rejects malformed payloads.
|
||||
func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
|
||||
promptCalls := 0
|
||||
_, err := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{
|
||||
ID: "c1",
|
||||
Name: "bash",
|
||||
Arguments: `{"command":"echo hi","sandbox_permissions":"with_additional_permissions",` +
|
||||
`"additional_permissions":{"network":{"enabled":true}}}`,
|
||||
},
|
||||
PermissionModeAuto,
|
||||
Options{
|
||||
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
promptCalls++
|
||||
return PermissionDecision{Action: PermissionDecisionDeny}, nil
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if promptCalls != 1 {
|
||||
t.Fatalf("OnPermissionRequest called %d times, want exactly 1 for a valid elevation request", promptCalls)
|
||||
}
|
||||
}
|
||||
@@ -1,126 +1,57 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"mvdan.cc/sh/v3/syntax"
|
||||
)
|
||||
|
||||
func proposedCommandPrefix(toolName string, args map[string]any) []string {
|
||||
if !isShellCommandTool(toolName) {
|
||||
if toolName != "bash" {
|
||||
return nil
|
||||
}
|
||||
command, ok := firstStringArg(args, "command", "cmd", "script", "shell")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
segments, ok := safeShellCommandSegments(command)
|
||||
tokens, ok := safeShellCommandTokens(command)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if requested, ok := requestedPrefixRule(args); ok {
|
||||
if safeRequestedPrefixForSegments(requested, segments) {
|
||||
if safeRequestedPrefix(requested, tokens) {
|
||||
return requested
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, tokens := range segments {
|
||||
if !knownSafeCommandSegment(tokens) && sandbox.ValidCommandPrefix(tokens) {
|
||||
return append([]string(nil), tokens...)
|
||||
}
|
||||
}
|
||||
if len(segments) == 0 || !sandbox.ValidCommandPrefix(segments[0]) {
|
||||
if !sandbox.ValidCommandPrefix(tokens) {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), segments[0]...)
|
||||
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
|
||||
}
|
||||
if shellCommandAdditionalPermissionsRequested(args) {
|
||||
if toolName != "bash" || options.Sandbox == nil {
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
}
|
||||
command, ok := firstStringArg(args, "command", "cmd", "script", "shell")
|
||||
if !ok {
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
}
|
||||
segments, ok := safeShellCommandSegments(command)
|
||||
tokens, ok := safeShellCommandTokens(command)
|
||||
if !ok {
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
}
|
||||
var matched sandbox.CommandPrefixGrant
|
||||
matchedAny := false
|
||||
matchedSession := false
|
||||
for _, tokens := range segments {
|
||||
if grant, ok := options.Sandbox.LookupCommandPrefix(toolName, tokens); ok {
|
||||
if !matchedAny {
|
||||
matched = grant
|
||||
}
|
||||
matchedAny = true
|
||||
continue
|
||||
}
|
||||
if grant, ok := options.Sandbox.LookupCommandPrefixForSession(toolName, tokens); ok {
|
||||
if !matchedAny {
|
||||
matched = grant
|
||||
}
|
||||
matchedAny = true
|
||||
matchedSession = true
|
||||
continue
|
||||
}
|
||||
if knownSafeCommandSegment(tokens) {
|
||||
continue
|
||||
}
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
if grant, ok := options.Sandbox.LookupCommandPrefix(toolName, tokens); ok {
|
||||
return grant, true, false
|
||||
}
|
||||
if matchedAny {
|
||||
return matched, true, matchedSession
|
||||
if grant, ok := options.Sandbox.LookupCommandPrefixForSession(toolName, tokens); ok {
|
||||
return grant, true, true
|
||||
}
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
}
|
||||
|
||||
func shellExecutionArgsForApproval(toolName string, args map[string]any, action PermissionDecisionAction, options Options) map[string]any {
|
||||
if !isShellCommandTool(toolName) || !shellPrefixApprovalBypassesSandbox(action) {
|
||||
return args
|
||||
}
|
||||
if options.Sandbox == nil || !options.Sandbox.UnsandboxedExecutionAllowed() {
|
||||
return args
|
||||
}
|
||||
if shellCommandAdditionalPermissionsRequested(args) || shellCommandRequiresEscalated(args) {
|
||||
return args
|
||||
}
|
||||
planned := cloneArgs(args)
|
||||
if planned == nil {
|
||||
planned = map[string]any{}
|
||||
}
|
||||
planned["sandbox_permissions"] = string(tools.SandboxPermissionsRequireEscalated)
|
||||
return planned
|
||||
}
|
||||
|
||||
func shellPrefixApprovalBypassesSandbox(action PermissionDecisionAction) bool {
|
||||
return action == PermissionDecisionAllowPrefix || action == PermissionDecisionAlwaysAllowPrefix
|
||||
}
|
||||
|
||||
func shellCommandRequiresEscalated(args map[string]any) bool {
|
||||
raw, ok := args["sandbox_permissions"]
|
||||
if !ok || raw == nil {
|
||||
return false
|
||||
}
|
||||
value, ok := raw.(string)
|
||||
if !ok {
|
||||
value = fmt.Sprint(raw)
|
||||
}
|
||||
return strings.TrimSpace(value) == string(tools.SandboxPermissionsRequireEscalated)
|
||||
}
|
||||
|
||||
func isShellCommandTool(toolName string) bool {
|
||||
return toolName == "bash" || toolName == "exec_command"
|
||||
}
|
||||
|
||||
func firstStringArg(args map[string]any, names ...string) (string, bool) {
|
||||
for _, name := range names {
|
||||
if raw, ok := args[name].(string); ok {
|
||||
@@ -169,92 +100,32 @@ func cleanPrefixRule(prefix []string) []string {
|
||||
}
|
||||
|
||||
func safeRequestedPrefix(prefix []string, command []string) bool {
|
||||
return safeRequestedPrefixForSegments(prefix, [][]string{command})
|
||||
}
|
||||
|
||||
func safeRequestedPrefixForSegments(prefix []string, segments [][]string) bool {
|
||||
if len(prefix) == 0 || !sandbox.ValidCommandPrefix(prefix) {
|
||||
if len(prefix) == 0 || !sandbox.ValidCommandPrefix(prefix) || len(prefix) > len(command) {
|
||||
return false
|
||||
}
|
||||
matched := false
|
||||
for _, command := range segments {
|
||||
if len(prefix) > len(command) {
|
||||
if knownSafeCommandSegment(command) {
|
||||
continue
|
||||
}
|
||||
for index := range prefix {
|
||||
if prefix[index] != command[index] {
|
||||
return false
|
||||
}
|
||||
if hasStringPrefix(command, prefix) {
|
||||
matched = true
|
||||
continue
|
||||
}
|
||||
if knownSafeCommandSegment(command) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return matched
|
||||
return true
|
||||
}
|
||||
|
||||
func safeShellCommandTokens(command string) ([]string, bool) {
|
||||
segments, ok := safeShellCommandSegments(command)
|
||||
if !ok || len(segments) != 1 {
|
||||
return nil, false
|
||||
}
|
||||
return segments[0], true
|
||||
}
|
||||
|
||||
func safeShellCommandSegments(command string) ([][]string, bool) {
|
||||
command = strings.TrimSpace(command)
|
||||
if command == "" {
|
||||
return nil, false
|
||||
}
|
||||
file, err := syntax.NewParser().Parse(strings.NewReader(command), "")
|
||||
if err != nil || len(file.Stmts) == 0 {
|
||||
if err != nil || len(file.Stmts) != 1 {
|
||||
return nil, false
|
||||
}
|
||||
segments := make([][]string, 0, len(file.Stmts))
|
||||
for _, stmt := range file.Stmts {
|
||||
if !collectSafeShellStatement(stmt, &segments) {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
if len(segments) == 0 {
|
||||
stmt := file.Stmts[0]
|
||||
if stmt.Negated || stmt.Background || stmt.Coprocess || stmt.Disown || stmt.Semicolon.IsValid() || len(stmt.Redirs) > 0 {
|
||||
return nil, false
|
||||
}
|
||||
return segments, true
|
||||
}
|
||||
|
||||
func collectSafeShellStatement(stmt *syntax.Stmt, segments *[][]string) bool {
|
||||
if stmt == nil || stmt.Negated || stmt.Background || stmt.Coprocess || stmt.Disown || len(stmt.Redirs) > 0 {
|
||||
return false
|
||||
}
|
||||
return collectSafeShellCommand(stmt.Cmd, segments)
|
||||
}
|
||||
|
||||
func collectSafeShellCommand(cmd syntax.Command, segments *[][]string) bool {
|
||||
switch node := cmd.(type) {
|
||||
case *syntax.CallExpr:
|
||||
tokens, ok := literalCallTokens(node)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
*segments = append(*segments, tokens)
|
||||
return true
|
||||
case *syntax.BinaryCmd:
|
||||
switch node.Op {
|
||||
case syntax.AndStmt, syntax.OrStmt, syntax.Pipe:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return collectSafeShellStatement(node.X, segments) && collectSafeShellStatement(node.Y, segments)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func literalCallTokens(call *syntax.CallExpr) ([]string, bool) {
|
||||
if call == nil || len(call.Assigns) > 0 || len(call.Args) == 0 {
|
||||
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))
|
||||
@@ -263,228 +134,17 @@ func literalCallTokens(call *syntax.CallExpr) ([]string, bool) {
|
||||
return nil, false
|
||||
}
|
||||
lit, ok := word.Parts[0].(*syntax.Lit)
|
||||
if !ok || strings.ContainsAny(lit.Value, "*?[]{}") {
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
tokens = append(tokens, lit.Value)
|
||||
}
|
||||
if !sandbox.ValidCommandPrefix(tokens) {
|
||||
return nil, false
|
||||
}
|
||||
return tokens, true
|
||||
}
|
||||
|
||||
func knownSafeCommandSegment(command []string) bool {
|
||||
if len(command) == 0 {
|
||||
return false
|
||||
}
|
||||
switch commandName(command[0]) {
|
||||
case "cat", "cd", "cut", "echo", "expr", "false", "grep", "head", "id",
|
||||
"ls", "nl", "paste", "pwd", "rev", "seq", "stat", "tail", "tr",
|
||||
"true", "uname", "uniq", "wc", "which", "whoami":
|
||||
return true
|
||||
case "base64":
|
||||
return safeBase64Command(command[1:])
|
||||
case "find":
|
||||
return safeFindCommand(command[1:])
|
||||
case "rg":
|
||||
return safeRipgrepCommand(command[1:])
|
||||
case "sed":
|
||||
return safeSedCommand(command[1:])
|
||||
case "git":
|
||||
return safeGitCommand(command)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func safeBase64Command(args []string) bool {
|
||||
for _, arg := range args {
|
||||
if arg == "-o" || arg == "--output" || strings.HasPrefix(arg, "--output=") || (strings.HasPrefix(arg, "-o") && arg != "-o") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func safeFindCommand(args []string) bool {
|
||||
unsafe := map[string]bool{
|
||||
"-exec": true, "-execdir": true, "-ok": true, "-okdir": true,
|
||||
"-delete": true,
|
||||
"-fls": true, "-fprint": true, "-fprint0": true, "-fprintf": true,
|
||||
}
|
||||
for _, arg := range args {
|
||||
if unsafe[arg] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func safeRipgrepCommand(args []string) bool {
|
||||
for _, arg := range args {
|
||||
if arg == "--search-zip" || arg == "-z" || arg == "--pre" || arg == "--hostname-bin" ||
|
||||
strings.HasPrefix(arg, "--pre=") || strings.HasPrefix(arg, "--hostname-bin=") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func safeSedCommand(args []string) bool {
|
||||
if len(args) < 2 || len(args) > 3 || args[0] != "-n" {
|
||||
return false
|
||||
}
|
||||
return validSedPrintArg(args[1])
|
||||
}
|
||||
|
||||
func validSedPrintArg(arg string) bool {
|
||||
if !strings.HasSuffix(arg, "p") {
|
||||
return false
|
||||
}
|
||||
body := strings.TrimSuffix(arg, "p")
|
||||
if body == "" {
|
||||
return false
|
||||
}
|
||||
for _, part := range strings.Split(body, ",") {
|
||||
if part == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range part {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func safeGitCommand(command []string) bool {
|
||||
subIndex, subcommand, ok := gitSubcommand(command)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if gitHasUnsafeGlobalOption(command[1:subIndex]) {
|
||||
return false
|
||||
}
|
||||
args := command[subIndex+1:]
|
||||
switch subcommand {
|
||||
case "status", "log", "diff", "show":
|
||||
return gitArgsReadOnly(args)
|
||||
case "branch":
|
||||
return gitArgsReadOnly(args) && gitBranchReadOnly(args)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func gitSubcommand(command []string) (int, string, bool) {
|
||||
for index := 1; index < len(command); index++ {
|
||||
arg := command[index]
|
||||
if gitOptionConsumesValue(arg) {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if gitOptionHasInlineValue(arg) || arg == "--" || strings.HasPrefix(arg, "-") {
|
||||
continue
|
||||
}
|
||||
switch arg {
|
||||
case "status", "log", "diff", "show", "branch":
|
||||
return index, arg, true
|
||||
default:
|
||||
return 0, "", false
|
||||
}
|
||||
}
|
||||
return 0, "", false
|
||||
}
|
||||
|
||||
func gitOptionConsumesValue(arg string) bool {
|
||||
switch arg {
|
||||
case "-C", "-c", "--config-env", "--exec-path", "--git-dir", "--namespace", "--super-prefix", "--work-tree":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func gitOptionHasInlineValue(arg string) bool {
|
||||
return strings.HasPrefix(arg, "--config-env=") ||
|
||||
strings.HasPrefix(arg, "--exec-path=") ||
|
||||
strings.HasPrefix(arg, "--git-dir=") ||
|
||||
strings.HasPrefix(arg, "--namespace=") ||
|
||||
strings.HasPrefix(arg, "--super-prefix=") ||
|
||||
strings.HasPrefix(arg, "--work-tree=") ||
|
||||
((strings.HasPrefix(arg, "-C") || strings.HasPrefix(arg, "-c")) && len(arg) > 2)
|
||||
}
|
||||
|
||||
func gitHasUnsafeGlobalOption(args []string) bool {
|
||||
for index := 0; index < len(args); index++ {
|
||||
arg := args[index]
|
||||
switch {
|
||||
case strings.HasPrefix(arg, "--upload-pack"):
|
||||
return true
|
||||
case arg == "-C" || strings.HasPrefix(arg, "-C"):
|
||||
return true
|
||||
case gitOptionConsumesValue(arg):
|
||||
index++
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func gitArgsReadOnly(args []string) bool {
|
||||
for _, arg := range args {
|
||||
switch {
|
||||
case strings.HasPrefix(arg, "--output="), strings.HasPrefix(arg, "--exec="):
|
||||
return false
|
||||
case arg == "--output", arg == "--exec":
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func gitBranchReadOnly(args []string) bool {
|
||||
if len(args) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, arg := range args {
|
||||
if strings.HasPrefix(arg, "--format=") {
|
||||
continue
|
||||
}
|
||||
switch arg {
|
||||
case "--list", "-l", "--show-current", "-a", "--all", "-r", "--remotes", "-v", "-vv", "--verbose":
|
||||
continue
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func commandName(raw string) string {
|
||||
name := strings.TrimSpace(raw)
|
||||
if index := strings.LastIndexAny(name, `/\`); index >= 0 {
|
||||
name = name[index+1:]
|
||||
}
|
||||
name = strings.ToLower(name)
|
||||
for _, suffix := range []string{".exe", ".cmd", ".bat", ".com"} {
|
||||
if strings.HasSuffix(name, suffix) {
|
||||
return strings.TrimSuffix(name, suffix)
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func hasStringPrefix(values []string, prefix []string) bool {
|
||||
if len(prefix) == 0 || len(prefix) > len(values) {
|
||||
return false
|
||||
}
|
||||
for index := range prefix {
|
||||
if values[index] != prefix[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func equalStringSlices(left []string, right []string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{"command": "go test ./..."})
|
||||
@@ -14,14 +10,6 @@ func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -33,35 +21,6 @@ func TestProposedCommandPrefixHonorsValidatedRequestedPrefix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixSupportsSegmentedCommands(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"})
|
||||
want := []string{"ps", "aux"}
|
||||
if !equalStringSlices(got, want) {
|
||||
t.Fatalf("prefix = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixHonorsRequestedPrefixAcrossSegments(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{
|
||||
"command": "git status --short && git status --branch",
|
||||
"prefix_rule": []any{"git", "status"},
|
||||
})
|
||||
want := []string{"git", "status"}
|
||||
if !equalStringSlices(got, want) {
|
||||
t.Fatalf("prefix = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixRejectsRequestedPrefixThatDoesNotCoverSegments(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{
|
||||
"command": "ps aux && npm install",
|
||||
"prefix_rule": []any{"ps", "aux"},
|
||||
})
|
||||
if got != nil {
|
||||
t.Fatalf("partial requested prefix should be rejected, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixRejectsUnsafeRequestedPrefix(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{
|
||||
"command": "git status --short",
|
||||
@@ -74,6 +33,7 @@ func TestProposedCommandPrefixRejectsUnsafeRequestedPrefix(t *testing.T) {
|
||||
|
||||
func TestProposedCommandPrefixRejectsUnsafeShellForms(t *testing.T) {
|
||||
cases := []string{
|
||||
"echo hi && echo bye",
|
||||
"cat < in > out",
|
||||
"FOO=bar go test",
|
||||
"echo $(whoami)",
|
||||
@@ -117,25 +77,6 @@ func TestProposedCommandPrefixRejectsUnsafeLaunchers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchCommandPrefixCoversSegmentedCommandWithSafeTail(t *testing.T) {
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
|
||||
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
|
||||
|
||||
grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"}, Options{Sandbox: engine})
|
||||
if !ok || !session || !equalStringSlices(grant.Prefix, []string{"ps", "aux"}) {
|
||||
t.Fatalf("match = %#v ok=%v session=%v, want session ps aux prefix", grant, ok, session)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchCommandPrefixRejectsUncoveredSegment(t *testing.T) {
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
|
||||
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
|
||||
|
||||
if grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": "ps aux && npm install"}, Options{Sandbox: engine}); ok {
|
||||
t.Fatalf("match = %#v session=%v, want no match because npm segment is uncovered", grant, session)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixRejectsRequestedUnsafeLauncherPrefix(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{
|
||||
"command": "find . -type f",
|
||||
|
||||
@@ -21,17 +21,12 @@ import (
|
||||
// the agent loop owns the provider wiring.
|
||||
|
||||
// defaultCompactionPreserveLast is how many trailing messages are kept verbatim
|
||||
// when the caller does not specify a preserve count. Kept in sync with the replay
|
||||
// reconstruction default in internal/sessions/replay.go.
|
||||
const defaultCompactionPreserveLast = 6
|
||||
// when the caller does not specify a preserve count.
|
||||
const defaultCompactionPreserveLast = 8
|
||||
|
||||
// compactionTriggerRatio is the fraction of the context window at which
|
||||
// proactive compaction kicks in (top of each turn). NOTE: this is speculative
|
||||
// tuning — validate against the per-turn latency/compaction metrics (roadmap 5.2)
|
||||
// before trusting it. Measured fixed overhead is ~7.4k tokens (not the ~16k the
|
||||
// roadmap assumed), so context fills slower; if data shows 0.7 triggers redundant
|
||||
// summarization, raise it back toward 0.8.
|
||||
const compactionTriggerRatio = 0.7
|
||||
// proactive compaction kicks in (top of each turn).
|
||||
const compactionTriggerRatio = 0.8
|
||||
|
||||
// summaryLabel prefixes the injected summary so it is unmistakable in the
|
||||
// transcript (and so tests can assert on it).
|
||||
@@ -285,25 +280,6 @@ func isContextLimitError(message string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// isStreamTimeoutError reports whether a streamed error is a provider stream
|
||||
// idle/stall timeout (providerio surfaces these as "idle timeout after …" /
|
||||
// "no output for …" / "stream stalled"). Such a turn produced nothing, so when
|
||||
// no content was forwarded yet it can be safely re-issued on a fresh connection
|
||||
// (see the stall-retry in the agent loop). Substring + case-insensitive to
|
||||
// tolerate the prefixed "provider stream error: …" wrapping.
|
||||
func isStreamTimeoutError(message string) bool {
|
||||
lowered := strings.ToLower(strings.TrimSpace(message))
|
||||
if lowered == "" {
|
||||
return false
|
||||
}
|
||||
for _, needle := range []string{"idle timeout after", "no output for", "stream stalled"} {
|
||||
if strings.Contains(lowered, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// compactionState carries the per-run state the agent loop needs to compact a
|
||||
// conversation safely. It is created once per Run and is a no-op whenever
|
||||
// options.ContextWindow <= 0.
|
||||
@@ -364,19 +340,6 @@ 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),
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// someRequestContains reports whether any message sent to the provider across all
|
||||
// turns contains substr — used to assert a continue nudge was actually injected.
|
||||
func someRequestContains(requests []zeroruntime.CompletionRequest, substr string) bool {
|
||||
for _, req := range requests {
|
||||
for _, msg := range req.Messages {
|
||||
if strings.Contains(msg.Content, substr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// planTurn is a turn that calls update_plan with the given item statuses (reusing
|
||||
// the package's shared toolTurn helper).
|
||||
func planTurn(statuses ...string) []zeroruntime.StreamEvent {
|
||||
items := make([]string, len(statuses))
|
||||
for i, s := range statuses {
|
||||
items[i] = `{"content":"step ` + s + `","status":"` + s + `"}`
|
||||
}
|
||||
return toolTurn("plan", "update_plan", `{"plan":[`+strings.Join(items, ",")+`]}`)
|
||||
}
|
||||
|
||||
// BUG #1 regression: a no-tool-call turn that ends mid-step while plan items are
|
||||
// still pending must NOT be accepted as success. The loop must re-prompt (bounded)
|
||||
// and, if the model keeps stalling, finalize as INCOMPLETE — never success.
|
||||
func TestCompletionGatePendingPlanContinuesThenIncomplete(t *testing.T) {
|
||||
// Mirrors the git-multibranch failure: plan with pending steps, then the model
|
||||
// keeps emitting "…Let me check the SSH configuration:" without acting.
|
||||
cue := "Now I need to configure the SSH server. Let me check the current SSH configuration:"
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
planTurn("completed", "pending", "pending"),
|
||||
textTurn(cue), textTurn(cue), textTurn(cue), textTurn(cue), textTurn(cue),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "set up a git server", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Incomplete {
|
||||
t.Fatalf("expected Incomplete=true (model stalled with pending plan), got false; final=%q turns=%d", result.FinalAnswer, result.Turns)
|
||||
}
|
||||
// 1 plan turn + maxContinueNudges(3) nudged turns + 1 final stalling turn = 5.
|
||||
// Critically it did NOT stop at the first text turn (request 2) as success.
|
||||
if len(provider.requests) != 1+maxContinueNudges+1 {
|
||||
t.Fatalf("expected %d provider turns (1 plan + %d nudges + 1 final), got %d",
|
||||
1+maxContinueNudges+1, maxContinueNudges, len(provider.requests))
|
||||
}
|
||||
if !someRequestContains(provider.requests, continueNudgeMarker) {
|
||||
t.Fatalf("expected a continue nudge (%q) to be injected into the conversation", continueNudgeMarker)
|
||||
}
|
||||
}
|
||||
|
||||
// A genuinely-complete single-turn answer (no plan, no continuation cue) must
|
||||
// still finalize as success — the gate must not break short/read-only tasks.
|
||||
func TestCompletionGateAcceptsGenuineCompletion(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn("The file contains 42 lines."),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "count the lines", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("genuine completion wrongly marked Incomplete; final=%q", result.FinalAnswer)
|
||||
}
|
||||
if result.FinalAnswer != "The file contains 42 lines." {
|
||||
t.Fatalf("final answer = %q, want the completed answer", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("expected exactly 1 turn (no spurious re-prompt), got %d", len(provider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
// A continuation-cue turn triggers a re-prompt, but once the model actually
|
||||
// finishes (clean answer, no cue, no pending plan) the run exits as success — the
|
||||
// nudge gives the model a path to a legitimate completion.
|
||||
func TestCompletionGateContinuesOnCueThenSucceeds(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn("Let me read the file:"),
|
||||
textTurn("Done. The file has 42 lines."),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "count the lines", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("run wrongly marked Incomplete after the model completed; final=%q", result.FinalAnswer)
|
||||
}
|
||||
if result.FinalAnswer != "Done. The file has 42 lines." {
|
||||
t.Fatalf("final answer = %q, want the completed answer", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected 2 turns (1 cue re-prompted + 1 completion), got %d", len(provider.requests))
|
||||
}
|
||||
if !someRequestContains(provider.requests, continueNudgeMarker) {
|
||||
t.Fatalf("expected a continue nudge after the continuation-cue turn")
|
||||
}
|
||||
}
|
||||
|
||||
// With the gate OFF (the interactive/TUI default), a continuation-cue turn is
|
||||
// accepted as the final answer exactly as before — guaranteeing no behavior
|
||||
// change for non-headless callers.
|
||||
func TestCompletionGateOffPreservesLegacyBehavior(t *testing.T) {
|
||||
cue := "Let me check the config:"
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
textTurn(cue),
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "do a thing", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
// RequireCompletionSignal deliberately left false.
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("legacy path must never set Incomplete; final=%q", result.FinalAnswer)
|
||||
}
|
||||
if result.FinalAnswer != cue {
|
||||
t.Fatalf("final answer = %q, want %q (legacy: text-only turn is the answer)", result.FinalAnswer, cue)
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("legacy path must not re-prompt; got %d turns", len(provider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
// review #6: the continuation-cue detector must catch a mid-line action announcement
|
||||
// that stops on a colon (the git-multibranch case), without flagging genuine closers
|
||||
// — a recommendation, a plain summary colon, or a sign-off.
|
||||
func TestContinuationCueMatching(t *testing.T) {
|
||||
cases := []struct {
|
||||
text string
|
||||
cue bool
|
||||
}{
|
||||
{"Now I need to configure the SSH server. Let me check the current SSH configuration:", true},
|
||||
{"Let me read the file:", true},
|
||||
{"Now I'll run the tests:", true},
|
||||
{"Next, I suggest reviewing the changes.", false}, // recommendation, no colon
|
||||
{"Here is the summary:", false}, // summary colon, no action lead-in
|
||||
{"Let me know if you need anything:", false}, // sign-off
|
||||
{"The function is implemented and all tests pass.", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := endsWithContinuationCue(c.text); got != c.cue {
|
||||
t.Errorf("endsWithContinuationCue(%q) = %v, want %v", c.text, got, c.cue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// review #4: a run that loops to the MaxTurns ceiling (always calling a tool, so it
|
||||
// never reaches the no-tool-call gate) was reported as success. Under the headless
|
||||
// gate, a max-turns cutoff is INCOMPLETE — the agent was stopped mid-run, not done.
|
||||
func TestMaxTurnsCutoffIsIncompleteUnderGate(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
toolEvery := toolTurn("c", "update_plan", `{"plan":[{"content":"step","status":"in_progress"}]}`)
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
toolEvery, toolEvery, toolEvery, toolEvery,
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "keep working", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 2,
|
||||
RequireCompletionSignal: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Incomplete {
|
||||
t.Fatalf("a max-turns cutoff under the gate must be Incomplete; final=%q", result.FinalAnswer)
|
||||
}
|
||||
if !strings.Contains(result.IncompleteReason, "max-turns") {
|
||||
t.Fatalf("IncompleteReason = %q, want it to cite max-turns", result.IncompleteReason)
|
||||
}
|
||||
}
|
||||
@@ -81,11 +81,3 @@ Otherwise, confirm before executing.
|
||||
6. Avoid redundant confirmations if you already confirmed and there is no material new risk.
|
||||
7. Write/shell operations inside the workspace do not need the same scrutiny as operations outside it.
|
||||
8. Interactive programs (editors, pagers, REPLs, `ssh`/`psql`/`mysql` without a command, `top`/`htop`, `git rebase -i`) will be blocked because they hang the agent; always use the non-interactive alternative.
|
||||
|
||||
## Sandbox Permission Requests
|
||||
|
||||
- Prefer sandboxed additional permissions over full escalation: use `sandbox_permissions: "with_additional_permissions"` with the required file-system or network permission when that can satisfy the command.
|
||||
- Use `sandbox_permissions: "require_escalated"` only when the command must run outside the sandbox, such as inspecting or controlling host/global state that the sandbox intentionally hides.
|
||||
- If a sandboxed shell command returns only sandbox-local state when the user asked for host/global state, do not answer from that misleading output. Rerun the needed command with `sandbox_permissions: "require_escalated"`, a short user-facing `justification`, and a narrow `prefix_rule` when appropriate.
|
||||
- For `require_escalated`, include a short user-facing `justification`. When a reusable approval is appropriate, include a narrow `prefix_rule`; do not suggest broad shell, interpreter, or launcher prefixes.
|
||||
- Shell commands are evaluated as independent command segments across `|`, `&&`, `||`, and `;`. A reusable prefix must apply safely to the command segment it is meant to authorize.
|
||||
@@ -1,99 +0,0 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
// decodeToolArguments must (a) decode a normal single-object payload unchanged,
|
||||
// (b) tolerate a weak model packing multiple concatenated top-level JSON objects
|
||||
// into one arguments string by decoding the FIRST object and ignoring the rest
|
||||
// (the minimax-m3 "invalid character '{' after top-level value" failure), and
|
||||
// (c) still reject a genuinely malformed/truncated first object.
|
||||
func TestDecodeToolArguments(t *testing.T) {
|
||||
t.Run("single object decodes normally", func(t *testing.T) {
|
||||
var a map[string]any
|
||||
if err := decodeToolArguments(`{"path":"x"}`, &a); err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if a["path"] != "x" {
|
||||
t.Fatalf("decoded %v, want path=x", a)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("concatenated objects decode the first, ignore trailing", func(t *testing.T) {
|
||||
var b map[string]any
|
||||
if err := decodeToolArguments(`{"path":"first"}{"path":"second"}`, &b); err != nil {
|
||||
t.Fatalf("multi-object should decode the first, got err = %v", err)
|
||||
}
|
||||
if b["path"] != "first" {
|
||||
t.Fatalf("decoded %v, want the first object (path=first)", b)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("distinct tools' args concatenated (real minimax case): first object used", func(t *testing.T) {
|
||||
var c map[string]any
|
||||
if err := decodeToolArguments(`{"plan":[1,2]}{"cmd":"go version"}`, &c); err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if _, ok := c["plan"]; !ok || c["cmd"] != nil {
|
||||
t.Fatalf("decoded %v, want only the first object's keys", c)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("truncated first object still errors", func(t *testing.T) {
|
||||
var d map[string]any
|
||||
if err := decodeToolArguments(`{"path":`, &d); err == nil {
|
||||
t.Fatal("truncated JSON must still error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-JSON trailing garbage still errors (not masked)", func(t *testing.T) {
|
||||
var g map[string]any
|
||||
if err := decodeToolArguments(`{"x":1}xyz`, &g); err == nil {
|
||||
t.Fatal("a valid first object followed by non-JSON garbage must still error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("recoverableToolArguments: only whole-JSON trailing is tolerated", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
ok bool
|
||||
first string
|
||||
}{
|
||||
{`{"a":1}`, true, `{"a":1}`},
|
||||
{`{"a":1}{"b":2}`, true, `{"a":1}`},
|
||||
{`{"a":1} {"b":2} `, true, `{"a":1}`},
|
||||
{`{"a":1}xyz`, false, ""},
|
||||
{`{"a":`, false, ""},
|
||||
{` `, false, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
first, ok := recoverableToolArguments(c.in)
|
||||
if ok != c.ok || (ok && first != c.first) {
|
||||
t.Fatalf("recoverableToolArguments(%q) = (%q,%v), want (%q,%v)", c.in, first, ok, c.first, c.ok)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("historySafeToolCalls recovers the first object, not {}", func(t *testing.T) {
|
||||
out := historySafeToolCalls([]ToolCall{
|
||||
{ID: "c1", Name: "read_file", Arguments: `{"path":"README.md"}{"path":"AGENTS.md"}`}, // concatenated -> first
|
||||
{ID: "c2", Name: "read_file", Arguments: `{"path":"x"}xyz`}, // garbage -> {}
|
||||
{ID: "c3", Name: "read_file", Arguments: `{"path":"ok"}`}, // valid -> unchanged
|
||||
})
|
||||
if out[0].Arguments != `{"path":"README.md"}` {
|
||||
t.Fatalf("concatenated args should record the first object, got %q", out[0].Arguments)
|
||||
}
|
||||
if out[1].Arguments != "{}" {
|
||||
t.Fatalf("garbage-trailing args should record {}, got %q", out[1].Arguments)
|
||||
}
|
||||
if out[2].Arguments != `{"path":"ok"}` {
|
||||
t.Fatalf("valid args should be unchanged, got %q", out[2].Arguments)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("whitespace-only errors (caller guards empty)", func(t *testing.T) {
|
||||
var e map[string]any
|
||||
if err := decodeToolArguments(" ", &e); err == nil {
|
||||
t.Fatal("whitespace-only must error")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -129,24 +129,6 @@ func (fakeToolSearchTool) Run(_ context.Context, _ map[string]any) tools.Result
|
||||
return tools.Result{Status: tools.StatusOK, Output: "ok"}
|
||||
}
|
||||
|
||||
func toolDefinitionByName(definitions []zeroruntime.ToolDefinition, name string) *zeroruntime.ToolDefinition {
|
||||
for i := range definitions {
|
||||
if definitions[i].Name == name {
|
||||
return &definitions[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func assertNoDeferredDiscoveryMessage(t *testing.T, request zeroruntime.CompletionRequest) {
|
||||
t.Helper()
|
||||
for _, message := range request.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleUser && strings.Contains(message.Content, "Deferred tools:") {
|
||||
t.Fatalf("deferred discovery must not be appended as a user message: %q", message.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartitionToolsInactiveIsByteIdenticalAndDropsToolSearch(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
@@ -237,8 +219,8 @@ func TestPartitionToolsBelowThresholdInactive(t *testing.T) {
|
||||
// FIX 6(a): a deferred tool removed by DisabledTools must NOT count toward the
|
||||
// eligible total. With N deferred registered and threshold == N, disabling one
|
||||
// drops the surviving eligible count to N-1 < threshold, so the partition takes
|
||||
// the INACTIVE path (empty discovery text, all VISIBLE tools exposed with full
|
||||
// schemas, the disabled one filtered out entirely).
|
||||
// the INACTIVE path (empty reminder, all VISIBLE tools exposed with full schemas,
|
||||
// the disabled one filtered out entirely).
|
||||
func TestPartitionToolsDisabledDeferredDropsBelowThresholdInactive(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
||||
@@ -264,9 +246,9 @@ func TestPartitionToolsDisabledDeferredDropsBelowThresholdInactive(t *testing.T)
|
||||
}
|
||||
|
||||
// FIX 6(b): with deferral ACTIVE, a DisabledTools-hidden deferred tool must never
|
||||
// influence discovery NOR be exposed — it is filtered out before the partition
|
||||
// appear in the reminder NOR be exposed — it is filtered out before the partition
|
||||
// even considers it.
|
||||
func TestPartitionToolsActiveExcludesDisabledDeferredFromDiscoveryAndExposed(t *testing.T) {
|
||||
func TestPartitionToolsActiveExcludesDisabledDeferredFromReminderAndExposed(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
|
||||
@@ -274,31 +256,25 @@ func TestPartitionToolsActiveExcludesDisabledDeferredFromDiscoveryAndExposed(t *
|
||||
registry.Register(fakeToolSearchTool{}) // usable loader => deferral can activate
|
||||
|
||||
// 3 deferred, disable beta => 2 surviving eligible, threshold 2 => active.
|
||||
exposed, discovery := partitionTools(registry, PermissionModeAuto, Options{
|
||||
exposed, reminder := partitionTools(registry, PermissionModeAuto, Options{
|
||||
DeferThreshold: 2,
|
||||
DisabledTools: []string{"mcp__srv__beta"},
|
||||
}, map[string]bool{})
|
||||
|
||||
if discovery == "" {
|
||||
t.Fatalf("expected active path with discovery text for the unloaded tools")
|
||||
if reminder == "" {
|
||||
t.Fatalf("expected active path with a non-empty reminder for the unloaded tools")
|
||||
}
|
||||
if strings.Contains(discovery, "mcp__srv__beta") {
|
||||
t.Fatalf("disabled deferred tool must not appear in discovery text, got %q", discovery)
|
||||
if strings.Contains(reminder, "mcp__srv__beta") {
|
||||
t.Fatalf("disabled deferred tool must not appear in the reminder, got %q", reminder)
|
||||
}
|
||||
for _, def := range exposed {
|
||||
if def.Name == "mcp__srv__beta" {
|
||||
t.Fatalf("disabled deferred tool must not be exposed, got %#v", exposed)
|
||||
}
|
||||
}
|
||||
search := toolDefinitionByName(exposed, tools.ToolSearchToolName)
|
||||
if search == nil {
|
||||
t.Fatalf("tool_search must be exposed on active path, got %#v", exposed)
|
||||
}
|
||||
if search.Description != discovery {
|
||||
t.Fatalf("tool_search description must carry discovery text\n got: %q\nwant: %q", search.Description, discovery)
|
||||
}
|
||||
if !strings.Contains(discovery, "mcp") {
|
||||
t.Fatalf("discovery text must list the surviving deferred source, got %q", discovery)
|
||||
// The two surviving deferred tools are hidden (unloaded) and named in the reminder.
|
||||
if !strings.Contains(reminder, "mcp__srv__alpha") || !strings.Contains(reminder, "mcp__srv__gamma") {
|
||||
t.Fatalf("reminder must list the surviving unloaded deferred tools, got %q", reminder)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,7 +289,7 @@ func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
|
||||
loaded := map[string]bool{"mcp__srv__alpha": true}
|
||||
|
||||
// 2 eligible deferred tools, threshold 2 => active.
|
||||
exposed, discovery := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 2}, loaded)
|
||||
exposed, reminder := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 2}, loaded)
|
||||
|
||||
exposedNames := map[string]bool{}
|
||||
for _, def := range exposed {
|
||||
@@ -331,21 +307,14 @@ func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
|
||||
if exposedNames["mcp__srv__beta"] {
|
||||
t.Fatalf("unloaded deferred tool must be hidden from exposed, got %#v", exposed)
|
||||
}
|
||||
if discovery == "" {
|
||||
t.Fatalf("expected non-empty discovery text for the hidden tool")
|
||||
if reminder == "" {
|
||||
t.Fatalf("expected a non-empty reminder for the hidden tool")
|
||||
}
|
||||
search := toolDefinitionByName(exposed, tools.ToolSearchToolName)
|
||||
if search == nil {
|
||||
t.Fatalf("expected tool_search exposed on active path, got %#v", exposed)
|
||||
if !strings.Contains(reminder, "mcp__srv__beta") {
|
||||
t.Fatalf("expected reminder to list the hidden tool, got %q", reminder)
|
||||
}
|
||||
if search.Description != discovery || !strings.Contains(search.Description, "mcp") {
|
||||
t.Fatalf("tool_search description must carry discovery source, got %q", search.Description)
|
||||
}
|
||||
if strings.Contains(search.Description, "mcp__srv__alpha") {
|
||||
t.Fatalf("tool_search description must not list already-loaded tool names, got %q", search.Description)
|
||||
}
|
||||
if !strings.Contains(search.Description, "mcp__srv__beta") {
|
||||
t.Fatalf("tool_search description must list exact hidden tool names, got %q", search.Description)
|
||||
if strings.Contains(reminder, "mcp__srv__alpha") {
|
||||
t.Fatalf("loaded tool must not appear in the reminder, got %q", reminder)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,9 +337,9 @@ func TestPartitionToolsActiveNothingHiddenEmptyReminder(t *testing.T) {
|
||||
if !exposedNames["tool_search"] {
|
||||
t.Fatalf("expected tool_search exposed on active path, got %#v", exposed)
|
||||
}
|
||||
// No hidden tools means no dynamic discovery text is needed.
|
||||
// BuildDeferredReminder returns "" for no hidden lines.
|
||||
if reminder != "" {
|
||||
t.Fatalf("expected empty discovery text when nothing is hidden, got %q", reminder)
|
||||
t.Fatalf("expected empty reminder when nothing is hidden, got %q", reminder)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,8 +376,7 @@ func TestRunLoadsDeferredToolThenAdvertisesNextTurn(t *testing.T) {
|
||||
t.Fatalf("expected two turns, got %d", len(provider.requests))
|
||||
}
|
||||
|
||||
// Turn 1: alpha is hidden (not advertised), and discovery lives on the
|
||||
// tool_search definition rather than a trailing user message.
|
||||
// Turn 1: alpha is hidden (not advertised) and the reminder is a trailing user msg.
|
||||
turn1Tools := map[string]bool{}
|
||||
for _, def := range provider.requests[0].Tools {
|
||||
turn1Tools[def.Name] = true
|
||||
@@ -416,13 +384,9 @@ func TestRunLoadsDeferredToolThenAdvertisesNextTurn(t *testing.T) {
|
||||
if turn1Tools["mcp__srv__alpha"] {
|
||||
t.Fatalf("turn 1 must not advertise the not-yet-loaded deferred tool")
|
||||
}
|
||||
assertNoDeferredDiscoveryMessage(t, provider.requests[0])
|
||||
search1 := toolDefinitionByName(provider.requests[0].Tools, tools.ToolSearchToolName)
|
||||
if search1 == nil {
|
||||
t.Fatalf("turn 1 must expose tool_search, got %#v", provider.requests[0].Tools)
|
||||
}
|
||||
if !strings.Contains(search1.Description, "Tool discovery") || !strings.Contains(search1.Description, "mcp") {
|
||||
t.Fatalf("tool_search description must carry deferred discovery, got %q", search1.Description)
|
||||
last1 := provider.requests[0].Messages[len(provider.requests[0].Messages)-1]
|
||||
if last1.Role != zeroruntime.MessageRoleUser || !strings.Contains(last1.Content, "tool_search") {
|
||||
t.Fatalf("turn 1 request must end with the deferred-tools reminder, got role=%s content=%q", last1.Role, last1.Content)
|
||||
}
|
||||
|
||||
// Turn 2: alpha is now loaded and advertised with a full schema.
|
||||
@@ -437,20 +401,20 @@ func TestRunLoadsDeferredToolThenAdvertisesNextTurn(t *testing.T) {
|
||||
t.Fatalf("beta was never loaded; it must stay hidden in turn 2")
|
||||
}
|
||||
|
||||
// The discovery text must NOT persist into the returned message history.
|
||||
// The reminder must NOT persist into the returned message history.
|
||||
for _, m := range result.Messages {
|
||||
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Tool discovery") {
|
||||
t.Fatalf("deferred discovery must not be persisted in result.Messages")
|
||||
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Call tool_search") {
|
||||
t.Fatalf("the deferred-tools reminder must not be persisted in result.Messages")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunReactiveRetryKeepsLoadedDeferredToolAndDiscovery drives a mid-run
|
||||
// TestRunReactiveRetryKeepsLoadedDeferredToolAndReminder drives a mid-run
|
||||
// context-limit error that triggers reactive compaction+retry while deferral is
|
||||
// ACTIVE and a deferred tool is already loaded. The retried turn must NOT be
|
||||
// degraded to the empty-loaded/no-discovery state: it must still advertise the
|
||||
// loaded tool's FULL schema and keep tool_search discovery for hidden tools.
|
||||
func TestRunReactiveRetryKeepsLoadedDeferredToolAndDiscovery(t *testing.T) {
|
||||
// degraded to the empty-loaded/no-reminder state: it must still advertise the
|
||||
// loaded tool's FULL schema and carry the <system-reminder> for the hidden tool.
|
||||
func TestRunReactiveRetryKeepsLoadedDeferredToolAndReminder(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
// load_signal asks the loop to load mcp__srv__alpha next turn.
|
||||
registry.Register(loadSignalTool{value: "mcp__srv__alpha"})
|
||||
@@ -528,20 +492,24 @@ func TestRunReactiveRetryKeepsLoadedDeferredToolAndDiscovery(t *testing.T) {
|
||||
t.Fatalf("retry must advertise the loaded tool's FULL schema, got %#v", alpha.Parameters)
|
||||
}
|
||||
|
||||
assertNoDeferredDiscoveryMessage(t, retry)
|
||||
search := toolDefinitionByName(retry.Tools, tools.ToolSearchToolName)
|
||||
if search == nil {
|
||||
t.Fatalf("retry must expose tool_search for still-hidden tools, got %#v", retry.Tools)
|
||||
// The retry must carry the deferred-tools reminder as its trailing user
|
||||
// message (the hidden tool is mcp__srv__beta) — not the no-reminder state.
|
||||
last := retry.Messages[len(retry.Messages)-1]
|
||||
if last.Role != zeroruntime.MessageRoleUser || !strings.Contains(last.Content, "tool_search") {
|
||||
t.Fatalf("retry request must end with the deferred-tools reminder, got role=%s content=%q", last.Role, last.Content)
|
||||
}
|
||||
if !strings.Contains(search.Description, "Tool discovery") || !strings.Contains(search.Description, "mcp") {
|
||||
t.Fatalf("retry tool_search description must carry deferred discovery, got %q", search.Description)
|
||||
if !strings.Contains(last.Content, "mcp__srv__beta") {
|
||||
t.Fatalf("retry reminder must list the still-hidden tool mcp__srv__beta, got %q", last.Content)
|
||||
}
|
||||
if strings.Contains(last.Content, "mcp__srv__alpha") {
|
||||
t.Fatalf("loaded tool must not appear in the retry reminder, got %q", last.Content)
|
||||
}
|
||||
|
||||
// The discovery text must NOT persist into the returned message history, even on
|
||||
// The reminder must NOT persist into the returned message history, even on
|
||||
// the reactive-retry path.
|
||||
for _, m := range result.Messages {
|
||||
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Tool discovery") {
|
||||
t.Fatalf("deferred discovery must not be persisted in result.Messages")
|
||||
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Call tool_search") {
|
||||
t.Fatalf("the deferred-tools reminder must not be persisted in result.Messages")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -576,15 +544,15 @@ func (provider *connectErrorProvider) StreamCompletion(_ context.Context, reques
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndDiscovery mirrors
|
||||
// TestRunReactiveRetryKeepsLoadedDeferredToolAndDiscovery but drives the
|
||||
// TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndReminder mirrors
|
||||
// TestRunReactiveRetryKeepsLoadedDeferredToolAndReminder but drives the
|
||||
// CONNECT-TIME error path: StreamCompletion itself returns a non-nil
|
||||
// context-limit error (rather than a mid-stream StreamEventError). With deferral
|
||||
// ACTIVE and a deferred tool already loaded, the rebuilt retry request must still
|
||||
// advertise the loaded tool's FULL schema AND carry tool_search discovery —
|
||||
// i.e. the first reactive block reuses exposed, not the empty-loaded
|
||||
// advertise the loaded tool's FULL schema AND carry the deferred-tools reminder —
|
||||
// i.e. the first reactive block reuses exposed/reminder, not the empty-loaded
|
||||
// partition.
|
||||
func TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndDiscovery(t *testing.T) {
|
||||
func TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndReminder(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(loadSignalTool{value: "mcp__srv__alpha"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha tool"})
|
||||
@@ -657,19 +625,22 @@ func TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndDiscovery(t *testi
|
||||
t.Fatalf("retry must advertise the loaded tool's FULL schema, got %#v", alpha.Parameters)
|
||||
}
|
||||
|
||||
assertNoDeferredDiscoveryMessage(t, retry)
|
||||
search := toolDefinitionByName(retry.Tools, tools.ToolSearchToolName)
|
||||
if search == nil {
|
||||
t.Fatalf("retry must expose tool_search for still-hidden tools, got %#v", retry.Tools)
|
||||
// The retry must carry the deferred-tools reminder as its trailing user message.
|
||||
last := retry.Messages[len(retry.Messages)-1]
|
||||
if last.Role != zeroruntime.MessageRoleUser || !strings.Contains(last.Content, "tool_search") {
|
||||
t.Fatalf("retry request must end with the deferred-tools reminder, got role=%s content=%q", last.Role, last.Content)
|
||||
}
|
||||
if !strings.Contains(search.Description, "Tool discovery") || !strings.Contains(search.Description, "mcp") {
|
||||
t.Fatalf("retry tool_search description must carry deferred discovery, got %q", search.Description)
|
||||
if !strings.Contains(last.Content, "mcp__srv__beta") {
|
||||
t.Fatalf("retry reminder must list the still-hidden tool mcp__srv__beta, got %q", last.Content)
|
||||
}
|
||||
if strings.Contains(last.Content, "mcp__srv__alpha") {
|
||||
t.Fatalf("loaded tool must not appear in the retry reminder, got %q", last.Content)
|
||||
}
|
||||
|
||||
// The discovery text must NOT persist into the returned message history.
|
||||
// The reminder must NOT persist into the returned message history.
|
||||
for _, m := range result.Messages {
|
||||
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Tool discovery") {
|
||||
t.Fatalf("deferred discovery must not be persisted in result.Messages")
|
||||
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Call tool_search") {
|
||||
t.Fatalf("the deferred-tools reminder must not be persisted in result.Messages")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -678,7 +649,8 @@ func TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndDiscovery(t *testi
|
||||
// regression: N deferred tools are registered alongside tool_search, the operator
|
||||
// allowlists ONLY the N deferred names (NOT tool_search), and DeferThreshold == N.
|
||||
// Deferral must ACTIVATE, the partition must STILL expose tool_search (the gateway
|
||||
// to the allowlisted tools), and a tool_search call must be DISPATCH-ALLOWED.
|
||||
// to the allowlisted tools), and a tool_search call must be DISPATCH-ALLOWED — so
|
||||
// the reminder never points the model at an unreachable tool.
|
||||
func TestAllowlistedDeferredToolsKeepToolSearchReachable(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
deferredNames := []string{"mcp__srv__alpha", "mcp__srv__beta"}
|
||||
@@ -695,14 +667,14 @@ func TestAllowlistedDeferredToolsKeepToolSearchReachable(t *testing.T) {
|
||||
EnabledTools: append([]string{}, deferredNames...),
|
||||
}
|
||||
|
||||
exposed, discovery := partitionTools(registry, PermissionModeAuto, options, map[string]bool{})
|
||||
exposed, reminder := partitionTools(registry, PermissionModeAuto, options, map[string]bool{})
|
||||
|
||||
// Deferral active => non-empty tool_search discovery for hidden deferred tools.
|
||||
if discovery == "" {
|
||||
t.Fatalf("expected deferral active (non-empty discovery) at threshold N, got empty")
|
||||
// Deferral active => non-empty reminder advertising the hidden deferred tools.
|
||||
if reminder == "" {
|
||||
t.Fatalf("expected deferral active (non-empty reminder) at threshold N, got empty")
|
||||
}
|
||||
if !strings.Contains(discovery, "tool_search") {
|
||||
t.Fatalf("discovery must instruct the model to call tool_search, got %q", discovery)
|
||||
if !strings.Contains(reminder, "tool_search") {
|
||||
t.Fatalf("reminder must instruct the model to call tool_search, got %q", reminder)
|
||||
}
|
||||
|
||||
// FIX 2(a): tool_search is exposed even though the allowlist omits it.
|
||||
@@ -713,10 +685,6 @@ func TestAllowlistedDeferredToolsKeepToolSearchReachable(t *testing.T) {
|
||||
if !exposedNames["tool_search"] {
|
||||
t.Fatalf("tool_search must be exposed on the active path despite the allowlist omitting it, got %#v", exposed)
|
||||
}
|
||||
search := toolDefinitionByName(exposed, tools.ToolSearchToolName)
|
||||
if search == nil || search.Description != discovery {
|
||||
t.Fatalf("tool_search description must carry discovery text, got %#v discovery=%q", search, discovery)
|
||||
}
|
||||
// The allowlisted-but-unloaded deferred tools are hidden (not exposed).
|
||||
for _, name := range deferredNames {
|
||||
if exposedNames[name] {
|
||||
|
||||
@@ -30,12 +30,12 @@ func TestDeniedPermissionResultCategories(t *testing.T) {
|
||||
if plain.DenialReason != DenialPermissionDenied {
|
||||
t.Fatalf("plain denial = %q, want %q", plain.DenialReason, DenialPermissionDenied)
|
||||
}
|
||||
// Sandbox-block-driven denial.
|
||||
// Sandbox-violation-driven denial.
|
||||
sandboxDenied := deniedPermissionResult(ToolCall{ID: "c2", Name: "bash"}, "outside workspace", PermissionEvent{
|
||||
ToolName: "bash",
|
||||
Block: &sandbox.Block{Code: "outside_workspace"},
|
||||
ToolName: "bash",
|
||||
Violation: &sandbox.Violation{Code: "outside_workspace"},
|
||||
})
|
||||
if sandboxDenied.DenialReason != DenialSandboxBlock {
|
||||
t.Fatalf("sandbox denial = %q, want %q", sandboxDenied.DenialReason, DenialSandboxBlock)
|
||||
if sandboxDenied.DenialReason != DenialSandboxViolation {
|
||||
t.Fatalf("sandbox denial = %q, want %q", sandboxDenied.DenialReason, DenialSandboxViolation)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -45,192 +44,8 @@ const (
|
||||
// with the same error, so NO model (weak or strong) burns turns looping on a
|
||||
// bad call.
|
||||
toolFailureStopAt = 4
|
||||
|
||||
// maxContinueNudges bounds how many times the headless completion gate
|
||||
// (Options.RequireCompletionSignal) re-prompts a model that stopped without a
|
||||
// tool call while work clearly remained. Once spent, the run finalizes as
|
||||
// INCOMPLETE rather than nudging forever (and it is still bounded by maxTurns
|
||||
// and the run deadline).
|
||||
maxContinueNudges = 3
|
||||
)
|
||||
|
||||
// continueNudgeMarker is a stable substring for tests.
|
||||
const continueNudgeMarker = "the task is not finished"
|
||||
|
||||
// continueNudge tells a model that stopped without a tool call — while work
|
||||
// clearly remained — to keep going, or to mark the plan complete and summarize if
|
||||
// it is genuinely done. The second path gives it a clean route to a legitimate
|
||||
// completion (a finished plan + no continuation cue then exits as success).
|
||||
func continueNudge(reason string) string {
|
||||
return "You stopped without calling a tool, but " + continueNudgeMarker + " (" + reason + "). " +
|
||||
"Do not stop here: take the next concrete action with a tool now. " +
|
||||
"If you are genuinely finished, first mark the plan complete with update_plan, then give your final summary."
|
||||
}
|
||||
|
||||
// endsWithContinuationCue reports whether an assistant message ends mid-thought —
|
||||
// the model announcing its OWN next action and then stopping on a colon
|
||||
// ("…Let me check the SSH configuration:") rather than concluding. It requires
|
||||
// BOTH a trailing colon AND an action lead-in on the last line, so genuine closers
|
||||
// are NOT flagged: a forward-looking recommendation ("Next, I suggest reviewing
|
||||
// the changes."), a sign-off ("Let me know if you need anything"), or a summary
|
||||
// that merely ends in a colon ("Here is the summary:"). A bare trailing colon alone
|
||||
// is too common in legitimate final answers to use as a signal.
|
||||
func endsWithContinuationCue(text string) bool {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
lines := strings.Split(trimmed, "\n")
|
||||
last := ""
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
if s := strings.TrimSpace(lines[i]); s != "" {
|
||||
last = strings.ToLower(s)
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.HasSuffix(last, ":") {
|
||||
return false
|
||||
}
|
||||
// Inspect the final clause (after the last sentence break) so a mid-line action
|
||||
// announcement is caught ("…configure the server. Let me check the config:")
|
||||
// while a plain summary colon ("Here is the summary:") or a recommendation is not.
|
||||
clause := last
|
||||
if idx := strings.LastIndex(last, ". "); idx >= 0 {
|
||||
clause = strings.TrimSpace(last[idx+2:])
|
||||
}
|
||||
if strings.HasPrefix(clause, "let me know") { // sign-off, not a mid-step action
|
||||
return false
|
||||
}
|
||||
for _, cue := range []string{
|
||||
"let me ", "let's ", "now i'll ", "now i will ", "now let me ", "let me now ",
|
||||
"i'll now ", "i will now ", "next i'll ", "next, i'll ", "first, i'll ", "first let me ",
|
||||
} {
|
||||
if strings.HasPrefix(clause, cue) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// planStatusRemaining reports whether a raw update_plan status string represents
|
||||
// unfinished work. Anything not clearly completed/failed (incl. empty/unknown,
|
||||
// which the update_plan tool coerces to "pending") counts as remaining, matching
|
||||
// that tool's own status normalization.
|
||||
func planStatusRemaining(status string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "completed", "complete", "done", "finished", "resolved", "✓", "x", "[x]",
|
||||
"failed", "fail", "error", "errored", "blocked", "cancelled", "canceled", "abandoned", "skipped":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// acceptanceNudgeMarker is a stable substring for tests; it is embedded verbatim
|
||||
// in acceptanceVerificationNudge.
|
||||
const acceptanceNudgeMarker = "verify your work against the task's stated success criterion"
|
||||
|
||||
// selfReportPhrases are high-signal admissions of guessing/fabrication or stated
|
||||
// uncertainty about the produced result. They are matched by plain substring with
|
||||
// NO context guard, so every entry must be FIRST-PERSON or unambiguously about the
|
||||
// model's own output — NOT a phrase that also describes implemented behavior.
|
||||
// (Earlier versions included "fall back to" / "placeholder value" / "as a
|
||||
// fallback" / bare "best guess" / "without proper", which match legitimate final
|
||||
// answers like "the parser will fall back to UTF-8" or "I replaced the placeholder
|
||||
// value", wrongly downgrading completed runs — so those are removed. Admissions of
|
||||
// INABILITY are handled separately by inabilityStems, which carry a guard.)
|
||||
var selfReportPhrases = []string{
|
||||
// first-person admissions of guessing / fabrication / assumption
|
||||
"i guessed", "my best guess", "i'm guessing", "this is a guess", "just a guess",
|
||||
"i made it up", "i fabricated", "i assumed a", "i had to assume",
|
||||
// first-person lack of capability (inability stems also cover cannot/could-not)
|
||||
"i do not have the ability", "i don't have the ability", "i lack the ability",
|
||||
"no way for me to", "not possible for me to",
|
||||
// stated uncertainty about the correctness of the produced result
|
||||
"may not be correct", "might not be correct", "may be incorrect", "might be incorrect",
|
||||
"this may not work", "this might not work", "not fully functional", "not fully working",
|
||||
}
|
||||
|
||||
// inabilityStems are first-person "I cannot / can't / could not / am unable to /
|
||||
// do not have" stems. Matching the STEM (not a fixed verb) generalizes over
|
||||
// whatever action the model claims it could not perform — "analyze", "determine",
|
||||
// "do", "complete", "verify", "see", etc. — so the detector is not defeated by
|
||||
// re-phrasing (the chess case slipped a fixed "cannot analyze" list by writing
|
||||
// "…which I cannot do without proper image analysis capabilities").
|
||||
var inabilityStems = []string{
|
||||
"i cannot ", "i can't ", "i can not ", "i could not ", "i couldn't ",
|
||||
"i am unable to", "i'm unable to", "i was unable to", "i wasn't able to",
|
||||
"i was not able to", "i do not have", "i don't have", "unable to ",
|
||||
"without being able to",
|
||||
}
|
||||
|
||||
// successNegationTails are negated phrasings that indicate SUCCESS, not an
|
||||
// admission ("I could not find any remaining issues", "I cannot reproduce the
|
||||
// bug"). When an inability stem is immediately followed by one of these, it is not
|
||||
// treated as an admission, so a clean result is not misreported as INCOMPLETE.
|
||||
var successNegationTails = []string{
|
||||
"find any", "found any", "find a ", "see any", "detect any", "identify any",
|
||||
"reproduce", "spot any", "locate any",
|
||||
}
|
||||
|
||||
// selfReportedIncompletion returns a short reason when the model's final text
|
||||
// admits it guessed or could not meet the objective, else "". Case-insensitive.
|
||||
func selfReportedIncompletion(text string) string {
|
||||
lower := strings.ToLower(text)
|
||||
for _, phrase := range selfReportPhrases {
|
||||
if strings.Contains(lower, phrase) {
|
||||
return selfReportReason(phrase)
|
||||
}
|
||||
}
|
||||
for _, stem := range inabilityStems {
|
||||
// Scan EVERY occurrence of the stem, not just the first: an earlier
|
||||
// success-negation use ("I could not find any examples, so I could not
|
||||
// implement it") must not mask a later genuine admission with the same stem.
|
||||
for start := 0; ; {
|
||||
rel := strings.Index(lower[start:], stem)
|
||||
if rel < 0 {
|
||||
break
|
||||
}
|
||||
abs := start + rel
|
||||
tail := strings.TrimSpace(lower[abs+len(stem):])
|
||||
if !hasAnyPrefix(tail, successNegationTails) {
|
||||
return selfReportReason(strings.TrimSpace(stem) + " …")
|
||||
}
|
||||
start = abs + len(stem)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func selfReportReason(marker string) string {
|
||||
return `the final message admits the objective was not met ("` + marker + `")`
|
||||
}
|
||||
|
||||
func hasAnyPrefix(s string, prefixes []string) bool {
|
||||
for _, p := range prefixes {
|
||||
if strings.HasPrefix(s, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// acceptanceVerificationNudge forces a TASK-GROUNDED acceptance check before a run
|
||||
// may finalize as success. It explicitly rejects the three false-success patterns
|
||||
// the bug-hunt surfaced: well-formed output treated as correct; pre-existing tests
|
||||
// passing treated as the objective being met; and a result that merely matches a
|
||||
// baseline the task asked to beat or improve. General — no task-specific content.
|
||||
func acceptanceVerificationNudge() string {
|
||||
return "Before this task can be marked complete, " + acceptanceNudgeMarker + " — " +
|
||||
"NOT the shape or format of your output, NOT that pre-existing tests pass, and NOT that your " +
|
||||
"result merely matches a baseline you were asked to beat or improve. " +
|
||||
"Re-read the original task, then run a concrete check that exercises the actual requirement: " +
|
||||
"execute the program or tests that demonstrate the required behavior, or directly probe the specific " +
|
||||
"thing the task asked you to produce, recover, fix, or optimize. " +
|
||||
"If that check passes, reply PASS and cite the evidence. " +
|
||||
"If it does not pass — or you cannot run such a check — say so plainly and keep working; do not claim success."
|
||||
}
|
||||
|
||||
// toolFailureHintMarker is a stable substring for tests.
|
||||
const toolFailureHintMarker = "kept failing with the same error"
|
||||
|
||||
@@ -362,10 +177,6 @@ type guardState struct {
|
||||
staleReminderSent bool
|
||||
toolOnlyTurns int
|
||||
toolOnlyReminderSent bool
|
||||
// planItemsPending is the number of remaining (pending/in_progress) items in
|
||||
// the most recent update_plan call, so the headless completion gate can tell
|
||||
// whether work is unfinished when the model stops without a tool call.
|
||||
planItemsPending int
|
||||
// toolFailures tracks consecutive same-error failures per tool, keyed by tool
|
||||
// name, so the loop can hint then halt instead of looping forever.
|
||||
toolFailures map[string]*toolFailureRecord
|
||||
@@ -434,9 +245,6 @@ func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (sto
|
||||
state.toolCallsSincePlanUpdate = 0
|
||||
// A fresh plan update opens a new stale interval.
|
||||
state.staleReminderSent = false
|
||||
// Record how many items remain so the completion gate knows whether
|
||||
// work is unfinished if the model later stops without a tool call.
|
||||
state.observePlanUpdate(call.Arguments)
|
||||
} else {
|
||||
state.toolCallsSincePlanUpdate++
|
||||
}
|
||||
@@ -445,33 +253,6 @@ func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (sto
|
||||
return state.emptyTurns >= maxEmptyTurns
|
||||
}
|
||||
|
||||
// pendingPlanItems reports whether the most recent update_plan call still has
|
||||
// unfinished (pending/in_progress) items. False when no plan was ever recorded.
|
||||
func (state *guardState) pendingPlanItems() bool {
|
||||
return state.planItemsPending > 0
|
||||
}
|
||||
|
||||
// observePlanUpdate parses an update_plan call's raw arguments and records how
|
||||
// many items are still remaining. Malformed arguments leave the prior count
|
||||
// unchanged (best-effort — the plan panel itself tolerates the same).
|
||||
func (state *guardState) observePlanUpdate(arguments string) {
|
||||
var parsed struct {
|
||||
Plan []struct {
|
||||
Status string `json:"status"`
|
||||
} `json:"plan"`
|
||||
}
|
||||
if json.Unmarshal([]byte(arguments), &parsed) != nil {
|
||||
return
|
||||
}
|
||||
pending := 0
|
||||
for _, item := range parsed.Plan {
|
||||
if planStatusRemaining(item.Status) {
|
||||
pending++
|
||||
}
|
||||
}
|
||||
state.planItemsPending = pending
|
||||
}
|
||||
|
||||
func (state *guardState) progressReminder() string {
|
||||
if state.toolOnlyReminderSent || state.toolOnlyTurns < toolOnlyProgressReminderAt {
|
||||
return ""
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+186
-1047
File diff suppressed because it is too large.
Load diff
+45
-1123
File diff suppressed because it is too large.
Load diff
@@ -1,51 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
type safetyTool struct {
|
||||
name string
|
||||
safety tools.Safety
|
||||
}
|
||||
|
||||
func (t safetyTool) Name() string { return t.name }
|
||||
func (t safetyTool) Description() string { return "test tool" }
|
||||
func (t safetyTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", AdditionalProperties: false}
|
||||
}
|
||||
func (t safetyTool) Safety() tools.Safety { return t.safety }
|
||||
func (t safetyTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK}
|
||||
}
|
||||
|
||||
// member-auto advertises the in-workspace mutators a headless member needs to
|
||||
// build (write/edit + shell) that plain Auto hides, but NOT network or denied
|
||||
// tools — the sandbox still gates the advertised ones at call time.
|
||||
func TestToolAdvertisedMemberAuto(t *testing.T) {
|
||||
write := safetyTool{name: "write_file", safety: tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionPrompt}}
|
||||
shell := safetyTool{name: "bash", safety: tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt}}
|
||||
read := safetyTool{name: "read_file", safety: tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}}
|
||||
network := safetyTool{name: "net_tool", safety: tools.Safety{SideEffect: tools.SideEffectNetwork, Permission: tools.PermissionPrompt}}
|
||||
denied := safetyTool{name: "blocked", safety: tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionDeny}}
|
||||
|
||||
// Plain Auto hides prompt-requiring mutators (the read-only member problem).
|
||||
if ToolAdvertised(write, PermissionModeAuto) || ToolAdvertised(shell, PermissionModeAuto) {
|
||||
t.Fatal("Auto must NOT advertise write/shell prompt tools")
|
||||
}
|
||||
|
||||
for _, tool := range []tools.Tool{write, shell, read} {
|
||||
if !ToolAdvertised(tool, PermissionModeMemberAuto) {
|
||||
t.Fatalf("member-auto must advertise %q", tool.Name())
|
||||
}
|
||||
}
|
||||
if ToolAdvertised(network, PermissionModeMemberAuto) {
|
||||
t.Fatal("member-auto must NOT advertise a network prompt tool")
|
||||
}
|
||||
if ToolAdvertised(denied, PermissionModeMemberAuto) {
|
||||
t.Fatal("member-auto must NOT advertise a denied tool")
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
|
||||
// Cache-stability property: loading a deferred tool must APPEND it after the
|
||||
// always-eager tools, not sort it into the middle of them — otherwise the tool
|
||||
// definitions (the provider's cacheable prefix) shift on every load. The old
|
||||
// behaviour alpha-sorted the whole array, so "mcp__srv__alpha" jumped ahead of
|
||||
// "read_file".
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root)) // non-deferred, "read_file"
|
||||
registry.Register(fakeToolSearchTool{})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
|
||||
|
||||
exposed, _ := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 2}, map[string]bool{"mcp__srv__alpha": true})
|
||||
|
||||
pos := func(name string) int {
|
||||
for i := range exposed {
|
||||
if exposed[i].Name == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
readPos, searchPos, alphaPos := pos("read_file"), pos("tool_search"), pos("mcp__srv__alpha")
|
||||
if readPos < 0 || searchPos < 0 || alphaPos < 0 {
|
||||
t.Fatalf("expected read_file, tool_search and loaded alpha all exposed, got %#v", exposed)
|
||||
}
|
||||
// The eager tools must precede the loaded deferred tool (appended, not inserted).
|
||||
if !(readPos < alphaPos && searchPos < alphaPos) {
|
||||
t.Fatalf("loaded deferred tool must be appended after the eager block; got read=%d search=%d alpha=%d", readPos, searchPos, alphaPos)
|
||||
}
|
||||
// beta was never loaded — it stays hidden.
|
||||
if pos("mcp__srv__beta") != -1 {
|
||||
t.Fatalf("unloaded deferred tool must stay hidden, got %#v", exposed)
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// promptShellTool is a minimal shell-style tool whose Safety requires a prompt,
|
||||
// standing in for bash so buildPermissionEvent can be exercised directly.
|
||||
type promptShellTool struct{}
|
||||
|
||||
func (promptShellTool) Name() string { return "bash" }
|
||||
func (promptShellTool) Description() string { return "run a shell command" }
|
||||
func (promptShellTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", AdditionalProperties: false}
|
||||
}
|
||||
func (promptShellTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs a shell command"}
|
||||
}
|
||||
func (promptShellTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK}
|
||||
}
|
||||
|
||||
// A command whose sandbox decision is Allow but that explicitly requests
|
||||
// additional sandbox permissions is an ELEVATION: it must be surfaced as a
|
||||
// prompt, never Action=allow. Previously it carried allow while the loop still
|
||||
// blocked on OnPermissionRequest, and the TUI (which renders only prompts)
|
||||
// dropped it → the run deadlocked forever. This is the exact gpt-5.5
|
||||
// folder-creation shape captured in the reported hang.
|
||||
func TestBuildPermissionEventPromptsForAdditionalPermissions(t *testing.T) {
|
||||
call := ToolCall{ID: "call_1", Name: "bash", Arguments: `{}`}
|
||||
args := map[string]any{
|
||||
"command": "mkdir foo && cd foo && go mod init foo",
|
||||
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
|
||||
}
|
||||
// Base command allowed by the sandbox…
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionAllow}
|
||||
|
||||
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
|
||||
if !ok {
|
||||
t.Fatal("buildPermissionEvent returned ok=false; the elevation request must produce an event")
|
||||
}
|
||||
if event.Action != PermissionActionPrompt {
|
||||
t.Fatalf("Action = %q, want prompt — an additional-permissions elevation must ask the user, not auto-allow", event.Action)
|
||||
}
|
||||
// Sanity: shouldRequestPermission agrees the loop blocks on this.
|
||||
if !shouldRequestPermission(promptShellTool{}, args, false, decision) {
|
||||
t.Fatal("shouldRequestPermission must be true for an additional-permissions request")
|
||||
}
|
||||
}
|
||||
|
||||
// Without the additional-permissions flag, a sandbox-allowed command is NOT a
|
||||
// prompt (shouldRequestPermission is false; it just runs) — the override must
|
||||
// not over-reach and prompt for ordinary allowed commands.
|
||||
func TestBuildPermissionEventKeepsAllowForOrdinaryAllowedCommand(t *testing.T) {
|
||||
call := ToolCall{ID: "call_2", Name: "bash", Arguments: `{}`}
|
||||
args := map[string]any{"command": "ls"}
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionAllow}
|
||||
|
||||
event, _ := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
|
||||
if event.Action == PermissionActionPrompt {
|
||||
t.Fatalf("Action = prompt for an ordinary allowed command; want allow (no spurious prompt)")
|
||||
}
|
||||
if shouldRequestPermission(promptShellTool{}, args, false, decision) {
|
||||
t.Fatal("shouldRequestPermission must be false for an ordinary sandbox-allowed command")
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func TestPersistPermissionGrantScopesWebFetchToHost(t *testing.T) {
|
||||
|
||||
grant, err := persistPermissionGrant("web_fetch", map[string]any{"url": "https://Example.COM:443/docs"}, "trust this host", Options{
|
||||
Sandbox: engine,
|
||||
Autonomy: "medium",
|
||||
Autonomy: string(sandbox.AutonomyMedium),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -67,10 +67,10 @@ func TestPersistPermissionGrantScopesWebFetchToHost(t *testing.T) {
|
||||
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 {
|
||||
if lookup, err := store.Lookup("web_fetch", "example.com", sandbox.AutonomyMedium); 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 {
|
||||
if lookup, err := store.Lookup("web_fetch", "api.example.com", sandbox.AutonomyMedium); err != nil || lookup.Matched {
|
||||
t.Fatalf("expected subdomain lookup to re-prompt: lookup=%#v err=%v", lookup, err)
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// These ceilings are a deliberate ratchet on Zero's fixed per-turn overhead — the
|
||||
// system prompt and the eager tool schemas that ride on EVERY request. They are
|
||||
// set ~10% above the current measured cost, using ApproxTextTokens (the same
|
||||
// estimate the compaction loop and /context use, ~non-whitespace-bytes/4). A change
|
||||
// that pushes either past its ceiling must be justified (and the ceiling raised
|
||||
// deliberately) or trimmed — the per-turn floor should not creep up silently.
|
||||
//
|
||||
// Measured baselines (2026-07): base system prompt ~3160 tokens; the tools a normal
|
||||
// (auto permission-mode) interactive turn sends ~2430 tokens. The tool figure is
|
||||
// the auto-mode advertised set, not the full core registry — higher-risk tools that
|
||||
// only advertise in other modes are excluded, matching what a real turn actually
|
||||
// pays.
|
||||
const (
|
||||
maxBaseSystemPromptTokens = 3500
|
||||
maxEagerToolSchemaTokens = 2700
|
||||
)
|
||||
|
||||
func TestSystemPromptTokenBudget(t *testing.T) {
|
||||
// Minimal render: a model is set (so the session block renders) but no Cwd, so
|
||||
// the workspace map, project guidelines, and repo map are excluded — this is the
|
||||
// fixed base every session pays regardless of the workspace it runs in.
|
||||
prompt := buildSystemPrompt(Options{Model: "claude-opus-4-8"})
|
||||
got := ApproxTextTokens(prompt)
|
||||
t.Logf("base system prompt: %d tokens (%d bytes)", got, len(prompt))
|
||||
if got > maxBaseSystemPromptTokens {
|
||||
t.Fatalf("base system prompt is %d tokens, over the %d ceiling — trim it or raise the ceiling deliberately", got, maxBaseSystemPromptTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEagerToolSchemaTokenBudget(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
for _, tool := range tools.CoreToolsScoped(t.TempDir(), nil) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
// Options{} keeps DeferThreshold at 0, so deferral is inactive and every core
|
||||
// tool is exposed eagerly — exactly what a plugin-free session sends each turn.
|
||||
exposed, _ := partitionTools(registry, PermissionModeAuto, Options{}, map[string]bool{})
|
||||
got := estimateToolDefTokens(exposed)
|
||||
t.Logf("eager core tool schemas: %d tokens across %d tools", got, len(exposed))
|
||||
if got > maxEagerToolSchemaTokens {
|
||||
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — defer a tool or raise the ceiling deliberately", got, maxEagerToolSchemaTokens)
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
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 ")
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
// stallRetryNoticeFor builds a notifier for the loop's content-stall retry (a
|
||||
// stream that connected and produced only transient output, then went silent).
|
||||
// Distinct wording from reconnectNoticeFor's "connection lost" because the
|
||||
// connection was fine — the model stalled. Surfaced through OnReasoning, the
|
||||
// non-content channel, so it never folds into the answer text. Nil when there
|
||||
// is no reasoning sink (the retry still happens silently).
|
||||
func stallRetryNoticeFor(options Options) reconnectNotifier {
|
||||
if options.OnReasoning == nil {
|
||||
return nil
|
||||
}
|
||||
return func(attempt, max int) {
|
||||
options.OnReasoning(fmt.Sprintf("\n[no output — model stalled; retrying %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
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
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
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
|
||||
func TestRequestPermissionsTurnGrantAllowsLaterToolAndCleansUp(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
outside := tempDirOutsideDefaultTemp(t)
|
||||
outside := t.TempDir()
|
||||
target := filepath.Join(outside, "vegetables.txt")
|
||||
scope, err := sandbox.NewScope(workspace, nil)
|
||||
if err != nil {
|
||||
@@ -63,7 +63,7 @@ func TestRequestPermissionsTurnGrantAllowsLaterToolAndCleansUp(t *testing.T) {
|
||||
result, err := Run(context.Background(), "write the file", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAuto,
|
||||
Autonomy: "medium",
|
||||
Autonomy: string(sandbox.AutonomyMedium),
|
||||
Cwd: workspace,
|
||||
Sandbox: engine,
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
@@ -113,78 +113,14 @@ func TestRequestPermissionsTurnGrantAllowsLaterToolAndCleansUp(t *testing.T) {
|
||||
SideEffect: sandbox.SideEffectWrite,
|
||||
Permission: sandbox.PermissionPrompt,
|
||||
PermissionMode: sandbox.PermissionModeAuto,
|
||||
Autonomy: sandbox.AutonomyMedium,
|
||||
Args: map[string]any{"path": target},
|
||||
})
|
||||
if decision.Action != sandbox.ActionPrompt {
|
||||
if decision.Action != sandbox.ActionDeny {
|
||||
t.Fatalf("turn grant should be cleaned up after Run, got decision %#v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func tempDirOutsideDefaultTemp(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir, err := os.MkdirTemp(".", ".zero-sandbox-outside-")
|
||||
if err != nil {
|
||||
t.Fatalf("MkdirTemp outside default temp: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(%q): %v", dir, err)
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
|
||||
return resolved
|
||||
}
|
||||
return filepath.Clean(abs)
|
||||
}
|
||||
|
||||
func TestInlineAdditionalPermissionsGrantAllowsNetworkCommand(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{
|
||||
WorkspaceRoot: workspace,
|
||||
Policy: sandbox.DefaultPolicy(),
|
||||
})
|
||||
args := map[string]any{
|
||||
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
|
||||
"additional_permissions": map[string]any{
|
||||
"network": map[string]any{"enabled": true},
|
||||
},
|
||||
}
|
||||
cleanup, err := grantInlineAdditionalPermissions(args, sandbox.PermissionGrantScopeTurn, Options{
|
||||
Sandbox: engine,
|
||||
Cwd: workspace,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("grantInlineAdditionalPermissions: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
decision := engine.Evaluate(context.Background(), sandbox.Request{
|
||||
WorkspaceRoot: workspace,
|
||||
ToolName: "exec_command",
|
||||
SideEffect: sandbox.SideEffectShell,
|
||||
Permission: sandbox.PermissionPrompt,
|
||||
PermissionGranted: true,
|
||||
PermissionMode: sandbox.PermissionModeAsk,
|
||||
Args: map[string]any{"cmd": "python3 -m http.server 8080 --bind 127.0.0.1"},
|
||||
})
|
||||
if decision.Action != sandbox.ActionAllow {
|
||||
t.Fatalf("inline network grant should allow shell network command, got %#v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInlineAdditionalPermissionsRequiresPromptUnderAutoAllow(t *testing.T) {
|
||||
args := map[string]any{
|
||||
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
|
||||
"additional_permissions": map[string]any{
|
||||
"network": map[string]any{"enabled": true},
|
||||
},
|
||||
}
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionAllow, AutoAllowed: true}
|
||||
if !shouldRequestPermission(tools.NewBashTool(t.TempDir()), args, false, decision) {
|
||||
t.Fatal("inline additional permissions must request approval even when a sandboxed shell would otherwise auto-allow")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestPermissionsDenyReturnsEmptyGrantAndContinues(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
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)")
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSkillsContextCapsLongList(t *testing.T) {
|
||||
skills := make([]SkillInfo, 0, 40)
|
||||
for i := 0; i < 40; i++ {
|
||||
n := strconv.Itoa(i)
|
||||
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: "does something useful, number " + n})
|
||||
}
|
||||
got := skillsContext(Options{Skills: skills})
|
||||
if len(got) > 1200 {
|
||||
t.Fatalf("skills block should stay bounded, got %d bytes:\n%s", len(got), got)
|
||||
}
|
||||
if !strings.Contains(got, "more (call skill") {
|
||||
t.Fatalf("expected an overflow summary line, got:\n%s", got)
|
||||
}
|
||||
// The first skill is always listed regardless of budget.
|
||||
if !strings.Contains(got, "- skill-0:") {
|
||||
t.Fatalf("expected the first skill to always be listed, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsContext(t *testing.T) {
|
||||
if got := skillsContext(Options{}); got != "" {
|
||||
t.Fatalf("no skills should yield an empty section, got %q", got)
|
||||
}
|
||||
got := skillsContext(Options{Skills: []SkillInfo{
|
||||
{Name: "commit-writer", Description: "Write a conventional-commit message."},
|
||||
{Name: " ", Description: "nameless, should be skipped"},
|
||||
{Name: "reviewer"},
|
||||
}})
|
||||
if !strings.Contains(got, "<available_skills>") || !strings.Contains(got, "</available_skills>") {
|
||||
t.Fatalf("missing available_skills block: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "- commit-writer: Write a conventional-commit message.") {
|
||||
t.Fatalf("missing commit-writer line: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "- reviewer\n") {
|
||||
t.Fatalf("reviewer (no description) line missing: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "nameless") {
|
||||
t.Fatalf("nameless entry should be skipped: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemPromptIncludesSkillsOnlyWhenInstalled(t *testing.T) {
|
||||
with := buildSystemPrompt(Options{Skills: []SkillInfo{
|
||||
{Name: "commit-writer", Description: "Write a commit message."},
|
||||
}})
|
||||
if !strings.Contains(with, "<available_skills>") || !strings.Contains(with, "skill tool") {
|
||||
t.Fatalf("expected available_skills guidance in system prompt: %q", with)
|
||||
}
|
||||
// Default (no skills) must reproduce the prior prompt: no skills block.
|
||||
without := buildSystemPrompt(Options{})
|
||||
if strings.Contains(without, "<available_skills>") {
|
||||
t.Fatalf("available_skills block must not appear without skills")
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// stallProvider connects successfully (HTTP 200) but the stream emits a stall/idle
|
||||
// timeout error for the first stallBefore calls, then succeeds with "done". The
|
||||
// pre-stall emission simulates different partial-output shapes:
|
||||
// - partialText: forwarded PROSE (StreamEventText) — must NOT be retried
|
||||
// (re-issuing would duplicate visible answer text).
|
||||
// - reasoningText: transient reasoning preview — IS retried.
|
||||
// - partialToolCall: a tool call started (+ arg delta) but never ended, the
|
||||
// "froze mid-write_file" case — IS retried (the incomplete call is never
|
||||
// executed or committed).
|
||||
type stallProvider struct {
|
||||
calls int32
|
||||
stallBefore int32
|
||||
partialText string
|
||||
reasoningText string
|
||||
partialToolCall string
|
||||
}
|
||||
|
||||
func (p *stallProvider) StreamCompletion(_ context.Context, _ zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
n := atomic.AddInt32(&p.calls, 1)
|
||||
ch := make(chan zeroruntime.StreamEvent, 5)
|
||||
if n <= p.stallBefore {
|
||||
if p.partialText != "" {
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: p.partialText}
|
||||
}
|
||||
if p.reasoningText != "" {
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventReasoning, Content: p.reasoningText}
|
||||
}
|
||||
if p.partialToolCall != "" {
|
||||
// A tool call that starts and streams a partial argument fragment but
|
||||
// never gets StreamEventToolCallEnd, then the stream errors — exactly
|
||||
// the gpt-5.x "began the big write_file then froze" shape.
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "tc_1", ToolName: p.partialToolCall}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "tc_1", ArgumentsFragment: `{"path":"x.html","content":"<!doctype`}
|
||||
}
|
||||
ch <- zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventError,
|
||||
Error: "provider stream error: no output for 6m (the model produced nothing)",
|
||||
}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// A no-output stall is re-issued on a fresh connection and recovers — this is the
|
||||
// macOS stale-pooled-connection hang turned into an automatic recovery.
|
||||
func TestRunRetriesStreamStallWithNoOutput(t *testing.T) {
|
||||
p := &stallProvider{stallBefore: 1}
|
||||
result, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
|
||||
if err != nil {
|
||||
t.Fatalf("a no-output stall should retry to success, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 stall + 1 retry), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A stall AFTER partial output must NOT be retried — re-issuing would duplicate
|
||||
// the already-streamed text. It surfaces the error instead.
|
||||
func TestRunDoesNotRetryStallAfterPartialOutput(t *testing.T) {
|
||||
p := &stallProvider{stallBefore: 1, partialText: "partial"}
|
||||
_, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
|
||||
if err == nil {
|
||||
t.Fatal("a stall after partial output must NOT be retried; want an error")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 1 {
|
||||
t.Fatalf("partial-then-stall must not retry, got %d calls", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A stall after only transient REASONING was forwarded (no prose text, no
|
||||
// completed tool call) IS retried: reasoning is a "thinking" preview, not
|
||||
// committed answer text, so re-streaming it on a clearly-signalled retry is
|
||||
// acceptable and recovers the common "reasoned then froze" stall. Only
|
||||
// forwarded final prose (OnText) blocks the retry (collected.Text is empty
|
||||
// here, so this exercises the transient-vs-prose gate specifically).
|
||||
func TestRunRetriesStallAfterOnlyReasoning(t *testing.T) {
|
||||
p := &stallProvider{stallBefore: 1, reasoningText: "thinking hard"}
|
||||
result, err := Run(context.Background(), "go", p, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnReasoning: func(string) {},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("a stall after only reasoning should retry to success, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 stall + 1 retry), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A stall mid-way through an INCOMPLETE tool call (started + partial args, never
|
||||
// ended) IS retried — the exact gpt-5.x / ollama "began the big write_file then
|
||||
// froze" case. The incomplete call is never executed (a turn returns on the
|
||||
// stall error before dispatch) and never committed to history, so re-issuing is
|
||||
// safe and recovers to success. This is the primary scenario this change fixes.
|
||||
func TestRunRetriesStallAfterIncompleteToolCall(t *testing.T) {
|
||||
starts := 0
|
||||
p := &stallProvider{stallBefore: 1, reasoningText: "planning", partialToolCall: "write_file"}
|
||||
result, err := Run(context.Background(), "go", p, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnReasoning: func(string) {},
|
||||
OnToolCallStart: func(string, string) { starts++ },
|
||||
OnToolCallDelta: func(string, string) {},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("a stall mid-incomplete-tool-call should retry to success, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 2 {
|
||||
t.Fatalf("want 2 calls (1 stall + 1 retry), got %d", got)
|
||||
}
|
||||
if starts != 1 {
|
||||
t.Fatalf("the incomplete tool call should have been forwarded once before the stall, got %d starts", starts)
|
||||
}
|
||||
}
|
||||
|
||||
// A persistent stall surfaces an error after exhausting the capped retries.
|
||||
func TestRunGivesUpAfterMaxStallRetries(t *testing.T) {
|
||||
p := &stallProvider{stallBefore: 99}
|
||||
_, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
|
||||
if err == nil {
|
||||
t.Fatal("a persistent stall must surface an error after exhausting retries")
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != int32(1+maxStreamStallRetries) {
|
||||
t.Fatalf("want %d calls (1 + %d retries), got %d", 1+maxStreamStallRetries, maxStreamStallRetries, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsStreamTimeoutError(t *testing.T) {
|
||||
timeouts := []string{
|
||||
"provider stream error: no output for 10m (the model produced nothing)",
|
||||
"provider stream error: idle timeout after 5m0s (upstream stopped sending data)",
|
||||
"stream stalled (upstream kept the connection alive but produced no output)",
|
||||
}
|
||||
for _, m := range timeouts {
|
||||
if !isStreamTimeoutError(m) {
|
||||
t.Fatalf("want timeout-classified: %q", m)
|
||||
}
|
||||
}
|
||||
notTimeouts := []string{"", "context length exceeded", "rate limit error: slow down", "model not found"}
|
||||
for _, m := range notTimeouts {
|
||||
if isStreamTimeoutError(m) {
|
||||
t.Fatalf("must NOT classify as timeout: %q", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -87,35 +86,12 @@ func buildSystemPrompt(options Options) string {
|
||||
if delegation := specialistDelegationContext(options); delegation != "" {
|
||||
sections = append(sections, delegation)
|
||||
}
|
||||
if skillsBlock := skillsContext(options); skillsBlock != "" {
|
||||
sections = append(sections, skillsBlock)
|
||||
}
|
||||
if style := responseStyleContext(options); style != "" {
|
||||
sections = append(sections, style)
|
||||
}
|
||||
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 ""
|
||||
@@ -168,64 +144,6 @@ func specialistDelegationContext(options Options) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// skillsContext lists the reusable skills the model can pull in on demand via the
|
||||
// skill tool, so it invokes the right one on the first try instead of guessing a
|
||||
// name, failing, and reading the error. It mirrors specialistDelegationContext:
|
||||
// names + one-line descriptions only (never skill bodies), and it renders nothing
|
||||
// when no skills are installed, so a skill-less run reproduces the previous prompt
|
||||
// byte-for-byte.
|
||||
// skillsContextListBudget bounds the bytes spent listing individual skills so a
|
||||
// workspace with dozens of them can't bloat every turn's prompt. Skills past the
|
||||
// budget are summarized as a count rather than dropped — the model can still load
|
||||
// any of them by name (an unknown name returns the full list from the skill tool).
|
||||
const skillsContextListBudget = 640
|
||||
|
||||
func skillsContext(options Options) string {
|
||||
if len(options.Skills) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("<available_skills>\n")
|
||||
b.WriteString("Reusable, on-demand instruction sets you can load with the skill tool. When a request matches one, call skill with its exact name to pull in its guidance before acting — do not guess names or skip a relevant skill.\n")
|
||||
listed, spent, omitted := 0, 0, 0
|
||||
for _, info := range options.Skills {
|
||||
name := strings.TrimSpace(info.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
line := "- " + name
|
||||
if desc := strings.TrimSpace(info.Description); desc != "" {
|
||||
line += ": " + truncateForSkillLine(desc)
|
||||
}
|
||||
line += "\n"
|
||||
// Always list at least one skill; past the budget, summarize the remainder
|
||||
// as a count instead of silently dropping it.
|
||||
if listed > 0 && spent+len(line) > skillsContextListBudget {
|
||||
omitted++
|
||||
continue
|
||||
}
|
||||
b.WriteString(line)
|
||||
spent += len(line)
|
||||
listed++
|
||||
}
|
||||
if omitted > 0 {
|
||||
b.WriteString("- …and " + strconv.Itoa(omitted) + " more (call skill with a name; an unknown name lists them all)\n")
|
||||
}
|
||||
b.WriteString("</available_skills>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// truncateForSkillLine keeps a skill's one-line description short so a single
|
||||
// verbose description can't dominate the skills-list budget.
|
||||
func truncateForSkillLine(desc string) string {
|
||||
const maxDescRunes = 100
|
||||
runes := []rune(desc)
|
||||
if len(runes) <= maxDescRunes {
|
||||
return desc
|
||||
}
|
||||
return strings.TrimSpace(string(runes[:maxDescRunes])) + "…"
|
||||
}
|
||||
|
||||
func sessionRuntimeContext(options Options) string {
|
||||
provider := strings.TrimSpace(options.ProviderName)
|
||||
model := strings.TrimSpace(options.Model)
|
||||
@@ -259,9 +177,9 @@ func workspaceContext(cwd string) string {
|
||||
b.WriteString("Working directory: " + cwd + "\n")
|
||||
b.WriteString("Operating system: " + runtime.GOOS + "\n")
|
||||
if runtime.GOOS == "windows" {
|
||||
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
|
||||
b.WriteString("Shell syntax: Windows cmd.exe syntax for bash tool; prefer the cwd argument instead of cd when changing directories.\n")
|
||||
} else {
|
||||
b.WriteString("Shell syntax: /bin/sh syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
|
||||
b.WriteString("Shell syntax: /bin/sh syntax for bash tool; prefer the cwd argument instead of cd when changing directories.\n")
|
||||
}
|
||||
if branch := gitBranchForPrompt(cwd); branch != "" {
|
||||
b.WriteString("Git branch: " + branch + "\n")
|
||||
@@ -372,9 +290,9 @@ func projectGuidelineLabel(match, gitRoot string) string {
|
||||
|
||||
// findProjectContextFile returns the first matching project guideline file
|
||||
// in dir. Lookup is case-insensitive on the basename and on the actual
|
||||
// filename returned, so a git-tracked file like AGENTS.md still resolves to
|
||||
// its true filename on case-sensitive filesystems — the returned path is what
|
||||
// should appear in the project guidelines label.
|
||||
// filename returned, so a git-tracked file like AGENTS.MD (uppercase MD)
|
||||
// still resolves to its true filename on case-sensitive filesystems — the
|
||||
// returned path is what should appear in the project guidelines label.
|
||||
// Returns "" when nothing matches.
|
||||
func findProjectContextFile(dir string) string {
|
||||
for _, name := range projectContextFiles {
|
||||
|
||||
@@ -16,12 +16,7 @@ work.
|
||||
implementation, verification, and a clear summary.
|
||||
- Only stop to ask the user (via the ask_user tool) when you are genuinely
|
||||
blocked on a decision that is theirs to make and that you cannot resolve from
|
||||
the code, the request, or sensible defaults. When the answer is likely one of a
|
||||
small set, include 2-4 suggested `options` and mark the best as `recommended`
|
||||
(it must be one of the options) so the user can pick quickly; give each option a
|
||||
short `optionDescriptions` line when a one-word label needs context, and a short
|
||||
`header` (2-3 words) per question as its tab label when you ask several. Omit
|
||||
options for genuinely open-ended questions.
|
||||
the code, the request, or sensible defaults.
|
||||
|
||||
## Workflow: plan then act
|
||||
|
||||
@@ -31,25 +26,15 @@ work.
|
||||
tests, or config before you modify behavior. Never edit a file you have not
|
||||
read.
|
||||
2. **Plan.** For multi-step work, call update_plan with an ordered checklist and
|
||||
keep it live. The plan bar is the user's progress signal — call update_plan
|
||||
after EACH concrete unit of work (every file written, every command run), not
|
||||
just at coarse milestones: mark the finished item completed and the next one
|
||||
in_progress before you start it. A plan stuck at 0/N while files are landing is
|
||||
a bug, not economy — these calls are cheap, expected, and the update_plan cards
|
||||
are hidden from the transcript, so frequent updates cost the user nothing and
|
||||
never clutter the conversation. Keep at most one item in_progress, and never
|
||||
batch the updates to the end of the turn. Skip the plan for trivial one-step
|
||||
tasks.
|
||||
keep it current as you go. Skip the plan for trivial one-step tasks.
|
||||
3. **Implement.** Make focused changes that match the surrounding code's style,
|
||||
naming, and conventions. Prefer the smallest change that fully solves the
|
||||
problem. Avoid broad refactors, unrelated rewrites, dependency churn, and
|
||||
formatting-only edits unless the user asked for them.
|
||||
4. **Verify.** Verify after edits; see the testing gate below. This is
|
||||
mandatory.
|
||||
5. **Summarize.** Close with a summary scaled to the work: a trivial fix earns one
|
||||
line; a substantial change earns a few short bullets covering what changed, where
|
||||
it lives, and what you did and did not verify. Lead with the outcome, and never
|
||||
pad — do not restate the task or narrate steps the user already watched you take.
|
||||
5. **Summarize.** Give a concise final response: what changed, why, which files
|
||||
matter, and what validation ran. State plainly what was not verified.
|
||||
|
||||
## Editing discipline
|
||||
|
||||
@@ -60,9 +45,7 @@ work.
|
||||
- Make one tool call per file. Do not batch multi-file writes into a single
|
||||
shell or script invocation.
|
||||
- For edits to existing files, prefer edit_file or apply_patch with minimal,
|
||||
targeted diffs. Match the existing indentation, imports, and idioms. Match the
|
||||
file's comment density: do not add explanatory comments unless the user asks or
|
||||
the code is already comment-dense.
|
||||
targeted diffs. Match the existing indentation, imports, and idioms.
|
||||
- Preserve behavior you were not asked to change. Do not delete or rewrite code
|
||||
you did not author unless the task requires it; if you must, say so.
|
||||
|
||||
@@ -80,29 +63,12 @@ work.
|
||||
|
||||
## Tool use
|
||||
|
||||
- Lead a multi-step task with a one- or two-sentence plain-language preamble on
|
||||
your approach, so the user can follow what you're about to do. Then keep a brief
|
||||
running account: drop a short, single-line note before each SIGNIFICANT step
|
||||
(e.g. "Now the stylesheet.", "Let me wire up the cart and filters.") and explain
|
||||
the outcome. Use tools to act, not to narrate — don't announce every individual
|
||||
call or read; narrate only the steps a person would care about, and skip
|
||||
narration entirely for trivial one-step tasks.
|
||||
- Use tools to act, not to narrate. Do not announce each call; just do the work
|
||||
and explain the outcome.
|
||||
- Run independent, read-only lookups together when you can, rather than one at a
|
||||
time, to move faster.
|
||||
- exec_command is for commands that have no native tool (build, test, git,
|
||||
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.
|
||||
- bash is for commands that have no native tool (build, test, git, package
|
||||
managers). It is not a substitute for the file tools.
|
||||
- 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
|
||||
|
||||
@@ -6,13 +6,11 @@ import "strings"
|
||||
//
|
||||
// Different model families respond to different framing: the GPT family benefits
|
||||
// from an explicit markdown/output spec and a strong "prefer native tools" nudge;
|
||||
// Gemini benefits from explicit tool-preference and conciseness guidance. The
|
||||
// Claude family is already aligned with the core prompt and needs no addendum —
|
||||
// comment discipline is universal in the core prompt now, so every family gets it.
|
||||
// modelPromptAddendum returns the family-specific block appended after the core
|
||||
// prompt, or "" when the family is unknown or needs no specialization.
|
||||
// Classification is by model id (the agent only has the model string — no registry
|
||||
// dependency).
|
||||
// Gemini benefits from explicit tool-preference and conciseness guidance; the
|
||||
// Claude family is already well-aligned with the core prompt and only needs a
|
||||
// light comment-discipline nudge. modelPromptAddendum returns the family-specific
|
||||
// block appended after the core prompt. Classification is by model id (the agent
|
||||
// only has the model string — no registry dependency).
|
||||
|
||||
const (
|
||||
familyOpenAI = "openai"
|
||||
@@ -47,10 +45,9 @@ func modelPromptAddendum(model string) string {
|
||||
return openAIPromptAddendum
|
||||
case familyGemini:
|
||||
return geminiPromptAddendum
|
||||
case familyAnthropic:
|
||||
return anthropicPromptAddendum
|
||||
default:
|
||||
// Anthropic (aligned with the core prompt) and unknown families get no
|
||||
// family-specific block; comment discipline now lives in the core prompt
|
||||
// for every model.
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -73,3 +70,8 @@ const geminiPromptAddendum = `<model_guidance>
|
||||
in one short clause why it is needed.
|
||||
- Use update_plan for any multi-step task and keep it current.
|
||||
</model_guidance>`
|
||||
|
||||
const anthropicPromptAddendum = `<model_guidance>
|
||||
- Do not add code comments unless the user asks or the surrounding code is
|
||||
comment-dense; match the file's existing comment density.
|
||||
</model_guidance>`
|
||||
@@ -29,11 +29,9 @@ func TestBuildSystemPromptAppendsModelAddendum(t *testing.T) {
|
||||
if got := buildSystemPrompt(Options{Model: "gpt-5"}); !strings.Contains(got, openAIPromptAddendum) {
|
||||
t.Fatalf("expected the OpenAI addendum in the gpt-5 prompt")
|
||||
}
|
||||
// Claude is aligned with the core prompt and gets no family addendum now that
|
||||
// comment discipline is universal; it must not pick up another family's block.
|
||||
claude := buildSystemPrompt(Options{Model: "claude-opus-4-6"})
|
||||
if strings.Contains(claude, "<model_guidance>") {
|
||||
t.Fatalf("expected no model_guidance block for Claude (aligned with core prompt)")
|
||||
if !strings.Contains(claude, anthropicPromptAddendum) {
|
||||
t.Fatalf("expected the Anthropic addendum in the claude prompt")
|
||||
}
|
||||
if strings.Contains(claude, openAIPromptAddendum) {
|
||||
t.Fatalf("the claude prompt must not contain the OpenAI addendum")
|
||||
|
||||
@@ -23,8 +23,7 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
"avoid broad refactors",
|
||||
"search the web before answering",
|
||||
"do not recognize",
|
||||
"scaled to the work",
|
||||
"comment density",
|
||||
"final response",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected core system prompt to include %q, got:\n%s", want, buildSystemPrompt(Options{}))
|
||||
@@ -32,19 +31,6 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemPromptExplainsSandboxEscalationForHiddenHostState(t *testing.T) {
|
||||
prompt := strings.ToLower(buildSystemPrompt(Options{}))
|
||||
for _, want := range []string{
|
||||
"sandboxed shell command returns only sandbox-local state",
|
||||
"sandbox_permissions: \"require_escalated\"",
|
||||
"prefix_rule",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected sandbox escalation guidance %q, got:\n%s", want, buildSystemPrompt(Options{}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptIncludesWorkspaceSeedFromCwd(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
writeSystemPromptTestFile(t, cwd, "go.mod", "module example.test/zero\n")
|
||||
@@ -129,7 +115,7 @@ func systemPromptTestBlock(t *testing.T, prompt, start, end string) string {
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptInjectsProjectGuidelinesCaseInsensitive(t *testing.T) {
|
||||
// Git tracks AGENTS.md on a case-sensitive filesystem; the
|
||||
// Git tracks AGENTS.MD (uppercase MD) on a case-sensitive filesystem; the
|
||||
// loader must still resolve it when the cwd lookup uses lowercase.
|
||||
cwd := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.MD"), []byte("Always run `make lint`."), 0o644); err != nil {
|
||||
|
||||
+13
-85
@@ -5,7 +5,6 @@ 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"
|
||||
)
|
||||
@@ -24,15 +23,6 @@ const (
|
||||
PermissionModeAsk PermissionMode = "ask"
|
||||
PermissionModeUnsafe PermissionMode = "unsafe"
|
||||
PermissionModeSpecDraft PermissionMode = "spec-draft"
|
||||
// PermissionModeMemberAuto is a headless mode for swarm/specialist MEMBERS: it
|
||||
// advertises the in-workspace mutators a member needs to build (write/edit +
|
||||
// shell) on top of the Auto set, while the sandbox engine still gates them at
|
||||
// call time — in-workspace writes and sandbox-backed shell auto-allow, but
|
||||
// out-of-workspace writes, network, and destructive commands still prompt (and
|
||||
// a headless member has no approver, so they are denied). It normalizes to Auto
|
||||
// everywhere except ToolAdvertised, so authority is never widened beyond what an
|
||||
// interactive auto agent already has inside the sandbox.
|
||||
PermissionModeMemberAuto PermissionMode = "member-auto"
|
||||
)
|
||||
|
||||
type StopReason string
|
||||
@@ -90,7 +80,7 @@ const (
|
||||
DenialFiltered DenialCategory = "filtered" // tool not enabled for this run
|
||||
DenialPermissionDenied DenialCategory = "permission_denied" // approval declined
|
||||
DenialApprovalCanceled DenialCategory = "approval_canceled" // approval canceled and run aborted
|
||||
DenialSandboxBlock DenialCategory = "sandbox_block" // blocked by the sandbox
|
||||
DenialSandboxViolation DenialCategory = "sandbox_violation" // blocked by the sandbox
|
||||
DenialHookBlocked DenialCategory = "hook_blocked" // vetoed by a beforeTool hook
|
||||
)
|
||||
|
||||
@@ -106,7 +96,7 @@ type PermissionRequest struct {
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Risk sandbox.Risk `json:"risk"`
|
||||
Args map[string]any `json:"args,omitempty"`
|
||||
Block *sandbox.Block `json:"block,omitempty"`
|
||||
Violation *sandbox.Violation `json:"violation,omitempty"`
|
||||
GrantMatched bool `json:"grantMatched,omitempty"`
|
||||
Grant *sandbox.Grant `json:"grant,omitempty"`
|
||||
CommandPrefix []string `json:"commandPrefix,omitempty"`
|
||||
@@ -132,25 +122,17 @@ type PermissionEvent struct {
|
||||
Scope string `json:"scope,omitempty"`
|
||||
DecisionReason string `json:"decisionReason,omitempty"`
|
||||
Risk sandbox.Risk `json:"risk"`
|
||||
Block *sandbox.Block `json:"block,omitempty"`
|
||||
Violation *sandbox.Violation `json:"violation,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. Options are
|
||||
// optional suggested answers an interactive front-end can render as a picker;
|
||||
// Recommended (when set) is the suggested default — it should match one of Options.
|
||||
// Header is an optional short tab title for a multi-question prompt (falls back to
|
||||
// the question text). OptionDescriptions, when present, holds a one-line description
|
||||
// per option aligned by index to Options (empty string = no description).
|
||||
// AskUserQuestion is one clarifying question the agent wants answered.
|
||||
type AskUserQuestion struct {
|
||||
Question string `json:"question"`
|
||||
Header string `json:"header,omitempty"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
OptionDescriptions []string `json:"optionDescriptions,omitempty"`
|
||||
Recommended string `json:"recommended,omitempty"`
|
||||
MultiSelect bool `json:"multiSelect,omitempty"`
|
||||
Question string `json:"question"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
MultiSelect bool `json:"multiSelect,omitempty"`
|
||||
}
|
||||
|
||||
// AskUserRequest is handed to OnAskUser when the model invokes the ask_user tool.
|
||||
@@ -174,16 +156,6 @@ type SpecialistInfo struct {
|
||||
WhenToUse string
|
||||
}
|
||||
|
||||
// SkillInfo is a one-line summary of a reusable, on-demand skill (its name and
|
||||
// frontmatter description) surfaced to the system prompt so the model can invoke
|
||||
// the right skill with the skill tool on the first try instead of guessing a name
|
||||
// and reading the failure. Like SpecialistInfo it is plain data, so the agent
|
||||
// package needs no dependency on internal/skills.
|
||||
type SkillInfo struct {
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
MaxTurns int
|
||||
// DeferThreshold activates deferred MCP-tool loading: when the number of
|
||||
@@ -199,12 +171,6 @@ type Options struct {
|
||||
// populated only where the Task tool is actually registered, so an empty slice
|
||||
// (the default) reproduces the previous prompt byte-for-byte.
|
||||
Specialists []SpecialistInfo
|
||||
// Skills lists the reusable skills installed for this run (the default skills
|
||||
// dir merged with any plugin skill roots). When non-empty the system prompt
|
||||
// gains an <available_skills> block naming them so the model loads the right one
|
||||
// via the skill tool on the first try. Empty (the default) reproduces the
|
||||
// previous prompt byte-for-byte.
|
||||
Skills []SkillInfo
|
||||
// Specialist/sub-agent metadata is carried through exec now and consumed by
|
||||
// the specialist runtime in later slices.
|
||||
SessionID string
|
||||
@@ -218,11 +184,6 @@ 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).
|
||||
@@ -246,28 +207,17 @@ 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)
|
||||
// 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)
|
||||
Hooks *hooks.Dispatcher
|
||||
EnabledTools []string
|
||||
DisabledTools []string
|
||||
OnText func(string)
|
||||
OnReasoning func(string)
|
||||
OnToolCall func(ToolCall)
|
||||
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.
|
||||
@@ -286,17 +236,6 @@ type Options struct {
|
||||
// byte-identical to before). One instance per run — it holds attempt state.
|
||||
SelfCorrect *SelfCorrector
|
||||
|
||||
// RequireCompletionSignal gates run completion for HEADLESS exec. Without it,
|
||||
// any assistant turn that produces text but no tool call is accepted as the
|
||||
// final answer. With it, a no-tool-call turn is NOT treated as "done" while
|
||||
// work clearly remains — pending update_plan items, or a message that ends on a
|
||||
// continuation cue ("…Let me check the config:"). The loop then nudges the
|
||||
// model to continue instead, bounded by maxContinueNudges (and still by
|
||||
// MaxTurns and the run deadline); if the model keeps stalling, the run
|
||||
// finalizes as INCOMPLETE (Result.Incomplete) rather than success. Default
|
||||
// false leaves the loop byte-identical, so the interactive TUI is unaffected.
|
||||
RequireCompletionSignal bool
|
||||
|
||||
runPermissions *permissionRunState
|
||||
}
|
||||
|
||||
@@ -310,17 +249,6 @@ type Result struct {
|
||||
// hit the token cap, FinishReasonContentFilter when it was filtered. Empty for
|
||||
// a normal completion.
|
||||
FinishReason string
|
||||
// Incomplete reports that a headless run (RequireCompletionSignal) stopped with
|
||||
// work clearly unfinished: the model ended a turn with no tool call while plan
|
||||
// items were pending or the message ended mid-step, the model admitted it
|
||||
// guessed / could not meet the objective, and/or it failed a task-grounded
|
||||
// acceptance check. Callers map it to a non-success terminal status / exit
|
||||
// code. False for every normal completion.
|
||||
Incomplete bool
|
||||
// IncompleteReason is a short, model-derived explanation of why the run was
|
||||
// marked Incomplete (e.g. "pending plan items remain"). Empty when Incomplete
|
||||
// is false. Surfaced in logs / run_end so an abandoned run is debuggable.
|
||||
IncompleteReason string
|
||||
}
|
||||
|
||||
// Truncated reports whether the final response ended abnormally (cut off at the
|
||||
|
||||
@@ -455,15 +455,11 @@ func TestHarnessTimeoutCancelsBlockedAgent(t *testing.T) {
|
||||
|
||||
// The timeout is longer than materialization of the small fixture even on
|
||||
// slower Windows CI runners, so the agent is reached before it blocks until
|
||||
// the deadline fires. 10s wasn't generous enough in practice — it failed
|
||||
// on an otherwise-unrelated PR merge (materialization alone consumed the
|
||||
// full budget on a contended Windows runner, so the agent was never
|
||||
// reached); widened for headroom, since the assertions below still bound
|
||||
// worst-case test time via that same deadline, not this constant.
|
||||
// the deadline fires.
|
||||
report := harness.Run(context.Background(), suitePath, suite, BenchmarkInput{
|
||||
TaskID: "document-stream-json-verify-events",
|
||||
WorkRoot: t.TempDir(),
|
||||
Timeout: 30 * time.Second,
|
||||
Timeout: 10 * time.Second,
|
||||
})
|
||||
|
||||
if !agentReached {
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
// 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.`
|
||||
@@ -1,71 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
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,
|
||||
// deps.newProvider is wrapped in fillAppDeps to apply the stored API key,
|
||||
// so ACP is authenticated for apiKeyStored profiles like every other
|
||||
// surface — no ACP-specific credential handling needed.
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
registry := newCoreRegistryScoped(workspaceRoot, scope)
|
||||
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
|
||||
return registry, 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
|
||||
}
|
||||
}
|
||||
+46
-362
@@ -6,8 +6,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -17,12 +15,10 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/localcontrol"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/observability"
|
||||
"github.com/Gitlawb/zero/internal/plugins"
|
||||
"github.com/Gitlawb/zero/internal/providerhealth"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/provideronboarding"
|
||||
"github.com/Gitlawb/zero/internal/providers"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
@@ -44,44 +40,37 @@ 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)
|
||||
// exportActiveProvider pins spawned children to the run's provider (production:
|
||||
// config.SetActiveProviderEnv, set in defaultAppDeps — deliberately NOT filled
|
||||
// by fillAppDeps, so tests never mutate the process environment unless they
|
||||
// inject it). nil ⇒ no export.
|
||||
exportActiveProvider func(providerName string)
|
||||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
||||
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)
|
||||
applyUpdate func(context.Context, update.Options) (update.ApplyResult, 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 {
|
||||
@@ -107,10 +96,9 @@ func Run(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
|
||||
func defaultAppDeps() appDeps {
|
||||
return appDeps{
|
||||
getwd: os.Getwd,
|
||||
stdin: os.Stdin,
|
||||
userConfigPath: config.DefaultUserConfigPath,
|
||||
exportActiveProvider: config.SetActiveProviderEnv,
|
||||
getwd: os.Getwd,
|
||||
stdin: os.Stdin,
|
||||
userConfigPath: config.DefaultUserConfigPath,
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
options, err := config.DefaultResolveOptions(workspaceRoot)
|
||||
if err != nil {
|
||||
@@ -127,19 +115,13 @@ func defaultAppDeps() appDeps {
|
||||
return config.ResolveMCP(options)
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
// Resolve the OAuth login ONCE: the bearer resolver and the login key it
|
||||
// bound must describe the same login (the key is passed on to the Codex
|
||||
// account-header resolver so it never re-selects independently).
|
||||
resolver, loginKey := oauthLoginForProfile(profile)
|
||||
return providers.New(profile, providers.Options{
|
||||
UserAgent: userAgent(),
|
||||
OAuthResolver: resolver,
|
||||
OAuthLoginKey: loginKey,
|
||||
OAuthResolver: oauthResolverForProfile(profile),
|
||||
})
|
||||
},
|
||||
probeProviderHealth: providerhealth.Probe,
|
||||
discoverProviderModels: defaultDiscoverProviderModels,
|
||||
detectLocalRuntimes: provideronboarding.DetectLocalRuntimes,
|
||||
probeProviderHealth: providerhealth.Probe,
|
||||
detectLocalRuntimes: provideronboarding.DetectLocalRuntimes,
|
||||
newSessionStore: func() *sessions.Store {
|
||||
return sessions.NewStore(sessions.StoreOptions{})
|
||||
},
|
||||
@@ -181,7 +163,6 @@ func defaultAppDeps() appDeps {
|
||||
runTUI: tui.Run,
|
||||
runEditor: openEditor,
|
||||
checkUpdate: update.Check,
|
||||
applyUpdate: update.Apply,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
@@ -209,21 +190,6 @@ 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)
|
||||
@@ -233,21 +199,9 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
// --theme <name> selects the TUI palette non-interactively (auto or any registered
|
||||
// theme; populates tui.Options.Theme, which resolveThemeMode prefers over
|
||||
// ZERO_THEME). Re-split --add-dir afterward so it may appear on either side of --theme.
|
||||
theme, args, err := splitLeadingThemeFlag(args)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
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, theme)
|
||||
return runInteractiveTUI(stderr, deps, agent.PermissionModeAsk, addDirs)
|
||||
}
|
||||
|
||||
// --add-dir grants an extra write root, and only the interactive TUI and
|
||||
@@ -281,48 +235,18 @@ 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)
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
return runInteractiveTUI(stderr, deps, agent.PermissionModeUnsafe, append(append([]string{}, addDirs...), moreDirs...))
|
||||
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
|
||||
}
|
||||
@@ -332,10 +256,7 @@ 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.
|
||||
// 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(addDirFlagArgs(addDirs), "--prompt", args[1])
|
||||
execArgs = append(execArgs, args[2:]...)
|
||||
return runExec(execArgs, stdout, stderr, deps)
|
||||
case "exec":
|
||||
@@ -363,8 +284,6 @@ 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":
|
||||
@@ -385,8 +304,6 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
return runSandbox(args[1:], stdout, stderr, deps)
|
||||
case "update":
|
||||
return runUpdate(args[1:], stdout, stderr, deps)
|
||||
case "upgrade":
|
||||
return runUpgrade(args[1:], stdout, stderr, deps)
|
||||
case "worktrees", "worktree":
|
||||
return runWorktrees(args[1:], stdout, stderr, deps)
|
||||
case "verify":
|
||||
@@ -403,20 +320,10 @@ 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
|
||||
}
|
||||
// First-run users reach for `zero login`/`zero logout` (reported in the
|
||||
// wild); point them at the real command instead of a bare usage pointer.
|
||||
switch strings.ToLower(args[0]) {
|
||||
case "login", "logout":
|
||||
if _, err := fmt.Fprintf(stderr, "did you mean %q?\n", "zero auth "+strings.ToLower(args[0])); err != nil {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintln(stderr, "Run zero --help for usage."); err != nil {
|
||||
return 1
|
||||
}
|
||||
@@ -447,9 +354,6 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
if deps.probeProviderHealth == nil {
|
||||
deps.probeProviderHealth = defaults.probeProviderHealth
|
||||
}
|
||||
if deps.discoverProviderModels == nil {
|
||||
deps.discoverProviderModels = defaults.discoverProviderModels
|
||||
}
|
||||
if deps.detectLocalRuntimes == nil {
|
||||
deps.detectLocalRuntimes = defaults.detectLocalRuntimes
|
||||
}
|
||||
@@ -519,31 +423,17 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
if deps.checkUpdate == nil {
|
||||
deps.checkUpdate = defaults.checkUpdate
|
||||
}
|
||||
if deps.applyUpdate == nil {
|
||||
deps.applyUpdate = defaults.applyUpdate
|
||||
}
|
||||
if deps.now == nil {
|
||||
deps.now = defaults.now
|
||||
}
|
||||
// Wrap newProvider ONCE, after all defaults are filled, so every surface that
|
||||
// builds the runtime provider gets the credential-store key applied. The
|
||||
// resolver is pure and leaves apiKeyStored profiles keyless; an unwrapped
|
||||
// build sends unauthenticated requests. config.ApplyStoredAPIKey is a no-op
|
||||
// when the key is already set or the profile isn't stored-key, so this is
|
||||
// safe and idempotent for every profile kind and every injected newProvider.
|
||||
baseNewProvider := deps.newProvider
|
||||
userConfigPath := deps.userConfigPath
|
||||
deps.newProvider = func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return baseNewProvider(applyStoredProviderKeyAt(profile, userConfigPath))
|
||||
}
|
||||
return deps
|
||||
}
|
||||
|
||||
func runInteractiveTUI(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string, theme string) int {
|
||||
return runInteractiveTUIWithSetup(stderr, deps, permissionMode, addDirs, theme, false)
|
||||
func runInteractiveTUI(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string) int {
|
||||
return runInteractiveTUIWithSetup(stderr, deps, permissionMode, addDirs, false)
|
||||
}
|
||||
|
||||
func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string, theme string, forceSetup bool) int {
|
||||
func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string, forceSetup bool) int {
|
||||
workspaceRoot, err := deps.getwd()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), 1)
|
||||
@@ -551,44 +441,12 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
|
||||
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
|
||||
if err != nil {
|
||||
// A resolve failure the setup wizard can FIX is not fatal for the
|
||||
// interactive TUI: drop into the wizard with an empty config so the user
|
||||
// can onboard/repair, instead of exiting with an error they can only fix
|
||||
// by hand-editing config.json. That covers a missing/unresolvable active
|
||||
// provider and an active provider without a model (custom endpoints have
|
||||
// no catalog default) — previously the second shape bricked bare `zero`
|
||||
// and `zero setup`, the exact commands that could have fixed it. Any
|
||||
// other error (malformed JSON, directory conflict, etc.) is still fatal,
|
||||
// and headless commands (zero config / zero exec) still fail with the
|
||||
// actionable message.
|
||||
if !errors.Is(err, config.ErrNoActiveProvider) && !errors.Is(err, config.ErrProviderRequiresModel) {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
// ErrNoActiveProvider can mean "nothing configured yet" (needs onboarding)
|
||||
// or "providers ARE configured, just none marked active" (e.g. config.json's
|
||||
// activeProvider is blank/stale). In the second case resolved.Providers still
|
||||
// carries the already-normalized list — prefer falling back to one of those
|
||||
// over wiping everything and forcing the user to re-enter credentials they
|
||||
// already saved.
|
||||
if usable, ok := firstUsableProvider(resolved.Providers); errors.Is(err, config.ErrNoActiveProvider) && ok {
|
||||
resolved.Provider = usable
|
||||
resolved.ActiveProvider = usable.Name
|
||||
} else {
|
||||
resolved = config.ResolvedConfig{}
|
||||
forceSetup = true
|
||||
}
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
userConfigPath, err := deps.userConfigPath()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to resolve user config path: "+err.Error(), 1)
|
||||
}
|
||||
// Fail-soft, one-time: move any inline plaintext API keys in config.json into
|
||||
// the encrypted credential store (interactive runs only; headless exec keeps
|
||||
// its existing behavior). Non-fatal — a missing keyring or write error leaves
|
||||
// the inline key in place and this run still uses the already-resolved key.
|
||||
if store, storeErr := config.ProviderKeyStoreAt(filepath.Dir(userConfigPath)); storeErr == nil {
|
||||
_, _ = config.MigratePlaintextProviderKeys(userConfigPath, store)
|
||||
}
|
||||
doctorUserConfigPath := ""
|
||||
projectConfigPath := ""
|
||||
if resolveOptions, optErr := config.DefaultResolveOptions(workspaceRoot); optErr == nil {
|
||||
@@ -629,7 +487,6 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
}
|
||||
|
||||
registry := newCoreRegistryScoped(workspaceRoot, scope)
|
||||
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
|
||||
specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot, resolved.Swarm.MaxTeamSize)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1)
|
||||
@@ -708,17 +565,13 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
lastKnownMCPConfig := mcpConfig
|
||||
return deps.runTUI(context.Background(), tui.Options{
|
||||
Cwd: workspaceRoot,
|
||||
Theme: theme,
|
||||
SavedTheme: resolved.Preferences.Theme,
|
||||
UserConfigPath: userConfigPath,
|
||||
DoctorUserConfigPath: doctorUserConfigPath,
|
||||
ProjectConfigPath: projectConfigPath,
|
||||
ProviderName: resolved.Provider.Name,
|
||||
ModelName: resolved.Provider.Model,
|
||||
ProviderProfile: resolved.Provider,
|
||||
SavedProviders: usableSavedProviders(resolved.Providers),
|
||||
FavoriteModels: resolved.Preferences.FavoriteModels,
|
||||
RecapsEnabled: resolved.Preferences.RecapsEnabled(),
|
||||
Provider: provider,
|
||||
NewProvider: deps.newProvider,
|
||||
ProbeProviderHealth: deps.probeProviderHealth,
|
||||
@@ -752,17 +605,15 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
MaxTurns: resolved.MaxTurns,
|
||||
Registry: registry,
|
||||
PermissionMode: permissionMode,
|
||||
Autonomy: "low",
|
||||
Autonomy: string(sandbox.AutonomyLow),
|
||||
Sandbox: sandboxEngine,
|
||||
FileTracker: tools.NewFileTracker(),
|
||||
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
|
||||
DeferThreshold: resolved.Tools.DeferThreshold,
|
||||
Specialists: specialistRuntime.specialists,
|
||||
Skills: pluginActivation.skillInfos(deps.skillsDir()),
|
||||
},
|
||||
PermissionMode: permissionMode,
|
||||
Notify: resolved.Notify,
|
||||
KeyBindings: resolved.KeyBindings,
|
||||
Setup: tui.SetupOptions{
|
||||
Visible: setupVisible,
|
||||
Required: needsSetup,
|
||||
@@ -790,50 +641,11 @@ func tuiSandboxSetupCommand(backend sandbox.Backend, deps appDeps) func(context.
|
||||
}
|
||||
}
|
||||
|
||||
// buildProvider constructs the run's provider at STARTUP — it is called only from
|
||||
// the two launch paths (interactive TUI and headless exec), never from mid-run
|
||||
// rebuilds (escalation, wizard, ACP go through deps.newProvider directly).
|
||||
func buildProvider(resolved config.ResolvedConfig, deps appDeps) (zeroruntime.Provider, error) {
|
||||
if !config.HasProviderProfile(resolved.Provider) {
|
||||
return nil, nil
|
||||
}
|
||||
// deps.newProvider is wrapped in fillAppDeps to apply the stored key, so this
|
||||
// (and every other newProvider caller) needs no per-site key handling.
|
||||
provider, err := deps.newProvider(resolved.Provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Pin spawned children (sub-agents / swarm members inherit the environment) to
|
||||
// THIS run's provider from launch, not only after an in-session switch. Without
|
||||
// the launch-time export a child re-resolves config.json at spawn time, so a
|
||||
// provider switch persisted by ANOTHER zero process mid-session would silently
|
||||
// move new children onto a different provider (and different credentials) than
|
||||
// the parent is running. Runtime switches (/model, /provider, wizard,
|
||||
// onboarding) re-export on commit, keeping the pin current. Injected via deps
|
||||
// (nil in tests) because it mutates the process environment.
|
||||
if deps.exportActiveProvider != nil {
|
||||
deps.exportActiveProvider(resolved.Provider.Name)
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
// applyStoredProviderKeyAt loads the API key from the encrypted credential store
|
||||
// when the resolver left it empty (the resolver is pure — no I/O — so an
|
||||
// apiKeyStored profile reaches this layer keyless). No-op for inline/env-key
|
||||
// providers and when nothing is stored. This is applied once, centrally, by the
|
||||
// deps.newProvider wrapper in fillAppDeps so EVERY surface that builds a runtime
|
||||
// provider (headless exec, the ACP builder, exec's mid-run escalation switcher,
|
||||
// and any future caller) is covered without a per-site invariant to forget.
|
||||
func applyStoredProviderKeyAt(profile config.ProviderProfile, userConfigPath func() (string, error)) config.ProviderProfile {
|
||||
if userConfigPath == nil {
|
||||
return profile
|
||||
}
|
||||
if path, perr := userConfigPath(); perr == nil {
|
||||
if store, err := config.ProviderKeyStoreAt(filepath.Dir(path)); err == nil {
|
||||
profile = config.ApplyStoredAPIKey(profile, store)
|
||||
}
|
||||
}
|
||||
return profile
|
||||
return deps.newProvider(resolved.Provider)
|
||||
}
|
||||
|
||||
func newCoreRegistry(workspaceRoot string) *tools.Registry {
|
||||
@@ -845,71 +657,9 @@ func newCoreRegistryScoped(workspaceRoot string, scope tools.PathScope) *tools.R
|
||||
for _, tool := range tools.CoreToolsScoped(workspaceRoot, scope) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
registerLocalControlTools(registry, workspaceRoot, config.LocalControlConfig{})
|
||||
return registry
|
||||
}
|
||||
|
||||
func registerLocalControlTools(registry *tools.Registry, workspaceRoot string, cfg config.LocalControlConfig) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
browserOptions := localBrowserOptionsFromConfig(cfg)
|
||||
desktopOptions := localDesktopOptionsFromConfig(cfg)
|
||||
terminalOptions := localTerminalOptionsFromConfig(cfg)
|
||||
for _, tool := range tools.NewLocalBrowserTools(browserOptions) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
for _, tool := range tools.NewLocalDesktopTools(desktopOptions) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
for _, tool := range tools.NewLocalTerminalTools(terminalOptions) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
for _, tool := range tools.NewLocalControlArtifactTools(tools.LocalControlArtifactOptions{
|
||||
Browser: browserOptions,
|
||||
Desktop: desktopOptions,
|
||||
Terminal: terminalOptions,
|
||||
ArtifactsDir: localArtifactsDirFromConfig(workspaceRoot, cfg),
|
||||
}) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
}
|
||||
|
||||
func localBrowserOptionsFromConfig(cfg config.LocalControlConfig) localcontrol.BrowserOptions {
|
||||
return localcontrol.BrowserOptions{
|
||||
Enabled: cfg.BrowserEnabled(),
|
||||
Driver: cfg.Browser.Driver,
|
||||
HelperPath: cfg.Browser.HelperPath,
|
||||
}
|
||||
}
|
||||
|
||||
func localDesktopOptionsFromConfig(cfg config.LocalControlConfig) localcontrol.DesktopOptions {
|
||||
return localcontrol.DesktopOptions{
|
||||
Enabled: cfg.DesktopEnabled(),
|
||||
Driver: cfg.Desktop.Driver,
|
||||
HelperPath: cfg.Desktop.HelperPath,
|
||||
}
|
||||
}
|
||||
|
||||
func localTerminalOptionsFromConfig(cfg config.LocalControlConfig) localcontrol.TerminalOptions {
|
||||
return localcontrol.TerminalOptions{
|
||||
Enabled: cfg.TerminalEnabled(),
|
||||
Driver: cfg.Terminal.Driver,
|
||||
HelperPath: cfg.Terminal.HelperPath,
|
||||
}
|
||||
}
|
||||
|
||||
func localArtifactsDirFromConfig(workspaceRoot string, cfg config.LocalControlConfig) string {
|
||||
dir := strings.TrimSpace(cfg.ArtifactsDir)
|
||||
if dir == "" {
|
||||
dir = filepath.Join(".zero", "artifacts")
|
||||
}
|
||||
if filepath.IsAbs(dir) {
|
||||
return dir
|
||||
}
|
||||
return filepath.Join(workspaceRoot, dir)
|
||||
}
|
||||
|
||||
// agentToolRuntime bundles the specialist runtime with the swarm it backs so
|
||||
// their lifetimes (and shutdown) stay paired: registerSpecialistTools brings up
|
||||
// both, closeSpecialistRuntime tears down both.
|
||||
@@ -1069,7 +819,6 @@ 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
|
||||
|
||||
@@ -1134,71 +883,6 @@ func splitLeadingAddDirFlags(args []string) ([]string, []string, error) {
|
||||
return addDirs, args, nil
|
||||
}
|
||||
|
||||
// splitLeadingThemeFlag strips a leading --theme <auto|theme-name> (space or =form)
|
||||
// from the root argument list and validates it against the registered themes. The
|
||||
// last occurrence wins. A value outside the allowed set is a loud error rather than
|
||||
// a silent fallback.
|
||||
func splitLeadingThemeFlag(args []string) (string, []string, error) {
|
||||
theme := ""
|
||||
for len(args) > 0 {
|
||||
var value string
|
||||
switch {
|
||||
case args[0] == "--theme":
|
||||
if len(args) < 2 {
|
||||
return "", nil, errors.New("--theme requires a value (auto or a theme name; try --theme auto)")
|
||||
}
|
||||
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
|
||||
}
|
||||
if !tui.ValidThemeArg(value) {
|
||||
return "", nil, fmt.Errorf("--theme must be auto or a registered theme name (got %q)", value)
|
||||
}
|
||||
theme = strings.ToLower(value)
|
||||
}
|
||||
return theme, args, nil
|
||||
}
|
||||
|
||||
// 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) != "" || profile.APIKeyStored || 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
|
||||
// or a private-network host (192.168.x.y / 10.x.y.z / 172.16.x.y) — a local
|
||||
// provider like Ollama/LM Studio that needs no API key.
|
||||
func baseURLIsLoopback(baseURL string) bool {
|
||||
u := strings.TrimSpace(baseURL)
|
||||
if u == "" {
|
||||
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() || addr.IsPrivate()
|
||||
}
|
||||
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
|
||||
|
||||
+9
-438
@@ -5,7 +5,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -17,6 +16,7 @@ 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"
|
||||
@@ -158,141 +158,6 @@ func TestRunNoArgsLaunchesSetupTUIWithNilProviderWhenNoProviderConfigured(t *tes
|
||||
assertAgentOptions(t, launchedOptions, 12, agent.PermissionModeAsk)
|
||||
}
|
||||
|
||||
func TestRunNoArgsEntersSetupWhenResolveReportsNoActiveProvider(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
setCLIUserConfigRoot(t)
|
||||
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
var launchedOptions tui.Options
|
||||
launched := false
|
||||
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{}, fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "ghost")
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
t.Fatal("newProvider should not be called without a resolved provider")
|
||||
return nil, nil
|
||||
},
|
||||
userConfigPath: func() (string, error) {
|
||||
return userConfigPath, nil
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
launched = true
|
||||
launchedOptions = options
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exit code = %d, want 0 (setup TUI launched), stderr=%q", exitCode, stderr.String())
|
||||
}
|
||||
if !launched {
|
||||
t.Fatal("TUI was not launched; expected fallback into setup instead of a fatal error")
|
||||
}
|
||||
if !launchedOptions.Setup.Visible || !launchedOptions.Setup.Required {
|
||||
t.Fatalf("Setup = %#v, want visible required setup", launchedOptions.Setup)
|
||||
}
|
||||
if launchedOptions.Provider != nil {
|
||||
t.Fatalf("Provider = %#v, want nil for no-provider fallback", launchedOptions.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNoArgsFallsBackToUsableProviderWhenNoneMarkedActive(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
setCLIUserConfigRoot(t)
|
||||
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
var launchedOptions tui.Options
|
||||
launched := false
|
||||
var providerProfile config.ProviderProfile
|
||||
fake := &cliFakeProvider{}
|
||||
|
||||
usable := config.ProviderProfile{
|
||||
Name: "work",
|
||||
ProviderKind: config.ProviderKindOpenAI,
|
||||
BaseURL: config.OpenAIBaseURL,
|
||||
APIKey: "sk-test",
|
||||
Model: "gpt-test",
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
// config.json has providers configured but none marked active (e.g. a
|
||||
// blank/stale activeProvider field) — Resolve returns the successfully
|
||||
// normalized providers list alongside ErrNoActiveProvider.
|
||||
return config.ResolvedConfig{Providers: []config.ProviderProfile{usable}},
|
||||
fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "")
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
providerProfile = profile
|
||||
return fake, nil
|
||||
},
|
||||
userConfigPath: func() (string, error) {
|
||||
return userConfigPath, nil
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
launched = true
|
||||
launchedOptions = options
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exit code = %d, want 0, stderr=%q", exitCode, stderr.String())
|
||||
}
|
||||
if !launched {
|
||||
t.Fatal("TUI was not launched")
|
||||
}
|
||||
if launchedOptions.Setup.Visible || launchedOptions.Setup.Required {
|
||||
t.Fatalf("Setup = %#v, want no setup wizard — a usable saved provider exists", launchedOptions.Setup)
|
||||
}
|
||||
if providerProfile.Name != "work" {
|
||||
t.Fatalf("provider used = %q, want fallback to the usable saved provider %q", providerProfile.Name, "work")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNoArgsFailsWhenResolveErrorIsNotProviderRelated(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
setCLIUserConfigRoot(t)
|
||||
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{}, fmt.Errorf("invalid config JSON")
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
t.Fatal("TUI must not launch on a non-provider resolve error")
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode == 0 {
|
||||
t.Fatal("exit code = 0, want non-zero for fatal config error")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "invalid config JSON") {
|
||||
t.Fatalf("stderr = %q, want the underlying config error", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNoArgsLaunchesTUIWithMCPState(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
@@ -698,29 +563,6 @@ 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
|
||||
@@ -753,6 +595,9 @@ func TestRunAddDirWiresExtraWriteRootIntoTUISandboxScope(t *testing.T) {
|
||||
t.Fatal("AgentOptions.Sandbox = nil, want sandbox engine")
|
||||
}
|
||||
roots := launchedOptions.AgentOptions.Sandbox.Scope().Roots()
|
||||
if len(roots) != 2 {
|
||||
t.Fatalf("scope roots = %v, want exactly workspace + extra", roots)
|
||||
}
|
||||
// The scope stores symlink-resolved roots (e.g. macOS /var -> /private/var),
|
||||
// so compare against the resolved extra dir.
|
||||
resolvedExtra, err := filepath.EvalSymlinks(extra)
|
||||
@@ -812,14 +657,10 @@ func TestRunSkipPermissionsUnsafeMergesAddDirGrants(t *testing.T) {
|
||||
t.Fatal("AgentOptions.Sandbox = nil, want sandbox engine")
|
||||
}
|
||||
roots := launchedOptions.AgentOptions.Sandbox.Scope().Roots()
|
||||
found := false
|
||||
for _, root := range roots {
|
||||
if root == resolvedExtra {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
if len(roots) != 2 {
|
||||
t.Fatalf("scope roots = %v, want exactly workspace + extra", roots)
|
||||
}
|
||||
if !found {
|
||||
if roots[1] != resolvedExtra {
|
||||
t.Fatalf("scope roots = %v, want extra root %q", roots, resolvedExtra)
|
||||
}
|
||||
})
|
||||
@@ -1328,153 +1169,6 @@ func TestRunUpdateReportsUpToDate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpdateApplyTextAndJSON(t *testing.T) {
|
||||
applyResult := update.ApplyResult{
|
||||
Result: update.Result{
|
||||
CurrentVersion: "dev",
|
||||
LatestVersion: "0.2.0",
|
||||
UpdateAvailable: true,
|
||||
},
|
||||
Applied: true,
|
||||
InstallMethod: update.InstallMethodStandalone,
|
||||
BinaryPath: "/usr/local/bin/zero",
|
||||
Message: "updated to 0.2.0",
|
||||
}
|
||||
var gotOptions update.Options
|
||||
deps := appDeps{
|
||||
applyUpdate: func(_ context.Context, options update.Options) (update.ApplyResult, error) {
|
||||
gotOptions = options
|
||||
return applyResult, nil
|
||||
},
|
||||
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
|
||||
t.Fatal("checkUpdate should not run for --apply")
|
||||
return update.Result{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"update", "--apply"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "updated to 0.2.0") {
|
||||
t.Fatalf("unexpected apply text: %q", stdout.String())
|
||||
}
|
||||
if gotOptions.CurrentVersion != "dev" {
|
||||
t.Fatalf("unexpected options passed to applyUpdate: %#v", gotOptions)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
exitCode = runWithDeps([]string{"update", "--apply", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Applied bool `json:"applied"`
|
||||
InstallMethod string `json:"installMethod"`
|
||||
BinaryPath string `json:"binaryPath"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("apply JSON did not decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if !payload.Applied || payload.InstallMethod != string(update.InstallMethodStandalone) || payload.BinaryPath != applyResult.BinaryPath {
|
||||
t.Fatalf("unexpected apply JSON: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpgradeDefaultsToApply(t *testing.T) {
|
||||
var applyCalled bool
|
||||
deps := appDeps{
|
||||
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
|
||||
applyCalled = true
|
||||
return update.ApplyResult{Message: "already up to date"}, nil
|
||||
},
|
||||
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
|
||||
t.Fatal("checkUpdate should not run for `zero upgrade`")
|
||||
return update.Result{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"upgrade"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if !applyCalled {
|
||||
t.Fatal("expected `zero upgrade` to call applyUpdate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpdateRejectsCheckAndApplyTogether(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps([]string{"update", "--check", "--apply"}, &stdout, &stderr, appDeps{
|
||||
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
|
||||
t.Fatal("checkUpdate should not run")
|
||||
return update.Result{}, nil
|
||||
},
|
||||
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
|
||||
t.Fatal("applyUpdate should not run")
|
||||
return update.ApplyResult{}, nil
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != exitUsage {
|
||||
t.Fatalf("expected usage exit code, got %d", exitCode)
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "only one of --check or --apply") {
|
||||
t.Fatalf("expected --check/--apply usage error, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpdateRejectsTargetWithApply(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"update", "--apply", "--target", "linux-arm64"},
|
||||
{"upgrade", "--target", "linux-arm64"},
|
||||
} {
|
||||
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
|
||||
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
|
||||
t.Fatal("applyUpdate should not run when --target is combined with --apply")
|
||||
return update.ApplyResult{}, nil
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != exitUsage {
|
||||
t.Fatalf("expected usage exit code, got %d", exitCode)
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "--target cannot be combined with --apply") {
|
||||
t.Fatalf("expected --target/--apply usage error, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpdateReportsApplyError(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps([]string{"update", "--apply"}, &stdout, &stderr, appDeps{
|
||||
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
|
||||
return update.ApplyResult{}, errors.New("download failed")
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode == exitSuccess {
|
||||
t.Fatalf("expected non-success exit code, got %d", exitCode)
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "download failed") {
|
||||
t.Fatalf("expected apply error, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func assertHelpOutput(t *testing.T, args []string) {
|
||||
t.Helper()
|
||||
|
||||
@@ -1620,8 +1314,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 != "low" {
|
||||
t.Fatalf("AgentOptions.Autonomy = %q, want %q", options.AgentOptions.Autonomy, "low")
|
||||
if options.AgentOptions.Autonomy != string(sandbox.AutonomyLow) {
|
||||
t.Fatalf("AgentOptions.Autonomy = %q, want %q", options.AgentOptions.Autonomy, sandbox.AutonomyLow)
|
||||
}
|
||||
if options.AgentOptions.Sandbox == nil {
|
||||
t.Fatal("AgentOptions.Sandbox = nil, want sandbox engine")
|
||||
@@ -1633,126 +1327,3 @@ 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 or a registered theme name") {
|
||||
t.Fatalf("expected a clear --theme error, got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A custom endpoint saved without a model previously bricked bare `zero` and
|
||||
// `zero setup` — the exact commands that could have fixed it (the resolve
|
||||
// error escaped before the wizard could open). The interactive TUI now treats
|
||||
// the requires-model failure as "needs onboarding", same as a missing active
|
||||
// provider. The error comes from a REAL Resolve over a real config file, so
|
||||
// this exercises the production sentinel wrapping, not a hand-built error.
|
||||
func TestRunNoArgsEntersSetupWhenActiveProviderMissesModel(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
setCLIUserConfigRoot(t)
|
||||
brokenConfig := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(brokenConfig, []byte(`{
|
||||
"activeProvider": "gw",
|
||||
"providers": [{"name": "gw", "provider_kind": "openai-compatible", "baseURL": "https://gw.example/v1", "apiKey": "sk-x"}]
|
||||
}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
launched := false
|
||||
var launchedOptions tui.Options
|
||||
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: brokenConfig, Env: map[string]string{}})
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
t.Fatal("newProvider must not be called without a resolved provider")
|
||||
return nil, nil
|
||||
},
|
||||
userConfigPath: func() (string, error) { return filepath.Join(t.TempDir(), "zero", "config.json"), nil },
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
runTUI: func(_ context.Context, options tui.Options) int {
|
||||
launched = true
|
||||
launchedOptions = options
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exit code = %d, want 0 (setup TUI launched), stderr=%q", exitCode, stderr.String())
|
||||
}
|
||||
if !launched {
|
||||
t.Fatal("TUI was not launched; a fixable requires-model config must fall into setup, not exit fatally")
|
||||
}
|
||||
if !launchedOptions.Setup.Visible || !launchedOptions.Setup.Required {
|
||||
t.Fatalf("Setup = %#v, want visible required setup", launchedOptions.Setup)
|
||||
}
|
||||
}
|
||||
|
||||
// `zero login`/`zero logout` don't exist (it's `zero auth login`); first-run
|
||||
// users try them (reported in the wild), so the unknown-command error points at
|
||||
// the real command.
|
||||
func TestRunUnknownLoginSuggestsAuthLogin(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"login"}, &stdout, &stderr, appDeps{})
|
||||
if exitCode != 2 {
|
||||
t.Fatalf("exit = %d, want 2 (unknown command)", exitCode)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), `"zero auth login"`) {
|
||||
t.Fatalf("stderr = %q, want a zero auth login suggestion", stderr.String())
|
||||
}
|
||||
// An unrelated unknown command gets no misleading suggestion.
|
||||
stderr.Reset()
|
||||
runWithDeps([]string{"frobnicate"}, &stdout, &stderr, appDeps{})
|
||||
if strings.Contains(stderr.String(), "did you mean") {
|
||||
t.Fatalf("stderr = %q, unrelated commands must not get the auth hint", stderr.String())
|
||||
}
|
||||
}
|
||||
+4
-132
@@ -9,7 +9,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
"github.com/Gitlawb/zero/internal/provideroauth"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
@@ -39,8 +38,6 @@ 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]))
|
||||
}
|
||||
@@ -77,98 +74,6 @@ 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.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
|
||||
@@ -283,9 +188,6 @@ func newAuthManager(deps appDeps, out io.Writer) (*oauth.Manager, error) {
|
||||
// carries no token material. A real browser launch is intentionally not
|
||||
// performed (the sandbox/headless contexts make printing the safer default).
|
||||
OpenBrowser: func(string) error { return nil },
|
||||
// `zero auth login <preset>` (e.g. xai) should resolve the baked-in preset
|
||||
// without the operator exporting ZERO_OAUTH_ALLOW_PRESETS first.
|
||||
AllowPresets: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -301,27 +203,12 @@ func runAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps appDep
|
||||
if len(parsed.positional) != 1 {
|
||||
return writeExecUsageError(stderr, "usage: zero auth login <provider> [--device] [--scope <scope>]")
|
||||
}
|
||||
provider := parsed.positional[0]
|
||||
// ChatGPT (Codex) requires a fixed redirect_uri (http://localhost:1455/
|
||||
// auth/callback) and mandatory authorize params (id_token_add_organizations,
|
||||
// codex_cli_simplified_flow, originator) that the generic loopback flow
|
||||
// cannot supply. Route it to the dedicated ChatGPT login so
|
||||
// `zero auth login chatgpt` behaves identically to `zero auth chatgpt`.
|
||||
if strings.EqualFold(provider, "chatgpt") {
|
||||
if parsed.device {
|
||||
return writeExecUsageError(stderr, "ChatGPT login does not support --device (it is loopback-only)")
|
||||
}
|
||||
if len(parsed.scopes) > 0 {
|
||||
return writeExecUsageError(stderr, "ChatGPT login does not support --scope (the required scopes are fixed by the Codex client registration)")
|
||||
}
|
||||
return runAuthChatGPT(nil, stdout, stderr, deps)
|
||||
}
|
||||
manager, err := newAuthManager(deps, stdout)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
status, err := manager.Login(context.Background(), oauth.LoginOptions{
|
||||
Provider: provider,
|
||||
Provider: parsed.positional[0],
|
||||
Device: parsed.device,
|
||||
ExtraScopes: parsed.scopes,
|
||||
})
|
||||
@@ -355,19 +242,6 @@ func runAuthLogout(args []string, stdout io.Writer, stderr io.Writer, deps appDe
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
// Also drop any stored API key and its marker so `auth logout` clears the whole
|
||||
// credential (OAuth token AND key), not just the OAuth side. Surface deletion
|
||||
// failures rather than reporting success while a credential remains.
|
||||
keyRemoved, keyErr := config.ForgetProviderKey(provider)
|
||||
if keyErr != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(keyErr, redaction.Options{}), exitCrash)
|
||||
}
|
||||
if configPath, perr := deps.userConfigPath(); perr == nil {
|
||||
if _, clearErr := config.ClearProviderKeyStored(configPath, provider); clearErr != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(clearErr, redaction.Options{}), exitCrash)
|
||||
}
|
||||
}
|
||||
removed = removed || keyRemoved
|
||||
if parsed.json {
|
||||
payload := struct {
|
||||
Provider string `json:"provider"`
|
||||
@@ -378,7 +252,7 @@ func runAuthLogout(args []string, stdout io.Writer, stderr io.Writer, deps appDe
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
msg := fmt.Sprintf("No stored credential for %s.\n", provider)
|
||||
msg := fmt.Sprintf("No OAuth login stored for %s.\n", provider)
|
||||
if removed {
|
||||
msg = fmt.Sprintf("Logged out of %s.\n", provider)
|
||||
}
|
||||
@@ -499,14 +373,12 @@ 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
|
||||
off by default — enable it with ZERO_OAUTH_ALLOW_PRESETS=1, or set the
|
||||
ZERO_OAUTH_XAI_* vars yourself. "chatgpt" ('zero auth login chatgpt' or
|
||||
'zero auth chatgpt') uses a fixed-port loopback flow against the Codex backend.
|
||||
Any preset field is overridable via the env vars below. For a custom provider named <name>, set:
|
||||
ZERO_OAUTH_XAI_* vars yourself. Any preset field is overridable via the env vars
|
||||
below. For a custom provider named <name>, set:
|
||||
ZERO_OAUTH_<NAME>_CLIENT_ID (required)
|
||||
ZERO_OAUTH_<NAME>_CLIENT_SECRET (optional)
|
||||
ZERO_OAUTH_<NAME>_AUTHORIZE_URL ZERO_OAUTH_<NAME>_TOKEN_URL
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// seedStoredProviderKey writes key into a credential store rooted beside the
|
||||
// returned config path, and returns deps fields wired at that path — the same
|
||||
// layout applyStoredProviderKey resolves through in production.
|
||||
func seedStoredProviderKey(t *testing.T, providerName, key string) (userConfigPath string) {
|
||||
t.Helper()
|
||||
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") // never touch the real OS keychain in tests
|
||||
dir := t.TempDir()
|
||||
store, err := config.ProviderKeyStoreAt(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("ProviderKeyStoreAt: %v", err)
|
||||
}
|
||||
if err := store.Set(providerName, key); err != nil {
|
||||
t.Fatalf("seed stored key: %v", err)
|
||||
}
|
||||
return filepath.Join(dir, "config.json")
|
||||
}
|
||||
|
||||
// applyStoredProviderKeyAt must fill APIKey from the credential store for an
|
||||
// apiKeyStored profile — the single central helper the newProvider wrapper uses.
|
||||
func TestApplyStoredProviderKeyAtFillsFromCredstore(t *testing.T) {
|
||||
configPath := seedStoredProviderKey(t, "echo", "sk-stored-test")
|
||||
ucp := func() (string, error) { return configPath, nil }
|
||||
|
||||
stored := applyStoredProviderKeyAt(config.ProviderProfile{Name: "echo", APIKeyStored: true}, ucp)
|
||||
if stored.APIKey != "sk-stored-test" {
|
||||
t.Fatalf("APIKey = %q, want the stored key", stored.APIKey)
|
||||
}
|
||||
|
||||
// A profile that did NOT opt into stored-key auth must be left unchanged —
|
||||
// a stale store entry must not silently reactivate credentials.
|
||||
plain := applyStoredProviderKeyAt(config.ProviderProfile{Name: "echo"}, ucp)
|
||||
if plain.APIKey != "" {
|
||||
t.Fatalf("non-stored profile APIKey = %q, want empty", plain.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
// fillAppDeps wraps newProvider so EVERY surface that builds a runtime provider
|
||||
// (buildProvider, the ACP builder — which is just deps.newProvider — and exec's
|
||||
// escalation switcher) gets the stored key applied. Passing the raw resolved
|
||||
// profile previously sent unauthenticated requests for apiKeyStored profiles,
|
||||
// the default onboarding outcome. Testing the wrap covers all those surfaces.
|
||||
func TestFillAppDepsWrapsNewProviderWithStoredKey(t *testing.T) {
|
||||
configPath := seedStoredProviderKey(t, "echo", "sk-stored-wrap")
|
||||
|
||||
var captured config.ProviderProfile
|
||||
deps := fillAppDeps(appDeps{
|
||||
userConfigPath: func() (string, error) { return configPath, nil },
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
captured = profile
|
||||
return nil, nil // only the captured profile matters here
|
||||
},
|
||||
})
|
||||
|
||||
if _, err := deps.newProvider(config.ProviderProfile{Name: "echo", APIKeyStored: true}); err != nil {
|
||||
t.Fatalf("wrapped newProvider: %v", err)
|
||||
}
|
||||
if captured.APIKey != "sk-stored-wrap" {
|
||||
t.Fatalf("wrapped newProvider built with APIKey = %q, want the stored key (unauthenticated regression)", captured.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
// buildProvider (the TUI/exec STARTUP construction site) must export
|
||||
// ZERO_PROVIDER so children spawned at any point in the run are pinned to the
|
||||
// parent's provider from launch — not only after an in-session switch. Without
|
||||
// this, a provider switch persisted by another zero process mid-session moves
|
||||
// new children onto a different provider (and credentials) than the parent.
|
||||
func TestBuildProviderExportsActiveProviderEnv(t *testing.T) {
|
||||
t.Setenv(config.ActiveProviderEnv, "stale-from-elsewhere")
|
||||
// The exporter is injected (defaultAppDeps wires config.SetActiveProviderEnv;
|
||||
// fillAppDeps deliberately leaves it nil so ordinary tests never mutate the
|
||||
// process env). Inject the real one here to assert the actual env effect.
|
||||
deps := appDeps{
|
||||
exportActiveProvider: config.SetActiveProviderEnv,
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
resolved := config.ResolvedConfig{Provider: config.ProviderProfile{Name: "pinned", ProviderKind: config.ProviderKindAnthropic, Model: "m"}}
|
||||
if _, err := buildProvider(resolved, deps); err != nil {
|
||||
t.Fatalf("buildProvider: %v", err)
|
||||
}
|
||||
if got := os.Getenv(config.ActiveProviderEnv); got != "pinned" {
|
||||
t.Fatalf("%s = %q after startup build, want %q (children would spawn on the stale provider)", config.ActiveProviderEnv, got, "pinned")
|
||||
}
|
||||
|
||||
// A FAILED build must not move the pin: the process never committed to the
|
||||
// new provider, so children must keep resolving whatever was in effect.
|
||||
t.Setenv(config.ActiveProviderEnv, "still-current")
|
||||
failing := appDeps{
|
||||
exportActiveProvider: config.SetActiveProviderEnv,
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return nil, errors.New("boom")
|
||||
},
|
||||
}
|
||||
if _, err := buildProvider(resolved, failing); err == nil {
|
||||
t.Fatal("expected build error")
|
||||
}
|
||||
if got := os.Getenv(config.ActiveProviderEnv); got != "still-current" {
|
||||
t.Fatalf("%s = %q after failed build, want it untouched", config.ActiveProviderEnv, got)
|
||||
}
|
||||
|
||||
// No provider profile at all → no provider, no pin change.
|
||||
if _, err := buildProvider(config.ResolvedConfig{}, deps); err != nil {
|
||||
t.Fatalf("buildProvider(empty): %v", err)
|
||||
}
|
||||
if got := os.Getenv(config.ActiveProviderEnv); got != "still-current" {
|
||||
t.Fatalf("%s = %q after profile-less build, want it untouched", config.ActiveProviderEnv, got)
|
||||
}
|
||||
}
|
||||
|
||||
// A mid-run --allow-escalation model switch previously rebuilt the provider
|
||||
// from the PURE resolved profile (the stored key lives only in buildProvider's
|
||||
// local copy), so the escalated provider was keyless and the next completion
|
||||
// call 401'd mid-run. Every profile handed to newProvider on this path must
|
||||
// carry the stored key.
|
||||
func TestRunExecEscalationSwitchKeepsStoredKey(t *testing.T) {
|
||||
dataHome := t.TempDir()
|
||||
t.Setenv("XDG_DATA_HOME", dataHome)
|
||||
cwd := t.TempDir()
|
||||
|
||||
if _, ok := mustUpgradeTarget(t, "claude-haiku-4.5"); !ok {
|
||||
t.Skip("registry has no upgrade target for claude-haiku-4.5")
|
||||
}
|
||||
|
||||
configPath := seedStoredProviderKey(t, "echo", "sk-stored-escalation")
|
||||
|
||||
var builtProfiles []config.ProviderProfile
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{
|
||||
"exec",
|
||||
"--allow-escalation",
|
||||
"--model", "claude-haiku-4.5",
|
||||
"--init-session-id", "escalation_stored_key_run",
|
||||
"escalate please",
|
||||
}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
userConfigPath: func() (string, error) { return configPath, nil },
|
||||
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
model := "claude-haiku-4.5"
|
||||
if overrides.Provider.Model != "" {
|
||||
model = overrides.Provider.Model
|
||||
}
|
||||
cfg := execResolvedConfig()
|
||||
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
|
||||
cfg.Provider.Model = model
|
||||
cfg.Provider.APIKeyStored = true // key lives ONLY in the credstore
|
||||
cfg.MaxTurns = 3
|
||||
return cfg, nil
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
builtProfiles = append(builtProfiles, profile)
|
||||
return &usageEmittingEscalatingProvider{escalate: len(builtProfiles) == 1}, nil
|
||||
},
|
||||
})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want success; stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if len(builtProfiles) < 2 {
|
||||
t.Fatalf("want at least 2 provider builds (initial + escalated), got %d", len(builtProfiles))
|
||||
}
|
||||
for i, profile := range builtProfiles {
|
||||
if profile.APIKey != "sk-stored-escalation" {
|
||||
t.Fatalf("provider build %d APIKey = %q, want the stored key (escalated build went out keyless)", i, profile.APIKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func TestRunAuthLogoutNothing(t *testing.T) {
|
||||
if code := runWithDeps([]string{"auth", "logout", "demo"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "No stored credential for demo") {
|
||||
if !strings.Contains(stdout.String(), "No OAuth login stored for demo") {
|
||||
t.Fatalf("logout output = %q", stdout.String())
|
||||
}
|
||||
}
|
||||
@@ -156,44 +156,3 @@ func TestRunAuthHelp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunAuthLoginChatGPTRoutesToDedicatedFlow verifies `zero auth login
|
||||
// chatgpt` reaches the dedicated ChatGPT login (fixed-port loopback + mandatory
|
||||
// authorize params), not the generic manager path. The generic login accepts
|
||||
// --device, so a ChatGPT-specific rejection proves the routing took effect.
|
||||
// See issue #430: the generic path built a random-port 127.0.0.1 redirect_uri
|
||||
// without the required extra params, so OpenAI's authorize endpoint rejected it.
|
||||
func TestRunAuthLoginChatGPTRoutesToDedicatedFlow(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "login", "chatgpt", "--device"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Fatal("auth login chatgpt --device should be rejected (ChatGPT is loopback-only)")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "ChatGPT login does not support --device") {
|
||||
t.Fatalf("stderr = %q, want the ChatGPT-specific --device rejection", stderr.String())
|
||||
}
|
||||
// Case-insensitive provider name should still route.
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := runWithDeps([]string{"auth", "login", "ChatGPT", "--device"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Fatal("auth login ChatGPT --device should be rejected")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "ChatGPT login does not support --device") {
|
||||
t.Fatalf("stderr = %q, want the ChatGPT-specific rejection (case-insensitive)", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunAuthLoginChatGPTRejectsScope mirrors the --device rejection: --scope
|
||||
// must not be silently dropped on the ChatGPT path. The Codex client
|
||||
// registration pins a fixed scope set (incl. api.connectors.*), so custom
|
||||
// scopes are rejected up front rather than plumbed through.
|
||||
func TestRunAuthLoginChatGPTRejectsScope(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "login", "chatgpt", "--scope", "custom-scope"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Fatal("auth login chatgpt --scope should be rejected")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "ChatGPT login does not support --scope") {
|
||||
t.Fatalf("stderr = %q, want the ChatGPT-specific --scope rejection", stderr.String())
|
||||
}
|
||||
}
|
||||
@@ -81,9 +81,6 @@ func runProviders(args []string, stdout io.Writer, stderr io.Writer, deps appDep
|
||||
if command == "detect" {
|
||||
return runProvidersDetect(args, stdout, stderr, deps)
|
||||
}
|
||||
if command == "models" {
|
||||
return runProvidersModels(args, stdout, stderr, deps)
|
||||
}
|
||||
if command != "list" && command != "current" && command != "catalog" {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unknown providers command %q", command))
|
||||
}
|
||||
@@ -280,9 +277,6 @@ func listProviderCatalogSummaries(options commandCenterOptions) ([]providerCatal
|
||||
return nil, err
|
||||
}
|
||||
sort.SliceStable(summaries, func(i int, j int) bool {
|
||||
if summaries[i].Recommended != summaries[j].Recommended {
|
||||
return summaries[i].Recommended
|
||||
}
|
||||
return summaries[i].ID < summaries[j].ID
|
||||
})
|
||||
return summaries, nil
|
||||
@@ -360,17 +354,10 @@ func formatProviderCatalogSummaries(providers []providerCatalogSummary) string {
|
||||
}
|
||||
|
||||
func formatProviderCatalogLine(provider providerCatalogSummary) string {
|
||||
name := formatProviderCatalogValue(provider.Name, "unknown")
|
||||
prefix := " id="
|
||||
if provider.Recommended {
|
||||
prefix = " ★ id="
|
||||
name += " (recommended)"
|
||||
}
|
||||
lines := []string{
|
||||
fmt.Sprintf("%s%s name=%s",
|
||||
prefix,
|
||||
fmt.Sprintf(" id=%s name=%s",
|
||||
formatProviderCatalogValue(provider.ID, "unknown"),
|
||||
name,
|
||||
formatProviderCatalogValue(provider.Name, "unknown"),
|
||||
),
|
||||
fmt.Sprintf(" transport=%s defaultModel=%s",
|
||||
formatProviderCatalogValue(provider.Transport, "unknown"),
|
||||
@@ -468,11 +455,9 @@ func writeProvidersHelp(w io.Writer) error {
|
||||
zero providers use <name> [flags]
|
||||
zero providers setup <catalog-id> [flags]
|
||||
zero providers detect [flags]
|
||||
zero providers models [name] [flags]
|
||||
|
||||
Inspects resolved provider profiles and provider catalog descriptors without printing secrets.
|
||||
Detect probes for running local runtimes (Ollama, LM Studio) and prints adopt commands plus per-provider next steps.
|
||||
Models probes a provider's live model-listing endpoint (e.g. an OpenAI-compatible /v1/models) and lists the models it serves — including custom OpenAI-/Anthropic-compatible endpoints — so a self-hosted provider needs no per-model config.
|
||||
|
||||
Flags:
|
||||
--json Print JSON summary
|
||||
|
||||
@@ -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, stdout, stderr, func(id string) error { return store.Remove(id) }, "Removed")
|
||||
return cronSimple(store, rest, stderr, func(id string) error { return store.Remove(id) }, "removed")
|
||||
case "pause":
|
||||
return cronSetStatus(store, rest, stdout, stderr, cron.StatusPaused, "Paused")
|
||||
return cronSetStatus(store, rest, stderr, cron.StatusPaused)
|
||||
case "resume":
|
||||
return cronResume(store, now, rest, stdout, stderr)
|
||||
return cronResume(store, now, rest, 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, stdout io.Writer, stderr io.Writer, fn func(string) error, verb string) int {
|
||||
func cronSimple(store *cron.Store, args []string, stderr io.Writer, fn func(string) error, verb string) int {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(stderr, "An id is required.")
|
||||
return exitUsage
|
||||
@@ -204,11 +204,10 @@ func cronSimple(store *cron.Store, args []string, stdout io.Writer, stderr io.Wr
|
||||
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, stdout io.Writer, stderr io.Writer, status string, verb string) int {
|
||||
func cronSetStatus(store *cron.Store, args []string, stderr io.Writer, status string) int {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(stderr, "An id is required.")
|
||||
return exitUsage
|
||||
@@ -223,12 +222,11 @@ func cronSetStatus(store *cron.Store, args []string, stdout io.Writer, stderr io
|
||||
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, stdout io.Writer, stderr io.Writer) int {
|
||||
func cronResume(store *cron.Store, now func() time.Time, args []string, stderr io.Writer) int {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(stderr, "An id is required.")
|
||||
return exitUsage
|
||||
@@ -256,7 +254,6 @@ func cronResume(store *cron.Store, now func() time.Time, args []string, stdout 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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+20
-93
@@ -134,24 +134,6 @@ 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)
|
||||
@@ -187,94 +169,39 @@ 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.Truncate(time.Minute)); nxt.IsZero() {
|
||||
} else if nxt := sched.Next(fired); 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 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).
|
||||
// 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.
|
||||
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
|
||||
}
|
||||
// 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.AppendRun(job.ID, rec); err != nil {
|
||||
fmt.Fprintf(stderr, "warning: failed to record run for %s: %v\n", job.ID, err)
|
||||
}
|
||||
if err != nil {
|
||||
if err := store.Update(job); 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(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
|
||||
fmt.Fprintf(stdout, "fired %s -> exit %d (next: %s)\n", job.ID, code, formatCronTime(job.NextRunAt))
|
||||
}
|
||||
|
||||
func cronTruncate(s string, max int) string {
|
||||
|
||||
@@ -80,59 +80,6 @@ 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 }})
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -127,8 +128,8 @@ func TestValidateExecToolFiltersAllowsToolSearch(t *testing.T) {
|
||||
// TestRunExecListToolsAdvertisesMCPToolsWithoutToolSearch verifies that
|
||||
// `exec --list-tools` lists the core + MCP tools WITHOUT tool_search. This is
|
||||
// independent of the deferral threshold: --list-tools short-circuits and returns
|
||||
// before registerToolSearchIfEligible runs (see exec.go), so tool_search is
|
||||
// never registered on this path. The threshold gate itself is
|
||||
// before resolveConfig and registerToolSearchIfEligible ever run (see exec.go),
|
||||
// so tool_search is never registered on this path. The threshold gate itself is
|
||||
// exercised by TestRegisterToolSearchIfEligible{RegistersAtThreshold,
|
||||
// SkipsBelowThreshold,SkipsWhenThresholdZero} and the end-to-end at-threshold
|
||||
// path in TestTUIRunThreadsDeferThresholdAndRegistersToolSearch.
|
||||
@@ -140,7 +141,7 @@ func TestRunExecListToolsAdvertisesMCPToolsWithoutToolSearch(t *testing.T) {
|
||||
exitCode := runWithDeps([]string{"exec", "--list-tools"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return execResolvedConfig(), nil
|
||||
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
|
||||
},
|
||||
resolveMCPConfig: func(string) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
@@ -175,7 +176,7 @@ func TestRunExecListToolsHonorsJSONFormat(t *testing.T) {
|
||||
exitCode := runWithDeps([]string{"exec", "--list-tools", "-o", "json"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return execResolvedConfig(), nil
|
||||
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
|
||||
},
|
||||
resolveMCPConfig: func(string) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
|
||||
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
|
||||
|
||||
Loaded 100 of 617 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user