Compare commits

..
Author SHA1 Message Date
Vasanthdev2004 f5557a955d fix(deps): refresh the lockfile to patched shell-quote
shell-quote 1.9.0 is inside the range GHSA-pqg4-j6r4-53mv covers
(>= 1.8.4, < 1.11.0). It reaches the lock through tuistory,
@opentui/react and react-devtools-core, which asks for ^1.6.1, so
refreshing the lock moves it to 1.12.0 without touching package.json.
Release packaging installs the local-control helpers from this lock,
so the old version shipped in the helper tree.
2026-10-14 15:50:24 +05:30
781 changed files with 2633 additions and 16924 deletions

No files matched your search

+3 -3
View File
@@ -1,11 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Questions and setup help
url: https://github.com/Twigpine/zero/discussions/categories/q-a
url: https://github.com/Gitlawb/zero/discussions/categories/q-a
about: Ask in Discussions Q&A. Include zero --version, your OS, provider/model, and zero doctor output for setup problems.
- name: Feature ideas
url: https://github.com/Twigpine/zero/discussions/categories/ideas
url: https://github.com/Gitlawb/zero/discussions/categories/ideas
about: Propose and discuss ideas in Discussions before opening a PR (Zero is in controlled development, see CONTRIBUTING.md).
- name: Security vulnerability
url: https://github.com/Twigpine/zero/security/advisories/new
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 -1
View File
@@ -5,7 +5,7 @@ body:
- type: markdown
attributes:
value: |
New feature ideas start in [Discussions Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas),
New feature ideas start in [Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
not here. This form is for the step after that: an idea that gained traction in Discussions,
or one a maintainer asked to have tracked as an issue. Please link the discussion below.
Do not begin implementation until the issue has been reviewed and marked with the
+55 -23
View File
@@ -100,21 +100,19 @@ jobs:
gh release create "$tag" --repo "$GITHUB_REPOSITORY" --title "$tag" --generate-notes 2>/dev/null || true
gh release upload "$tag" dist/release/* --repo "$GITHUB_REPOSITORY" --clobber
# Publishes the npm package named in package.json (@twigpine/zero): first
# the five platform payloads (versions of the SAME package at suffixed
# versions like X.Y.Z-linux-x64, carrying the native binary + vendored
# helpers), then the wrapper at X.Y.Z whose optionalDependencies alias those
# platform versions. Platform versions MUST publish first — the wrapper pins
# them exactly, so the reverse order would open a window where installs
# resolve aliases that 404. See docs/NPM_PACKAGING.md for the full contract.
# Publishes @gitlawb/zero to npm: first the five platform payloads (versions
# of the SAME package at suffixed versions like X.Y.Z-linux-x64, carrying the
# native binary + vendored helpers), then the wrapper at X.Y.Z whose
# optionalDependencies alias those platform versions. Platform versions MUST
# publish first — the wrapper pins them exactly, so the reverse order would
# open a window where installs resolve aliases that 404. See
# docs/NPM_PACKAGING.md for the full contract.
#
# Auth is npm OIDC trusted publishing: no NPM_TOKEN secret. The package's
# npmjs.com settings must list this repo + workflow file + the npm-publish
# environment as a trusted publisher, and provenance is then generated
# automatically. npm checks provenance against package.json `repository`,
# so that must name this repository. Note npm only allows configuring a
# trusted publisher on an EXISTING package, so a new package name starts
# with a one-time manual `npm publish` of an empty 0.0.0 placeholder.
# npmjs.com settings must list this repo + workflow file as a trusted
# publisher, and provenance is then generated automatically. Note npm only
# allows configuring a trusted publisher on an EXISTING package, so the very
# first @gitlawb/zero publish is a one-time manual `npm publish` bootstrap.
# Because the platform payloads are versions of the same package, they reuse
# that single trusted-publisher configuration.
publish-npm:
@@ -127,7 +125,7 @@ jobs:
# maintainer approves it in the Actions UI before anything reaches npm.
environment:
name: npm-publish
url: https://www.npmjs.com/package/@twigpine/zero
url: https://www.npmjs.com/package/@gitlawb/zero
permissions:
contents: read
id-token: write # OIDC token exchange for npm trusted publishing + provenance
@@ -203,13 +201,47 @@ jobs:
- name: Assemble npm packages
run: node scripts/npm/build-platform-packages.mjs --artifacts-dir dist/artifacts --out-dir dist/npm
# Platform payloads first under the `platform` dist-tag, then the wrapper,
# with the `latest` dist-tag checked before and after (platform versions
# are semver PRERELEASES of the wrapper version and must never become
# `latest`). npm applies publishes asynchronously, so the script waits
# until npm shows every platform version before the wrapper goes out,
# and until `latest` moves after it. Versions already on the registry
# are skipped, so "Re-run failed jobs" is safe after a partial publish.
- name: Publish npm packages
# Platform versions are semver PRERELEASES of the wrapper version
# (X.Y.Z-linux-x64), so they must never publish under the default
# `latest` dist-tag — that would make `npm install @gitlawb/zero`
# deliver a platform payload as the CLI. Publish them under a dedicated
# tag and assert `latest` survived before the wrapper goes out.
- name: Publish platform packages to npm
shell: bash
run: scripts/npm/publish-packages.sh dist/npm
run: |
version="$(node -p "require('./package.json').version")"
for dir in dist/npm/platforms/*/; do
echo "publishing ${dir}"
(cd "$dir" && npm publish --access public --tag platform)
done
# Tolerate a failed lookup (registry lag, or no latest tag yet on a
# bootstrap publish): under set -e a bare failure here would abort
# AFTER the platform payloads went out, leaving no wrapper and a
# re-run that fails on republishing existing versions. An empty
# value falls through the guard below.
latest="$(npm view @gitlawb/zero dist-tags.latest || true)"
case "$latest" in
"${version}-"*)
echo "::error::dist-tag latest points at platform version ${latest} — a platform publish clobbered it. Fix the dist-tag before the wrapper publish." >&2
exit 1
;;
esac
echo "dist-tag latest is ${latest} (unchanged by platform publishes)."
- name: Publish wrapper to npm
shell: bash
run: |
version="$(node -p "require('./package.json').version")"
(cd dist/npm/wrapper && npm publish --access public)
# Confirm the wrapper owns `latest` (retry: registry metadata can lag
# a publish by a few seconds).
for attempt in 1 2 3 4 5; do
latest="$(npm view @gitlawb/zero dist-tags.latest || true)"
if [ "$latest" = "$version" ]; then
echo "dist-tag latest is ${version}."
exit 0
fi
sleep 5
done
echo "::error::dist-tag latest is ${latest}, expected ${version} after the wrapper publish." >&2
exit 1
+1 -1
View File
@@ -1 +1 @@
{".":"0.11.0"}
{".":"0.9.0"}
-64
View File
@@ -6,70 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
tagged. Until then, source builds report the version `dev`.
## [0.11.0](https://github.com/Twigpine/zero/compare/v0.10.0...v0.11.0) (2026-10-15)
### Features
* **providers:** add Concentrate provider preset ([#1147](https://github.com/Twigpine/zero/issues/1147)) ([61b0936](https://github.com/Twigpine/zero/commit/61b09364f0f45b0fa8aebaf0a6c3d1c55dbe01c3))
### Bug Fixes
* **cli:** let providers subcommands run with no active provider ([#1143](https://github.com/Twigpine/zero/issues/1143)) ([d1a0d04](https://github.com/Twigpine/zero/commit/d1a0d048ee2c1e7cbfb75160ec4c9f099f3c4472))
* **npm:** wait for npm to apply each publish, and drop the one-time @gitlawb/zero steps ([#1145](https://github.com/Twigpine/zero/issues/1145)) ([8e363fc](https://github.com/Twigpine/zero/commit/8e363fcaa5d51bf5b5b244692dc61bf8d4ae8799))
* **tui,cli:** stop a repository's git config from running programs in Zero's own git calls ([#1133](https://github.com/Twigpine/zero/issues/1133)) ([640d936](https://github.com/Twigpine/zero/commit/640d936bfbe00c4cd3017ca35fb7b5c15d91a1c5))
* **workspaceindex:** skip git-ignored and build-cache dirs in Scan only ([#1117](https://github.com/Twigpine/zero/issues/1117)) ([c88c77b](https://github.com/Twigpine/zero/commit/c88c77bf701039dca01d9786bf5648d779c949b3))
### Performance Improvements
* **redaction:** add fast-path byte checks to eliminate regex allocations on clean strings ([#945](https://github.com/Twigpine/zero/issues/945)) ([ce2fcff](https://github.com/Twigpine/zero/commit/ce2fcffaf10d8e8c8c5a13e06fd7809f09fc9867))
## [0.10.0](https://github.com/Twigpine/zero/compare/v0.9.0...v0.10.0) (2026-10-15)
### Features
* **notify:** make permission-prompt alerts on by default and discoverable ([#1001](https://github.com/Twigpine/zero/issues/1001)) ([7f8e5f0](https://github.com/Twigpine/zero/commit/7f8e5f08bb83371ce16cc54c97ab032a010026cf))
* **npm:** publish as @twigpine/zero ([#1142](https://github.com/Twigpine/zero/issues/1142)) ([530563a](https://github.com/Twigpine/zero/commit/530563a0bb58d3cbadceb97b3ee234ef763124df))
* **providers:** add GMI Cloud provider preset and partner listing ([#1127](https://github.com/Twigpine/zero/issues/1127)) ([1335ca6](https://github.com/Twigpine/zero/commit/1335ca6671023ecda81a3cfaae7e9f2ff5d6e0de))
* **providers:** opt-in ZERO_RESPONSE_HEADER_TIMEOUT override for slow first-byte deployments ([#1106](https://github.com/Twigpine/zero/issues/1106)) ([ddc45f2](https://github.com/Twigpine/zero/commit/ddc45f2c8152fdd04b45dc11ed20d246fe7740a5))
* **runtime:** add process-local per-model RPM admission limits ([#1116](https://github.com/Twigpine/zero/issues/1116)) ([eebb384](https://github.com/Twigpine/zero/commit/eebb384676fb567acc1f5a20dcbb17df8a03dc42))
* **sessions:** add zero sessions prune, which never removes a session another process has open ([#1089](https://github.com/Twigpine/zero/issues/1089)) ([23c27e0](https://github.com/Twigpine/zero/commit/23c27e0778e3af54c04b8749d1eda846d3253f4e))
### Bug Fixes
* **acp:** persist the session permission mode across restarts ([#1134](https://github.com/Twigpine/zero/issues/1134)) ([bb96206](https://github.com/Twigpine/zero/commit/bb96206c2781e846fd13437113eec5790afe9c81)), closes [#1125](https://github.com/Twigpine/zero/issues/1125)
* **acp:** persist token usage events for ACP sessions ([#1137](https://github.com/Twigpine/zero/issues/1137)) ([300cf62](https://github.com/Twigpine/zero/commit/300cf6275f8c94f06da3f00197d21cb8771842d9)), closes [#1118](https://github.com/Twigpine/zero/issues/1118)
* **acp:** publish sub-run sessions loaded over ACP as view-only ([#1078](https://github.com/Twigpine/zero/issues/1078)) ([7f11491](https://github.com/Twigpine/zero/commit/7f11491078662b2d389c278f3ce5d41d956670c4))
* **agent:** retry mid-stream transport aborts before tool dispatch ([#1076](https://github.com/Twigpine/zero/issues/1076)) ([b9ef1d2](https://github.com/Twigpine/zero/commit/b9ef1d271aa6ebc6d9adad2ef4ff0d695bb40da5))
* **config:** sync atomic config replacements ([#1093](https://github.com/Twigpine/zero/issues/1093)) ([4d2e9f0](https://github.com/Twigpine/zero/commit/4d2e9f0d08472f7304652ef7896df587e1cc6c21))
* **cron:** bound run history and read recent records from the tail ([#1094](https://github.com/Twigpine/zero/issues/1094)) ([42eb6ab](https://github.com/Twigpine/zero/commit/42eb6ab5fbe4ae9037f1f4cd42a00243c456a22e))
* **cron:** preserve newer claims when overlapping runs complete ([#1090](https://github.com/Twigpine/zero/issues/1090)) ([2eb9d94](https://github.com/Twigpine/zero/commit/2eb9d94fac56d91d749ea4f96edcf1c87f79b48c))
* **daemon:** bound pending sessions and prune on completion ([#1091](https://github.com/Twigpine/zero/issues/1091)) ([8a68660](https://github.com/Twigpine/zero/commit/8a68660161b04ce9668363faa14ff72c9fbad223))
* **daemon:** do not retry workers that finished with a final exit code ([#1112](https://github.com/Twigpine/zero/issues/1112)) ([eefa978](https://github.com/Twigpine/zero/commit/eefa978f6183332b7416c70c25242bcdfba1a75c)), closes [#1100](https://github.com/Twigpine/zero/issues/1100)
* **daemon:** stop worker process groups by their launch-time identity ([#1088](https://github.com/Twigpine/zero/issues/1088)) ([ab018b3](https://github.com/Twigpine/zero/commit/ab018b3098b5cb103cf8f90b8698d32a6687bdf7)), closes [#861](https://github.com/Twigpine/zero/issues/861)
* **deps:** refresh the lockfile to patched hono and @hono/node-server ([#1080](https://github.com/Twigpine/zero/issues/1080)) ([aa52040](https://github.com/Twigpine/zero/commit/aa52040498e012c41d5e8f6027a91c18eff861d4))
* **deps:** refresh the lockfile to patched shell-quote ([#1131](https://github.com/Twigpine/zero/issues/1131)) ([c56a4c4](https://github.com/Twigpine/zero/commit/c56a4c4e6e598504ab417e8cc4f5ab8d69f1b42f))
* **execution:** reserve process capacity before launching ([#1092](https://github.com/Twigpine/zero/issues/1092)) ([bb3346a](https://github.com/Twigpine/zero/commit/bb3346a82883c19ed8815fe1e874f54950711d69))
* **mcp:** forward image tool results on the existing image channel ([#1077](https://github.com/Twigpine/zero/issues/1077)) ([0324c1d](https://github.com/Twigpine/zero/commit/0324c1d4103328eb90cb24a0b582dba335255b5c))
* **mcp:** refresh OAuth tokens for discovered endpoints and registered clients ([#1113](https://github.com/Twigpine/zero/issues/1113)) ([571af70](https://github.com/Twigpine/zero/commit/571af701bd3d8dfaf40f87b5b112b25e7def5b29)), closes [#1101](https://github.com/Twigpine/zero/issues/1101)
* **oauth:** give every loopback callback server the bounded shutdown ([#1079](https://github.com/Twigpine/zero/issues/1079)) ([5473301](https://github.com/Twigpine/zero/commit/547330197b20ece591e077e876a4b8ffd015cb6e))
* **providers:** send stable session headers to OpenCode ([#1060](https://github.com/Twigpine/zero/issues/1060)) ([99721c7](https://github.com/Twigpine/zero/commit/99721c762f37cd43ac511007a5f51d1846df959e))
* **redaction:** redact adjacent AWS keys glued end to end ([#1095](https://github.com/Twigpine/zero/issues/1095)) ([df67ede](https://github.com/Twigpine/zero/commit/df67edec13345b73a85327adc003cacc7bdd8009))
* **redaction:** redact credentials split by an invisible character ([#1067](https://github.com/Twigpine/zero/issues/1067)) ([e20b762](https://github.com/Twigpine/zero/commit/e20b7624261cef8336a8eb0fbdd44d536ad77fc2))
* **sandbox:** say when a session's sandbox enforcement is degraded ([#1082](https://github.com/Twigpine/zero/issues/1082)) ([6b5852d](https://github.com/Twigpine/zero/commit/6b5852dc18bb256af1f25745697c2f2e3ea6f112))
* **search:** redact every text field of session metadata, not ten of thirty ([#1073](https://github.com/Twigpine/zero/issues/1073)) ([9520f2b](https://github.com/Twigpine/zero/commit/9520f2bad7e09ce4a8d0d42be7ce77d2ae928ddd))
* **security:** harden shell flag parsing, write roots, and formatters ([#1066](https://github.com/Twigpine/zero/issues/1066)) ([ac5fe0d](https://github.com/Twigpine/zero/commit/ac5fe0d21849b0f74af428dd3ca12e3b80a8b7aa))
* **sessions:** keep a session lease for the life of the process, not of the Store ([#1144](https://github.com/Twigpine/zero/issues/1144)) ([883e5be](https://github.com/Twigpine/zero/commit/883e5bec24dac791d1d60618c7f4927f4659b687))
* **tools:** hint when Windows resolves POSIX paths under root ([#1063](https://github.com/Twigpine/zero/issues/1063)) ([0c6f179](https://github.com/Twigpine/zero/commit/0c6f179a8443d4dfdfbbefe5cc6caf11c2a525f0))
* **tui:** bound live dictation regions ([#985](https://github.com/Twigpine/zero/issues/985)) ([feb9927](https://github.com/Twigpine/zero/commit/feb992757c582c68f0397e070e206d4bf07071e1))
* **tui:** show provider ownership in Recent and Favorites ([#1083](https://github.com/Twigpine/zero/issues/1083)) ([1697b7a](https://github.com/Twigpine/zero/commit/1697b7a5ce948e781877cdbe9223adac62c3fde6))
* **tui:** stop held peer message Allow/Deny from deadlocking Update ([#1111](https://github.com/Twigpine/zero/issues/1111)) ([a195e88](https://github.com/Twigpine/zero/commit/a195e8848cec1a27b4fe1789ecc572b943c32186)), closes [#1099](https://github.com/Twigpine/zero/issues/1099)
* unbreak main CI (cron claim tests, Go 1.26.9) ([#1124](https://github.com/Twigpine/zero/issues/1124)) ([2c418fc](https://github.com/Twigpine/zero/commit/2c418fc8ff6763de16703128f372fe526fddd331))
## [0.9.0](https://github.com/Gitlawb/zero/compare/v0.8.0...v0.9.0) (2026-09-15)
+5 -5
View File
@@ -9,9 +9,9 @@ reports, feature requests, reproduction details, logs, and discussion on existin
issues.
Questions and setup help belong in
[GitHub Discussions](https://github.com/Twigpine/zero/discussions) (see
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions) (see
[SUPPORT.md](SUPPORT.md)), not in the issue tracker. Feature ideas start in the
[Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas) category;
[Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) category;
an idea that gains traction there is the path to an approved issue and,
eventually, an accepted PR.
@@ -41,7 +41,7 @@ There are three primary ways to contribute:
1. Open a bug report with clear reproduction steps.
2. Propose a feature in
[Discussions Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas),
[Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
describing the problem, use case, and expected behavior. A feature-request
issue is filed only after the idea gains traction there or a maintainer asks
for one.
@@ -193,7 +193,7 @@ Clear bug reports are the most useful way to help the project during this phase.
## Submitting a Feature Request
Feature requests start as a thread in
[Discussions Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas),
[Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
not as an issue. The feature-request issue form exists for the step AFTER that:
when an idea has gained traction in Discussions or a maintainer has asked for an
issue to track it. Whether in the discussion or the eventual issue, please
@@ -222,7 +222,7 @@ contribution phase.
General questions, setup help, and "is this a bug or my config" uncertainty all
go to
[Discussions Q&A](https://github.com/Twigpine/zero/discussions/categories/q-a).
[Discussions Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a).
If you are unsure whether a proposed code change is appropriate, raise it in
Discussions Ideas or on the relevant issue first and ask. Please keep discussion
focused, specific, and respectful of maintainer time.
+19 -39
View File
@@ -12,7 +12,7 @@
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.10+" src="https://img.shields.io/badge/Go-1.26.10+-00ADD8?logo=go&logoColor=white">
<img alt="Go 1.26.9+" src="https://img.shields.io/badge/Go-1.26.9+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<br>
@@ -116,7 +116,7 @@ zero exec --output-format stream-json < turns.jsonl
### npm
```bash
npm install -g @twigpine/zero
npm install -g @gitlawb/zero
zero
```
@@ -130,38 +130,26 @@ matching GitHub Release whenever it is missing. Windows on ARM runs the x64
build under emulation. See [docs/NPM_PACKAGING.md](docs/NPM_PACKAGING.md) for
how the package is put together.
Installed Zero as `@gitlawb/zero`? The package moved to `@twigpine/zero` in
0.10.0 and the old name gets no further releases. Both packages provide the
`zero` command, so quit any running `zero`, then remove the old package first:
```bash
npm uninstall -g @gitlawb/zero
npm install -g @twigpine/zero
```
Your config, sessions, and logins are kept; they live outside the package. For
Bun, pnpm, and yarn, see [docs/INSTALL.md](docs/INSTALL.md#moving-from-gitlawbzero).
### Install scripts
Linux/macOS:
```bash
curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
```
Windows PowerShell:
```powershell
irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | iex
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
```
### From source
Source builds require Go 1.26.10+.
Source builds require Go 1.26.9+.
```bash
git clone https://github.com/Twigpine/zero.git
git clone https://github.com/Gitlawb/zero.git
cd zero
go run ./cmd/zero
```
@@ -173,18 +161,17 @@ If you are testing before the first public release, build from source:
go build -o zero ./cmd/zero
```
On Linux, native sandboxing needs Bubblewrap. The main `zero` binary re-executes
itself as the helper when `zero-linux-sandbox` is absent. A separate helper next
to `zero` or on `PATH` still takes precedence:
On Linux, build the sandbox helper too if you want native sandboxing:
```bash
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox # optional
go build -o zero-seccomp ./cmd/zero-seccomp # optional compatibility wrapper
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
go build -o zero-seccomp ./cmd/zero-seccomp # optional compatibility wrapper
```
Put `zero` 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.
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).
@@ -217,7 +204,6 @@ export AIMLAPI_API_KEY=...
export LONGCAT_API_KEY=...
export FIREWORKS_API_KEY=...
export GMI_API_KEY=...
export CONCENTRATE_API_KEY=...
export MINIMAX_API_KEY=...
export MINIMAXI_API_KEY=...
```
@@ -246,12 +232,6 @@ To configure GMI Cloud directly, run:
zero providers setup gmicloud --set-active
```
To configure Concentrate directly, run:
```bash
zero providers setup concentrate --set-active
```
MiniMax presets use the Anthropic-compatible endpoints for the global and China
regions:
@@ -546,19 +526,19 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
Real-time chat happens on the [Discord server](https://discord.gg/CaQDS6wdFn).
Questions, setup help, ideas, and sharing all live in
[GitHub Discussions](https://github.com/Twigpine/zero/discussions):
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
| Category | Use it for |
|---|---|
| [Q&A](https://github.com/Twigpine/zero/discussions/categories/q-a) | Setup help, provider/model configuration, "how do I" questions |
| [Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas) | Feature proposals and design discussion before any PR |
| [Show and tell](https://github.com/Twigpine/zero/discussions/categories/show-and-tell) | Your skills, plugins, MCP setups, themes, and workflows |
| [Announcements](https://github.com/Twigpine/zero/discussions/categories/announcements) | Releases and project news from the maintainers |
| [Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) | Setup help, provider/model configuration, "how do I" questions |
| [Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) | Feature proposals and design discussion before any PR |
| [Show and tell](https://github.com/Gitlawb/zero/discussions/categories/show-and-tell) | Your skills, plugins, MCP setups, themes, and workflows |
| [Announcements](https://github.com/Gitlawb/zero/discussions/categories/announcements) | Releases and project news from the maintainers |
For a good Q&A answer fast, include `zero --version`, your OS and install
method, the provider/model in use, and `zero doctor` output. See
[SUPPORT.md](SUPPORT.md). Bugs belong in
[issues](https://github.com/Twigpine/zero/issues/new/choose); security reports
[issues](https://github.com/Gitlawb/zero/issues/new/choose); security reports
follow [SECURITY.md](SECURITY.md), never a public thread.
## Contributing
+15 -31
View File
@@ -12,7 +12,7 @@
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.10+" src="https://img.shields.io/badge/Go-1.26.10+-00ADD8?logo=go&logoColor=white">
<img alt="Go 1.26.9+" src="https://img.shields.io/badge/Go-1.26.9+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<br>
@@ -105,41 +105,32 @@ zero exec --output-format stream-json < turns.jsonl
### npm
```bash
npm install -g @twigpine/zero
npm install -g @gitlawb/zero
zero
```
npm 包是一个小型包装器,其平台构建(Linux 和 macOS 的 x64/arm64、Windows 的 x64,包含浏览器/终端控制辅助工具)作为可选依赖直接从 npm registry 安装——没有安装脚本,也不会从 npm 之外下载任何内容。Bun、pnpm 和 yarn 的行为完全一致,无需任何信任或批准步骤。跳过可选依赖的安装(`--omit=optional`)也能工作:只要二进制文件缺失,包装器就会从对应的 GitHub Release 下载。Windows on ARM 通过模拟运行 x64 构建。详见 [docs/NPM_PACKAGING.md](docs/NPM_PACKAGING.md)。
之前通过 `@gitlawb/zero` 安装过 Zero?从 0.10.0 起,npm 包已迁移到 `@twigpine/zero`,旧包名不再发布新版本。两个包都提供 `zero` 命令,所以请先退出正在运行的 `zero`,再卸载旧包:
```bash
npm uninstall -g @gitlawb/zero
npm install -g @twigpine/zero
```
配置、会话和登录信息都会保留,它们存放在包之外。Bun、pnpm 和 yarn 的命令见 [docs/INSTALL.md](docs/INSTALL.md#moving-from-gitlawbzero)。
### 安装脚本
Linux/macOS:
```bash
curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
```
Windows PowerShell:
```powershell
irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | iex
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
```
### 从源码构建
源码构建需要 Go 1.26.10+。
源码构建需要 Go 1.26.9+。
```bash
git clone https://github.com/Twigpine/zero.git
git clone https://github.com/Gitlawb/zero.git
cd zero
go run ./cmd/zero
```
@@ -150,14 +141,14 @@ go run ./cmd/zero
go build -o zero ./cmd/zero
```
在 Linux 上,原生沙箱需要 Bubblewrap。主程序 `zero` 在找不到 `zero-linux-sandbox` 时会以自身作为辅助进程重新执行。同目录或 PATH 上的独立 helper 仍优先:
在 Linux 上,如果你需要原生沙箱,还需要构建沙箱辅助程序:
```bash
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox # 可选
go build -o zero-seccomp ./cmd/zero-seccomp # 可选的兼容性包装器
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
go build -o zero-seccomp ./cmd/zero-seccomp # 可选的兼容性包装器
```
将 `zero` 放在 `PATH` 上(`~/.local/bin` 是一个好的默认选择)。macOS 不需要额外的辅助二进制文件。Windows 源码构建可以使用主 `zero.exe` 作为沙箱辅助程序;发布包仍然附带独立的 Windows 辅助可执行文件。
将 `zero` 和 `zero-linux-sandbox` 放在 `PATH` 上的同一目录中(`~/.local/bin` 是一个好的默认选择)。macOS 不需要额外的辅助二进制文件。Windows 源码构建可以使用主 `zero.exe` 作为沙箱辅助程序;发布包仍然附带独立的 Windows 辅助可执行文件。
更多安装细节:[docs/INSTALL.md](docs/INSTALL.md)。
@@ -186,7 +177,6 @@ export ANTHROPIC_API_KEY=...
export GEMINI_API_KEY=...
export LONGCAT_API_KEY=...
export GMI_API_KEY=...
export CONCENTRATE_API_KEY=...
```
要直接配置美团 LongCat(LongCat-2.0),运行:
@@ -201,12 +191,6 @@ zero providers setup longcat --set-active
zero providers setup gmicloud --set-active
```
要直接配置 Concentrate,运行:
```bash
zero providers setup concentrate --set-active
```
对于本地模型,运行 Ollama、LM Studio 或 [Atomic Chat](https://atomic.chat) 桌面应用,
然后使用 `zero setup` 或 `zero providers detect`。使用 Atomic Chat 时,请先加载模型并启用
本地 OpenAI 兼容 API(默认地址为 `http://127.0.0.1:1337/v1`),再选择 `atomic-chat-local`。
@@ -374,14 +358,14 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
实时交流在 [Discord 服务器](https://discord.gg/CaQDS6wdFn) 进行。
提问、安装帮助、想法和分享都在
[GitHub Discussions](https://github.com/Twigpine/zero/discussions):
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
| 分类 | 用途 |
|---|---|
| [Q&A](https://github.com/Twigpine/zero/discussions/categories/q-a) | 安装帮助、提供商/模型配置、"如何做"类问题 |
| [Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas) | 功能提议和 PR 之前的设计讨论 |
| [Show and tell](https://github.com/Twigpine/zero/discussions/categories/show-and-tell) | 你的技能、插件、MCP 配置、主题和工作流 |
| [Announcements](https://github.com/Twigpine/zero/discussions/categories/announcements) | 维护者发布的版本和项目动态 |
| [Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) | 安装帮助、提供商/模型配置、"如何做"类问题 |
| [Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) | 功能提议和 PR 之前的设计讨论 |
| [Show and tell](https://github.com/Gitlawb/zero/discussions/categories/show-and-tell) | 你的技能、插件、MCP 配置、主题和工作流 |
| [Announcements](https://github.com/Gitlawb/zero/discussions/categories/announcements) | 维护者发布的版本和项目动态 |
## 贡献
+5 -5
View File
@@ -2,8 +2,8 @@
## Questions, setup help, configuration
Ask in [GitHub Discussions](https://github.com/Twigpine/zero/discussions). Use
[Q&A](https://github.com/Twigpine/zero/discussions/categories/q-a) so answers can
Ask in [GitHub Discussions](https://github.com/Gitlawb/zero/discussions). Use
[Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) so answers can
be marked and found by the next person.
The fastest way to a useful answer is to include:
@@ -18,19 +18,19 @@ The fastest way to a useful answer is to include:
## Bugs
If Zero does something wrong (crashes, wrong output, broken behavior), file a
[bug report](https://github.com/Twigpine/zero/issues/new/choose). If you are not
[bug report](https://github.com/Gitlawb/zero/issues/new/choose). If you are not
sure whether it is a bug or a setup problem, start in Discussions and we will
promote it to an issue if it is one.
## Feature ideas
Start a thread in
[Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas). Zero is
[Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas). Zero is
in controlled development (see CONTRIBUTING.md), so discussing an idea first is
also the path to getting a PR accepted.
## Security issues
Never file a public issue or discussion. Report privately via
[GitHub Security Advisories](https://github.com/Twigpine/zero/security/advisories/new)
[GitHub Security Advisories](https://github.com/Gitlawb/zero/security/advisories/new)
(see SECURITY.md).
+2 -2
View File
@@ -2,7 +2,7 @@ name: ZERO
description: >-
Run the ZERO coding agent headlessly in a GitHub workflow. ZERO is
model- and provider-agnostic: you supply the provider and API key as inputs.
author: Twigpine
author: Gitlawb
branding:
icon: terminal
@@ -110,7 +110,7 @@ inputs:
zero-repo:
description: Repository to install the ZERO release from (owner/repo).
required: false
default: Twigpine/zero
default: Gitlawb/zero
outputs:
exit-code:
+13 -63
View File
@@ -1,63 +1,11 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, writeSync } from 'node:fs';
import { existsSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const CURRENT_PACKAGE_NAME = '@twigpine/zero';
// The npm package moved from @gitlawb/zero to @twigpine/zero in 0.10.0. That
// release was also published once under the old name, so 0.9.0's `zero
// upgrade` and anyone still installing the old name land on a working build
// that says where the package went (docs/NPM_PACKAGING.md). Nothing is
// published under the old name after that.
const LEGACY_PACKAGE_NAME = '@gitlawb/zero';
// The npm name this wrapper was published under, read from its own
// package.json so the name lives in one place. The platform payload alias and
// every reinstall hint below derive from it.
function readPackageName(root) {
try {
const { name } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
if (typeof name === 'string' && name !== '') return name;
} catch {
// An unreadable manifest must not stop the CLI from launching.
}
return CURRENT_PACKAGE_NAME;
}
const packageName = readPackageName(packageRoot);
// Same note as legacyPackageSwitchMessage in internal/update/installmethod.go.
// Uninstalling first is the only safe order: both packages own the `zero` bin,
// so npm refuses to install the new one over the old (EEXIST), and forcing it
// lets a later uninstall of the old package delete the shared bin.
function legacyPackageNotice() {
return (
`[zero] Zero has moved to ${CURRENT_PACKAGE_NAME} on npm, and ${LEGACY_PACKAGE_NAME} gets no further releases.\n` +
'To keep getting updates, quit zero and run:\n' +
` npm uninstall -g ${LEGACY_PACKAGE_NAME}\n` +
` npm install -g ${CURRENT_PACKAGE_NAME}\n` +
'Uninstall first: both packages provide the `zero` command. With bun, pnpm or yarn, use its global remove and add commands instead.\n'
);
}
// Printed when the wrapper exits rather than before zero starts, because the
// TUI takes over the screen and would hide anything printed first. Only on a
// terminal, so scripts that capture stderr see nothing new. `zero update` and
// `zero upgrade` print the same note themselves.
if (packageName === LEGACY_PACKAGE_NAME && !['update', 'upgrade'].includes(process.argv[2])) {
process.on('exit', () => {
if (process.stderr.isTTY || process.env.ZERO_WRAPPER_SIMULATE_TTY === '1') {
// Synchronous: an exit handler cannot wait for an asynchronous write.
writeSync(2, legacyPackageNotice());
}
});
}
function zeroBinaryName(platform = process.platform) {
return platform === 'win32' ? 'zero.exe' : 'zero';
}
@@ -133,10 +81,10 @@ function bunRecoveryParagraph() {
return (
'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 ${packageName} (project install)\n` +
` bun pm -g trust ${packageName} (global install)\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": ["${packageName}"]\n` +
' "trustedDependencies": ["@gitlawb/zero"]\n' +
'to your project package.json and reinstall.\n' +
'\n'
);
@@ -145,14 +93,14 @@ function bunRecoveryParagraph() {
function buildFromSourceParagraph() {
return (
'If this platform has no prebuilt binary, build from source:\n' +
'https://github.com/Twigpine/zero (go run ./cmd/zero, requires Go 1.26+).'
'https://github.com/Gitlawb/zero (go run ./cmd/zero, requires Go 1.26+).'
);
}
function missingBinaryContextParagraph() {
return (
`Normally npm installs it as an optional dependency of ${packageName}\n` +
`(${packageName}-${process.platform}-${process.arch}), and the wrapper can\n` +
'Normally npm installs it as an optional dependency of @gitlawb/zero\n' +
`(@gitlawb/zero-${process.platform}-${process.arch}), and the wrapper can\n` +
'also download it from the GitHub Release when it is missing.'
);
}
@@ -165,7 +113,7 @@ function missingNativeRecoveryParagraphs(postinstallScript) {
'\n' +
'Things to try:\n' +
' - reinstall without omitting optional dependencies:\n' +
` npm install -g ${packageName}\n` +
' npm install -g @gitlawb/zero\n' +
' - run the downloader manually (needs write access to the package dir):\n' +
` node "${postinstallScript}"\n` +
'\n' +
@@ -234,12 +182,12 @@ function missingNativeDoctorTextReport(postinstallScript) {
);
}
// The platform payload is a version of this same package, installed under an
// npm: alias (see docs/NPM_PACKAGING.md). The alias name is derived from
// The platform payload is a version of @gitlawb/zero itself, installed under
// an npm: alias (see docs/NPM_PACKAGING.md). The alias name is derived from
// process.platform/process.arch, so an unsupported platform simply fails to
// resolve and we fall through to the downloader.
function platformPackageBinary() {
const aliasedName = `${packageName}-${process.platform}-${process.arch}`;
const aliasedName = `@gitlawb/zero-${process.platform}-${process.arch}`;
let manifestPath;
try {
manifestPath = createRequire(import.meta.url).resolve(`${aliasedName}/package.json`);
@@ -279,6 +227,8 @@ function downloadMissingBinary(packageRoot) {
});
}
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
function resolveNativeBinary() {
const fromPlatformPackage = platformPackageBinary();
if (fromPlatformPackage) return fromPlatformPackage;
+1 -1
View File
@@ -5,7 +5,7 @@ package main
import (
"os"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sandbox"
)
func main() {
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"strconv"
"strings"
"github.com/Twigpine/zero/internal/perfbench"
"github.com/Gitlawb/zero/internal/perfbench"
)
const (
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/perfbench"
"github.com/Gitlawb/zero/internal/perfbench"
)
func TestParseArgsUsesCliAndEnvOverrides(t *testing.T) {
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"path/filepath"
"strings"
"github.com/Twigpine/zero/internal/perfbench"
"github.com/Gitlawb/zero/internal/perfbench"
)
// taskOptions configures the `zero-perf-bench tasks` subcommand: the reproducible
+2 -2
View File
@@ -9,8 +9,8 @@ import (
"path/filepath"
"strings"
"github.com/Twigpine/zero/internal/execprofile"
"github.com/Twigpine/zero/internal/perfbench"
"github.com/Gitlawb/zero/internal/execprofile"
"github.com/Gitlawb/zero/internal/perfbench"
)
// turnOptions configures the `zero-perf-bench turn` subcommand: the per-turn
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"os"
"strings"
"github.com/Twigpine/zero/internal/review"
"github.com/Gitlawb/zero/internal/review"
)
func main() {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"path/filepath"
"strings"
"github.com/Twigpine/zero/internal/release"
"github.com/Gitlawb/zero/internal/release"
)
func main() {
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"os/exec"
"syscall"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sandbox"
)
func main() {
+1 -1
View File
@@ -3,7 +3,7 @@ package main
import (
"os"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sandbox"
)
func main() {
+1 -1
View File
@@ -3,7 +3,7 @@ package main
import (
"os"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sandbox"
)
func main() {
+1 -5
View File
@@ -3,13 +3,9 @@ package main
import (
"os"
"github.com/Twigpine/zero/internal/cli"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/cli"
)
func main() {
if len(os.Args) > 1 && os.Args[1] == sandbox.LinuxSandboxHelperSubcommand {
os.Exit(sandbox.RunLinuxSandboxHelper(os.Args[1:], os.Stderr))
}
os.Exit(cli.Run(os.Args[1:], os.Stdout, os.Stderr))
}
+5 -5
View File
@@ -28,7 +28,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Twigpine/zero@v1
- uses: Gitlawb/zero@v1
with:
prompt: ${{ inputs.task }}
provider: openai
@@ -66,7 +66,7 @@ jobs:
| `github-token` | no | `${{ github.token }}` | Token used to post a PR comment. Requires `pull-requests: write`. |
| `working-directory` | no | `${{ github.workspace }}` | Directory to run ZERO in. |
| `zero-version` | no | (action ref → `latest`) | ZERO release version/tag to install, e.g. `v1.2.3` or `latest`. |
| `zero-repo` | no | `Twigpine/zero` | Repository to install the ZERO release from. |
| `zero-repo` | no | `Gitlawb/zero` | Repository to install the ZERO release from. |
## Outputs
@@ -101,7 +101,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Twigpine/zero@v1
- uses: Gitlawb/zero@v1
with:
prompt: |
Investigate this issue and propose a fix.
@@ -134,7 +134,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Twigpine/zero@v1
- uses: Gitlawb/zero@v1
id: zero
with:
prompt-file: .github/zero/upgrade-deps.md
@@ -176,7 +176,7 @@ jobs:
- **Least-privilege tokens.** Grant only the permissions the workflow needs
(`contents: write` to edit files, `pull-requests: write` to comment). The
default `GITHUB_TOKEN` is scoped to the repository.
- **Pin the action.** Reference a tag (`Twigpine/zero@v1`) or a commit SHA so a
- **Pin the action.** Reference a tag (`Gitlawb/zero@v1`) or a commit SHA so a
workflow run uses a known ZERO version. `zero-version` lets you pin the
installed binary independently of the action ref.
- **Linux and macOS runners** are supported; Windows runners are rejected with a
+30 -78
View File
@@ -5,11 +5,11 @@ blessed one.
| Method | Command | Self-update |
| --- | --- | --- |
| Install script (Linux, macOS) | `curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh \| bash` | `zero upgrade` |
| Install script (Windows) | `irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 \| iex` | `zero upgrade` |
| npm | `npm install -g @twigpine/zero` | `zero upgrade` (runs npm) |
| Release archive | download from [Releases](https://github.com/Twigpine/zero/releases) | `zero upgrade` |
| mise | `mise use -g ubi:Twigpine/zero` | `mise upgrade` |
| Install script (Linux, macOS) | `curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh \| bash` | `zero upgrade` |
| Install script (Windows) | `irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 \| iex` | `zero upgrade` |
| npm | `npm install -g @gitlawb/zero` | `zero upgrade` (runs npm) |
| Release archive | download from [Releases](https://github.com/Gitlawb/zero/releases) | `zero upgrade` |
| mise | `mise use -g ubi:Gitlawb/zero` | `mise upgrade` |
| `go install` | `go install github.com/Gitlawb/zero/cmd/zero@latest` | rerun the command |
| Source | `go build -o zero ./cmd/zero` | rebuild |
@@ -29,7 +29,7 @@ Release and verify its checksum.
## npm
```bash
npm install -g @twigpine/zero
npm install -g @gitlawb/zero
zero
```
@@ -56,47 +56,15 @@ cannot write to keeps failing — rerun the install or the command below with
sufficient permissions). To trigger the fetch manually:
```bash
node "$(npm root -g)/@twigpine/zero/scripts/postinstall.mjs"
node "$(npm root -g)/@gitlawb/zero/scripts/postinstall.mjs"
```
### Moving from @gitlawb/zero
Zero was published as `@gitlawb/zero` up to 0.9.0. From 0.10.0 it is
`@twigpine/zero`, and the old name gets no further releases.
Both packages provide the `zero` command, so npm refuses to install the new
one while the old one is installed (`EEXIST`). Forcing it is worse: a later
uninstall of the old package removes the shared `zero` command. Quit any
running `zero` (on Windows a running `zero.exe` cannot be removed), then
uninstall the old package before installing the new one:
```bash
npm uninstall -g @gitlawb/zero
npm install -g @twigpine/zero
```
With another package manager, the same two steps:
```bash
bun remove -g @gitlawb/zero && bun add -g @twigpine/zero
pnpm remove -g @gitlawb/zero && pnpm add -g @twigpine/zero
yarn global remove @gitlawb/zero && yarn global add @twigpine/zero
```
Config, sessions, logins, and plugins live in your user config and home
directories, not in the package, so they carry over unchanged.
0.10.0 was also published once as `@gitlawb/zero`, so an old install that
updates lands on a working build. That build prints these steps when `zero`
exits in a terminal, and `zero upgrade` and `zero update --check` print them
too.
## Linux And macOS Script
Install the latest release:
```bash
curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
```
From a checkout:
@@ -121,7 +89,7 @@ scripts/install.sh --install-dir "$HOME/bin"
Defaults:
- Repository: `Twigpine/zero`
- Repository: `Gitlawb/zero`
- Version: latest GitHub release
- Install path: `~/.local/bin/zero`
@@ -132,7 +100,7 @@ Requirements: Bash, `curl` or `wget`, `tar`, and `shasum` or `sha256sum`.
Install the latest release:
```powershell
irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | iex
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
```
From a checkout:
@@ -155,7 +123,7 @@ powershell -ExecutionPolicy Bypass -File scripts/install.ps1 -InstallDir "$env:U
Defaults:
- Repository: `Twigpine/zero`
- Repository: `Gitlawb/zero`
- Version: latest GitHub release
- Install path: `%LOCALAPPDATA%\zero\bin\zero.exe`
@@ -166,13 +134,13 @@ archives through its `ubi` backend, so there is nothing extra to publish and no
registry in the middle:
```bash
mise use -g ubi:Twigpine/zero
mise use -g ubi:Gitlawb/zero
```
Pin a version the same way you would any other tool:
```bash
mise use -g ubi:Twigpine/[email protected]
mise use -g ubi:Gitlawb/[email protected]
```
Updates come from `mise upgrade`. `zero upgrade` also works, because a
@@ -185,19 +153,13 @@ describe a version that is no longer on disk. Prefer `mise upgrade`.
go install github.com/Gitlawb/zero/cmd/zero@latest
```
Every release so far declares the module path `github.com/Gitlawb/zero`, so
`go install` has to use that path; `github.com/Twigpine/zero/cmd/zero@latest`
fails with "module declares its path as" until a release is tagged with the new
module path.
This builds from source, so it needs Go 1.26.10+ and it does not go through the
This builds from source, so it needs Go 1.26.9+ and it does not go through the
release archives. Two consequences worth knowing before you pick it:
- On Linux, Bubblewrap is still required for native sandboxing. Zero resolves
the helper in this order: an executable `zero-linux-sandbox` next to `zero`,
then `PATH`, then self-exec of the main binary. Standalone helpers remain
optional and preferred. See
- On Linux you also need the sandbox helper, which is a separate binary and is
not installed by this command. See
[Sandbox Helpers For Source Builds](#sandbox-helpers-for-source-builds).
Without it, native sandboxing is unavailable.
- `zero upgrade` treats the result as a standalone install and will replace the
binary with a release build rather than rebuilding from source. If you chose
`go install` deliberately, rerun it instead.
@@ -207,7 +169,7 @@ macOS and Windows need no extra helper.
## From Source
```bash
git clone https://github.com/Twigpine/zero.git
git clone https://github.com/Gitlawb/zero.git
cd zero
go run ./cmd/zero
```
@@ -218,32 +180,25 @@ Build a local binary:
go build -o zero ./cmd/zero
```
Source builds require Go 1.26.10+.
Source builds require Go 1.26.9+.
### Sandbox Helpers For Source Builds
Release archives include the platform sandbox helpers. A source build of the
main `zero` binary is enough for Linux native sandboxing. Zero resolves the
helper in this order:
Release archives include the platform sandbox helpers. If you build directly
from source, build the helpers you need:
1. an executable `zero-linux-sandbox` next to `zero`
2. then `PATH`
3. then self-exec of the main binary
Bubblewrap must still be installed. Standalone helpers remain optional and
preferred.
A separate helper remains supported if it sits next to `zero` or on `PATH`:
Linux:
```bash
go build -o zero ./cmd/zero
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox # optional
go build -o zero-seccomp ./cmd/zero-seccomp # optional compatibility wrapper
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
go build -o zero-seccomp ./cmd/zero-seccomp
```
Put `zero` 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.
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.
@@ -258,7 +213,7 @@ seccomp filter on Android:
pkg install golang
# Build Zero for Android
git clone https://github.com/Twigpine/zero.git
git clone https://github.com/Gitlawb/zero.git
cd zero
CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -ldflags="-s -w" -o zero ./cmd/zero
@@ -347,10 +302,7 @@ See the [update guide](UPDATE.md) for update modes, flags, and platform details.
overwriting a binary a package manager owns leaves that manager describing a
version that is no longer there:
- **npm**: runs `npm install -g @twigpine/zero@latest` for you. An install of
the old `@gitlawb/zero` package refuses instead and prints the steps in
[Moving from @gitlawb/zero](#moving-from-gitlawbzero): that name gets no
further releases, so npm would only reinstall the same version.
- **npm**: runs `npm install -g @gitlawb/zero@latest` for you.
- **Homebrew**: refuses, and tells you to run `brew upgrade zero`. Replacing the
keg binary directly would be reverted by the next `brew upgrade` or
`brew reinstall`.
+18 -77
View File
@@ -1,6 +1,6 @@
# npm packaging
How `@twigpine/zero` is put together on npm, why it is shaped this way, and the
How `@gitlawb/zero` is put together on npm, why it is shaped this way, and the
rules the release pipeline must follow. Read this before touching
`package.json`, `bin/zero.js`, `scripts/postinstall.mjs`,
`scripts/npm/build-platform-packages.mjs`, or the npm-publish steps of
@@ -8,7 +8,7 @@ rules the release pipeline must follow. Read this before touching
## Goals
A `npm install -g @twigpine/zero` must be **silent and self-contained**:
A `npm install -g @gitlawb/zero` must be **silent and self-contained**:
- No `EBADENGINE` warnings — ours or from any package in the dependency tree.
- No install scripts anywhere in the tree, so npm's `allow-scripts` gating,
@@ -25,15 +25,15 @@ The model is the one used by Codex, esbuild, and Biome: a tiny wrapper package
plus per-platform payloads carrying the native binaries.
```
@twigpine/zero <- wrapper: bin/zero.js + optionalDependencies
├─ @twigpine/zero-darwin-arm64 -> npm:@twigpine/zero@{version}-darwin-arm64
├─ @twigpine/zero-darwin-x64 -> npm:@twigpine/zero@{version}-darwin-x64
├─ @twigpine/zero-linux-arm64 -> npm:@twigpine/zero@{version}-linux-arm64
├─ @twigpine/zero-linux-x64 -> npm:@twigpine/zero@{version}-linux-x64
└─ @twigpine/zero-win32-x64 -> npm:@twigpine/zero@{version}-win32-x64
@gitlawb/zero <- wrapper: bin/zero.js + optionalDependencies
├─ @gitlawb/zero-darwin-arm64 -> npm:@gitlawb/zero@{version}-darwin-arm64
├─ @gitlawb/zero-darwin-x64 -> npm:@gitlawb/zero@{version}-darwin-x64
├─ @gitlawb/zero-linux-arm64 -> npm:@gitlawb/zero@{version}-linux-arm64
├─ @gitlawb/zero-linux-x64 -> npm:@gitlawb/zero@{version}-linux-x64
└─ @gitlawb/zero-win32-x64 -> npm:@gitlawb/zero@{version}-win32-x64
```
- The platform "packages" are **versions of the same `@twigpine/zero` package**,
- The platform "packages" are **versions of the same `@gitlawb/zero` package**,
published at suffixed versions (`0.4.0-linux-x64`) and referenced through
`npm:` aliases in `optionalDependencies`. One package name means one npm
trusted-publisher configuration — no new publish credentials per platform.
@@ -56,7 +56,7 @@ plus per-platform payloads carrying the native binaries.
### Binary resolution in `bin/zero.js`
1. Resolve `@twigpine/zero-<platform>-<arch>` and exec the `zero` binary from
1. Resolve `@gitlawb/zero-<platform>-<arch>` and exec the `zero` binary from
it. This wins over any previously downloaded copy — the platform version is
pinned to the wrapper release.
2. Fall back to a binary previously downloaded next to the wrapper.
@@ -103,22 +103,10 @@ dependencies of the wrapper:
platform binary (plus the musl variant on linux, which its launcher detects
at runtime) — about 65 MB saved per platform package.
### One package name
The npm name lives in `package.json` and nothing else repeats it:
`scripts/npm/build-platform-packages.mjs` names the wrapper, the platform
versions, the alias names, and the alias targets from it, and `bin/zero.js`
reads its own `package.json` to derive the alias it resolves. The Go side
needs the name as a constant (`npmPackageName` in
`internal/update/installmethod.go`), and a test holds it equal to
`package.json`.
## Publishing rules (release pipeline)
These are the invariants `release-artifacts.yml` must hold. Breaking the first
one is user-visible immediately. `scripts/npm/publish-packages.sh` implements
them, and `internal/npmwrapper/publish_test.go` runs it against a fake
registry.
one is user-visible immediately.
1. **Platform versions must never become `latest`.** `0.4.0-linux-x64` is a
semver *prerelease* of `0.4.0`; publishing it without an explicit
@@ -130,67 +118,20 @@ registry.
bootstrap publish) — a missing value must not abort the job between the
platform and wrapper publishes, which would strand a half-published
release.
2. **Platform versions are on the registry before the wrapper.** The
wrapper's `optionalDependencies` pin exact suffixed versions; publishing
the wrapper first would create a window where installs resolve aliases
that 404. Ordering the uploads is not enough: npm applies a publish
asynchronously, and in 0.10.0 a platform version appeared ten minutes
after its upload returned, after the wrapper. The publish script waits
until npm shows every platform version before it publishes the wrapper,
and waits the same way for `latest` afterwards (up to 30 minutes in all).
2. **Platform versions publish before the wrapper.** The wrapper's
`optionalDependencies` pin exact suffixed versions; publishing the wrapper
first would create a window where installs resolve aliases that 404.
3. **Exact-version pinning.** The wrapper at `X.Y.Z` references platform
versions `X.Y.Z-<platform>-<arch>` exactly — never ranges — so a wrapper
and its binaries can never skew. The publish script refuses to start when
the wrapper's aliases do not match the platform payloads beside it.
and its binaries can never skew.
4. All publishes go through the existing npm OIDC trusted-publishing flow and
the `npm-publish` environment gate; no tokens. Because the platform
payloads are versions of the same package, they reuse the single
trusted-publisher configuration. npm checks provenance against
`package.json` `repository`, so it must name the repository the workflow
runs in.
5. **Re-runs are safe.** A version already on the registry is skipped rather
than republished, so re-running a job that failed between the platform and
wrapper publishes finishes the release. Only npm's E404 counts as "not
published"; a lookup that fails any other way is retried and then stops the
job before anything is published.
trusted-publisher configuration.
`zero update` keeps working unchanged: it detects an npm install by finding a
`package.json` named `@twigpine/zero` next to the running binary — true inside
a platform payload too — and updates via `npm install -g @twigpine/zero@latest`.
## The move from `@gitlawb/zero`
Zero was published as `@gitlawb/zero` up to 0.9.0 and as `@twigpine/zero`
from 0.10.0. There is no in-place migration: both packages own the `zero`
bin, so npm refuses to install one over the other (`EEXIST`), and forcing it
lets a later uninstall of the old package delete the shared bin. The user
has to uninstall the old package and install the new one, in that order.
Deprecating the old package alone would not have reached existing users.
0.9.0's `zero upgrade` runs `npm install -g @gitlawb/zero@latest`; with
nothing newer under that name, npm reinstalls nothing, prints no deprecation
warning (it only warns when it installs something), and Zero reports the
update as done. So 0.10.0 was published once more under the old name, as a
bridge:
- The release job assembled the same verified artifacts a second time with
`build-platform-packages.mjs --package-name @gitlawb/zero` and published
them with the same script, after the `@twigpine/zero` publish. The steps
that did this ran once and have been removed from `release-artifacts.yml`.
- The bridge build knows its name from the `package.json` beside it. The Go
binary reports it as `InstallMethodNpmLegacy`: `zero upgrade` refuses
with the switch steps instead of reinstalling it, and `zero update --check`
adds them. `bin/zero.js` prints the same steps after `zero` exits, on a
terminal only. This handling stays in the code, since only a build
installed under the old name ever triggers it.
- Updating from 0.9.0 installs that bridge build, so npm also shows the
deprecation message.
`@gitlawb/zero` is deprecated on every version and no longer has a trusted
publisher, so this release workflow cannot publish it again; another version
would take a maintainer publishing by hand. Its versions stay on the
registry: existing installs resolve their platform payload through them, so
they must never be unpublished.
`package.json` named `@gitlawb/zero` next to the running binary — true inside
a platform payload too — and updates via `npm install -g @gitlawb/zero@latest`.
## Runbook: bumping the vendored helpers
+9 -10
View File
@@ -2,8 +2,7 @@
Run this checklist when a PR changes npm distribution files such as
`package.json`, `bin/zero.js`, `scripts/npm/build-platform-packages.mjs`,
`scripts/npm/publish-packages.sh`, Go build or release tooling, or the npm
`bin` wrapper.
Go build or release tooling, or the npm `bin` wrapper.
## Required Checks
@@ -38,24 +37,24 @@ path:
```bash
# platform-package path
mkdir -p /tmp/zero-sim/node_modules/@twigpine
cp -R dist/npm/wrapper /tmp/zero-sim/node_modules/@twigpine/zero
mkdir -p /tmp/zero-sim/node_modules/@gitlawb
cp -R dist/npm/wrapper /tmp/zero-sim/node_modules/@gitlawb/zero
cp -R dist/npm/platforms/zero-<platform>-<arch> \
"/tmp/zero-sim/node_modules/@twigpine/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@twigpine/zero/bin/zero.js --version
"/tmp/zero-sim/node_modules/@gitlawb/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@gitlawb/zero/bin/zero.js --version
# first-run download fallback (delete the platform package first)
rm -rf "/tmp/zero-sim/node_modules/@twigpine/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@twigpine/zero/bin/zero.js --version
rm -rf "/tmp/zero-sim/node_modules/@gitlawb/zero-<platform>-<arch>"
node /tmp/zero-sim/node_modules/@gitlawb/zero/bin/zero.js --version
```
## Checklist
- `package.json` has the expected package name (`@twigpine/zero`), version,
- `package.json` has the expected package name (`@gitlawb/zero`), version,
`bin.zero` entry, and NO `scripts` entries — the published package must be
free of lifecycle scripts (see NPM_PACKAGING.md).
- `scripts/npm/build-platform-packages.mjs` emits platform payloads whose
`package.json` is `@twigpine/zero@<version>-<platform>-<arch>` with matching
`package.json` is `@gitlawb/zero@<version>-<platform>-<arch>` with matching
`os`/`cpu`, contains the executable binary and the vendored `helpers/` tree
with regular-file (non-symlink) `.bin` shims, and a wrapper payload with no
scripts, no dependencies, and the full five-alias `optionalDependencies`
+4 -8
View File
@@ -7,7 +7,7 @@ downloads, verifies, and installs it.
```bash
zero update --check
zero update --check --json
zero update --check --repo Twigpine/zero
zero update --check --repo Gitlawb/zero
zero update --check --target windows-x64
zero upgrade
@@ -28,11 +28,7 @@ them explicitly; `zero upgrade` is `zero update` with `--apply` implied.
`--apply` installs the update in place:
- npm installs delegate to `npm install -g @twigpine/zero@latest`. An install
of the old `@gitlawb/zero` package refuses, without a release check, and
prints the steps to switch to `@twigpine/zero` (see
[INSTALL.md](INSTALL.md#moving-from-gitlawbzero)); `--check` prints them
too.
- npm installs delegate to `npm install -g @gitlawb/zero@latest`.
- Standalone installs download the release archive, verify its checksum,
extract it, and atomically replace the running binary plus any installed
optional sandbox helpers.
@@ -42,7 +38,7 @@ them explicitly; `zero upgrade` is `zero update` with `--apply` implied.
- `--target` cannot be combined with `--apply`; it only applies to `--check`,
since applying always installs onto the current machine.
- `--repo` and `--endpoint` are ignored when applying to an npm-managed
install: that path delegates to `npm install -g @twigpine/zero@latest` and
install: that path delegates to `npm install -g @gitlawb/zero@latest` and
takes its release from the npm registry, not from GitHub. They still apply to
`--check` there.
- `--json` serializes Zero's final result. For npm-managed installs, npm may
@@ -69,7 +65,7 @@ Endpoint resolution order:
1. `--endpoint`
2. `ZERO_UPDATE_RELEASE_URL`
3. `--repo`
4. `https://api.github.com/repos/Twigpine/zero/releases/latest`
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 upgrade`
+2 -2
View File
@@ -1,6 +1,6 @@
module github.com/Twigpine/zero
module github.com/Gitlawb/zero
go 1.26.10
go 1.26.9
require (
charm.land/bubbles/v2 v2.1.1
+46 -128
View File
@@ -13,18 +13,17 @@ import (
"strings"
"sync"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/modelregistry"
"github.com/Twigpine/zero/internal/providercatalog"
"github.com/Twigpine/zero/internal/providermodelcatalog"
"github.com/Twigpine/zero/internal/providermodeldiscovery"
"github.com/Twigpine/zero/internal/redaction"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/usage"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodelcatalog"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/redaction"
"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
@@ -214,7 +213,7 @@ func (a *Agent) handleSessionNew(ctx context.Context, params json.RawMessage) (a
if err != nil {
return nil, RPCError(codeInternalError, "create session: "+err.Error())
}
sess, _ := a.registerSession(meta.SessionID, root, nil, model, models, restrictModels, false, agent.PermissionModeAuto)
sess, _ := a.registerSession(meta.SessionID, root, nil, model, models, restrictModels, false)
return NewSessionResult{
SessionID: sess.id,
ConfigOptions: a.configOptions(sess),
@@ -330,7 +329,7 @@ func (a *Agent) activatePersistedSession(ctx context.Context, p LoadSessionParam
// Resume has already refused a non-resumable kind above, so only load can
// reach here with one, and it publishes that session for viewing only.
readOnly := !sessions.IsResumableKind(meta.SessionKind)
sess, existed := a.registerSession(meta.SessionID, root, history, model, models, restrictModels, readOnly, restoredPermissionMode(meta.PermissionMode))
sess, existed := a.registerSession(meta.SessionID, root, history, model, models, restrictModels, readOnly)
if existed {
_, messages, historyWarning, historyErr = a.refreshSessionHistory(sess, requireHistoryLog)
if operation == persistedSessionResume && historyErr != nil {
@@ -528,6 +527,8 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
queue := func(input sessions.AppendEventInput) {
pendingEvents = append(pendingEvents, input)
}
queue(messageEvent("user", userText))
visionCache := make(map[string]bool)
var visionCacheMu sync.Mutex
supportsVision := func(modelID string) bool {
@@ -551,9 +552,6 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
effectivePrompt = fmt.Sprintf("[Note: %s]\n\n%s", msg, userText)
images = nil
}
// Match the TUI: only images actually sent to the model enter history.
attachments := sessions.MessageAttachments{Images: len(images)}
queue(messageEvent("user", userText, attachments))
opts := agent.Options{
Cwd: sess.cwd,
@@ -569,12 +567,6 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
SupportsVision: supportsVision,
OnText: note.text,
OnReasoning: note.thought,
// Persist token usage the same way `zero exec` does, so `zero usage report`
// sees ACP sessions too. It joins the turn's buffered batch: a hard run
// failure that aborts the turn drops its usage together with its history.
OnUsage: func(u agent.Usage) {
queue(usageEvent(u))
},
OnToolCall: func(call agent.ToolCall) {
queue(toolCallEvent(call))
note.toolCall(call)
@@ -594,7 +586,7 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
agentPrompt := buildPrompt(sess.snapshotHistory(), effectivePrompt)
result, runErr := a.deps.RunAgent(ctx, agentPrompt, provider, opts)
if result.FinalAnswer != "" {
queue(messageEvent("assistant", result.FinalAnswer, sessions.MessageAttachments{}))
queue(messageEvent("assistant", result.FinalAnswer))
}
reason, stopErr := stopReasonFor(result, runErr)
if stopErr != nil {
@@ -615,7 +607,7 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
// one file write, which is the contiguity restored here — not a disk
// transaction, and not a promise about crashes mid-fsync. Reported by @jatmn.
persistenceErr := a.persistTurn(sess.id, pendingEvents)
sess.appendHistory(turnRecord{user: attachments.WithContent(userText), assistant: result.FinalAnswer})
sess.appendHistory(turnRecord{user: userText, assistant: result.FinalAnswer})
a.warnPersistence(
note,
"save session history",
@@ -703,9 +695,7 @@ func (a *Agent) handleSetMode(_ context.Context, params json.RawMessage) (any, e
mode := agent.PermissionMode(p.ModeID)
switch mode {
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
if err := a.updateMode(sess, mode); err != nil {
return nil, err
}
sess.setMode(mode)
(&notifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
return SetSessionModeResult{}, nil
case agent.PermissionModeUnsafe:
@@ -741,9 +731,7 @@ func (a *Agent) handleSetConfigOption(_ context.Context, params json.RawMessage)
mode := agent.PermissionMode(p.Value)
switch mode {
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
if err := a.updateMode(sess, mode); err != nil {
return nil, err
}
sess.setMode(mode)
(&notifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
case agent.PermissionModeUnsafe:
return nil, RPCError(codeInvalidParams, "mode not permitted over ACP: "+p.Value)
@@ -785,35 +773,6 @@ func (a *Agent) updateModel(sess *acpSession, model string, restrictModels bool)
return nil
}
// updateMode persists the mode BEFORE applying it, so a failed write fails the
// request instead of leaving disk behind memory; the same order updateModel
// uses. The caller holds sess.turnMu (both mode doors take it and it is not
// reentrant, so this must not lock it again); the write under turnMu briefly
// blocks that session's prompts, which is what makes the store authoritative.
func (a *Agent) updateMode(sess *acpSession, mode agent.PermissionMode) error {
if _, err := a.deps.Store.UpdateMode(sess.id, string(mode)); err != nil {
return RPCError(codeInternalError, "save mode selection: "+err.Error())
}
sess.setMode(mode)
return nil
}
// restoredPermissionMode maps a persisted mode onto the modes ACP offers. A
// session written before modes were persisted has none and keeps the historical
// auto default. A stored value outside the three is never allowed to come back
// weaker than ask, and unsafe can never be reached because ACP refuses to set
// it in the first place.
func restoredPermissionMode(saved string) agent.PermissionMode {
switch mode := agent.PermissionMode(strings.TrimSpace(saved)); mode {
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
return mode
case "":
return agent.PermissionModeAuto
default:
return agent.PermissionModeAsk
}
}
func (a *Agent) handleCancel(_ context.Context, params json.RawMessage) {
var p CancelParams
if err := json.Unmarshal(params, &p); err != nil {
@@ -931,23 +890,10 @@ func (a *Agent) persistTurn(sessionID string, inputs []sessions.AppendEventInput
return err
}
func messageEvent(role, content string, attachments sessions.MessageAttachments) sessions.AppendEventInput {
payload := map[string]any{"role": role, "content": content}
if role == "user" && (attachments.Images > 0 || attachments.Documents > 0) {
payload["attachments"] = attachments
}
func messageEvent(role, content string) sessions.AppendEventInput {
return sessions.AppendEventInput{
Type: sessions.EventMessage,
Payload: payload,
}
}
// usageEvent records one provider usage report in the same persisted shape the
// exec runtime writes (usage.EventUsagePayload is the single writer).
func usageEvent(u agent.Usage) sessions.AppendEventInput {
return sessions.AppendEventInput{
Type: sessions.EventUsage,
Payload: usage.EventUsagePayload(u),
Payload: map[string]any{"role": role, "content": content},
}
}
@@ -962,25 +908,16 @@ func toolCallEvent(call agent.ToolCall) sessions.AppendEventInput {
}
}
// toolResultEvent persists a tool result through the ONE session contract every
// writer shares, agent.ToolResultSessionPayload.
//
// This used to spell its own payload and wrote result.Output. After the
// output/notice split that field is the UNDECORATED text, and nothing else in
// the payload carried the enforcement notices, so the disclosure was dropped at
// the moment of persisting. The live tool_call_update for the same result had
// shown it (toolResultContent reads ModelOutput), and then session/load
// replayed the call with the sandbox's narrowing gone. Fixing only the live
// translation could not reach this: the text was already lost on disk.
//
// The session store is shared with the TUI and headless exec, so the same
// payload is also what lets a TUI resume of an ACP-written session draw the
// notice as card furniture, from the typed field and the undecorated body.
// Reported by @jatmn.
func toolResultEvent(result agent.ToolResult) sessions.AppendEventInput {
return sessions.AppendEventInput{
Type: sessions.EventToolResult,
Payload: agent.ToolResultSessionPayload(result),
Type: sessions.EventToolResult,
Payload: map[string]any{
"toolCallId": result.ToolCallID,
"name": result.Name,
"status": result.Status,
"output": result.Output,
"changedFiles": append([]string(nil), result.ChangedFiles...),
},
}
}
@@ -1108,16 +1045,14 @@ func (a *Agent) loadHistory(sessionID string, requireHistoryLog bool) ([]turnRec
continue
}
var msg struct {
Role string `json:"role"`
Content string `json:"content"`
Attachments sessions.MessageAttachments `json:"attachments"`
Role string `json:"role"`
Content string `json:"content"`
}
if json.Unmarshal(raw, &msg) != nil {
continue
}
switch msg.Role {
case "user":
msg.Content = msg.Attachments.WithContent(msg.Content)
messages = append(messages, persistedMessage{eventID: persistedMessageIdentity(sessionID, e), role: msg.Role, content: msg.Content})
if havePending {
records = append(records, turnRecord{user: pendingUser})
@@ -1153,14 +1088,13 @@ func replayToolUpdate(event sessions.Event) *ToolCallUpdate {
return nil
}
var payload struct {
Name string `json:"name"`
ToolCallID string `json:"toolCallId"`
ID string `json:"id"`
Arguments string `json:"arguments"`
Status string `json:"status"`
Output string `json:"output"`
ChangedFiles []string `json:"changedFiles"`
EnforcementNotices []string `json:"enforcementNotices"`
Name string `json:"name"`
ToolCallID string `json:"toolCallId"`
ID string `json:"id"`
Arguments string `json:"arguments"`
Status string `json:"status"`
Output string `json:"output"`
ChangedFiles []string `json:"changedFiles"`
}
if json.Unmarshal(raw, &payload) != nil {
return nil
@@ -1180,27 +1114,12 @@ func replayToolUpdate(event sessions.Event) *ToolCallUpdate {
if status == "" {
status = tools.StatusOK
}
// THE DISCLOSURE IS RESTORED AS WHAT IT WAS: typed notices beside undecorated
// text, so toolCallResult composes it the way it did live and the client
// sees it exactly once. The stored output already has the notices composed
// in (that is the shared contract, because output is the provider-facing
// text), so handing it to ModelOutput together with the typed field would
// draw the disclosure twice, and dropping the typed field would make this
// reader depend on every writer having decorated the text. Taking the
// notices back off the front covers both: a record whose output was never
// decorated comes through unchanged and still gets its notice.
//
// displayPreview is deliberately not read. It is the card body for a
// terminal, and the ACP wire has no card: the live update sends the model
// text, so a replay that sent the preview instead would not match it.
notices := append([]string(nil), payload.EnforcementNotices...)
upd := toolCallResult(agent.ToolResult{
ToolCallID: id,
Name: payload.Name,
Status: status,
Output: tools.WithoutEnforcementNotices(payload.Output, notices),
EnforcementNotices: notices,
ChangedFiles: append([]string(nil), payload.ChangedFiles...),
ToolCallID: id,
Name: payload.Name,
Status: status,
Output: payload.Output,
ChangedFiles: append([]string(nil), payload.ChangedFiles...),
})
return &upd
}
@@ -1291,15 +1210,14 @@ func promptImages(blocks []ContentBlock) []zeroruntime.ImageBlock {
// session is returned rather than orphaning its turn or resetting its mode/model.
// The bool reports that case so persisted activation can re-read and apply disk
// history under turnMu. history is set BEFORE first publication so no concurrent
// prompt can read a half-initialized session. mode is the session's initial
// permission mode: auto for a new session, the restored value for load/resume.
func (a *Agent) registerSession(id, cwd string, history []turnRecord, model string, models []SessionConfigOptionValue, restrictModels bool, readOnly bool, mode agent.PermissionMode) (*acpSession, bool) {
// prompt can read a half-initialized session.
func (a *Agent) registerSession(id, cwd string, history []turnRecord, model string, models []SessionConfigOptionValue, restrictModels bool, readOnly bool) (*acpSession, bool) {
a.mu.Lock()
defer a.mu.Unlock()
if existing := a.sessions[id]; existing != nil {
return existing, true
}
sess := &acpSession{id: id, cwd: cwd, mode: mode, model: model, models: models, restrictModels: restrictModels, history: history, readOnly: readOnly}
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, model: model, models: models, restrictModels: restrictModels, history: history, readOnly: readOnly}
a.sessions[id] = sess
return sess, false
}
+8 -12
View File
@@ -19,13 +19,13 @@ import (
"testing"
"time"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/providermodeldiscovery"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"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
@@ -960,11 +960,7 @@ func TestACPHardTurnFailureDoesNotCommitHistory(t *testing.T) {
t.Fatal(err)
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID,
Prompt: []ContentBlock{
TextBlock("failed question"),
ImageBlock(base64.StdEncoding.EncodeToString([]byte("failed image")), "image/png"),
},
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("failed question")},
}, &PromptResult{}); err == nil {
t.Fatal("hard RunAgent failure was reported as a successful turn")
}
+1 -1
View File
@@ -15,7 +15,7 @@ import (
"fmt"
"testing"
"github.com/Twigpine/zero/internal/agent"
"github.com/Gitlawb/zero/internal/agent"
)
// ---- an offered option is an acceptable answer ----
@@ -1,195 +0,0 @@
package acp
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"time"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
)
const persistedNotice = "least-privilege notice: read access was narrowed"
func mustRawPayload(t *testing.T, payload map[string]any) json.RawMessage {
t.Helper()
encoded, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
return encoded
}
func toolUpdateText(update ToolCallUpdate) string {
var text strings.Builder
for _, part := range update.Content {
if part.Content != nil {
text.WriteString(part.Content.Text)
}
}
return text.String()
}
// nextToolResultUpdate returns the next tool_call_update, skipping the
// tool_call that announces it.
func nextToolResultUpdate(t *testing.T, ctx context.Context, updates <-chan ToolCallUpdate, stage string) ToolCallUpdate {
t.Helper()
for {
select {
case update := <-updates:
if update.SessionUpdate == UpdateToolCallUpdate {
return update
}
case <-ctx.Done():
t.Fatalf("%s: the tool result update never arrived", stage)
}
}
}
// A DISCLOSURE SHOWN LIVE HAS TO SURVIVE BEING WRITTEN DOWN.
//
// enforcement_notice_test.go pins the live translation: toolResultContent reads
// ModelOutput, so a connected ACP client sees the sandbox's narrowing. That was
// the only half fixed. The turn also persists each result, and the persisted
// payload was spelled by hand in this package with result.Output, which after
// the output/notice split is the undecorated text, and with no notices field at
// all. The disclosure was gone from disk, so session/load replayed a sandboxed
// command as if nothing had constrained it.
//
// This runs a real turn, so the result reaches the store through the same
// OnToolResult callback production uses, then loads the session in a fresh
// agent and reads the replayed update. It fails if only translate.go is fixed,
// which is the state it was written against. Reported by @jatmn.
func TestACPReloadedToolResultCarriesTheEnforcementNoticeOnce(t *testing.T) {
for _, tc := range []struct {
name string
output string
}{
{name: "a command with output", output: "the command output"},
// An enforced command that printed nothing still has a disclosure, and
// an empty body is where a reader that falls back to the decorated text
// draws it twice.
{name: "a command that printed nothing", output: ""},
} {
t.Run(tc.name, func(t *testing.T) {
deps := testDeps(t)
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
call := agent.ToolCall{ID: "call-sandboxed", Name: "bash", Arguments: `{"command":"ls"}`}
opts.OnToolCall(call)
opts.OnToolResult(agent.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusOK,
Output: tc.output,
EnforcementNotices: []string{persistedNotice},
})
return agent.Result{FinalAnswer: "done"}, nil
}
workspace := t.TempDir()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
live := newHarness(t, deps)
var created NewSessionResult
if err := live.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: workspace, McpServers: []McpServer{}}, &created); err != nil {
t.Fatalf("session/new: %v", err)
}
if err := live.client.Call(ctx, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("list the files")}}, &PromptResult{}); err != nil {
t.Fatalf("session/prompt: %v", err)
}
liveText := toolUpdateText(nextToolResultUpdate(t, ctx, live.tools, "live"))
if count := strings.Count(liveText, persistedNotice); count != 1 {
t.Fatalf("premise: the live update carries the notice %d times, want 1:\n%s", count, liveText)
}
live.stop()
loader := newHarness(t, deps)
defer loader.stop()
if err := loader.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &LoadSessionResult{}); err != nil {
t.Fatalf("session/load: %v", err)
}
replayed := toolUpdateText(nextToolResultUpdate(t, ctx, loader.tools, "replay"))
if count := strings.Count(replayed, persistedNotice); count != 1 {
t.Errorf("the replayed result carries the notice %d times, want exactly 1:\n%s", count, replayed)
}
if replayed != liveText {
t.Errorf("the replayed result does not match what the client saw live\nlive: %q\nreplayed: %q", liveText, replayed)
}
})
}
}
// THE ACP WRITER IS THE SHARED CONTRACT, NOT A COPY OF IT. The session store is
// the one the TUI and headless exec also write to and resume from, so a payload
// spelled here by hand is a third representation waiting to drift. It already
// had: it was the only writer with no notices and no undecorated body.
func TestToolResultEventIsTheSharedSessionPayload(t *testing.T) {
result := agent.ToolResult{
ToolCallID: "call-1",
Name: "bash",
Status: tools.StatusOK,
Output: "the command output",
EnforcementNotices: []string{persistedNotice},
ChangedFiles: []string{"a.go"},
}
event := toolResultEvent(result)
if event.Type != sessions.EventToolResult {
t.Fatalf("event type = %q", event.Type)
}
if want := agent.ToolResultSessionPayload(result); !reflect.DeepEqual(event.Payload, want) {
t.Fatalf("the ACP payload is not the shared one\n got: %#v\nwant: %#v", event.Payload, want)
}
payload, ok := event.Payload.(map[string]any)
if !ok {
t.Fatalf("payload is %T", event.Payload)
}
if notices, _ := payload["enforcementNotices"].([]string); len(notices) != 1 || notices[0] != persistedNotice {
t.Errorf("typed notices were not persisted: %#v", payload["enforcementNotices"])
}
if body, present := payload["displayPreview"]; !present || body != "the command output" {
t.Errorf("the undecorated body was not persisted: %#v (present=%v)", body, present)
}
}
// A record can reach the reader in either shape, and both have to come out as
// one disclosure: output with the notices already composed in, which is what
// every current writer stores, and output left undecorated beside the typed
// field, which a reader must not answer by dropping the notice.
func TestReplayedToolResultComposesTheNoticeOnceForEitherStoredShape(t *testing.T) {
for _, tc := range []struct {
name string
output string
want string
}{
{name: "output stored decorated", output: persistedNotice + "\n\nthe command output", want: persistedNotice + "\n\nthe command output"},
{name: "output stored undecorated", output: "the command output", want: persistedNotice + "\n\nthe command output"},
{name: "decorated and otherwise empty", output: persistedNotice, want: persistedNotice},
{name: "undecorated and empty", output: "", want: persistedNotice},
} {
t.Run(tc.name, func(t *testing.T) {
update := replayToolUpdate(sessions.Event{Type: sessions.EventToolResult, Payload: mustRawPayload(t, map[string]any{
"toolCallId": "call-1", "name": "bash", "status": "ok",
"output": tc.output, "enforcementNotices": []string{persistedNotice},
})})
if update == nil {
t.Fatal("the stored result was not replayed")
}
if got := toolUpdateText(*update); got != tc.want {
t.Errorf("replayed text = %q, want %q", got, tc.want)
}
})
}
// And a stored result with no notices is replayed as it was written.
plain := replayToolUpdate(sessions.Event{Type: sessions.EventToolResult, Payload: mustRawPayload(t, map[string]any{
"toolCallId": "call-2", "name": "bash", "status": "ok", "output": "plain output",
})})
if plain == nil || toolUpdateText(*plain) != "plain output" {
t.Errorf("an ordinary stored result changed on replay: %+v", plain)
}
}
-65
View File
@@ -1,65 +0,0 @@
package acp
import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/tools"
)
// AN ACP CLIENT MUST SEE THE DISCLOSURE THE TUI SEES.
//
// agent.ToolResult stores the UNDECORATED model text alongside the typed
// enforcement notices; ModelOutput is what composes them. Reading .Output
// directly compiles and looks right, and silently drops the notice for every
// ACP client, which is the one surface with no other way to learn the sandbox
// narrowed what the command could do.
func TestToolResultContentCarriesTheEnforcementNotice(t *testing.T) {
const notice = "least-privilege notice: read access was narrowed"
result := agent.ToolResult{
Name: "bash",
Status: tools.StatusOK,
Output: "the command output",
EnforcementNotices: []string{notice},
}
content := toolResultContent(result)
if len(content) == 0 {
t.Fatal("no content produced for a successful tool result")
}
var text strings.Builder
for _, part := range content {
if part.Content != nil {
text.WriteString(part.Content.Text)
}
}
got := text.String()
if count := strings.Count(got, notice); count != 1 {
t.Errorf("the notice appears %d times, want exactly 1:\n%s", count, got)
}
if !strings.Contains(got, "the command output") {
t.Errorf("the underlying output was lost:\n%s", got)
}
}
// And a result with no notice is unchanged, so the accessor is not adding
// anything to ordinary output.
func TestToolResultContentLeavesAnOrdinaryResultAlone(t *testing.T) {
result := agent.ToolResult{
Name: "bash",
Status: tools.StatusOK,
Output: "plain output",
}
content := toolResultContent(result)
if len(content) == 0 {
t.Fatal("no content produced")
}
if content[0].Content == nil {
t.Fatal("content block missing")
}
if got := content[0].Content.Text; got != "plain output" {
t.Errorf("ordinary output = %q, want it untouched", got)
}
}
-181
View File
@@ -1,181 +0,0 @@
package acp
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"strings"
"testing"
"time"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/providermodeldiscovery"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/zeroruntime"
)
func TestACPImageAttachmentHistory(t *testing.T) {
for _, tc := range []struct {
name string
text string
images int
withoutVision bool
want string
}{
{name: "image only", images: 1, want: "[1 image]"},
{name: "text and images", text: "compare these", images: 2, want: "compare these [2 images]"},
{name: "ignored by model", images: 1, withoutVision: true, want: ""},
} {
t.Run(tc.name, func(t *testing.T) {
deps := testDeps(t)
deps.DiscoverModels = func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
modalities := []string{"text", "image"}
if tc.withoutVision {
modalities = []string{"text"}
}
return []providermodeldiscovery.Model{{ID: "fake-model", InputModalities: modalities}}, nil
}
type observedTurn struct {
prompt string
images int
}
turns := make(chan observedTurn, 8)
deps.RunAgent = func(_ context.Context, prompt string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
turns <- observedTurn{prompt: prompt, images: len(opts.Images)}
return agent.Result{FinalAnswer: "received"}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
workspace := t.TempDir()
h := newHarness(t, deps)
defer h.stop()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: workspace}, &created); err != nil {
t.Fatal(err)
}
const imageData = "image-history-bytes-not-for-storage"
encoded := base64.StdEncoding.EncodeToString([]byte(imageData))
var blocks []ContentBlock
if tc.text != "" {
blocks = append(blocks, TextBlock(tc.text))
}
for range tc.images {
blocks = append(blocks, ImageBlock(encoded, "image/png"))
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: blocks}, &PromptResult{}); err != nil {
t.Fatal(err)
}
live := <-turns
wantLiveImages := tc.images
if tc.withoutVision {
wantLiveImages = 0
}
if live.images != wantLiveImages {
t.Fatalf("live images = %d, want %d", live.images, wantLiveImages)
}
events, err := deps.Store.ReadEvents(created.SessionID)
if err != nil || len(events) < 2 {
t.Fatalf("read completed turn: events=%+v err=%v", events, err)
}
var user struct {
Role string `json:"role"`
Content string `json:"content"`
Attachments struct {
Images int `json:"images"`
} `json:"attachments"`
}
if err := json.Unmarshal(events[0].Payload, &user); err != nil {
t.Fatal(err)
}
if user.Role != "user" || user.Content != tc.text || user.Attachments.Images != wantLiveImages {
t.Errorf("stored user event = %s, want original text and attachments.images=%d", events[0].Payload, wantLiveImages)
}
for _, event := range events {
if bytes.Contains(event.Payload, []byte(encoded)) || bytes.Contains(event.Payload, []byte(imageData)) {
t.Errorf("image data leaked into session event: %s", event.Payload)
}
}
followUp := func(t *testing.T, client *clientHarness) {
t.Helper()
if err := client.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("what did I send?")},
}, &PromptResult{}); err != nil {
t.Fatal(err)
}
got := <-turns
if want := "User: " + tc.want + "\n"; !strings.Contains(got.prompt, want) {
t.Errorf("seeded prompt missing user history %q: %q", want, got.prompt)
}
if got.images != 0 {
t.Errorf("historical images reattached: %d", got.images)
}
}
followUp(t, h)
h.stop()
// Fresh agents and stores reproduce a process restart, with no shared
// in-memory history to hide a missing attachment in the event log.
for _, method := range []string{MethodSessionLoad, MethodSessionResume} {
t.Run(method, func(t *testing.T) {
restoredDeps := deps
restoredDeps.Store = sessions.NewStore(sessions.StoreOptions{RootDir: deps.Store.RootDir})
loader := newHarness(t, restoredDeps)
defer loader.stop()
if err := loader.client.Call(ctx, method, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace}, &LoadSessionResult{}); err != nil {
t.Fatal(err)
}
if method == MethodSessionLoad {
select {
case update := <-loader.notifications:
if update.SessionUpdate != UpdateUserMessageChunk || update.Content.Text != tc.want {
t.Errorf("replayed user = %+v, want %q", update, tc.want)
}
case <-ctx.Done():
t.Fatal("user turn was not replayed")
}
} else {
select {
case update := <-loader.notifications:
t.Errorf("resume replayed transcript: %+v", update)
default:
}
}
followUp(t, loader)
})
}
})
}
}
func TestACPAttachmentHistoryReadsExistingSessionMessages(t *testing.T) {
deps := testDeps(t)
meta, err := deps.Store.Create(sessions.CreateInput{SessionID: "attachment-compatibility", Cwd: t.TempDir()})
if err != nil {
t.Fatal(err)
}
for i, payload := range []string{
`{"role":"user","content":"old text"}`,
`{"role":"user","content":"","attachments":{"images":1,"documents":2}}`,
`{"role":"user","content":"still text","attachments":{"images":-1,"documents":1.5}}`,
`{"role":"assistant","content":"answer","attachments":{"images":1}}`,
} {
if _, err := deps.Store.AppendEvent(meta.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: json.RawMessage(payload)}); err != nil {
t.Fatalf("append message %d: %v", i, err)
}
}
a := &Agent{deps: deps}
_, messages, _, err := a.loadHistory(meta.SessionID, false)
if err != nil {
t.Fatal(err)
}
want := []string{"old text", "[1 image] [2 documents]", "still text", "answer"}
if len(messages) != len(want) {
t.Fatalf("messages = %+v", messages)
}
for i, text := range want {
if messages[i].content != text {
t.Errorf("message %d = %q, want %q", i, messages[i].content, text)
}
}
}
-213
View File
@@ -1,213 +0,0 @@
package acp
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// captureAgentOptions makes every turn record the agent.Options it was built
// with, so a test can assert the permission mode the next prompt actually runs
// with rather than only what the wire reports.
func captureAgentOptions(deps *Deps) *agent.Options {
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
}
return &captured
}
func newHarnessT(t *testing.T, deps Deps) *clientHarness {
t.Helper()
h := newHarness(t, deps)
t.Cleanup(h.stop)
return h
}
func mustCall(t *testing.T, ctx context.Context, h *clientHarness, method string, params any, result any) {
t.Helper()
if err := h.client.Call(ctx, method, params, result); err != nil {
t.Fatalf("%s: %v", method, err)
}
}
// restartHarness replaces the live agent with a fresh one over the same store,
// which is the process restart the persisted mode has to survive.
func restartHarness(t *testing.T, h *clientHarness, deps Deps) *clientHarness {
t.Helper()
h.stop()
return newHarnessT(t, deps)
}
func modeOptionValue(t *testing.T, options []SessionConfigOption) string {
t.Helper()
for _, option := range options {
if option.ID == configIDMode {
return option.CurrentValue
}
}
t.Fatalf("no mode config option in %+v", options)
return ""
}
func assertModeReported(t *testing.T, result LoadSessionResult, want agent.PermissionMode) {
t.Helper()
if result.Modes == nil || result.Modes.CurrentModeID != string(want) {
t.Fatalf("modes = %+v, want currentModeId %q", result.Modes, want)
}
if got := modeOptionValue(t, result.ConfigOptions); got != string(want) {
t.Fatalf("mode config option = %q, want %q", got, want)
}
}
func TestACPModePersistsAcrossRestartLoadAndResume(t *testing.T) {
deps := testDeps(t)
captured := captureAgentOptions(&deps)
h := newHarnessT(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
workspace := t.TempDir()
var created NewSessionResult
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: workspace, McpServers: []McpServer{}}, &created)
mustCall(t, ctx, h, MethodSessionSetMode, SetSessionModeParams{SessionID: created.SessionID, ModeID: string(agent.PermissionModePlan)}, &SetSessionModeResult{})
meta, err := deps.Store.Get(created.SessionID)
if err != nil || meta == nil {
t.Fatalf("store.Get: %v", err)
}
if meta.PermissionMode != string(agent.PermissionModePlan) {
t.Fatalf("stored mode = %q, want plan", meta.PermissionMode)
}
// LOAD after a restart: the response must report the stored mode, and the
// next prompt must actually run with it.
h = restartHarness(t, h, deps)
var loaded LoadSessionResult
mustCall(t, ctx, h, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &loaded)
assertModeReported(t, loaded, agent.PermissionModePlan)
mustCall(t, ctx, h, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("continue")}}, &PromptResult{})
if captured.PermissionMode != agent.PermissionModePlan {
t.Fatalf("after load, agent.Options.PermissionMode = %q, want plan", captured.PermissionMode)
}
// RESUME after another restart reports the same.
h = restartHarness(t, h, deps)
var resumed ResumeSessionResult
mustCall(t, ctx, h, MethodSessionResume, ResumeSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &resumed)
assertModeReported(t, resumed, agent.PermissionModePlan)
}
func TestACPModePersistsViaSetConfigOption(t *testing.T) {
deps := testDeps(t)
h := newHarnessT(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
workspace := t.TempDir()
var created NewSessionResult
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: workspace, McpServers: []McpServer{}}, &created)
var configured SetSessionConfigOptionResult
mustCall(t, ctx, h, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
SessionID: created.SessionID, ConfigID: configIDMode, Value: string(agent.PermissionModeAsk),
}, &configured)
if got := modeOptionValue(t, configured.ConfigOptions); got != string(agent.PermissionModeAsk) {
t.Fatalf("configured mode = %q, want ask", got)
}
meta, err := deps.Store.Get(created.SessionID)
if err != nil || meta == nil {
t.Fatalf("store.Get: %v", err)
}
if meta.PermissionMode != string(agent.PermissionModeAsk) {
t.Fatalf("stored mode = %q, want ask", meta.PermissionMode)
}
h = restartHarness(t, h, deps)
var loaded LoadSessionResult
mustCall(t, ctx, h, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &loaded)
assertModeReported(t, loaded, agent.PermissionModeAsk)
}
// TestACPModeRestoreFallback pins the restore boundary: no stored value keeps
// the historical auto default, and a stored value outside the three offered
// modes must never come back weaker than ask (and never unsafe).
func TestACPModeRestoreFallback(t *testing.T) {
cases := []struct {
name string
seed string // "" means metadata written before modes were persisted
want agent.PermissionMode
}{
{name: "unset keeps auto", want: agent.PermissionModeAuto},
{name: "ask preserved", seed: string(agent.PermissionModeAsk), want: agent.PermissionModeAsk},
{name: "unknown falls back to ask", seed: "bogus", want: agent.PermissionModeAsk},
{name: "unsafe never restored", seed: string(agent.PermissionModeUnsafe), want: agent.PermissionModeAsk},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
deps := testDeps(t)
captured := captureAgentOptions(&deps)
h := newHarnessT(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
workspace := t.TempDir()
var created NewSessionResult
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: workspace, McpServers: []McpServer{}}, &created)
if tc.seed != "" {
if _, err := deps.Store.UpdateMode(created.SessionID, tc.seed); err != nil {
t.Fatalf("seed mode: %v", err)
}
}
h = restartHarness(t, h, deps)
var loaded LoadSessionResult
mustCall(t, ctx, h, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &loaded)
assertModeReported(t, loaded, tc.want)
mustCall(t, ctx, h, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("go")}}, &PromptResult{})
if captured.PermissionMode != tc.want {
t.Fatalf("next prompt PermissionMode = %q, want %q", captured.PermissionMode, tc.want)
}
})
}
}
// TestACPSetModeWriteFailureKeepsLiveMode pins the store-first order: when the
// mode cannot be saved the request fails and the live session is untouched.
func TestACPSetModeWriteFailureKeepsLiveMode(t *testing.T) {
deps := testDeps(t)
captured := captureAgentOptions(&deps)
h := newHarnessT(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var created NewSessionResult
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &created)
// Remove the metadata file so UpdateMode fails but the live session stays
// promptable (turn persistence only warns when the file is gone).
metadataPath := filepath.Join(deps.Store.RootDir, created.SessionID, sessions.MetadataFile)
if err := os.Remove(metadataPath); err != nil {
t.Fatalf("remove metadata: %v", err)
}
err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: created.SessionID, ModeID: string(agent.PermissionModePlan)}, &SetSessionModeResult{})
var rpcErr *rpcError
if !errors.As(err, &rpcErr) || rpcErr.Code != codeInternalError {
t.Fatalf("set_mode with an unsavable mode = %v, want internal error", err)
}
mustCall(t, ctx, h, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("still auto")}}, &PromptResult{})
if captured.PermissionMode != agent.PermissionModeAuto {
t.Fatalf("failed set_mode changed the live mode: PermissionMode = %q, want auto", captured.PermissionMode)
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ package acp
import (
"encoding/json"
"github.com/Twigpine/zero/internal/agent"
"github.com/Gitlawb/zero/internal/agent"
)
// permission.go maps ZERO's permission prompt model onto ACP's
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/agent"
"github.com/Gitlawb/zero/internal/agent"
)
func escalationRequest(escalates bool) agent.PermissionRequest {
+1 -1
View File
@@ -3,7 +3,7 @@ package acp
import (
"testing"
"github.com/Twigpine/zero/internal/agent"
"github.com/Gitlawb/zero/internal/agent"
)
func TestBuildPermissionOptions(t *testing.T) {
+4 -8
View File
@@ -8,8 +8,8 @@ import (
"unicode"
"unicode/utf8"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/tools"
"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
@@ -83,7 +83,7 @@ func browserToolDetails(name string) (*BrowserToolDetails, bool) {
}
}
const zeroBrowserMetaKey = "github.com/Twigpine/zero/browser"
const zeroBrowserMetaKey = "github.com/Gitlawb/zero/browser"
// attachBrowserToolDetails stores ZERO's browser descriptor in ACP's reserved
// extension channel. Keeping this in one helper prevents start, result, and
@@ -248,11 +248,7 @@ func toolCallResult(result agent.ToolResult) ToolCallUpdate {
func toolResultContent(result agent.ToolResult) []ToolCallContent {
content := make([]ToolCallContent, 0, 1+len(result.FileDiffs))
// ModelOutput, not the raw field. agent.ToolResult stores the undecorated
// model text alongside the typed enforcement notices, and the accessor is
// what composes the two; reading Output directly sends an ACP client the
// output with the disclosure missing.
text := strings.TrimRight(result.ModelOutput(), "\n")
text := strings.TrimRight(result.Output, "\n")
if text == "" {
text = result.Display.Summary
}
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"unicode"
"unicode/utf8"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
)
func browserDescriptor(t *testing.T, update ToolCallUpdate) BrowserToolDetails {
-309
View File
@@ -1,309 +0,0 @@
package acp
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/Twigpine/zero/internal/agent"
"github.com/Twigpine/zero/internal/sessions"
"github.com/Twigpine/zero/internal/zeroruntime"
)
// `zero exec` persists every provider usage report as a session usage event, so
// `zero usage report --session <id>` can total a run. The ACP agent did not set
// OnUsage, which dropped the token counts of every ACP session. A turn with two
// provider requests (a tool round trip) must leave two usage events, in the shape
// the usage report reads, next to the turn's messages.
func TestACPPromptPersistsProviderUsage(t *testing.T) {
deps := testDeps(t)
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
if opts.OnUsage == nil {
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
return agent.Result{FinalAnswer: "done"}, nil
}
opts.OnUsage(agent.Usage{InputTokens: 1200, OutputTokens: 34})
opts.OnUsage(agent.Usage{InputTokens: 1300, OutputTokens: 21, CachedInputTokens: 900})
return agent.Result{FinalAnswer: "done"}, nil
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
t.Fatal(err)
}
var result PromptResult
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("count my tokens")},
}, &result); err != nil {
t.Fatalf("session/prompt: %v", err)
}
events, err := deps.Store.ReadEvents(created.SessionID)
if err != nil {
t.Fatal(err)
}
type usagePayload struct {
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
TotalTokens int `json:"totalTokens"`
CachedInputTokens int `json:"cachedInputTokens"`
}
var got []usagePayload
messages := 0
for _, event := range events {
switch event.Type {
case sessions.EventUsage:
var payload usagePayload
if err := json.Unmarshal(event.Payload, &payload); err != nil {
t.Fatalf("usage payload %s: %v", event.Payload, err)
}
assertUsagePayloadIsTokenCountsOnly(t, event.Payload)
got = append(got, payload)
case sessions.EventMessage:
messages++
}
}
want := []usagePayload{
{PromptTokens: 1200, CompletionTokens: 34, TotalTokens: 1234},
{PromptTokens: 1300, CompletionTokens: 21, TotalTokens: 1321, CachedInputTokens: 900},
}
if len(got) != len(want) {
t.Fatalf("usage events = %+v, want %+v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("usage event %d = %+v, want %+v", i, got[i], want[i])
}
}
if messages != 2 {
t.Fatalf("message events = %d, want the user and assistant messages of the turn", messages)
}
}
// Usage joins the turn's buffered batch, so a hard run failure that aborts the
// turn leaves nothing durable, exactly as it does for the turn's messages.
func TestACPHardTurnFailureDropsItsUsageWithTheTurn(t *testing.T) {
deps := testDeps(t)
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
if opts.OnUsage == nil {
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
return agent.Result{}, errors.New("injected hard run failure")
}
opts.OnUsage(agent.Usage{InputTokens: 500, OutputTokens: 5})
return agent.Result{}, errors.New("injected hard run failure")
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
t.Fatal(err)
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("failing turn")},
}, &PromptResult{}); err == nil {
t.Fatal("hard RunAgent failure was reported as a successful turn")
}
events, err := deps.Store.ReadEvents(created.SessionID)
if err != nil {
t.Fatal(err)
}
if len(events) != 0 {
t.Fatalf("aborted turn left durable events: %+v", events)
}
}
// StopCancelled is a successful ACP outcome: the turn commits. Tokens the
// provider already reported were spent, so they stay in the session log even
// though the client cancelled. A hard error (the test above) is the path that
// drops them.
func TestACPCancelledTurnKeepsReportedUsage(t *testing.T) {
deps := testDeps(t)
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
if opts.OnUsage == nil {
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
return agent.Result{}, context.Canceled
}
opts.OnUsage(agent.Usage{InputTokens: 80, OutputTokens: 3})
return agent.Result{FinalAnswer: "partial"}, context.Canceled
}
h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
t.Fatal(err)
}
var result PromptResult
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("cancelled turn")},
}, &result); err != nil {
t.Fatalf("cancelled turn returned an RPC error: %v", err)
}
if result.StopReason != StopCancelled {
t.Fatalf("stop reason = %q, want %q", result.StopReason, StopCancelled)
}
events, err := deps.Store.ReadEvents(created.SessionID)
if err != nil {
t.Fatal(err)
}
usageEvents := 0
for _, event := range events {
if event.Type != sessions.EventUsage {
continue
}
usageEvents++
assertUsagePayloadIsTokenCountsOnly(t, event.Payload)
var payload struct {
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
TotalTokens int `json:"totalTokens"`
}
if err := json.Unmarshal(event.Payload, &payload); err != nil {
t.Fatal(err)
}
if payload.PromptTokens != 80 || payload.CompletionTokens != 3 || payload.TotalTokens != 83 {
t.Fatalf("cancelled-turn usage = %+v, want 80/3/83", payload)
}
}
if usageEvents != 1 {
t.Fatalf("usage events = %d, want the one report from the cancelled turn", usageEvents)
}
}
// Usage events sit in the same log session/load and session/resume read, but
// history reconstruction skips them. The wire replay stays the conversation,
// and the next turn's prompt stays the conversation: token counts never become
// context and never cross the ACP wire.
func TestACPLoadAndResumeIgnoreUsageEvents(t *testing.T) {
const userLine = "unique-acp-user-line"
const answer = "unique-acp-answer"
deps := testDeps(t)
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
if opts.OnUsage == nil {
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
return agent.Result{FinalAnswer: answer}, nil
}
opts.OnUsage(agent.Usage{InputTokens: 1200, OutputTokens: 34})
return agent.Result{FinalAnswer: answer}, nil
}
workspace := t.TempDir()
h := newHarness(t, deps)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var created NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: workspace}, &created); err != nil {
t.Fatal(err)
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock(userLine)},
}, &PromptResult{}); err != nil {
t.Fatalf("session/prompt: %v", err)
}
h.stop()
events, err := deps.Store.ReadEvents(created.SessionID)
if err != nil {
t.Fatal(err)
}
sawUsage := false
for _, event := range events {
if event.Type == sessions.EventUsage {
sawUsage = true
assertUsagePayloadIsTokenCountsOnly(t, event.Payload)
}
}
if !sawUsage {
t.Fatal("session log has no usage event to ignore")
}
loader := newHarness(t, deps)
if err := loader.client.Call(ctx, MethodSessionLoad, LoadSessionParams{
SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{},
}, &LoadSessionResult{}); err != nil {
t.Fatalf("session/load: %v", err)
}
var replayed []string
for {
select {
case update := <-loader.notifications:
replayed = append(replayed, update.Content.Text)
case <-time.After(150 * time.Millisecond):
goto drained
}
}
drained:
loader.stop()
if len(replayed) != 2 || replayed[0] != userLine || replayed[1] != answer {
t.Fatalf("session/load replay = %#v, want the user line and the answer only", replayed)
}
for _, text := range replayed {
if strings.Contains(text, "promptTokens") || strings.Contains(text, "1200") {
t.Fatalf("session/load replayed usage onto the wire: %q", text)
}
}
var gotPrompt string
deps.RunAgent = func(_ context.Context, prompt string, _ zeroruntime.Provider, _ agent.Options) (agent.Result, error) {
gotPrompt = prompt
return agent.Result{FinalAnswer: "continued"}, nil
}
resumer := newHarness(t, deps)
defer resumer.stop()
if err := resumer.client.Call(ctx, MethodSessionResume, ResumeSessionParams{
SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{},
}, &ResumeSessionResult{}); err != nil {
t.Fatalf("session/resume: %v", err)
}
if err := resumer.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("carry on")},
}, &PromptResult{}); err != nil {
t.Fatalf("resumed session/prompt: %v", err)
}
if !strings.Contains(gotPrompt, userLine) || !strings.Contains(gotPrompt, answer) {
t.Fatalf("resumed prompt = %q, want the prior conversation", gotPrompt)
}
if strings.Contains(gotPrompt, "promptTokens") || strings.Contains(gotPrompt, "1200") {
t.Fatalf("resumed prompt contains persisted usage: %q", gotPrompt)
}
}
// usagePayloadAllowedKeys is the non-escalating EventUsagePayload shape. ACP
// has no model escalation, and the writer must not grow a prompt, tool
// argument, or other raw field.
var usagePayloadAllowedKeys = map[string]bool{
"promptTokens": true,
"completionTokens": true,
"totalTokens": true,
"cachedInputTokens": true,
"cacheWriteTokens": true,
"reasoningTokens": true,
}
func assertUsagePayloadIsTokenCountsOnly(t *testing.T, payload json.RawMessage) {
t.Helper()
var raw map[string]json.RawMessage
if err := json.Unmarshal(payload, &raw); err != nil {
t.Fatalf("usage payload %s: %v", payload, err)
}
if len(raw) == 0 {
t.Fatalf("usage payload is empty: %s", payload)
}
for key := range raw {
if !usagePayloadAllowedKeys[key] {
t.Fatalf("usage payload key %q is not a token count: %s", key, payload)
}
}
}
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func enabledSelfCorrector(t *testing.T) *SelfCorrector {
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
type normalizationRecordingShellTool struct {
-160
View File
@@ -1,160 +0,0 @@
package agent
import (
"context"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/tools"
)
// The tool's own disclosure and the afterTool hook's are the SAME STRING, which
// is the real case: both come from the fixed Windows deny_read warning, so a hook
// running under the same token shape as the tool it follows reports exactly what
// the tool reported.
const sharedEnforcementNotice = "least-privilege notice"
const afterToolChatter = "vet-found-nothing"
// sharedNoticeHookPreparer runs a hook that prints ordinary output and carries
// the same enforcement notice the tool carries.
type sharedNoticeHookPreparer struct{}
func (sharedNoticeHookPreparer) PrepareExecution(_ context.Context, _ execution.Request) (execution.PreparedCommand, error) {
var command *exec.Cmd
if runtime.GOOS == "windows" {
command = exec.Command("cmd.exe", "/c", "echo "+afterToolChatter)
} else {
command = exec.Command("/bin/sh", "-c", "echo "+afterToolChatter)
}
return execution.PreparedCommand{
Command: command,
Enforcement: execution.Enforcement{Notices: []string{sharedEnforcementNotice}},
}, nil
}
func afterToolNoticeDispatcher(t *testing.T) *hooks.Dispatcher {
t.Helper()
audit, err := hooks.NewAuditStore(hooks.AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
if err != nil {
t.Fatalf("NewAuditStore: %v", err)
}
return hooks.NewDispatcher(hooks.DispatcherOptions{
Config: hooks.Config{
Enabled: true,
Hooks: []hooks.Definition{
{ID: "zero.after-tool", Event: hooks.EventAfterTool, Matcher: "notice_projection", Command: "vet", Enabled: true},
},
},
Audit: audit,
Cwd: t.TempDir(),
Execution: execution.NewRunner(sharedNoticeHookPreparer{}),
})
}
// ONE DISCLOSURE, ONE DELIVERY, FROM EITHER HOOK PHASE.
//
// beforeTool was moved onto the typed EnforcementNotices slice and afterTool was
// left folding its notices into the prose feedback. Both halves then wrote the
// same fact: the typed slice, which every surface composes through ModelOutput
// and HumanDisplay, and the "Hook output:" block appended to the body. Since the
// two carry the identical fixed string, the model saw the disclosure twice and a
// bash or exec card showed it in the amber furniture and again in the body.
//
// Half a symmetry is its own defect, and this is the composition that catches it:
// a tool that reports a notice, followed by an afterTool hook that reports the
// same one.
func TestAnAfterToolNoticeIsDeliveredOnce(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(noticeProjectionTool{})
result, err := executeToolCall(context.Background(), registry, ToolCall{
ID: "call-1", Name: "notice_projection", Arguments: `{}`,
}, PermissionModeAuto, Options{Cwd: t.TempDir(), Hooks: afterToolNoticeDispatcher(t)})
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
// SETUP: the hook really ran, or the silence below is the silence of a hook
// that never executed.
if !strings.Contains(result.ModelOutput(), afterToolChatter) {
t.Fatalf("SETUP INVALID: the afterTool hook's own output never arrived, so nothing here is under test:\n%s", result.ModelOutput())
}
if got := strings.Count(result.ModelOutput(), sharedEnforcementNotice); got != 1 {
t.Errorf("the disclosure reached the model %d times, want once:\n%s", got, result.ModelOutput())
}
if got := strings.Count(strings.Join(result.EnforcementNotices, "\n"), sharedEnforcementNotice); got != 1 {
t.Errorf("the typed slice carries the disclosure %d times, want once: %v", got, result.EnforcementNotices)
}
// The body must not carry it at all: the surfaces draw it from the slice, so a
// copy in the body is what renders it twice.
if strings.Contains(result.BaseModelOutput(), sharedEnforcementNotice) {
t.Errorf("the disclosure is in the result body as well as the typed slice:\n%s", result.BaseModelOutput())
}
if strings.Count(result.HumanDisplay().Summary, sharedEnforcementNotice) != 1 {
t.Errorf("the card summary shows the disclosure %d times, want once:\n%s",
strings.Count(result.HumanDisplay().Summary, sharedEnforcementNotice), result.HumanDisplay().Summary)
}
}
// And an afterTool hook that discloses something the tool did not still gets
// through, on the typed channel rather than as prose.
func TestAnAfterToolNoticeReachesTheTypedSlice(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(quietTool{})
result, err := executeToolCall(context.Background(), registry, ToolCall{
ID: "call-1", Name: "quiet_tool", Arguments: `{}`,
}, PermissionModeAuto, Options{Cwd: t.TempDir(), Hooks: quietToolAfterHookDispatcher(t)})
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if !strings.Contains(result.ModelOutput(), afterToolChatter) {
t.Fatalf("SETUP INVALID: the afterTool hook never ran:\n%s", result.ModelOutput())
}
if len(result.EnforcementNotices) != 1 || result.EnforcementNotices[0] != sharedEnforcementNotice {
t.Errorf("an afterTool hook's disclosure did not reach the typed slice, so no card renders it: %v", result.EnforcementNotices)
}
if strings.Contains(result.BaseModelOutput(), sharedEnforcementNotice) {
t.Errorf("the disclosure travelled as prose in the body instead:\n%s", result.BaseModelOutput())
}
}
// quietTool carries no enforcement notice of its own.
type quietTool struct{}
func (quietTool) Name() string { return "quiet_tool" }
func (quietTool) Description() string { return "test tool with no enforcement notice" }
func (quietTool) Parameters() tools.Schema { return tools.Schema{Type: "object"} }
func (quietTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}
func (quietTool) Run(ctx context.Context, args map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "the command output"}
}
func quietToolAfterHookDispatcher(t *testing.T) *hooks.Dispatcher {
t.Helper()
audit, err := hooks.NewAuditStore(hooks.AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
if err != nil {
t.Fatalf("NewAuditStore: %v", err)
}
return hooks.NewDispatcher(hooks.DispatcherOptions{
Config: hooks.Config{
Enabled: true,
Hooks: []hooks.Definition{
{ID: "zero.after-tool", Event: hooks.EventAfterTool, Matcher: "quiet_tool", Command: "vet", Enabled: true},
},
},
Audit: audit,
Cwd: t.TempDir(),
Execution: execution.NewRunner(sharedNoticeHookPreparer{}),
})
}
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// providerCallingAskUserThenAnswer returns a mock provider whose first turn calls
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"testing"
"time"
"github.com/Twigpine/zero/internal/testutil"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/testutil"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestAsyncDiagnosticsNilCollectorNoOps(t *testing.T) {
-210
View File
@@ -1,210 +0,0 @@
package agent
import (
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/tools"
zeroruntime "github.com/Twigpine/zero/internal/zeroruntime"
)
const beforeToolNotice = "denyRead is configured, so the write jail is not confining writes"
// beforeToolChatter is what a hook prints for its own reasons. It must never
// reach the model: main is silent for a successful hook, and a hook that logs is
// not asking to be heard by anything but the operator's terminal.
const beforeToolChatter = "hook-ran-and-logged-this"
// noticeHookPreparer plans the hook command with an enforcement notice attached,
// the way the sandbox does for a command it weakened. The prepared child prints
// ordinary output as well, so one run carries both kinds of text and the
// delivery decision has to tell them apart.
type noticeHookPreparer struct{}
func (noticeHookPreparer) PrepareExecution(_ context.Context, _ execution.Request) (execution.PreparedCommand, error) {
var command *exec.Cmd
if runtime.GOOS == "windows" {
command = exec.Command("cmd.exe", "/c", "echo "+beforeToolChatter)
} else {
command = exec.Command("/bin/sh", "-c", "echo "+beforeToolChatter)
}
return execution.PreparedCommand{
Command: command,
Enforcement: execution.Enforcement{Notices: []string{beforeToolNotice}},
}, nil
}
func beforeToolDispatcher(t *testing.T, event hooks.Event, exitCode int) *hooks.Dispatcher {
t.Helper()
audit, err := hooks.NewAuditStore(hooks.AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
if err != nil {
t.Fatalf("NewAuditStore: %v", err)
}
return hooks.NewDispatcher(hooks.DispatcherOptions{
Config: hooks.Config{
Enabled: true,
Hooks: []hooks.Definition{
{ID: "zero.before-tool", Event: event, Matcher: "read_file", Command: "hook", Enabled: true},
},
},
Audit: audit,
Cwd: t.TempDir(),
Execution: execution.NewRunner(noticeHookPreparer{}),
})
}
func readFileRunOptions(t *testing.T, dispatcher *hooks.Dispatcher) (Options, *mockProvider, string) {
t.Helper()
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("hello"), 0o644); err != nil {
t.Fatalf("write notes.txt: %v", err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"notes.txt"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "read it"},
{Type: zeroruntime.StreamEventDone},
},
}}
return Options{
SessionID: "session-hook",
Cwd: root,
Registry: registry,
ProviderName: "test-provider",
Model: "test-model",
Hooks: dispatcher,
MaxTurns: 2,
}, provider, root
}
// countRequestsContaining reports how many provider requests carry needle, so a
// notice delivered twice is distinguishable from one delivered once.
func countRequestsContaining(requests []zeroruntime.CompletionRequest, needle string) int {
total := 0
for _, request := range requests {
for _, message := range request.Messages {
if strings.Contains(message.Content, needle) {
total++
}
}
}
return total
}
// THE NOTICE CROSSES TO THE MODEL. THE HOOK'S OWN OUTPUT DOES NOT.
//
// executeToolCall used to read the beforeTool outcome only when Blocked was
// true, so a hook that ran under the weakened DenyRead token said so to nobody.
// Delivering DispatchOutcome.Messages fixed that and overshot: hookMessage folds
// the notice together with the hook's ordinary stdout, so every successful
// hook's routine logging became a standing input channel into the next model
// request, which is not what main does.
//
// One hook run produces both kinds of text here, because the bug is exactly a
// failure to tell them apart. Asserted on what the PROVIDER received, since that
// is the boundary that matters; a unit test on the joining helper cannot see
// which slice the loop passes it.
func TestSuccessfulBeforeToolHookDeliversItsNoticeAndNotItsOutput(t *testing.T) {
options, provider, _ := readFileRunOptions(t, beforeToolDispatcher(t, hooks.EventBeforeTool, 0))
if _, err := Run(context.Background(), "read the notes", provider, options); err != nil {
t.Fatalf("Run: %v", err)
}
// The tool ran, so this is the successful-hook path rather than a blocked
// call that never reached the tool.
if !someRequestContains(provider.requests, "hello") {
t.Fatal("SETUP INVALID: the tool result never reached the model, so nothing was delivered to check")
}
// And the hook really did run and really did print, or the silence asserted
// below would be the silence of a hook that never executed.
if !someRequestContains(provider.requests, beforeToolNotice) {
t.Fatal("the enforcement notice never reached the model, so a hook could run under the weakened token and say so to nobody")
}
if got := countRequestsContaining(provider.requests, beforeToolNotice); got != 1 {
t.Errorf("the notice reached the model %d times, want exactly once", got)
}
if someRequestContains(provider.requests, beforeToolChatter) {
t.Error("the hook's ordinary output reached the model; main is silent for a successful hook and routine logging must not become model input")
}
}
// A VETO MUST NOT SWALLOW A NOTICE FROM A HOOK THAT ALREADY RAN.
//
// Dispatch runs hooks in order and returns at the first veto. The successful
// hook ahead of it may already have run under the weakened token, and that is a
// fact about something that happened. The veto result used to be built from the
// blocking hook's Reason alone, so the earlier disclosure existed only in the
// audit record.
func TestABlockedCallStillCarriesTheEarlierHooksNotice(t *testing.T) {
audit, err := hooks.NewAuditStore(hooks.AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
if err != nil {
t.Fatalf("NewAuditStore: %v", err)
}
dispatcher := hooks.NewDispatcher(hooks.DispatcherOptions{
Config: hooks.Config{
Enabled: true,
Hooks: []hooks.Definition{
{ID: "zero.first", Event: hooks.EventBeforeTool, Matcher: "read_file", Command: "hook", Enabled: true},
{ID: "zero.veto", Event: hooks.EventBeforeTool, Matcher: "read_file", Command: "veto", Enabled: true},
},
},
Audit: audit,
Cwd: t.TempDir(),
Execution: execution.NewRunner(vetoSecondPreparer{}),
})
options, provider, _ := readFileRunOptions(t, dispatcher)
if _, err := Run(context.Background(), "read the notes", provider, options); err != nil {
t.Fatalf("Run: %v", err)
}
// SETUP: the second hook really did veto, or this is the ordinary path.
if !someRequestContains(provider.requests, "was blocked by hook") {
t.Fatal("SETUP INVALID: the call was not blocked, so the veto path is not under test")
}
if !someRequestContains(provider.requests, beforeToolNotice) {
t.Error("the veto result dropped the notice from the hook that had already run under the weakened token")
}
if got := countRequestsContaining(provider.requests, beforeToolNotice); got != 1 {
t.Errorf("the notice reached the model %d times, want exactly once", got)
}
if someRequestContains(provider.requests, beforeToolChatter) {
t.Error("the vetoed result carried the earlier hook's ordinary output")
}
}
// vetoSecondPreparer runs the first hook successfully with a notice and makes
// the second one exit non-zero, which is a veto for a blocking event.
type vetoSecondPreparer struct{}
func (vetoSecondPreparer) PrepareExecution(_ context.Context, request execution.Request) (execution.PreparedCommand, error) {
script := "echo " + beforeToolChatter
notices := []string{beforeToolNotice}
if request.Command.Name == "veto" {
script = "exit 2"
notices = nil
}
var command *exec.Cmd
if runtime.GOOS == "windows" {
command = exec.Command("cmd.exe", "/c", script)
} else {
command = exec.Command("/bin/sh", "-c", script)
}
return execution.PreparedCommand{
Command: command,
Enforcement: execution.Enforcement{Notices: notices},
}, nil
}
@@ -1,129 +0,0 @@
package agent
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/tools"
zeroruntime "github.com/Twigpine/zero/internal/zeroruntime"
)
// editMatchingDispatcher is beforeToolDispatcher for the edit tool, so the same
// notice-and-chatter hook runs ahead of a call whose result carries a diff.
func editMatchingDispatcher(t *testing.T) *hooks.Dispatcher {
t.Helper()
audit, err := hooks.NewAuditStore(hooks.AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
if err != nil {
t.Fatalf("NewAuditStore: %v", err)
}
return hooks.NewDispatcher(hooks.DispatcherOptions{
Config: hooks.Config{
Enabled: true,
Hooks: []hooks.Definition{
{ID: "zero.before-tool", Event: hooks.EventBeforeTool, Matcher: "edit_file", Command: "hook", Enabled: true},
},
},
Audit: audit,
Cwd: t.TempDir(),
Execution: execution.NewRunner(noticeHookPreparer{}),
})
}
// editFileRunOptions is readFileRunOptions for a tool whose result carries a
// rich preview, which is the case the disclosure went missing on.
func editFileRunOptions(t *testing.T, dispatcher *hooks.Dispatcher, onResult func(ToolResult)) (Options, *mockProvider) {
t.Helper()
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "x.go"), []byte("package main\n\nconst answer = 41\n"), 0o644); err != nil {
t.Fatalf("write x.go: %v", err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedEditFileTool(root, nil))
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "edit_file"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"x.go","old_string":"41","new_string":"42"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "edited it"},
{Type: zeroruntime.StreamEventDone},
},
}}
return Options{
SessionID: "session-hook-edit",
Cwd: root,
Registry: registry,
ProviderName: "test-provider",
Model: "test-model",
Hooks: dispatcher,
OnToolResult: onResult,
PermissionMode: PermissionModeUnsafe,
MaxTurns: 2,
}, provider
}
// THE NOTICE HAS TO SURVIVE A RESULT WHOSE BODY IS A DIFF.
//
// This is the boundary the component suites left between them. The delivery test
// above proves a beforeTool notice reaches the provider with read_file, whose
// body is its output. The card tests prove a card renders a notice when the
// result already carries one typed. Neither composed the two, and in between them
// the notice was being folded into result.Output as hook prose.
//
// For an edit or a write that is exactly where it disappears: the card builds its
// body from Display.Preview rather than Output, and draws enforcement furniture
// only from the typed slice, so the operator saw the diff and no disclosure at
// all, live and restored, while the model saw the disclosure. A notice about a
// weakened token was hidden on precisely the results where something was written.
func TestABeforeToolNoticeSurvivesARichPreviewResult(t *testing.T) {
dispatcher := editMatchingDispatcher(t)
var results []ToolResult
options, provider := editFileRunOptions(t, dispatcher, func(result ToolResult) {
results = append(results, result)
})
if _, err := Run(context.Background(), "bump the answer", provider, options); err != nil {
t.Fatalf("Run: %v", err)
}
if len(results) != 1 {
t.Fatalf("SETUP INVALID: %d tool results, want the one edit", len(results))
}
result := results[0]
if result.Status != tools.StatusOK {
t.Fatalf("SETUP INVALID: the edit failed, so this is not the successful rich-preview path: %s", result.Output)
}
preview := result.BaseDisplay().Preview
if !strings.Contains(preview, "42") {
t.Fatalf("SETUP INVALID: the result carries no diff preview, which is the whole case under test: %q", preview)
}
// TYPED, which is what every interactive surface reads.
if len(result.EnforcementNotices) != 1 || result.EnforcementNotices[0] != beforeToolNotice {
t.Errorf("the hook's disclosure did not reach the typed field, so the card and the restored card show the diff and no disclosure: %v", result.EnforcementNotices)
}
// AND NOT IN THE BODY AS WELL. Decoration has one owner per surface; carrying
// it in both places renders it twice wherever the surface draws the slice.
if strings.Contains(result.BaseModelOutput(), beforeToolNotice) {
t.Errorf("the disclosure is in the result body as well as the typed field:\n%s", result.BaseModelOutput())
}
// The diff itself stays a parseable diff.
if strings.Contains(preview, beforeToolNotice) {
t.Errorf("the disclosure was glued onto the diff:\n%s", preview)
}
// And the model still sees it, exactly once, with none of the hook's chatter.
if got := countRequestsContaining(provider.requests, beforeToolNotice); got != 1 {
t.Errorf("the notice reached the model %d times, want exactly once", got)
}
if someRequestContains(provider.requests, beforeToolChatter) {
t.Error("the hook's ordinary output reached the model")
}
}
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"runtime"
"strings"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"mvdan.cc/sh/v3/syntax"
)
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"runtime"
"testing"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"strings"
"unicode/utf8"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// Session compaction.
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// compactableHistory returns a history whose middle is large enough to trip
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestCompactMessagesReturnsMetadataForManualCompaction(t *testing.T) {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strings"
"unicode/utf8"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// Compaction preservation.
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"testing"
"unicode/utf8"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// stateConversation is a long enough conversation that Compact elides a middle
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"regexp"
"strings"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
const (
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"testing"
"unicode/utf8"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestClipBytesHandlesTinyLimitsOnRuneBoundaries(t *testing.T) {
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// recentEdits extracts each mutated file's path and a one-line note from the
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"sync/atomic"
"testing"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
var markerPattern = regexp.MustCompile(`msg-\d+`)
+3 -3
View File
@@ -6,9 +6,9 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// --- Pure Compact() tests -------------------------------------------------
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// someRequestContains reports whether any message sent to the provider across all
+1 -1
View File
@@ -3,7 +3,7 @@ package agent
import (
"encoding/json"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// Context budget.
+1 -1
View File
@@ -3,7 +3,7 @@ package agent
import (
"testing"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestMeasureContextSplitsByCategory(t *testing.T) {
+1 -1
View File
@@ -3,7 +3,7 @@ package agent
import (
"strings"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type contextPlannerConfig struct {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"reflect"
"testing"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestContextPlannerPreservesProviderRequest(t *testing.T) {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"fmt"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/tools"
)
// richMCPTool mimics a real MCP tool's schema (Exa/Firecrawl-style search): an
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestOptionsDeferThresholdFieldExists(t *testing.T) {
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"context"
"testing"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
func TestExecuteToolCallCategorizesFilteredDenial(t *testing.T) {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/tools"
)
// argsPermTool implements tools.ArgsPermissioner; plainTool does not.
@@ -1,114 +0,0 @@
package agent
import (
"context"
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
)
// noticeProjectionTool stands in for any sandboxed command tool: it reports the
// enforcement disclosure the way the real ones do, through the sandbox metadata
// key that finalizeToolOutcome promotes into the typed notice slice.
type noticeProjectionTool struct{}
func (noticeProjectionTool) Name() string { return "notice_projection" }
func (noticeProjectionTool) Description() string { return "test tool carrying an enforcement notice" }
func (noticeProjectionTool) Parameters() tools.Schema { return tools.Schema{Type: "object"} }
func (noticeProjectionTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}
func (noticeProjectionTool) Run(ctx context.Context, args map[string]any) tools.Result {
return tools.Result{
Status: tools.StatusOK,
Output: "the command output",
Display: tools.Display{Summary: "the human summary"},
Meta: map[string]string{"sandbox_notices": "least-privilege notice"},
}
}
// THE PROJECTION MUST CARRY ONE REPRESENTATION, NOT TWO.
//
// executeToolCall copies the typed notice slice into agent.ToolResult, so the
// text fields it copies alongside must be the UNDECORATED base. Storing the
// already-rendered text there instead leaves the same fact in two places with no
// contract between them: it happens to render once today only because the
// outcome arrives finalized and the agent accessor then reads Outcome.ModelView
// rather than the stored field. Any result that reaches the accessor without a
// finalized outcome renders the disclosure twice, and every raw reader of
// .Output sees text that disagrees with Outcome.ModelView.
func TestEnforcementNoticeIsStoredOnceAndRenderedOnce(t *testing.T) {
const notice = "least-privilege notice"
registry := tools.NewRegistry()
registry.Register(noticeProjectionTool{})
result, err := executeToolCall(context.Background(), registry, ToolCall{
ID: "call-1", Name: "notice_projection", Arguments: `{}`,
}, PermissionModeAuto, Options{Cwd: t.TempDir()})
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if len(result.EnforcementNotices) == 0 {
t.Fatalf("the notice never reached the agent result: %#v", result)
}
// The stored fields are the canonical undecorated base, and they agree with
// the finalized outcome they were projected from.
if strings.Contains(result.Output, notice) {
t.Errorf("ToolResult.Output stores the rendered notice as well as the slice: %q", result.Output)
}
if strings.Contains(result.Display.Summary, notice) {
t.Errorf("ToolResult.Display.Summary stores the rendered notice as well as the slice: %q", result.Display.Summary)
}
if result.Output != result.Outcome.ModelView {
t.Errorf("stored output %q disagrees with the finalized model view %q", result.Output, result.Outcome.ModelView)
}
if result.Display.Summary != result.Outcome.HumanView.Summary {
t.Errorf("stored summary %q disagrees with the finalized human view %q", result.Display.Summary, result.Outcome.HumanView.Summary)
}
// And every consumer that renders goes through the accessors, which show the
// disclosure exactly once without hiding the output it is attached to.
// loop.go builds the provider transcript from ModelOutput; the CLI writer and
// the TUI cards use both accessors.
transcript := result.ModelOutput()
if got := strings.Count(transcript, notice); got != 1 {
t.Errorf("the transcript shows the notice %d times, want 1: %q", got, transcript)
}
if !strings.Contains(transcript, "the command output") {
t.Errorf("the transcript lost the command output: %q", transcript)
}
summary := result.HumanDisplay().Summary
if got := strings.Count(summary, notice); got != 1 {
t.Errorf("the human summary shows the notice %d times, want 1: %q", got, summary)
}
if !strings.Contains(summary, "the human summary") {
t.Errorf("the human summary lost the tool summary: %q", summary)
}
}
// A result that never crossed the registry has no finalized outcome, so the
// accessor falls back to the stored field. That is the path on which a stored
// rendering would double, and it is the reason the contract above is stated on
// the stored fields rather than only on the accessors.
func TestUnfinalizedResultStillRendersTheNoticeOnce(t *testing.T) {
const notice = "least-privilege notice"
result := ToolResult{
Status: tools.StatusOK,
Output: "the command output",
Display: tools.Display{Summary: "the human summary"},
EnforcementNotices: []string{notice},
}
if result.Outcome.Finalized() {
t.Fatal("fixture is finalized; it no longer covers the fallback path")
}
if got := strings.Count(result.ModelOutput(), notice); got != 1 {
t.Errorf("ModelOutput shows the notice %d times, want 1: %q", got, result.ModelOutput())
}
if got := strings.Count(result.HumanDisplay().Summary, notice); got != 1 {
t.Errorf("HumanDisplay shows the notice %d times, want 1: %q", got, result.HumanDisplay().Summary)
}
}
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// TestOptionsModelSwitcherFieldExists asserts the escalation hook is an
+2 -2
View File
@@ -2,8 +2,8 @@
package agent
import (
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func equalStringSlices(left []string, right []string) bool {
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"strings"
"time"
"github.com/Twigpine/zero/internal/lsp"
"github.com/Gitlawb/zero/internal/lsp"
)
// fileDiagnosticsTimeout bounds one inline post-edit diagnostics check so a
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type customApplyPatchTool struct {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"strconv"
"strings"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// Guardrail thresholds for the agent loop. These keep a runaway model from
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// emptyTurn is a stream that produces no visible text and no tool calls.
+4 -94
View File
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/tools"
)
func TestAppendHookFeedbackFormatsOutput(t *testing.T) {
@@ -84,102 +84,12 @@ func TestBlockedByHookResultFallsBackWhenReasonEmpty(t *testing.T) {
}
}
// THE BLOCKING HOOK'S OWN NOTICE IS A NOTICE, NOT PART OF THE VETO TEXT.
//
// It used to arrive folded into Reason, so it reached the model inside the error
// message and never reached EnforcementNotices, which is what every surface
// renders as the disclosure and what the session record keeps apart from the
// body.
func TestBlockedByHookResultCarriesTheBlockersNoticeOnTheTypedSlice(t *testing.T) {
const notice = "denyRead is configured, so the write jail is not confining writes"
out := blockedByHookResult(
ToolCall{ID: "c4", Name: "bash"},
hooks.DispatchOutcome{Blocked: true, BlockedBy: "policy", Reason: "policy violation", Notices: []string{notice}},
)
if len(out.EnforcementNotices) != 1 || out.EnforcementNotices[0] != notice {
t.Fatalf("EnforcementNotices = %#v, want the blocking hook's notice", out.EnforcementNotices)
}
if strings.Contains(out.Output, notice) {
t.Errorf("the notice is also inside the veto message, so it is said twice:\n%s", out.Output)
}
if got := strings.Count(out.ModelOutput(), notice); got != 1 {
t.Errorf("the model sees the notice %d times, want once:\n%s", got, out.ModelOutput())
}
}
// AN EARLIER HOOK'S NOTICE SURVIVES A REASON THAT QUOTES IT.
//
// The veto path used to drop every notice whose text appeared anywhere in the
// blocking hook's reason, on the theory that the reason already carried it. A
// blocking hook that printed the same sentence, for example by relaying another
// tool's stderr, therefore removed an earlier hook's disclosure from the typed
// slice.
func TestBlockedByHookResultKeepsAnEarlierNoticeTheReasonQuotes(t *testing.T) {
const notice = "denyRead is configured, so the write jail is not confining writes"
out := blockedByHookResult(
ToolCall{ID: "c5", Name: "bash"},
hooks.DispatchOutcome{
Blocked: true,
BlockedBy: "veto",
Reason: "refusing: the last run reported \"" + notice + "\"",
Notices: []string{notice},
},
)
if len(out.EnforcementNotices) != 1 || out.EnforcementNotices[0] != notice {
t.Fatalf("the earlier hook's notice was dropped because the veto quoted it: %#v", out.EnforcementNotices)
}
}
func TestDispatchHelpersAreNoopWithoutDispatcher(t *testing.T) {
options := Options{} // Hooks is nil
if _, blocked := dispatchBeforeTool(context.Background(), options, ToolCall{Name: "bash"}, nil); blocked {
t.Fatal("a nil dispatcher must never block a tool")
}
if feedback, notices := dispatchAfterTool(context.Background(), options, ToolCall{Name: "bash"}, nil, tools.Result{}); feedback != "" || notices != nil {
t.Fatalf("a nil dispatcher must yield no feedback and no notices, got %q and %v", feedback, notices)
}
}
// A SUCCESSFUL beforeTool HOOK'S NOTICE MUST STAY TYPED.
//
// executeToolCall used to read the beforeTool outcome only when Blocked was true,
// so a hook that ran fine and produced an enforcement notice — for instance that
// it ran under the weakened DenyRead token — put that notice in the audit record
// and nowhere anybody could see it. The first fix delivered it as prose appended
// to the result output, which reached the model and no interactive surface,
// because those build their enforcement furniture from the typed slice.
//
// So the delivery is the typed field, and this pins the merge: the hook's notice
// first, the tool's own after it, each exactly once, blanks contributing nothing.
func TestBeforeToolNoticesMergeIntoTheTypedField(t *testing.T) {
const notice = "hook ran without WRITE_RESTRICTED because denyRead is configured"
const toolOwned = "the sandbox dropped the network capability for this call"
result := withAppliedHookNotices(ToolResult{Output: "ok"}, []string{notice}, nil)
if len(result.EnforcementNotices) != 1 || result.EnforcementNotices[0] != notice {
t.Fatalf("a successful beforeTool notice did not reach the typed field: %v", result.EnforcementNotices)
}
// AND NOT THE OUTPUT AS WELL, or every surface that renders the slice shows
// the disclosure twice.
if strings.Contains(result.Output, notice) {
t.Errorf("the notice was written into the output as well as the typed field: %q", result.Output)
}
// Both arrive, hook first, when the tool carries its own.
result = withAppliedHookNotices(ToolResult{EnforcementNotices: []string{toolOwned}}, []string{notice}, nil)
if len(result.EnforcementNotices) != 2 || result.EnforcementNotices[0] != notice || result.EnforcementNotices[1] != toolOwned {
t.Fatalf("the hook and tool notices did not merge in order: %v", result.EnforcementNotices)
}
// The same disclosure from both sides is carried once.
result = withAppliedHookNotices(ToolResult{EnforcementNotices: []string{notice}}, []string{notice}, nil)
if len(result.EnforcementNotices) != 1 {
t.Errorf("one disclosure reported by both the hook and the tool was carried %d times: %v", len(result.EnforcementNotices), result.EnforcementNotices)
}
// Blank notices contribute nothing, so a run with no hook output stays silent.
result = withAppliedHookNotices(ToolResult{}, []string{"", " "}, nil)
if len(result.EnforcementNotices) != 0 {
t.Errorf("blank hook notices produced %v, want nothing", result.EnforcementNotices)
if feedback := dispatchAfterTool(context.Background(), options, ToolCall{Name: "bash"}, nil, tools.Result{}); feedback != "" {
t.Fatalf("a nil dispatcher must yield no feedback, got %q", feedback)
}
}
+45 -200
View File
@@ -11,15 +11,15 @@ import (
"strings"
"sync"
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/modelregistry"
"github.com/Twigpine/zero/internal/redaction"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/streamjson"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/streamjson"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
const maxTurnsAnswer = "Agent reached maximum number of turns without a final answer."
@@ -1467,31 +1467,10 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
}
// beforeTool hooks may veto the call before it runs (a non-zero exit blocks).
//
// A SUCCESSFUL beforeTool HOOK STILL HAS SOMETHING TO SAY, BUT ONLY ITS NOTICE.
//
// Reading the outcome only when Blocked left the enforcement disclosure in the
// audit record and nowhere the model or the operator could see it: a hook could
// run under the weakened DenyRead token and say so to nobody.
//
// Notices, NOT Messages. Messages is presentation text that hookMessage builds
// by folding the notice together with the hook's ordinary stdout, so delivering
// it would put every successful hook's routine logging, large diagnostics, and
// whatever text a hook happened to process into the next model request. That is
// a behaviour change nobody asked for and a standing input channel. main is
// silent for successful hooks and stays silent here for everything except the
// disclosure. Carried to the tool result below, the same surface afterTool
// feedback already uses.
var beforeToolNotices []string
// Filled after the tool runs, and merged with beforeTool's at the one
// finalization below so both phases land on the same contract.
var afterToolNotices []string
if toolFound {
outcome, blocked := dispatchBeforeTool(ctx, options, call, args)
if blocked {
if outcome, blocked := dispatchBeforeTool(ctx, options, call, args); blocked {
return blockedByHookResult(call, outcome), nil
}
beforeToolNotices = outcome.Notices
}
args = shellExecutionArgsForApproval(call.Name, args, decisionAction, options)
@@ -1537,10 +1516,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
})
if retryResult, directResult, retried, action, reason, prefix, abortErr := maybeRetryUnsandboxedAfterSandboxRestriction(ctx, registry, call, tool, args, result, permissionMode, options, progressCallback); retried || directResult != nil || abortErr != nil {
if directResult != nil {
// A denied, cancelled, or ungrantable retry still returns a result for a
// call whose beforeTool hook already ran. Without this the disclosure is
// produced and then dropped on the floor.
return withAppliedHookNotices(*directResult, beforeToolNotices, nil), abortErr
return *directResult, abortErr
}
result = retryResult
permissionGranted = true
@@ -1566,15 +1542,7 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
// afterTool hooks run once the tool has executed; their output (e.g. a
// formatter or vet result) is surfaced back to the model on the result.
if toolFound {
// NEITHER PHASE'S NOTICES ARE FOLDED IN HERE. They are typed enforcement
// data on both sides now, and appending them as hook prose was what kept
// beforeTool's out of every surface that renders the typed slice, and what
// made afterTool's arrive twice once that slice was composed. Only the
// hook's own output, which is what an afterTool validator asked to say,
// still travels this way.
feedback, notices := dispatchAfterTool(ctx, options, call, args, result)
afterToolNotices = notices
if strings.TrimSpace(feedback) != "" {
if feedback := dispatchAfterTool(ctx, options, call, args, result); feedback != "" {
var didRedact bool
result.Output, didRedact = appendHookFeedback(result.Output, feedback)
if didRedact {
@@ -1601,32 +1569,28 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
// Secret scrubbing happens at the registry boundary (the single point both
// the agent loop and the MCP server pass through), so result.Output is
// already redacted here and result.Redacted reflects whether it changed.
//
// Wrapped in the same finalization every other return from a hooked call
// uses, so the normal path cannot drift away from the veto and retry paths.
return withAppliedHookNotices(ToolResult{
Risk: executedRisk,
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.BaseModelOutput(),
Truncated: result.Truncated,
Meta: result.Meta,
EnforcementNotices: append([]string(nil), result.EnforcementNotices...),
Images: result.Images,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
FileDiffs: result.FileDiffs,
ChangeSummaries: result.ChangeSummaries,
Display: result.BaseDisplay(),
Outcome: result.Outcome,
LoadedTools: loadedToolsFromResult(result.Meta),
return ToolResult{
Risk: executedRisk,
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.ModelOutput(),
Truncated: result.Truncated,
Meta: result.Meta,
Images: result.Images,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
FileDiffs: result.FileDiffs,
ChangeSummaries: result.ChangeSummaries,
Display: result.HumanDisplay(),
Outcome: result.Outcome,
LoadedTools: loadedToolsFromResult(result.Meta),
// A tool may signal a mid-run model escalation by carrying the target id
// in Meta["escalate_to_model"]. Lift it into the typed loop-level field;
// the Run turn loop performs the actual provider switch. Empty for every
// ordinary tool result.
RequestedModel: result.Meta["escalate_to_model"],
}, beforeToolNotices, afterToolNotices), nil
}, nil
}
const sandboxNamespaceLimitedReason = "sandbox output is limited to the sandbox PID namespace; host/global state requires approval"
@@ -2010,9 +1974,9 @@ func dispatchBeforeTool(ctx context.Context, options Options, call ToolCall, arg
// dispatchAfterTool runs configured afterTool hooks once a tool has executed and
// returns any advisory output (e.g. a formatter or vet result) to surface back
// to the model. afterTool hooks never block. A nil dispatcher is a no-op.
func dispatchAfterTool(ctx context.Context, options Options, call ToolCall, args map[string]any, result tools.Result) (string, []string) {
func dispatchAfterTool(ctx context.Context, options Options, call ToolCall, args map[string]any, result tools.Result) string {
if options.Hooks == nil || hooksSuppressed(options) {
return "", nil
return ""
}
outcome := options.Hooks.Dispatch(ctx, hooks.DispatchInput{
Event: hooks.EventAfterTool,
@@ -2028,13 +1992,7 @@ func dispatchAfterTool(ctx context.Context, options Options, call ToolCall, args
"changedFiles": result.ChangedFiles,
},
})
// Both halves, because they have different destinations. The hook's own output
// is prose that belongs in the result body where an afterTool validator's
// findings have always gone; its enforcement notices are typed data that the
// caller merges into the result the same way beforeTool's are. Returning only
// the joined messages was what left afterTool on the old channel while
// beforeTool moved, and a notice reported by both then appeared twice.
return strings.TrimSpace(strings.Join(outcome.Messages, "\n")), outcome.Notices
return strings.TrimSpace(strings.Join(outcome.Messages, "\n"))
}
// dispatchSessionStart runs configured sessionStart hooks once before the first
@@ -2098,7 +2056,7 @@ func blockedByHookResult(call ToolCall, outcome hooks.DispatchOutcome) ToolResul
reason = "blocked by a beforeTool hook"
}
message := fmt.Sprintf("Error: %q was blocked by hook %q: %s", call.Name, outcome.BlockedBy, reason)
result := ToolResult{
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
@@ -2106,124 +2064,12 @@ func blockedByHookResult(call ToolCall, outcome hooks.DispatchOutcome) ToolResul
Redacted: redacted,
DenialReason: DenialHookBlocked,
}
// Dispatch runs hooks in order and stops at the first veto, so an earlier hook
// may already have run under a weakened token before this one said no. Its
// notice describes something that happened and has to survive the veto, and so
// does the blocking hook's own.
//
// ALL OF THEM, FROM THE TYPED SLICE. Reason carries the hook's words only, so
// nothing here is said twice and nothing has to be filtered out. The filter
// this replaced matched notices against the reason text, and dropped an
// earlier hook's disclosure whenever the blocking hook's output quoted it.
return withAppliedHookNotices(result, outcome.Notices, nil)
}
// withAppliedHookNotices is the single place a hook enforcement notice
// reaches a tool result on a path that does NOT run afterTool.
//
// The normal tail joins the notices with the afterTool feedback and delivers
// both at once. Two other exits return a result for a call whose hook already
// ran: a later hook's veto, and a denied, cancelled, or ungrantable unsandboxed
// retry. Routing all three through one function is what keeps "the hook ran
// under this token" from depending on which exit the call happened to take.
//
// NO REBUDGET HERE, AND THAT IS LOAD-BEARING ON WHAT MAY PASS THROUGH. The
// normal tail appends and then calls Registry.RebudgetAfterHook, because what it
// appends is afterTool feedback: hook stdout, which a hook can make arbitrarily
// large. These notices cannot be: they are CommandPlan.Notes, which only this
// package's own fixed sentences may fill (none does today; #1006 refuses the
// denyRead trade the last one described), and nothing hook-authored reaches
// this slice: DispatchOutcome.Messages is where
// hook output lives, and the capture site deliberately does not read it.
//
// So if anything ever widens what is delivered here to include text a hook or a
// tool can size, this needs the rebudget step as well, which means converting
// through tools.Result the way the tail does rather than editing Output in
// place. Do not widen it without that.
// withAppliedHookNotices carries a hook's disclosures onto the result as TYPED
// enforcement notices, and is the single finalization point every return path
// from a hooked call goes through.
//
// BOTH PHASES, because half a symmetry is its own defect. beforeTool was moved
// onto the typed slice while afterTool was left folding its notices into the
// prose feedback, and since the two carry the identical fixed string, a
// disclosure reported by both then reached the model twice: once from the typed
// prepend every surface composes, once inside the hook feedback in the body. A
// bash or exec card showed it in the furniture and again in the body.
//
// It used to fold them into result.Output as prose. That reached the provider,
// which reads the output, and reached nothing else. Every interactive surface
// builds its enforcement furniture from the typed slice, and for an edit or a
// write the card shows Display.Preview instead of Output, so the disclosure was
// absent from the live card in both its collapsed and expanded states and from
// the session payload that restores them. A notice about a weakened token was
// therefore shown to the model and hidden from the operator, on exactly the
// results where something was written.
//
// Typed here, the canonical accessors compose it per surface, the same way a
// tool's own notices already work. Nothing is written into Output as well:
// decoration has one owner per surface or the disclosure appears twice.
// Hook notices lead, in run order, with the tool's own after them: dedupe makes
// the ordering moot for the duplicate case that motivated this, and for distinct
// disclosures it keeps the shape the tool-owned notices already had.
func withAppliedHookNotices(result ToolResult, before []string, after []string) ToolResult {
hookNotices := before
if len(after) > 0 {
hookNotices = append(append([]string(nil), before...), after...)
}
merged, didRedact := mergeEnforcementNotices(hookNotices, result.EnforcementNotices)
result.EnforcementNotices = merged
if didRedact {
result.Redacted = true
}
return result
}
// mergeEnforcementNotices puts the hook disclosures ahead of the
// tool's own and drops exact repeats, so a surface rendering the slice shows
// each disclosure exactly once.
//
// Hook notices are third-party text arriving on an intercepted path that bypasses
// the registry's redaction boundary, so they are scrubbed here the way
// appendHookFeedback scrubbed them while they travelled as prose. The bool
// reports whether scrubbing changed anything, so Redacted keeps matching the
// registry's contract.
func mergeEnforcementNotices(before []string, own []string) ([]string, bool) {
if len(before) == 0 && len(own) == 0 {
return nil, false
}
merged := make([]string, 0, len(before)+len(own))
seen := make(map[string]struct{}, len(before)+len(own))
redacted := false
for index, notice := range append(append([]string(nil), before...), own...) {
if index < len(before) {
scrubbed := redaction.RedactString(notice, redaction.Options{})
redacted = redacted || scrubbed != notice
notice = scrubbed
}
if strings.TrimSpace(notice) == "" {
continue
}
if _, already := seen[notice]; already {
continue
}
seen[notice] = struct{}{}
merged = append(merged, notice)
}
if len(merged) == 0 {
return nil, redacted
}
return merged, redacted
}
// appendHookFeedback appends afterTool hook output to a tool result's output,
// scrubbed for secrets like every other string crossing the tool boundary. The
// returned bool reports whether scrubbing changed the feedback, so the caller can
// set ToolResult.Redacted to match the registry's redaction contract.
// The joiner that folded beforeTool notices in with the afterTool feedback is
// gone. Sending a typed enforcement notice out as hook prose was the defect, not
// the delivery: see withAppliedHookNotices. afterTool feedback still arrives here
// on its own, which is all this path was ever meant to carry.
func appendHookFeedback(output string, feedback string) (string, bool) {
scrubbed := redaction.RedactString(feedback, redaction.Options{})
redacted := scrubbed != feedback
@@ -2358,19 +2204,18 @@ func askUserFallbackResult(ctx context.Context, registry *tools.Registry, call T
Cwd: options.Cwd,
})
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.BaseModelOutput(),
Truncated: result.Truncated,
Meta: result.Meta,
EnforcementNotices: append([]string(nil), result.EnforcementNotices...),
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
FileDiffs: result.FileDiffs,
ChangeSummaries: result.ChangeSummaries,
Display: result.BaseDisplay(),
Outcome: result.Outcome,
ToolCallID: call.ID,
Name: call.Name,
Status: result.Status,
Output: result.ModelOutput(),
Truncated: result.Truncated,
Meta: result.Meta,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
FileDiffs: result.FileDiffs,
ChangeSummaries: result.ChangeSummaries,
Display: result.HumanDisplay(),
Outcome: result.Outcome,
}
}
return ToolResult{
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"context"
"testing"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// imageEchoProvider records the messages of the first request it receives, then
+7 -7
View File
@@ -13,13 +13,13 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/execution"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/specmode"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/specmode"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type mockProvider struct {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/tools"
)
type safetyTool struct {
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"testing"
"time"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// midStreamAbortProvider connects successfully but emits a transport-abort
@@ -8,10 +8,10 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type propagationOutputTool struct {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context"
"sync"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/tools"
)
// Parallel read-ahead for tool batches. When a turn requests several
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"testing"
"time"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// probeTool records execution overlap and ordering so tests can assert what
@@ -3,7 +3,7 @@ package agent
import (
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/tools"
)
func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"reflect"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// countingSchemaTool records how many times its Parameters() schema is read, so a
@@ -4,8 +4,8 @@ import (
"context"
"testing"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
// promptShellTool is a minimal shell-style tool whose Safety requires a prompt,
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sandbox"
)
func TestPermissionScope(t *testing.T) {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func planUpdateTurn(status string) zeroruntime.CollectedStream {
@@ -4,8 +4,8 @@ import (
"path/filepath"
"testing"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
// escalatingEngine allows unsandboxed escalation: no denied reads, so
+1 -1
View File
@@ -1,7 +1,7 @@
package agent
import (
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sandbox"
)
// profileController observes per-turn run signals and decides at most one
+4 -4
View File
@@ -5,10 +5,10 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/sandbox"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestProfileControllerNilPolicyIsNoOp(t *testing.T) {
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/tools"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// These ceilings are a deliberate ratchet on Zero's fixed per-turn overhead — the
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"sort"
"strings"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// promptSubstrings are the cacheable components of the prompt a run sends to a
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// TestComputePrefixFingerprintStableAcrossCalls asserts the headline property
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"fmt"
"strings"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// Stale tool-output pruning reclaims context at ZERO token/latency cost before
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"strings"
"testing"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// bigToolResult builds a tool-result message with approximately tokens worth of
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"strings"
"time"
"github.com/Twigpine/zero/internal/errhint"
"github.com/Twigpine/zero/internal/trace"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/errhint"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// Mid-stream reconnect: a long autonomous task (a big refactor, a swarm member,
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"testing"
"time"
"github.com/Twigpine/zero/internal/zeroruntime"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// flakyProvider fails the first failBefore connect attempts with failErr, then
Loaded 100 of 781 files, more files were not shown because too many files have changed in this diff. Show more