Compare commits

..
Author SHA1 Message Date
Kevin Codex b549f5928f refactor(config): drop the unreachable SetNotify wrapper
SetNotify was kept in #1001 as a thin replace-form wrapper over
UpdateNotify for existing tests, but every product surface (the CLI
`zero config notify` and the TUI /notify picker) calls UpdateNotify's
merge form directly, so the advisory deadcode job reports it as
unreachable. Move the replace helper into writer_test.go as the
unexported setNotify, keep every test it served unchanged in behaviour,
and point the two CLI doc comments at UpdateNotify.

`make deadcode` no longer lists internal/config; the other advisory
findings in that job are pre-existing and untouched.
2026-10-14 15:58:35 +08:00
80 changed files with 442 additions and 5772 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.
+10 -29
View File
@@ -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,30 +130,18 @@ 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
@@ -161,7 +149,7 @@ irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | i
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
```
@@ -216,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=...
```
@@ -245,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:
@@ -545,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
+9 -25
View File
@@ -105,33 +105,24 @@ 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
```
### 从源码构建
@@ -139,7 +130,7 @@ irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | i
源码构建需要 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
```
@@ -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) | 维护者发布的版本和项目动态 |
## 贡献
+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;
+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
+16 -51
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
@@ -201,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
```
@@ -245,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
@@ -334,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`
+7 -57
View File
@@ -23,7 +23,6 @@ import (
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/usage"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -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 {
@@ -568,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)
@@ -702,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:
@@ -740,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)
@@ -784,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 {
@@ -937,15 +897,6 @@ func messageEvent(role, content string) sessions.AppendEventInput {
}
}
// 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),
}
}
func toolCallEvent(call agent.ToolCall) sessions.AppendEventInput {
return sessions.AppendEventInput{
Type: sessions.EventToolCall,
@@ -1259,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
}
-213
View File
@@ -1,213 +0,0 @@
package acp
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/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)
}
}
-309
View File
@@ -1,309 +0,0 @@
package acp
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/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)
}
}
}
+1 -35
View File
@@ -1,7 +1,6 @@
package cli
import (
"errors"
"fmt"
"io"
"sort"
@@ -145,7 +144,7 @@ func runProviders(args []string, stdout io.Writer, stderr io.Writer, deps appDep
return exitSuccess
}
resolved, exitCode := resolveProviderManagementConfig(stderr, deps)
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
if exitCode != exitSuccess {
return exitCode
}
@@ -218,45 +217,12 @@ func runModels(args []string, stdout io.Writer, stderr io.Writer) int {
}
func resolveCommandCenterConfig(stderr io.Writer, deps appDeps) (config.ResolvedConfig, int) {
return resolveCommandCenterConfigFor(stderr, deps, false)
}
// resolveProviderManagementConfig resolves for the `providers` subcommands that
// only need the provider LIST (`list`, `current`, `use`, `remove`, `rename`).
// Those keep working when nothing is selected (issue #1130). It is deliberately
// NOT shared with the commands that REPORT the configuration — `zero config`
// above all: per the resolver's headless contract, `zero config` / `zero exec`
// still fail with the actionable resolve error, and only the interactive TUI
// recovers from a missing active provider.
func resolveProviderManagementConfig(stderr io.Writer, deps appDeps) (config.ResolvedConfig, int) {
return resolveCommandCenterConfigFor(stderr, deps, true)
}
func resolveCommandCenterConfigFor(stderr io.Writer, deps appDeps, allowMissingActiveProvider bool) (config.ResolvedConfig, int) {
workspaceRoot, err := resolveWorkspaceRoot("", deps)
if err != nil {
return config.ResolvedConfig{}, writeExecUsageError(stderr, err.Error())
}
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
if err != nil {
// A missing active provider must not stop the `providers` subcommands
// that answer for profiles coming from the environment (OPENAI_API_KEY,
// GEMINI_API_KEY, ...), where an env-derived profile is never marked
// active in config.json. The resolver elects an active provider by
// itself only when there is exactly one, so two env providers (or one
// key plus one base URL) made every subcommand exit with `no active
// provider configured: active provider "" not found` (issue #1130). It
// still returns the successfully normalized list alongside the error,
// so hand that partial config back WITHOUT electing an active provider:
// these commands report what is configured, not what a run would use.
// Adopting firstUsableProvider here made `providers current`/`list`
// present a provider that `zero exec` would still refuse to run with,
// and made a base-URL-only profile (no API key) fail `providers list`
// because the election demands a credential. Electing a provider to RUN
// with stays in the interactive TUI's recovery path (app.go).
if allowMissingActiveProvider && errors.Is(err, config.ErrNoActiveProvider) {
return resolved, exitSuccess
}
return config.ResolvedConfig{}, writeAppError(stderr, err.Error(), exitProvider)
}
return resolved, exitSuccess
-34
View File
@@ -880,40 +880,6 @@ func providerSetupDeps(configPath string) appDeps {
}
}
// providerEnvSetupDeps is providerSetupDeps plus a REAL resolver pinned to a
// temporary user config and a fixed environment. providerSetupDeps only
// redirects WRITES, so without this the `providers` subcommands resolve against
// the developer's own config.json and ambient provider variables: a test that
// exports OPENAI_API_KEY passes on a machine whose real config has an active
// provider and fails on one that also has GEMINI_API_KEY exported (issue
// #1130). Passing Env makes resolution fully hermetic — config.envValue prefers
// the map over os.Getenv whenever it is non-nil.
func providerEnvSetupDeps(t *testing.T, configPath string, env map[string]string) appDeps {
t.Helper()
cwd := t.TempDir()
deps := providerSetupDeps(configPath)
deps.getwd = func() (string, error) { return cwd, nil }
deps.resolveConfig = func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.Resolve(config.ResolveOptions{
UserConfigPath: configPath,
Env: env,
Overrides: overrides,
})
}
// summarizeConfig reads OAuth LOGIN state through the default store, which
// resolves the developer's own ZERO_OAUTH_TOKENS_PATH (or XDG keyring root)
// — a machine with a stored ChatGPT login would make `providers list`
// assertions flip. Pin the file and the backend per the coding guideline
// that no test may read the developer's real config, cache or state dirs:
// forcing "file" also prevents an ambient ZERO_OAUTH_STORAGE=keyring from
// pulling the OS keychain into these tests.
tokensPath := filepath.Join(t.TempDir(), "oauth-tokens.json")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokensPath)
t.Setenv("ZERO_OAUTH_STORAGE", "file")
return deps
}
func readFileConfig(t *testing.T, path string) config.FileConfig {
t.Helper()
-7
View File
@@ -49,15 +49,8 @@ func TestLocalAdoptionSavesAndReloadsEligibleModel(t *testing.T) {
}
}))
defer server.Close()
// A test-sized probe deadline. DetectLocalRuntimes defaults to 400ms and
// treats a timeout as "runtime not running", so under -race on a busy runner
// one slow loopback round trip can silently drop the candidate. Timeout
// enforcement is covered separately in provideronboarding:
// TestDetectLocalRuntimesAppliesDefaultTimeout pins it with an explicit
// 20ms deadline.
detected := provideronboarding.DetectLocalRuntimes(context.Background(), provideronboarding.LocalDetectOptions{
HTTPClient: server.Client(),
Timeout: 5 * time.Second,
Candidates: []provideronboarding.LocalRuntime{{CatalogID: "atomic-chat-local", Name: "Atomic Chat Local", DefaultModel: "local-model", BaseURL: server.URL + "/v1"}},
})
if len(detected) != 1 {
@@ -21,7 +21,6 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
{id: "longcat", name: "LongCat", transport: "openai-compatible", defaultModel: "LongCat-2.0"},
{id: "fireworks", name: "Fireworks AI", transport: "openai-compatible", defaultModel: "accounts/fireworks/models/kimi-k2p7-code"},
{id: "gmicloud", name: "GMI Cloud", transport: "openai-compatible", defaultModel: "Qwen/Qwen3.8-Max"},
{id: "concentrate", name: "Concentrate", transport: "openai-compatible", defaultModel: "deepseek-v4-flash-0731"},
{id: "ollama-cloud", name: "Ollama Cloud", transport: "openai-compatible", defaultModel: "qwen3-coder:480b"},
{id: "ollama", name: "Ollama Local", transport: "openai-compatible", defaultModel: "llama3.1"},
}
+4 -4
View File
@@ -542,9 +542,9 @@ func providerResolvedByName(providers []config.ProviderProfile, name string) boo
// reports the situation plainly instead of that confusing error (issue
// #707).
func reportUnpersistedProviderUse(stdout, stderr io.Writer, deps appDeps, options providerUseOptions, configPath string) (int, bool) {
resolved, exitCode := resolveProviderManagementConfig(stderr, deps)
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
if exitCode != exitSuccess {
// resolveProviderManagementConfig already wrote its own error to stderr;
// resolveCommandCenterConfig already wrote its own error to stderr;
// stop here instead of letting the caller try SetActiveProvider too.
return exitCode, true
}
@@ -578,7 +578,7 @@ func reportUnpersistedProviderUse(stdout, stderr io.Writer, deps appDeps, option
// name isn't resolvable at all, it returns handled=false so the caller falls
// through to RemoveProvider's real "not found" error.
func reportUnpersistedProviderRemove(stdout, stderr io.Writer, deps appDeps, name string, jsonOutput bool, configPath string) (int, bool) {
resolved, exitCode := resolveProviderManagementConfig(stderr, deps)
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
if exitCode != exitSuccess {
return exitCode, true
}
@@ -609,7 +609,7 @@ func reportUnpersistedProviderRemove(stdout, stderr io.Writer, deps appDeps, nam
}
func reportUnpersistedProviderRename(stdout, stderr io.Writer, deps appDeps, name string, jsonOutput bool, configPath string) (int, bool) {
resolved, exitCode := resolveProviderManagementConfig(stderr, deps)
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
if exitCode != exitSuccess {
return exitCode, true
}
+8 -227
View File
@@ -189,11 +189,12 @@ func TestRunProvidersUseSurfacesMalformedConfig(t *testing.T) {
}
func TestRunProvidersUseEnvDerivedJSONIncludesConfigPath(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "sk-env")
var stdout, stderr bytes.Buffer
configPath := filepath.Join(t.TempDir(), "config.json")
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
exitCode := runWithDeps([]string{"providers", "use", "openai", "--json"}, &stdout, &stderr, providerEnvSetupDeps(t, configPath, map[string]string{"OPENAI_API_KEY": "sk-env"}))
exitCode := runWithDeps([]string{"providers", "use", "openai", "--json"}, &stdout, &stderr, providerSetupDeps(configPath))
if exitCode != exitSuccess {
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
@@ -211,11 +212,12 @@ func TestRunProvidersUseEnvDerivedJSONIncludesConfigPath(t *testing.T) {
}
func TestRunProvidersRemoveEnvDerivedJSONKeepsSchema(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "sk-env")
var stdout, stderr bytes.Buffer
configPath := filepath.Join(t.TempDir(), "config.json")
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
exitCode := runWithDeps([]string{"providers", "remove", "openai", "--json"}, &stdout, &stderr, providerEnvSetupDeps(t, configPath, map[string]string{"OPENAI_API_KEY": "sk-env"}))
exitCode := runWithDeps([]string{"providers", "remove", "openai", "--json"}, &stdout, &stderr, providerSetupDeps(configPath))
if exitCode != exitSuccess {
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
@@ -235,11 +237,12 @@ func TestRunProvidersRemoveEnvDerivedJSONKeepsSchema(t *testing.T) {
}
func TestRunProvidersRenameEnvDerivedExplainsNoSavedProfile(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "sk-env")
var stdout, stderr bytes.Buffer
configPath := filepath.Join(t.TempDir(), "config.json")
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
exitCode := runWithDeps([]string{"providers", "rename", "openai", "renamed"}, &stdout, &stderr, providerEnvSetupDeps(t, configPath, map[string]string{"OPENAI_API_KEY": "sk-env"}))
exitCode := runWithDeps([]string{"providers", "rename", "openai", "renamed"}, &stdout, &stderr, providerSetupDeps(configPath))
if exitCode != exitSuccess {
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
@@ -250,11 +253,12 @@ func TestRunProvidersRenameEnvDerivedExplainsNoSavedProfile(t *testing.T) {
}
func TestRunProvidersRenameEnvDerivedJSONKeepsSchema(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "sk-env")
var stdout, stderr bytes.Buffer
configPath := filepath.Join(t.TempDir(), "config.json")
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
exitCode := runWithDeps([]string{"providers", "rename", "openai", "renamed", "--json"}, &stdout, &stderr, providerEnvSetupDeps(t, configPath, map[string]string{"OPENAI_API_KEY": "sk-env"}))
exitCode := runWithDeps([]string{"providers", "rename", "openai", "renamed", "--json"}, &stdout, &stderr, providerSetupDeps(configPath))
if exitCode != exitSuccess {
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
@@ -272,229 +276,6 @@ func TestRunProvidersRenameEnvDerivedJSONKeepsSchema(t *testing.T) {
}
}
// Two env-derived providers and no saved active provider is the case issue
// #1130 reports: the resolver only elects an active provider by itself when
// there is exactly one, so `providers list/use/remove/rename` used to exit 3
// with `active provider "" not found` before doing anything.
func TestRunProvidersSubcommandsWithMultipleEnvDerivedProviders(t *testing.T) {
env := map[string]string{
"OPENAI_API_KEY": "sk-env",
"GEMINI_API_KEY": "gem-env",
}
cases := []struct {
name string
args []string
want string
}{
{name: "list", args: []string{"providers", "list"}, want: "openai"},
{name: "list json", args: []string{"providers", "list", "--json"}, want: `"name": "openai"`},
{name: "use", args: []string{"providers", "use", "openai"}, want: "is not saved in config.json"},
{name: "remove", args: []string{"providers", "remove", "openai"}, want: "nothing to remove there"},
{name: "rename", args: []string{"providers", "rename", "openai", "renamed"}, want: "no saved profile to rename"},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
var stdout, stderr bytes.Buffer
exitCode := runWithDeps(tt.args, &stdout, &stderr, providerEnvSetupDeps(t, configPath, env))
if exitCode != exitSuccess {
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
if !strings.Contains(stdout.String(), tt.want) {
t.Fatalf("stdout = %q, want it to contain %q", stdout.String(), tt.want)
}
})
}
}
func TestRunProvidersListShowsEveryEnvDerivedProvider(t *testing.T) {
env := map[string]string{
"OPENAI_API_KEY": "sk-env",
"GEMINI_API_KEY": "gem-env",
}
configPath := filepath.Join(t.TempDir(), "config.json")
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"providers", "list", "--json"}, &stdout, &stderr, providerEnvSetupDeps(t, configPath, env))
if exitCode != exitSuccess {
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
var payload struct {
Providers []struct {
Name string `json:"name"`
} `json:"providers"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
}
names := make(map[string]bool, len(payload.Providers))
for _, provider := range payload.Providers {
names[provider.Name] = true
}
for _, want := range []string{"openai", "google"} {
if !names[want] {
t.Fatalf("providers = %v, want %q listed", names, want)
}
}
}
// The provider-management commands report what is CONFIGURED, not what a run
// would use: with env-derived providers and no saved active provider they must
// not present any provider as selected. Electing firstUsableProvider in
// resolveCommandCenterConfig made `providers current` claim `openai` while
// `zero exec` in the same environment still exited 3 with `no active provider
// configured` — and it read the OAuth store on this path (maintainer review of
// this PR).
func TestRunProvidersSubcommandsElectNoActiveProvider(t *testing.T) {
env := map[string]string{
"OPENAI_API_KEY": "sk-env",
"GEMINI_API_KEY": "gem-env",
}
deps := func(t *testing.T) appDeps {
configPath := filepath.Join(t.TempDir(), "config.json")
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
return providerEnvSetupDeps(t, configPath, env)
}
t.Run("current shows nothing selected", func(t *testing.T) {
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"providers", "current"}, &stdout, &stderr, deps(t))
if exitCode != exitSuccess {
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
if !strings.Contains(stdout.String(), "(none)") {
t.Fatalf("stdout = %q, want an empty selection", stdout.String())
}
if strings.Contains(stdout.String(), "openai") {
t.Fatalf("stdout = %q, want no provider elected as current", stdout.String())
}
})
t.Run("list marks nothing active", func(t *testing.T) {
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"providers", "list", "--json"}, &stdout, &stderr, deps(t))
if exitCode != exitSuccess {
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
var payload struct {
Providers []struct {
Name string `json:"name"`
Active bool `json:"active"`
} `json:"providers"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
}
if len(payload.Providers) < 2 {
t.Fatalf("providers = %+v, want both env-derived profiles listed", payload.Providers)
}
for _, provider := range payload.Providers {
if provider.Active {
t.Fatalf("provider %q marked active, want nothing active", provider.Name)
}
}
})
t.Run("use json leaves activeProvider empty", func(t *testing.T) {
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"providers", "use", "openai", "--json"}, &stdout, &stderr, deps(t))
if exitCode != exitSuccess {
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
var payload struct {
ActiveProvider string `json:"activeProvider"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
}
if payload.ActiveProvider != "" {
t.Fatalf("activeProvider = %q, want it empty for an unpersisted selection", payload.ActiveProvider)
}
})
}
// A base-URL-only environment (OPENAI_BASE_URL / GEMINI_BASE_URL set, no API
// keys) is the other half of issue #1130: electing firstUsableProvider made
// `providers list` exit 3 because the election demands a credential. The list
// must succeed and still mark nothing active. The resolver itself keeps only
// the keyless openai-compatible profile here (a google profile without a
// credential is dropped during normalization), so `openai` is the expected
// entry — pinning that as-is keeps this test about the COMMAND, not the
// resolver.
func TestRunProvidersListWithBaseURLOnlyProviders(t *testing.T) {
env := map[string]string{
"OPENAI_BASE_URL": "https://openai.proxy.example.com/v1",
"GEMINI_BASE_URL": "https://gemini.proxy.example.com",
}
configPath := filepath.Join(t.TempDir(), "config.json")
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"providers", "list", "--json"}, &stdout, &stderr, providerEnvSetupDeps(t, configPath, env))
if exitCode != exitSuccess {
t.Fatalf("exit code = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
var payload struct {
Providers []struct {
Name string `json:"name"`
Active bool `json:"active"`
} `json:"providers"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
}
names := make(map[string]bool, len(payload.Providers))
for _, provider := range payload.Providers {
if provider.Active {
t.Fatalf("provider %q marked active, want nothing active", provider.Name)
}
names[provider.Name] = true
}
if !names["openai"] {
t.Fatalf("providers = %v, want the base-URL-only openai profile listed", names)
}
}
// `zero config` is NOT one of the provider-management commands: it reports the
// resolved configuration, so a missing active provider has to stay actionable
// (the headless contract the resolver documents for `zero config` / `zero
// exec`). Only the interactive TUI recovers from that state.
func TestRunConfigSummaryStillFailsWithNoActiveProvider(t *testing.T) {
env := map[string]string{
"OPENAI_API_KEY": "sk-env",
"GEMINI_API_KEY": "gem-env",
}
configPath := filepath.Join(t.TempDir(), "config.json")
writeProviderOnboardingConfig(t, configPath, config.FileConfig{})
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"config"}, &stdout, &stderr, providerEnvSetupDeps(t, configPath, env))
if exitCode == exitSuccess {
t.Fatalf("exit code = %d, want the actionable resolve error: %s", exitCode, stdout.String())
}
message := stderr.String()
if message == "" {
message = stdout.String()
}
if !strings.Contains(message, "no active provider configured") {
t.Fatalf("output = %q / %q, want the actionable resolve error", stdout.String(), stderr.String())
}
}
func TestRunProvidersUseRejectsUsageErrors(t *testing.T) {
cases := []struct {
name string
+2 -88
View File
@@ -485,88 +485,6 @@ func TestTUISandboxSetupCommandGatedToWindowsNativeBackend(t *testing.T) {
}
func TestRunSandboxPolicyJSONGoldenIncludesManagerBaselineFields(t *testing.T) {
// Pin before any t.TempDir so an ambient temp dir cannot reach the snapshot.
// Unix reads TMPDIR (and always considers /tmp). Windows reads TEMP and TMP.
// XDG_RUNTIME_DIR is not a temp write-root candidate.
pinSandboxPolicyGoldenTempEnv(t)
assertSandboxPolicyJSONGolden(t, "")
}
// TestRunSandboxPolicyJSONGoldenNestedTempDir stages the temp-dir shape from
// #1121 and then pins over it. On Unix that shape is a TMPDIR inside /tmp; on
// Windows it is TMP inside TEMP. NewScope drops the inner root, the policy
// JSON still lists it, and the golden normalizer leaves it behind. The pin
// replaces those variables before the snapshot, so the golden matches and the
// nested path does not survive. The directories come from t.TempDir, never
// from the package working directory, so a checkout under /tmp is not special.
func TestRunSandboxPolicyJSONGoldenNestedTempDir(t *testing.T) {
nested := stageNestedSandboxTempEnv(t)
pinSandboxPolicyGoldenTempEnv(t)
normalized := assertSandboxPolicyJSONGolden(t, "")
if strings.Contains(normalized, nested) || strings.Contains(normalized, filepath.ToSlash(nested)) {
t.Fatalf("normalized policy leaked nested temp root %q:\n%s", nested, normalized)
}
}
// stageNestedSandboxTempEnv installs the leaking temp-dir shape and returns
// the inner path. The caller pins afterward. Skip, rather than fail, when
// that shape cannot be built.
func stageNestedSandboxTempEnv(t *testing.T) string {
t.Helper()
if runtime.GOOS == "windows" {
outer := t.TempDir()
inner := filepath.Join(outer, "inner")
if err := os.MkdirAll(inner, 0o755); err != nil {
t.Skipf("create nested TEMP/TMP: %v", err)
}
t.Setenv("TEMP", outer)
t.Setenv("TMP", inner)
return inner
}
resolvedTmp, err := filepath.EvalSymlinks("/tmp")
if err != nil {
t.Skipf("resolve /tmp: %v", err)
}
info, err := os.Stat(resolvedTmp)
if err != nil || !info.IsDir() {
t.Skip("/tmp is not a directory; cannot stage a nested TMPDIR")
}
// t.TempDir is a child of os.TempDir(). A further child is the ambient
// case: TMPDIR inside the hard-coded /tmp candidate.
nested := filepath.Join(t.TempDir(), "nested")
if err := os.MkdirAll(nested, 0o755); err != nil {
t.Skipf("create nested TMPDIR: %v", err)
}
resolvedNested, err := filepath.EvalSymlinks(nested)
if err != nil {
t.Skipf("resolve nested TMPDIR: %v", err)
}
rel, err := filepath.Rel(resolvedTmp, resolvedNested)
if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
t.Skipf("temp dir %q is not inside /tmp; cannot stage the nested TMPDIR case", resolvedNested)
}
t.Setenv("TMPDIR", resolvedNested)
return resolvedNested
}
// pinSandboxPolicyGoldenTempEnv fixes the temp variables
// defaultTempWriteRootCandidatesForGOOS reads so the golden does not depend on
// the process environment. An empty TMPDIR leaves the Unix hard-coded /tmp as
// the only candidate. TEMP and TMP name one directory so neither is nested in
// the other.
func pinSandboxPolicyGoldenTempEnv(t *testing.T) {
t.Helper()
if runtime.GOOS == "windows" {
dir := t.TempDir()
t.Setenv("TEMP", dir)
t.Setenv("TMP", dir)
return
}
t.Setenv("TMPDIR", "")
}
func assertSandboxPolicyJSONGolden(t *testing.T, mustList string) string {
t.Helper()
// Point all user-config roots at an empty directory so the platform-specific
// credential deny baseline can be asserted without leaking host paths into
// the platform-neutral golden comparison.
@@ -608,8 +526,8 @@ func assertSandboxPolicyJSONGolden(t *testing.T, mustList string) string {
t.Fatalf("policy exit = %d, stderr %q", exitCode, stderr.String())
}
raw := stdout.String()
got := replacePathToken(raw, workspace, "$WORKSPACE")
got := stdout.String()
got = replacePathToken(got, workspace, "$WORKSPACE")
got = replacePathToken(got, store.FilePath(), "$GRANTS")
gotBytes := normalizeSandboxPolicyGoldenTempRoots(t, []byte(got), workspace, emptyHome)
wantBytes, err := os.ReadFile(filepath.Join("testdata", "sandbox_policy_windows_unavailable.golden.json"))
@@ -619,10 +537,6 @@ func assertSandboxPolicyJSONGolden(t *testing.T, mustList string) string {
if !jsonValuesEqual(t, wantBytes, gotBytes) {
t.Fatalf("policy JSON golden mismatch\nwant:\n%s\ngot:\n%s", string(wantBytes), string(gotBytes))
}
if mustList != "" && !strings.Contains(raw, mustList) {
t.Fatalf("policy JSON did not list pinned temp root %q\n%s", mustList, raw)
}
return string(gotBytes)
}
func normalizeSandboxPolicyGoldenTempRoots(t *testing.T, gotBytes []byte, workspace string, emptyHome string) []byte {
+1 -9
View File
@@ -7,8 +7,6 @@ import (
"strconv"
"strings"
"time"
"github.com/Gitlawb/zero/internal/gitsafe"
)
type scratchFileBaseline map[string]bool
@@ -80,13 +78,7 @@ func gitUntrackedFiles(workspaceRoot string) ([]string, bool) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// gitsafe keeps the workspace's own git config from running a program, and
// --ignore-submodules=dirty keeps git out of submodule work trees.
command, err := gitsafe.Command(ctx, workspaceRoot, "status", "--porcelain", "-z", "--untracked-files=all", "--ignore-submodules=dirty")
if err != nil {
return nil, false
}
output, err := command.Output()
output, err := exec.CommandContext(ctx, "git", "-C", workspaceRoot, "status", "--porcelain", "-z", "--untracked-files=all").Output()
if err != nil {
// Not a git repo (or git failed for some other reason) — nothing
// reliable to report against, so stay silent rather than guess.
+5 -6
View File
@@ -3,6 +3,7 @@ package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"time"
@@ -71,12 +72,10 @@ var lockConfigFileFn = lockConfigFile
func lockConfigFile(path string) (func() error, error) {
lockPath := path + ".lock"
// Every mutator locks before it writes, so this is where the config
// directory is normally created. ensureConfigDir also syncs each new entry
// into its parent before the lock file exists; writeConfigData would find
// nothing missing by then and could not run that barrier itself.
if err := ensureConfigDir(filepath.Dir(lockPath)); err != nil {
return nil, err
if dir := filepath.Dir(lockPath); dir != "." && dir != "" {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("create config directory %s: %w", dir, err)
}
}
deadline := time.Now().Add(configLockTimeout)
for {
+4 -117
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
@@ -1097,116 +1096,12 @@ func writeConfigFile(path string, cfg FileConfig) error {
return writeConfigData(path, data)
}
// Sync seams allow tests to inject persistence failures at each barrier.
var syncConfigFileFn = (*os.File).Sync
var syncConfigDirFn = syncConfigDir
// Windows cannot sync directory handles, so rename durability is best effort
// there, matching the sessions store. Elsewhere a directory the user can write
// but not read cannot be opened for syncing either; that is also best effort,
// because the write it follows has already succeeded. Every other failure to
// open, sync, or close the directory is reported: the barrier was requested and
// not reached, so the caller must not acknowledge the save.
func syncConfigDir(dir string) error {
if runtime.GOOS == "windows" {
return nil
}
d, err := os.Open(dir)
if err != nil {
if errors.Is(err, os.ErrPermission) {
return nil
}
return err
}
return errors.Join(d.Sync(), d.Close())
}
// ensureConfigDir is the ONE path that creates the config directory, shared by
// lockConfigFile and writeConfigData. Every exported mutator locks before it
// writes, and the lock file is a sibling of the config, so in practice the lock
// path is what first creates the directory; a barrier that lived only in
// writeConfigData would find nothing missing by then and never run.
//
// Each missing component is created with os.Mkdir rather than os.MkdirAll so
// the call knows exactly which entries it created, then each new entry is
// synced into its parent (deepest first) so a crash cannot lose the directory,
// and with it the config, after a successful write. A component that appeared
// between the lookup and the Mkdir belongs to another writer: it is not rolled
// back, but its parent is still synced, because this write depends on the
// entry being durable whoever created it.
//
// If a barrier fails, the directories this call created are removed again.
// Leaving them would let a retry find nothing missing, skip the barrier, and
// report a durability it never established. Only what this call created is
// removed, with os.Remove so a directory another process has since populated
// stays; a failed rollback is joined into the error, never swallowed.
func ensureConfigDir(dir string) error {
if dir == "." || dir == "" {
return nil
}
missing := missingConfigDirs(dir)
if len(missing) == 0 {
return nil
}
var created []string
for i := len(missing) - 1; i >= 0; i-- {
d := missing[i]
err := os.Mkdir(d, 0o700)
if errors.Is(err, os.ErrExist) {
continue
}
if err != nil {
return errors.Join(fmt.Errorf("create config directory %s: %w", d, err), removeConfigDirs(created))
}
created = append(created, d)
}
for _, d := range missing {
parent := filepath.Dir(d)
if err := syncConfigDirFn(parent); err != nil {
return errors.Join(fmt.Errorf("sync config directory %s: %w", parent, err), removeConfigDirs(created))
}
}
return nil
}
// removeConfigDirs rolls back directories ensureConfigDir created in this
// call, deepest first. os.Remove refuses a non-empty directory, so an entry
// another process already populated survives and its error is reported.
func removeConfigDirs(created []string) error {
var errs []error
for i := len(created) - 1; i >= 0; i-- {
if err := os.Remove(created[i]); err != nil {
errs = append(errs, fmt.Errorf("roll back config directory %s: %w", created[i], err))
}
}
return errors.Join(errs...)
}
// missingConfigDirs lists dir and each ancestor that does not exist yet,
// deepest first, so ensureConfigDir can create them shallowest first and sync
// each new entry into its parent deepest first.
func missingConfigDirs(dir string) []string {
var missing []string
for d := dir; ; {
if _, err := os.Lstat(d); !errors.Is(err, os.ErrNotExist) {
return missing
}
missing = append(missing, d)
parent := filepath.Dir(d)
if parent == d {
return missing
}
d = parent
}
}
func writeConfigData(path string, data []byte) error {
dir := filepath.Dir(path)
// Usually a no-op: the lock path has already created and synced the
// directory. It still matters for the rare unlocked caller and for a
// directory removed between the lock and the write.
if err := ensureConfigDir(dir); err != nil {
return err
if dir != "." && dir != "" {
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create config directory %s: %w", dir, err)
}
}
if len(data) == 0 || data[len(data)-1] != '\n' {
data = append(data, '\n')
@@ -1227,19 +1122,11 @@ func writeConfigData(path string, data []byte) error {
_ = tmp.Close()
return fmt.Errorf("write config %s: %w", path, err)
}
// Persist the complete contents before making the replacement visible.
if err := syncConfigFileFn(tmp); err != nil {
return fmt.Errorf("sync config %s: %w", path, errors.Join(err, tmp.Close()))
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("write config %s: %w", path, err)
}
if err := os.Rename(tmpPath, path); err != nil {
return fmt.Errorf("write config %s: %w", path, err)
}
// The replacement is already visible if this fails; do not roll it back.
if err := syncConfigDirFn(dir); err != nil {
return fmt.Errorf("sync config directory %s: %w", dir, err)
}
return nil
}
-290
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
@@ -16,295 +15,6 @@ import (
"testing"
)
func TestWriteConfigDataSync(t *testing.T) {
failure := errors.New("injected sync failure")
for _, stage := range []string{"success", "file", "directory"} {
t.Run(stage, func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
oldData, newData := "{\"old\":true}\n", "{\"new\":true}\n"
if err := os.WriteFile(path, []byte(oldData), 0o600); err != nil {
t.Fatal(err)
}
checkData := func(path, want string) {
t.Helper()
got, err := os.ReadFile(path)
if err != nil || string(got) != want {
t.Fatalf("read %s = %q, %v; want %q", path, got, err, want)
}
}
fileSync, dirSync := syncConfigFileFn, syncConfigDirFn
t.Cleanup(func() { syncConfigFileFn, syncConfigDirFn = fileSync, dirSync })
var tmp *os.File
var calls []string
syncConfigFileFn = func(f *os.File) error {
tmp = f
calls = append(calls, "file")
checkData(f.Name(), newData)
checkData(path, oldData)
if stage == "file" {
return failure
}
return fileSync(f)
}
syncConfigDirFn = func(got string) error {
calls = append(calls, "directory")
if got != dir || tmp == nil {
t.Fatalf("directory sync = %q, temp = %v", got, tmp)
}
if _, err := tmp.Seek(0, io.SeekCurrent); !errors.Is(err, os.ErrClosed) {
t.Fatalf("temp must be closed before directory sync: %v", err)
}
checkData(path, newData)
if stage == "directory" {
return failure
}
return dirSync(got)
}
err := writeConfigData(path, []byte(strings.TrimSuffix(newData, "\n")))
if stage == "success" {
if err != nil {
t.Fatal(err)
}
} else if !errors.Is(err, failure) {
t.Fatalf("error = %v, want injected sync failure", err)
}
wantCalls := []string{"file", "directory"}
wantData := newData
if stage == "file" {
wantCalls, wantData = []string{"file"}, oldData
}
if !reflect.DeepEqual(calls, wantCalls) {
t.Fatalf("sync calls = %v, want %v", calls, wantCalls)
}
checkData(path, wantData)
if _, err := tmp.Seek(0, io.SeekCurrent); !errors.Is(err, os.ErrClosed) {
t.Fatalf("temp must be closed on return: %v", err)
}
entries, err := os.ReadDir(dir)
if err != nil || len(entries) != 1 || entries[0].Name() != "config.json" {
t.Fatalf("temporary file not cleaned up: %v, %v", entries, err)
}
})
}
}
func TestWriteConfigDataSyncsCreatedDirectories(t *testing.T) {
root := t.TempDir()
outer := filepath.Join(root, "a")
dir := filepath.Join(outer, "b")
path := filepath.Join(dir, "config.json")
failure := errors.New("injected sync failure")
dirSync := syncConfigDirFn
t.Cleanup(func() { syncConfigDirFn = dirSync })
for _, fail := range []bool{false, true} {
var calls []string
syncConfigDirFn = func(got string) error {
calls = append(calls, got)
if fail && got == root {
return failure
}
return dirSync(got)
}
if err := os.RemoveAll(outer); err != nil {
t.Fatal(err)
}
err := writeConfigData(path, []byte(`{}`))
if fail {
if !errors.Is(err, failure) {
t.Fatalf("error = %v, want injected sync failure", err)
}
if want := []string{outer, root}; !reflect.DeepEqual(calls, want) {
t.Fatalf("sync calls = %v, want %v", calls, want)
}
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("config must not be written after a failed directory sync: %v", err)
}
continue
}
if err != nil {
t.Fatal(err)
}
// Each new directory's entry is synced into its parent, then the
// config's own directory after the rename.
if want := []string{outer, root, dir}; !reflect.DeepEqual(calls, want) {
t.Fatalf("sync calls = %v, want %v", calls, want)
}
}
}
func TestLockingMutatorSyncsCreatedDirectories(t *testing.T) {
root := t.TempDir()
outer := filepath.Join(root, "a")
dir := filepath.Join(outer, "b")
path := filepath.Join(dir, "config.json")
failure := errors.New("injected sync failure")
dirSync := syncConfigDirFn
t.Cleanup(func() { syncConfigDirFn = dirSync })
exists := func(p string) bool {
t.Helper()
_, err := os.Lstat(p)
if err != nil && !errors.Is(err, os.ErrNotExist) {
t.Fatal(err)
}
return err == nil
}
// Every exported mutator acquires the config lock before it writes, and
// the lock file is a sibling of the config, so the lock path is what first
// creates the config directory. The barrier for those new entries has to
// run there, before the lock file exists: by the time writeConfigData
// looks, nothing is missing any more.
for _, fail := range []bool{true, false} {
var calls []string
syncConfigDirFn = func(got string) error {
calls = append(calls, got)
if got == outer || got == root {
if exists(path+".lock") || exists(path) {
t.Fatalf("parent sync of %s ran after the lock file or config was created", got)
}
}
if fail && got == root {
return failure
}
return dirSync(got)
}
_, err := UpdateNotify(path, func(NotifyConfig) NotifyConfig {
return NotifyConfig{Mode: "off"}
})
if fail {
if !errors.Is(err, failure) {
t.Fatalf("error = %v, want injected sync failure", err)
}
if want := []string{outer, root}; !reflect.DeepEqual(calls, want) {
t.Fatalf("sync calls = %v, want %v", calls, want)
}
// The directories this attempt created are gone again, so the
// retry below re-creates them and re-runs the barrier instead of
// finding them present and reporting a durability it never had.
if exists(outer) {
t.Fatalf("%s must be rolled back after a failed parent sync", outer)
}
continue
}
if err != nil {
t.Fatal(err)
}
if want := []string{outer, root, dir}; !reflect.DeepEqual(calls, want) {
t.Fatalf("sync calls = %v, want %v", calls, want)
}
got, err := UserNotify(path)
if err != nil || got.Mode != "off" {
t.Fatalf("UserNotify = %+v, %v; want mode off", got, err)
}
}
}
func TestWriteConfigDataRollsBackOnlyCreatedDirectories(t *testing.T) {
root := t.TempDir()
existing := filepath.Join(root, "existing")
if err := os.Mkdir(existing, 0o700); err != nil {
t.Fatal(err)
}
keep := filepath.Join(existing, "keep")
if err := os.WriteFile(keep, []byte("keep\n"), 0o600); err != nil {
t.Fatal(err)
}
outer := filepath.Join(existing, "a")
dir := filepath.Join(outer, "b")
path := filepath.Join(dir, "config.json")
failure := errors.New("injected sync failure")
dirSync := syncConfigDirFn
t.Cleanup(func() { syncConfigDirFn = dirSync })
stat := func(p string) error {
_, err := os.Lstat(p)
return err
}
// The barrier for "a" fails: both directories this call created go, the
// pre-existing directory and its file stay.
syncConfigDirFn = func(got string) error {
if got == existing {
return failure
}
return dirSync(got)
}
if err := writeConfigData(path, []byte(`{}`)); !errors.Is(err, failure) {
t.Fatalf("error = %v, want injected sync failure", err)
}
for _, p := range []string{dir, outer} {
if err := stat(p); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("%s must be removed after a failed parent sync: %v", p, err)
}
}
for _, p := range []string{existing, keep} {
if err := stat(p); err != nil {
t.Fatalf("pre-existing %s must survive the rollback: %v", p, err)
}
}
// A directory another process populated in the meantime is not ours to
// remove: it stays, the failed rollback is reported with the barrier
// failure, and the write still does not acknowledge the save.
intruder := filepath.Join(dir, "other.lock")
syncConfigDirFn = func(got string) error {
if got == existing {
if err := os.WriteFile(intruder, nil, 0o600); err != nil {
t.Fatal(err)
}
return failure
}
return dirSync(got)
}
err := writeConfigData(path, []byte(`{}`))
if !errors.Is(err, failure) {
t.Fatalf("error = %v, want injected sync failure", err)
}
if err := stat(intruder); err != nil {
t.Fatalf("a populated directory must not be removed: %v", err)
}
if !strings.Contains(err.Error(), "roll back config directory") {
t.Fatalf("error must report the failed rollback: %v", err)
}
if err := stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("config must not be written after a failed directory sync: %v", err)
}
}
func TestSyncConfigDir(t *testing.T) {
dir := t.TempDir()
if err := syncConfigDir(dir); err != nil {
t.Fatal(err)
}
err := syncConfigDir(filepath.Join(dir, "missing"))
if runtime.GOOS == "windows" {
// Directory handles cannot be synced on Windows, so every call is a
// no-op there, including one for a directory that does not exist.
if err != nil {
t.Fatalf("syncConfigDir must be a no-op on windows: %v", err)
}
return
}
// A directory that is gone means the persistence barrier was never
// reached, so the caller must not acknowledge the save.
if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("missing directory: error = %v, want %v", err, os.ErrNotExist)
}
// A directory the user can write but not read cannot be opened for
// syncing; that is best effort, as in the sessions store, because the
// write it follows has already succeeded.
unreadable := filepath.Join(dir, "unreadable")
if err := os.Mkdir(unreadable, 0o300); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(unreadable, 0o700) })
if err := syncConfigDir(unreadable); err != nil {
t.Fatalf("write-only directory must be best effort: %v", err)
}
}
func TestSetActiveProviderSwitchesConfiguredProvider(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{
-218
View File
@@ -1,218 +0,0 @@
package gitsafe
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
// The packages whose git calls Zero makes on its own. The CLI runs git for the
// scratch-file warning and the TUI for its files sweep and pull request
// diffstat; each call has to come through Command.
var automaticGitPackages = []string{
filepath.Join("..", "cli"),
filepath.Join("..", "tui"),
}
// parsePackage parses the non-test Go files in dir.
func parsePackage(t *testing.T, dir string) (*token.FileSet, []*ast.File) {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read %s: %v", dir, err)
}
fset := token.NewFileSet()
var files []*ast.File
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
file, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, 0)
if err != nil {
t.Fatalf("parse %s: %v", name, err)
}
files = append(files, file)
}
if len(files) == 0 {
t.Fatalf("no Go files in %s", dir)
}
return fset, files
}
// importName is the name file uses for the package at path, or "" when the
// file does not import it.
func importName(file *ast.File, path string) string {
for _, spec := range file.Imports {
if value, err := strconv.Unquote(spec.Path.Value); err != nil || value != path {
continue
}
if spec.Name != nil {
return spec.Name.Name
}
return filepath.Base(path)
}
return ""
}
// directGitExecs lists the exec.Command and exec.CommandContext calls in files
// whose program is the literal "git".
func directGitExecs(fset *token.FileSet, files []*ast.File) []string {
var found []string
for _, file := range files {
execName := importName(file, "os/exec")
if execName == "" {
continue
}
ast.Inspect(file, func(node ast.Node) bool {
call, ok := node.(*ast.CallExpr)
if !ok {
return true
}
selector, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
if pkg, ok := selector.X.(*ast.Ident); !ok || pkg.Name != execName {
return true
}
program := 0
switch selector.Sel.Name {
case "Command":
case "CommandContext":
program = 1
default:
return true
}
if len(call.Args) <= program {
return true
}
if literal, ok := call.Args[program].(*ast.BasicLit); ok && literal.Kind == token.STRING {
if value, err := strconv.Unquote(literal.Value); err == nil && value == "git" {
found = append(found, fset.Position(call.Pos()).String())
}
}
return true
})
}
return found
}
func TestAutomaticGitCallsDoNotExecGitDirectly(t *testing.T) {
for _, dir := range automaticGitPackages {
fset, files := parsePackage(t, dir)
for _, position := range directGitExecs(fset, files) {
t.Errorf("%s runs git directly; go through gitsafe.Command so the repository's config cannot make it run a program", position)
}
}
}
// gitArguments returns the git arguments of call when it is gitsafe.Command or a
// runner called with the program "git" (the TUI's runner(ctx, cwd, "git", ...)),
// as far as they are string literals.
func gitArguments(call *ast.CallExpr, gitsafeName string) ([]string, bool) {
start := -1
if selector, ok := call.Fun.(*ast.SelectorExpr); ok && gitsafeName != "" && selector.Sel.Name == "Command" {
if pkg, ok := selector.X.(*ast.Ident); ok && pkg.Name == gitsafeName {
start = 2
}
}
if start < 0 {
for index, argument := range call.Args {
if literal, ok := argument.(*ast.BasicLit); ok && literal.Kind == token.STRING && literal.Value == `"git"` {
start = index + 1
break
}
}
}
if start < 0 || start > len(call.Args) {
return nil, false
}
var arguments []string
for _, argument := range call.Args[start:] {
if literal, ok := argument.(*ast.BasicLit); ok && literal.Kind == token.STRING {
if value, err := strconv.Unquote(literal.Value); err == nil {
arguments = append(arguments, value)
continue
}
}
arguments = append(arguments, "")
}
return arguments, true
}
// A submodule has its own config, which Command does not check, so the
// automatic status and diff calls stay out of submodule work trees.
func TestAutomaticStatusAndDiffSkipSubmoduleWorkTrees(t *testing.T) {
checked := 0
for _, dir := range automaticGitPackages {
fset, files := parsePackage(t, dir)
for _, file := range files {
gitsafeName := importName(file, "github.com/Gitlawb/zero/internal/gitsafe")
ast.Inspect(file, func(node ast.Node) bool {
call, ok := node.(*ast.CallExpr)
if !ok {
return true
}
arguments, ok := gitArguments(call, gitsafeName)
if !ok || len(arguments) == 0 || (arguments[0] != "status" && arguments[0] != "diff") {
return true
}
checked++
for _, argument := range arguments {
if argument == "--ignore-submodules=dirty" {
return true
}
}
t.Errorf("%s runs git %s without --ignore-submodules=dirty", fset.Position(call.Pos()), arguments[0])
return true
})
}
}
// The scratch-file status, the files sweep's status and diff, and the pull
// request diffstat.
if checked != 4 {
t.Fatalf("checked %d automatic status or diff calls, want 4; update this test when they change", checked)
}
}
// The TUI runs every git command through one runner, whose program name is a
// parameter, so the check above cannot see it. The runner has to hand git to
// Command itself.
func TestTUICommandRunnerSendsGitThroughCommand(t *testing.T) {
_, files := parsePackage(t, filepath.Join("..", "tui"))
var runner *ast.FuncDecl
var runnerFile *ast.File
for _, file := range files {
for _, declaration := range file.Decls {
if function, ok := declaration.(*ast.FuncDecl); ok && function.Recv == nil && function.Name.Name == "defaultPRCommandRunner" {
runner, runnerFile = function, file
}
}
}
if runner == nil {
t.Fatal("defaultPRCommandRunner not found in internal/tui")
}
gitsafeName := importName(runnerFile, "github.com/Gitlawb/zero/internal/gitsafe")
calls := false
if gitsafeName != "" {
ast.Inspect(runner.Body, func(node ast.Node) bool {
if call, ok := node.(*ast.CallExpr); ok {
if selector, ok := call.Fun.(*ast.SelectorExpr); ok && selector.Sel.Name == "Command" {
if pkg, ok := selector.X.(*ast.Ident); ok && pkg.Name == gitsafeName {
calls = true
}
}
}
return !calls
})
}
if !calls {
t.Fatal("defaultPRCommandRunner does not call gitsafe.Command, so the TUI's git calls skip it")
}
}
-192
View File
@@ -1,192 +0,0 @@
// Package gitsafe builds the read-only git commands Zero runs on its own, as
// opposed to a git command the user or the model asked for: the status and diff
// calls behind the TUI's changed-files sweep, its pull request diffstat and the
// scratch-file warning, which run without anyone asking for git.
//
// A REPOSITORY CAN MAKE THOSE COMMANDS RUN A PROGRAM. git status and git diff read
// the repository's own .git/config, and several keys there name programs git then
// runs: core.fsmonitor on every status, a clean filter or diff driver assigned
// through .gitattributes whenever a file's content has to be read, and hooks such
// as post-index-change when the index is rewritten. A repository unpacked from an
// archive or handed over as a directory carries that config, and Zero would run it
// on the first turn, although it refuses the same workspace's project hooks and
// plugins until the user trusts it.
//
// The single-key cases are switched off on the command line. Filter and diff
// driver commands are named per driver, so they cannot be switched off without
// knowing the names; a repository whose own config defines any is skipped instead.
// Commands defined in the user's own global config, such as git-lfs, are the
// user's choice and are not affected.
package gitsafe
import (
"bytes"
"context"
"errors"
"os"
"os/exec"
"runtime"
"strings"
)
// ErrRepositoryDefinesCommands is returned for a repository whose own config
// defines a filter or diff driver command. The caller goes without the git
// information rather than run it.
var ErrRepositoryDefinesCommands = errors.New("the repository's own git config defines a filter or diff command, so Zero does not run git there on its own")
// commandKeys matches the config keys whose value is a program git can run while
// computing status or a diff, and which are named per driver. Git prints section
// and variable names in lower case, so the patterns do not need to fold case.
const commandKeys = `filter\..+\.(clean|smudge|process)|diff\..+\.(command|textconv)|diff\.external`
// worktreeConfigKey makes git also read per-worktree config (config.worktree),
// which a --local read leaves out. Without it git ignores that file.
const worktreeConfigKey = "extensions.worktreeconfig"
const (
localConfigPattern = `^(` + commandKeys + `|extensions\.worktreeconfig)$`
worktreeConfigPattern = `^(` + commandKeys + `)$`
)
// Command returns git with args, run in dir, with the programs a repository can
// configure turned off. It fails with ErrRepositoryDefinesCommands when the
// repository's own config defines a filter or diff driver command, and with git's
// own error when dir is not inside a repository.
//
// Callers that inspect a work tree that may contain submodules should also pass
// --ignore-submodules=dirty: a submodule has its own config, which this does not
// read, and git only runs commands there when it looks inside the submodule.
func Command(ctx context.Context, dir string, args ...string) (*exec.Cmd, error) {
env := environment()
defines, err := repositoryDefinesCommands(ctx, dir, env)
if err != nil {
return nil, err
}
if defines {
return nil, ErrRepositoryDefinesCommands
}
command := exec.CommandContext(ctx, "git", append(disabledPrograms(), args...)...)
command.Dir = dir
// No opportunistic index refresh: a read-only call never rewrites the index,
// so it never triggers the hooks that run when the index is written. Git
// before 2.15 does not know the variable and ignores it.
command.Env = append(env, "GIT_OPTIONAL_LOCKS=0")
return command, nil
}
// disabledPrograms turns off the single-key ways a repository runs a program
// during status or diff. Git ignores a key it does not know, so an older git
// without hooksPath (2.9) or fsmonitor (2.16) accepts these unchanged.
func disabledPrograms() []string {
return []string{"-c", "core.fsmonitor=false", "-c", "core.hooksPath=" + os.DevNull}
}
// repositoryLocationVariables make git use a given repository, work tree or
// index instead of discovering them from the working directory. Git exports
// them to hooks, so a Zero started from a hook inherits the hook's repository.
var repositoryLocationVariables = []string{
"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR",
"GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_NAMESPACE", "GIT_PREFIX",
}
// environment is Zero's environment without the repository-location variables,
// so git finds the repository from dir. The config check and the command share
// it: checking one repository's config and then running git in another would
// let the second one's commands through.
func environment() []string {
parent := os.Environ()
env := make([]string, 0, len(parent)+1)
for _, entry := range parent {
name, _, _ := strings.Cut(entry, "=")
if !isRepositoryLocationVariable(name) {
env = append(env, entry)
}
}
return env
}
func isRepositoryLocationVariable(name string) bool {
for _, variable := range repositoryLocationVariables {
// Windows environment names are case-insensitive.
if name == variable || (runtime.GOOS == "windows" && strings.EqualFold(name, variable)) {
return true
}
}
return false
}
// repositoryDefinesCommands reports whether the repository's own config, with
// includes followed, defines a filter or diff driver command. The local config is
// read once, together with the key that turns on per-worktree config, and that
// file is read only when it is on, so most repositories cost one git process.
// Reading config runs nothing.
func repositoryDefinesCommands(ctx context.Context, dir string, env []string) (bool, error) {
entries, err := readConfig(ctx, dir, env, "--local", localConfigPattern)
if err != nil {
return false, err
}
worktreeConfig := false
for _, entry := range entries {
if entry.key != worktreeConfigKey {
return true, nil
}
worktreeConfig = !configFalse(entry.value)
}
if !worktreeConfig {
return false, nil
}
// The extension needs git 2.20, which also has --worktree, so a failure here
// is real and the caller goes without git.
entries, err = readConfig(ctx, dir, env, "--worktree", worktreeConfigPattern)
if err != nil {
return false, err
}
return len(entries) > 0, nil
}
// configFalse reports whether a config value is one of git's spellings of false.
// Anything else, including a key with no value, counts as on, so an unexpected
// spelling costs one more read rather than skipping the file.
func configFalse(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "false", "no", "off", "0":
return true
}
return false
}
type configEntry struct {
key string
value string
}
// readConfig returns the entries in one config scope whose key matches pattern.
// It is a variable so tests can answer for git.
var readConfig = func(ctx context.Context, dir string, env []string, scope string, pattern string) ([]configEntry, error) {
command := exec.CommandContext(ctx, "git", "config", "-z", scope, "--includes", "--get-regexp", pattern)
command.Dir = dir
command.Env = env
output, err := command.Output()
if err != nil {
var exit *exec.ExitError
if errors.As(err, &exit) && exit.ExitCode() == 1 {
return nil, nil // no key matched
}
return nil, err
}
return parseConfigEntries(output), nil
}
// parseConfigEntries splits git config -z output: each entry ends with a NUL,
// and a newline separates the key from its value when there is one.
func parseConfigEntries(output []byte) []configEntry {
var entries []configEntry
for _, record := range bytes.Split(output, []byte{0}) {
if len(record) == 0 {
continue
}
key, value, _ := bytes.Cut(record, []byte{'\n'})
entries = append(entries, configEntry{key: string(key), value: string(value)})
}
return entries
}
-327
View File
@@ -1,327 +0,0 @@
package gitsafe
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"reflect"
"regexp"
"runtime"
"strings"
"testing"
)
type configCall struct {
scope string
env []string
}
// answerConfig stands in for git's config read and records each call.
func answerConfig(t *testing.T, answer func(scope string) ([]configEntry, error)) *[]configCall {
t.Helper()
var calls []configCall
previous := readConfig
readConfig = func(_ context.Context, _ string, env []string, scope string, _ string) ([]configEntry, error) {
calls = append(calls, configCall{scope: scope, env: env})
return answer(scope)
}
t.Cleanup(func() { readConfig = previous })
return &calls
}
func noEntries(string) ([]configEntry, error) { return nil, nil }
func scopes(calls *[]configCall) []string {
var read []string
for _, call := range *calls {
read = append(read, call.scope)
}
return read
}
// effectiveEnv returns the value a child process gets for name: os/exec keeps
// the last entry for a name, compared without case on Windows.
func effectiveEnv(env []string, name string) (string, bool) {
value, found := "", false
for _, entry := range env {
key, rest, ok := strings.Cut(entry, "=")
if ok && (key == name || (runtime.GOOS == "windows" && strings.EqualFold(key, name))) {
value, found = rest, true
}
}
return value, found
}
func TestCommandTurnsOffRepositoryPrograms(t *testing.T) {
answerConfig(t, noEntries)
t.Setenv("GIT_OPTIONAL_LOCKS", "1")
dir := t.TempDir()
command, err := Command(context.Background(), dir, "status", "--porcelain")
if err != nil {
t.Fatalf("Command: %v", err)
}
want := []string{"git", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=" + os.DevNull, "status", "--porcelain"}
if !reflect.DeepEqual(command.Args, want) {
t.Fatalf("args = %q, want %q", command.Args, want)
}
if command.Dir != dir {
t.Fatalf("dir = %q, want %q", command.Dir, dir)
}
// An inherited GIT_OPTIONAL_LOCKS must not turn the index refresh back on.
if value, _ := effectiveEnv(command.Env, "GIT_OPTIONAL_LOCKS"); value != "0" {
t.Fatalf("effective GIT_OPTIONAL_LOCKS = %q, want 0", value)
}
}
func TestCommandDropsRepositoryLocationVariables(t *testing.T) {
// Turn per-worktree config on so both reads happen and both are checked.
calls := answerConfig(t, func(scope string) ([]configEntry, error) {
if scope == "--local" {
return []configEntry{{key: worktreeConfigKey, value: "true"}}, nil
}
return nil, nil
})
elsewhere := filepath.Join(t.TempDir(), "elsewhere")
for _, name := range repositoryLocationVariables {
t.Setenv(name, elsewhere)
}
t.Setenv("ZERO_GITSAFE_TEST_KEPT", "kept")
command, err := Command(context.Background(), t.TempDir(), "status")
if err != nil {
t.Fatalf("Command: %v", err)
}
for _, name := range repositoryLocationVariables {
if value, found := effectiveEnv(command.Env, name); found {
t.Fatalf("%s=%q reached git", name, value)
}
}
if value, _ := effectiveEnv(command.Env, "ZERO_GITSAFE_TEST_KEPT"); value != "kept" {
t.Fatalf("an unrelated variable was dropped: %q", value)
}
// The config check has to look at the repository the command runs in, so it
// gets the same environment, minus only the lock variable added afterwards.
if got := scopes(calls); !reflect.DeepEqual(got, []string{"--local", "--worktree"}) {
t.Fatalf("config reads = %q, want --local then --worktree", got)
}
for _, call := range *calls {
if !reflect.DeepEqual(call.env, command.Env[:len(command.Env)-1]) {
t.Fatalf("the %s config read ran with a different environment than the command", call.scope)
}
}
}
func TestLocationVariableNamesFoldCaseOnlyOnWindows(t *testing.T) {
if !isRepositoryLocationVariable("GIT_DIR") {
t.Fatal("GIT_DIR not recognised")
}
if got, want := isRepositoryLocationVariable("Git_Dir"), runtime.GOOS == "windows"; got != want {
t.Fatalf("Git_Dir recognised = %v, want %v on %s", got, want, runtime.GOOS)
}
for _, name := range []string{"GIT_DIRECTORY", "MY_GIT_DIR", "GIT_CONFIG_GLOBAL", ""} {
if isRepositoryLocationVariable(name) {
t.Fatalf("%q treated as a repository-location variable", name)
}
}
}
func TestCommandRefusesRepositoryThatDefinesCommands(t *testing.T) {
enabled := configEntry{key: worktreeConfigKey, value: "true"}
command := configEntry{key: "filter.x.clean", value: "x"}
cases := map[string]map[string][]configEntry{
"local": {"--local": {command}},
"local after the extension": {"--local": {enabled, command}},
"per-worktree": {"--local": {enabled}, "--worktree": {{key: "diff.x.textconv", value: "x"}}},
}
for name, answers := range cases {
t.Run(name, func(t *testing.T) {
answerConfig(t, func(scope string) ([]configEntry, error) { return answers[scope], nil })
got, err := Command(context.Background(), t.TempDir(), "status")
if !errors.Is(err, ErrRepositoryDefinesCommands) {
t.Fatalf("err = %v, want ErrRepositoryDefinesCommands", err)
}
if got != nil {
t.Fatal("a command came back for a repository that defines commands")
}
})
}
}
func TestCommandReadsPerWorktreeConfigOnlyWhenItIsOn(t *testing.T) {
cases := map[string]struct {
local []configEntry
want []string
}{
"no extension": {nil, []string{"--local"}},
"extension off": {[]configEntry{{key: worktreeConfigKey, value: "false"}}, []string{"--local"}},
"extension off as no": {[]configEntry{{key: worktreeConfigKey, value: "No"}}, []string{"--local"}},
"extension on": {[]configEntry{{key: worktreeConfigKey, value: "true"}}, []string{"--local", "--worktree"}},
"extension with no value": {[]configEntry{{key: worktreeConfigKey}}, []string{"--local", "--worktree"}},
"turned off again": {[]configEntry{{key: worktreeConfigKey, value: "true"}, {key: worktreeConfigKey, value: "off"}}, []string{"--local"}},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
calls := answerConfig(t, func(scope string) ([]configEntry, error) {
if scope == "--local" {
return tc.local, nil
}
return nil, nil
})
if _, err := Command(context.Background(), t.TempDir(), "status"); err != nil {
t.Fatalf("Command: %v", err)
}
if got := scopes(calls); !reflect.DeepEqual(got, tc.want) {
t.Fatalf("config reads = %q, want %q", got, tc.want)
}
})
}
}
func TestCommandFailsWhenConfigCannotBeRead(t *testing.T) {
unreadable := errors.New("not a git repository")
for _, failing := range []string{"--local", "--worktree"} {
t.Run(failing, func(t *testing.T) {
answerConfig(t, func(scope string) ([]configEntry, error) {
if scope == failing {
return nil, unreadable
}
return []configEntry{{key: worktreeConfigKey, value: "true"}}, nil
})
if _, err := Command(context.Background(), t.TempDir(), "status"); !errors.Is(err, unreadable) {
t.Fatalf("err = %v, want the config read error", err)
}
})
}
}
// Go's regexp stands in for git's extended regex here; the patterns use nothing
// the two treat differently.
func TestConfigPatterns(t *testing.T) {
local := regexp.MustCompile(localConfigPattern)
worktree := regexp.MustCompile(worktreeConfigPattern)
for _, key := range []string{
"filter.lfs.clean", "filter.lfs.smudge", "filter.lfs.process", "filter.a.b.clean",
"diff.word.textconv", "diff.tool.command", "diff.external",
} {
if !local.MatchString(key) || !worktree.MatchString(key) {
t.Errorf("%s is not matched in both scopes", key)
}
}
if !local.MatchString(worktreeConfigKey) || worktree.MatchString(worktreeConfigKey) {
t.Errorf("%s belongs to the local read only", worktreeConfigKey)
}
for _, key := range []string{
"filter.lfs.required", "diff.word.cachetextconv", "diff.word.binary",
"core.fsmonitor", "diff.external.x", "diff.renames", "extensions.worktreeconfigx",
} {
if local.MatchString(key) || worktree.MatchString(key) {
t.Errorf("%s is matched", key)
}
}
}
func TestParseConfigEntries(t *testing.T) {
got := parseConfigEntries([]byte("filter.x.clean\nrun this\nand that\x00extensions.worktreeconfig\x00"))
want := []configEntry{
{key: "filter.x.clean", value: "run this\nand that"},
{key: "extensions.worktreeconfig"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("entries = %+v, want %+v", got, want)
}
}
// Real git: a fresh repository with nothing configured, the same repository with
// per-worktree config turned on, and a directory that is not a repository. This
// pins the output format and exit codes readConfig relies on; the command keys
// themselves are covered above.
func TestCommandAgainstRealGit(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
repo := t.TempDir()
outside := t.TempDir()
// Both share a parent; stop git from finding an enclosing repository there.
t.Setenv("GIT_CEILING_DIRECTORIES", filepath.Dir(repo))
// Run as if from a hook in another repository. The setup below has to reach
// repo, as Command does, and never the repository these name.
decoy := t.TempDir()
t.Setenv("GIT_DIR", filepath.Join(decoy, ".git"))
t.Setenv("GIT_WORK_TREE", decoy)
decoyUntouched := func(step string) {
t.Helper()
if _, err := os.Stat(filepath.Join(decoy, ".git")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("%s reached the repository GIT_DIR names instead of repo: %v", step, err)
}
}
git := func(args ...string) {
t.Helper()
command := exec.Command("git", append([]string{"-C", repo}, args...)...)
command.Env = environment()
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v: %s", args, err, output)
}
}
git("init", "-q")
decoyUntouched("git init")
gitRead := readConfig
var read []string
readConfig = func(ctx context.Context, dir string, env []string, scope string, pattern string) ([]configEntry, error) {
read = append(read, scope)
return gitRead(ctx, dir, env, scope, pattern)
}
t.Cleanup(func() { readConfig = gitRead })
command, err := Command(context.Background(), repo, "status", "--porcelain")
if err != nil {
t.Fatalf("Command in a plain repository: %v", err)
}
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("git status: %v: %s", err, output)
}
if !reflect.DeepEqual(read, []string{"--local"}) {
t.Fatalf("config reads in a plain repository = %q, want --local only", read)
}
if !gitAtLeast(t, 2, 20) {
t.Log("skipping the per-worktree case: it needs git 2.20 for extensions.worktreeConfig")
} else {
git("config", "extensions.worktreeConfig", "true")
decoyUntouched("git config")
read = nil
if _, err := Command(context.Background(), repo, "status"); err != nil {
t.Fatalf("Command with per-worktree config on: %v", err)
}
if !reflect.DeepEqual(read, []string{"--local", "--worktree"}) {
t.Fatalf("config reads with per-worktree config on = %q, want --local then --worktree", read)
}
}
if _, err := Command(context.Background(), outside, "status"); err == nil {
t.Fatal("Command outside a repository succeeded")
}
}
// gitAtLeast reports whether the git on PATH is at least major.minor.
func gitAtLeast(t *testing.T, major, minor int) bool {
t.Helper()
output, err := exec.Command("git", "version").Output()
if err != nil {
t.Fatalf("git version: %v", err)
}
var gotMajor, gotMinor int
if _, err := fmt.Sscanf(strings.TrimPrefix(strings.TrimSpace(string(output)), "git version "), "%d.%d", &gotMajor, &gotMinor); err != nil {
t.Fatalf("parse %q: %v", output, err)
}
return gotMajor > major || (gotMajor == major && gotMinor >= minor)
}
+2 -2
View File
@@ -22,7 +22,7 @@ func TestUnixInstallerScriptMatchesReleaseContracts(t *testing.T) {
}
containsAll(t, script, []string{
"set -euo pipefail",
`ZERO_REPO="${ZERO_REPO:-Twigpine/zero}"`,
`ZERO_REPO="${ZERO_REPO:-Gitlawb/zero}"`,
`ZERO_INSTALL_DIR="${ZERO_INSTALL_DIR:-$HOME/.local/bin}"`,
`archive_name="zero-v${version}-${platform}-${arch}.tar.gz"`,
`checksum_name="${archive_name}.sha256"`,
@@ -220,7 +220,7 @@ func runUnixInstaller(t *testing.T, fixture unixInstallFixture) (string, string,
command.Env = append(os.Environ(),
"PATH="+fixture.mockBin+string(os.PathListSeparator)+os.Getenv("PATH"),
"ZERO_GITHUB_BASE_URL=https://example.test",
"ZERO_REPO=Twigpine/zero",
"ZERO_REPO=Gitlawb/zero",
)
var stdout bytes.Buffer
var stderr bytes.Buffer
+69 -163
View File
@@ -28,9 +28,69 @@ func TestBuildPlatformPackagesAssemblesPublishPayloads(t *testing.T) {
tar := requireCommand(t, "tar")
version := packageVersion(t)
root := writePlatformArchiveFixture(t, tar, version)
root := t.TempDir()
staging := filepath.Join(root, "staging")
// Payload the platform package must keep.
writeFixtureFile(t, filepath.Join(staging, "zero"), "#!/usr/bin/env sh\necho fixture-zero\n", 0o755)
writeFixtureFile(t, filepath.Join(staging, "zero-linux-sandbox"), "#!/usr/bin/env sh\n", 0o755)
writeFixtureFile(t, filepath.Join(staging, "zero-seccomp"), "#!/usr/bin/env sh\n", 0o755)
// Wrapper-owned files the platform package must exclude.
writeFixtureFile(t, filepath.Join(staging, "package.json"), `{"name":"@gitlawb/zero"}`, 0o644)
writeFixtureFile(t, filepath.Join(staging, "README.md"), "readme\n", 0o644)
writeFixtureFile(t, filepath.Join(staging, "VERSION"), version+"\n", 0o644)
writeFixtureFile(t, filepath.Join(staging, "bin", "zero.js"), "#!/usr/bin/env node\n", 0o755)
// Vendored helpers tree with npm's symlink .bin shims and agent-browser's
// multi-platform binary set.
agentBrowserDir := filepath.Join(staging, "helpers", "node_modules", "agent-browser")
writeFixtureFile(t, filepath.Join(agentBrowserDir, "package.json"), `{"name":"agent-browser"}`, 0o644)
writeFixtureFile(t, filepath.Join(agentBrowserDir, "bin", "agent-browser.js"), "#!/usr/bin/env node\nconsole.log('fixture agent-browser')\n", 0o644)
for _, name := range []string{
"agent-browser-linux-x64",
"agent-browser-linux-musl-x64",
"agent-browser-linux-arm64",
"agent-browser-darwin-arm64",
"agent-browser-darwin-x64",
"agent-browser-win32-x64.exe",
} {
writeFixtureFile(t, filepath.Join(agentBrowserDir, "bin", name), "native\n", 0o755)
}
tuistoryDir := filepath.Join(staging, "helpers", "node_modules", "tuistory")
writeFixtureFile(t, filepath.Join(tuistoryDir, "dist", "cli.js"), "#!/usr/bin/env node\n", 0o644)
binDir := filepath.Join(staging, "helpers", "node_modules", ".bin")
mustMkdirAllFixture(t, binDir)
if err := os.Symlink("../agent-browser/bin/agent-browser.js", filepath.Join(binDir, "agent-browser")); err != nil {
t.Fatalf("Symlink agent-browser shim: %v", err)
}
if err := os.Symlink("../tuistory/dist/cli.js", filepath.Join(binDir, "tuistory")); err != nil {
t.Fatalf("Symlink tuistory shim: %v", err)
}
// Archive with the payload at the archive root, like zero-release's
// createArchive, plus the sha256 sidecar the script verifies against.
assetName := fmt.Sprintf("zero-v%s-linux-x64.tar.gz", version)
archivePath := filepath.Join(root, assetName)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if output, err := exec.CommandContext(ctx, tar, "-C", staging, "-czf", archivePath, ".").CombinedOutput(); err != nil {
t.Fatalf("tar failed: %v\n%s", err, output)
}
archiveBytes, err := os.ReadFile(archivePath)
if err != nil {
t.Fatalf("ReadFile archive: %v", err)
}
digest := sha256.Sum256(archiveBytes)
writeFixtureFile(t, archivePath+".sha256", fmt.Sprintf("%x %s\n", digest, assetName), 0o644)
outDir := filepath.Join(root, "out")
runBuildPlatformPackages(t, node, root, outDir)
script := filepath.Join(repoRoot(t), "scripts", "npm", "build-platform-packages.mjs")
runCtx, runCancel := context.WithTimeout(context.Background(), 60*time.Second)
defer runCancel()
command := exec.CommandContext(runCtx, node, script, "--artifacts-dir", root, "--out-dir", outDir, "--only", "linux-x64")
command.Env = append(os.Environ(), "NODE_OPTIONS=")
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("build-platform-packages failed: %v\n%s", err, output)
}
platformDir := filepath.Join(outDir, "platforms", "zero-linux-x64")
var platformPkg struct {
@@ -41,8 +101,8 @@ func TestBuildPlatformPackagesAssemblesPublishPayloads(t *testing.T) {
Scripts map[string]string
}
unmarshalJSONFile(t, filepath.Join(platformDir, "package.json"), &platformPkg)
if platformPkg.Name != "@twigpine/zero" {
t.Fatalf("platform package name = %q, want @twigpine/zero (same-name suffixed versions share one trusted publisher)", platformPkg.Name)
if platformPkg.Name != "@gitlawb/zero" {
t.Fatalf("platform package name = %q, want @gitlawb/zero (same-name suffixed versions share one trusted publisher)", platformPkg.Name)
}
if want := version + "-linux-x64"; platformPkg.Version != want {
t.Fatalf("platform package version = %q, want %q", platformPkg.Version, want)
@@ -138,11 +198,11 @@ func TestBuildPlatformPackagesAssemblesPublishPayloads(t *testing.T) {
t.Fatalf("published wrapper has dependencies: %v (helpers are vendored in the platform payloads)", wrapperPkg.Dependencies)
}
wantAliases := map[string]string{
"@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-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",
}
if len(wrapperPkg.Optional) != len(wantAliases) {
t.Fatalf("wrapper optionalDependencies = %v, want %v", wrapperPkg.Optional, wantAliases)
@@ -159,160 +219,6 @@ func TestBuildPlatformPackagesAssemblesPublishPayloads(t *testing.T) {
}
}
// writePlatformArchiveFixture writes a linux-x64 release archive shaped like
// the output of `zero-release package`, with its .sha256 sidecar, into a new
// directory and returns that directory.
func writePlatformArchiveFixture(t *testing.T, tar string, version string) string {
t.Helper()
root := t.TempDir()
staging := filepath.Join(root, "staging")
// Payload the platform package must keep.
writeFixtureFile(t, filepath.Join(staging, "zero"), "#!/usr/bin/env sh\necho fixture-zero\n", 0o755)
writeFixtureFile(t, filepath.Join(staging, "zero-linux-sandbox"), "#!/usr/bin/env sh\n", 0o755)
writeFixtureFile(t, filepath.Join(staging, "zero-seccomp"), "#!/usr/bin/env sh\n", 0o755)
// Wrapper-owned files the platform package must exclude.
writeFixtureFile(t, filepath.Join(staging, "package.json"), `{"name":"@twigpine/zero"}`, 0o644)
writeFixtureFile(t, filepath.Join(staging, "README.md"), "readme\n", 0o644)
writeFixtureFile(t, filepath.Join(staging, "VERSION"), version+"\n", 0o644)
writeFixtureFile(t, filepath.Join(staging, "bin", "zero.js"), "#!/usr/bin/env node\n", 0o755)
// Vendored helpers tree with npm's symlink .bin shims and agent-browser's
// multi-platform binary set.
agentBrowserDir := filepath.Join(staging, "helpers", "node_modules", "agent-browser")
writeFixtureFile(t, filepath.Join(agentBrowserDir, "package.json"), `{"name":"agent-browser"}`, 0o644)
writeFixtureFile(t, filepath.Join(agentBrowserDir, "bin", "agent-browser.js"), "#!/usr/bin/env node\nconsole.log('fixture agent-browser')\n", 0o644)
for _, name := range []string{
"agent-browser-linux-x64",
"agent-browser-linux-musl-x64",
"agent-browser-linux-arm64",
"agent-browser-darwin-arm64",
"agent-browser-darwin-x64",
"agent-browser-win32-x64.exe",
} {
writeFixtureFile(t, filepath.Join(agentBrowserDir, "bin", name), "native\n", 0o755)
}
tuistoryDir := filepath.Join(staging, "helpers", "node_modules", "tuistory")
writeFixtureFile(t, filepath.Join(tuistoryDir, "dist", "cli.js"), "#!/usr/bin/env node\n", 0o644)
binDir := filepath.Join(staging, "helpers", "node_modules", ".bin")
mustMkdirAllFixture(t, binDir)
if err := os.Symlink("../agent-browser/bin/agent-browser.js", filepath.Join(binDir, "agent-browser")); err != nil {
t.Fatalf("Symlink agent-browser shim: %v", err)
}
if err := os.Symlink("../tuistory/dist/cli.js", filepath.Join(binDir, "tuistory")); err != nil {
t.Fatalf("Symlink tuistory shim: %v", err)
}
// Archive with the payload at the archive root, like zero-release's
// createArchive, plus the sha256 sidecar the script verifies against.
assetName := fmt.Sprintf("zero-v%s-linux-x64.tar.gz", version)
archivePath := filepath.Join(root, assetName)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if output, err := exec.CommandContext(ctx, tar, "-C", staging, "-czf", archivePath, ".").CombinedOutput(); err != nil {
t.Fatalf("tar failed: %v\n%s", err, output)
}
archiveBytes, err := os.ReadFile(archivePath)
if err != nil {
t.Fatalf("ReadFile archive: %v", err)
}
digest := sha256.Sum256(archiveBytes)
writeFixtureFile(t, archivePath+".sha256", fmt.Sprintf("%x %s\n", digest, assetName), 0o644)
return root
}
func runBuildPlatformPackages(t *testing.T, node string, artifactsDir string, outDir string, extraArgs ...string) {
t.Helper()
script := filepath.Join(repoRoot(t), "scripts", "npm", "build-platform-packages.mjs")
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
args := append([]string{script, "--artifacts-dir", artifactsDir, "--out-dir", outDir, "--only", "linux-x64"}, extraArgs...)
command := exec.CommandContext(ctx, node, args...)
command.Env = append(os.Environ(), "NODE_OPTIONS=")
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("build-platform-packages failed: %v\n%s", err, output)
}
}
// The final release under the pre-rename name is the same artifacts assembled
// with --package-name. Every place a payload names its package must follow it:
// a wrapper that kept the current name in one alias would resolve a package
// npm never installed.
func TestBuildPlatformPackagesAssemblesUnderAnotherPackageName(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fixture uses POSIX symlinks and shell scripts")
}
node := requireNode(t)
tar := requireCommand(t, "tar")
version := packageVersion(t)
root := writePlatformArchiveFixture(t, tar, version)
outDir := filepath.Join(root, "out")
runBuildPlatformPackages(t, node, root, outDir, "--package-name", "@gitlawb/zero")
platformManifest := filepath.Join(outDir, "platforms", "zero-linux-x64", "package.json")
var platformPkg struct {
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
}
unmarshalJSONFile(t, platformManifest, &platformPkg)
if platformPkg.Name != "@gitlawb/zero" || platformPkg.Version != version+"-linux-x64" {
t.Fatalf("platform package = %s@%s, want @gitlawb/zero@%s-linux-x64", platformPkg.Name, platformPkg.Version, version)
}
wrapperManifest := filepath.Join(outDir, "wrapper", "package.json")
var wrapperPkg struct {
Name string `json:"name"`
Version string `json:"version"`
Optional map[string]string `json:"optionalDependencies"`
Repository struct {
URL string `json:"url"`
} `json:"repository"`
}
unmarshalJSONFile(t, wrapperManifest, &wrapperPkg)
if wrapperPkg.Name != "@gitlawb/zero" || wrapperPkg.Version != version {
t.Fatalf("wrapper = %s@%s, want @gitlawb/zero@%s", wrapperPkg.Name, wrapperPkg.Version, version)
}
if len(wrapperPkg.Optional) != 5 {
t.Fatalf("wrapper optionalDependencies = %v, want the full five-platform matrix", wrapperPkg.Optional)
}
for alias, spec := range wrapperPkg.Optional {
platform := strings.TrimPrefix(alias, "@gitlawb/zero-")
if platform == alias || spec != "npm:@gitlawb/zero@"+version+"-"+platform {
t.Fatalf("optionalDependencies[%q] = %q, want npm:@gitlawb/zero@%s-<platform> under an @gitlawb/zero-<platform> alias", alias, spec, version)
}
}
// Provenance is checked against the repository the workflow runs in, which
// is the same for both names.
if !strings.Contains(wrapperPkg.Repository.URL, "github.com/Twigpine/zero") {
t.Fatalf("wrapper repository = %q, want the Twigpine/zero repository npm checks provenance against", wrapperPkg.Repository.URL)
}
for _, manifest := range []string{platformManifest, wrapperManifest} {
data, err := os.ReadFile(manifest)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), "@twigpine/zero") {
t.Fatalf("%s still names @twigpine/zero:\n%s", manifest, data)
}
}
}
func TestBuildPlatformPackagesRejectsAnInvalidPackageName(t *testing.T) {
node := requireNode(t)
script := filepath.Join(repoRoot(t), "scripts", "npm", "build-platform-packages.mjs")
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
defer cancel()
command := exec.CommandContext(ctx, node, script, "--artifacts-dir", t.TempDir(), "--package-name", "../zero")
command.Env = append(os.Environ(), "NODE_OPTIONS=")
output, err := command.CombinedOutput()
if err == nil {
t.Fatalf("build-platform-packages accepted --package-name ../zero:\n%s", output)
}
if !strings.Contains(string(output), "is not an npm package name") {
t.Fatalf("output does not explain the rejection: %s", output)
}
}
func unmarshalJSONFile(t *testing.T, path string, target any) {
t.Helper()
data, err := os.ReadFile(path)
+9 -101
View File
@@ -24,9 +24,9 @@ const (
// by the generic missing-binary path and the doctor text fallback.
const (
bunRecoveryLead = "You installed with Bun, which does not run dependency lifecycle scripts"
bunPmTrustProject = "bun pm trust @twigpine/zero"
bunPmTrustGlobal = "bun pm -g trust @twigpine/zero"
bunRecoveryTrustedDeps = `"trustedDependencies": ["@twigpine/zero"]`
bunPmTrustProject = "bun pm trust @gitlawb/zero"
bunPmTrustGlobal = "bun pm -g trust @gitlawb/zero"
bunRecoveryTrustedDeps = `"trustedDependencies": ["@gitlawb/zero"]`
buildFromSourceLead = "If this platform has no prebuilt binary, build from source:"
)
@@ -80,8 +80,8 @@ func TestPackageBinPointsToNodeWrapper(t *testing.T) {
if err := json.Unmarshal(bytes, &pkg); err != nil {
t.Fatalf("Unmarshal package.json: %v", err)
}
if pkg.Name != "@twigpine/zero" {
t.Fatalf("name = %q, want @twigpine/zero", pkg.Name)
if pkg.Name != "@gitlawb/zero" {
t.Fatalf("name = %q, want @gitlawb/zero", pkg.Name)
}
if pkg.Bin["zero"] != "bin/zero.js" {
t.Fatalf("bin.zero = %q, want bin/zero.js", pkg.Bin["zero"])
@@ -158,7 +158,7 @@ func TestPostinstallComputesAssetPlan(t *testing.T) {
if plan.BinaryName != tc.wantBinary {
t.Fatalf("%s/%s: binaryName=%q want %q", tc.platform, tc.arch, plan.BinaryName, tc.wantBinary)
}
wantURL := "https://github.com/Twigpine/zero/releases/download/v" + version + "/" + tc.wantAsset
wantURL := "https://github.com/Gitlawb/zero/releases/download/v" + version + "/" + tc.wantAsset
if plan.AssetURL != wantURL {
t.Fatalf("%s/%s: assetUrl=%q want %q", tc.platform, tc.arch, plan.AssetURL, wantURL)
}
@@ -321,11 +321,11 @@ func TestNodeWrapperPrefersPlatformPackageBinary(t *testing.T) {
t.Fatalf("WriteFile downloaded fixture: %v", err)
}
platformDir := filepath.Join(root, "node_modules", "@twigpine", "zero-"+nodePlatformName()+"-"+nodeArchName())
platformDir := filepath.Join(root, "node_modules", "@gitlawb", "zero-"+nodePlatformName()+"-"+nodeArchName())
if err := os.MkdirAll(platformDir, 0o755); err != nil {
t.Fatalf("MkdirAll platform package: %v", err)
}
if err := os.WriteFile(filepath.Join(platformDir, "package.json"), []byte(`{"name":"@twigpine/zero"}`), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(platformDir, "package.json"), []byte(`{"name":"@gitlawb/zero"}`), 0o644); err != nil {
t.Fatalf("WriteFile platform package.json: %v", err)
}
if err := os.WriteFile(filepath.Join(platformDir, "zero"), []byte("#!/usr/bin/env sh\nprintf 'platform-zero'; for arg in \"$@\"; do printf ' %s' \"$arg\"; done; printf '\\n'\n"), 0o755); err != nil {
@@ -347,98 +347,6 @@ func TestNodeWrapperPrefersPlatformPackageBinary(t *testing.T) {
}
}
// bin/zero.js derives the platform payload alias from its own package.json
// name rather than a hardcoded one, so a wrapper assembled under any name
// finds the payload published under that same name
// (build-platform-packages.mjs names both from one value).
func TestNodeWrapperResolvesPlatformPackageUnderItsOwnName(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("mock executable fixture uses a POSIX shell script")
}
wrapperPath := copyWrapperFixture(t)
root := filepath.Dir(filepath.Dir(wrapperPath))
writeFixtureFile(t, filepath.Join(root, "package.json"), `{"type":"module","name":"@example/zero"}`, 0o644)
// A payload under the default name sits beside it and must lose.
writePlatformPackageFixture(t, root, "@twigpine/zero", "default-name-zero")
writePlatformPackageFixture(t, root, "@example/zero", "own-name-zero")
stdout, stderr, exitCode := runWrapperFixture(t, wrapperPath, "--version")
if exitCode != wrapperExitSuccess {
t.Fatalf("wrapper exit = %d; stdout=%s stderr=%s", exitCode, stdout, stderr)
}
if got := strings.TrimSpace(stdout); got != "own-name-zero --version" {
t.Fatalf("wrapper output = %q, want the payload aliased under its own name (own-name-zero --version)", got)
}
}
// The bridge release published under the old name runs normally and, once
// zero exits, says where the package went and how to switch, in the one order
// npm accepts.
func TestNodeWrapperLegacyBuildTellsUsersToSwitchAfterExit(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("mock executable fixture uses a POSIX shell script")
}
wrapperPath := copyWrapperFixture(t)
root := filepath.Dir(filepath.Dir(wrapperPath))
writeFixtureFile(t, filepath.Join(root, "package.json"), `{"type":"module","name":"@gitlawb/zero"}`, 0o644)
writePlatformPackageFixture(t, root, "@gitlawb/zero", "legacy-zero")
stdout, stderr, exitCode := runWrapperFixtureWithEnv(t, wrapperPath, []string{"ZERO_WRAPPER_SIMULATE_TTY=1"}, "--version")
if exitCode != wrapperExitSuccess {
t.Fatalf("wrapper exit = %d; stdout=%s stderr=%s", exitCode, stdout, stderr)
}
if got := strings.TrimSpace(stdout); got != "legacy-zero --version" {
t.Fatalf("stdout = %q, want the legacy build to run normally with the notice kept off stdout", got)
}
uninstall := strings.Index(stderr, "npm uninstall -g @gitlawb/zero")
install := strings.Index(stderr, "npm install -g @twigpine/zero")
if uninstall < 0 || install < 0 || uninstall > install {
t.Fatalf("stderr does not give the switch steps, uninstall first: %q", stderr)
}
}
func TestNodeWrapperLegacyNoticeStaysOutOfTheWay(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("mock executable fixture uses a POSIX shell script")
}
for _, tc := range []struct {
name, packageName string
env []string
args []string
}{
// Scripts capturing stderr must not see a new line in it.
{name: "not a terminal", packageName: "@gitlawb/zero", args: []string{"--version"}},
// The Go side prints the same note for these; once is enough.
{name: "zero upgrade", packageName: "@gitlawb/zero", env: []string{"ZERO_WRAPPER_SIMULATE_TTY=1"}, args: []string{"upgrade"}},
{name: "zero update", packageName: "@gitlawb/zero", env: []string{"ZERO_WRAPPER_SIMULATE_TTY=1"}, args: []string{"update", "--check"}},
{name: "current package", packageName: "@twigpine/zero", env: []string{"ZERO_WRAPPER_SIMULATE_TTY=1"}, args: []string{"--version"}},
} {
t.Run(tc.name, func(t *testing.T) {
wrapperPath := copyWrapperFixture(t)
root := filepath.Dir(filepath.Dir(wrapperPath))
writeFixtureFile(t, filepath.Join(root, "package.json"), `{"type":"module","name":"`+tc.packageName+`"}`, 0o644)
writePlatformPackageFixture(t, root, tc.packageName, "zero")
stdout, stderr, exitCode := runWrapperFixtureWithEnv(t, wrapperPath, tc.env, tc.args...)
if exitCode != wrapperExitSuccess {
t.Fatalf("wrapper exit = %d; stdout=%s stderr=%s", exitCode, stdout, stderr)
}
if strings.TrimSpace(stderr) != "" {
t.Fatalf("stderr = %q, want nothing", stderr)
}
})
}
}
// writePlatformPackageFixture installs a fake platform payload where npm puts
// the wrapper's optionalDependency alias <packageName>-<platform>-<arch>; its
// zero binary prints label followed by its arguments.
func writePlatformPackageFixture(t *testing.T, root string, packageName string, label string) {
t.Helper()
dir := filepath.Join(root, "node_modules", filepath.FromSlash(packageName+"-"+nodePlatformName()+"-"+nodeArchName()))
writeFixtureFile(t, filepath.Join(dir, "package.json"), `{"name":"`+packageName+`"}`, 0o644)
writeFixtureFile(t, filepath.Join(dir, "zero"), "#!/usr/bin/env sh\nprintf '"+label+"'; for arg in \"$@\"; do printf ' %s' \"$arg\"; done; printf '\\n'\n", 0o755)
}
func TestNodeWrapperFallsBackToDownloadedBinary(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("mock executable fixture uses a POSIX shell script")
@@ -485,7 +393,7 @@ func TestNodeWrapperRunsFirstRunDownloaderWhenBinaryMissing(t *testing.T) {
if err := os.WriteFile(filepath.Join(scriptsDir, "postinstall.mjs"), postinstall, 0o644); err != nil {
t.Fatalf("WriteFile postinstall fixture: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"type":"module","name":"@twigpine/zero","version":"0.0.0"}`), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(`{"type":"module","name":"@gitlawb/zero","version":"0.0.0"}`), 0o644); err != nil {
t.Fatalf("WriteFile package fixture: %v", err)
}
-433
View File
@@ -1,433 +0,0 @@
package npmwrapper
import (
"bufio"
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
// fakeNpm stands in for npm on PATH when scripts/npm/publish-packages.sh runs.
// It keeps one package's registry state in FAKE_NPM_STATE and answers the way
// npm 11 does: a lookup of a missing package or a missing version fails with
// E404, and it refuses a version published twice or any lookup or publish for
// a package name other than the one it holds. Every call is appended to
// FAKE_NPM_STATE.log.
//
// FAKE_NPM_CLOBBER_LATEST=1 makes every publish take `latest`, the failure the
// script must stop on. FAKE_NPM_VERSION_LOOKUP_ERRORS=<n> fails the first n
// version lookups with a network error instead of answering them.
// FAKE_NPM_APPLY_AFTER=<n> applies each publish only after n further lookups,
// the way npm accepts an upload and shows the version minutes later; until
// then the version and its dist-tag are invisible. When a wrapper is published,
// the aliased versions not yet visible are recorded in missingAtWrapperPublish.
const fakeNpm = `#!/usr/bin/env node
const fs = require('fs');
const stateFile = process.env.FAKE_NPM_STATE;
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
const args = process.argv.slice(2);
fs.appendFileSync(stateFile + '.log', JSON.stringify({ args, cwd: process.cwd() }) + '\n');
const save = () => fs.writeFileSync(stateFile, JSON.stringify(state));
const notFound = (detail) => { process.stderr.write('npm error code E404\nnpm error 404 ' + detail + '\n'); process.exit(1); };
state.pending = state.pending || [];
const apply = (publish) => {
state.versions.push(publish.version);
state.distTags[publish.tag] = publish.version;
if (process.env.FAKE_NPM_CLOBBER_LATEST === '1') state.distTags.latest = publish.version;
};
if (args[0] === 'view') {
for (const publish of state.pending) publish.wait -= 1;
state.pending.filter((publish) => publish.wait <= 0).forEach(apply);
state.pending = state.pending.filter((publish) => publish.wait > 0);
save();
const [spec, field] = args.slice(1);
const at = spec.lastIndexOf('@');
const name = at > 0 ? spec.slice(0, at) : spec;
if (field === 'version') {
const failures = Number(process.env.FAKE_NPM_VERSION_LOOKUP_ERRORS || 0);
state.versionLookups = (state.versionLookups || 0) + 1;
save();
if (state.versionLookups <= failures) {
process.stderr.write('npm error code ECONNRESET\nnpm error network aborted\n');
process.exit(1);
}
}
if (name !== state.name || state.versions.length === 0) notFound('Not Found - GET ' + name);
if (field === 'version') {
const version = spec.slice(at + 1);
if (!state.versions.includes(version)) notFound('No match found for version ' + version);
process.stdout.write(version + '\n');
process.exit(0);
}
if (field === 'dist-tags.latest') {
if (state.distTags.latest) process.stdout.write(state.distTags.latest + '\n');
process.exit(0);
}
}
if (args[0] === 'publish') {
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
if (pkg.name !== state.name) {
process.stderr.write('fake npm: refusing to publish ' + pkg.name + '\n');
process.exit(1);
}
if (state.versions.includes(pkg.version) || state.pending.some((publish) => publish.version === pkg.version)) {
process.stderr.write('npm error code EPUBLISHCONFLICT\n');
process.exit(1);
}
if (pkg.optionalDependencies) {
state.missingAtWrapperPublish = Object.values(pkg.optionalDependencies)
.map((spec) => spec.slice(spec.lastIndexOf('@') + 1))
.filter((version) => !state.versions.includes(version));
}
const tagIndex = args.indexOf('--tag');
const publish = { version: pkg.version, tag: tagIndex === -1 ? 'latest' : args[tagIndex + 1], wait: Number(process.env.FAKE_NPM_APPLY_AFTER || 0) };
if (publish.wait > 0) state.pending.push(publish);
else apply(publish);
save();
process.exit(0);
}
process.stderr.write('fake npm: unsupported command ' + args.join(' ') + '\n');
process.exit(2);
`
const (
publishFixtureName = "@example/zero"
publishFixtureVersion = "1.2.3"
)
type fakeRegistryState struct {
Name string `json:"name"`
Versions []string `json:"versions"`
DistTags map[string]string `json:"distTags"`
// VersionLookups counts `npm view <name>@<version> version` calls.
VersionLookups int `json:"versionLookups,omitempty"`
// MissingAtWrapperPublish lists the platform versions the wrapper aliased
// that npm did not show yet when the wrapper was published.
MissingAtWrapperPublish []string `json:"missingAtWrapperPublish,omitempty"`
}
type npmCall struct {
Args []string `json:"args"`
Cwd string `json:"cwd"`
}
type publishRun struct {
stdout, stderr string
exitCode int
state fakeRegistryState
publishes []npmCall
}
// writePublishFixture lays out what build-platform-packages.mjs emits: a
// wrapper whose optionalDependencies alias the given platform payloads, and
// one directory per payload in platforms.
func writePublishFixture(t *testing.T, aliasedPlatforms []string, platformDirs []string) string {
t.Helper()
npmDir := filepath.Join(t.TempDir(), "npm")
aliases := map[string]string{}
for _, platform := range aliasedPlatforms {
aliases[publishFixtureName+"-"+platform] = "npm:" + publishFixtureName + "@" + publishFixtureVersion + "-" + platform
}
wrapper, err := json.Marshal(map[string]any{
"name": publishFixtureName,
"version": publishFixtureVersion,
"optionalDependencies": aliases,
})
if err != nil {
t.Fatal(err)
}
writeFixtureFile(t, filepath.Join(npmDir, "wrapper", "package.json"), string(wrapper), 0o644)
for _, platform := range platformDirs {
manifest := `{"name":"` + publishFixtureName + `","version":"` + publishFixtureVersion + "-" + platform + `"}`
writeFixtureFile(t, filepath.Join(npmDir, "platforms", "zero-"+platform, "package.json"), manifest, 0o644)
}
return npmDir
}
func runPublishPackages(t *testing.T, npmDir string, registry fakeRegistryState, extraEnv ...string) publishRun {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("publish-packages.sh runs on the Linux release runner; the fake npm is a POSIX executable")
}
bash := requireCommand(t, "bash")
requireNode(t)
binDir := t.TempDir()
writeFixtureFile(t, filepath.Join(binDir, "npm"), fakeNpm, 0o755)
if registry.DistTags == nil {
registry.DistTags = map[string]string{}
}
if registry.Versions == nil {
registry.Versions = []string{}
}
stateFile := filepath.Join(t.TempDir(), "registry.json")
initial, err := json.Marshal(registry)
if err != nil {
t.Fatal(err)
}
writeFixtureFile(t, stateFile, string(initial), 0o644)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
command := exec.CommandContext(ctx, bash, filepath.Join(repoRoot(t), "scripts", "npm", "publish-packages.sh"), npmDir)
command.Env = append(os.Environ(),
"PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"),
"FAKE_NPM_STATE="+stateFile,
"ZERO_NPM_PUBLISH_RETRY_DELAY=0",
"NODE_OPTIONS=",
)
command.Env = append(command.Env, extraEnv...)
var stdout, stderr strings.Builder
command.Stdout = &stdout
command.Stderr = &stderr
run := publishRun{}
if err := command.Run(); err != nil {
exitErr, ok := err.(*exec.ExitError)
if !ok {
t.Fatalf("publish-packages.sh: %v; stderr=%s", err, stderr.String())
}
run.exitCode = exitErr.ExitCode()
}
if ctx.Err() != nil {
t.Fatalf("publish-packages.sh timed out; stdout=%s stderr=%s", stdout.String(), stderr.String())
}
run.stdout, run.stderr = stdout.String(), stderr.String()
unmarshalJSONFile(t, stateFile, &run.state)
if log, err := os.Open(stateFile + ".log"); err == nil {
defer log.Close()
scanner := bufio.NewScanner(log)
for scanner.Scan() {
var call npmCall
if err := json.Unmarshal(scanner.Bytes(), &call); err != nil {
t.Fatalf("fake npm log line %q: %v", scanner.Text(), err)
}
if len(call.Args) > 0 && call.Args[0] == "publish" {
run.publishes = append(run.publishes, call)
}
}
}
return run
}
// publishedDirs names each publish by the payload directory it ran in, in order.
func (run publishRun) publishedDirs() []string {
dirs := []string{}
for _, call := range run.publishes {
dirs = append(dirs, filepath.Base(call.Cwd))
}
return dirs
}
func TestPublishPackagesPublishesPlatformPayloadsBeforeTheWrapper(t *testing.T) {
platforms := []string{"darwin-arm64", "linux-x64"}
npmDir := writePublishFixture(t, platforms, platforms)
run := runPublishPackages(t, npmDir, fakeRegistryState{Name: publishFixtureName})
if run.exitCode != 0 {
t.Fatalf("exit = %d; stdout=%s stderr=%s", run.exitCode, run.stdout, run.stderr)
}
if got, want := strings.Join(run.publishedDirs(), ","), "zero-darwin-arm64,zero-linux-x64,wrapper"; got != want {
t.Fatalf("publish order = %s, want %s (the wrapper pins the platform versions, so it must go last)", got, want)
}
for index, call := range run.publishes {
args := strings.Join(call.Args, " ")
if !strings.Contains(args, "--access public") || !strings.Contains(args, "--ignore-scripts") {
t.Fatalf("publish %d args = %q, want --access public and --ignore-scripts", index, args)
}
isWrapper := filepath.Base(call.Cwd) == "wrapper"
if hasPlatformTag := strings.Contains(args, "--tag platform"); hasPlatformTag == isWrapper {
t.Fatalf("publish %d (%s) args = %q: platform payloads need --tag platform and the wrapper must take latest", index, filepath.Base(call.Cwd), args)
}
}
if latest := run.state.DistTags["latest"]; latest != publishFixtureVersion {
t.Fatalf("latest = %q, want %q", latest, publishFixtureVersion)
}
}
// "Re-run failed jobs" after a partial publish must finish the release rather
// than fail on the versions that already went out.
func TestPublishPackagesSkipsVersionsAlreadyOnTheRegistry(t *testing.T) {
platforms := []string{"darwin-arm64", "linux-x64"}
npmDir := writePublishFixture(t, platforms, platforms)
run := runPublishPackages(t, npmDir, fakeRegistryState{
Name: publishFixtureName,
Versions: []string{"1.2.2", publishFixtureVersion + "-darwin-arm64"},
DistTags: map[string]string{"latest": "1.2.2", "platform": publishFixtureVersion + "-darwin-arm64"},
})
if run.exitCode != 0 {
t.Fatalf("exit = %d; stdout=%s stderr=%s", run.exitCode, run.stdout, run.stderr)
}
if got, want := strings.Join(run.publishedDirs(), ","), "zero-linux-x64,wrapper"; got != want {
t.Fatalf("published %s, want %s (darwin-arm64 was already on the registry)", got, want)
}
if !strings.Contains(run.stdout, publishFixtureName+"@"+publishFixtureVersion+"-darwin-arm64 is already on the registry") {
t.Fatalf("stdout does not report the skipped version: %s", run.stdout)
}
// Re-running once everything is out publishes nothing and still succeeds.
rerun := runPublishPackages(t, npmDir, run.state)
if rerun.exitCode != 0 {
t.Fatalf("rerun exit = %d; stdout=%s stderr=%s", rerun.exitCode, rerun.stdout, rerun.stderr)
}
if len(rerun.publishes) != 0 {
t.Fatalf("rerun published %v, want nothing", rerun.publishedDirs())
}
}
func TestPublishPackagesStopsBeforeTheWrapperWhenAPlatformTookLatest(t *testing.T) {
platforms := []string{"linux-x64"}
npmDir := writePublishFixture(t, platforms, platforms)
run := runPublishPackages(t, npmDir, fakeRegistryState{Name: publishFixtureName}, "FAKE_NPM_CLOBBER_LATEST=1")
if run.exitCode == 0 {
t.Fatalf("exit = 0 after a platform publish took latest; stdout=%s", run.stdout)
}
if got := strings.Join(run.publishedDirs(), ","); got != "zero-linux-x64" {
t.Fatalf("published %s, want only the platform payload (the wrapper must not follow a clobbered latest)", got)
}
if !strings.Contains(run.stderr, "clobbered") {
t.Fatalf("stderr does not explain the failure: %s", run.stderr)
}
}
// A wrapper that aliases a platform version with no payload beside it would
// install an alias that 404s, so nothing may be published at all.
func TestPublishPackagesRefusesAWrapperWhoseAliasesDoNotMatchThePayloads(t *testing.T) {
npmDir := writePublishFixture(t, []string{"darwin-arm64", "linux-x64"}, []string{"linux-x64"})
run := runPublishPackages(t, npmDir, fakeRegistryState{Name: publishFixtureName})
if run.exitCode == 0 {
t.Fatalf("exit = 0 with a missing platform payload; stdout=%s", run.stdout)
}
if len(run.publishes) != 0 {
t.Fatalf("published %v before noticing the mismatch, want nothing", run.publishedDirs())
}
if !strings.Contains(run.stderr, "do not match the platform payloads") {
t.Fatalf("stderr does not explain the failure: %s", run.stderr)
}
}
// A lookup that fails for a reason other than E404 must not read as "not
// published": on the re-run this skip exists for, the version is usually out
// already, and publishing it again fails with EPUBLISHCONFLICT.
func TestPublishPackagesRetriesAFailedLookupInsteadOfPublishing(t *testing.T) {
platforms := []string{"linux-x64"}
npmDir := writePublishFixture(t, platforms, platforms)
run := runPublishPackages(t, npmDir, fakeRegistryState{
Name: publishFixtureName,
Versions: []string{publishFixtureVersion + "-linux-x64"},
DistTags: map[string]string{"platform": publishFixtureVersion + "-linux-x64"},
}, "FAKE_NPM_VERSION_LOOKUP_ERRORS=2")
if run.exitCode != 0 {
t.Fatalf("exit = %d after two transient lookup failures; stdout=%s stderr=%s", run.exitCode, run.stdout, run.stderr)
}
if got := strings.Join(run.publishedDirs(), ","); got != "wrapper" {
t.Fatalf("published %s, want only the wrapper (the platform version was already out)", got)
}
}
// When the registry cannot answer at all, nothing may be published: the
// script cannot tell which versions are already out.
func TestPublishPackagesFailsWhenTheRegistryCannotBeReached(t *testing.T) {
platforms := []string{"linux-x64"}
npmDir := writePublishFixture(t, platforms, platforms)
run := runPublishPackages(t, npmDir, fakeRegistryState{Name: publishFixtureName}, "FAKE_NPM_VERSION_LOOKUP_ERRORS=1000")
if run.exitCode == 0 {
t.Fatalf("exit = 0 with the registry unreachable; stdout=%s", run.stdout)
}
if len(run.publishes) != 0 {
t.Fatalf("published %v without being able to look anything up, want nothing", run.publishedDirs())
}
if !strings.Contains(run.stderr, "could not look up") || !strings.Contains(run.stderr, "ECONNRESET") {
t.Fatalf("stderr does not explain the failure or show npm's error: %s", run.stderr)
}
}
// npm accepts an upload and shows the version minutes later (ten minutes for a
// 0.10.0 platform payload). Publishing the wrapper as soon as the platform
// uploads return would leave installs resolving aliases that 404, and checking
// `latest` right after the wrapper upload reports a failure that is not one.
func TestPublishPackagesWaitsForNpmToApplyEachPublish(t *testing.T) {
platforms := []string{"darwin-arm64", "linux-x64"}
npmDir := writePublishFixture(t, platforms, platforms)
run := runPublishPackages(t, npmDir, fakeRegistryState{Name: publishFixtureName}, "FAKE_NPM_APPLY_AFTER=8")
if run.exitCode != 0 {
t.Fatalf("exit = %d while npm applied the publishes late; stdout=%s stderr=%s", run.exitCode, run.stdout, run.stderr)
}
if len(run.state.MissingAtWrapperPublish) != 0 {
t.Fatalf("the wrapper went out while npm did not show %v yet; installs would resolve aliases that 404", run.state.MissingAtWrapperPublish)
}
if latest := run.state.DistTags["latest"]; latest != publishFixtureVersion {
t.Fatalf("latest = %q, want %q", latest, publishFixtureVersion)
}
}
// A publish npm never applies must fail the job at the deadline, before the
// wrapper goes out, instead of waiting forever.
func TestPublishPackagesGivesUpWhenNpmNeverAppliesAPublish(t *testing.T) {
platforms := []string{"linux-x64"}
npmDir := writePublishFixture(t, platforms, platforms)
run := runPublishPackages(t, npmDir, fakeRegistryState{Name: publishFixtureName},
"FAKE_NPM_APPLY_AFTER=1000000", "ZERO_NPM_PUBLISH_WAIT_TIMEOUT=1")
if run.exitCode == 0 {
t.Fatalf("exit = 0 although npm never showed the platform version; stdout=%s", run.stdout)
}
if got := strings.Join(run.publishedDirs(), ","); got != "zero-linux-x64" {
t.Fatalf("published %s, want only the platform payload (the wrapper must wait for it)", got)
}
if !strings.Contains(run.stderr, "still does not show "+publishFixtureName+"@"+publishFixtureVersion+"-linux-x64") {
t.Fatalf("stderr does not say what npm never showed: %s", run.stderr)
}
}
// Bash arithmetic reads a leading zero as octal and rejects 08 outright, so
// the timeout has to be converted explicitly rather than used as typed. The
// rejection does not stop a script: it prints an error, leaves the deadline
// unset and carries on, so the failure only shows once the script has to wait,
// after the platform payloads are out. npm applying late makes it wait here.
func TestPublishPackagesReadsTheWaitTimeoutAsDecimal(t *testing.T) {
platforms := []string{"linux-x64"}
npmDir := writePublishFixture(t, platforms, platforms)
run := runPublishPackages(t, npmDir, fakeRegistryState{Name: publishFixtureName},
"ZERO_NPM_PUBLISH_WAIT_TIMEOUT=08", "FAKE_NPM_APPLY_AFTER=3")
if run.exitCode != 0 {
t.Fatalf("exit = %d with ZERO_NPM_PUBLISH_WAIT_TIMEOUT=08; stdout=%s stderr=%s", run.exitCode, run.stdout, run.stderr)
}
if got := strings.Join(run.publishedDirs(), ","); got != "zero-linux-x64,wrapper" {
t.Fatalf("published %s, want zero-linux-x64,wrapper", got)
}
}
// A setting the script cannot use must stop it before anything is published:
// a negative timeout would otherwise end the wait before it starts, and a bad
// delay would only fail at the first sleep, after the platform payloads.
func TestPublishPackagesRejectsAnInvalidWaitSetting(t *testing.T) {
for _, tc := range []struct{ variable, value string }{
{"ZERO_NPM_PUBLISH_WAIT_TIMEOUT", "-5"},
{"ZERO_NPM_PUBLISH_WAIT_TIMEOUT", "abc"},
{"ZERO_NPM_PUBLISH_WAIT_TIMEOUT", "1e3"},
{"ZERO_NPM_PUBLISH_WAIT_TIMEOUT", "86401"},
{"ZERO_NPM_PUBLISH_WAIT_TIMEOUT", "9999999999999999999999"},
{"ZERO_NPM_PUBLISH_RETRY_DELAY", "soon"},
} {
t.Run(tc.variable+"="+tc.value, func(t *testing.T) {
platforms := []string{"linux-x64"}
npmDir := writePublishFixture(t, platforms, platforms)
run := runPublishPackages(t, npmDir, fakeRegistryState{Name: publishFixtureName}, tc.variable+"="+tc.value)
if run.exitCode == 0 {
t.Fatalf("exit = 0 with %s=%s; stdout=%s", tc.variable, tc.value, run.stdout)
}
if len(run.publishes) != 0 {
t.Fatalf("published %v before rejecting %s=%s, want nothing", run.publishedDirs(), tc.variable, tc.value)
}
if !strings.Contains(run.stderr, tc.variable) {
t.Fatalf("stderr does not name %s: %s", tc.variable, run.stderr)
}
})
}
}
-8
View File
@@ -147,14 +147,6 @@ var descriptors = []Descriptor{
// provider id so the remote catalog resolves without a mapping; GMI's own
// docs name the key GMI_API_KEY, models.dev names it GMICLOUD_API_KEY.
openAICompat("gmicloud", "GMI Cloud", "https://api.gmi-serving.com/v1", "Qwen/Qwen3.8-Max", []string{"GMI_API_KEY", "GMICLOUD_API_KEY"}, "gmi cloud", "gmi"),
// Concentrate AI — OpenAI-compatible gateway to models from many labs.
// models.dev has no Concentrate provider, so the id and key name match
// OpenClaude's Concentrate integration. The default is the dated id
// deepseek-v4-flash-0731: Concentrate's /v1/models keeps the rolling name
// deepseek-v4-flash only as an alias, and onboarding matches the default
// against live ids. "concentrate ai" also covers "concentrate.ai" and
// "concentrate-ai" once normalized.
openAICompat("concentrate", "Concentrate", "https://api.concentrate.ai/v1", "deepseek-v4-flash-0731", []string{"CONCENTRATE_API_KEY"}, "concentrate ai"),
openAICompat("longcat", "LongCat", "https://api.longcat.chat/openai", "LongCat-2.0", []string{"LONGCAT_API_KEY"}, "meituan longcat", "meituan", "longcat-2.0"),
openAICompat("nvidia-nim", "NVIDIA NIM", "https://integrate.api.nvidia.com/v1", "nvidia/llama-3.1-nemotron-70b-instruct", []string{"NVIDIA_API_KEY"}, "nvidia nim"),
anthropicCompat("minimax", "MiniMax", "https://api.minimax.io/anthropic", "MiniMax-M3", []string{"MINIMAX_API_KEY"}, "mini-max", "mini_max"),
+1 -31
View File
@@ -28,7 +28,6 @@ var expectedCatalogIDs = []string{
"moonshot",
"atlascloud",
"gmicloud",
"concentrate",
"longcat",
"nvidia-nim",
"minimax",
@@ -228,31 +227,6 @@ func TestGMICloudDescriptor(t *testing.T) {
}
}
func TestConcentrateDescriptor(t *testing.T) {
descriptor, err := Require("concentrate")
if err != nil {
t.Fatalf("Require(concentrate) error = %v", err)
}
if descriptor.Name != "Concentrate" {
t.Fatalf("Name = %q, want Concentrate", descriptor.Name)
}
if descriptor.DefaultBaseURL != "https://api.concentrate.ai/v1" {
t.Fatalf("DefaultBaseURL = %q, want Concentrate OpenAI-compatible endpoint", descriptor.DefaultBaseURL)
}
if descriptor.DefaultModel != "deepseek-v4-flash-0731" {
t.Fatalf("DefaultModel = %q, want deepseek-v4-flash-0731", descriptor.DefaultModel)
}
if descriptor.Transport != TransportOpenAICompatible {
t.Fatalf("Transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
}
if !descriptor.RequiresAuth {
t.Fatal("RequiresAuth = false, want true")
}
if !reflect.DeepEqual(descriptor.AuthEnvVars, []string{"CONCENTRATE_API_KEY"}) {
t.Fatalf("AuthEnvVars = %#v, want CONCENTRATE_API_KEY", descriptor.AuthEnvVars)
}
}
func TestCatalogDescriptorsExposeRequiredDefaults(t *testing.T) {
for _, descriptor := range All() {
if descriptor.ID == "" {
@@ -376,10 +350,6 @@ func TestLookupNormalizesIDsAndAliases(t *testing.T) {
"GMI Cloud": "gmicloud",
"gmi-cloud": "gmicloud",
"gmi": "gmicloud",
"Concentrate": "concentrate",
"concentrate ai": "concentrate",
"concentrate.ai": "concentrate",
"concentrate-ai": "concentrate",
"nvidia nim": "nvidia-nim",
"xiaomi mimo": "xiaomi-mimo",
"custom_openai_compatible": "custom-openai-compatible",
@@ -430,7 +400,7 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
TransportBedrock: {"bedrock"},
TransportVertex: {"vertex"},
TransportAnthropicCompat: {"minimax", "minimaxi-cn", "opencode-go-anthropic-compatible", "custom-anthropic-compatible"},
TransportOpenAICompat: {"gitlawb-opengateway", "aimlapi", "ollama-cloud", "ollama", "lmstudio", "atomic-chat-local", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "fireworks", "dashscope", "moonshot", "atlascloud", "gmicloud", "concentrate", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
TransportOpenAICompat: {"gitlawb-opengateway", "aimlapi", "ollama-cloud", "ollama", "lmstudio", "atomic-chat-local", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "fireworks", "dashscope", "moonshot", "atlascloud", "gmicloud", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
}
for transport, wantIDs := range cases {
-14
View File
@@ -27,8 +27,6 @@ type Model struct {
const minimaxModelSource = "https://platform.minimax.io/docs/api-reference/api-overview"
const concentrateModelSource = "https://api.concentrate.ai/v1/models"
// Shared by both "minimax" and "minimaxi-cn"; Models() rebuilds a fresh slice
// per call, so the shared backing slice cannot be mutated by callers.
// Flat cost fields stay unset because MiniMax-M3 pricing is tiered and this
@@ -157,18 +155,6 @@ var curatedModels = map[string][]Model{
{ID: "deepseek-ai/DeepSeek-V4-Pro", Description: "reasoning model"},
{ID: "deepseek-ai/DeepSeek-V4-Flash", Description: "fast coding model"},
},
// Context windows, tool calling and thinking support come from
// concentrateModelSource (max_input_tokens, function_calling, thinking).
// Max output: 393,216 for both DeepSeek V4 models, 262,144 for Kimi K3 and
// GLM-5.3. The default is the dated id deepseek-v4-flash-0731, the model
// behind the deepseek-v4-flash alias (OpenClaude's Concentrate default): the
// live list carries ids only, so an alias would not survive discovery.
"concentrate": {
{ID: "deepseek-v4-flash-0731", Description: "catalog default", ContextWindow: 1_048_576, ToolCall: true, Reasoning: true, Source: concentrateModelSource},
{ID: "deepseek-v4-pro", Description: "reasoning model", ContextWindow: 1_048_576, ToolCall: true, Reasoning: true, Source: concentrateModelSource},
{ID: "kimi-k3", Description: "coding model", ContextWindow: 1_048_576, ToolCall: true, Reasoning: true, Source: concentrateModelSource},
{ID: "glm-5.3", Description: "coding model", ContextWindow: 1_048_576, ToolCall: true, Reasoning: true, Source: concentrateModelSource},
},
"nvidia-nim": {
{ID: "nvidia/llama-3.1-nemotron-70b-instruct", Description: "catalog default"},
{ID: "meta/llama-3.1-70b-instruct", Description: "general model"},
@@ -53,11 +53,6 @@ func TestModelsAreProviderScoped(t *testing.T) {
want: []string{"Qwen/Qwen3.8-Max", "Qwen/Qwen3.8-Flash", "deepseek-ai/DeepSeek-V4-Pro", "deepseek-ai/DeepSeek-V4-Flash"},
notWant: []string{"gpt-4.1", "claude-sonnet-4.5", "deepseek-ai/deepseek-v4-pro"},
},
{
provider: "concentrate",
want: []string{"deepseek-v4-flash-0731", "deepseek-v4-pro", "kimi-k3", "glm-5.3"},
notWant: []string{"gpt-4.1", "claude-sonnet-4.5", "deepseek-ai/DeepSeek-V4-Pro"},
},
{
provider: "mistral",
want: []string{"mistral-large-latest", "codestral-latest"},
@@ -188,28 +183,6 @@ func TestMiniMaxModelsExposeCurrentCapabilities(t *testing.T) {
}
}
func TestConcentrateModelsExposeCatalogLimits(t *testing.T) {
descriptor, ok := providercatalog.Get("concentrate")
if !ok {
t.Fatal("provider concentrate missing from catalog")
}
models := Models(descriptor)
if got, want := modelIDs(models), []string{"deepseek-v4-flash-0731", "deepseek-v4-pro", "kimi-k3", "glm-5.3"}; !reflect.DeepEqual(got, want) {
t.Fatalf("Concentrate models = %#v, want %#v", got, want)
}
if models[0].Description != "catalog default" {
t.Fatalf("first Concentrate model description = %q, want catalog default", models[0].Description)
}
for _, model := range models {
if model.ContextWindow != 1_048_576 || !model.ToolCall || !model.Reasoning {
t.Fatalf("%s capabilities = %#v, want 1,048,576 context with tools and reasoning", model.ID, model)
}
if model.Source != concentrateModelSource {
t.Fatalf("%s source = %q, want %q", model.ID, model.Source, concentrateModelSource)
}
}
}
// Pins the contract that MiniMax CN exposes exactly the same model IDs as the
// international provider; a divergence here is almost always a mistake.
func TestMiniMaxCNModelsMirrorInternational(t *testing.T) {
@@ -5,7 +5,6 @@ import (
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"github.com/Gitlawb/zero/internal/config"
@@ -39,54 +38,3 @@ func TestDiscoverCatalogFallsBackWhenLiveIDsMismatch(t *testing.T) {
t.Fatalf("models = %q, want curated catalog fallback (gpt-4.1) instead of an empty list", got)
}
}
// Concentrate's /v1/models lists dated snapshots as ids and keeps rolling names
// such as deepseek-v4-flash only in "aliases", which discovery does not read.
// Onboarding keeps the default selected only when it matches a live id, so the
// preset's default must be an id, or setup lands on whatever model sorts first.
func TestDiscoverCatalogConcentrateKeepsDefaultModelInLiveList(t *testing.T) {
var liveHits atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api.json":
_, _ = w.Write([]byte(`{}`))
case "/v1/models":
liveHits.Add(1)
_, _ = w.Write([]byte(`{"object":"list","data":[
{"id":"claude-fable-5","slug":"claude-fable-5","max_input_tokens":1000000},
{"id":"deepseek-v4-flash-0731","slug":"deepseek-v4-flash-0731","aliases":["deepseek-v4-flash"],"max_input_tokens":1048576},
{"id":"deepseek-v4-pro","slug":"deepseek-v4-pro","max_input_tokens":1048576},
{"id":"kimi-k3","slug":"kimi-k3","max_input_tokens":1048576},
{"id":"glm-5.3","slug":"glm-5.3","max_input_tokens":1048576}
]}`))
default:
t.Fatalf("unexpected request path %q", r.URL.Path)
}
}))
defer server.Close()
provider, ok := providercatalog.Get("concentrate")
if !ok {
t.Fatal("provider concentrate missing from catalog")
}
provider.DefaultBaseURL = server.URL + "/v1"
models, err := DiscoverCatalog(context.Background(), provider, config.ProviderProfile{
CatalogID: "concentrate", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: server.URL + "/v1", APIKey: "sk-test",
}, Options{HTTPClient: server.Client(), ModelsDevURL: server.URL + "/api.json"})
if err != nil {
t.Fatalf("DiscoverCatalog returned error: %v", err)
}
if liveHits.Load() == 0 {
t.Fatal("DiscoverCatalog never requested /v1/models")
}
// claude-fable-5 is only in the live response, never in the curated list:
// finding it proves the result is the live list, not the curated fallback
// (which also contains the default and would pass on its own).
got := "," + strings.Join(modelIDs(models), ",") + ","
if !strings.Contains(got, ",claude-fable-5,") {
t.Fatalf("models = %q, want the live Concentrate list (with live-only claude-fable-5), not the curated fallback", strings.Trim(got, ","))
}
if !strings.Contains(got, ","+provider.DefaultModel+",") {
t.Fatalf("default model %q missing from discovered models %q", provider.DefaultModel, strings.Trim(got, ","))
}
}
+47 -157
View File
@@ -9,7 +9,6 @@ import (
"sort"
"strings"
"unicode"
"unicode/utf8"
)
const (
@@ -97,21 +96,6 @@ var textSecretPatterns = []*regexp.Regexp{
regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`),
}
// textSecretPrefixes lists the mandatory literal prefixes for each entry in
// textSecretPatterns so RedactString can skip the regex scan when none of the
// trigger prefixes appear in the input.
var textSecretPrefixes = [][]string{
{"sk-ant-"},
{"github_pat_"},
{"ghp_", "gho_", "ghu_", "ghs_", "ghr_"},
{"glpat-"},
{"AIza"},
{"xox"},
{"AKIA", "ASIA"},
{"eyJ"},
{"eyJ"},
}
// anchoredSecretPatterns mirrors textSecretPatterns with the leading \b
// replaced by \A, so each shape can be tested at an exact byte offset. A
// credential that starts exactly where a redacted span ends (two AWS keys
@@ -264,71 +248,55 @@ func RedactString(value string, options Options) string {
}
}
if strings.Contains(redacted, "-----BEGIN ") && strings.Contains(redacted, "PRIVATE KEY") {
redacted = privateKeyPattern.ReplaceAllString(redacted, replacement)
}
if strings.Contains(redacted, ":") && strings.Contains(redacted, "\"") {
redacted = jsonStringPattern.ReplaceAllStringFunc(redacted, func(match string) string {
parts := jsonStringPattern.FindStringSubmatch(match)
if len(parts) < 3 || !IsSensitiveKey(parts[2], options) {
return match
}
return parts[1] + `"` + replacement + `"`
})
}
if strings.Contains(redacted, "=") {
redacted = assignPattern.ReplaceAllStringFunc(redacted, func(match string) string {
parts := assignPattern.FindStringSubmatch(match)
if len(parts) < 6 || !IsSensitiveKey(parts[1], options) {
return match
}
if parts[3] != "" {
return parts[1] + parts[2] + `"` + replacement + `"`
}
if parts[4] != "" {
return parts[1] + parts[2] + `'` + replacement + `'`
}
return parts[1] + parts[2] + replacement
})
}
if strings.Contains(redacted, ":") {
if containsCaseInsensitive(redacted, "authorization") {
redacted = headerPattern.ReplaceAllStringFunc(redacted, func(match string) string {
groups := headerPattern.FindStringSubmatch(match)
if groups[2] != "" {
return groups[1] + ": " + groups[2] + " " + replacement
}
return groups[1] + ": " + replacement
})
redacted = privateKeyPattern.ReplaceAllString(redacted, replacement)
redacted = jsonStringPattern.ReplaceAllStringFunc(redacted, func(match string) string {
parts := jsonStringPattern.FindStringSubmatch(match)
if len(parts) < 3 || !IsSensitiveKey(parts[2], options) {
return match
}
redacted = secretHeader.ReplaceAllString(redacted, "$1: "+replacement)
}
if strings.Contains(redacted, "://") && strings.Contains(redacted, "@") {
redacted = redactURLPasswords(redacted, replacement)
}
if strings.Contains(redacted, "?") || strings.Contains(redacted, "&") {
redacted = queryPattern.ReplaceAllStringFunc(redacted, func(match string) string {
parts := queryPattern.FindStringSubmatch(match)
if len(parts) < 4 || !IsSensitiveKey(parts[2], options) {
return match
}
return parts[1] + parts[2] + "=" + replacement
})
}
return parts[1] + `"` + replacement + `"`
})
redacted = assignPattern.ReplaceAllStringFunc(redacted, func(match string) string {
parts := assignPattern.FindStringSubmatch(match)
if len(parts) < 6 || !IsSensitiveKey(parts[1], options) {
return match
}
if parts[3] != "" {
return parts[1] + parts[2] + `"` + replacement + `"`
}
if parts[4] != "" {
return parts[1] + parts[2] + `'` + replacement + `'`
}
return parts[1] + parts[2] + replacement
})
redacted = headerPattern.ReplaceAllStringFunc(redacted, func(match string) string {
groups := headerPattern.FindStringSubmatch(match)
// groups[2] is the known scheme (kept for readability) or "" for an opaque /
// custom-scheme credential — in which case the whole value is redacted (M12).
if groups[2] != "" {
return groups[1] + ": " + groups[2] + " " + replacement
}
return groups[1] + ": " + replacement
})
redacted = secretHeader.ReplaceAllString(redacted, "$1: "+replacement)
redacted = redactURLPasswords(redacted, replacement)
redacted = queryPattern.ReplaceAllStringFunc(redacted, func(match string) string {
parts := queryPattern.FindStringSubmatch(match)
if len(parts) < 4 || !IsSensitiveKey(parts[2], options) {
return match
}
return parts[1] + parts[2] + "=" + replacement
})
// openai keys first so the filter can drop kebab-case false positives
// before any other pattern rewrites nearby text.
if strings.Contains(redacted, "sk-") {
redacted = openaiKeyPattern.ReplaceAllStringFunc(redacted, func(match string) string {
if !openAIShapeIsSecret(match) {
return match
}
return replacement
})
}
for i, pattern := range textSecretPatterns {
if hasAnyPrefix(redacted, textSecretPrefixes[i]) {
redacted = redactAdjacent(redacted, pattern, anchoredSecretPatterns[i], replacement)
redacted = openaiKeyPattern.ReplaceAllStringFunc(redacted, func(match string) string {
if !openAIShapeIsSecret(match) {
return match
}
return replacement
})
for i, pattern := range textSecretPatterns {
redacted = redactAdjacent(redacted, pattern, anchoredSecretPatterns[i], replacement)
}
// Credentials broken up by invisible characters last, over what the
// contiguous matchers left: a whole credential is already claimed by its own
@@ -338,76 +306,6 @@ func RedactString(value string, options Options) string {
return redacted
}
func hasAnyPrefix(s string, prefixes []string) bool {
for _, p := range prefixes {
if strings.Contains(s, p) {
return true
}
}
return false
}
func containsCaseInsensitive(s, substr string) bool {
if len(substr) == 0 {
return true
}
if len(s) < len(substr) {
return false
}
for i := 0; i <= len(s)-len(substr); i++ {
if hasFoldedASCIIPrefix(s[i:], substr) {
return true
}
}
return false
}
// hasFoldedASCIIPrefix reports whether s starts with substr under Unicode
// simple case folding (assuming substr is lowercase ASCII). This ensures that
// non-ASCII runes that fold to ASCII in (?i) regular expressions (U+017F 'ſ' ->
// 's' and U+212A 'K' -> 'k') pass the fast-path gate with zero allocations.
func hasFoldedASCIIPrefix(s, substr string) bool {
si := 0
for j := 0; j < len(substr); j++ {
if si >= len(s) {
return false
}
c2 := toLowerASCII(substr[j])
c1 := s[si]
if c1 < utf8.RuneSelf {
if c1 != c2 && toLowerASCII(c1) != c2 {
return false
}
si++
continue
}
r, width := utf8.DecodeRuneInString(s[si:])
if foldRuneToLowerASCII(r) != c2 {
return false
}
si += width
}
return true
}
func foldRuneToLowerASCII(r rune) byte {
switch r {
case '\u017f': // LATIN SMALL LETTER LONG S
return 's'
case '\u212a': // KELVIN SIGN
return 'k'
default:
return 0
}
}
func toLowerASCII(c byte) byte {
if c >= 'A' && c <= 'Z' {
return c + ('a' - 'A')
}
return c
}
// knownOpenAIKeyPrefix is the redaction-side twin of secrets.knownOpenAIKeyPrefix:
// known OpenAI-issued forms redact even with an alphabet-only body.
func knownOpenAIKeyPrefix(match string) bool {
@@ -659,15 +557,7 @@ func redactURLPasswords(value string, replacement string) string {
if _, hasPassword := parsed.User.Password(); !hasPassword {
return candidate
}
username := parsed.User.Username()
userinfo := url.UserPassword(username, replacement)
parsed.User = userinfo
out := parsed.String()
encodedUserinfo := userinfo.String()
literalUserinfo := url.User(username).String() + ":" + replacement
if encodedUserinfo != literalUserinfo {
out = strings.Replace(out, encodedUserinfo, literalUserinfo, 1)
}
return out
parsed.User = url.UserPassword(parsed.User.Username(), replacement)
return parsed.String()
})
}
+3 -418
View File
@@ -33,7 +33,7 @@ func TestRedactStringRedactsAdjacentAWSKeys(t *testing.T) {
func TestRedactStringCoversCommonSecretShapes(t *testing.T) {
input := strings.Join([]string{
`{"apiKey":"sk-proj-abcdefghijklmnopqrstuvwxyz"}`,
"authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz1234567890",
"authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz123456",
"https://zero:[email protected]/path?token=glpat-abcdefghijklmnopqrstuvwxyz",
"-----BEGIN PRIVATE KEY-----\nabc123\n-----END PRIVATE KEY-----",
}, "\n")
@@ -42,7 +42,7 @@ func TestRedactStringCoversCommonSecretShapes(t *testing.T) {
for _, leaked := range []string{
"sk-proj-abcdefghijklmnopqrstuvwxyz",
"ghp_abcdefghijklmnopqrstuvwxyz1234567890",
"ghp_abcdefghijklmnopqrstuvwxyz123456",
"super-secret",
"glpat-abcdefghijklmnopqrstuvwxyz",
"abc123",
@@ -81,7 +81,7 @@ func TestRedactValueHandlesSensitiveKeysAndCycles(t *testing.T) {
func TestRedactErrorRedactsMessageStackAndFields(t *testing.T) {
err := withFieldsError{
err: errors.New("request failed with api_key=sk-test-secret1234567890"),
Token: "ghp_abcdefghijklmnopqrstuvwxyz1234567890",
Token: "ghp_abcdefghijklmnopqrstuvwxyz123456",
Detail: "safe",
}
@@ -185,418 +185,3 @@ func TestRedactStringLongAdjacentKeyRunCompletesPromptly(t *testing.T) {
t.Fatalf("expected %d redaction markers, got %d", count, strings.Count(got, RedactedSecret))
}
}
func BenchmarkRedactString(b *testing.B) {
sample := strings.Join([]string{
`{"apiKey":"sk-proj-abcdefghijklmnopqrstuvwxyz1234567890"}`,
"authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz1234567890",
"https://zero:[email protected]/path?token=glpat-abcdefghijklmnopqrstuvwxyz",
"export AWS_SECRET_ACCESS_KEY=AKIAIOSFODNN7EXAMPLE",
"normal line with no secrets just log messages and numbers 123456789",
"another normal line about git commit status and file diff output",
}, "\n")
opts := Options{ExtraSecretValues: []string{"super-secret"}}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = RedactString(sample, opts)
}
}
func BenchmarkRedactStringClean(b *testing.B) {
cleanText := "normal line with no secrets just log messages and numbers 123456789\nanother line without secrets"
opts := Options{}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = RedactString(cleanText, opts)
}
}
func TestRedactStringConditionalGates(t *testing.T) {
const ghp = "ghp_abcdefghijklmnopqrstuvwxyz1234567890"
const gho = "gho_abcdefghijklmnopqrstuvwxyz1234567890"
const pat = "github_pat_abcdefghijklmnopqrstuv"
const ant = "sk-ant-api03-abcdefghijklmnopqrst"
const glpat = "glpat-abcdefghijkl"
const aiza = "AIza" + "01234567890123456789012345678901234"
const slack = "xoxb-1234567890-abcdefghij"
const akia = "AKIAIOSFODNN7EXAMPLE"
const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signaturexx"
tests := []struct {
name string
in string
leaked []string
keep []string
wantHit bool
}{
{
name: "private key match",
in: "-----BEGIN RSA PRIVATE KEY-----\nabc123\n-----END RSA PRIVATE KEY-----",
leaked: []string{"abc123"},
wantHit: true,
},
{
name: "private key near-miss",
in: "-----BEGIN PUBLIC KEY-----\nabc123\n-----END PUBLIC KEY-----",
keep: []string{"abc123"},
wantHit: false,
},
{
name: "json sensitive key",
in: `{"apiKey":"hunter2secret"}`,
leaked: []string{"hunter2secret"},
wantHit: true,
},
{
name: "json non-sensitive key",
in: `{"name":"hunter2secret"}`,
keep: []string{"hunter2secret"},
wantHit: false,
},
{
name: "assign sensitive",
in: "AWS_SECRET_ACCESS_KEY=supersecretvalue",
leaked: []string{"supersecretvalue"},
wantHit: true,
},
{
name: "assign non-sensitive",
in: "PATH=/usr/bin",
keep: []string{"/usr/bin"},
wantHit: false,
},
{
name: "authorization header case-insensitive",
in: "AUTHORIZATION: Bearer " + ghp,
leaked: []string{ghp},
wantHit: true,
},
{
name: "authorization near-miss not a header name",
in: "X-Request-Id: Bearer not-a-token-value",
keep: []string{"not-a-token-value"},
wantHit: false,
},
{
name: "query token",
in: "https://example.test/x?token=" + glpat,
leaked: []string{glpat},
wantHit: true,
},
{
name: "query near-miss",
in: "https://example.test/x?page=42",
keep: []string{"page=42"},
wantHit: false,
},
{
name: "openai sk-proj",
in: "key=" + "sk-proj-abcdefghijklmnopqrstuvwxyz1234",
leaked: []string{"sk-proj-abcdefghijklmnopqrstuvwxyz1234"},
wantHit: true,
},
{
name: "sk-ant",
in: ant,
leaked: []string{ant},
wantHit: true,
},
{
name: "github_pat",
in: pat,
leaked: []string{pat},
wantHit: true,
},
{
name: "ghp classic",
in: ghp,
leaked: []string{ghp},
wantHit: true,
},
{
name: "gho oauth",
in: gho,
leaked: []string{gho},
wantHit: true,
},
{
name: "ghp too short unchanged",
in: "ghp_shorttoken",
keep: []string{"ghp_shorttoken"},
wantHit: false,
},
{
name: "unsupported prefix ghx",
in: "ghx_abcdefghijklmnopqrstuvwxyz1234567890",
keep: []string{"ghx_abcdefghijklmnopqrstuvwxyz1234567890"},
wantHit: false,
},
{
name: "glpat",
in: glpat,
leaked: []string{glpat},
wantHit: true,
},
{
name: "google aiza",
in: aiza,
leaked: []string{aiza},
wantHit: true,
},
{
name: "slack xoxb",
in: slack,
leaked: []string{slack},
wantHit: true,
},
{
name: "aws akia",
in: akia,
leaked: []string{akia},
wantHit: true,
},
{
name: "jwt",
in: jwt,
leaked: []string{jwt},
wantHit: true,
},
{
name: "secret header x-api-key",
in: "X-API-Key: hunter2secret",
leaked: []string{"hunter2secret"},
wantHit: true,
},
{
name: "non-secret header unchanged",
in: "X-Request-Id: hunter2secret",
keep: []string{"hunter2secret"},
wantHit: false,
},
{
name: "url password",
in: "https://user:[email protected]/x",
leaked: []string{"hunter2secret"},
wantHit: true,
},
{
name: "url user without password unchanged",
in: "https://[email protected]/x",
keep: []string{"https://[email protected]/x"},
wantHit: false,
},
{
name: "url password does not unescape path marker",
in: "https://user:[email protected]/%5BREDACTED%5D",
leaked: []string{"hunter2secret"},
keep: []string{"/%5BREDACTED%5D"},
wantHit: true,
},
{
name: "url encoded username does not inject header",
in: "https://%0AAuthorization%3A%20Bearer%20opaque-token:[email protected]/x",
leaked: []string{
"hunter2secret",
"Authorization: Bearer",
"\nAuthorization",
},
keep: []string{"Authorization"},
wantHit: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := RedactString(tc.in, Options{})
for _, leak := range tc.leaked {
if strings.Contains(got, leak) {
t.Fatalf("leaked %q in %q", leak, got)
}
}
for _, keep := range tc.keep {
if !strings.Contains(got, keep) {
t.Fatalf("near-miss changed: want %q still in %q", keep, got)
}
}
if tc.wantHit && !strings.Contains(got, RedactedSecret) {
t.Fatalf("expected %q, got %q", RedactedSecret, got)
}
if !tc.wantHit && strings.Contains(got, RedactedSecret) {
t.Fatalf("unexpected redaction: %q", got)
}
})
}
}
func TestRedactURLPasswords_HostlessFailClosed(t *testing.T) {
cases := []struct {
in string
leaked string
keep []string
}{
{in: "http://admin:secret@", leaked: "secret", keep: []string{"admin"}},
{in: "http://admin:secret@?q=1", leaked: "secret", keep: []string{"?q=1"}},
{in: "http://admin:secret@#fragment", leaked: "secret", keep: []string{"#fragment"}},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
got := redactURLPasswords(tc.in, RedactedSecret)
if strings.Contains(got, tc.leaked) {
t.Fatalf("leaked %q in %q", tc.leaked, got)
}
if !strings.Contains(got, RedactedSecret) {
t.Fatalf("expected literal %q in %q", RedactedSecret, got)
}
if strings.Contains(got, "%5BREDACTED%5D") {
t.Fatalf("marker was percent-encoded in %q", got)
}
for _, keep := range tc.keep {
if !strings.Contains(got, keep) {
t.Fatalf("want %q still in %q", keep, got)
}
}
})
}
}
func TestRedactString_URLPasswordHostlessFailClosed(t *testing.T) {
tests := []struct {
name string
in string
leaked []string
keep []string
}{
{
name: "hostless trailing at",
in: "http://admin:secret@",
leaked: []string{"secret"},
keep: []string{RedactedSecret, "admin"},
},
{
name: "hostless query",
in: "http://admin:secret@?q=1",
leaked: []string{"secret"},
keep: []string{RedactedSecret, "?q=1"},
},
{
name: "hostless fragment",
in: "http://admin:secret@#fragment",
leaked: []string{"secret"},
keep: []string{RedactedSecret, "#fragment"},
},
{
name: "embedded hostless",
in: "connecting to http://admin:hunter2@ now",
leaked: []string{"hunter2"},
keep: []string{RedactedSecret, "connecting to ", " now"},
},
{
name: "https proxy env hostless",
in: "HTTPS_PROXY=http://proxyuser:pr0xyp4ss@",
leaked: []string{"pr0xyp4ss"},
keep: []string{RedactedSecret, "HTTPS_PROXY=", "proxyuser"},
},
{
name: "host present keeps encoded path and query",
in: "https://user:[email protected]/%5BREDACTED%5D?q=a%20b",
leaked: []string{"secret"},
keep: []string{RedactedSecret, "/%5BREDACTED%5D", "q=a%20b"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := RedactString(tc.in, Options{})
if strings.Contains(got, "%5BREDACTED%5D") && !strings.Contains(tc.in, "%5BREDACTED%5D") {
t.Fatalf("marker was percent-encoded: %q", got)
}
for _, leak := range tc.leaked {
if strings.Contains(got, leak) {
t.Fatalf("leaked %q in %q", leak, got)
}
}
for _, keep := range tc.keep {
if !strings.Contains(got, keep) {
t.Fatalf("want %q still in %q", keep, got)
}
}
})
}
}
func TestRedactString_QueryGateIsolatedFromAssignAndTextSecrets(t *testing.T) {
const opaque = "opaque-query-fixture-value"
in := "https://example.test/x?[password]=" + opaque
if !IsSensitiveKey("[password]", Options{}) {
t.Fatal("fixture key [password] must normalize to a sensitive key")
}
if assignPattern.MatchString(in) {
t.Fatal("fixture must not satisfy assignPattern; otherwise the query gate is not isolated")
}
if kept := RedactString(opaque, Options{}); kept != opaque {
t.Fatalf("opaque value must not match text-secret patterns, got %q", kept)
}
got := RedactString(in, Options{})
if strings.Contains(got, opaque) {
t.Fatalf("query gate missed the value: %q", got)
}
if !strings.Contains(got, RedactedSecret) {
t.Fatalf("query gate did not insert marker: %q", got)
}
}
func TestRedactString_UnicodeFoldedHeaderNames(t *testing.T) {
tests := []struct {
name string
in string
leaked string
}{
{
name: "x-api-key with kelvin sign U+212A",
in: "X-API-\u212aey: opaque-header-secret-1",
leaked: "opaque-header-secret-1",
},
{
name: "set-cookie with long s U+017F",
in: "\u017fet-cookie: session=opaque-cookie-secret-2",
leaked: "opaque-cookie-secret-2",
},
{
name: "containsCaseInsensitive kelvin sign U+212A",
in: "Authori\u212aation",
leaked: "",
},
{
name: "containsCaseInsensitive long s U+017F",
in: "\u017fet-cookie",
leaked: "",
},
}
if !containsCaseInsensitive("Authori\u212aation", "authorikation") {
t.Fatal("containsCaseInsensitive failed to fold U+212A KELVIN SIGN to 'k'")
}
if !containsCaseInsensitive("\u017fet-cookie", "set-cookie") {
t.Fatal("containsCaseInsensitive failed to fold U+017F LONG S to 's'")
}
for _, tc := range tests {
if tc.leaked == "" {
continue
}
t.Run(tc.name, func(t *testing.T) {
got := RedactString(tc.in, Options{})
if strings.Contains(got, tc.leaked) {
t.Fatalf("leaked %q in %q", tc.leaked, got)
}
if !strings.Contains(got, RedactedSecret) {
t.Fatalf("expected %q in %q", RedactedSecret, got)
}
})
}
}
+2 -4
View File
@@ -90,9 +90,7 @@ func TestScanBuildsDeterministicSnapshot(t *testing.T) {
func TestScanDoesNotFollowSymlinkedDirectories(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "main.go", "package main\n")
// Not named "target": outside a git work tree Scan skips that name as Cargo build
// output, and the real directory must be scanned for this test to exercise the link.
target := filepath.Join(root, "real-dir")
target := filepath.Join(root, "target")
writeFile(t, target, "hidden.go", "package hidden\n")
link := filepath.Join(root, "linked")
if err := os.Symlink(target, link); err != nil {
@@ -103,7 +101,7 @@ func TestScanDoesNotFollowSymlinkedDirectories(t *testing.T) {
if err != nil {
t.Fatalf("Scan: %v", err)
}
want := []string{"main.go", "real-dir/hidden.go"}
want := []string{"main.go", "target/hidden.go"}
if !reflect.DeepEqual(pathsOf(got.Files), want) {
t.Fatalf("Files=%v want %v", pathsOf(got.Files), want)
}
-1
View File
@@ -261,7 +261,6 @@ func redactMetadata(session sessions.Metadata, options redaction.Options) sessio
session.Title = redaction.RedactString(session.Title, options)
session.Cwd = redaction.RedactString(session.Cwd, options)
session.ModelID = redaction.RedactString(session.ModelID, options)
session.PermissionMode = redaction.RedactString(session.PermissionMode, options)
session.Provider = redaction.RedactString(session.Provider, options)
session.Tag = redaction.RedactString(session.Tag, options)
session.ParentSessionID = redaction.RedactString(session.ParentSessionID, options)
-102
View File
@@ -1,102 +0,0 @@
package sessions
import (
"bufio"
"fmt"
"io"
"os"
"os/exec"
"runtime"
"strings"
"testing"
"time"
)
// TestSessionFileLockBlocksAnotherProcessUntilItExits proves the OS session lock
// (flock on unix, LockFileEx on Windows) serializes mutations across processes,
// not just across Store instances in one process, and that it is released when
// the holder exits without unlocking.
func TestSessionFileLockBlocksAnotherProcessUntilItExits(t *testing.T) {
root := t.TempDir()
store := NewStore(StoreOptions{RootDir: root})
if _, err := store.Create(CreateInput{SessionID: "s"}); err != nil {
t.Fatal(err)
}
cmd := exec.Command(os.Args[0], "-test.run=^TestSessionFileLockHelperProcess$")
cmd.Env = append(os.Environ(), "ZERO_SESSIONS_LOCK_HELPER=1", "ZERO_SESSIONS_LOCK_ROOT="+root)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
stdin, err := cmd.StdinPipe()
if err != nil {
t.Fatal(err)
}
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
exited := false
t.Cleanup(func() {
if !exited {
_ = cmd.Process.Kill()
_ = cmd.Wait()
}
})
ready, err := bufio.NewReader(stdout).ReadString('\n')
if err != nil || strings.TrimSpace(ready) != "ready" {
t.Fatalf("helper readiness = %q, err %v", ready, err)
}
// The helper process holds the session lock, so this must block.
done := make(chan error, 1)
go func() {
_, appendErr := store.AppendEvent("s", AppendEventInput{Type: EventMessage, Payload: map[string]any{}})
done <- appendErr
}()
select {
case err := <-done:
t.Fatalf("AppendEvent completed while another process held the session lock (err=%v)", err)
case <-time.After(300 * time.Millisecond):
// Expected: still blocked.
}
// The helper exits without unlocking; the OS must release the lock for us.
if err := stdin.Close(); err != nil {
t.Fatal(err)
}
if err := cmd.Wait(); err != nil {
t.Fatalf("helper exit: %v", err)
}
exited = true
select {
case err := <-done:
if err != nil {
t.Fatalf("AppendEvent after the holder exited: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("AppendEvent did not proceed after the lock holder exited")
}
}
func TestSessionFileLockHelperProcess(t *testing.T) {
if os.Getenv("ZERO_SESSIONS_LOCK_HELPER") != "1" {
return
}
store := NewStore(StoreOptions{RootDir: os.Getenv("ZERO_SESSIONS_LOCK_ROOT")})
unlock, err := store.lockSession("s")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if _, err := fmt.Fprintln(os.Stdout, "ready"); err != nil {
os.Exit(3)
}
_, _ = io.Copy(io.Discard, os.Stdin)
// The unlock closure is the only reference to the open lock file/handle.
// Keep it alive until stdin closes, or GC finalization of the file could
// release the OS lock while this helper is meant to be holding it.
runtime.KeepAlive(unlock) // deliberately exit without calling unlock
}
+16 -73
View File
@@ -143,15 +143,21 @@ func (store *Store) hold(sessionID string) (busy bool) {
if _, held := store.leases[sessionID]; held {
return false
}
path := store.leasePath(sessionID)
held, busy := takeProcessLease(path)
if !held {
return busy
// Creates the lease file, never the directory: a session that is gone stays
// gone.
file, err := openLeaseFile(store.leasePath(sessionID))
if err != nil {
return false
}
locked, err := tryLockLease(file, false)
if err != nil || !locked {
_ = file.Close()
return err == nil
}
if store.leases == nil {
store.leases = map[string]string{}
store.leases = map[string]*os.File{}
}
store.leases[sessionID] = path
store.leases[sessionID] = file
return false
}
@@ -159,80 +165,17 @@ func (store *Store) hold(sessionID string) (busy bool) {
// yet: a process holds every session it has touched until it exits, which
// releases every lease with it, and tests use Release to stand in for that
// exit. A long-lived process that switches sessions, like the TUI on /new, could
// call it for the session it leaves. The lease itself goes once no other Store
// in the process holds it either.
// call it for the session it leaves.
func (store *Store) Release(sessionID string) {
store.leasesMu.Lock()
defer store.leasesMu.Unlock()
path, held := store.leases[sessionID]
file, held := store.leases[sessionID]
if !held {
return
}
delete(store.leases, sessionID)
giveBackProcessLease(path)
}
// processLeases are the lease files this process holds, by path.
//
// A LEASE IS THE PROCESS'S, NOT THE STORE'S. A Store can go out of scope while
// the process goes on using what it holds: PrepareExec opens one for an exec run,
// takes the calling or forked session's lease on it, and the run never touches
// that Store again. Had the file stayed with the Store, the garbage collector
// would close it once the Store was unreachable, which drops the lock, and prune
// could remove a session the run still uses. So every Store holding a session
// shares one file here, and the file is closed only when the last of them
// releases it.
var processLeases = struct {
sync.Mutex
files map[string]*processLease
}{files: map[string]*processLease{}}
type processLease struct {
file *os.File
holders int
}
// takeProcessLease adds a holder to the process's lease on path, taking the lease
// first when nothing in the process holds it. It reports busy when the lease
// could not be taken because prune holds it exclusively right now.
func takeProcessLease(path string) (held bool, busy bool) {
processLeases.Lock()
defer processLeases.Unlock()
if lease := processLeases.files[path]; lease != nil {
lease.holders++
return true, false
}
// Creates the lease file, never the directory: a session that is gone stays
// gone.
file, err := openLeaseFile(path)
if err != nil {
return false, false
}
locked, err := tryLockLease(file, false)
if err != nil || !locked {
_ = file.Close()
return false, err == nil
}
processLeases.files[path] = &processLease{file: file, holders: 1}
return true, false
}
// giveBackProcessLease removes a holder from the process's lease on path, and
// unlocks and closes the file once no holder is left.
func giveBackProcessLease(path string) {
processLeases.Lock()
defer processLeases.Unlock()
lease := processLeases.files[path]
if lease == nil {
return
}
lease.holders--
if lease.holders > 0 {
return
}
delete(processLeases.files, path)
unlockLease(lease.file)
_ = lease.file.Close()
unlockLease(file)
_ = file.Close()
}
// HoldExclusive takes sessionID's lease exclusively without waiting, which is how
-97
View File
@@ -1,97 +0,0 @@
package sessions
import (
"runtime"
"testing"
"time"
)
// collectGarbage runs the collector and gives finalizers time to run, so a file
// left only to a Store that is gone would be closed by the time it returns.
func collectGarbage() {
for i := 0; i < 5; i++ {
runtime.GC()
time.Sleep(20 * time.Millisecond)
}
}
// A lease lasts for the life of the process, not of the Store that took it.
// PrepareExec takes the calling or forked session's lease on a Store the exec
// run never touches again, so a lease that went with the Store would be dropped
// once the garbage collector closed its file, and prune could remove a session
// the run still uses.
func TestLeaseOutlivesTheStoreThatTookIt(t *testing.T) {
for _, take := range []struct {
name string
do func(store *Store) error
}{
{"create with a parent", func(store *Store) error {
_, err := store.Create(CreateInput{SessionID: "new", ParentSessionID: "parent"})
return err
}},
{"exec calling session", func(store *Store) error {
_, err := PrepareExec(PrepareExecOptions{Store: store, SessionID: "new", CallingSessionID: "parent"})
return err
}},
{"hold", func(store *Store) error {
store.Hold("parent")
return nil
}},
} {
t.Run(take.name, func(t *testing.T) {
root := t.TempDir()
createFinishedSession(t, root, "parent", "2026-06-01T00:00:00Z", "")
// The Store is unreachable once this returns, as PrepareExec's is in
// an exec run.
func() {
store := NewStore(StoreOptions{RootDir: root, Now: fixedClock("2026-06-01T00:00:00Z")})
if err := take.do(store); err != nil {
t.Fatalf("%s: %v", take.name, err)
}
}()
collectGarbage()
report, err := pruneStore(root).Prune(PruneOptions{OlderThan: thirtyDays, DryRun: true})
if err != nil {
t.Fatalf("dry run: %v", err)
}
if reason := keptReason(report, "parent"); reason != PruneKeptOpen {
t.Fatalf("parent kept for %q once the Store that took its lease was collected, want %q", reason, PruneKeptOpen)
}
})
}
}
// Stores in one process share the lease on a session, and it lasts until the
// last of them releases it.
func TestLeaseHeldByTwoStoresLastsUntilBothRelease(t *testing.T) {
root := t.TempDir()
createFinishedSession(t, root, "shared", "2026-06-01T00:00:00Z", "")
first := NewStore(StoreOptions{RootDir: root})
second := NewStore(StoreOptions{RootDir: root})
first.Hold("shared")
second.Hold("shared")
// Releasing twice from one Store gives up only that Store's hold.
first.Release("shared")
first.Release("shared")
if release, locked, err := pruneStore(root).HoldExclusive("shared"); err != nil || locked {
release()
t.Fatalf("prune took a session another Store still holds: locked=%v, %v", locked, err)
}
second.Release("shared")
release, locked, err := pruneStore(root).HoldExclusive("shared")
if err != nil || !locked {
t.Fatalf("prune could not take the session after both Stores released it: locked=%v, %v", locked, err)
}
release()
// Held again after the last release, the lease is taken afresh.
first.Hold("shared")
if release, locked, err := pruneStore(root).HoldExclusive("shared"); err != nil || locked {
release()
t.Fatalf("prune took a session held again after it was released: locked=%v, %v", locked, err)
}
first.Release("shared")
}
+3
View File
@@ -353,6 +353,9 @@ func TestCaptureAndPruneConcurrent(t *testing.T) {
// separate Store instances on the same RootDir (e.g. CLI rewind vs TUI). While
// one Store holds the session lock, another Store's AppendEvent must block.
func TestSessionFileLockSerializesAcrossStores(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("flock semantics differ on windows")
}
root := t.TempDir()
storeA := NewStore(StoreOptions{RootDir: root})
if _, err := storeA.Create(CreateInput{SessionID: "s"}); err != nil {
-35
View File
@@ -129,38 +129,3 @@ func TestUpdateModel(t *testing.T) {
t.Fatalf("persisted model: metadata=%+v err=%v", persisted, err)
}
}
func TestUpdateMode(t *testing.T) {
store := newTitleTestStore(t)
session, err := store.Create(CreateInput{})
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := store.AppendEvent(session.SessionID, AppendEventInput{Type: EventMessage, Payload: map[string]any{"role": "user"}}); err != nil {
t.Fatalf("append: %v", err)
}
before, err := store.Get(session.SessionID)
if err != nil || before == nil {
t.Fatalf("get before: %v", err)
}
updated, err := store.UpdateMode(session.SessionID, " plan ")
if err != nil {
t.Fatalf("update mode: %v", err)
}
if updated.PermissionMode != "plan" {
t.Fatalf("mode = %q, want plan", updated.PermissionMode)
}
if updated.UpdatedAt != before.UpdatedAt || updated.EventCount != before.EventCount {
t.Fatalf("mode update changed activity metadata: before=%+v after=%+v", before, updated)
}
persisted, err := store.Get(session.SessionID)
if err != nil || persisted == nil || persisted.PermissionMode != "plan" {
t.Fatalf("persisted mode: metadata=%+v err=%v", persisted, err)
}
cleared, err := store.UpdateMode(session.SessionID, "")
if err != nil || cleared.PermissionMode != "" {
t.Fatalf("clearing mode: metadata=%+v err=%v", cleared, err)
}
}
+2 -33
View File
@@ -97,7 +97,6 @@ type Metadata struct {
Title string `json:"title,omitempty"`
Cwd string `json:"cwd,omitempty"`
ModelID string `json:"modelId,omitempty"`
PermissionMode string `json:"permissionMode,omitempty"`
Provider string `json:"provider,omitempty"`
Tag string `json:"tag,omitempty"`
Depth int `json:"depth,omitempty"`
@@ -235,10 +234,9 @@ type Store struct {
sessionLocks map[string]*sync.Mutex
idCounter atomic.Uint64
// leases are the sessions this Store holds open, with the path of each
// lease file. The files themselves belong to the process. See Hold.
// leases are the sessions this Store holds open. See Hold.
leasesMu sync.Mutex
leases map[string]string
leases map[string]*os.File
}
var sessionIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$`)
@@ -830,35 +828,6 @@ func (store *Store) UpdateModel(sessionID string, modelID string) (Metadata, err
return session, nil
}
// UpdateMode replaces a session's permission mode without changing its activity
// timestamp or event counters. An empty mode clears the session override; the
// stored value is not validated here because the reader that turns it back into
// a live mode is the boundary that decides what a stored string may become.
func (store *Store) UpdateMode(sessionID string, mode string) (Metadata, error) {
if !ValidSessionID(sessionID) {
return Metadata{}, fmt.Errorf("invalid zero session id %q", sessionID)
}
mode = strings.TrimSpace(mode)
unlock, err := store.lockSession(sessionID)
if err != nil {
return Metadata{}, err
}
defer unlock()
session, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, err
}
if session.PermissionMode == mode {
return session, nil
}
session.PermissionMode = mode
if err := store.writeMetadata(session); err != nil {
return Metadata{}, err
}
return session, nil
}
func (store *Store) ReadEvents(sessionID string) ([]Event, error) {
events, _, err := store.ReadEventsWithPresence(sessionID)
return events, err
-96
View File
@@ -1,96 +0,0 @@
package testutil
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// setenv is os.Setenv, swappable so tests can fail a redirect part way through.
var setenv = os.Setenv
// userDirEnv lists every variable that os.UserHomeDir, os.UserConfigDir and
// os.UserCacheDir (and Zero's own config.UserConfigDir) read, across Linux,
// macOS and Windows. Setting only the XDG ones still leaves macOS resolving
// from HOME and Windows from USERPROFILE, APPDATA and LOCALAPPDATA.
var userDirEnv = []string{
"HOME",
"USERPROFILE",
"APPDATA",
"LOCALAPPDATA",
"XDG_CONFIG_HOME",
"XDG_CACHE_HOME",
"XDG_DATA_HOME",
"XDG_STATE_HOME",
}
// IsolateUserDirs points every per-user directory root at root so code under
// test cannot read the developer's real config, cache or home directory, or
// write to them. It is meant for a package's TestMain, which has no *testing.T
// (tests that need their own layout still use t.Setenv, which wins while it
// is active). The returned function restores the previous environment.
//
// It fails closed: if any variable cannot be redirected, the variables it had
// already changed are restored and an error is returned, so the caller must
// not run the tests against a partially isolated environment.
func IsolateUserDirs(root string) (restore func() error, err error) {
type saved struct {
name string
value string
set bool
}
previous := make([]saved, 0, len(userDirEnv))
restore = func() error {
var errs []error
for _, prior := range previous {
var err error
if prior.set {
err = os.Setenv(prior.name, prior.value)
} else {
err = os.Unsetenv(prior.name)
}
if err != nil {
errs = append(errs, fmt.Errorf("restore %s: %w", prior.name, err))
}
}
return errors.Join(errs...)
}
for _, name := range userDirEnv {
value, set := os.LookupEnv(name)
if err := setenv(name, root); err != nil {
setErr := fmt.Errorf("isolate %s: %w", name, err)
return nil, errors.Join(setErr, restore())
}
previous = append(previous, saved{name: name, value: value, set: set})
}
return restore, nil
}
// PathWithin reports whether path is root or lies beneath it, comparing the
// physical forms so symlinked temp dirs (/var vs /private/var on macOS) and
// Windows short names do not cause false results. Components that do not
// exist yet are kept as spelled under their nearest existing ancestor.
func PathWithin(root, path string) bool {
rel, err := filepath.Rel(physicalPath(root), physicalPath(path))
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
func physicalPath(path string) string {
if abs, err := filepath.Abs(path); err == nil {
path = abs
}
var missing []string
for current := path; ; {
if resolved, err := filepath.EvalSymlinks(current); err == nil {
return filepath.Join(append([]string{resolved}, missing...)...)
}
parent := filepath.Dir(current)
if parent == current {
return path
}
missing = append([]string{filepath.Base(current)}, missing...)
current = parent
}
}
-141
View File
@@ -1,141 +0,0 @@
package testutil
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestIsolateUserDirsRedirectsAndRestores(t *testing.T) {
// Seed distinctive values so restoring is observable, then make sure the
// helper hands them back (set ones restored, unset ones unset again).
t.Setenv("HOME", "before-home")
t.Setenv("XDG_STATE_HOME", "before-state")
// t.Setenv registers restoration of the inherited value before it is
// cleared, so later tests in this process still see it.
t.Setenv("XDG_DATA_HOME", "")
os.Unsetenv("XDG_DATA_HOME")
root := t.TempDir()
restore, err := IsolateUserDirs(root)
if err != nil {
t.Fatal(err)
}
home, err := os.UserHomeDir()
if err != nil {
t.Fatal(err)
}
config, err := os.UserConfigDir()
if err != nil {
t.Fatal(err)
}
cache, err := os.UserCacheDir()
if err != nil {
t.Fatal(err)
}
for name, dir := range map[string]string{"home": home, "config": config, "cache": cache} {
if !PathWithin(root, dir) {
t.Errorf("%s dir %q is outside the isolated root %q", name, dir, root)
}
}
if err := restore(); err != nil {
t.Fatal(err)
}
if got := os.Getenv("HOME"); got != "before-home" {
t.Errorf("HOME after restore = %q, want before-home", got)
}
if got := os.Getenv("XDG_STATE_HOME"); got != "before-state" {
t.Errorf("XDG_STATE_HOME after restore = %q, want before-state", got)
}
if _, set := os.LookupEnv("XDG_DATA_HOME"); set {
t.Error("XDG_DATA_HOME was unset before and must be unset again after restore")
}
}
func TestIsolateUserDirsFailsClosed(t *testing.T) {
for _, name := range userDirEnv {
t.Setenv(name, "before-"+strings.ToLower(name))
}
// A NUL byte is rejected by Setenv on every supported platform, so the
// very first redirect fails.
restore, err := IsolateUserDirs("bad\x00root")
if err == nil {
if restore != nil {
_ = restore()
}
t.Fatal("IsolateUserDirs succeeded with a root Setenv must reject")
}
if restore != nil {
t.Error("IsolateUserDirs returned a restore func alongside an error")
}
for _, name := range userDirEnv {
if got, want := os.Getenv(name), "before-"+strings.ToLower(name); got != want {
t.Errorf("%s after failed isolation = %q, want %q", name, got, want)
}
}
}
func TestIsolateUserDirsRollsBackPartialRedirect(t *testing.T) {
for _, name := range userDirEnv {
t.Setenv(name, "before-"+strings.ToLower(name))
}
// Let the first redirect (HOME) succeed, then fail the second, so the
// rollback has to undo a variable that really was changed.
failing := userDirEnv[1]
t.Cleanup(func() { setenv = os.Setenv })
setenv = func(name, value string) error {
if name == failing {
return errors.New("injected setenv failure")
}
return os.Setenv(name, value)
}
restore, err := IsolateUserDirs(t.TempDir())
if err == nil {
if restore != nil {
_ = restore()
}
t.Fatalf("IsolateUserDirs succeeded although setting %s failed", failing)
}
if !strings.Contains(err.Error(), failing) {
t.Errorf("error %q does not name the failing variable %s", err, failing)
}
for _, name := range userDirEnv {
if got, want := os.Getenv(name), "before-"+strings.ToLower(name); got != want {
t.Errorf("%s after partial isolation = %q, want %q", name, got, want)
}
}
}
func TestPathWithinComparesPhysicalPaths(t *testing.T) {
base := t.TempDir()
root := filepath.Join(base, "root")
if err := os.Mkdir(root, 0o755); err != nil {
t.Fatal(err)
}
alias := filepath.Join(base, "alias")
if err := os.Symlink(root, alias); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
for _, tc := range []struct {
root, path string
want bool
}{
{root, root, true},
{root, filepath.Join(alias, "not", "created", "yet"), true},
{alias, filepath.Join(root, "child"), true},
{root, base, false},
{alias, filepath.Join(base, "sibling"), false},
} {
if got := PathWithin(tc.root, tc.path); got != tc.want {
t.Errorf("PathWithin(%q, %q) = %v, want %v", tc.root, tc.path, got, tc.want)
}
}
}
+5 -19
View File
@@ -373,34 +373,20 @@ func TestExecCommandForegroundServerReturnsSessionAndServesHTTP(t *testing.T) {
if err != nil {
t.Fatalf("foreground server should return session_id, meta=%#v output=%q", start.Meta, start.Output)
}
addr := parseListeningAddress(start.Output)
if addr == "" {
t.Fatalf("server output did not include listening address: %q", start.Output)
}
t.Cleanup(func() {
writeTool.Run(context.Background(), map[string]any{
"session_id": sessionID,
"chars": "\u0003",
})
})
// Process start-up and net.Listen can outlast the first yield on a loaded
// runner, so keep polling the session until the address shows up.
output := start.Output
addr := parseListeningAddress(output)
for deadline := time.Now().Add(20 * time.Second); addr == "" && time.Now().Before(deadline); {
poll := writeTool.Run(context.Background(), map[string]any{
"session_id": sessionID,
"yield_time_ms": 250,
})
if poll.Status != StatusOK {
t.Fatalf("write_stdin poll status = %s: %s", poll.Status, poll.Output)
}
output += "\n" + poll.Output
addr = parseListeningAddress(output)
}
if addr == "" {
t.Fatalf("server output did not include listening address: %q", output)
}
response, err := http.Get("http://" + addr)
if err != nil {
t.Fatalf("foreground exec server was not reachable at %s: %v; output=%q", addr, err, output)
t.Fatalf("foreground exec server was not reachable at %s: %v; output=%q", addr, err, start.Output)
}
defer response.Body.Close()
bytes, err := io.ReadAll(response.Body)
+2 -4
View File
@@ -54,9 +54,7 @@ func gitSweepCmd(parent context.Context, cwd string, baseline bool) tea.Cmd {
defer cancel()
// --untracked-files=all enumerates files inside new directories; plain
// porcelain collapses them to "dir/", which is useless as a FILES row.
// --ignore-submodules=dirty keeps git out of submodule work trees, whose
// own config gitsafe does not check.
status, err := defaultPRCommandRunner(ctx, cwd, "git", "status", "--porcelain", "--untracked-files=all", "--ignore-submodules=dirty")
status, err := defaultPRCommandRunner(ctx, cwd, "git", "status", "--porcelain", "--untracked-files=all")
if err != nil {
return gitSweepMsg{baseline: baseline, ok: false}
}
@@ -68,7 +66,7 @@ func gitSweepCmd(parent context.Context, cwd string, baseline bool) tea.Cmd {
// would never match a porcelain path). Best-effort: a failure (e.g. unborn
// HEAD in a fresh repo) keeps the file list with zero counts rather than
// dropping the sweep.
if numstat, err := defaultPRCommandRunner(ctx, cwd, "git", "diff", "HEAD", "--numstat", "-z", "--ignore-submodules=dirty"); err == nil {
if numstat, err := defaultPRCommandRunner(ctx, cwd, "git", "diff", "HEAD", "--numstat", "-z"); err == nil {
stats := parseGitNumstat(numstat)
for i := range files {
if counts, ok := stats[files[i].path]; ok {
-63
View File
@@ -1,63 +0,0 @@
package tui
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/testutil"
)
// Guards TestMain: the per-user directories every newModel reads (and the ones
// tests could write to) must resolve inside the isolated root on every OS.
func TestUserDirsAreIsolatedFromTheDeveloperHome(t *testing.T) {
if isolatedUserDirsRoot == "" {
t.Fatal("TestMain did not isolate the user directories")
}
home, err := os.UserHomeDir()
if err != nil {
t.Fatal(err)
}
userConfig, err := config.UserConfigDir()
if err != nil {
t.Fatal(err)
}
cache, err := os.UserCacheDir()
if err != nil {
t.Fatal(err)
}
for name, dir := range map[string]string{"home": home, "config": userConfig, "cache": cache} {
if !testutil.PathWithin(isolatedUserDirsRoot, dir) {
t.Errorf("%s dir %q is outside the isolated root %q", name, dir, isolatedUserDirsRoot)
}
}
}
// newModel loads slash commands from <user config>/zero/commands. A fresh model
// must see none, and must see exactly what is planted in the isolated dir.
func TestNewModelLoadsUserCommandsOnlyFromIsolatedConfig(t *testing.T) {
if got := len(newModel(context.Background(), Options{Cwd: t.TempDir()}).userCommands); got != 0 {
t.Fatalf("fresh model loaded %d user command(s) from outside the test sandbox", got)
}
userConfig, err := config.UserConfigDir()
if err != nil {
t.Fatal(err)
}
dir := filepath.Join(userConfig, "zero", "commands")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "hermetic-probe.md")
if err := os.WriteFile(path, []byte("probe"), 0o644); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Remove(path) })
commands := newModel(context.Background(), Options{Cwd: t.TempDir()}).userCommands
if len(commands) != 1 || commands[0].Name != "hermetic-probe" {
t.Fatalf("user commands = %#v, want only the planted hermetic-probe", commands)
}
}
-40
View File
@@ -1,40 +0,0 @@
package tui
import (
"fmt"
"os"
"testing"
"github.com/Gitlawb/zero/internal/testutil"
)
// isolatedUserDirsRoot is where TestMain points every per-user directory.
var isolatedUserDirsRoot string
// TestMain keeps the whole package off the developer's real home, config and
// cache directories. newModel loads the user's slash commands from the config
// dir, so without this every test sees (and could write to) real user state.
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "zero-tui-test-home-")
if err != nil {
fmt.Fprintln(os.Stderr, "tui tests: create isolated user dirs:", err)
os.Exit(1)
}
isolatedUserDirsRoot = root
restore, err := testutil.IsolateUserDirs(root)
if err != nil {
fmt.Fprintln(os.Stderr, "tui tests: isolate user dirs:", err)
_ = os.RemoveAll(root)
os.Exit(1)
}
code := m.Run()
if err := restore(); err != nil {
fmt.Fprintln(os.Stderr, "tui tests: restore user dirs:", err)
code = 1
}
if err := os.RemoveAll(root); err != nil {
fmt.Fprintln(os.Stderr, "tui tests: remove isolated user dirs:", err)
code = 1
}
os.Exit(code)
}
+6 -14
View File
@@ -860,11 +860,6 @@ type permissionRequestMsg struct {
type pendingPermissionPrompt struct {
request agent.PermissionRequest
decide func(agent.PermissionDecision)
// decideCmd is the Update-safe alternative to decide for prompts the TUI
// itself raises: decide forwards through runtimeMessageSink, which blocks on
// the program's unbuffered message channel when called from Update, so these
// prompts return a command that yields the follow-up message instead.
decideCmd func(agent.PermissionDecision) tea.Cmd
// cursor is the highlighted option index (into permissionOptions): 0 is the
// resting approval choice. Moved by ↑/↓/Tab; confirmed by Enter or a click.
// Hotkeys resolve the matching request-provided option directly.
@@ -4526,13 +4521,11 @@ func (m model) resolvePermissionWithReason(decision permissionDecision, reason s
return m, nil
}
resolved := agent.PermissionDecision{Action: decision, Reason: reason}
if pending.decide != nil {
pending.decide(resolved)
}
var decideCmd tea.Cmd
if pending.decideCmd != nil {
decideCmd = pending.decideCmd(resolved)
pending.decide(agent.PermissionDecision{
Action: decision,
Reason: reason,
})
}
m.pendingPermission = nil
// Time spent at the prompt is user wait, not provider silence. Restart the
@@ -4542,10 +4535,9 @@ func (m model) resolvePermissionWithReason(decision permissionDecision, reason s
if pending.request.ToolName == peerPermissionToolName {
// Receipt delivery completes asynchronously. That completion advances
// the peer queue after this prompt is fully settled.
return m, decideCmd
return m, nil
}
next, cmd := m.openNextPeerApproval()
return next, tea.Batch(decideCmd, cmd)
return m.openNextPeerApproval()
}
func permissionDecisionReason(decision permissionDecision) string {
+4 -5
View File
@@ -119,11 +119,10 @@ func (m model) openNextPeerApproval() (model, tea.Cmd) {
}
m.pendingPermission = &pendingPermissionPrompt{
request: request,
// Resolved from Update, so the decision must come back as a command:
// runtimeMessageSink blocks on the event loop that is running Update.
decideCmd: func(decision agent.PermissionDecision) tea.Cmd {
decided := peerDecisionMsg{message: message, allow: decision.Action == agent.PermissionDecisionAllow}
return func() tea.Msg { return decided }
decide: func(decision agent.PermissionDecision) {
if m.runtimeMessageSink != nil {
m.runtimeMessageSink(peerDecisionMsg{message: message, allow: decision.Action == agent.PermissionDecisionAllow})
}
},
}
return m, nil
+12 -73
View File
@@ -5,7 +5,6 @@ import (
"encoding/json"
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
@@ -126,82 +125,19 @@ func TestPermissionMismatchHoldsPeerMessageForExplicitDecision(t *testing.T) {
}
}
approvedModel, approveCmd := next.resolvePermission(permissionDecisionAllow)
approvedModel, _ := next.resolvePermission(permissionDecisionAllow)
approved := approvedModel.(model)
if approved.pendingPermission != nil {
t.Fatal("approval prompt did not close")
}
decision, ok := peerDecisionFromCmd(approveCmd)
if !ok || !decision.allow || decision.message.ID != "held-1" {
t.Fatalf("approval did not return a peer decision command: %#v", decision)
}
select {
case raw := <-messages:
t.Fatalf("approval sent %#v through the runtime sink from Update", raw)
default:
}
}
// peerDecisionFromCmd runs cmd (flattening tea.Batch) and returns the first
// peerDecisionMsg it produces.
func peerDecisionFromCmd(cmd tea.Cmd) (peerDecisionMsg, bool) {
if cmd == nil {
return peerDecisionMsg{}, false
}
switch msg := cmd().(type) {
case peerDecisionMsg:
return msg, true
case tea.BatchMsg:
for _, inner := range msg {
if decision, ok := peerDecisionFromCmd(inner); ok {
return decision, true
}
decision, ok := raw.(peerDecisionMsg)
if !ok || !decision.allow || decision.message.ID != "held-1" {
t.Fatalf("decision = %#v", raw)
}
}
return peerDecisionMsg{}, false
}
// The runtime sink forwards to program.Send, which blocks on the unbuffered
// message channel the event loop reads between Update calls. Resolving a held
// peer prompt must therefore never call the sink from Update.
func TestPeerApprovalDecisionDoesNotCallBlockingSinkFromUpdate(t *testing.T) {
for _, tc := range []struct {
name string
decision permissionDecision
allow bool
}{
{"allow", permissionDecisionAllow, true},
{"deny", permissionDecisionDeny, false},
} {
t.Run(tc.name, func(t *testing.T) {
release := make(chan struct{})
t.Cleanup(func() { close(release) })
m := newModel(context.Background(), Options{
PermissionMode: agent.PermissionModeAsk,
RuntimeMessageSink: func(tea.Msg) { <-release },
})
m, _ = m.handlePeerMessage(peermsg.InboundMessage{
ID: "held-blocking", From: peermsg.Peer{Ref: "11223344"}, Body: "hold me", RequiresApproval: true,
})
type result struct {
cmd tea.Cmd
}
done := make(chan result, 1)
go func() {
_, cmd := m.resolvePermission(tc.decision)
done <- result{cmd: cmd}
}()
select {
case res := <-done:
decision, ok := peerDecisionFromCmd(res.cmd)
if !ok || decision.allow != tc.allow || decision.message.ID != "held-blocking" {
t.Fatalf("decision = %#v ok=%v", decision, ok)
}
case <-time.After(5 * time.Second):
t.Fatal("resolving a held peer prompt blocked on the runtime sink")
}
})
default:
t.Fatal("approval did not enqueue a peer decision")
}
}
@@ -234,7 +170,7 @@ func TestPeerApprovalDecisionWaitsForCompletionBeforeOpeningNext(t *testing.T) {
second := peermsg.InboundMessage{ID: "second", From: peermsg.Peer{Ref: "22222222"}, Body: "second", RequiresApproval: true}
m, _ = m.handlePeerMessage(first)
m, _ = m.handlePeerMessage(second)
resolvedModel, resolveCmd := m.resolvePermission(permissionDecisionDeny)
resolvedModel, _ := m.resolvePermission(permissionDecisionDeny)
resolved := resolvedModel.(model)
if resolved.pendingPermission != nil {
t.Fatal("next approval opened before peer decision completed")
@@ -242,8 +178,11 @@ func TestPeerApprovalDecisionWaitsForCompletionBeforeOpeningNext(t *testing.T) {
if len(resolved.peerApprovalQueue) != 1 {
t.Fatalf("queue = %#v", resolved.peerApprovalQueue)
}
decision, ok := peerDecisionFromCmd(resolveCmd)
if !ok {
var decision peerDecisionMsg
select {
case raw := <-messages:
decision = raw.(peerDecisionMsg)
default:
t.Fatal("peer decision was not emitted")
}
next, _ := resolved.handlePeerDecision(decision.message, decision.allow)
+4 -18
View File
@@ -13,8 +13,6 @@ import (
"time"
"charm.land/lipgloss/v2"
"github.com/Gitlawb/zero/internal/gitsafe"
)
type PrStatus string
@@ -245,7 +243,7 @@ func getLocalDiffStats(ctx context.Context, cwd string, baseBranch string, run p
if err != nil {
mergeBase = originBase
}
output, err := run(ctx, cwd, "git", "diff", "--numstat", "--ignore-submodules=dirty", strings.TrimSpace(mergeBase))
output, err := run(ctx, cwd, "git", "diff", "--numstat", strings.TrimSpace(mergeBase))
if err != nil {
return 0, 0, err
}
@@ -292,21 +290,9 @@ func jsonScalarString(raw json.RawMessage) string {
}
func defaultPRCommandRunner(ctx context.Context, cwd string, name string, args ...string) (string, error) {
var command *exec.Cmd
if name == "git" {
// Every git call the TUI makes on its own comes through here (the files
// sweep and the pull request diffstat), so the repository being shown
// cannot make them run a program.
safe, err := gitsafe.Command(ctx, cwd, args...)
if err != nil {
return "", fmt.Errorf("%s: %w", name, err)
}
command = safe
} else {
command = exec.CommandContext(ctx, name, args...)
if strings.TrimSpace(cwd) != "" {
command.Dir = cwd
}
command := exec.CommandContext(ctx, name, args...)
if strings.TrimSpace(cwd) != "" {
command.Dir = cwd
}
var stdout bytes.Buffer
var stderr bytes.Buffer
+3 -3
View File
@@ -15,7 +15,7 @@ func TestPrServiceDetectsGitHubPRAndDiffStats(t *testing.T) {
"git merge-base origin/main HEAD": {
output: "abc123\n",
},
"git diff --numstat --ignore-submodules=dirty abc123": {
"git diff --numstat abc123": {
output: "42\t7\tinternal/tui/view.go\n",
},
}}
@@ -49,7 +49,7 @@ func TestPrServiceFallsBackToGitLabMR(t *testing.T) {
"git merge-base origin/develop HEAD": {
output: "def456\n",
},
"git diff --numstat --ignore-submodules=dirty def456": {
"git diff --numstat def456": {
output: "4\t5\ta.go\n5\t6\tb.go\n",
},
}}
@@ -71,7 +71,7 @@ func TestGetLocalDiffStatsFallsBackToOriginBase(t *testing.T) {
"git merge-base origin/main HEAD": {
err: errors.New("unknown revision"),
},
"git diff --numstat --ignore-submodules=dirty origin/main": {
"git diff --numstat origin/main": {
output: "3\t0\tREADME.md\n",
},
}}
+5 -12
View File
@@ -53,6 +53,11 @@ var (
// `npm install -g` for npm-managed installs, or by downloading, verifying,
// and atomically replacing the binary for standalone installs.
func Apply(ctx context.Context, options Options) (ApplyResult, error) {
checkResult, err := Check(ctx, options)
if err != nil {
return ApplyResult{}, err
}
executablePath, err := currentExecutable()
if err != nil {
return ApplyResult{}, fmt.Errorf("resolve current executable: %w", err)
@@ -61,18 +66,6 @@ func Apply(ctx context.Context, options Options) (ApplyResult, error) {
executablePath = resolved
}
method := DetectInstallMethod(executablePath)
if method == InstallMethodNpmLegacy {
// Nothing newer is ever published under the old name, so npm would
// reinstall this same build and the update would report success. Refused
// before the release check because the answer does not depend on it, and
// it has to reach someone who is offline too.
return ApplyResult{}, errors.New(legacyPackageSwitchMessage())
}
checkResult, err := Check(ctx, options)
if err != nil {
return ApplyResult{}, err
}
// Only the standalone path rewrites the binary in place, so only it needs the
// recovery state. npm and Homebrew hand the work to the package manager.
if method == InstallMethodStandalone {
-47
View File
@@ -433,50 +433,3 @@ func TestVerifyArchiveChecksumRejectsFilenameMismatch(t *testing.T) {
t.Fatalf("expected error to name both the referenced file and the expected one, got %q", err)
}
}
// An install of the old package name gets no further releases, so npm would
// reinstall the same build and Apply would report an update that did not
// happen. It must refuse with the switch steps in the one safe order, without
// waiting on a release check that cannot change the answer.
func TestApplyRefusesALegacyNpmInstallWithTheSwitchSteps(t *testing.T) {
dir := t.TempDir()
binary := filepath.Join(dir, "zero")
original := []byte("legacy binary")
if err := os.WriteFile(binary, original, 0o755); err != nil {
t.Fatal(err)
}
manifest := `{"name":"@gitlawb/zero","version":"0.10.0-linux-x64","os":["linux"],"cpu":["x64"]}`
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
restore := currentExecutable
currentExecutable = func() (string, error) { return binary, nil }
t.Cleanup(func() { currentExecutable = restore })
_, err := Apply(context.Background(), Options{
CurrentVersion: "0.10.0",
Fetch: func(context.Context, string) (Release, error) {
t.Error("Apply ran the release check for a legacy install; the refusal must not depend on the network")
return Release{}, errors.New("offline")
},
})
if err == nil {
t.Fatal("Apply did not refuse a legacy npm install")
}
message := err.Error()
uninstall := strings.Index(message, "npm uninstall -g @gitlawb/zero")
install := strings.Index(message, "npm install -g @twigpine/zero")
if uninstall < 0 || install < 0 {
t.Fatalf("refusal does not give both switch commands: %q", message)
}
if uninstall > install {
t.Fatalf("refusal installs before uninstalling, which npm rejects with EEXIST: %q", message)
}
after, readErr := os.ReadFile(binary)
if readErr != nil {
t.Fatal(readErr)
}
if string(after) != string(original) {
t.Error("Apply rewrote the binary it refused to update")
}
}
+18 -68
View File
@@ -9,28 +9,17 @@ import (
)
// npmPackageName is the published package name for the npm distribution of
// zero, and must equal the name in the repository's package.json
// (TestNpmPackageNameMatchesPackageJSON holds the two together).
// scripts/postinstall.mjs downloads the native binary into the same directory
// as package.json and leaves a ".zero-binary-version" marker file next to it —
// both are reliable signals that a given executable came from an npm install.
const npmPackageName = "@twigpine/zero"
// legacyNpmPackageName is the name zero was published under on npm up to
// 0.9.0. The 0.10.0 release was published once more under it, so that 0.9.0's
// `zero upgrade` and anyone still installing the old name land on a build that
// says where the package went (docs/NPM_PACKAGING.md). Nothing is published
// under it after that, which is why an install of it is told to switch instead
// of offered an update.
const legacyNpmPackageName = "@gitlawb/zero"
// zero (see package.json). scripts/postinstall.mjs downloads the native
// binary into the same directory as package.json and leaves a
// ".zero-binary-version" marker file next to it — both are reliable signals
// that a given executable came from an npm install.
const npmPackageName = "@gitlawb/zero"
// InstallMethod identifies how the running zero binary was installed.
type InstallMethod string
const (
InstallMethodNpm InstallMethod = "npm"
// InstallMethodNpmLegacy is an npm install of legacyNpmPackageName.
InstallMethodNpmLegacy InstallMethod = "npm-legacy"
InstallMethodNpm InstallMethod = "npm"
InstallMethodHomebrew InstallMethod = "homebrew"
InstallMethodStandalone InstallMethod = "standalone"
)
@@ -64,71 +53,32 @@ func DetectInstallMethod(executablePath string) InstallMethod {
return InstallMethodHomebrew
}
dir := filepath.Dir(executablePath)
pkg, hasManifest := readNpmManifest(dir)
if _, err := os.Stat(filepath.Join(dir, ".zero-binary-version")); err == nil {
// The first-run downloader put this binary next to the wrapper's own
// package.json, which names the package it was installed as.
if hasManifest && pkg.Name == legacyNpmPackageName {
return InstallMethodNpmLegacy
}
return InstallMethodNpm
}
if !hasManifest {
data, err := os.ReadFile(filepath.Join(dir, "package.json"))
if err != nil {
return InstallMethodStandalone
}
var pkg struct {
Name string `json:"name"`
OS []string `json:"os"`
CPU []string `json:"cpu"`
Bin json.RawMessage `json:"bin"`
}
if err := json.Unmarshal(data, &pkg); err != nil {
return InstallMethodStandalone
}
// The native platform package is inert and constrained to one OS/CPU. The
// repository's wrapper package has the same name but carries a bin entry and
// broad platform lists, so name alone would misclassify `go build -o zero`
// from the repository root as an npm-managed install.
if len(pkg.OS) != 1 || len(pkg.CPU) != 1 || len(pkg.Bin) != 0 {
return InstallMethodStandalone
}
switch pkg.Name {
case npmPackageName:
if pkg.Name == npmPackageName && len(pkg.OS) == 1 && len(pkg.CPU) == 1 && len(pkg.Bin) == 0 {
return InstallMethodNpm
case legacyNpmPackageName:
return InstallMethodNpmLegacy
}
return InstallMethodStandalone
}
type npmManifest struct {
Name string `json:"name"`
OS []string `json:"os"`
CPU []string `json:"cpu"`
Bin json.RawMessage `json:"bin"`
}
// readNpmManifest reads the package.json in dir, reporting false when there is
// none or it does not parse.
func readNpmManifest(dir string) (npmManifest, bool) {
data, err := os.ReadFile(filepath.Join(dir, "package.json"))
if err != nil {
return npmManifest{}, false
}
var pkg npmManifest
if err := json.Unmarshal(data, &pkg); err != nil {
return npmManifest{}, false
}
return pkg, true
}
// legacyPackageSwitchMessage tells someone running the build published under
// legacyNpmPackageName how to move to npmPackageName. bin/zero.js prints the
// same note.
//
// There is no in-place migration. 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. Uninstalling first
// is the only safe order, and it has to be the user's own step: the running
// binary is inside the package it would remove.
func legacyPackageSwitchMessage() string {
return "Zero has moved to " + npmPackageName + " on npm, and " + legacyNpmPackageName + " gets no further releases. To keep getting updates, quit zero and run:\n" +
" npm uninstall -g " + legacyNpmPackageName + "\n" +
" npm install -g " + npmPackageName + "\n" +
"Uninstall first: both packages provide the `zero` command. With bun, pnpm or yarn, use its global remove and add commands instead."
}
// isHomebrewPath reports whether executablePath is inside a Homebrew keg.
//
// goos is a parameter rather than runtime.GOOS so the decision can be tested on
+2 -76
View File
@@ -1,31 +1,11 @@
package update
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// The self-updater installs npmPackageName and recognises installs by it, so
// it must be the name the release actually publishes. A rename that misses
// this constant leaves `zero upgrade` reinstalling the old package.
func TestNpmPackageNameMatchesPackageJSON(t *testing.T) {
data, err := os.ReadFile(filepath.Join("..", "..", "package.json"))
if err != nil {
t.Fatalf("ReadFile package.json: %v", err)
}
var pkg struct {
Name string `json:"name"`
}
if err := json.Unmarshal(data, &pkg); err != nil {
t.Fatalf("Unmarshal package.json: %v", err)
}
if pkg.Name != npmPackageName {
t.Fatalf("package.json name = %q, npmPackageName = %q; they must match", pkg.Name, npmPackageName)
}
}
func TestDetectInstallMethodStandaloneByDefault(t *testing.T) {
dir := t.TempDir()
exePath := filepath.Join(dir, "zero")
@@ -57,7 +37,7 @@ func TestDetectInstallMethodNpmViaPackageJSON(t *testing.T) {
if err := os.WriteFile(exePath, []byte("binary"), 0o755); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"@twigpine/zero","version":"0.5.0-linux-x64","os":["linux"],"cpu":["x64"]}`), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"@gitlawb/zero","version":"0.5.0-linux-x64","os":["linux"],"cpu":["x64"]}`), 0o644); err != nil {
t.Fatalf("WriteFile package.json: %v", err)
}
if method := DetectInstallMethod(exePath); method != InstallMethodNpm {
@@ -71,7 +51,7 @@ func TestDetectInstallMethodDoesNotTreatRepositoryManifestAsNpmBinary(t *testing
if err := os.WriteFile(exePath, []byte("binary"), 0o755); err != nil {
t.Fatal(err)
}
manifest := `{"name":"@twigpine/zero","bin":{"zero":"bin/zero.js"},"os":["linux","darwin","win32","android"],"cpu":["x64","arm64"]}`
manifest := `{"name":"@gitlawb/zero","bin":{"zero":"bin/zero.js"},"os":["linux","darwin","win32","android"],"cpu":["x64","arm64"]}`
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
@@ -93,57 +73,3 @@ func TestDetectInstallMethodIgnoresUnrelatedPackageJSON(t *testing.T) {
t.Fatalf("DetectInstallMethod = %q, want standalone", method)
}
}
// The bridge release published under the old name must recognise itself, so
// `zero upgrade` can tell it to switch instead of reinstalling the same build.
func TestDetectInstallMethodRecognisesALegacyPlatformPayload(t *testing.T) {
dir := t.TempDir()
exePath := filepath.Join(dir, "zero")
if err := os.WriteFile(exePath, []byte("binary"), 0o755); err != nil {
t.Fatal(err)
}
manifest := `{"name":"@gitlawb/zero","version":"0.10.0-linux-x64","os":["linux"],"cpu":["x64"]}`
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
if method := DetectInstallMethod(exePath); method != InstallMethodNpmLegacy {
t.Fatalf("DetectInstallMethod = %q, want %q", method, InstallMethodNpmLegacy)
}
}
// A binary the first-run downloader fetched sits next to the wrapper's own
// package.json, which is the only place the installed name is recorded.
func TestDetectInstallMethodRecognisesALegacyDownloadedBinary(t *testing.T) {
dir := t.TempDir()
exePath := filepath.Join(dir, "zero")
if err := os.WriteFile(exePath, []byte("binary"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, ".zero-binary-version"), []byte("0.10.0\n"), 0o644); err != nil {
t.Fatal(err)
}
manifest := `{"name":"@gitlawb/zero","bin":{"zero":"bin/zero.js"},"os":["linux","darwin","win32","android"],"cpu":["x64","arm64"]}`
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
if method := DetectInstallMethod(exePath); method != InstallMethodNpmLegacy {
t.Fatalf("DetectInstallMethod = %q, want %q", method, InstallMethodNpmLegacy)
}
}
// Without the downloader's marker, a wrapper-shaped manifest under the old
// name is a checkout, not an install, exactly as for the current name.
func TestDetectInstallMethodDoesNotTreatALegacyWrapperManifestAsAnInstall(t *testing.T) {
dir := t.TempDir()
exePath := filepath.Join(dir, "zero")
if err := os.WriteFile(exePath, []byte("binary"), 0o755); err != nil {
t.Fatal(err)
}
manifest := `{"name":"@gitlawb/zero","bin":{"zero":"bin/zero.js"},"os":["linux","darwin","win32","android"],"cpu":["x64","arm64"]}`
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
if method := DetectInstallMethod(exePath); method != InstallMethodStandalone {
t.Fatalf("DetectInstallMethod = %q, want standalone", method)
}
}
+2 -11
View File
@@ -15,7 +15,7 @@ import (
)
const (
DefaultRepository = "Twigpine/zero"
DefaultRepository = "Gitlawb/zero"
DefaultTimeout = 5 * time.Second
)
@@ -233,10 +233,6 @@ func Format(result Result) string {
"Latest release: " + result.ReleaseURL,
}
lines = appendAssetLines(lines, result.ReleaseAsset)
if result.installMethod == InstallMethodNpmLegacy {
// Up to date today, but the next release will not reach this install.
lines = append(lines, legacyPackageSwitchMessage())
}
return strings.Join(lines, "\n")
}
@@ -326,11 +322,6 @@ func upgradeGuidance(asset AssetCheck, sourceFlag string, installMethod InstallM
// `brew upgrade zero` would change this one instead.
return "This Homebrew-managed installation is updated with `brew upgrade zero`. `zero upgrade` refuses here, because replacing the keg binary would leave Homebrew's records describing a version that is no longer installed."
}
if installMethod == InstallMethodNpmLegacy {
// Placed like the Homebrew answer above, and for the same reason: it is a
// property of the install on this machine, and `zero upgrade` refuses it.
return legacyPackageSwitchMessage()
}
if sourceFlag != "" {
if installMethod == InstallMethodNpm {
return "This npm-managed installation can be updated with `npm install -g " + npmPackageName + "@latest`, which installs the official npm package. The custom `" + sourceFlag + "` source only affects the release check and update gating, not the npm install source."
@@ -466,7 +457,7 @@ func releasePlatform(goos string) (string, error) {
// self-updating release asset. Point users at `npm update` rather
// than a source rebuild, since that's the documented Termux
// install/upgrade path and doesn't require a Go toolchain.
return "", fmt.Errorf("no published release for %q (release assets: linux, macos, windows). Your build is the current version of record. Upgrade with `npm update -g %s` to get the latest.", goos, npmPackageName) //nolint:staticcheck // Preserve established user-facing error text.
return "", fmt.Errorf("no published release for %q (release assets: linux, macos, windows). Your build is the current version of record. Upgrade with `npm update -g @gitlawb/zero` to get the latest.", goos) //nolint:staticcheck // Preserve established user-facing error text.
}
}
+1 -25
View File
@@ -587,35 +587,11 @@ func TestFormatNpmCustomSourceExplainsInstallSource(t *testing.T) {
SourceFlag: "--repo someone/fork", installMethod: InstallMethodNpm,
}
output := Format(result)
if strings.Contains(output, "zero upgrade --repo") || !strings.Contains(output, "npm install -g @twigpine/zero@latest") || !strings.Contains(output, "only affects the release check and update gating") {
if strings.Contains(output, "zero upgrade --repo") || !strings.Contains(output, "npm install -g @gitlawb/zero@latest") || !strings.Contains(output, "only affects the release check and update gating") {
t.Fatalf("npm custom-source guidance is misleading: %q", output)
}
}
// A legacy install is told to switch whether or not an update is out: up to
// date today, the next release will not reach it, and `zero upgrade` refuses.
func TestFormatTellsALegacyNpmInstallToSwitch(t *testing.T) {
asset := assetCheckForTest(t, "v0.11.0", runtime.GOOS, runtime.GOARCH)
for name, result := range map[string]Result{
"up to date": {
CurrentVersion: "0.10.0", LatestVersion: "0.10.0", ReleaseURL: "https://example.test/release",
ReleaseAsset: asset, installMethod: InstallMethodNpmLegacy,
},
"update available": {
CurrentVersion: "0.10.0", LatestVersion: "0.11.0", ReleaseURL: "https://example.test/release",
ReleaseAsset: asset, UpdateAvailable: true, installMethod: InstallMethodNpmLegacy,
},
} {
output := Format(result)
if !strings.Contains(output, "npm uninstall -g @gitlawb/zero") || !strings.Contains(output, "npm install -g @twigpine/zero") {
t.Errorf("%s: output does not give the switch steps: %q", name, output)
}
if strings.Contains(output, "Run `zero upgrade`") {
t.Errorf("%s: output recommends `zero upgrade`, which refuses a legacy install: %q", name, output)
}
}
}
func TestFormatCrossTargetCustomSourceKeepsAccurateGuidance(t *testing.T) {
goos, goarch := "linux", "amd64"
if runtime.GOOS == goos && runtime.GOARCH == goarch {
-139
View File
@@ -1,139 +0,0 @@
package workspaceindex
import (
"bytes"
"context"
"os"
"os/exec"
"path"
"strings"
"time"
)
// gitIgnoredPathsTimeout bounds how long Scan waits on git before giving up and falling
// back to the non-git behavior. Scan also runs for the per-turn workspace seed, so a slow or
// degenerate tree (network filesystem, huge monorepo) must not stall a turn.
const gitIgnoredPathsTimeout = 5 * time.Second
// gitIgnoredPaths asks git which paths under root it considers ignored, honoring every
// source git itself resolves (nested .gitignore files, .git/info/exclude,
// core.excludesFile). Returns a set of paths relative to root: whole ignored directories
// collapsed to one entry plus individually ignored files, using forward slashes and no
// trailing slash (the `rel` form Scan computes).
//
// Returns (nil, false) when root is not inside a git work tree, git is missing or older
// than the floor below, or a command fails or times out. Scan then behaves as it does
// outside git. A scan must never fail or hang because of this lookup.
//
// Git version floor: 2.16. `status --ignored=<mode>` landed in 2.16 and `--porcelain=v1`
// in 2.11; an older git rejects the arguments, which lands on the (nil, false) path.
// GIT_OPTIONAL_LOCKS (2.15) is an environment variable, so an older git ignores it.
//
// Why `status --ignored=matching` and not `ls-files --others --ignored --directory`:
// ls-files stops descending at the first untracked directory, so an ignored directory or
// file nested inside one (svc/build-out/, app/cache.db) is never reported. status descends,
// collapses a fully ignored directory to a single entry, and lists partially ignored files
// individually. `-uno` is not usable as a speedup: with it, status reports no ignored
// entries at all.
func gitIgnoredPaths(root string) (map[string]bool, bool) {
ctx, cancel := context.WithTimeout(context.Background(), gitIgnoredPathsTimeout)
defer cancel()
// status reports paths relative to the repository top level even when run from a
// subdirectory, while Scan's `rel` is relative to root. show-prefix is root's own
// location inside the repo ("" at the top level, "svc/" for <repo>/svc), and also the
// cheapest way to learn whether this is a git work tree at all.
prefixOut, err := gitCommand(ctx, root, "rev-parse", "--show-prefix").Output()
if err != nil {
return nil, false
}
prefix := strings.TrimSpace(string(prefixOut))
// -z: NUL-terminated and unquoted. Without it git C-quotes non-ASCII bytes
// (core.quotePath), e.g. "caf\303\251/", which would never match the raw `rel`.
// The trailing "." limits the report to root when root is a repository subdirectory.
// --untracked-files=normal: status.showUntrackedFiles=no makes git reject
// --ignored=matching; without an explicit override Scan falls back to the fixed list.
out, err := gitCommand(ctx, root, "status", "--porcelain=v1", "-z", "--untracked-files=normal", "--ignored=matching", ".").Output()
if err != nil {
return nil, false
}
return parseIgnoredStatus(out, prefix), true
}
// parseIgnoredStatus extracts the ignored paths from `git status --porcelain=v1 -z` output,
// made relative to prefix (root's location inside the repository).
//
// Each record is "XY <path>". A rename or copy (R or C in either status column) is followed
// by one more NUL field holding the original path with no XY prefix. That field is consumed,
// not inspected: an original name that itself begins with "!! " would otherwise read as an
// ignored entry and hide a real file from Scan.
func parseIgnoredStatus(out []byte, prefix string) map[string]bool {
paths := make(map[string]bool)
skipOrigPath := false
for entry := range bytes.SplitSeq(out, []byte{0}) {
if skipOrigPath {
skipOrigPath = false
continue
}
if len(entry) < 3 {
continue
}
if x, y := entry[0], entry[1]; x == 'R' || x == 'C' || y == 'R' || y == 'C' {
skipOrigPath = true
continue
}
// Ignored entries are "!! <path>"; untracked "??" and changed entries are skipped.
rest, ok := bytes.CutPrefix(entry, []byte("!! "))
if !ok {
continue
}
rel, ok := strings.CutPrefix(string(rest), prefix)
if !ok {
continue
}
rel = path.Clean(strings.TrimSuffix(rel, "/"))
if rel == "." || rel == "" {
continue
}
paths[rel] = true
}
return paths
}
// gitCommand builds a git invocation with a fixed argv (no shell) rooted at dir.
//
// GIT_OPTIONAL_LOCKS=0 stops `git status` from taking .git/index.lock to refresh the
// index stat cache. Scan runs on every turn, often while the user or the agent is running
// git in the same repository; an opportunistic lock there can make a concurrent
// `git commit` or `git add` fail with "index.lock exists".
func gitCommand(ctx context.Context, dir string, args ...string) *exec.Cmd {
// #nosec G204 -- fixed argv, no shell; dir is the caller's own absolute path.
// core.fsmonitor in .git/config can point at a command git status runs; disable it so
// Scan never executes foreign hooks from the repository under scan.
cmd := exec.CommandContext(ctx, "git", append([]string{"-C", dir, "-c", "core.fsmonitor=false"}, args...)...)
cmd.Env = append(withoutRepoLocationEnv(os.Environ()), "GIT_OPTIONAL_LOCKS=0")
return cmd
}
// withoutRepoLocationEnv drops the repository path overrides from git(1),
// "The Git Repository": GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE, GIT_COMMON_DIR,
// GIT_OBJECT_DIRECTORY, and GIT_ALTERNATE_OBJECT_DIRECTORIES. Hooks export
// them (githooks(5)) so git commands inside the hook find that repository.
// Inherited, -C dir still describes the hook's repository, and Scan applies
// its ignore rules instead of the tree under dir. GIT_CEILING_DIRECTORIES is
// not one of these; it only bounds discovery and must be inherited.
func withoutRepoLocationEnv(env []string) []string {
out := make([]string, 0, len(env))
for _, kv := range env {
key, _, _ := strings.Cut(kv, "=")
switch key {
case "GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR",
"GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES":
continue
}
out = append(out, kv)
}
return out
}
-387
View File
@@ -1,387 +0,0 @@
package workspaceindex
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
)
// isolateGitConfig keeps the git child processes away from the developer's real global and
// system config: a global core.excludesFile would change what counts as ignored and make
// these tests depend on the machine. GIT_CONFIG_GLOBAL needs git >= 2.32; HOME and
// XDG_CONFIG_HOME cover older git and macOS/Windows home resolution.
func isolateGitConfig(t *testing.T) {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
}
// stopGitDiscoveryAbove keeps git from finding an enclosing repository above root, so a
// TMPDIR that happens to live inside a git work tree cannot turn a "not a repo" fixture
// into one.
func stopGitDiscoveryAbove(t *testing.T, root string) {
t.Helper()
t.Setenv("GIT_CEILING_DIRECTORIES", filepath.Dir(root))
}
// requireGitIgnoredMatching skips below git 2.16, where `status --ignored=matching` landed.
// Older git takes Scan's non-git path, which the fallback tests cover separately.
func requireGitIgnoredMatching(t *testing.T) {
t.Helper()
out, err := exec.Command("git", "version").Output()
if err != nil {
t.Skipf("git unavailable: %v", err)
}
var major, minor int
if _, err := fmt.Sscanf(strings.TrimPrefix(strings.TrimSpace(string(out)), "git version "), "%d.%d", &major, &minor); err != nil {
t.Skipf("cannot parse %q: %v", out, err)
}
if major < 2 || (major == 2 && minor < 16) {
t.Skipf("needs git >= 2.16 for status --ignored=matching, have %d.%d", major, minor)
}
}
func runGit(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir, "-c", "user.name=zero-test", "-c", "[email protected]", "-c", "commit.gpgsign=false"}, args...)...)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
}
func initGitRepo(t *testing.T, root, gitignore string) {
t.Helper()
isolateGitConfig(t)
requireGitIgnoredMatching(t)
stopGitDiscoveryAbove(t, root)
runGit(t, root, "init", "-q")
if gitignore != "" {
writeFile(t, root, ".gitignore", gitignore)
}
}
// A virtualenv or cache directory whose name no fixed list anticipates must still be
// skipped when the repository's own .gitignore excludes it. Reported against a real repo
// where `zero repo-map` spent its whole file budget inside a `.locust_env/` virtualenv and
// never reached the repo's own README.md.
func TestScanHonorsRepoGitignore(t *testing.T) {
root := t.TempDir()
initGitRepo(t, root, ".locust_env/\n*.db\ncafé-cache/\n")
writeFile(t, root, "README.md", "# Example\n")
writeFile(t, root, "app/main.py", "print('hi')\n")
writeFile(t, root, "app/café.py", "x = 1\n")
writeFile(t, root, ".locust_env/lib/python3.13/site-packages/flask/sansio/README.md", "vendored")
writeFile(t, root, "audit_results.db", "sqlite")
// Ignored file inside an untracked directory: `ls-files --directory` would miss it.
writeFile(t, root, "app/cache.db", "sqlite")
// Non-ASCII ignored directory: only matches because the lookup uses -z (no C-quoting).
writeFile(t, root, "café-cache/blob.json", "{}")
got, err := Scan(root, Options{MaxDepth: DefaultMaxDepth})
if err != nil {
t.Fatalf("Scan: %v", err)
}
want := []string{".gitignore", "README.md", "app/café.py", "app/main.py"}
if !reflect.DeepEqual(pathsOf(got.Files), want) {
t.Fatalf("Files=%v want %v", pathsOf(got.Files), want)
}
}
// Scanning a repository subdirectory must resolve ignore rules relative to that
// subdirectory (Scan's `rel`), not relative to the repository top level.
func TestScanHonorsRepoGitignoreFromSubdirectoryRoot(t *testing.T) {
repo := t.TempDir()
initGitRepo(t, repo, "build-out/\n")
writeFile(t, repo, "svc/handler.go", "package svc\n")
writeFile(t, repo, "svc/build-out/gen.go", "package gen\n")
got, err := Scan(filepath.Join(repo, "svc"), Options{MaxDepth: DefaultMaxDepth})
if err != nil {
t.Fatalf("Scan: %v", err)
}
if want := []string{"handler.go"}; !reflect.DeepEqual(pathsOf(got.Files), want) {
t.Fatalf("Files=%v want %v", pathsOf(got.Files), want)
}
}
// Inside a git work tree, git decides. A tracked directory that merely shares a name with
// a build-cache directory (a Go package called "target", say) is real source and must be
// scanned; the fixed list is only a stand-in for when git cannot be asked.
func TestScanKeepsTrackedBuildCacheNamedDirInsideGitRepo(t *testing.T) {
root := t.TempDir()
initGitRepo(t, root, "")
writeFile(t, root, "main.go", "package main\n")
writeFile(t, root, "target/target.go", "package target\n")
runGit(t, root, "add", ".")
runGit(t, root, "commit", "-q", "-m", "fixture")
got, err := Scan(root, Options{MaxDepth: DefaultMaxDepth})
if err != nil {
t.Fatalf("Scan: %v", err)
}
if want := []string{"main.go", "target/target.go"}; !reflect.DeepEqual(pathsOf(got.Files), want) {
t.Fatalf("Files=%v want %v", pathsOf(got.Files), want)
}
}
// Scanning a directory that the enclosing repository ignores as a whole must still list
// its contents: git reports the root itself as ignored, which is not a reason to skip
// everything under it.
func TestScanOfIgnoredRootStillListsItsFiles(t *testing.T) {
repo := t.TempDir()
initGitRepo(t, repo, "out/\n")
writeFile(t, repo, "out/report.md", "# report\n")
writeFile(t, repo, "out/data/rows.csv", "a,b\n")
got, err := Scan(filepath.Join(repo, "out"), Options{MaxDepth: DefaultMaxDepth})
if err != nil {
t.Fatalf("Scan: %v", err)
}
if want := []string{"data/rows.csv", "report.md"}; !reflect.DeepEqual(pathsOf(got.Files), want) {
t.Fatalf("Files=%v want %v", pathsOf(got.Files), want)
}
}
// Outside a git work tree, Scan falls back to the fixed build/cache list so the file
// budget is not spent on build output. Reported against a repo where
// rust/*/target/debug/.fingerprint alone held hundreds of files: with a small budget the
// real source must still be reached and the scan must not report truncation.
func TestScanSkipsBuildCacheDirsOutsideGit(t *testing.T) {
isolateGitConfig(t)
root := t.TempDir()
stopGitDiscoveryAbove(t, root)
if _, ok := gitIgnoredPaths(root); ok {
t.Fatalf("gitIgnoredPaths(non-repo) reported a work tree")
}
writeFile(t, root, "src/lib.rs", "pub fn f() {}\n")
writeFile(t, root, "app/main.py", "print('hi')\n")
for i := range 10 {
writeFile(t, root, fmt.Sprintf("rust/crate/target/debug/.fingerprint/foo-%d/lib-foo.d", i), "x")
}
writeFile(t, root, "app/__pycache__/main.cpython-312.pyc", "x")
writeFile(t, root, ".venv/lib/site-packages/pkg.py", "x")
writeFile(t, root, "Venv/lib/pkg.py", "x")
writeFile(t, root, ".terraform/providers/provider.json", "x")
writeFile(t, root, ".pytest_cache/v/cache/nodeids", "x")
writeFile(t, root, ".mypy_cache/3.12/mod.json", "x")
writeFile(t, root, ".ruff_cache/content", "x")
got, err := Scan(root, Options{MaxFiles: 3, MaxDepth: DefaultMaxDepth})
if err != nil {
t.Fatalf("Scan: %v", err)
}
if want := []string{"app/main.py", "src/lib.rs"}; !reflect.DeepEqual(pathsOf(got.Files), want) {
t.Fatalf("Files=%v want %v", pathsOf(got.Files), want)
}
if got.Truncated {
t.Fatalf("Truncated=true: build-cache files consumed the file budget")
}
}
// When git cannot run at all, Scan must not error and must behave exactly as outside a
// repository, even inside one: .gitignore is not consulted, the fixed list applies.
func TestScanFallsBackWhenGitUnavailable(t *testing.T) {
root := t.TempDir()
initGitRepo(t, root, ".locust_env/\n")
writeFile(t, root, "main.go", "package main\n")
writeFile(t, root, ".locust_env/lib/x.py", "kept: git is not available to honor .gitignore")
writeFile(t, root, "target/debug/app.d", "skipped by the fixed list")
t.Setenv("PATH", t.TempDir())
if paths, ok := gitIgnoredPaths(root); ok || paths != nil {
t.Fatalf("gitIgnoredPaths without git = (%v, %v), want (nil, false)", paths, ok)
}
got, err := Scan(root, Options{MaxDepth: DefaultMaxDepth})
if err != nil {
t.Fatalf("Scan: %v", err)
}
if want := []string{".gitignore", ".locust_env/lib/x.py", "main.go"}; !reflect.DeepEqual(pathsOf(got.Files), want) {
t.Fatalf("Files=%v want %v", pathsOf(got.Files), want)
}
}
// ShouldSkipDir is shared with glob, grep, list_directory and path autocomplete. The
// build/cache names Scan skips must stay visible there, so a question about a dependency in
// .venv or generated code under target can still be answered.
func TestShouldSkipDirLeavesScanOnlyDirsVisibleToTools(t *testing.T) {
for _, name := range []string{"target", "__pycache__", ".venv", "venv", ".pytest_cache", ".terraform", ".mypy_cache", ".ruff_cache"} {
if ShouldSkipDir(name) {
t.Fatalf("ShouldSkipDir(%q)=true: tools would stop seeing it", name)
}
if !isScanOnlySkipDir(name) {
t.Fatalf("isScanOnlySkipDir(%q)=false want true", name)
}
}
for _, name := range []string{"Target", " target ", "__PYCACHE__", ".VENV"} {
if !isScanOnlySkipDir(name) {
t.Fatalf("isScanOnlySkipDir(%q)=false want true (case/whitespace-insensitive)", name)
}
}
for _, name := range []string{"src", "targets", "venv2", "internal"} {
if isScanOnlySkipDir(name) {
t.Fatalf("isScanOnlySkipDir(%q)=true want false", name)
}
}
}
// A repository's core.fsmonitor can run an external command during status; the
// ignored-path lookup must not trigger it. Git for Windows hands both that
// command and the hook body to sh, which consumes backslashes. The script path
// stored in core.fsmonitor and the marker path inside the hook are quoted and
// use forward slashes: an unquoted backslash script path never starts the
// hook, and an unquoted backslash marker (touch C:\tmp\run\marker) creates
// C:tmprunmarker, so the file this test stats is never created. A status
// without the override must create the marker first; otherwise a hook that
// does not run would still pass.
func TestGitIgnoredPathsDisablesRepoFsmonitor(t *testing.T) {
root := t.TempDir()
initGitRepo(t, root, ".locust_env/\n")
writeFile(t, root, "README.md", "# x\n")
writeFile(t, root, ".locust_env/lib/x.py", "x\n")
marker := filepath.Join(root, ".fsmonitor-ran")
script := filepath.Join(root, "fsmonitor-hook.sh")
hook := "#!/bin/sh\ntouch '" + filepath.ToSlash(marker) + "'\n"
if err := os.WriteFile(script, []byte(hook), 0o755); err != nil {
t.Fatalf("write fsmonitor hook: %v", err)
}
runGit(t, root, "config", "core.fsmonitor", "'"+filepath.ToSlash(script)+"'")
runGit(t, root, "status", "--porcelain=v1", "-z", "--untracked-files=normal", "--ignored=matching", ".")
if _, err := os.Stat(marker); err != nil {
t.Fatalf("fsmonitor hook did not run without the override: %v", err)
}
if err := os.Remove(marker); err != nil {
t.Fatalf("remove marker: %v", err)
}
if _, ok := gitIgnoredPaths(root); !ok {
t.Fatal("gitIgnoredPaths failed")
}
if _, err := os.Stat(marker); err == nil {
t.Fatalf("fsmonitor hook ran during gitIgnoredPaths (marker %q exists)", marker)
} else if !os.IsNotExist(err) {
t.Fatalf("stat marker: %v", err)
}
}
// status.showUntrackedFiles=no makes git reject --ignored=matching unless
// --untracked-files=normal is passed explicitly; ignored paths must still come from git.
func TestScanHonorsRepoGitignoreWhenShowUntrackedFilesNo(t *testing.T) {
root := t.TempDir()
initGitRepo(t, root, ".locust_env/\n")
runGit(t, root, "config", "status.showUntrackedFiles", "no")
writeFile(t, root, "README.md", "# Example\n")
writeFile(t, root, "app/main.py", "print('hi')\n")
writeFile(t, root, ".locust_env/lib/x.py", "vendored\n")
got, err := Scan(root, Options{MaxDepth: DefaultMaxDepth})
if err != nil {
t.Fatalf("Scan: %v", err)
}
want := []string{".gitignore", "README.md", "app/main.py"}
if !reflect.DeepEqual(pathsOf(got.Files), want) {
t.Fatalf("Files=%v want %v", pathsOf(got.Files), want)
}
}
// Git exports repository-location variables to hooks (githooks(5)). With them
// pointing at another repository, Scan must still apply root's own .gitignore
// and match a lookup made without those variables.
func TestScanIgnoresInheritedRepositoryLocation(t *testing.T) {
root := t.TempDir()
initGitRepo(t, root, ".locust_env/\n")
writeFile(t, root, "README.md", "# Example\n")
writeFile(t, root, ".locust_env/lib/x.py", "vendored\n")
clean, err := Scan(root, Options{MaxDepth: DefaultMaxDepth})
if err != nil {
t.Fatalf("Scan: %v", err)
}
other := t.TempDir()
initGitRepo(t, other, "")
writeFile(t, other, "README.md", "other\n")
gitDir := filepath.Join(other, ".git")
t.Setenv("GIT_DIR", gitDir)
t.Setenv("GIT_WORK_TREE", other)
t.Setenv("GIT_INDEX_FILE", filepath.Join(gitDir, "index"))
t.Setenv("GIT_COMMON_DIR", gitDir)
t.Setenv("GIT_OBJECT_DIRECTORY", filepath.Join(gitDir, "objects"))
t.Setenv("GIT_ALTERNATE_OBJECT_DIRECTORIES", filepath.Join(gitDir, "objects"))
got, err := Scan(root, Options{MaxDepth: DefaultMaxDepth})
if err != nil {
t.Fatalf("Scan with inherited repository location: %v", err)
}
if !reflect.DeepEqual(pathsOf(got.Files), pathsOf(clean.Files)) {
t.Fatalf("Files=%v want clean environment %v", pathsOf(got.Files), pathsOf(clean.Files))
}
}
// The ignored-path lookup runs every turn, often next to a git command the user or agent
// is running in the same repository. It must not take the optional index lock, and a
// GIT_OPTIONAL_LOCKS value inherited from the environment must not re-enable it.
func TestGitCommandDisablesOptionalLocks(t *testing.T) {
t.Setenv("GIT_OPTIONAL_LOCKS", "1")
cmd := gitCommand(context.Background(), t.TempDir(), "status")
value := ""
for _, entry := range cmd.Env {
if after, ok := strings.CutPrefix(entry, "GIT_OPTIONAL_LOCKS="); ok {
value = after
}
}
if value != "0" {
t.Fatalf("effective GIT_OPTIONAL_LOCKS=%q want \"0\"", value)
}
}
// A rename or copy record carries its original path in the next NUL field, with no XY
// prefix. An original name that starts with "!! " must not be read as an ignored entry.
func TestParseIgnoredStatusSkipsRenameAndCopyOrigPaths(t *testing.T) {
out := []byte("R b\x00!! a\x00" +
"C d\x00!! c\x00" +
" R e\x00!! f\x00" +
"!! out/\x00" +
"?? new.txt\x00" +
" M main.go\x00" +
"!! svc/build/\x00")
if got, want := parseIgnoredStatus(out, ""), map[string]bool{"out": true, "svc/build": true}; !reflect.DeepEqual(got, want) {
t.Fatalf("prefix \"\": got %v want %v", got, want)
}
if got, want := parseIgnoredStatus(out, "svc/"), map[string]bool{"build": true}; !reflect.DeepEqual(got, want) {
t.Fatalf("prefix \"svc/\": got %v want %v", got, want)
}
}
// End to end: after `git mv "!! a" b`, status reports "R b\0!! a\0". The tracked file "a"
// must still be scanned.
func TestScanKeepsFileNamedLikeRenameOrigPath(t *testing.T) {
root := t.TempDir()
initGitRepo(t, root, "")
writeFile(t, root, "!! a", "moved\n")
writeFile(t, root, "a", "kept\n")
runGit(t, root, "add", ".")
runGit(t, root, "commit", "-q", "-m", "fixture")
runGit(t, root, "mv", "!! a", "b")
got, err := Scan(root, Options{MaxDepth: DefaultMaxDepth})
if err != nil {
t.Fatalf("Scan: %v", err)
}
if want := []string{"a", "b"}; !reflect.DeepEqual(pathsOf(got.Files), want) {
t.Fatalf("Files=%v want %v", pathsOf(got.Files), want)
}
}
+2 -26
View File
@@ -63,18 +63,6 @@ func Scan(root string, options Options) (Summary, error) {
maxDepth = DefaultMaxDepth
}
// Inside a git work tree, git's own ignore rules decide what else to skip; outside one
// (or when git is unavailable or too old), a fixed list of build/cache directory names
// stands in. Both apply to Scan only: ShouldSkipDir is shared with glob, grep,
// list_directory and path autocomplete, which must keep seeing these directories.
ignored, hasIgnored := gitIgnoredPaths(cleanRoot)
skipScanDir := func(rel, name string) bool {
if hasIgnored {
return ignored[rel]
}
return isScanOnlySkipDir(name)
}
files := []File{}
dirs := map[string]struct{}{}
maxDepthSeen := 0
@@ -95,7 +83,7 @@ func Scan(root string, options Options) (Summary, error) {
rel = filepath.ToSlash(rel)
if entry.IsDir() {
if ShouldSkipDir(entry.Name()) || isSymlink(entry) || skipScanDir(rel, entry.Name()) {
if ShouldSkipDir(entry.Name()) || isSymlink(entry) {
return filepath.SkipDir
}
depth := pathDepth(rel)
@@ -110,7 +98,7 @@ func Scan(root string, options Options) (Summary, error) {
return nil
}
if isSymlink(entry) || ShouldSkipFile(rel) || (hasIgnored && ignored[rel]) {
if isSymlink(entry) || ShouldSkipFile(rel) {
return nil
}
depth := FileDepth(rel)
@@ -187,18 +175,6 @@ func ShouldSkipDir(name string) bool {
}
}
// isScanOnlySkipDir names build and cache directories Scan skips when it cannot ask git
// what is ignored. It is deliberately separate from ShouldSkipDir: a tracked package named
// "target" or a question about a dependency inside .venv must stay visible to the tools.
func isScanOnlySkipDir(name string) bool {
switch strings.ToLower(strings.TrimSpace(name)) {
case "target", "__pycache__", ".venv", "venv", ".pytest_cache", ".terraform", ".mypy_cache", ".ruff_cache":
return true
default:
return false
}
}
func ShouldSkipFile(rel string) bool {
base := strings.ToLower(path.Base(filepath.ToSlash(rel)))
switch base {
+1 -3
View File
@@ -83,9 +83,7 @@ func Build(input Input) Seed {
}
// BuildFromWorkspace scans the local filesystem with workspaceindex and builds
// a seed from the resulting file list. Its only git operation is the read-only
// ignored-path lookup inside workspaceindex.Scan; branch and dirty state come
// from the caller's GitInfo.
// a seed from the resulting file list. It performs no git operations.
func BuildFromWorkspace(root string, git GitInfo) (Seed, error) {
summary, err := workspaceindex.Scan(root, workspaceindex.Options{
MaxFiles: workspaceindex.DefaultMaxFiles,
+7 -7
View File
@@ -1,12 +1,12 @@
{
"name": "@twigpine/zero",
"version": "0.11.0",
"name": "@gitlawb/zero",
"version": "0.9.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@twigpine/zero",
"version": "0.11.0",
"name": "@gitlawb/zero",
"version": "0.9.0",
"cpu": [
"x64",
"arm64"
@@ -709,9 +709,9 @@
}
},
"node_modules/shell-quote": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz",
"integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==",
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
"integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
"license": "MIT",
"peer": true,
"engines": {
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@twigpine/zero",
"version": "0.11.0",
"name": "@gitlawb/zero",
"version": "0.9.0",
"description": "Zero — a fast, multi-provider terminal coding agent.",
"type": "module",
"module": "bin/zero.js",
@@ -30,11 +30,11 @@
],
"repository": {
"type": "git",
"url": "git+https://github.com/Twigpine/zero.git"
"url": "git+https://github.com/Gitlawb/zero.git"
},
"homepage": "https://zero.twigpine.com",
"homepage": "https://github.com/Gitlawb/zero#readme",
"bugs": {
"url": "https://github.com/Twigpine/zero/issues"
"url": "https://github.com/Gitlawb/zero/issues"
},
"license": "MIT"
}
+1 -1
View File
@@ -5,7 +5,7 @@
"bootstrap-sha": "02f0c0929bec65f5fc41bdcab3eb518d2b0b329a",
"packages": {
".": {
"package-name": "@twigpine/zero"
"package-name": "@gitlawb/zero"
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
[CmdletBinding()]
param(
[string]$Version = $env:ZERO_VERSION,
[string]$Repository = $(if ($env:ZERO_REPO) { $env:ZERO_REPO } else { "Twigpine/zero" }),
[string]$Repository = $(if ($env:ZERO_REPO) { $env:ZERO_REPO } else { "Gitlawb/zero" }),
[string]$InstallDir = $env:ZERO_INSTALL_DIR,
[string]$GitHubApi = $(if ($env:ZERO_GITHUB_API) { $env:ZERO_GITHUB_API } else { "https://api.github.com" }),
[string]$GitHubBaseUrl = $(if ($env:ZERO_GITHUB_BASE_URL) { $env:ZERO_GITHUB_BASE_URL } else { "https://github.com" })
+2 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
ZERO_REPO="${ZERO_REPO:-Twigpine/zero}"
ZERO_REPO="${ZERO_REPO:-Gitlawb/zero}"
ZERO_VERSION="${ZERO_VERSION:-latest}"
ZERO_INSTALL_DIR="${ZERO_INSTALL_DIR:-$HOME/.local/bin}"
ZERO_GITHUB_API="${ZERO_GITHUB_API:-https://api.github.com}"
@@ -16,7 +16,7 @@ Usage:
Environment:
ZERO_VERSION Release version or tag. Defaults to latest.
ZERO_REPO GitHub repository. Defaults to Twigpine/zero.
ZERO_REPO GitHub repository. Defaults to Gitlawb/zero.
ZERO_INSTALL_DIR Directory for the zero binary. Defaults to ~/.local/bin.
ZERO_GITHUB_API GitHub API base URL. Defaults to https://api.github.com.
ZERO_GITHUB_BASE_URL GitHub web base URL. Defaults to https://github.com.
+20 -44
View File
@@ -2,22 +2,21 @@
// build-platform-packages.mjs: assemble the npm publish payloads for a release
// from the per-platform archives built by `zero-release package`.
//
// Layout written under --out-dir (default dist/npm), where <name> is the
// package.json name, or --package-name when given:
// wrapper/ <name>@X.Y.Z — bin/zero.js, the
// Layout written under --out-dir (default dist/npm):
// wrapper/ @gitlawb/[email protected] — bin/zero.js, the
// first-run fallback downloader, and a
// package.json with NO scripts and NO
// dependencies, only optionalDependencies
// aliasing the platform versions below.
// platforms/zero-<platform>-<arch>/
// <name>@X.Y.Z-<platform>-<arch> —
// @gitlawb/zero@X.Y.Z-<platform>-<arch> —
// the zero binary, sandbox helpers, and the
// vendored local-control helpers/ tree,
// copied straight out of the release archive.
//
// The platform "packages" are versions of the SAME package at suffixed
// (semver-prerelease) versions, referenced from the wrapper through npm:
// aliases. One package name keeps a single npm trusted-publisher config.
// The platform "packages" are versions of the SAME @gitlawb/zero package at
// suffixed (semver-prerelease) versions, referenced from the wrapper through
// npm: aliases. One package name keeps a single npm trusted-publisher config.
// See docs/NPM_PACKAGING.md for the publishing rules this feeds.
//
// Archives are verified against their .sha256 files before extraction. All
@@ -25,16 +24,9 @@
// CI must always assemble the full matrix, and the wrapper's
// optionalDependencies always reference the full matrix regardless).
//
// --package-name assembles the same artifacts under another name. It was
// added for the one release (0.10.0) also published under the pre-rename name
// @gitlawb/zero (see docs/NPM_PACKAGING.md); everything in the payloads that
// names the package follows it, so the binary and wrapper know which name they
// were installed as.
//
// Usage:
// node scripts/npm/build-platform-packages.mjs \
// --artifacts-dir dist/artifacts [--out-dir dist/npm] [--only linux-x64,...] \
// [--package-name @scope/name]
// --artifacts-dir dist/artifacts [--out-dir dist/npm] [--only linux-x64,...]
import {
chmodSync,
@@ -79,7 +71,7 @@ function fail(message) {
}
function parseArgs(argv) {
const args = { artifactsDir: '', outDir: join(repoRoot, 'dist', 'npm'), only: null, packageName: '' };
const args = { artifactsDir: '', outDir: join(repoRoot, 'dist', 'npm'), only: null };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
const next = () => {
@@ -97,14 +89,6 @@ function parseArgs(argv) {
case '--only':
args.only = new Set(next().split(',').map((entry) => entry.trim()).filter(Boolean));
break;
case '--package-name':
args.packageName = next();
// A scoped or unscoped npm name; anything else would also become a
// path segment in the alias npm installs.
if (!/^(@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/.test(args.packageName)) {
fail(`--package-name ${JSON.stringify(args.packageName)} is not an npm package name`);
}
break;
default:
fail(`unknown argument ${arg}`);
}
@@ -115,7 +99,6 @@ function parseArgs(argv) {
function readWrapperPackage() {
const pkg = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8'));
if (!pkg.name) fail('package.json name is empty');
if (!pkg.version) fail('package.json version is empty');
return pkg;
}
@@ -124,17 +107,14 @@ function platformVersion(version, entry) {
return `${version}-${entry.platform}-${entry.arch}`;
}
// bin/zero.js derives the same alias from its own package name at runtime, so
// the wrapper name, the alias names, and the alias targets must all come from
// one value.
function aliasName(packageName, entry) {
return `${packageName}-${entry.platform}-${entry.arch}`;
function aliasName(entry) {
return `@gitlawb/zero-${entry.platform}-${entry.arch}`;
}
function optionalDependencies(packageName, version) {
function optionalDependencies(version) {
const aliases = {};
for (const entry of MATRIX) {
aliases[aliasName(packageName, entry)] = `npm:${packageName}@${platformVersion(version, entry)}`;
aliases[aliasName(entry)] = `npm:@gitlawb/zero@${platformVersion(version, entry)}`;
}
return aliases;
}
@@ -280,7 +260,7 @@ function verifyHelperShims(packageDir, entry, assetName) {
}
}
function buildPlatformPackage(entry, packageName, version, artifactsDir, outDir) {
function buildPlatformPackage(entry, version, artifactsDir, outDir) {
const assetName = `zero-v${version}-${entry.release}.${entry.ext}`;
const archivePath = join(artifactsDir, assetName);
if (!existsSync(archivePath)) fail(`missing release archive ${archivePath}`);
@@ -315,9 +295,9 @@ function buildPlatformPackage(entry, packageName, version, artifactsDir, outDir)
const wrapperPkg = readWrapperPackage();
const manifest = {
name: packageName,
name: '@gitlawb/zero',
version: platformVersion(version, entry),
description: `zero native binary for ${entry.platform}-${entry.arch} (installed via ${packageName}@${version})`,
description: `zero native binary for ${entry.platform}-${entry.arch} (installed via @gitlawb/zero@${version})`,
os: [entry.platform],
cpu: [entry.arch],
license: wrapperPkg.license,
@@ -332,7 +312,7 @@ function buildPlatformPackage(entry, packageName, version, artifactsDir, outDir)
}
}
function buildWrapperPackage(packageName, version, outDir) {
function buildWrapperPackage(version, outDir) {
const wrapperDir = join(outDir, 'wrapper');
rmSync(wrapperDir, { recursive: true, force: true });
mkdirSync(join(wrapperDir, 'bin'), { recursive: true });
@@ -353,17 +333,14 @@ function buildWrapperPackage(packageName, version, outDir) {
// stages into the platform payloads — see internal/release/release.go).
delete pkg.scripts;
delete pkg.dependencies;
pkg.name = packageName;
pkg.optionalDependencies = optionalDependencies(packageName, version);
pkg.optionalDependencies = optionalDependencies(version);
writeFileSync(join(wrapperDir, 'package.json'), JSON.stringify(pkg, null, 2) + '\n');
return wrapperDir;
}
function main() {
const args = parseArgs(process.argv.slice(2));
const wrapperPkg = readWrapperPackage();
const packageName = args.packageName || wrapperPkg.name;
const { version } = wrapperPkg;
const version = readWrapperPackage().version;
const selected = MATRIX.filter(
(entry) => !args.only || args.only.has(`${entry.platform}-${entry.arch}`),
@@ -379,14 +356,13 @@ function main() {
const built = [];
for (const entry of selected) {
built.push(buildPlatformPackage(entry, packageName, version, args.artifactsDir, args.outDir));
built.push(buildPlatformPackage(entry, version, args.artifactsDir, args.outDir));
}
const wrapperDir = buildWrapperPackage(packageName, version, args.outDir);
const wrapperDir = buildWrapperPackage(version, args.outDir);
process.stdout.write(
JSON.stringify(
{
name: packageName,
version,
wrapperDir,
platformDirs: built,
-188
View File
@@ -1,188 +0,0 @@
#!/usr/bin/env bash
# publish-packages.sh: publish the npm payloads that build-platform-packages.mjs
# assembled under <npm-dir> (wrapper/ plus platforms/*/), in the order and
# with the dist-tags docs/NPM_PACKAGING.md requires:
#
# 1. every platform payload, under the `platform` dist-tag, never `latest`;
# 2. wait until npm shows every platform version, then check that `latest`
# was not moved onto one;
# 3. the wrapper, which takes `latest`, then wait until `latest` is its
# version.
#
# The waits exist because npm applies a publish asynchronously: `npm publish`
# returns once the upload is accepted, and the version can appear minutes
# later (ten minutes for a platform payload of 0.10.0). Ordering the uploads
# alone would still let the wrapper appear first, with aliases that 404.
#
# The package name comes from the assembled wrapper's package.json, so the
# same script publishes whatever name the payloads were built under.
#
# A version that is already on the registry is skipped, which makes
# re-running a release job that failed halfway safe: the platform payloads
# that went out are not published twice, and the wrapper still follows them.
# Publishes pass --ignore-scripts because the payloads are already assembled
# and verified; nothing should run at pack time.
#
# Usage: scripts/npm/publish-packages.sh <npm-dir>
#
# Auth comes from the environment (npm OIDC trusted publishing in CI).
# ZERO_NPM_PUBLISH_RETRY_DELAY overrides the seconds between registry lookups,
# and ZERO_NPM_PUBLISH_WAIT_TIMEOUT the seconds to wait for npm to apply the
# publishes (default 1800).
set -euo pipefail
fail() {
echo "::error::$*" >&2
exit 1
}
npm_dir="${1:-}"
[ -n "$npm_dir" ] || fail "usage: publish-packages.sh <npm-dir>"
wrapper_dir="${npm_dir%/}/wrapper"
[ -f "${wrapper_dir}/package.json" ] || fail "${wrapper_dir}/package.json not found; run build-platform-packages.mjs first"
manifest_field() {
node -e 'const value = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"))[process.argv[2]]; process.stdout.write(value === undefined ? "" : String(value));' "$1/package.json" "$2"
}
name="$(manifest_field "$wrapper_dir" name)"
version="$(manifest_field "$wrapper_dir" version)"
[ -n "$name" ] && [ -n "$version" ] || fail "${wrapper_dir}/package.json has no name or version"
# whole_seconds <variable> <value> <max>: the value as a decimal number of
# seconds. Checked here, before anything is published: Bash arithmetic reads a
# leading zero as octal (0600 would wait 384 seconds, and 08 is an error), a
# negative timeout would end the wait before it starts, and a bad delay would
# only fail at the first sleep, possibly after the platform payloads went out.
whole_seconds() {
case "$2" in
'' | *[!0-9]*) fail "$1 must be a whole number of seconds, got '$2'" ;;
esac
# Bounded before the conversion so it cannot overflow.
[ "${#2}" -le 18 ] || fail "$1 must be at most $3 seconds, got '$2'"
local seconds=$((10#$2))
[ "$seconds" -le "$3" ] || fail "$1 must be at most $3 seconds, got '$2'"
echo "$seconds"
}
delay="$(whole_seconds ZERO_NPM_PUBLISH_RETRY_DELAY "${ZERO_NPM_PUBLISH_RETRY_DELAY:-5}" 600)"
wait_timeout="$(whole_seconds ZERO_NPM_PUBLISH_WAIT_TIMEOUT "${ZERO_NPM_PUBLISH_WAIT_TIMEOUT:-1800}" 86400)"
# The wrapper pins every platform version exactly, so a wrapper whose aliases
# do not match the payloads beside it would install aliases that 404. Check
# before anything is published.
shopt -s nullglob
platform_dirs=("${npm_dir%/}"/platforms/*/)
shopt -u nullglob
[ "${#platform_dirs[@]}" -gt 0 ] || fail "no platform payloads under ${npm_dir%/}/platforms"
expected_platforms="$(node -e 'const pkg = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); process.stdout.write(Object.values(pkg.optionalDependencies || {}).sort().join("\n"));' "${wrapper_dir}/package.json")"
actual_platforms=""
for dir in "${platform_dirs[@]}"; do
dir="${dir%/}"
platform_name="$(manifest_field "$dir" name)"
platform_version="$(manifest_field "$dir" version)"
[ "$platform_name" = "$name" ] || fail "${dir} is ${platform_name}, not ${name}"
case "$platform_version" in
"${version}-"*) ;;
*) fail "${dir} version ${platform_version} is not a platform version of ${name}@${version}" ;;
esac
actual_platforms="${actual_platforms}npm:${name}@${platform_version}"$'\n'
done
actual_platforms="$(printf '%s' "$actual_platforms" | sort)"
if [ "$actual_platforms" != "$expected_platforms" ]; then
fail "the wrapper's optionalDependencies do not match the platform payloads under ${npm_dir%/}/platforms (wrapper: $(echo "$expected_platforms" | tr '\n' ' '); payloads: $(echo "$actual_platforms" | tr '\n' ' '))"
fi
# npm reports every kind of "not on the registry" (no such package, no such
# version) as E404. Any other lookup failure (network, auth, a registry
# outage) is retried and then fails the job: reading it as "not published"
# would publish a version that may already be out, and npm would reject that
# with EPUBLISHCONFLICT on the very re-run this skip exists for.
already_published() {
local output status errors
errors="$(mktemp)"
for _ in 1 2 3; do
status=0
output="$(npm view "${name}@$1" version 2>"$errors")" || status=$?
if [ "$status" -eq 0 ]; then
rm -f "$errors"
[ "$output" = "$1" ]
return
fi
if grep -q 'code E404' "$errors"; then
rm -f "$errors"
return 1
fi
sleep "$delay"
done
cat "$errors" >&2
rm -f "$errors"
fail "could not look up ${name}@$1 on the registry, so cannot tell whether it is already published"
}
# publish <dir> <version> [npm publish flags...]
publish() {
local dir="$1" package_version="$2"
shift 2
if already_published "$package_version"; then
echo "${name}@${package_version} is already on the registry; not publishing it again."
return 0
fi
echo "publishing ${name}@${package_version} from ${dir}"
(cd "$dir" && npm publish --access public --ignore-scripts "$@")
}
# Unlike already_published, these lookups may fail without stopping the job:
# while npm has not applied a publish, an error or an E404 both mean "not
# yet", and the deadline bounds the wait.
version_visible() {
[ "$(npm view "${name}@$1" version 2>/dev/null || true)" = "$1" ]
}
latest_is() {
[ "$(npm view "$name" dist-tags.latest 2>/dev/null || true)" = "$1" ]
}
# One deadline covers every wait in this run, so the job cannot wait
# wait_timeout once per version.
deadline=$((SECONDS + wait_timeout))
# wait_until <what we are waiting for> <command...>
wait_until() {
local what="$1"
shift
until "$@"; do
if [ "$SECONDS" -ge "$deadline" ]; then
fail "npm accepted the upload but still does not show ${what} after ${wait_timeout}s. Re-run once it does: versions already on the registry are skipped."
fi
sleep "$delay"
done
}
for dir in "${platform_dirs[@]}"; do
dir="${dir%/}"
publish "$dir" "$(manifest_field "$dir" version)" --tag platform
done
for dir in "${platform_dirs[@]}"; do
platform_version="$(manifest_field "${dir%/}" version)"
wait_until "${name}@${platform_version}" version_visible "$platform_version"
echo "${name}@${platform_version} is on the registry."
done
# Platform versions are semver prereleases of the wrapper version, so a
# platform publish that took `latest` would make a plain install deliver a
# payload with no bin. Stop before the wrapper goes out. A failed lookup is
# tolerated (no `latest` yet on a package's first release): failing here would
# strand the platform payloads without their wrapper.
latest="$(npm view "$name" dist-tags.latest || true)"
case "$latest" in
"${version}-"*)
fail "dist-tag latest points at platform version ${latest}; a platform publish clobbered it. Fix the dist-tag before publishing the wrapper."
;;
esac
echo "dist-tag latest is ${latest:-unset} (unchanged by the platform publishes)."
publish "$wrapper_dir" "$version"
wait_until "${name}@${version} as the latest dist-tag" latest_is "$version"
echo "dist-tag latest is ${version}."
+2 -2
View File
@@ -16,7 +16,7 @@
// ZERO_INSTALL_DRY_RUN=1 print the resolved plan as JSON, no network, exit 0
// ZERO_INSTALL_PLATFORM=… override process.platform (linux|darwin|win32|android)
// ZERO_INSTALL_ARCH=… override process.arch (x64|arm64)
// ZERO_REPO=owner/name override the GitHub repo (default Twigpine/zero)
// ZERO_REPO=owner/name override the GitHub repo (default Gitlawb/zero)
// ZERO_GITHUB_BASE_URL=… override the download host (default https://github.com)
import {
@@ -41,7 +41,7 @@ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'));
const VERSION = pkg.version;
const REPO = process.env.ZERO_REPO || 'Twigpine/zero';
const REPO = process.env.ZERO_REPO || 'Gitlawb/zero';
const BASE = (process.env.ZERO_GITHUB_BASE_URL || 'https://github.com').replace(/\/+$/, '');
// The .sha256 is fetched from the same origin as the archive, so TLS authenticity
// is the only real integrity control — require https unless explicitly overridden